diff --git a/.env_example b/.env_example index 13cf99c6..d064efed 100644 --- a/.env_example +++ b/.env_example @@ -2,7 +2,10 @@ D2AP_BUNGIE_API_KEY= D2AP_BUNGIE_CLIENT_ID= D2AP_BUNGIE_CLIENT_SECRET= -D2AP_HIGHLIGHT_MONITORING_ID= +D2AP_SENTRY_DSN= +D2AP_OPEN_REPLAY_PROJECT_KEY= + +D2AP_SHOW_LOGS=0 D2AP_FEATURE_ENABLE_MODSLOT_LIMITATION=0 D2AP_FEATURE_ENABLE_ZERO_WASTE=0 diff --git a/README.md b/README.md index 5b0c6baa..8e134bb1 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,8 @@ D2ArmorPicker is a powerful web-based tool designed for Destiny 2 players who wa ### Building Production and Beta packages - Copy `.env_dev` to `.env` and/or `.env_beta`. -- To build a production package, set the environment flag `PRODUCTION=1`. -- To build a beta package, set the environment flag `BETA=1`. +- To build a production package, set the environment flag `RELEASE=PROD`. +- To build a beta package, set the environment flag `RELEASE=BETA`. Then you can use `npm run build`. @@ -48,7 +48,7 @@ Then you can use `npm run build`. You can also deploy the page to a "github pages" page. Please note that I strongly discourage hosting alternative D2AP installations, let's make this one as awesome as possible. -1. Set the environment flag `BETA=1` or `PRODUCTION=1`. +1. Set the environment flag `RELEASE` to one of the posible values `PROD`, `BETA`, `CANARY`, `DEV`. 1. Modify the `deploy` script in `package.json` and remove`--base-href=/ --cname=d2armorpicker.com`. The same for the beta command. If you deploy to `yourname.github.io/fancyrepo`, then you may have to set `--base-href=/fancyrepo`. 1. `npm run deploy` (given you forked the repository first). diff --git a/angular.json b/angular.json index 63466a74..d4352ddc 100644 --- a/angular.json +++ b/angular.json @@ -24,7 +24,12 @@ "tsConfig": "tsconfig.app.json", "assets": [ "src/favicon.ico", - "src/assets" + "src/assets", + { + "glob": "sql-wasm.wasm", + "input": "node_modules/sql.js/dist/", + "output": "assets/" + } ], "styles": [ { @@ -55,12 +60,7 @@ "maximumError": "80kb" } ], - "fileReplacements": [ - { - "replace": "src/environments/environment.ts", - "with": "src/environments/environment.prod.ts" - } - ], + "fileReplacements": [], "outputHashing": "all", "sourceMap": true, "vendorChunk": true @@ -78,12 +78,7 @@ "maximumError": "80kb" } ], - "fileReplacements": [ - { - "replace": "src/environments/environment.ts", - "with": "src/environments/environment.prod.ts" - } - ], + "fileReplacements": [], "outputHashing": "all", "sourceMap": true, "vendorChunk": true @@ -101,12 +96,7 @@ "maximumError": "80kb" } ], - "fileReplacements": [ - { - "replace": "src/environments/environment.ts", - "with": "src/environments/environment.prod.ts" - } - ], + "fileReplacements": [], "styles": [ { "input": "src/styles.scss", @@ -159,7 +149,8 @@ "browserTarget": "D2ArmorPicker:build:canary" }, "development": { - "browserTarget": "D2ArmorPicker:build:development" + "browserTarget": "D2ArmorPicker:build:development", + "host": "192.168.100.15" } }, "defaultConfiguration": "development" @@ -176,10 +167,19 @@ "main": "src/test.ts", "polyfills": "src/polyfills.ts", "tsConfig": "tsconfig.spec.json", - "karmaConfig": "karma.conf.js", "assets": [ "src/favicon.ico", - "src/assets" + "src/assets", + { + "glob": "*.wasm", + "input": "node_modules/sql.js/dist/", + "output": "assets/" + }, + { + "glob": "sql-wasm.js", + "input": "node_modules/sql.js/dist/", + "output": "assets/" + } ], "styles": [ "./node_modules/@angular/material/prebuilt-themes/deeppurple-amber.css", @@ -219,4 +219,4 @@ "setParserOptionsProject": true } } -} +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index f48ea883..0610603b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "d2-armor-picker", - "version": "2.9.6", + "version": "2.9.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "d2-armor-picker", - "version": "2.9.6", + "version": "2.9.10", "dependencies": { "@angular/animations": "^16.2.12", "@angular/cdk": "^16.2.14", @@ -19,16 +19,21 @@ "@angular/platform-browser-dynamic": "^16.2.12", "@angular/router": "^16.2.12", "@destinyitemmanager/dim-api-types": "^1.31.0", + "@openreplay/tracker": "^17.1.6", + "@openreplay/tracker-assist": "^11.0.11", + "@sentry/angular": "^10.38.0", "all-contributors-cli": "^6.26.1", "angular-cli-ghpages": "^1.0.7", "angular-oauth2-oidc": "^12.1.0", "bungie-api-ts": "^5.1.0", "dexie": "^4.0.7", "highlight.run": "^9.7.1", + "jszip": "^3.10.1", "lodash": "^4.17.21", "lzutf8": "^0.6.3", "ngx-logger": "^5.0.12", "rxjs": "^6.6.7", + "sql.js": "^1.14.0", "tslib": "^2.6.2", "zone.js": "^0.13.0" }, @@ -43,9 +48,12 @@ "@angular/compiler-cli": "^16.2.12", "@commitlint/cli": "^18.2.12", "@commitlint/config-angular": "^18.2.12", + "@sentry/webpack-plugin": "^4.9.1", "@types/jasmine": "^5.1.4", + "@types/jszip": "^3.4.0", "@types/lodash": "^4.17.13", "@types/node": "^22.9.3", + "@types/sql.js": "^1.4.9", "@typescript-eslint/eslint-plugin": "^5.59.2", "@typescript-eslint/parser": "^5.59.2", "dotenv": "^16.4.5", @@ -5320,6 +5328,42 @@ "node": ">= 10" } }, + "node_modules/@openreplay/network-proxy": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@openreplay/network-proxy/-/network-proxy-1.2.2.tgz", + "integrity": "sha512-Zr53s6DZvBvHTDyASMKA4G1pGHZtXLDdmWAk6hDKtnzUdRuNW66D+ja2v+x+zbCGBN95iBlOcAxbLlYbwmoMjg==", + "license": "MIT" + }, + "node_modules/@openreplay/tracker": { + "version": "17.1.6", + "resolved": "https://registry.npmjs.org/@openreplay/tracker/-/tracker-17.1.6.tgz", + "integrity": "sha512-NmDHw9xh1eRkoyMTcjdtt0tJyIVgI4yqG/c1HgLvjHSvOn/wQIM5L6+cfsDTjR40IyVxD7IxH3oqBtsvow0b0w==", + "license": "MIT", + "dependencies": { + "@openreplay/network-proxy": "^1.2.2", + "error-stack-parser": "^2.1.4", + "error-stack-parser-es": "^0.1.5", + "fflate": "^0.8.2", + "web-vitals": "^5.1.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/@openreplay/tracker-assist": { + "version": "11.0.11", + "resolved": "https://registry.npmjs.org/@openreplay/tracker-assist/-/tracker-assist-11.0.11.tgz", + "integrity": "sha512-oBn3VQJ56CHlmx0gkWhlW40kxlgHwvRXs9OZzWH6SLE607lDxQ+3r5sBvNDfxCoK92lXGKrZ9/spTUhDW84e/w==", + "license": "MIT", + "dependencies": { + "csstype": "^3.1.3", + "fflate": "^0.8.2", + "socket.io-client": "^4.8.1" + }, + "peerDependencies": { + "@openreplay/tracker": ">=14.0.14" + } + }, "node_modules/@parcel/watcher": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.0.4.tgz", @@ -5367,6 +5411,408 @@ "yarn": ">= 1.13.0" } }, + "node_modules/@sentry-internal/browser-utils": { + "version": "10.38.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.38.0.tgz", + "integrity": "sha512-UOJtYmdcxHCcV0NPfXFff/a95iXl/E0EhuQ1y0uE0BuZDMupWSF5t2BgC4HaE5Aw3RTjDF3XkSHWoIF6ohy7eA==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.38.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/feedback": { + "version": "10.38.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.38.0.tgz", + "integrity": "sha512-JXneg9zRftyfy1Fyfc39bBlF/Qd8g4UDublFFkVvdc1S6JQPlK+P6q22DKz3Pc8w3ySby+xlIq/eTu9Pzqi4KA==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.38.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/replay": { + "version": "10.38.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.38.0.tgz", + "integrity": "sha512-YWIkL6/dnaiQyFiZXJ/nN+NXGv/15z45ia86bE/TMq01CubX/DUOilgsFz0pk2v/pg3tp/U2MskLO9Hz0cnqeg==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "10.38.0", + "@sentry/core": "10.38.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/replay-canvas": { + "version": "10.38.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.38.0.tgz", + "integrity": "sha512-OXWM9jEqNYh4VTvrMu7v+z1anz+QKQ/fZXIZdsO7JTT2lGNZe58UUMeoq386M+Saxen8F9SUH7yTORy/8KI5qw==", + "license": "MIT", + "dependencies": { + "@sentry-internal/replay": "10.38.0", + "@sentry/core": "10.38.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/angular": { + "version": "10.38.0", + "resolved": "https://registry.npmjs.org/@sentry/angular/-/angular-10.38.0.tgz", + "integrity": "sha512-Ncr4dzE1QUqrf1yaKovUrtvsvOH+rhV8Gu0ZExJyhw8NnqlRLypV/p8XwKgJwged36gFzzbeuW+cGVRXwua+yg==", + "license": "MIT", + "dependencies": { + "@sentry/browser": "10.38.0", + "@sentry/core": "10.38.0", + "tslib": "^2.4.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@angular/common": ">= 14.x <= 21.x", + "@angular/core": ">= 14.x <= 21.x", + "@angular/router": ">= 14.x <= 21.x", + "rxjs": "^6.5.5 || ^7.x" + } + }, + "node_modules/@sentry/babel-plugin-component-annotate": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.9.1.tgz", + "integrity": "sha512-0gEoi2Lb54MFYPOmdTfxlNKxI7kCOvNV7gP8lxMXJ7nCazF5OqOOZIVshfWjDLrc0QrSV6XdVvwPV9GDn4wBMg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@sentry/browser": { + "version": "10.38.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.38.0.tgz", + "integrity": "sha512-3phzp1YX4wcQr9mocGWKbjv0jwtuoDBv7+Y6Yfrys/kwyaL84mDLjjQhRf4gL5SX7JdYkhBp4WaiNlR0UC4kTA==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "10.38.0", + "@sentry-internal/feedback": "10.38.0", + "@sentry-internal/replay": "10.38.0", + "@sentry-internal/replay-canvas": "10.38.0", + "@sentry/core": "10.38.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/bundler-plugin-core": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@sentry/bundler-plugin-core/-/bundler-plugin-core-4.9.1.tgz", + "integrity": "sha512-moii+w7N8k8WdvkX7qCDY9iRBlhgHlhTHTUQwF2FNMhBHuqlNpVcSJJqJMjFUQcjYMBDrZgxhfKV18bt5ixwlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.18.5", + "@sentry/babel-plugin-component-annotate": "4.9.1", + "@sentry/cli": "^2.57.0", + "dotenv": "^16.3.1", + "find-up": "^5.0.0", + "glob": "^10.5.0", + "magic-string": "0.30.8", + "unplugin": "1.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@sentry/bundler-plugin-core/node_modules/@sentry/cli": { + "version": "2.58.4", + "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.58.4.tgz", + "integrity": "sha512-ArDrpuS8JtDYEvwGleVE+FgR+qHaOp77IgdGSacz6SZy6Lv90uX0Nu4UrHCQJz8/xwIcNxSqnN22lq0dH4IqTg==", + "dev": true, + "hasInstallScript": true, + "license": "FSL-1.1-MIT", + "dependencies": { + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.7", + "progress": "^2.0.3", + "proxy-from-env": "^1.1.0", + "which": "^2.0.2" + }, + "bin": { + "sentry-cli": "bin/sentry-cli" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@sentry/cli-darwin": "2.58.4", + "@sentry/cli-linux-arm": "2.58.4", + "@sentry/cli-linux-arm64": "2.58.4", + "@sentry/cli-linux-i686": "2.58.4", + "@sentry/cli-linux-x64": "2.58.4", + "@sentry/cli-win32-arm64": "2.58.4", + "@sentry/cli-win32-i686": "2.58.4", + "@sentry/cli-win32-x64": "2.58.4" + } + }, + "node_modules/@sentry/bundler-plugin-core/node_modules/@sentry/cli-darwin": { + "version": "2.58.4", + "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.58.4.tgz", + "integrity": "sha512-kbTD+P4X8O+nsNwPxCywtj3q22ecyRHWff98rdcmtRrvwz8CKi/T4Jxn/fnn2i4VEchy08OWBuZAqaA5Kh2hRQ==", + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/bundler-plugin-core/node_modules/@sentry/cli-linux-arm": { + "version": "2.58.4", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.58.4.tgz", + "integrity": "sha512-rdQ8beTwnN48hv7iV7e7ZKucPec5NJkRdrrycMJMZlzGBPi56LqnclgsHySJ6Kfq506A2MNuQnKGaf/sBC9REA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/bundler-plugin-core/node_modules/@sentry/cli-linux-arm64": { + "version": "2.58.4", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.58.4.tgz", + "integrity": "sha512-0g0KwsOozkLtzN8/0+oMZoOuQ0o7W6O+hx+ydVU1bktaMGKEJLMAWxOQNjsh1TcBbNIXVOKM/I8l0ROhaAb8Ig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/bundler-plugin-core/node_modules/@sentry/cli-linux-i686": { + "version": "2.58.4", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.58.4.tgz", + "integrity": "sha512-NseoIQAFtkziHyjZNPTu1Gm1opeQHt7Wm1LbLrGWVIRvUOzlslO9/8i6wETUZ6TjlQxBVRgd3Q0lRBG2A8rFYA==", + "cpu": [ + "x86", + "ia32" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/bundler-plugin-core/node_modules/@sentry/cli-linux-x64": { + "version": "2.58.4", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.58.4.tgz", + "integrity": "sha512-d3Arz+OO/wJYTqCYlSN3Ktm+W8rynQ/IMtSZLK8nu0ryh5mJOh+9XlXY6oDXw4YlsM8qCRrNquR8iEI1Y/IH+Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/bundler-plugin-core/node_modules/@sentry/cli-win32-arm64": { + "version": "2.58.4", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.58.4.tgz", + "integrity": "sha512-bqYrF43+jXdDBh0f8HIJU3tbvlOFtGyRjHB8AoRuMQv9TEDUfENZyCelhdjA+KwDKYl48R1Yasb4EHNzsoO83w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/bundler-plugin-core/node_modules/@sentry/cli-win32-i686": { + "version": "2.58.4", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.58.4.tgz", + "integrity": "sha512-3triFD6jyvhVcXOmGyttf+deKZcC1tURdhnmDUIBkiDPJKGT/N5xa4qAtHJlAB/h8L9jgYih9bvJnvvFVM7yug==", + "cpu": [ + "x86", + "ia32" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/bundler-plugin-core/node_modules/@sentry/cli-win32-x64": { + "version": "2.58.4", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.58.4.tgz", + "integrity": "sha512-cSzN4PjM1RsCZ4pxMjI0VI7yNCkxiJ5jmWncyiwHXGiXrV1eXYdQ3n1LhUYLZ91CafyprR0OhDcE+RVZ26Qb5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/bundler-plugin-core/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@sentry/bundler-plugin-core/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@sentry/bundler-plugin-core/node_modules/magic-string": { + "version": "0.30.8", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz", + "integrity": "sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@sentry/bundler-plugin-core/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@sentry/bundler-plugin-core/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/@sentry/core": { + "version": "10.38.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.38.0.tgz", + "integrity": "sha512-1pubWDZE5y5HZEPMAZERP4fVl2NH3Ihp1A+vMoVkb3Qc66Diqj1WierAnStlZP7tCx0TBa0dK85GTW/ZFYyB9g==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/webpack-plugin": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@sentry/webpack-plugin/-/webpack-plugin-4.9.1.tgz", + "integrity": "sha512-Ssx2lHiq8VWywUGd/hmW3U3VYBC0Up7D6UzUiDAWvy18PbTCVszaa54fKMFEQ1yIBg/ePRET53pIzfkcZgifmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sentry/bundler-plugin-core": "4.9.1", + "unplugin": "1.0.1", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "webpack": ">=4.40.0" + } + }, + "node_modules/@sentry/webpack-plugin/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@sigstore/bundle": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-1.1.0.tgz", @@ -5513,7 +5959,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", - "dev": true, "license": "MIT" }, "node_modules/@tootallnate/once": { @@ -5663,6 +6108,13 @@ "@types/node": "*" } }, + "node_modules/@types/emscripten": { + "version": "1.41.5", + "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz", + "integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -5740,6 +6192,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jszip": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@types/jszip/-/jszip-3.4.0.tgz", + "integrity": "sha512-GFHqtQQP3R4NNuvZH3hNCYD0NbyBZ42bkN7kO3NDrU/SnvIZWMS8Bp38XCsRKBT5BXvgm0y1zqpZWp/ZkRzBzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "jszip": "*" + } + }, "node_modules/@types/lodash": { "version": "4.17.13", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.13.tgz", @@ -5859,6 +6321,17 @@ "@types/node": "*" } }, + "node_modules/@types/sql.js": { + "version": "1.4.9", + "resolved": "https://registry.npmjs.org/@types/sql.js/-/sql.js-1.4.9.tgz", + "integrity": "sha512-ep8b36RKHlgWPqjNG9ToUrPiwkhwh0AEzy883mO5Xnd+cL6VBH1EvSjBAAuxLUFF2Vn/moE3Me6v9E1Lo+48GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/emscripten": "*", + "@types/node": "*" + } + }, "node_modules/@types/ws": { "version": "8.5.13", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz", @@ -8230,7 +8703,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true, "license": "MIT" }, "node_modules/cors": { @@ -8447,6 +8919,12 @@ "dev": true, "license": "MIT" }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, "node_modules/custom-event": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz", @@ -8493,7 +8971,6 @@ "version": "4.3.7", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -8986,11 +9463,61 @@ "node": ">=10.2.0" } }, + "node_modules/engine.io-client": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz", + "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-client/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io-client/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/engine.io-parser": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -9125,6 +9652,24 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "license": "MIT", + "dependencies": { + "stackframe": "^1.3.4" + } + }, + "node_modules/error-stack-parser-es": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-0.1.5.tgz", + "integrity": "sha512-xHku1X40RO+fO8yJ8Wh2f2rZWVjqyhb1zgq1yZ8aZRQkv6OOKhKWRUaht3eSCUbAOBaKIgM+ykwFLE+QUxgGeg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/es-define-property": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", @@ -9814,6 +10359,12 @@ "node": ">=0.8.0" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -10899,13 +11450,6 @@ "pako": "^1.0.3" } }, - "node_modules/hdr-histogram-js/node_modules/pako": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", - "dev": true, - "license": "(MIT AND Zlib)" - }, "node_modules/hdr-histogram-percentiles-obj": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/hdr-histogram-percentiles-obj/-/hdr-histogram-percentiles-obj-3.0.0.tgz", @@ -11306,6 +11850,12 @@ "node": ">=0.10.0" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/immutable": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", @@ -11691,7 +12241,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true, "license": "MIT" }, "node_modules/isbinaryfile": { @@ -12092,6 +12641,48 @@ "node": "*" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/karma": { "version": "6.4.4", "resolved": "https://registry.npmjs.org/karma/-/karma-6.4.4.tgz", @@ -12439,6 +13030,15 @@ } } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lilconfig": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.2.tgz", @@ -13939,7 +14539,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/multicast-dns": { @@ -14929,6 +15528,12 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -15499,9 +16104,18 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true, "license": "MIT" }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/promise-inflight": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", @@ -16732,6 +17346,12 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -17023,11 +17643,42 @@ } } }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-client/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/socket.io-parser": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", - "dev": true, "license": "MIT", "dependencies": { "@socket.io/component-emitter": "~3.1.0", @@ -17254,6 +17905,12 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/sql.js": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.0.tgz", + "integrity": "sha512-NXYh+kFqLiYRCNAaHD0PcbjFgXyjuolEKLMk5vRt2DgPENtF1kkNzzMlg42dUk5wIsH8MhUzsRhaUxIisoSlZQ==", + "license": "MIT" + }, "node_modules/ssri": { "version": "10.0.6", "resolved": "https://registry.npmjs.org/ssri/-/ssri-10.0.6.tgz", @@ -17277,6 +17934,12 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", + "license": "MIT" + }, "node_modules/statuses": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", @@ -18402,6 +19065,19 @@ "node": ">= 0.8" } }, + "node_modules/unplugin": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-1.0.1.tgz", + "integrity": "sha512-aqrHaVBWW1JVKBHmGo33T5TxeL0qWzfvjWokObHA9bYmN7eNDkwOxmLjhioHl9878qDFMAaT51XNroRyuz7WxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.8.1", + "chokidar": "^3.5.3", + "webpack-sources": "^3.2.3", + "webpack-virtual-modules": "^0.5.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", @@ -18661,6 +19337,12 @@ "defaults": "^1.0.3" } }, + "node_modules/web-vitals": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.1.0.tgz", + "integrity": "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==", + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz", @@ -18899,6 +19581,13 @@ } } }, + "node_modules/webpack-virtual-modules": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.5.0.tgz", + "integrity": "sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw==", + "dev": true, + "license": "MIT" + }, "node_modules/webpack/node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -19161,6 +19850,14 @@ "dev": true, "license": "MIT" }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index df5ad7b9..a4ee1cbc 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "d2-armor-picker", - "version": "2.9.6", + "version": "2.9.10", "scripts": { "ng": "ng", "config": "ts-node set-env.ts", - "start": "npm run config && ng serve --ssl", + "start": "npm run config && ng serve --ssl --disable-host-check", "build": "npm run config && ng build", - "deploy": "npm run config && ng deploy D2ArmorPicker --base-href=/ --cname=d2armorpicker.com", + "deploy": "npm run config && ng deploy D2ArmorPicker --repo=https://github.com/Mijago/D2ArmorPicker.git --base-href=/ --cname=d2armorpicker.com", "deploy-beta": "npm run config && ng deploy D2ArmorPicker --build-target D2ArmorPicker:build:beta --repo=https://github.com/Mijago/D2ArmorPicker-beta.git --base-href=/ --cname=beta.d2armorpicker.com", "deploy-canary": "npm run config && ng deploy D2ArmorPicker --build-target D2ArmorPicker:build:canary --repo=https://github.com/Mijago/D2ArmorPicker-Canary.git --base-href=/ --cname=canary.d2armorpicker.com ", "deploy-nznaza": "npm run config && ng deploy D2ArmorPicker --build-target D2ArmorPicker:build:beta --no-silent --repo=https://github.com/nznaza/D2ArmorPicker.git --base-href=/D2ArmorPicker/ --cname=nznaza.github.io/D2ArmorPicker/", @@ -30,16 +30,21 @@ "@angular/platform-browser-dynamic": "^16.2.12", "@angular/router": "^16.2.12", "@destinyitemmanager/dim-api-types": "^1.31.0", + "@openreplay/tracker": "^17.1.6", + "@openreplay/tracker-assist": "^11.0.11", + "@sentry/angular": "^10.38.0", "all-contributors-cli": "^6.26.1", "angular-cli-ghpages": "^1.0.7", "angular-oauth2-oidc": "^12.1.0", "bungie-api-ts": "^5.1.0", "dexie": "^4.0.7", "highlight.run": "^9.7.1", + "jszip": "^3.10.1", "lodash": "^4.17.21", "lzutf8": "^0.6.3", "ngx-logger": "^5.0.12", "rxjs": "^6.6.7", + "sql.js": "^1.14.0", "tslib": "^2.6.2", "zone.js": "^0.13.0" }, @@ -54,9 +59,12 @@ "@angular/compiler-cli": "^16.2.12", "@commitlint/cli": "^18.2.12", "@commitlint/config-angular": "^18.2.12", + "@sentry/webpack-plugin": "^4.9.1", "@types/jasmine": "^5.1.4", + "@types/jszip": "^3.4.0", "@types/lodash": "^4.17.13", "@types/node": "^22.9.3", + "@types/sql.js": "^1.4.9", "@typescript-eslint/eslint-plugin": "^5.59.2", "@typescript-eslint/parser": "^5.59.2", "dotenv": "^16.4.5", diff --git a/set-env.ts b/set-env.ts index c10d7486..712bf753 100644 --- a/set-env.ts +++ b/set-env.ts @@ -17,48 +17,95 @@ const writeFile = require("fs").writeFile; -const production = process.env["PRODUCTION"] === "1"; -const beta_branch = process.env["BETA"] === "1"; -const canary_branch = process.env["CANARY"] === "1"; +// RELEASE can be one of: 'PROD', 'BETA', 'CANARY'. Defaults to 'CANARY' if missing/invalid +const releaseRaw = (process.env["RELEASE"] || "").toUpperCase(); +const release = ["PROD", "BETA", "CANARY", "DEV"].includes(releaseRaw) ? releaseRaw : "DEV"; -const version = "2.9.6"; +const is_production = release === "PROD"; +const is_beta = release === "BETA"; +const is_canary = release === "CANARY"; +const is_dev = release === "DEV"; + +const version = "2.9.10"; // Configure Angular `environment.ts` file path -const targetPath = production +const targetPath = "./src/environments/environment.ts"; + +const copyPath = is_production ? "./src/environments/environment.prod.ts" - : beta_branch || canary_branch - ? "./src/environments/environment.prod.ts" - : "./src/environments/environment.ts"; + : is_beta + ? "./src/environments/environment.beta.ts" + : is_canary + ? "./src/environments/environment.canary.ts" + : "./src/environments/environment.dev.ts"; // Load node modules -const dotenvfile = production +const dotenvfile = is_production ? ".env" - : beta_branch + : is_beta ? ".env_beta" - : canary_branch + : is_canary ? ".env_canary" : ".env_dev"; -require("dotenv").config({ path: dotenvfile }); +// Only load from .env if key variables are not already present in the environment +const requiredEnvKeys = [ + "D2AP_BUNGIE_API_KEY", + "D2AP_BUNGIE_CLIENT_ID", + "D2AP_BUNGIE_CLIENT_SECRET", + "D2AP_OPEN_REPLAY_PROJECT_KEY", + "D2AP_FEATURE_ENABLE_MODSLOT_LIMITATION", + "D2AP_FEATURE_ENABLE_ZERO_WASTE", + "D2AP_FEATURE_ENABLE_GUARDIAN_GAMES_FEATURES", + "D2AP_SHOW_LOGS", + // Feature flags are optional; they default to disabled when not set +]; + +const optionalEnvKeys = ["D2AP_SENTRY_DSN"]; + +const hasAllRequiredEnv = requiredEnvKeys.every((k) => { + const val = process.env[k] ?? ""; + return val.length > 0; +}); + +if (!hasAllRequiredEnv) { + const dotenv = require("dotenv"); + const result = dotenv.config({ path: dotenvfile }); + if (result.error) { + throw new Error(`Failed to load env file at ${dotenvfile}: ${result.error}`); + } + // After attempting to load, warn for any missing keys + const missingKeys = requiredEnvKeys.filter((k) => !process.env[k]); + if (missingKeys.length > 0) { + throw new Error( + `Missing required environment variables after loading ${dotenvfile}: ${missingKeys.join(", ")}` + ); + } +} else { + console.log("Environment variables already set; skipping .env file load."); +} const revision = require("child_process").execSync("git rev-parse --short HEAD").toString().trim(); -var version_tag = production ? "" : beta_branch ? "-beta-" + revision : "-dev-" + revision; +var version_tag = is_production ? "" : is_beta ? "-beta-" + revision : "-dev-" + revision; -console.log(`Reading ${dotenvfile} version ${version + version_tag}`); +console.log(`Reading ${dotenvfile} version ${version + version_tag} (RELEASE=${release})`); const data = { version: version + version_tag, revision: revision, - production: production, - beta: beta_branch, - canary: canary_branch, + production: is_production, + beta: is_beta, + canary: is_canary, apiKey: process.env["D2AP_BUNGIE_API_KEY"], clientId: process.env["D2AP_BUNGIE_CLIENT_ID"], client_secret: process.env["D2AP_BUNGIE_CLIENT_SECRET"], nodeEnv: process.env["NODE_ENV"], offlineMode: false, - highlight_project_id: process.env["D2AP_HIGHLIGHT_MONITORING_ID"], + // highlight_project_id: process.env["D2AP_HIGHLIGHT_MONITORING_ID"], + open_replay_project_key: process.env["D2AP_OPEN_REPLAY_PROJECT_KEY"], + sentryDsn: process.env["D2AP_SENTRY_DSN"], + showLogs: process.env["D2AP_SHOW_LOGS"] == "1", featureFlags: { enableModslotLimitation: process.env["D2AP_FEATURE_ENABLE_MODSLOT_LIMITATION"] == "1", enableZeroWaste: process.env["D2AP_FEATURE_ENABLE_ZERO_WASTE"] == "1", @@ -72,6 +119,14 @@ writeFile(targetPath, envConfigFile, (err: NodeJS.ErrnoException | null) => { if (err) { throw console.error(err); } else { - console.log(`Angular environment.ts file generated correctly at ${targetPath} \n`); + console.log(`Angular environment.ts file generated correctly\n`); + + writeFile(copyPath, envConfigFile, (err2: NodeJS.ErrnoException | null) => { + if (err2) { + throw console.error(err2); + } else { + console.log(`Active Angular environment copied to ${copyPath}`); + } + }); } }); diff --git a/src/app/app.component.html b/src/app/app.component.html index 39d3a0d5..5abba084 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -17,8 +17,8 @@ - You are using the  unstable  canary build. Some features may be unfinished or - vanish. Please report any issues you encounter. + You are using the  unstable  canary build. Some features may be unfinished, + vanish or have breaking bugs. Please report any issues you encounter. @@ -35,5 +35,20 @@ - + +
+
+ Recent Logs +
+
+
+ {{ log.timestamp | date: "HH:mm:ss" }} + {{ log.level | logLevel }} + {{ log.message }} +
+
+
+ diff --git a/src/app/app.component.scss b/src/app/app.component.scss index a2b5d829..356d3daf 100644 --- a/src/app/app.component.scss +++ b/src/app/app.component.scss @@ -21,10 +21,89 @@ position: relative; max-width: 100vw; word-break: break-all; + flex-wrap: wrap; + justify-content: center; } .canary-warning { - font-size: 11pt; + font-size: 13pt; + font-weight: bold; + color: #111; + background: linear-gradient(90deg, #fffb00 0%, #ffae00 100%); + text-shadow: + 0 0 2px #fff, + 0 0 4px #ffae00; + box-shadow: + 0 0 8px 2px #fff700, + 0 0 16px 4px #ffae00; + border: 2px solid #fff700; + animation: + canary-glow 1.2s infinite alternate, + canary-shake 1s infinite; + position: relative; + overflow-x: hidden; + overflow-y: visible; +} + +@keyframes canary-glow { + 0% { + box-shadow: + 0 0 8px 2px #fff700, + 0 0 16px 4px #ffae00; + } + 100% { + box-shadow: + 0 0 16px 4px #fff700, + 0 0 32px 8px #ffae00; + } +} + +@keyframes canary-shake { + 0% { + transform: translateX(0) scale(1.01); + } + 20% { + transform: translateX(-1px) rotate(-0.5deg) scale(1.01); + } + 50% { + transform: translateX(1px) rotate(0.5deg) scale(1.01); + } + 80% { + transform: translateX(-0.5px) rotate(-0.25deg) scale(1.01); + } + 100% { + transform: translateX(0) scale(1.01); + } +} + +.canary-warning::after { + content: ""; + pointer-events: none; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: + repeating-linear-gradient(135deg, rgba(255, 255, 255, 0.3) 0 2px, transparent 2px 8px), + radial-gradient(circle, #fff 0.5px, transparent 1.5px) 0 0/10px 10px repeat; + opacity: 0.7; + mix-blend-mode: lighten; + animation: canary-glitter 1.5s linear infinite; + z-index: 2; +} + +@keyframes canary-glitter { + 0% { + background-position: + 0 0, + 0 0; + } + 100% { + background-position: + 40px 40px, + 10px 10px; + } } .bungie-day img.donordrive { @@ -41,3 +120,135 @@ .header-spacer { flex: 1 1 auto; } + +// Recent Logs Styles +.recent-logs-container { + background-color: rgba(0, 0, 0, 0.9); + border-bottom: 1px solid #444; + padding: 8px 16px; + max-width: 100vw; + overflow: hidden; + font-family: "Roboto Mono", monospace; + font-size: 12px; + + .recent-logs-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; + + .recent-logs-title { + color: #fff; + font-weight: 500; + font-size: 13px; + } + + .clear-logs-btn { + color: #ccc; + width: 24px; + height: 24px; + line-height: 24px; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + line-height: 16px; + } + + &:hover { + color: #fff; + background-color: rgba(255, 255, 255, 0.1); + } + } + } + + .recent-logs-list { + .log-entry { + display: flex; + align-items: center; + padding: 2px 0; + border-left: 3px solid transparent; + padding-left: 8px; + margin: 1px 0; + + .log-timestamp { + color: #888; + margin-right: 8px; + font-size: 10px; + min-width: 60px; + } + + .log-level { + margin-right: 8px; + font-weight: bold; + min-width: 50px; + font-size: 10px; + } + + .log-message { + color: #ddd; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &.log-trace { + border-left-color: #6c757d; + .log-level { + color: #6c757d; + } + } + + &.log-debug { + border-left-color: #17a2b8; + .log-level { + color: #17a2b8; + } + } + + &.log-info { + border-left-color: #28a745; + .log-level { + color: #28a745; + } + } + + &.log-warn { + border-left-color: #ffc107; + .log-level { + color: #ffc107; + } + .log-message { + color: #fff3cd; + } + } + + &.log-error { + border-left-color: #dc3545; + .log-level { + color: #dc3545; + } + .log-message { + color: #f8d7da; + } + } + + &.log-fatal { + border-left-color: #dc3545; + background-color: rgba(220, 53, 69, 0.1); + .log-level { + color: #dc3545; + background-color: rgba(220, 53, 69, 0.2); + padding: 1px 4px; + border-radius: 2px; + } + .log-message { + color: #fff; + font-weight: bold; + } + } + } + } +} diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 83b380aa..6e56abcf 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -15,44 +15,99 @@ * along with this program. If not, see . */ -import { AfterViewInit, Component } from "@angular/core"; +import { AfterViewInit, Component, OnInit } from "@angular/core"; import { environment } from "../environments/environment"; -import { InventoryService } from "./services/inventory.service"; -import { NGXLogger } from "ngx-logger"; +import { UserInformationService } from "src/app/services/user-information.service"; +import { LoggingProxyService, LogEntry } from "./services/logging-proxy.service"; +import { AuthService } from "./services/auth.service"; +import { Observable } from "rxjs"; @Component({ selector: "app-root", templateUrl: "./app.component.html", styleUrls: ["./app.component.scss"], }) -export class AppComponent implements AfterViewInit { +export class AppComponent implements AfterViewInit, OnInit { title = "D2ArmorPicker"; is_beta = environment.beta; is_canary = environment.canary; + showLogs = environment.showLogs; + recentLogs$: Observable; constructor( - private inventoryService: InventoryService, - private logger: NGXLogger - ) {} + private userInformationService: UserInformationService, + private logger: LoggingProxyService, + public authService: AuthService + ) { + this.recentLogs$ = this.logger.getRecentLogs(); + } + + ngOnInit() { + this.logger.debug("AppComponent", "ngOnInit", "Application initialized"); + window.addEventListener("unhandledrejection", (event) => { + this.logger.error("AppV2CoreComponent", "Unhandled Promise Rejection", JSON.stringify(event)); + }); + window.onerror = (errorMsg, url, lineNumber) => { + this.logger.error( + "AppV2CoreComponent", + "Unhandled Error", + JSON.stringify({ errorMsg, url, lineNumber }) + ); + return false; + }; + } + ngAfterViewInit(): void { - // Check if InventoryService is initialized after 2 seconds + // Check if UserInformationService is initialized after 10 seconds // if not, forcefully trigger an initial refreshAll setTimeout(() => { - if (!this.inventoryService.isInitialized) { + if ( + !this.userInformationService.isInitialized && + !this.userInformationService.isFetchingManifest + ) { this.logger.warn( "AppComponent", "ngAfterViewInit", - "InventoryService is not initialized after 2 seconds, triggering initial refreshAll." + "UserInformationService is not initialized after 10 seconds, triggering initial refreshManifestAndArmor." ); - this.inventoryService.refreshAll(true, true).catch((err) => { + this.userInformationService.refreshManifestAndInventory(true, true).catch((err) => { this.logger.error( "AppComponent", "ngAfterViewInit", - "Error during initial refreshAll:", + "Error during initial refreshManifestAndArmor:", err ); }); } - }, 2000); + }, 10 * 1000); + } + + /** + * Get CSS class for log level + */ + getLogLevelClass(level: number): string { + switch (level) { + case 0: // TRACE + return "log-trace"; + case 1: // DEBUG + return "log-debug"; + case 2: // INFO + return "log-info"; + case 3: // WARN + return "log-warn"; + case 4: // ERROR + return "log-error"; + case 5: // FATAL + return "log-fatal"; + default: + return "log-info"; + } + } + + /** + * Clear recent logs + */ + clearLogs(): void { + this.logger.clearRecentLogs(); } } diff --git a/src/app/app.module.ts b/src/app/app.module.ts index b553fc46..3979dd76 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -15,13 +15,14 @@ * along with this program. If not, see . */ -import { NgModule } from "@angular/core"; +import { APP_INITIALIZER, ErrorHandler, NgModule } from "@angular/core"; import { BrowserModule } from "@angular/platform-browser"; +import * as Sentry from "@sentry/angular"; import { AppComponent } from "./app.component"; import { LoginComponent } from "./components/login/login.component"; import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; import { HttpClientModule } from "@angular/common/http"; -import { RouterModule, Routes } from "@angular/router"; +import { Router, RouterModule, Routes } from "@angular/router"; import { HandleBungieLoginComponent } from "./components/handle-bungie-login/handle-bungie-login.component"; import { AuthenticatedGuard } from "./guards/authenticated.guard"; import { NotAuthenticatedGuard } from "./guards/not-authenticated.guard"; @@ -52,7 +53,6 @@ import { ItemTooltipRendererDirective } from "./components/authenticated-v2/over import { ItemIconComponent } from "./components/authenticated-v2/components/item-icon/item-icon.component"; import { ArmorInvestigationPageComponent } from "./components/authenticated-v2/subpages/armor-investigation-page/armor-investigation-page.component"; import { ChangelogDialogComponent } from "./components/authenticated-v2/components/changelog-dialog/changelog-dialog.component"; -import { ChangelogDialogControllerComponent } from "./components/authenticated-v2/components/changelog-dialog-controller/changelog-dialog-controller.component"; import { ChangelogListComponent } from "./components/authenticated-v2/components/changelog-list/changelog-list.component"; import { LayoutModule } from "@angular/cdk/layout"; import { ArmorPerkIconComponent } from "./components/authenticated-v2/components/armor-perk-icon/armor-perk-icon.component"; @@ -71,39 +71,116 @@ import { VendorIdFromItemIdPipe, VendorNamePipe, } from "./components/authenticated-v2/pipes/vendor-name-pipe"; +import { LogLevelPipe } from "./components/authenticated-v2/pipes/log-level.pipe"; import { environment } from "../environments/environment"; -import { H } from "highlight.run"; +// import { H } from "highlight.run"; +import Tracker from "@openreplay/tracker"; +import trackerAssist from "@openreplay/tracker-assist"; + import { ResultsCardViewComponent } from "./components/authenticated-v2/results/results-card-view/results-card-view.component"; +import { ResultsTableViewComponent } from "./components/authenticated-v2/results/results-table-view/results-table-view.component"; import { GearsetSelectionComponent } from "./components/authenticated-v2/settings/desired-mod-limit-selection/gearset-selection/gearset-selection.component"; import { GearsetcTooltipDirective as GearsetTooltipDirective } from "./components/authenticated-v2/overlays/gearset-tooltip/gearset-tooltip.directive"; import { GearsetTooltipComponent } from "./components/authenticated-v2/overlays/gearset-tooltip/gearset-tooltip.component"; -if (!!environment.highlight_project_id) { - H.init(environment.highlight_project_id, { - environment: environment.production - ? "production" - : environment.beta - ? "beta" - : environment.canary - ? "canary" - : "dev", - tracingOrigins: true, - inlineImages: false, - version: environment.version, - networkRecording: { - enabled: true, - recordHeadersAndBody: false, - urlBlocklist: [ - "https://bungie.net/common/destiny2_content/icons/", - "https://www.bungie.net/img/", - ], - }, +let openReplayTracker: Tracker; + +export function identifyUserWithTracker(membershipData: GroupUserInfoCard | null) { + try { + openReplayTracker.setMetadata("version", `${environment.version}`); + + if (!membershipData) return; + const identifier = `${membershipData.displayName}(I${membershipData.membershipId}T${membershipData.membershipType})`; + + openReplayTracker.identify(identifier); + openReplayTracker.setMetadata( + "bungieGlobalDisplayName", + membershipData.bungieGlobalDisplayName + ); + openReplayTracker.setMetadata( + "bungieGlobalDisplayNameCode", + (membershipData.bungieGlobalDisplayNameCode ?? -1).toString() + ); + openReplayTracker.setMetadata("membershipType", membershipData.membershipType.toString()); + openReplayTracker.setMetadata( + "applicableMembershipTypes", + JSON.stringify(membershipData.applicableMembershipTypes) + ); + + // Identify user with Sentry + Sentry.setUser({ + id: identifier, + username: membershipData.displayName, + email: membershipData.bungieGlobalDisplayName, + extra: { + membershipId: membershipData.membershipId, + membershipType: membershipData.membershipType, + bungieGlobalDisplayNameCode: membershipData.bungieGlobalDisplayNameCode ?? -1, + applicableMembershipTypes: membershipData.applicableMembershipTypes, + iconPath: membershipData.iconPath, + }, + }); + // H.identify(identifier, { + // highlightDisplayName: `${membershipData.displayName}(I${membershipData.membershipId}T${membershipData.membershipType})`, + // avatar: `https://bungie.net${membershipData.iconPath}`, + // bungieGlobalDisplayName: membershipData.bungieGlobalDisplayName, + // bungieGlobalDisplayNameCode: membershipData.bungieGlobalDisplayNameCode ?? -1, + // membershipType: membershipData.membershipType, + // applicableMembershipTypes: JSON.stringify(membershipData.applicableMembershipTypes), + // }); + } catch (err) { + console.error("Error identifying user with tracker", err); + } +} + +try { + openReplayTracker = new Tracker({ + projectKey: environment.open_replay_project_key, }); + + openReplayTracker.start(); + const options = {}; + openReplayTracker.use(trackerAssist(options)); // check the list of available options below + + let membershipInfo: GroupUserInfoCard | null = JSON.parse( + localStorage.getItem("user-membershipInfo") || "null" + ); + if (membershipInfo) { + console.log("Found cached membership info, using it to identify user in OpenReplay"); + } + identifyUserWithTracker(membershipInfo); +} catch (e) { + console.error("Failed to initialize OpenReplay tracker", e); } +// if (!!environment.highlight_project_id) { +// H.init(environment.highlight_project_id, { +// environment: environment.production +// ? "production" +// : environment.beta +// ? "beta" +// : environment.canary +// ? "canary" +// : "dev", +// tracingOrigins: true, +// inlineImages: false, +// version: environment.version, +// networkRecording: { +// enabled: true, +// recordHeadersAndBody: false, +// urlBlocklist: [ +// "https://bungie.net/common/destiny2_content/icons/", +// "https://www.bungie.net/img/", +// ], +// }, +// }); +// } + import { ModslotVisualizationComponent } from "./components/authenticated-v2/settings/desired-mod-limit-selection/modslot-visualization/modslot-visualization.component"; import { ModLimitSegmentedComponent } from "./components/authenticated-v2/settings/desired-mod-limit-selection/mod-limit-segmented/mod-limit-segmented.component"; +import { PrivacyPolicyComponent } from "./components/privacy-policy/privacy-policy.component"; +import { GroupUserInfoCard } from "bungie-api-ts/groupv2"; const routes: Routes = [ { @@ -134,8 +211,9 @@ const routes: Routes = [ ], }, //{path: '', component: MainComponent, canActivate: [AuthenticatedGuard]}, + { path: "privacy-policy", component: PrivacyPolicyComponent }, { path: "login", component: LoginComponent, canActivate: [NotAuthenticatedGuard] }, - { path: "login-bungie", component: HandleBungieLoginComponent }, + { path: "authenticate", component: HandleBungieLoginComponent }, { path: "**", redirectTo: "/" }, ]; @@ -164,6 +242,7 @@ const routes: Routes = [ CountElementInListPipe, VendorIdFromItemIdPipe, VendorNamePipe, + LogLevelPipe, IgnoredItemsListComponent, HelpPageComponent, ArmorPickerPageComponent, @@ -174,7 +253,6 @@ const routes: Routes = [ ItemIconComponent, ArmorInvestigationPageComponent, ChangelogDialogComponent, - ChangelogDialogControllerComponent, ChangelogListComponent, ArmorPerkIconComponent, ExoticPerkTooltipComponent, @@ -187,9 +265,11 @@ const routes: Routes = [ StatCooldownTooltipComponent, SlotLimitationTitleComponent, ResultsCardViewComponent, + ResultsTableViewComponent, GearsetSelectionComponent, ModslotVisualizationComponent, ModLimitSegmentedComponent, + PrivacyPolicyComponent, ], imports: [ CommonModule, @@ -198,16 +278,38 @@ const routes: Routes = [ BrowserModule, BrowserAnimationsModule, HttpClientModule, - RouterModule.forRoot(routes, { useHash: true }), + RouterModule.forRoot(routes, { useHash: false }), ClipboardModule, LayoutModule, LoggerModule.forRoot({ - serverLoggingUrl: "/api/logs", + // serverLoggingUrl: "/api/logs", level: environment.production ? NgxLoggerLevel.ERROR : NgxLoggerLevel.DEBUG, serverLogLevel: NgxLoggerLevel.ERROR, }), ], - providers: [], + providers: [ + { + provide: ErrorHandler, + useValue: Sentry.createErrorHandler({ + showDialog: false, + }), + }, + + { + provide: ErrorHandler, + useValue: Sentry.createErrorHandler(), + }, + { + provide: Sentry.TraceService, + deps: [Router], + }, + { + provide: APP_INITIALIZER, + useFactory: () => () => {}, + deps: [Sentry.TraceService], + multi: true, + }, + ], bootstrap: [AppComponent], }) export class AppModule {} diff --git a/src/app/components/authenticated-v2/app-v2-core/app-v2-core.component.html b/src/app/components/authenticated-v2/app-v2-core/app-v2-core.component.html index 92db994f..0b368341 100644 --- a/src/app/components/authenticated-v2/app-v2-core/app-v2-core.component.html +++ b/src/app/components/authenticated-v2/app-v2-core/app-v2-core.component.html @@ -29,67 +29,18 @@ - - - - - - -
- - - {{ link.name }} - -
- - - attach_money - Buy me a coffee! - - - - Open changelog - -
-
-
-
-
- - + - + D2ArmorPicker by Mijago - - + - - + diff --git a/src/app/components/authenticated-v2/app-v2-core/app-v2-core.component.ts b/src/app/components/authenticated-v2/app-v2-core/app-v2-core.component.ts index f113e823..2e55e874 100644 --- a/src/app/components/authenticated-v2/app-v2-core/app-v2-core.component.ts +++ b/src/app/components/authenticated-v2/app-v2-core/app-v2-core.component.ts @@ -15,13 +15,22 @@ * along with this program. If not, see . */ -import { Component, OnInit } from "@angular/core"; -import { NGXLogger } from "ngx-logger"; +import { + Component, + OnInit, + AfterViewInit, + ChangeDetectionStrategy, + OnDestroy, + ChangeDetectorRef, + NgZone, +} from "@angular/core"; +import { LoggingProxyService } from "../../../services/logging-proxy.service"; import { StatusProviderService } from "../../../services/status-provider.service"; -import { Observable } from "rxjs"; +import { Observable, Subject } from "rxjs"; import { BreakpointObserver, Breakpoints } from "@angular/cdk/layout"; -import { map, shareReplay } from "rxjs/operators"; -import { InventoryService } from "../../../services/inventory.service"; +import { map, shareReplay, takeUntil } from "rxjs/operators"; +import { UserInformationService } from "src/app/services/user-information.service"; +import { ArmorCalculatorService } from "../../../services/armor-calculator.service"; import { AuthService } from "../../../services/auth.service"; import { NavigationEnd, Router } from "@angular/router"; import { environment } from "../../../../environments/environment"; @@ -32,16 +41,22 @@ import { CharacterStatsService } from "../../../services/character-stats.service selector: "app-app-v2-core", templateUrl: "./app-v2-core.component.html", styleUrls: ["./app-v2-core.component.scss"], + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class AppV2CoreComponent implements OnInit { +export class AppV2CoreComponent implements OnInit, AfterViewInit, OnDestroy { version = environment.version; activeLinkIndex = 0; computationProgress = 0; + private ngUnsubscribe = new Subject(); navLinks = [ { link: "/", name: "Home", }, + { + link: "/cluster", + name: "Clustering", + }, { link: "/help", name: "Help", @@ -50,18 +65,27 @@ export class AppV2CoreComponent implements OnInit { link: "/account", name: "Account", }, + { + link: "/privacy-policy", + name: "Privacy Policy", + }, ]; constructor( public status: StatusProviderService, private breakpointObserver: BreakpointObserver, - private inv: InventoryService, + private inv: UserInformationService, + private armorCalculator: ArmorCalculatorService, private auth: AuthService, private router: Router, private characterStats: CharacterStatsService, public changelog: ChangelogService, - private logger: NGXLogger - ) {} + private logger: LoggingProxyService, + private cdr: ChangeDetectorRef, + private ngZone: NgZone + ) { + this.logger.debug("AppV2CoreComponent", "constructor", "Component initialized"); + } isHandset$: Observable = this.breakpointObserver .observe([Breakpoints.Handset, Breakpoints.Small, Breakpoints.XSmall]) @@ -81,20 +105,54 @@ export class AppV2CoreComponent implements OnInit { this.navLinks.find((tab) => tab.link === this.router.url) as any ); }); + } + ngAfterViewInit(): void { + this.logger.debug("AppV2CoreComponent", "ngAfterViewInit", "Component after view initialized"); + this.changelog.checkAndShowChangelog(); this.characterStats.loadCharacterStats(); - - this.inv.calculationProgress.subscribe((progress) => { - this.computationProgress = progress; - }); + this.armorCalculator.calculationProgress + .pipe(takeUntil(this.ngUnsubscribe)) + .subscribe((progress) => { + this.ngZone.run(() => { + this.computationProgress = progress; + this.cdr.markForCheck(); + }); + }); } async refreshAll(b: boolean) { this.logger.debug("AppV2CoreComponent", "refreshAll", "Trigger refreshAll due to button press"); - await this.inv.refreshAll(b); + try { + await this.inv.refreshManifestAndInventory(b); + } catch (error) { + this.logger.error( + "AppV2CoreComponent", + "refreshAll", + "Failed to refresh manifest and inventory", + error + ); + } + } + + async logout() { + try { + await this.auth.logout(); + this.logger.debug("AppV2CoreComponent", "logout", "Logout successful, navigating to login"); + await this.router.navigate(["login"]); + } catch (error) { + this.logger.error("AppV2CoreComponent", "logout", "Failed during logout process", error); + // Still try to navigate even if logout fails + try { + await this.router.navigate(["login"]); + } catch (navError) { + this.logger.error("AppV2CoreComponent", "logout", "Failed to navigate to login", navError); + } + } } - logout() { - this.auth.logout(); + ngOnDestroy() { + this.ngUnsubscribe.next(); + this.ngUnsubscribe.complete(); } } diff --git a/src/app/components/authenticated-v2/components/changelog-dialog-controller/changelog-dialog-controller.component.html b/src/app/components/authenticated-v2/components/changelog-dialog-controller/changelog-dialog-controller.component.html deleted file mode 100644 index 09b6ee1b..00000000 --- a/src/app/components/authenticated-v2/components/changelog-dialog-controller/changelog-dialog-controller.component.html +++ /dev/null @@ -1,16 +0,0 @@ - diff --git a/src/app/components/authenticated-v2/components/changelog-dialog/changelog-dialog.component.html b/src/app/components/authenticated-v2/components/changelog-dialog/changelog-dialog.component.html index 5838639f..57a55acd 100644 --- a/src/app/components/authenticated-v2/components/changelog-dialog/changelog-dialog.component.html +++ b/src/app/components/authenticated-v2/components/changelog-dialog/changelog-dialog.component.html @@ -18,13 +18,18 @@

D2ArmorPicker Changelog for Version {{ changelog.changelogData[0].version }}

- +
Hi! There has been a new version of D2ArmorPicker! The following list shows all the relevant changes. Note that you can always look at the changelogs in the Help tab.
- + +
+ Loading changelog... +
+ +
+ hourglass_top +

Continuing calculation...

+ +

+ Additional combinations are still being checked. Current results are shown while the + calculation continues. +

+ +
+ +
+
cancel @@ -273,237 +296,33 @@

No results found

+ +
+ warning +
+ Calculation was cancelled. + + These results might be incomplete. The possible max stats might be inaccurate +
+
- + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Weapon - {{ element.stats[ArmorStat.StatWeapon] }} - - - Health - {{ element.stats[ArmorStat.StatHealth] }} - - - Class - {{ element.stats[ArmorStat.StatClass] }} - - - Grenade - {{ element.stats[ArmorStat.StatGrenade] }} - - - Super - {{ element.stats[ArmorStat.StatSuper] }} - - Melee - {{ element.stats[ArmorStat.StatMelee] }} - - - Used Mods - - - Exotic -
- - - -
-
Sources - - - - - - - expand_more - - - expand_less - - Total - - {{ getTotalStats(element) }} - -
- -
-
+ [results]="_results" + [class.loading-blur]="(status.status | async)?.updatingResultsTable"> +
- -
- + + + + + + + + + +
+

Database Management

+

+ Use these options if you experience database issues such as missing exotics or corrupted + data. +

+
+ +
+
+ + +
+

Complete Reset

+

+ Warning: This will completely reset the application, removing all + settings, builds, and data. +

+
+ +
+
+ + + + + + Privacy & Legal + Information about data handling and privacy. + + + + Learn about how we handle your data and what information we collect:
- Download manifest information (d2ap_manifest.json) - -
-
- - If you experience issues with the local database - for example, exotics not being found, try - this:
- - Delete Database
- If you really want to reset EVERYTHING, use this:
- - Reset the whole application + mat-raised-button + routerLink="/privacy-policy" + target="_blank"> + View Privacy Policy +

+ This application uses the Bungie.net API. Bungie's privacy policy also applies to data + accessed through their API.
diff --git a/src/app/components/authenticated-v2/subpages/account-config-page/account-config-page.component.ts b/src/app/components/authenticated-v2/subpages/account-config-page/account-config-page.component.ts index 387a5e66..49e190f9 100644 --- a/src/app/components/authenticated-v2/subpages/account-config-page/account-config-page.component.ts +++ b/src/app/components/authenticated-v2/subpages/account-config-page/account-config-page.component.ts @@ -17,8 +17,10 @@ import { Component } from "@angular/core"; import { DatabaseService } from "../../../../services/database.service"; -import { InventoryService } from "../../../../services/inventory.service"; +import { UserInformationService } from "src/app/services/user-information.service"; import { AuthService } from "../../../../services/auth.service"; +import { Router } from "@angular/router"; +import { environment } from "../../../../../environments/environment"; @Component({ selector: "app-account-config-page", @@ -26,9 +28,12 @@ import { AuthService } from "../../../../services/auth.service"; styleUrls: ["./account-config-page.component.css"], }) export class AccountConfigPageComponent { + isDevEnvironment = !environment.production && !environment.beta && !environment.canary; + constructor( + private router: Router, private db: DatabaseService, - public inv: InventoryService, + public inv: UserInformationService, private loginService: AuthService ) {} @@ -56,12 +61,89 @@ export class AccountConfigPageComponent { async resetDatabase() { await this.db.resetDatabase(); - await this.inv.refreshAll(true, true); + await this.inv.refreshManifestAndInventory(true, true); + } + + async downloadSystemInformation() { + // Get localStorage key names + const localStorageKeys = Object.keys(localStorage); + + // Get database table counts + const tableCounts = { + manifestArmor: await this.db.manifestArmor.count(), + inventoryArmor: await this.db.inventoryArmor.count(), + equipableItemSetDefinition: await this.db.equipableItemSetDefinition.count(), + sandboxPerkDefinition: await this.db.sandboxPerkDefinition.count(), + sandboxAbilities: await this.db.sandboxAbilities.count(), + manifestCollectibles: await this.db.manifestCollectibles.count(), + vendorNames: await this.db.vendorNames.count(), + vendorItemSubscreen: await this.db.vendorItemSubscreen.count(), + }; + + const systemInfo = { + timestamp: new Date().toISOString(), + localStorage: { + keyCount: localStorageKeys.length, + keys: localStorageKeys.sort(), + }, + database: { + name: this.db.name, + tables: tableCounts, + }, + }; + + const url = window.URL.createObjectURL(new Blob([JSON.stringify(systemInfo, null, 2)])); + const link = document.createElement("a"); + link.href = url; + link.setAttribute("download", "d2ap_system_info.json"); + document.body.appendChild(link); + link.click(); + } + + async importArmorData(event: Event) { + const input = event.target as HTMLInputElement; + if (!input.files || input.files.length === 0) { + return; + } + + const file = input.files[0]; + if (!file.name.endsWith(".json")) { + alert("Please select a JSON file."); + return; + } + + try { + const text = await file.text(); + const armorData = JSON.parse(text); + + if (!Array.isArray(armorData)) { + alert("Invalid file format. Expected an array of armor items."); + return; + } + + // Clear existing armor data and replace with imported data + await this.db.inventoryArmor.clear(); + await this.db.inventoryArmor.bulkPut(armorData); + + alert(`Successfully imported ${armorData.length} armor items.`); + + // Clear the input so the same file can be selected again + input.value = ""; + } catch (error) { + console.error("Error importing armor data:", error); + alert("Error importing file. Please check that it's a valid d2ap_armor.json file."); + } + } + + triggerFileInput() { + const fileInput = document.getElementById("armorFileInput") as HTMLInputElement; + fileInput?.click(); } async resetEverything() { localStorage.clear(); await this.db.resetDatabase(); await this.loginService.logout(); + this.router.navigate(["/login"]); } } diff --git a/src/app/components/authenticated-v2/subpages/armor-cluster-page/armor-cluster-page.component.html b/src/app/components/authenticated-v2/subpages/armor-cluster-page/armor-cluster-page.component.html index b6e11d16..21f9b669 100644 --- a/src/app/components/authenticated-v2/subpages/armor-cluster-page/armor-cluster-page.component.html +++ b/src/app/components/authenticated-v2/subpages/armor-cluster-page/armor-cluster-page.component.html @@ -64,6 +64,22 @@ +
+ Cluster Count
Choose how many clusters to generate.
+ + + + + {{ clusterCount }} + +
Exotics
Decide whether you want to see or hide exotics.
@@ -71,46 +87,40 @@ visibility_off visibility - All + All
+
- Masterwork
Decide whether you want to see or hide masterworked armor.
+ Armor System
Decide which armor system you want to see.
- - visibility_off + Armor 2.0 - visibilityArmor 3.0 - All + All
+
Class
Decide which class you want to see.
- + - + - + - All + All
@@ -171,22 +181,18 @@
- - + + diff --git a/src/app/components/authenticated-v2/subpages/armor-cluster-page/armor-cluster-page.component.ts b/src/app/components/authenticated-v2/subpages/armor-cluster-page/armor-cluster-page.component.ts index 06830418..cf1939db 100644 --- a/src/app/components/authenticated-v2/subpages/armor-cluster-page/armor-cluster-page.component.ts +++ b/src/app/components/authenticated-v2/subpages/armor-cluster-page/armor-cluster-page.component.ts @@ -19,409 +19,11 @@ import { AfterViewInit, Component } from "@angular/core"; import { IInventoryArmor, InventoryArmorSource } from "../../../../data/types/IInventoryArmor"; import { DatabaseService } from "../../../../services/database.service"; import { MatSnackBar } from "@angular/material/snack-bar"; -import { InventoryService } from "../../../../services/inventory.service"; +import { UserInformationService } from "src/app/services/user-information.service"; import { debounceTime } from "rxjs/operators"; import { ArmorSlot } from "../../../../data/enum/armor-slot"; -import { MAXIMUM_MASTERWORK_LEVEL } from "src/app/data/constants"; - -var clusterData = [ - { - id: 0, - size: 214, - centroids: [ - 5.242990654205609, 3.4392523364485994, 23.074766355140188, 4.52336448598131, - 17.99532710280374, 8.074766355140188, - ], - std: [ - 3.262185500658884, 3.137958193527344, 2.0745611905196912, 3.223158380401712, - 2.6478649185881147, 3.356022774004607, 3.3823650213485315, - ], - mean: [ - 62.350467289719624, 5.242990654205608, 3.439252336448598, 23.074766355140188, - 4.5233644859813085, 17.99532710280374, 8.074766355140186, - ], - }, - { - id: 1, - size: 264, - centroids: [ - 13.613636363636365, 3.060606060606063, 14.431818181818182, 6.731060606060606, - 12.575757575757574, 10.575757575757576, - ], - std: [ - 4.915624932359143, 2.735897756946947, 1.897597343200423, 3.036390647550984, - 3.2253188618447473, 3.0097969617389406, 3.1372197425220545, - ], - mean: [ - 60.98863636363637, 13.613636363636363, 3.0606060606060606, 14.431818181818182, - 6.731060606060606, 12.575757575757576, 10.575757575757576, - ], - }, - { - id: 2, - size: 220, - centroids: [ - 5.209090909090909, 15.586363636363636, 10.936363636363636, 13.281818181818181, - 13.604545454545452, 3.8818181818181836, - ], - std: [ - 3.3631090204013643, 2.931782865076046, 2.9200605883727038, 3.289049973803757, - 2.6349662061268395, 2.6591750828428204, 2.395703224398619, - ], - mean: [ - 62.5, 5.209090909090909, 15.586363636363636, 10.936363636363636, 13.281818181818181, - 13.604545454545455, 3.881818181818182, - ], - }, - { - id: 3, - size: 230, - centroids: [ - 8.6, 15.330434782608698, 6.943478260869563, 6.408695652173913, 5.578260869565216, - 18.26086956521739, - ], - std: [ - 4.27370926113142, 3.855014315301441, 3.2487061290912984, 3.5081098914433078, - 3.559587955581114, 3.1761194803539676, 3.596667444705349, - ], - mean: [ - 61.12173913043478, 8.6, 15.330434782608696, 6.943478260869565, 6.408695652173913, - 5.578260869565217, 18.26086956521739, - ], - }, - { - id: 4, - size: 261, - centroids: [ - 11.74712643678161, 5.655172413793104, 13.873563218390803, 11.022988505747126, - 16.57088122605364, 3.0038314176245224, - ], - std: [ - 3.5878146263573103, 2.8197860971981994, 2.9890605143791737, 2.9825309245974925, - 2.7497287061744258, 2.8324620286123516, 1.8428616632651005, - ], - mean: [ - 61.87356321839081, 11.74712643678161, 5.655172413793103, 13.873563218390805, - 11.022988505747126, 16.57088122605364, 3.003831417624521, - ], - }, - { - id: 5, - size: 249, - centroids: [ - 6.598393574297189, 7.2289156626506035, 7.674698795180722, 6.831325301204822, - 5.995983935742974, 7.425702811244981, - ], - std: [ - 12.188382924990831, 3.7780346472852226, 3.7855581933977014, 3.9659084518463055, - 3.2508780840166134, 2.4288222155150856, 3.23718036285365, - ], - mean: [ - 41.75502008032129, 6.598393574297189, 7.228915662650603, 7.674698795180723, 6.831325301204819, - 5.995983935742972, 7.42570281124498, - ], - }, - { - id: 6, - size: 241, - centroids: [ - 4.580912863070541, 10.62655601659751, 15.72199170124481, 20.186721991701248, - 5.7634854771784205, 4.8672199170124495, - ], - std: [ - 4.0360666526825675, 2.5776864620318327, 2.726712032141287, 2.7236414380869185, - 3.4135743182998106, 3.164594939982558, 2.8952195061747896, - ], - mean: [ - 61.74688796680498, 4.580912863070539, 10.62655601659751, 15.721991701244812, - 20.186721991701244, 5.763485477178423, 4.867219917012448, - ], - }, - { - id: 7, - size: 352, - centroids: [ - 4.96875, 10.849431818181818, 15.676136363636362, 5.085227272727275, 13.079545454545453, - 12.113636363636365, - ], - std: [ - 3.864447558701623, 2.876694966732471, 2.825917840642513, 2.8500410080793133, - 2.604753041986593, 2.753797030174534, 3.084495990290069, - ], - mean: [ - 61.77272727272727, 4.96875, 10.849431818181818, 15.676136363636363, 5.0852272727272725, - 13.079545454545455, 12.113636363636363, - ], - }, - { - id: 8, - size: 219, - centroids: [ - 21.401826484018265, 4.529680365296804, 4.954337899543379, 14.022831050228312, - 7.168949771689496, 6.8036529680365305, - ], - std: [ - 7.654498360234386, 3.856746305047457, 3.0774088630224354, 3.486239910130717, - 4.509361233853425, 3.691515724130434, 3.433882092856456, - ], - mean: [ - 58.881278538812786, 21.401826484018265, 4.529680365296803, 4.954337899543379, - 14.02283105022831, 7.168949771689498, 6.80365296803653, - ], - }, - { - id: 9, - size: 183, - centroids: [ - 4.448087431693989, 22.114754098360656, 4.868852459016392, 7.459016393442623, - 11.240437158469945, 9.765027322404372, - ], - std: [ - 7.900837110469869, 3.265921971202623, 3.896032191192026, 3.424902708986657, - 3.3556927380910535, 3.7206143627650876, 3.9564220813839577, - ], - mean: [ - 59.89617486338798, 4.448087431693989, 22.114754098360656, 4.868852459016393, - 7.459016393442623, 11.240437158469945, 9.765027322404372, - ], - }, - { - id: 10, - size: 197, - centroids: [ - 13.563451776649746, 10.761421319796954, 6.527918781725887, 20.654822335025383, - 4.934010152284262, 5.18274111675127, - ], - std: [ - 4.239153654030002, 3.3077343290831713, 2.8924486501981233, 2.9338771160608355, - 3.5098461495889413, 3.192898794854312, 2.920215710334156, - ], - mean: [ - 61.6243654822335, 13.563451776649746, 10.761421319796954, 6.527918781725888, - 20.65482233502538, 4.934010152284264, 5.182741116751269, - ], - }, - { - id: 11, - size: 176, - centroids: [ - 8.255681818181818, 16.181818181818183, 7.11931818181818, 4.619318181818182, 21.0625, - 5.505681818181819, - ], - std: [ - 3.349621998445128, 3.4620343467651815, 3.3296776490755446, 2.945693532873097, - 2.7583580190103967, 3.126214049887545, 3.078027492852078, - ], - mean: [ - 62.74431818181818, 8.255681818181818, 16.181818181818183, 7.119318181818182, - 4.619318181818182, 21.0625, 5.505681818181818, - ], - }, - { - id: 12, - size: 194, - centroids: [ - 3.5670103092783503, 14.773195876288659, 13.036082474226804, 13.185567010309278, - 4.979381443298967, 12.144329896907218, - ], - std: [ - 3.89886364980042, 2.255251632823776, 2.5893570716174388, 3.248552824613239, - 3.1004926799108317, 2.8845824993776903, 2.8864151735009584, - ], - mean: [ - 61.68556701030928, 3.5670103092783507, 14.77319587628866, 13.036082474226804, - 13.185567010309278, 4.979381443298969, 12.144329896907216, - ], - }, - { - id: 13, - size: 302, - centroids: [ - 5.311258278145695, 4.4701986754966905, 21.897350993377486, 10.688741721854305, - 6.834437086092715, 12.605960264900663, - ], - std: [ - 5.1142628152416245, 2.9920946350614983, 2.6882491851253567, 3.071581172564087, - 2.99431450280505, 3.116626644660552, 2.58548804104293, - ], - mean: [ - 61.80794701986755, 5.311258278145695, 4.470198675496689, 21.897350993377483, - 10.688741721854305, 6.8344370860927155, 12.605960264900663, - ], - }, - { - id: 14, - size: 364, - centroids: [ - 12.263736263736265, 11.32967032967033, 5.936813186813188, 8.524725274725274, - 12.07142857142857, 7.782967032967034, - ], - std: [ - 6.666392881793753, 2.8942239993391565, 2.947866371540881, 3.041289192757803, - 3.0876888182534996, 2.4540644069260296, 2.6803305558675676, - ], - mean: [ - 57.90934065934066, 12.263736263736265, 11.32967032967033, 5.936813186813187, - 8.524725274725276, 12.071428571428571, 7.782967032967033, - ], - }, - { - id: 15, - size: 219, - centroids: [ - 6.981735159817351, 5.9908675799086755, 18.127853881278536, 4.97716894977169, - 5.182648401826483, 20.89041095890411, - ], - std: [ - 4.3335830659244685, 3.5503759593138464, 3.5270785628911785, 4.123339305032141, - 2.7998081886699646, 3.283747903424203, 2.8311584355558064, - ], - mean: [ - 62.15068493150685, 6.981735159817352, 5.9908675799086755, 18.12785388127854, - 4.9771689497716896, 5.1826484018264845, 20.89041095890411, - ], - }, - { - id: 16, - size: 147, - centroids: [ - 6.285714285714285, 20.836734693877553, 4.8639455782312915, 17.510204081632654, - 5.897959183673469, 7.394557823129253, - ], - std: [ - 3.358145469360083, 3.4877476344546254, 3.2245649941865486, 3.0557367019327, - 4.0937094190209065, 3.1136788219952605, 3.842013283457479, - ], - mean: [ - 62.7891156462585, 6.285714285714286, 20.836734693877553, 4.863945578231292, - 17.510204081632654, 5.8979591836734695, 7.394557823129252, - ], - }, - { - id: 17, - size: 202, - centroids: [ - 18.425742574257423, 6.871287128712871, 6.678217821782177, 5.06930693069307, 20.40594059405941, - 5.876237623762377, - ], - std: [ - 3.0226636827682554, 3.292828132097339, 3.518715749263091, 3.4627255810748356, - 3.075363266145594, 3.5804283293104753, 3.315804113390658, - ], - mean: [ - 63.32673267326733, 18.425742574257427, 6.871287128712871, 6.678217821782178, - 5.069306930693069, 20.405940594059405, 5.876237623762377, - ], - }, - { - id: 18, - size: 286, - centroids: [ - 11.22027972027972, 7.073426573426573, 12.65034965034965, 12.594405594405593, - 4.209790209790211, 13.220279720279722, - ], - std: [ - 4.720510224882134, 2.8685361372318168, 2.6005789112305338, 3.052197922484291, - 2.451602446915686, 2.7244266014541174, 2.7638787756329526, - ], - mean: [ - 60.96853146853147, 11.22027972027972, 7.073426573426573, 12.65034965034965, - 12.594405594405595, 4.20979020979021, 13.22027972027972, - ], - }, - { - id: 19, - size: 197, - centroids: [ - 6.263959390862944, 8.568527918781726, 16.80710659898477, 4.000000000000003, - 22.593908629441625, 4.883248730964468, - ], - std: [ - 2.7333118141791917, 3.3626657547151964, 3.4540163497542435, 2.646397291854302, - 2.565469285152567, 3.018237668617547, 2.8287109641017594, - ], - mean: [ - 63.11675126903553, 6.2639593908629445, 8.568527918781726, 16.80710659898477, 4.0, - 22.593908629441625, 4.883248730964467, - ], - }, - { - id: 20, - size: 279, - centroids: [ - 18.025089605734767, 6.150537634408602, 6.161290322580646, 5.767025089605736, - 7.999999999999998, 15.602150537634408, - ], - std: [ - 6.590788110639117, 3.6436721401086296, 3.165505073725202, 3.13700467469825, - 2.7769973891540207, 3.4672154149710614, 4.017425492621824, - ], - mean: [ - 59.70609318996416, 18.025089605734767, 6.150537634408602, 6.161290322580645, - 5.767025089605735, 8.0, 15.602150537634408, - ], - }, - { - id: 21, - size: 78, - centroids: [ - 15.96153846153846, 16.85897435897436, 16.807692307692307, 3.552713678800501e-15, - 5.329070518200751e-15, -5.329070518200751e-15, - ], - std: [2.095815090231219, 7.438828122504502, 7.482836393563639, 8.12413063050432, 0.0, 0.0, 0.0], - mean: [ - 49.62820512820513, 15.961538461538462, 16.858974358974358, 16.807692307692307, 0.0, 0.0, 0.0, - ], - }, - { - id: 22, - size: 137, - centroids: [ - 4.525547445255475, 3.583941605839417, 23.532846715328468, 19.948905109489054, - 4.3576642335766405, 6.248175182481752, - ], - std: [ - 2.9351806639916567, 2.7575572416084317, 2.2707939003224227, 2.908029186302962, - 3.3306093063017315, 2.6644092885703916, 3.4848010785869454, - ], - mean: [ - 62.197080291970806, 4.525547445255475, 3.5839416058394162, 23.532846715328468, - 19.94890510948905, 4.357664233576642, 6.248175182481752, - ], - }, - { - id: 23, - size: 194, - centroids: [ - 13.242268041237114, 3.5103092783505163, 14.675257731958762, 19.55154639175258, - 6.139175257731957, 5.092783505154641, - ], - std: [ - 3.810494814771566, 2.886105181640385, 2.0818477512276696, 2.6197152448917316, - 3.409818599464837, 3.0311357231629183, 2.9733966349516936, - ], - mean: [ - 62.21134020618557, 13.242268041237113, 3.5103092783505154, 14.675257731958762, - 19.551546391752577, 6.139175257731959, 5.092783505154639, - ], - }, - { - id: 24, - size: 239, - centroids: [ - 5.569037656903766, 5.401673640167365, 20.92468619246862, 13.09205020920502, - 13.497907949790793, 4.096234309623432, - ], - std: [ - 3.8502204432228733, 3.071015816571908, 3.081143061480697, 2.79904271710946, 2.169280265760518, - 2.62802363192924, 2.3450167178621983, - ], - mean: [ - 62.58158995815899, 5.569037656903766, 5.401673640167364, 20.92468619246862, - 13.092050209205022, 13.497907949790795, 4.096234309623431, - ], - }, -]; +import { ArmorSystem } from "src/app/data/types/IManifestArmor"; +import { ARMORSTAT_ORDER, ArmorStatNames } from "src/app/data/enum/armor-stat"; @Component({ selector: "app-armor-cluster-page", @@ -429,23 +31,25 @@ var clusterData = [ styleUrls: ["./armor-cluster-page.component.css"], }) export class ArmorClusterPageComponent implements AfterViewInit { - clusterInformation = clusterData; + clusterInformation: any[] = []; items: Array = []; clusters: IInventoryArmor[][] = []; - exoticFilter: number = 0; - masterworkFilter: number = 0; - classFilter: number = -1; + exoticFilter: number | undefined = undefined; + classFilter: number | undefined = undefined; + armorSystemFilter: ArmorSystem | undefined = undefined; + clusterCount: number = 10; + + public ARMORSTAT_ORDER = ARMORSTAT_ORDER; + public ArmorStatNames = ArmorStatNames; constructor( private db: DatabaseService, private _snackBar: MatSnackBar, - private inventory: InventoryService - ) { - this.clusterInformation = clusterData.sort((a, b) => { - return b.mean[3] - a.mean[3]; - }); - } + private inventory: UserInformationService + ) {} + + // No longer needed: clusterCount is updated via ngModel on the slider thumb async ngAfterViewInit(): Promise { this.inventory.inventory.pipe(debounceTime(200)).subscribe(async () => { @@ -455,29 +59,139 @@ export class ArmorClusterPageComponent implements AfterViewInit { } public async Update() { - var items = (await this.db.inventoryArmor.toArray()).filter( - (item) => item.source === InventoryArmorSource.Inventory + // Filter items by inventory and user options + console.log("classFilter", this.exoticFilter !== -1); + const items = (await this.db.inventoryArmor.toArray()).filter( + (item) => + item.source === InventoryArmorSource.Inventory && + item.slot !== ArmorSlot.ArmorSlotClass && + item.slot !== ArmorSlot.ArmorSlotNone && + (this.classFilter === undefined || item.clazz === this.classFilter) && + (this.exoticFilter !== -1 || !item.isExotic) && + (this.exoticFilter !== 1 || item.isExotic) && + (this.armorSystemFilter === undefined || item.armorSystem === this.armorSystemFilter) ); + this.items = items; + + // Prepare stat vectors for clustering + const statVectors = items.map((item) => [ + item.mobility, + item.resilience, + item.recovery, + item.discipline, + item.intellect, + item.strength, + //item.mobility + item.resilience + item.recovery + item.discipline + item.intellect + item.strength, + ]); + + // Run k-means clustering with deterministic seed + const k = Math.min(this.clusterCount, items.length); + const seed = 42; + const { assignments, centroids } = this.kmeans(statVectors, k, 20, seed); + + // Group items by cluster + const clusters: IInventoryArmor[][] = Array.from({ length: k }, () => []); + items.forEach((item, idx) => { + const clusterId = assignments[idx]; + if (clusterId !== undefined && clusterId >= 0) clusters[clusterId].push(item); + }); + // Calculate clusterInformation (mean for each cluster) + let clusterInformation = centroids.map((centroid, i) => { + // Calculate mean for each stat in the cluster + const clusterItems = clusters[i]; + if (clusterItems.length === 0) return { mean: centroid, size: 0 }; + const mean = Array(6).fill(0); + clusterItems.forEach((item) => { + mean[0] += item.mobility; + mean[1] += item.resilience; + mean[2] += item.recovery; + mean[3] += item.discipline; + mean[4] += item.intellect; + mean[5] += item.strength; + //mean[6] +=item.mobility + item.resilience + item.recovery + item.discipline + item.intellect + item.strength; + }); + for (let j = 0; j < 7; j++) mean[j] /= clusterItems.length; + return { mean, size: clusterItems.length }; + }); - var clusters: IInventoryArmor[][] = []; - for (let i = 0; i < this.clusterInformation.length; i++) { - clusters.push([]); - } - - for (let item of items) { - if (item.slot == ArmorSlot.ArmorSlotClass) continue; - if (item.slot == ArmorSlot.ArmorSlotNone) continue; // ignores stasis and halloween masks. - - if (this.classFilter != -1 && item.clazz != this.classFilter) continue; - if (this.exoticFilter == -1 && item.isExotic) continue; - if (this.exoticFilter == 1 && !item.isExotic) continue; - if (this.masterworkFilter == -1 && item.masterworkLevel == MAXIMUM_MASTERWORK_LEVEL) continue; - if (this.masterworkFilter == 1 && item.masterworkLevel != MAXIMUM_MASTERWORK_LEVEL) continue; + // Pair clusters and info, sort descending by size, then unpack + const paired = clusters.map((c, i) => ({ cluster: c, info: clusterInformation[i] })); + paired.sort((a, b) => b.cluster.length - a.cluster.length); + this.clusters = paired.map((p) => p.cluster); + this.clusterInformation = paired.map((p) => p.info); + } - var clusterId = this.getClusterid(item); - clusters[clusterId].push(item); + // Simple k-means implementation for stat vectors, deterministic with seed + private kmeans( + data: number[][], + k: number, + maxIter = 20, + seed = 42 + ): { assignments: number[]; centroids: number[][] } { + if (data.length === 0 || k === 0) return { assignments: [], centroids: [] }; + + // Deterministic PRNG (Mulberry32) + function mulberry32(a: number) { + return function () { + var t = (a += 0x6d2b79f5); + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + } + const rand = mulberry32(seed); + + // Randomly initialize centroids using deterministic PRNG + let centroids = data.slice(0, k).map((vec) => vec.slice()); + if (data.length > k) { + const used = new Set(); + centroids = []; + while (centroids.length < k) { + const idx = Math.floor(rand() * data.length); + if (!used.has(idx)) { + centroids.push(data[idx].slice()); + used.add(idx); + } + } + } + let assignments = new Array(data.length).fill(0); + for (let iter = 0; iter < maxIter; iter++) { + // Assign + assignments = data.map((vec) => { + let minDist = Infinity, + minIdx = 0; + for (let i = 0; i < centroids.length; i++) { + const dist = this.vectorDistance(vec, centroids[i]); + if (dist < minDist) { + minDist = dist; + minIdx = i; + } + } + return minIdx; + }); + // Update centroids + const newCentroids = Array.from({ length: k }, () => Array(data[0].length).fill(0)); + const counts = Array(k).fill(0); + data.forEach((vec, idx) => { + const cluster = assignments[idx]; + counts[cluster]++; + for (let j = 0; j < vec.length; j++) { + newCentroids[cluster][j] += vec[j]; + } + }); + for (let i = 0; i < k; i++) { + if (counts[i] > 0) { + for (let j = 0; j < newCentroids[i].length; j++) { + newCentroids[i][j] /= counts[i]; + } + } else { + // Reinitialize empty cluster deterministically + newCentroids[i] = data[Math.floor(rand() * data.length)].slice(); + } + } + centroids = newCentroids; } - this.clusters = clusters; + return { assignments, centroids }; } openSnackBar(message: string) { diff --git a/src/app/components/authenticated-v2/subpages/armor-investigation-page/armor-investigation-page.component.ts b/src/app/components/authenticated-v2/subpages/armor-investigation-page/armor-investigation-page.component.ts index 200dc317..35db9263 100644 --- a/src/app/components/authenticated-v2/subpages/armor-investigation-page/armor-investigation-page.component.ts +++ b/src/app/components/authenticated-v2/subpages/armor-investigation-page/armor-investigation-page.component.ts @@ -16,10 +16,10 @@ */ import { Component, OnDestroy, OnInit } from "@angular/core"; -import { NGXLogger } from "ngx-logger"; +import { LoggingProxyService } from "../../../../services/logging-proxy.service"; import { Subject } from "rxjs"; import { debounceTime, takeUntil } from "rxjs/operators"; -import { InventoryService } from "../../../../services/inventory.service"; +import { UserInformationService } from "src/app/services/user-information.service"; import { IInventoryArmor, InventoryArmorSource } from "../../../../data/types/IInventoryArmor"; import { DatabaseService } from "../../../../services/database.service"; import { ArmorSystem, IManifestArmor } from "../../../../data/types/IManifestArmor"; @@ -71,9 +71,9 @@ export class ArmorInvestigationPageComponent implements OnInit, OnDestroy { plugData: { [p: string]: IManifestArmor } = {}; constructor( - public inventory: InventoryService, + public inventory: UserInformationService, private db: DatabaseService, - private logger: NGXLogger + private logger: LoggingProxyService ) {} ngOnInit(): void { @@ -236,8 +236,8 @@ export class ArmorInvestigationPageComponent implements OnInit, OnDestroy { this.armorItemsPerSlot = armorItems.reduce((p, v) => { const slot = !v.slot ? 10 : v.slot; - if (!p.has(slot)) p.set(slot, []); - p.get(slot)?.push(v); + if (!p.has(slot as ArmorSlot)) p.set(slot as ArmorSlot, []); + p.get(slot as ArmorSlot)?.push(v); return p; }, new Map()); diff --git a/src/app/components/authenticated-v2/subpages/armor-picker-page/armor-picker-page.component.css b/src/app/components/authenticated-v2/subpages/armor-picker-page/armor-picker-page.component.css index f1be2f03..999ae34a 100644 --- a/src/app/components/authenticated-v2/subpages/armor-picker-page/armor-picker-page.component.css +++ b/src/app/components/authenticated-v2/subpages/armor-picker-page/armor-picker-page.component.css @@ -19,4 +19,8 @@ width: fit-content; margin-right: 3px; white-space: nowrap; -} + margin: auto; + display: flex; + flex-wrap: wrap; + justify-content: center; +} \ No newline at end of file diff --git a/src/app/components/authenticated-v2/subpages/privacy-policy-page/privacy-policy-page.component.html b/src/app/components/authenticated-v2/subpages/privacy-policy-page/privacy-policy-page.component.html new file mode 100644 index 00000000..32769450 --- /dev/null +++ b/src/app/components/authenticated-v2/subpages/privacy-policy-page/privacy-policy-page.component.html @@ -0,0 +1,146 @@ +
+
+

Privacy Policy

+

Last Updated: February 11, 2026

+ +
+

Introduction

+

+ Welcome to D2ArmorPicker. This Privacy Policy explains how this service collects and uses + your personal information. D2ArmorPicker is committed to being transparent about data + practices. +

+
+ +
+

Information We Collect

+ +

Personal Information

+

When you use D2ArmorPicker, we may collect the following types of information:

+
    +
  • Bungie Account Information: Your Bungie username and associated game + data when you authenticate with our service
  • +
  • IP Address: Your IP address for analytics purposes
  • +
  • Usage Data: Information about how you interact with our service, + including pages visited and features used
  • +
+ +

Destiny 2 Game Data

+

+ When you connect your Bungie account, this service accesses your Destiny 2 character + information, armor pieces, and related game data through the Bungie API. This information is + used solely to provide armor optimization services. +

+
+ +
+

How We Use Your Information

+

The collected information is used for the following purposes:

+
    +
  • To provide and maintain services
  • +
  • To authenticate your identity and ensure access to your game data
  • +
  • To analyze usage patterns and improve the service
  • +
  • To troubleshoot technical issues and provide support
  • +
+
+ +
+

Data Storage and Security

+

+ Game data retrieved from the Bungie API is processed in real-time and may be cached + temporarily to improve performance. Sensitive account credentials are not stored. +

+

We take reasonable measures to protect your information from unauthorized access, use, or + disclosure.

+

Information of usage patterns, errors and activity may be collected to improve the + service.

+
+ +
+

Data Sharing

+

+ Your personal information is not sold, traded, or otherwise transferred to third parties + except in the following circumstances: +

+
    +
  • When required by law or to comply with legal processes
  • +
  • To protect the rights, privacy, safety, or property of this service
  • +
+

+ Third-party services may be used for analytics and error tracking. These services are bound + by their own privacy policies and data processing agreements. +

+
+ +
+

Cookies and Local Storage

+

+ This service uses browser local storage to save your preferences, settings, and temporary + data to enhance your experience. This information remains on your device and can be cleared + through your browser settings. +

+
+ +
+

Third-Party Links

+

+ This service may contain links to third-party websites or services. D2ArmorPicker is not + responsible for the privacy practices of these external sites. You are encouraged to review + their privacy policies. +

+
+ +
+

Changes to This Privacy Policy

+

+ This Privacy Policy may be updated from time to time. You will be notified of any changes by + posting the new Privacy Policy on this page and updating the "Last Updated" date. +

+
+ +
+

Contact Us

+

+ If you have any questions about this Privacy Policy or data practices, please reach out + through the following channels: +

+ +
+ +
+

Bungie.net API

+

+ This service uses the Bungie.net API. Bungie's Privacy Policy applies to data accessed + through their API. You can review Bungie's Privacy Policy at + https://www.bungie.net/en/View/privacypolicy. +

+

OpenReplay

+

+ This service uses OpenReplay for session replay and analytics. OpenReplay's Privacy Policy + applies to data collected through their services. You can review OpenReplay's Privacy Policy + at + https://openreplay.com/privacy. +

+
+
+
diff --git a/src/app/components/authenticated-v2/subpages/privacy-policy-page/privacy-policy-page.component.scss b/src/app/components/authenticated-v2/subpages/privacy-policy-page/privacy-policy-page.component.scss new file mode 100644 index 00000000..4091a22f --- /dev/null +++ b/src/app/components/authenticated-v2/subpages/privacy-policy-page/privacy-policy-page.component.scss @@ -0,0 +1,147 @@ +.privacy-policy-container { + padding: 20px; + max-width: 800px; + margin: 0 auto; +} + +.privacy-policy-content { + line-height: 1.6; + color: #333; + + h1 { + color: #1976d2; + border-bottom: 2px solid #1976d2; + padding-bottom: 10px; + margin-bottom: 20px; + } + + h2 { + color: #424242; + margin-top: 30px; + margin-bottom: 15px; + border-left: 4px solid #1976d2; + padding-left: 12px; + } + + h3 { + color: #616161; + margin-top: 20px; + margin-bottom: 10px; + } + + .last-updated { + font-style: italic; + color: #666; + margin-bottom: 30px; + } + + p { + margin-bottom: 15px; + text-align: justify; + } + + ul { + margin-bottom: 15px; + padding-left: 20px; + + li { + margin-bottom: 8px; + + strong { + color: #1976d2; + } + } + } + + section { + margin-bottom: 25px; + + &.acknowledgment { + background-color: #f5f5f5; + padding: 20px; + border-radius: 8px; + border-left: 4px solid #ff9800; + margin-top: 40px; + + h2 { + color: #ff9800; + border-left-color: #ff9800; + margin-top: 0; + } + } + } + + a { + color: #1976d2; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } +} + +// Dark theme support +@media (prefers-color-scheme: dark) { + .privacy-policy-content { + color: #e0e0e0; + + h1 { + color: #64b5f6; + border-bottom-color: #64b5f6; + } + + h2 { + color: #bdbdbd; + border-left-color: #64b5f6; + } + + h3 { + color: #9e9e9e; + } + + .last-updated { + color: #999; + } + + section.acknowledgment { + background-color: #2e2e2e; + border-left-color: #ffb74d; + + h2 { + color: #ffb74d; + border-left-color: #ffb74d; + } + } + + a { + color: #64b5f6; + } + + strong { + color: #64b5f6 !important; + } + } +} + +// Responsive design +@media (max-width: 768px) { + .privacy-policy-container { + padding: 15px; + } + + .privacy-policy-content { + h1 { + font-size: 1.8rem; + } + + h2 { + font-size: 1.4rem; + margin-top: 25px; + } + + section.acknowledgment { + padding: 15px; + } + } +} diff --git a/src/app/components/authenticated-v2/subpages/privacy-policy-page/privacy-policy-page.component.spec.ts b/src/app/components/authenticated-v2/subpages/privacy-policy-page/privacy-policy-page.component.spec.ts new file mode 100644 index 00000000..8c83195a --- /dev/null +++ b/src/app/components/authenticated-v2/subpages/privacy-policy-page/privacy-policy-page.component.spec.ts @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2023 D2ArmorPicker by Mijago. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { PrivacyPolicyPageComponent } from "./privacy-policy-page.component"; + +describe("PrivacyPolicyPageComponent", () => { + let component: PrivacyPolicyPageComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [PrivacyPolicyPageComponent], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(PrivacyPolicyPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should display privacy policy content", () => { + const compiled = fixture.nativeElement; + expect(compiled.querySelector("h1")).toBeTruthy(); + expect(compiled.querySelector("h1").textContent).toContain("Privacy Policy"); + }); + + it("should contain required sections", () => { + const compiled = fixture.nativeElement; + const sections = compiled.querySelectorAll("section h2"); + const sectionTitles = Array.from(sections).map((section: any) => section.textContent.trim()); + + expect(sectionTitles).toContain("Information We Collect"); + expect(sectionTitles).toContain("How We Use Your Information"); + expect(sectionTitles).toContain("Data Storage and Security"); + expect(sectionTitles).toContain("Your Rights"); + expect(sectionTitles).toContain("Contact Us"); + }); +}); diff --git a/src/app/components/authenticated-v2/components/changelog-dialog-controller/changelog-dialog-controller.component.ts b/src/app/components/authenticated-v2/subpages/privacy-policy-page/privacy-policy-page.component.ts similarity index 60% rename from src/app/components/authenticated-v2/components/changelog-dialog-controller/changelog-dialog-controller.component.ts rename to src/app/components/authenticated-v2/subpages/privacy-policy-page/privacy-policy-page.component.ts index 777347f9..670a4d5f 100644 --- a/src/app/components/authenticated-v2/components/changelog-dialog-controller/changelog-dialog-controller.component.ts +++ b/src/app/components/authenticated-v2/subpages/privacy-policy-page/privacy-policy-page.component.ts @@ -15,16 +15,13 @@ * along with this program. If not, see . */ -import { AfterViewInit, Component } from "@angular/core"; -import { ChangelogService } from "../../../../services/changelog.service"; +import { Component } from "@angular/core"; @Component({ - selector: "app-changelog-dialog-controller", - templateUrl: "./changelog-dialog-controller.component.html", + selector: "app-privacy-policy-page", + templateUrl: "./privacy-policy-page.component.html", + styleUrls: ["./privacy-policy-page.component.scss"], }) -export class ChangelogDialogControllerComponent implements AfterViewInit { - constructor(public changelog: ChangelogService) {} - ngAfterViewInit(): void { - if (this.changelog.mustShowChangelog) this.changelog.openChangelogDialog(); - } +export class PrivacyPolicyPageComponent { + constructor() {} } diff --git a/src/app/components/handle-bungie-login/handle-bungie-login.component.ts b/src/app/components/handle-bungie-login/handle-bungie-login.component.ts index 00cdcba0..635b60a8 100644 --- a/src/app/components/handle-bungie-login/handle-bungie-login.component.ts +++ b/src/app/components/handle-bungie-login/handle-bungie-login.component.ts @@ -16,9 +16,10 @@ */ import { Component, OnInit } from "@angular/core"; -import { NGXLogger } from "ngx-logger"; +import { LoggingProxyService } from "../../services/logging-proxy.service"; import { ActivatedRoute, Router } from "@angular/router"; -import { AuthService } from "../../services/auth.service"; +import { HttpClientService } from "../../services/http-client.service"; +import { UserInformationService } from "../../services/user-information.service"; @Component({ selector: "app-handle-bungie-login", @@ -29,8 +30,9 @@ export class HandleBungieLoginComponent implements OnInit { constructor( private activatedRoute: ActivatedRoute, private router: Router, - private loginService: AuthService, - private logger: NGXLogger + private httpClient: HttpClientService, + private userInfo: UserInformationService, + private logger: LoggingProxyService ) {} ngOnInit(): void { @@ -38,25 +40,65 @@ export class HandleBungieLoginComponent implements OnInit { let code = params["code"]; if (window.location.search.indexOf("?code=") > -1) code = window.location.search.substr(6); - if (!code) return; + if (!code) { + this.logger.warn( + "HandleBungieLoginComponent", + "ngAfterViewInit", + "No OAuth code found, redirecting to login" + ); + await this.router.navigate(["/login"]); + return; + } this.logger.info( "HandleBungieLoginComponent", - "ngOnInit", - "Code: " + JSON.stringify({ code }) + "ngAfterViewInit", + // replace code with asterisks to avoid leaking it in logs + "Code: " + code.replace(/./g, "*") ); - this.loginService.authCode = code; + this.httpClient.authCode = code; this.logger.info( "HandleBungieLoginComponent", - "ngOnInit", + "ngAfterViewInit", "Generate tokens with the new code" ); - await this.loginService.generateTokens(); - this.logger.info("HandleBungieLoginComponent", "ngOnInit", "Now navigate to /"); - await this.router.navigate(["/"]); + try { + const tokenGenerationSuccess = await this.httpClient.generateTokens(); + + if (tokenGenerationSuccess && this.httpClient.isAuthenticated()) { + this.logger.info( + "HandleBungieLoginComponent", + "ngAfterViewInit", + "Authentication successful, initializing user data" + ); + + // Initialize user information service for authenticated user + this.userInfo.initializeForAuthenticatedUser(); + + // Clear the auth code from localStorage after successful token generation + this.httpClient.authCode = null; + // Use Angular router navigation with replaceUrl to clean up the URL history + await this.router.navigate(["/"], { replaceUrl: true }); + } else { + this.logger.error( + "HandleBungieLoginComponent", + "ngAfterViewInit", + "Token generation failed, navigating to login" + ); + await this.router.navigate(["/login"]); + } + } catch (error) { + this.logger.error( + "HandleBungieLoginComponent", + "ngAfterViewInit", + "Error during token generation", + error + ); + await this.router.navigate(["/login"]); + } }); } } diff --git a/src/app/components/login/login.component.css b/src/app/components/login/login.component.css index 310049ba..198638b8 100644 --- a/src/app/components/login/login.component.css +++ b/src/app/components/login/login.component.css @@ -51,7 +51,7 @@ mat-card { max-width: 800px; width: 100%; margin: 0; - backdrop-filter: blur(10px); + /*backdrop-filter: blur(10px);*/ border-radius: 16px; box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3); } @@ -170,6 +170,28 @@ mat-card { position: relative; /* Reset positioning for login button */ } +.login-footer { + text-align: center; + margin-top: 20px; + padding-top: 15px; + border-top: 1px solid rgba(0, 0, 0, 0.1); +} + +.privacy-link { + font-size: 0.9rem; + color: rgba(0, 0, 0, 0.6); + margin: 0; +} + +.privacy-link a { + color: #1976d2; + text-decoration: none; +} + +.privacy-link a:hover { + text-decoration: underline; +} + /* Responsive Design */ @media (max-width: 768px) { .login-container { diff --git a/src/app/components/login/login.component.html b/src/app/components/login/login.component.html index b608f658..c5de8062 100644 --- a/src/app/components/login/login.component.html +++ b/src/app/components/login/login.component.html @@ -55,6 +55,13 @@

{{ item.title }}

Sign in with Bungie + + diff --git a/src/app/components/login/login.component.ts b/src/app/components/login/login.component.ts index b0e1686a..305754af 100644 --- a/src/app/components/login/login.component.ts +++ b/src/app/components/login/login.component.ts @@ -51,6 +51,26 @@ export class LoginComponent implements OnInit, OnDestroy { ngOnInit() { this.startAutoScroll(); + // remove extraneous data from localStorage + const openReplayUuid = localStorage.getItem("__openreplay_uuid"); + const clarityCharacterStats = localStorage.getItem("clarity-character-stats"); + const clarityCharacterStatsVersion = localStorage.getItem("clarity-character-stats-version"); + const d2apChangelogVersionLastRead = localStorage.getItem("d2ap-changelogVersion-lastRead"); + const d2apDbLastName = localStorage.getItem("d2ap-db-lastName"); + const d2apManifestLastDate = localStorage.getItem("d2ap-manifest-lastDate"); + const d2apManifestLastVersion = localStorage.getItem("d2ap-manifest-lastVersion"); + localStorage.clear(); + if (openReplayUuid) localStorage.setItem("__openreplay_uuid", openReplayUuid); + if (clarityCharacterStats) + localStorage.setItem("clarity-character-stats", clarityCharacterStats); + if (clarityCharacterStatsVersion) + localStorage.setItem("clarity-character-stats-version", clarityCharacterStatsVersion); + if (d2apChangelogVersionLastRead) + localStorage.setItem("d2ap-changelogVersion-lastRead", d2apChangelogVersionLastRead); + if (d2apDbLastName) localStorage.setItem("d2ap-db-lastName", d2apDbLastName); + if (d2apManifestLastDate) localStorage.setItem("d2ap-manifest-lastDate", d2apManifestLastDate); + if (d2apManifestLastVersion) + localStorage.setItem("d2ap-manifest-lastVersion", d2apManifestLastVersion); } ngOnDestroy() { diff --git a/src/app/components/privacy-policy/privacy-policy.component.html b/src/app/components/privacy-policy/privacy-policy.component.html new file mode 100644 index 00000000..2983c20b --- /dev/null +++ b/src/app/components/privacy-policy/privacy-policy.component.html @@ -0,0 +1,158 @@ +
+
+

Privacy Policy

+

Last Updated: February 11, 2026

+ +
+

Introduction

+

+ Welcome to D2ArmorPicker. This Privacy Policy explains how this service collects and uses + your personal information. D2ArmorPicker is committed to being transparent about data + practices. +

+
+ +
+

Information We Collect

+ +

Personal Information

+

When you use D2ArmorPicker, we may collect the following types of information:

+
    +
  • Bungie Account Information: Your Bungie username and associated game + data when you authenticate with our service
  • +
  • IP Address: Your IP address for analytics purposes
  • +
  • Usage Data: Information about how you interact with our service, + including pages visited and features used
  • +
+ +

Destiny 2 Game Data

+

+ When you connect your Bungie account, this service accesses your Destiny 2 character + information, armor pieces, and related game data through the Bungie API. This information is + used solely to provide armor optimization services. +

+
+ +
+

How We Use Your Information

+

The collected information is used for the following purposes:

+
    +
  • To provide and maintain services
  • +
  • To authenticate your identity and ensure access to your game data
  • +
  • To analyze usage patterns and improve the service
  • +
  • To troubleshoot technical issues and provide support
  • +
+
+ +
+

Data Storage and Security

+

+ Game data retrieved from the Bungie API is processed in real-time and may be cached + temporarily to improve performance. Sensitive account credentials are not stored. +

+

We take reasonable measures to protect your information from unauthorized access, use, or + disclosure.

+

Information of usage patterns, errors and activity may be collected to improve the + service.

+
+ +
+

Data Sharing

+

+ Your personal information is not sold, traded, or otherwise transferred to third parties + except in the following circumstances: +

+
    +
  • When required by law or to comply with legal processes
  • +
  • To protect the rights, privacy, safety, or property of this service
  • +
+

+ Third-party services may be used for analytics and error tracking. These services are bound + by their own privacy policies and data processing agreements. +

+
+ +
+

Cookies and Local Storage

+

+ This service uses browser local storage to save your preferences, settings, and temporary + data to enhance your experience. This information remains on your device and can be cleared + through your browser settings. +

+
+ +
+

Third-Party Links

+

+ This service may contain links to third-party websites or services. D2ArmorPicker is not + responsible for the privacy practices of these external sites. You are encouraged to review + their privacy policies. +

+
+ +
+

Changes to This Privacy Policy

+

+ This Privacy Policy may be updated from time to time. You will be notified of any changes by + posting the new Privacy Policy on this page and updating the "Last Updated" date. +

+
+ +
+

Contact Us

+

+ If you have any questions about this Privacy Policy or data practices, please reach out + through the following channels: +

+ +
+ +
+

Bungie.net API

+

+ This service uses the Bungie.net API. Bungie's Privacy Policy applies to data accessed + through their API. You can review Bungie's Privacy Policy at + https://www.bungie.net/en/View/privacypolicy. +

+

OpenReplay

+

+ This service uses OpenReplay for session replay and analytics. OpenReplay's Privacy Policy + applies to data collected through their services. You can review OpenReplay's Privacy Policy + at + https://openreplay.com/privacy. +

+

Sentry

+

+ This service uses Sentry for error tracking and monitoring. Sentry's Privacy Policy applies + to data collected through their services. You can review Sentry's Privacy Policy at + https://sentry.io/privacy/. +

+
+ + +
+
diff --git a/src/app/components/privacy-policy/privacy-policy.component.scss b/src/app/components/privacy-policy/privacy-policy.component.scss new file mode 100644 index 00000000..a5d66af9 --- /dev/null +++ b/src/app/components/privacy-policy/privacy-policy.component.scss @@ -0,0 +1,180 @@ +.privacy-policy-container { + padding: 20px; + max-width: 800px; + margin: 0 auto; +} + +.privacy-policy-content { + line-height: 1.6; + color: #333; + + h1 { + color: #1976d2; + border-bottom: 2px solid #1976d2; + padding-bottom: 10px; + margin-bottom: 20px; + } + + h2 { + color: #424242; + margin-top: 30px; + margin-bottom: 15px; + border-left: 4px solid #1976d2; + padding-left: 12px; + } + + h3 { + color: #616161; + margin-top: 20px; + margin-bottom: 10px; + } + + .last-updated { + font-style: italic; + color: #666; + margin-bottom: 30px; + } + + p { + margin-bottom: 15px; + text-align: justify; + } + + ul { + margin-bottom: 15px; + padding-left: 20px; + + li { + margin-bottom: 8px; + + strong { + color: #1976d2; + } + } + } + + section { + margin-bottom: 25px; + + &.acknowledgment { + background-color: #f5f5f5; + padding: 20px; + border-radius: 8px; + border-left: 4px solid #ff9800; + margin-top: 40px; + + h2 { + color: #ff9800; + border-left-color: #ff9800; + margin-top: 0; + } + } + } + + a { + color: #1976d2; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + .back-to-login { + margin-top: 40px; + padding-top: 20px; + border-top: 1px solid #e0e0e0; + + .back-link { + display: inline-block; + padding: 10px 20px; + background-color: #1976d2; + color: white; + text-decoration: none; + border-radius: 4px; + transition: background-color 0.3s ease; + + &:hover { + background-color: #1565c0; + text-decoration: none; + } + } + } +} + +// Dark theme support +@media (prefers-color-scheme: dark) { + .privacy-policy-content { + color: #e0e0e0; + + h1 { + color: #64b5f6; + border-bottom-color: #64b5f6; + } + + h2 { + color: #bdbdbd; + border-left-color: #64b5f6; + } + + h3 { + color: #9e9e9e; + } + + .last-updated { + color: #999; + } + + section.acknowledgment { + background-color: #2e2e2e; + border-left-color: #ffb74d; + + h2 { + color: #ffb74d; + border-left-color: #ffb74d; + } + } + + a { + color: #64b5f6; + } + + strong { + color: #64b5f6 !important; + } + + .back-to-login { + border-top-color: #424242; + + .back-link { + background-color: #64b5f6; + + &:hover { + background-color: #42a5f5; + } + } + } + } +} + +// Responsive design +@media (max-width: 768px) { + .privacy-policy-container { + padding: 15px; + } + + .privacy-policy-content { + h1 { + font-size: 1.8rem; + } + + h2 { + font-size: 1.4rem; + margin-top: 25px; + } + + section.acknowledgment { + padding: 15px; + } + } +} diff --git a/src/app/components/privacy-policy/privacy-policy.component.ts b/src/app/components/privacy-policy/privacy-policy.component.ts new file mode 100644 index 00000000..40c4d1f8 --- /dev/null +++ b/src/app/components/privacy-policy/privacy-policy.component.ts @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 D2ArmorPicker by Mijago. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { Component } from "@angular/core"; + +@Component({ + selector: "app-privacy-policy", + templateUrl: "./privacy-policy.component.html", + styleUrls: ["./privacy-policy.component.scss"], +}) +export class PrivacyPolicyComponent { + constructor() {} +} diff --git a/src/app/data/buildConfiguration.ts b/src/app/data/buildConfiguration.ts index 7075e6d3..d81ee518 100644 --- a/src/app/data/buildConfiguration.ts +++ b/src/app/data/buildConfiguration.ts @@ -26,6 +26,7 @@ import { EnumDictionary } from "./types/EnumDictionary"; import { ModifierType } from "./enum/modifierType"; import { ModOptimizationStrategy } from "./enum/mod-optimization-strategy"; import { DestinyClass } from "bungie-api-ts/destiny2/interfaces"; +import { environment } from "src/environments/environment"; export function getDefaultStatDict( value: number @@ -78,8 +79,9 @@ export class BuildConfiguration { // New compact stat mod limits (global, not per-slot) statModLimits: StatModLimits = { maxMods: 5, maxMajorMods: 5 }; + calculateTierFiveTuning = !environment.production; putArtificeMods = true; - useFotlArmor = true; + useFotlArmor = false; allowBlueArmorPieces = true; // Allow armor 2.0, which is the legacy armor system allowLegacyLegendaryArmor = true; @@ -96,6 +98,7 @@ export class BuildConfiguration { onlyUseMasterworkedLegendaries = false; modOptimizationStrategy: ModOptimizationStrategy = ModOptimizationStrategy.None; limitParsedResults = true; // Limits the amount of results that are parsed. This looses some results, but solves memory issues + earlyAbortClassItems = true; // High-Speed setting tryLimitWastedStats = false; onlyShowResultsWithNoWastedStats = false; @@ -112,6 +115,7 @@ export class BuildConfiguration { enabledMods: [], disabledItems: [], addConstent1Health: false, + calculateTierFiveTuning: !environment.production, assumeEveryLegendaryIsArtifice: false, assumeEveryExoticIsArtifice: false, assumeClassItemIsArtifice: false, @@ -130,6 +134,7 @@ export class BuildConfiguration { assumeLegendariesMasterworked: true, assumeExoticsMasterworked: true, limitParsedResults: true, + earlyAbortClassItems: true, modOptimizationStrategy: ModOptimizationStrategy.None, tryLimitWastedStats: false, onlyShowResultsWithNoWastedStats: false, diff --git a/src/app/data/changelog.ts b/src/app/data/changelog.ts index 8169e208..5c6c4bc8 100644 --- a/src/app/data/changelog.ts +++ b/src/app/data/changelog.ts @@ -33,6 +33,141 @@ export const CHANGELOG_DATA: { clearManifest?: boolean; entries: ChangelogEntry[]; }[] = [ + { + version: "2.9.12", + date: "February, 2026", + clearManifest: false, + entries: [ + { + type: ChangelogEntryType.MODIFIED, + text: "Updated worker spawn logic of armor calculator, to be more readable and for potential future optimizations.", + }, + { + type: ChangelogEntryType.MODIFIED, + text: "Added more logging to the armor calculator worker, to help with debugging if it crashes.", + }, + { + type: ChangelogEntryType.MODIFIED, + text: "Fixed progress bar for armor calculation, it should now reflect the actual progress of the calculation process.", + }, + ], + }, + { + version: "2.9.11", + date: "February 19, 2026", + clearManifest: true, + entries: [ + { + type: ChangelogEntryType.MODIFIED, + text: "Changed the logic for manifest download to use sqlite (if WASM fails, fallback to old JSON method), reduces calls to the API, and memory usage, fixing crashes and performance issues, primarily for iOS users.", + issues: [], + }, + { + type: ChangelogEntryType.MODIFIED, + text: "Better view for tablet users? Please give feedback on this, screen sizes varies a lot, share your device model/characteristics.", + issues: [], + }, + ], + }, + { + version: "2.9.10", + date: "February 15, 2026", + clearManifest: true, + entries: [ + { + type: ChangelogEntryType.ADD, + text: "Add a privacy notice, this is due to the session replay added, to help with user experience and debugging. :)", + issues: [], + }, + { + type: ChangelogEntryType.ADD, + text: "Add toggle for T5 tuning calculations.", + issues: [], + }, + { + type: ChangelogEntryType.MODIFIED, + text: "Changed session replay solution.", + issues: [], + }, + { + type: ChangelogEntryType.MODIFIED, + text: "Refactored the logic for validating the manifest and inventory to reduce calls and time validating with the BungieAPI.", + issues: [], + }, + { + type: ChangelogEntryType.ADD, + text: "Added advanced setting 'High Speed Mode' that will skip calculations in certain places. May result in missing results, though.", + issues: [], + }, + { + type: ChangelogEntryType.MODIFIED, + text: "Clustering is updated and re-enabled.", + issues: [], + }, + { + type: ChangelogEntryType.ADD, + text: "Add toggle for T5 tuning calculations.", + issues: [], + }, + { + type: ChangelogEntryType.MODIFIED, + text: "Changed session replay solution.", + issues: [], + }, + { + type: ChangelogEntryType.MODIFIED, + text: "Refactored the logic for validating the manifest and inventory to reduce calls and time validating with the BungieAPI.", + issues: [], + }, + { + type: ChangelogEntryType.MODIFIED, + text: "Consolidated the armor initialization logic", + issues: [], + }, + { + type: ChangelogEntryType.MODIFIED, + text: "Refactored and consolidated the API token calls", + issues: [], + }, + { + type: ChangelogEntryType.MODIFIED, + text: "Moved calculator logic to its own service, to improve code structure and have better handling of when to recalculate", + issues: [], + }, + { + type: ChangelogEntryType.MODIFIED, + text: "Implemented a delay for loading the full changelog for iOS users, to help with crashes", + issues: [], + }, + ], + }, + { + version: "2.9.7", + date: "August 27, 2025", + clearManifest: false, + entries: [ + { + type: ChangelogEntryType.MODIFIED, + text: "Changed logic for Manifest and Armor Initialization to avoid race conditions.", + issues: [], + }, + { + type: ChangelogEntryType.MODIFIED, + text: "Changed logic to validate Manifest cache, to reduce calls and time validating with the BungieAPI.", + issues: [], + }, + { + type: ChangelogEntryType.MODIFIED, + text: "Only retrigger armor calculation when vendor data is updated if the vendor data is going to be used", + issues: [], + }, + { + type: ChangelogEntryType.MODIFIED, + text: "Adapted the algorithm so that it allows negative stat values.", + issues: [], + }, + ], + }, { version: "2.9.6", date: "July 31, 2025", diff --git a/src/app/data/commonFunctions.ts b/src/app/data/commonFunctions.ts index bd2db0e7..683eed5c 100644 --- a/src/app/data/commonFunctions.ts +++ b/src/app/data/commonFunctions.ts @@ -1,5 +1,26 @@ import { isEqual as _isEqual } from "lodash"; +/** + * Detects whether the current device is likely a mobile / tablet. + * Uses the User-Agent Client Hints API when available, then falls + * back to the legacy `userAgent` string. + */ +function isMobileDevice(): boolean { + // Modern: UA Client Hints (Chromium 89+) + if ((navigator as any).userAgentData?.mobile) { + return true; + } + // Legacy fallback + return /Android|iPhone|iPad|iPod|Mobile|Tablet/i.test(navigator.userAgent); +} + +export function calculateCPUConcurrency(): number { + const logicalCores = navigator?.hardwareConcurrency || 4; + const estimatedPhysicalCores = isMobileDevice() ? logicalCores : Math.ceil(logicalCores / 2); + // Reserve at least 1 core for the main thread, but ensure we use at least 3 cores for calculations on desktop + return Math.max(3, estimatedPhysicalCores - 1); +} + export function getDifferences( object1: T, object2: T @@ -31,3 +52,31 @@ export function getDifferences( return { changes: differences }; } + +export function getHumanReadableDifferences(object1: T, object2: T): string { + const diff = getDifferences(object1, object2); + const changes = diff.changes; + + if (Object.keys(changes).length === 0) { + return "No changes"; + } + + const changeStrings = Object.entries(changes) + .map(([key, change]) => { + const c = change as { from: string; to: string } | undefined; + if (c) { + // Remove quotes from JSON stringified values for cleaner display + const from = c.from.replace(/^"(.*)"$/, "$1"); + const to = c.to.replace(/^"(.*)"$/, "$1"); + return `${key}: ${from} -> ${to}`; + } + return ""; + }) + .filter((str) => str.length > 0); + + if (changeStrings.length === 1) { + return changeStrings[0]; + } + + return changeStrings.join("\n"); +} diff --git a/src/app/data/database.ts b/src/app/data/database.ts index 5be1601a..7eb91c6d 100644 --- a/src/app/data/database.ts +++ b/src/app/data/database.ts @@ -22,13 +22,28 @@ import { IManifestCollectible } from "./types/IManifestCollectible"; import { IVendorInfo } from "./types/IVendorInfo"; import { IVendorItemSubscreen } from "./types/IVendorItemSubscreen"; import { DestinyEquipableItemSetDefinition } from "../services/bungie-api.service"; -import { DestinySandboxPerkDefinition } from "bungie-api-ts/destiny2"; +import { + DestinySandboxPerkDefinition, + DestinyInventoryItemDefinition, +} from "bungie-api-ts/destiny2"; -export class Database extends Dexie { +const schema = { + manifestArmor: "id++, hash, isExotic", + inventoryArmor: + "id++, itemInstanceId, isExotic, hash, name, masterworked, clazz, slot, source, gearSetHash, perk, [clazz+gearSetHash]", + sandboxPerkDefinition: "id++, hash", + equipableItemSetDefinition: "id++, hash, setPerks, setItems", + sandboxAbilities: "id++, hash", + manifestCollectibles: "id++, hash", + vendorNames: "id++, vendorId", + vendorItemSubscreen: "itemHash", +}; +export class D2APDatabase extends Dexie { manifestArmor!: Dexie.Table; inventoryArmor!: Dexie.Table; equipableItemSetDefinition!: Dexie.Table; sandboxPerkDefinition!: Dexie.Table; + sandboxAbilities!: Dexie.Table; // Maps the collectible hash to the inventory item hash manifestCollectibles!: Dexie.Table; @@ -38,15 +53,6 @@ export class Database extends Dexie { constructor() { super("d2armorpicker-v2"); - this.version(31).stores({ - manifestArmor: "id++, hash, isExotic", - inventoryArmor: - "id++, itemInstanceId, isExotic, hash, name, masterworked, clazz, slot, source, gearSetHash, perk, [clazz+gearSetHash]", - sandboxPerkDefinition: "id++, hash", - equipableItemSetDefinition: "id++, hash, setPerks, setItems", - manifestCollectibles: "id++, hash", - vendorNames: "id++, vendorId", - vendorItemSubscreen: "itemHash", - }); + this.version(33).stores(schema); } } diff --git a/src/app/data/enum/armor-stat.ts b/src/app/data/enum/armor-stat.ts index 4252ae18..a5e16cf2 100644 --- a/src/app/data/enum/armor-stat.ts +++ b/src/app/data/enum/armor-stat.ts @@ -244,6 +244,15 @@ export const MapAlternativeToArmorPerkOrSlot: EnumDictionary = { + [ArmorStatHashes[ArmorStat.StatWeapon]]: ArmorStat.StatWeapon, + [ArmorStatHashes[ArmorStat.StatHealth]]: ArmorStat.StatHealth, + [ArmorStatHashes[ArmorStat.StatClass]]: ArmorStat.StatClass, + [ArmorStatHashes[ArmorStat.StatGrenade]]: ArmorStat.StatGrenade, + [ArmorStatHashes[ArmorStat.StatSuper]]: ArmorStat.StatSuper, + [ArmorStatHashes[ArmorStat.StatMelee]]: ArmorStat.StatMelee, +}; + export const MapAlternativeSocketTypeToArmorPerkOrSlot: EnumDictionary = { [1719555937]: ArmorPerkOrSlot.SlotArtifice, [2770223926]: ArmorPerkOrSlot.SlotArtifice, diff --git a/src/app/data/generated/precalculatedModCombinations.ts b/src/app/data/generated/precalculatedModCombinations.ts index d8285391..ab6f7328 100644 --- a/src/app/data/generated/precalculatedModCombinations.ts +++ b/src/app/data/generated/precalculatedModCombinations.ts @@ -17,565 +17,567 @@ // Dicts: [artifice, minor, major, total] -export const precalculatedModCombinations: { [key: number]: [number, number, number, number][] } = { +export const precalculatedModCombinations: { + [key: number]: [number, number, number, number, number, number][]; +} = { 1: [ - [1, 0, 0, 3], - [0, 1, 0, 5], - [0, 0, 1, 10], + [1, 0, 0, 0, 0, 3], + [0, 1, 0, 0, 0, 5], + [0, 0, 1, 0, 0, 10], ], 2: [ - [1, 0, 0, 3], - [0, 1, 0, 5], - [0, 0, 1, 10], + [1, 0, 0, 0, 0, 3], + [0, 1, 0, 0, 0, 5], + [0, 0, 1, 0, 0, 10], ], 3: [ - [1, 0, 0, 3], - [0, 1, 0, 5], - [0, 0, 1, 10], + [1, 0, 0, 0, 0, 3], + [0, 1, 0, 0, 0, 5], + [0, 0, 1, 0, 0, 10], ], 4: [ - [0, 1, 0, 5], - [2, 0, 0, 6], - [0, 0, 1, 10], + [0, 1, 0, 0, 0, 5], + [2, 0, 0, 0, 0, 6], + [0, 0, 1, 0, 0, 10], ], 5: [ - [0, 1, 0, 5], - [2, 0, 0, 6], - [0, 0, 1, 10], + [0, 1, 0, 0, 0, 5], + [2, 0, 0, 0, 0, 6], + [0, 0, 1, 0, 0, 10], ], 6: [ - [2, 0, 0, 6], - [1, 1, 0, 8], - [0, 2, 0, 10], - [0, 0, 1, 10], + [2, 0, 0, 0, 0, 6], + [1, 1, 0, 0, 0, 8], + [0, 2, 0, 0, 0, 10], + [0, 0, 1, 0, 0, 10], ], 7: [ - [1, 1, 0, 8], - [3, 0, 0, 9], - [0, 2, 0, 10], - [0, 0, 1, 10], + [1, 1, 0, 0, 0, 8], + [3, 0, 0, 0, 0, 9], + [0, 2, 0, 0, 0, 10], + [0, 0, 1, 0, 0, 10], ], 8: [ - [1, 1, 0, 8], - [3, 0, 0, 9], - [0, 2, 0, 10], - [0, 0, 1, 10], + [1, 1, 0, 0, 0, 8], + [3, 0, 0, 0, 0, 9], + [0, 2, 0, 0, 0, 10], + [0, 0, 1, 0, 0, 10], ], 9: [ - [3, 0, 0, 9], - [0, 2, 0, 10], - [0, 0, 1, 10], - [2, 1, 0, 11], + [3, 0, 0, 0, 0, 9], + [0, 2, 0, 0, 0, 10], + [0, 0, 1, 0, 0, 10], + [2, 1, 0, 0, 0, 11], ], 10: [ - [0, 2, 0, 10], - [0, 0, 1, 10], - [2, 1, 0, 11], - [4, 0, 0, 12], + [0, 2, 0, 0, 0, 10], + [0, 0, 1, 0, 0, 10], + [2, 1, 0, 0, 0, 11], + [4, 0, 0, 0, 0, 12], ], 11: [ - [2, 1, 0, 11], - [4, 0, 0, 12], - [1, 2, 0, 13], - [1, 0, 1, 13], - [0, 3, 0, 15], - [0, 1, 1, 15], - [0, 0, 2, 20], + [2, 1, 0, 0, 0, 11], + [4, 0, 0, 0, 0, 12], + [1, 2, 0, 0, 0, 13], + [1, 0, 1, 0, 0, 13], + [0, 3, 0, 0, 0, 15], + [0, 1, 1, 0, 0, 15], + [0, 0, 2, 0, 0, 20], ], 12: [ - [4, 0, 0, 12], - [1, 2, 0, 13], - [1, 0, 1, 13], - [3, 1, 0, 14], - [0, 3, 0, 15], - [0, 1, 1, 15], - [0, 0, 2, 20], + [4, 0, 0, 0, 0, 12], + [1, 2, 0, 0, 0, 13], + [1, 0, 1, 0, 0, 13], + [3, 1, 0, 0, 0, 14], + [0, 3, 0, 0, 0, 15], + [0, 1, 1, 0, 0, 15], + [0, 0, 2, 0, 0, 20], ], 13: [ - [1, 2, 0, 13], - [1, 0, 1, 13], - [3, 1, 0, 14], - [5, 0, 0, 15], - [0, 3, 0, 15], - [0, 1, 1, 15], - [0, 0, 2, 20], + [1, 2, 0, 0, 0, 13], + [1, 0, 1, 0, 0, 13], + [3, 1, 0, 0, 0, 14], + [5, 0, 0, 0, 0, 15], + [0, 3, 0, 0, 0, 15], + [0, 1, 1, 0, 0, 15], + [0, 0, 2, 0, 0, 20], ], 14: [ - [3, 1, 0, 14], - [5, 0, 0, 15], - [0, 3, 0, 15], - [0, 1, 1, 15], - [2, 2, 0, 16], - [2, 0, 1, 16], - [0, 0, 2, 20], + [3, 1, 0, 0, 0, 14], + [5, 0, 0, 0, 0, 15], + [0, 3, 0, 0, 0, 15], + [0, 1, 1, 0, 0, 15], + [2, 2, 0, 0, 0, 16], + [2, 0, 1, 0, 0, 16], + [0, 0, 2, 0, 0, 20], ], 15: [ - [5, 0, 0, 15], - [0, 3, 0, 15], - [0, 1, 1, 15], - [2, 2, 0, 16], - [2, 0, 1, 16], - [4, 1, 0, 17], - [0, 0, 2, 20], + [5, 0, 0, 0, 0, 15], + [0, 3, 0, 0, 0, 15], + [0, 1, 1, 0, 0, 15], + [2, 2, 0, 0, 0, 16], + [2, 0, 1, 0, 0, 16], + [4, 1, 0, 0, 0, 17], + [0, 0, 2, 0, 0, 20], ], 16: [ - [2, 2, 0, 16], - [2, 0, 1, 16], - [4, 1, 0, 17], - [1, 3, 0, 18], - [1, 1, 1, 18], - [0, 4, 0, 20], - [0, 2, 1, 20], - [0, 0, 2, 20], + [2, 2, 0, 0, 0, 16], + [2, 0, 1, 0, 0, 16], + [4, 1, 0, 0, 0, 17], + [1, 3, 0, 0, 0, 18], + [1, 1, 1, 0, 0, 18], + [0, 4, 0, 0, 0, 20], + [0, 2, 1, 0, 0, 20], + [0, 0, 2, 0, 0, 20], ], 17: [ - [4, 1, 0, 17], - [1, 3, 0, 18], - [1, 1, 1, 18], - [3, 2, 0, 19], - [3, 0, 1, 19], - [0, 4, 0, 20], - [0, 2, 1, 20], - [0, 0, 2, 20], + [4, 1, 0, 0, 0, 17], + [1, 3, 0, 0, 0, 18], + [1, 1, 1, 0, 0, 18], + [3, 2, 0, 0, 0, 19], + [3, 0, 1, 0, 0, 19], + [0, 4, 0, 0, 0, 20], + [0, 2, 1, 0, 0, 20], + [0, 0, 2, 0, 0, 20], ], 18: [ - [1, 1, 1, 18], - [1, 3, 0, 18], - [3, 2, 0, 19], - [3, 0, 1, 19], - [5, 1, 0, 20], - [0, 4, 0, 20], - [0, 2, 1, 20], - [0, 0, 2, 20], + [1, 1, 1, 0, 0, 18], + [1, 3, 0, 0, 0, 18], + [3, 2, 0, 0, 0, 19], + [3, 0, 1, 0, 0, 19], + [5, 1, 0, 0, 0, 20], + [0, 4, 0, 0, 0, 20], + [0, 2, 1, 0, 0, 20], + [0, 0, 2, 0, 0, 20], ], 19: [ - [3, 2, 0, 19], - [3, 0, 1, 19], - [5, 1, 0, 20], - [0, 4, 0, 20], - [0, 2, 1, 20], - [0, 0, 2, 20], - [2, 3, 0, 21], - [2, 1, 1, 21], + [3, 2, 0, 0, 0, 19], + [3, 0, 1, 0, 0, 19], + [5, 1, 0, 0, 0, 20], + [0, 4, 0, 0, 0, 20], + [0, 2, 1, 0, 0, 20], + [0, 0, 2, 0, 0, 20], + [2, 3, 0, 0, 0, 21], + [2, 1, 1, 0, 0, 21], ], 20: [ - [5, 1, 0, 20], - [0, 4, 0, 20], - [0, 2, 1, 20], - [0, 0, 2, 20], - [2, 3, 0, 21], - [2, 1, 1, 21], - [4, 2, 0, 22], - [4, 0, 1, 22], + [5, 1, 0, 0, 0, 20], + [0, 4, 0, 0, 0, 20], + [0, 2, 1, 0, 0, 20], + [0, 0, 2, 0, 0, 20], + [2, 3, 0, 0, 0, 21], + [2, 1, 1, 0, 0, 21], + [4, 2, 0, 0, 0, 22], + [4, 0, 1, 0, 0, 22], ], 21: [ - [2, 3, 0, 21], - [2, 1, 1, 21], - [4, 2, 0, 22], - [4, 0, 1, 22], - [1, 4, 0, 23], - [1, 2, 1, 23], - [1, 0, 2, 23], - [0, 5, 0, 25], - [0, 3, 1, 25], - [0, 1, 2, 25], - [0, 0, 3, 30], + [2, 3, 0, 0, 0, 21], + [2, 1, 1, 0, 0, 21], + [4, 2, 0, 0, 0, 22], + [4, 0, 1, 0, 0, 22], + [1, 4, 0, 0, 0, 23], + [1, 2, 1, 0, 0, 23], + [1, 0, 2, 0, 0, 23], + [0, 5, 0, 0, 0, 25], + [0, 3, 1, 0, 0, 25], + [0, 1, 2, 0, 0, 25], + [0, 0, 3, 0, 0, 30], ], 22: [ - [4, 2, 0, 22], - [4, 0, 1, 22], - [1, 4, 0, 23], - [1, 2, 1, 23], - [1, 0, 2, 23], - [3, 3, 0, 24], - [3, 1, 1, 24], - [0, 5, 0, 25], - [0, 3, 1, 25], - [0, 1, 2, 25], - [0, 0, 3, 30], + [4, 2, 0, 0, 0, 22], + [4, 0, 1, 0, 0, 22], + [1, 4, 0, 0, 0, 23], + [1, 2, 1, 0, 0, 23], + [1, 0, 2, 0, 0, 23], + [3, 3, 0, 0, 0, 24], + [3, 1, 1, 0, 0, 24], + [0, 5, 0, 0, 0, 25], + [0, 3, 1, 0, 0, 25], + [0, 1, 2, 0, 0, 25], + [0, 0, 3, 0, 0, 30], ], 23: [ - [1, 4, 0, 23], - [1, 2, 1, 23], - [1, 0, 2, 23], - [3, 3, 0, 24], - [3, 1, 1, 24], - [5, 2, 0, 25], - [0, 5, 0, 25], - [5, 0, 1, 25], - [0, 3, 1, 25], - [0, 1, 2, 25], - [0, 0, 3, 30], + [1, 4, 0, 0, 0, 23], + [1, 2, 1, 0, 0, 23], + [1, 0, 2, 0, 0, 23], + [3, 3, 0, 0, 0, 24], + [3, 1, 1, 0, 0, 24], + [5, 2, 0, 0, 0, 25], + [0, 5, 0, 0, 0, 25], + [5, 0, 1, 0, 0, 25], + [0, 3, 1, 0, 0, 25], + [0, 1, 2, 0, 0, 25], + [0, 0, 3, 0, 0, 30], ], 24: [ - [3, 3, 0, 24], - [3, 1, 1, 24], - [5, 2, 0, 25], - [0, 5, 0, 25], - [5, 0, 1, 25], - [0, 3, 1, 25], - [0, 1, 2, 25], - [2, 4, 0, 26], - [2, 2, 1, 26], - [2, 0, 2, 26], - [0, 0, 3, 30], + [3, 3, 0, 0, 0, 24], + [3, 1, 1, 0, 0, 24], + [5, 2, 0, 0, 0, 25], + [0, 5, 0, 0, 0, 25], + [5, 0, 1, 0, 0, 25], + [0, 3, 1, 0, 0, 25], + [0, 1, 2, 0, 0, 25], + [2, 4, 0, 0, 0, 26], + [2, 2, 1, 0, 0, 26], + [2, 0, 2, 0, 0, 26], + [0, 0, 3, 0, 0, 30], ], 25: [ - [5, 2, 0, 25], - [0, 5, 0, 25], - [5, 0, 1, 25], - [0, 3, 1, 25], - [0, 1, 2, 25], - [2, 4, 0, 26], - [2, 2, 1, 26], - [2, 0, 2, 26], - [4, 3, 0, 27], - [4, 1, 1, 27], - [0, 0, 3, 30], + [5, 2, 0, 0, 0, 25], + [0, 5, 0, 0, 0, 25], + [5, 0, 1, 0, 0, 25], + [0, 3, 1, 0, 0, 25], + [0, 1, 2, 0, 0, 25], + [2, 4, 0, 0, 0, 26], + [2, 2, 1, 0, 0, 26], + [2, 0, 2, 0, 0, 26], + [4, 3, 0, 0, 0, 27], + [4, 1, 1, 0, 0, 27], + [0, 0, 3, 0, 0, 30], ], 26: [ - [2, 4, 0, 26], - [2, 2, 1, 26], - [2, 0, 2, 26], - [4, 3, 0, 27], - [4, 1, 1, 27], - [1, 5, 0, 28], - [1, 3, 1, 28], - [1, 1, 2, 28], - [0, 4, 1, 30], - [0, 2, 2, 30], - [0, 0, 3, 30], + [2, 4, 0, 0, 0, 26], + [2, 2, 1, 0, 0, 26], + [2, 0, 2, 0, 0, 26], + [4, 3, 0, 0, 0, 27], + [4, 1, 1, 0, 0, 27], + [1, 5, 0, 0, 0, 28], + [1, 3, 1, 0, 0, 28], + [1, 1, 2, 0, 0, 28], + [0, 4, 1, 0, 0, 30], + [0, 2, 2, 0, 0, 30], + [0, 0, 3, 0, 0, 30], ], 27: [ - [4, 3, 0, 27], - [4, 1, 1, 27], - [1, 5, 0, 28], - [1, 3, 1, 28], - [1, 1, 2, 28], - [3, 4, 0, 29], - [3, 2, 1, 29], - [3, 0, 2, 29], - [0, 4, 1, 30], - [0, 2, 2, 30], - [0, 0, 3, 30], + [4, 3, 0, 0, 0, 27], + [4, 1, 1, 0, 0, 27], + [1, 5, 0, 0, 0, 28], + [1, 3, 1, 0, 0, 28], + [1, 1, 2, 0, 0, 28], + [3, 4, 0, 0, 0, 29], + [3, 2, 1, 0, 0, 29], + [3, 0, 2, 0, 0, 29], + [0, 4, 1, 0, 0, 30], + [0, 2, 2, 0, 0, 30], + [0, 0, 3, 0, 0, 30], ], 28: [ - [1, 5, 0, 28], - [1, 3, 1, 28], - [1, 1, 2, 28], - [3, 4, 0, 29], - [3, 2, 1, 29], - [3, 0, 2, 29], - [5, 3, 0, 30], - [5, 1, 1, 30], - [0, 4, 1, 30], - [0, 2, 2, 30], - [0, 0, 3, 30], + [1, 5, 0, 0, 0, 28], + [1, 3, 1, 0, 0, 28], + [1, 1, 2, 0, 0, 28], + [3, 4, 0, 0, 0, 29], + [3, 2, 1, 0, 0, 29], + [3, 0, 2, 0, 0, 29], + [5, 3, 0, 0, 0, 30], + [5, 1, 1, 0, 0, 30], + [0, 4, 1, 0, 0, 30], + [0, 2, 2, 0, 0, 30], + [0, 0, 3, 0, 0, 30], ], 29: [ - [3, 4, 0, 29], - [3, 2, 1, 29], - [3, 0, 2, 29], - [5, 3, 0, 30], - [5, 1, 1, 30], - [0, 4, 1, 30], - [0, 2, 2, 30], - [0, 0, 3, 30], - [2, 5, 0, 31], - [2, 3, 1, 31], - [2, 1, 2, 31], + [3, 4, 0, 0, 0, 29], + [3, 2, 1, 0, 0, 29], + [3, 0, 2, 0, 0, 29], + [5, 3, 0, 0, 0, 30], + [5, 1, 1, 0, 0, 30], + [0, 4, 1, 0, 0, 30], + [0, 2, 2, 0, 0, 30], + [0, 0, 3, 0, 0, 30], + [2, 5, 0, 0, 0, 31], + [2, 3, 1, 0, 0, 31], + [2, 1, 2, 0, 0, 31], ], 30: [ - [5, 3, 0, 30], - [5, 1, 1, 30], - [0, 4, 1, 30], - [0, 2, 2, 30], - [0, 0, 3, 30], - [2, 5, 0, 31], - [2, 3, 1, 31], - [2, 1, 2, 31], - [4, 4, 0, 32], - [4, 2, 1, 32], - [4, 0, 2, 32], + [5, 3, 0, 0, 0, 30], + [5, 1, 1, 0, 0, 30], + [0, 4, 1, 0, 0, 30], + [0, 2, 2, 0, 0, 30], + [0, 0, 3, 0, 0, 30], + [2, 5, 0, 0, 0, 31], + [2, 3, 1, 0, 0, 31], + [2, 1, 2, 0, 0, 31], + [4, 4, 0, 0, 0, 32], + [4, 2, 1, 0, 0, 32], + [4, 0, 2, 0, 0, 32], ], 31: [ - [2, 5, 0, 31], - [2, 3, 1, 31], - [2, 1, 2, 31], - [4, 4, 0, 32], - [4, 2, 1, 32], - [4, 0, 2, 32], - [1, 4, 1, 33], - [1, 2, 2, 33], - [1, 0, 3, 33], - [0, 3, 2, 35], - [0, 1, 3, 35], - [0, 0, 4, 40], + [2, 5, 0, 0, 0, 31], + [2, 3, 1, 0, 0, 31], + [2, 1, 2, 0, 0, 31], + [4, 4, 0, 0, 0, 32], + [4, 2, 1, 0, 0, 32], + [4, 0, 2, 0, 0, 32], + [1, 4, 1, 0, 0, 33], + [1, 2, 2, 0, 0, 33], + [1, 0, 3, 0, 0, 33], + [0, 3, 2, 0, 0, 35], + [0, 1, 3, 0, 0, 35], + [0, 0, 4, 0, 0, 40], ], 32: [ - [4, 4, 0, 32], - [4, 2, 1, 32], - [4, 0, 2, 32], - [1, 4, 1, 33], - [1, 2, 2, 33], - [1, 0, 3, 33], - [3, 5, 0, 34], - [3, 3, 1, 34], - [3, 1, 2, 34], - [0, 3, 2, 35], - [0, 1, 3, 35], - [0, 0, 4, 40], + [4, 4, 0, 0, 0, 32], + [4, 2, 1, 0, 0, 32], + [4, 0, 2, 0, 0, 32], + [1, 4, 1, 0, 0, 33], + [1, 2, 2, 0, 0, 33], + [1, 0, 3, 0, 0, 33], + [3, 5, 0, 0, 0, 34], + [3, 3, 1, 0, 0, 34], + [3, 1, 2, 0, 0, 34], + [0, 3, 2, 0, 0, 35], + [0, 1, 3, 0, 0, 35], + [0, 0, 4, 0, 0, 40], ], 33: [ - [1, 4, 1, 33], - [1, 2, 2, 33], - [1, 0, 3, 33], - [3, 5, 0, 34], - [3, 3, 1, 34], - [3, 1, 2, 34], - [5, 4, 0, 35], - [5, 2, 1, 35], - [5, 0, 2, 35], - [0, 3, 2, 35], - [0, 1, 3, 35], - [0, 0, 4, 40], + [1, 4, 1, 0, 0, 33], + [1, 2, 2, 0, 0, 33], + [1, 0, 3, 0, 0, 33], + [3, 5, 0, 0, 0, 34], + [3, 3, 1, 0, 0, 34], + [3, 1, 2, 0, 0, 34], + [5, 4, 0, 0, 0, 35], + [5, 2, 1, 0, 0, 35], + [5, 0, 2, 0, 0, 35], + [0, 3, 2, 0, 0, 35], + [0, 1, 3, 0, 0, 35], + [0, 0, 4, 0, 0, 40], ], 34: [ - [3, 5, 0, 34], - [3, 3, 1, 34], - [3, 1, 2, 34], - [5, 4, 0, 35], - [5, 2, 1, 35], - [5, 0, 2, 35], - [0, 3, 2, 35], - [0, 1, 3, 35], - [2, 4, 1, 36], - [2, 2, 2, 36], - [2, 0, 3, 36], - [0, 0, 4, 40], + [3, 5, 0, 0, 0, 34], + [3, 3, 1, 0, 0, 34], + [3, 1, 2, 0, 0, 34], + [5, 4, 0, 0, 0, 35], + [5, 2, 1, 0, 0, 35], + [5, 0, 2, 0, 0, 35], + [0, 3, 2, 0, 0, 35], + [0, 1, 3, 0, 0, 35], + [2, 4, 1, 0, 0, 36], + [2, 2, 2, 0, 0, 36], + [2, 0, 3, 0, 0, 36], + [0, 0, 4, 0, 0, 40], ], 35: [ - [5, 4, 0, 35], - [5, 2, 1, 35], - [5, 0, 2, 35], - [0, 3, 2, 35], - [0, 1, 3, 35], - [2, 4, 1, 36], - [2, 2, 2, 36], - [2, 0, 3, 36], - [4, 5, 0, 37], - [4, 3, 1, 37], - [4, 1, 2, 37], - [0, 0, 4, 40], + [5, 4, 0, 0, 0, 35], + [5, 2, 1, 0, 0, 35], + [5, 0, 2, 0, 0, 35], + [0, 3, 2, 0, 0, 35], + [0, 1, 3, 0, 0, 35], + [2, 4, 1, 0, 0, 36], + [2, 2, 2, 0, 0, 36], + [2, 0, 3, 0, 0, 36], + [4, 5, 0, 0, 0, 37], + [4, 3, 1, 0, 0, 37], + [4, 1, 2, 0, 0, 37], + [0, 0, 4, 0, 0, 40], ], 36: [ - [2, 4, 1, 36], - [2, 2, 2, 36], - [2, 0, 3, 36], - [4, 5, 0, 37], - [4, 3, 1, 37], - [4, 1, 2, 37], - [1, 3, 2, 38], - [1, 1, 3, 38], - [0, 2, 3, 40], - [0, 0, 4, 40], + [2, 4, 1, 0, 0, 36], + [2, 2, 2, 0, 0, 36], + [2, 0, 3, 0, 0, 36], + [4, 5, 0, 0, 0, 37], + [4, 3, 1, 0, 0, 37], + [4, 1, 2, 0, 0, 37], + [1, 3, 2, 0, 0, 38], + [1, 1, 3, 0, 0, 38], + [0, 2, 3, 0, 0, 40], + [0, 0, 4, 0, 0, 40], ], 37: [ - [4, 5, 0, 37], - [4, 3, 1, 37], - [4, 1, 2, 37], - [1, 3, 2, 38], - [1, 1, 3, 38], - [3, 4, 1, 39], - [3, 2, 2, 39], - [3, 0, 3, 39], - [0, 2, 3, 40], - [0, 0, 4, 40], + [4, 5, 0, 0, 0, 37], + [4, 3, 1, 0, 0, 37], + [4, 1, 2, 0, 0, 37], + [1, 3, 2, 0, 0, 38], + [1, 1, 3, 0, 0, 38], + [3, 4, 1, 0, 0, 39], + [3, 2, 2, 0, 0, 39], + [3, 0, 3, 0, 0, 39], + [0, 2, 3, 0, 0, 40], + [0, 0, 4, 0, 0, 40], ], 38: [ - [1, 3, 2, 38], - [1, 1, 3, 38], - [3, 4, 1, 39], - [3, 2, 2, 39], - [3, 0, 3, 39], - [5, 5, 0, 40], - [5, 3, 1, 40], - [5, 1, 2, 40], - [0, 2, 3, 40], - [0, 0, 4, 40], + [1, 3, 2, 0, 0, 38], + [1, 1, 3, 0, 0, 38], + [3, 4, 1, 0, 0, 39], + [3, 2, 2, 0, 0, 39], + [3, 0, 3, 0, 0, 39], + [5, 5, 0, 0, 0, 40], + [5, 3, 1, 0, 0, 40], + [5, 1, 2, 0, 0, 40], + [0, 2, 3, 0, 0, 40], + [0, 0, 4, 0, 0, 40], ], 39: [ - [3, 4, 1, 39], - [3, 2, 2, 39], - [3, 0, 3, 39], - [5, 5, 0, 40], - [5, 3, 1, 40], - [5, 1, 2, 40], - [0, 2, 3, 40], - [0, 0, 4, 40], - [2, 3, 2, 41], - [2, 1, 3, 41], + [3, 4, 1, 0, 0, 39], + [3, 2, 2, 0, 0, 39], + [3, 0, 3, 0, 0, 39], + [5, 5, 0, 0, 0, 40], + [5, 3, 1, 0, 0, 40], + [5, 1, 2, 0, 0, 40], + [0, 2, 3, 0, 0, 40], + [0, 0, 4, 0, 0, 40], + [2, 3, 2, 0, 0, 41], + [2, 1, 3, 0, 0, 41], ], 40: [ - [5, 5, 0, 40], - [5, 3, 1, 40], - [5, 1, 2, 40], - [0, 2, 3, 40], - [0, 0, 4, 40], - [2, 3, 2, 41], - [2, 1, 3, 41], - [4, 4, 1, 42], - [4, 2, 2, 42], - [4, 0, 3, 42], + [5, 5, 0, 0, 0, 40], + [5, 3, 1, 0, 0, 40], + [5, 1, 2, 0, 0, 40], + [0, 2, 3, 0, 0, 40], + [0, 0, 4, 0, 0, 40], + [2, 3, 2, 0, 0, 41], + [2, 1, 3, 0, 0, 41], + [4, 4, 1, 0, 0, 42], + [4, 2, 2, 0, 0, 42], + [4, 0, 3, 0, 0, 42], ], 41: [ - [2, 3, 2, 41], - [2, 1, 3, 41], - [4, 4, 1, 42], - [4, 2, 2, 42], - [4, 0, 3, 42], - [1, 2, 3, 43], - [1, 0, 4, 43], - [0, 1, 4, 45], - [0, 0, 5, 50], + [2, 3, 2, 0, 0, 41], + [2, 1, 3, 0, 0, 41], + [4, 4, 1, 0, 0, 42], + [4, 2, 2, 0, 0, 42], + [4, 0, 3, 0, 0, 42], + [1, 2, 3, 0, 0, 43], + [1, 0, 4, 0, 0, 43], + [0, 1, 4, 0, 0, 45], + [0, 0, 5, 0, 0, 50], ], 42: [ - [4, 4, 1, 42], - [4, 2, 2, 42], - [4, 0, 3, 42], - [1, 2, 3, 43], - [1, 0, 4, 43], - [3, 3, 2, 44], - [3, 1, 3, 44], - [0, 1, 4, 45], - [0, 0, 5, 50], + [4, 4, 1, 0, 0, 42], + [4, 2, 2, 0, 0, 42], + [4, 0, 3, 0, 0, 42], + [1, 2, 3, 0, 0, 43], + [1, 0, 4, 0, 0, 43], + [3, 3, 2, 0, 0, 44], + [3, 1, 3, 0, 0, 44], + [0, 1, 4, 0, 0, 45], + [0, 0, 5, 0, 0, 50], ], 43: [ - [1, 2, 3, 43], - [1, 0, 4, 43], - [3, 3, 2, 44], - [3, 1, 3, 44], - [5, 4, 1, 45], - [5, 2, 2, 45], - [5, 0, 3, 45], - [0, 1, 4, 45], - [0, 0, 5, 50], + [1, 2, 3, 0, 0, 43], + [1, 0, 4, 0, 0, 43], + [3, 3, 2, 0, 0, 44], + [3, 1, 3, 0, 0, 44], + [5, 4, 1, 0, 0, 45], + [5, 2, 2, 0, 0, 45], + [5, 0, 3, 0, 0, 45], + [0, 1, 4, 0, 0, 45], + [0, 0, 5, 0, 0, 50], ], 44: [ - [3, 3, 2, 44], - [3, 1, 3, 44], - [5, 4, 1, 45], - [5, 2, 2, 45], - [5, 0, 3, 45], - [0, 1, 4, 45], - [2, 2, 3, 46], - [2, 0, 4, 46], - [0, 0, 5, 50], + [3, 3, 2, 0, 0, 44], + [3, 1, 3, 0, 0, 44], + [5, 4, 1, 0, 0, 45], + [5, 2, 2, 0, 0, 45], + [5, 0, 3, 0, 0, 45], + [0, 1, 4, 0, 0, 45], + [2, 2, 3, 0, 0, 46], + [2, 0, 4, 0, 0, 46], + [0, 0, 5, 0, 0, 50], ], 45: [ - [5, 4, 1, 45], - [5, 2, 2, 45], - [5, 0, 3, 45], - [0, 1, 4, 45], - [2, 2, 3, 46], - [2, 0, 4, 46], - [4, 3, 2, 47], - [4, 1, 3, 47], - [0, 0, 5, 50], + [5, 4, 1, 0, 0, 45], + [5, 2, 2, 0, 0, 45], + [5, 0, 3, 0, 0, 45], + [0, 1, 4, 0, 0, 45], + [2, 2, 3, 0, 0, 46], + [2, 0, 4, 0, 0, 46], + [4, 3, 2, 0, 0, 47], + [4, 1, 3, 0, 0, 47], + [0, 0, 5, 0, 0, 50], ], 46: [ - [2, 2, 3, 46], - [2, 0, 4, 46], - [4, 3, 2, 47], - [4, 1, 3, 47], - [1, 1, 4, 48], - [0, 0, 5, 50], + [2, 2, 3, 0, 0, 46], + [2, 0, 4, 0, 0, 46], + [4, 3, 2, 0, 0, 47], + [4, 1, 3, 0, 0, 47], + [1, 1, 4, 0, 0, 48], + [0, 0, 5, 0, 0, 50], ], 47: [ - [4, 3, 2, 47], - [4, 1, 3, 47], - [1, 1, 4, 48], - [3, 2, 3, 49], - [3, 0, 4, 49], - [0, 0, 5, 50], + [4, 3, 2, 0, 0, 47], + [4, 1, 3, 0, 0, 47], + [1, 1, 4, 0, 0, 48], + [3, 2, 3, 0, 0, 49], + [3, 0, 4, 0, 0, 49], + [0, 0, 5, 0, 0, 50], ], 48: [ - [1, 1, 4, 48], - [3, 2, 3, 49], - [3, 0, 4, 49], - [5, 3, 2, 50], - [5, 1, 3, 50], - [0, 0, 5, 50], + [1, 1, 4, 0, 0, 48], + [3, 2, 3, 0, 0, 49], + [3, 0, 4, 0, 0, 49], + [5, 3, 2, 0, 0, 50], + [5, 1, 3, 0, 0, 50], + [0, 0, 5, 0, 0, 50], ], 49: [ - [3, 2, 3, 49], - [3, 0, 4, 49], - [5, 3, 2, 50], - [5, 1, 3, 50], - [0, 0, 5, 50], - [2, 1, 4, 51], + [3, 2, 3, 0, 0, 49], + [3, 0, 4, 0, 0, 49], + [5, 3, 2, 0, 0, 50], + [5, 1, 3, 0, 0, 50], + [0, 0, 5, 0, 0, 50], + [2, 1, 4, 0, 0, 51], ], 50: [ - [5, 3, 2, 50], - [5, 1, 3, 50], - [0, 0, 5, 50], - [2, 1, 4, 51], - [4, 2, 3, 52], - [4, 0, 4, 52], + [5, 3, 2, 0, 0, 50], + [5, 1, 3, 0, 0, 50], + [0, 0, 5, 0, 0, 50], + [2, 1, 4, 0, 0, 51], + [4, 2, 3, 0, 0, 52], + [4, 0, 4, 0, 0, 52], ], 51: [ - [2, 1, 4, 51], - [4, 2, 3, 52], - [4, 0, 4, 52], - [1, 0, 5, 53], + [2, 1, 4, 0, 0, 51], + [4, 2, 3, 0, 0, 52], + [4, 0, 4, 0, 0, 52], + [1, 0, 5, 0, 0, 53], ], 52: [ - [4, 2, 3, 52], - [4, 0, 4, 52], - [1, 0, 5, 53], - [3, 1, 4, 54], + [4, 2, 3, 0, 0, 52], + [4, 0, 4, 0, 0, 52], + [1, 0, 5, 0, 0, 53], + [3, 1, 4, 0, 0, 54], ], 53: [ - [1, 0, 5, 53], - [3, 1, 4, 54], - [5, 2, 3, 55], - [5, 0, 4, 55], + [1, 0, 5, 0, 0, 53], + [3, 1, 4, 0, 0, 54], + [5, 2, 3, 0, 0, 55], + [5, 0, 4, 0, 0, 55], ], 54: [ - [3, 1, 4, 54], - [5, 2, 3, 55], - [5, 0, 4, 55], - [2, 0, 5, 56], + [3, 1, 4, 0, 0, 54], + [5, 2, 3, 0, 0, 55], + [5, 0, 4, 0, 0, 55], + [2, 0, 5, 0, 0, 56], ], 55: [ - [5, 2, 3, 55], - [5, 0, 4, 55], - [2, 0, 5, 56], - [4, 1, 4, 57], + [5, 2, 3, 0, 0, 55], + [5, 0, 4, 0, 0, 55], + [2, 0, 5, 0, 0, 56], + [4, 1, 4, 0, 0, 57], ], 56: [ - [2, 0, 5, 56], - [4, 1, 4, 57], + [2, 0, 5, 0, 0, 56], + [4, 1, 4, 0, 0, 57], ], 57: [ - [4, 1, 4, 57], - [3, 0, 5, 59], + [4, 1, 4, 0, 0, 57], + [3, 0, 5, 0, 0, 59], ], 58: [ - [3, 0, 5, 59], - [5, 1, 4, 60], + [3, 0, 5, 0, 0, 59], + [5, 1, 4, 0, 0, 60], ], 59: [ - [3, 0, 5, 59], - [5, 1, 4, 60], + [3, 0, 5, 0, 0, 59], + [5, 1, 4, 0, 0, 60], ], 60: [ - [5, 1, 4, 60], - [4, 0, 5, 62], - ], - 61: [[4, 0, 5, 62]], - 62: [[4, 0, 5, 62]], - 63: [[5, 0, 5, 65]], - 64: [[5, 0, 5, 65]], - 65: [[5, 0, 5, 65]], + [5, 1, 4, 0, 0, 60], + [4, 0, 5, 0, 0, 62], + ], + 61: [[4, 0, 5, 0, 0, 62]], + 62: [[4, 0, 5, 0, 0, 62]], + 63: [[5, 0, 5, 0, 0, 65]], + 64: [[5, 0, 5, 0, 0, 65]], + 65: [[5, 0, 5, 0, 0, 65]], }; diff --git a/src/app/data/generated/precalculatedModCombinationsWithTunings.ts b/src/app/data/generated/precalculatedModCombinationsWithTunings.ts new file mode 100644 index 00000000..6b55c457 --- /dev/null +++ b/src/app/data/generated/precalculatedModCombinationsWithTunings.ts @@ -0,0 +1,5060 @@ +export const precalculatedTuningModCombinations: { + [key: number]: [number, number, number, number, number, number][]; +} = { + 1: [ + [0, 0, 0, 1, 1, 1], + [0, 0, 0, 2, 2, 2], + [1, 0, 0, 0, 0, 3], + [0, 0, 0, 3, 3, 3], + [0, 0, 0, 4, 4, 4], + [0, 1, 0, 0, 0, 5], + [0, 0, 0, 5, 1, 5], + [0, 0, 0, 6, 2, 6], + [0, 0, 0, 7, 3, 7], + [0, 0, 0, 8, 4, 8], + [0, 0, 0, 9, 5, 9], + [0, 0, 1, 0, 0, 10], + [0, 0, 0, 10, 2, 10], + [0, 0, 0, 11, 3, 11], + [0, 0, 0, 12, 4, 12], + [0, 0, 0, 13, 5, 13], + [0, 0, 0, 15, 3, 15], + [0, 0, 0, 16, 4, 16], + [0, 0, 0, 17, 5, 17], + [0, 0, 0, 20, 4, 20], + [0, 0, 0, 21, 5, 21], + [0, 0, 0, 25, 5, 25], + ], + 2: [ + [0, 0, 0, 2, 2, 2], + [1, 0, 0, 0, 0, 3], + [1, 0, 0, 1, 1, 3], + [0, 0, 0, 3, 3, 3], + [0, 0, 0, 4, 4, 4], + [0, 1, 0, 0, 0, 5], + [0, 0, 0, 5, 1, 5], + [0, 1, 0, 1, 1, 6], + [0, 0, 0, 6, 2, 6], + [0, 0, 0, 7, 3, 7], + [0, 0, 0, 8, 4, 8], + [0, 0, 0, 9, 5, 9], + [0, 0, 1, 0, 0, 10], + [0, 0, 0, 10, 2, 10], + [0, 0, 1, 1, 1, 11], + [0, 0, 0, 11, 3, 11], + [0, 0, 0, 12, 4, 12], + [0, 0, 0, 13, 5, 13], + [0, 0, 0, 15, 3, 15], + [0, 0, 0, 16, 4, 16], + [0, 0, 0, 17, 5, 17], + [0, 0, 0, 20, 4, 20], + [0, 0, 0, 21, 5, 21], + [0, 0, 0, 25, 5, 25], + ], + 3: [ + [1, 0, 0, 0, 0, 3], + [1, 0, 0, 1, 1, 3], + [1, 0, 0, 2, 2, 3], + [0, 0, 0, 3, 3, 3], + [0, 0, 0, 4, 4, 4], + [0, 1, 0, 0, 0, 5], + [0, 0, 0, 5, 1, 5], + [0, 1, 0, 1, 1, 6], + [0, 0, 0, 6, 2, 6], + [0, 1, 0, 2, 2, 7], + [0, 0, 0, 7, 3, 7], + [0, 0, 0, 8, 4, 8], + [0, 0, 0, 9, 5, 9], + [0, 0, 1, 0, 0, 10], + [0, 0, 0, 10, 2, 10], + [0, 0, 1, 1, 1, 11], + [0, 0, 0, 11, 3, 11], + [0, 0, 1, 2, 2, 12], + [0, 0, 0, 12, 4, 12], + [0, 0, 0, 13, 5, 13], + [0, 0, 0, 15, 3, 15], + [0, 0, 0, 16, 4, 16], + [0, 0, 0, 17, 5, 17], + [0, 0, 0, 20, 4, 20], + [0, 0, 0, 21, 5, 21], + [0, 0, 0, 25, 5, 25], + ], + 4: [ + [1, 0, 0, 1, 1, 3], + [1, 0, 0, 2, 2, 3], + [1, 0, 0, 3, 3, 3], + [0, 0, 0, 4, 4, 4], + [0, 1, 0, 0, 0, 5], + [0, 0, 0, 5, 1, 5], + [2, 0, 0, 0, 0, 6], + [0, 1, 0, 1, 1, 6], + [0, 0, 0, 6, 2, 6], + [0, 1, 0, 2, 2, 7], + [0, 0, 0, 7, 3, 7], + [0, 1, 0, 3, 3, 8], + [0, 0, 0, 8, 4, 8], + [0, 0, 0, 9, 5, 9], + [0, 0, 1, 0, 0, 10], + [0, 0, 0, 10, 2, 10], + [0, 0, 1, 1, 1, 11], + [0, 0, 0, 11, 3, 11], + [0, 0, 1, 2, 2, 12], + [0, 0, 0, 12, 4, 12], + [0, 0, 1, 3, 3, 13], + [0, 0, 0, 13, 5, 13], + [0, 0, 0, 15, 3, 15], + [0, 0, 0, 16, 4, 16], + [0, 0, 0, 17, 5, 17], + [0, 0, 0, 20, 4, 20], + [0, 0, 0, 21, 5, 21], + [0, 0, 0, 25, 5, 25], + ], + 5: [ + [1, 0, 0, 2, 2, 3], + [1, 0, 0, 3, 3, 3], + [1, 0, 0, 4, 4, 3], + [0, 1, 0, 0, 0, 5], + [0, 0, 0, 5, 1, 5], + [2, 0, 0, 0, 0, 6], + [2, 0, 0, 1, 1, 6], + [0, 1, 0, 1, 1, 6], + [0, 0, 0, 6, 2, 6], + [0, 1, 0, 2, 2, 7], + [0, 0, 0, 7, 3, 7], + [0, 1, 0, 3, 3, 8], + [0, 0, 0, 8, 4, 8], + [0, 1, 0, 4, 4, 9], + [0, 0, 0, 9, 5, 9], + [0, 0, 1, 0, 0, 10], + [0, 0, 0, 10, 2, 10], + [0, 0, 1, 1, 1, 11], + [0, 0, 0, 11, 3, 11], + [0, 0, 1, 2, 2, 12], + [0, 0, 0, 12, 4, 12], + [0, 0, 1, 3, 3, 13], + [0, 0, 0, 13, 5, 13], + [0, 0, 1, 4, 4, 14], + [0, 0, 0, 15, 3, 15], + [0, 0, 0, 16, 4, 16], + [0, 0, 0, 17, 5, 17], + [0, 0, 0, 20, 4, 20], + [0, 0, 0, 21, 5, 21], + [0, 0, 0, 25, 5, 25], + ], + 6: [ + [1, 0, 0, 5, 1, 3], + [1, 0, 0, 3, 3, 3], + [1, 0, 0, 4, 4, 3], + [2, 0, 0, 0, 0, 6], + [2, 0, 0, 1, 1, 6], + [0, 1, 0, 1, 1, 6], + [2, 0, 0, 2, 2, 6], + [0, 0, 0, 6, 2, 6], + [0, 1, 0, 2, 2, 7], + [0, 0, 0, 7, 3, 7], + [1, 1, 0, 0, 0, 8], + [0, 1, 0, 3, 3, 8], + [0, 0, 0, 8, 4, 8], + [0, 1, 0, 4, 4, 9], + [0, 0, 0, 9, 5, 9], + [0, 2, 0, 0, 0, 10], + [0, 0, 1, 0, 0, 10], + [0, 1, 0, 5, 1, 10], + [0, 0, 0, 10, 2, 10], + [0, 0, 1, 1, 1, 11], + [0, 0, 0, 11, 3, 11], + [0, 0, 1, 2, 2, 12], + [0, 0, 0, 12, 4, 12], + [0, 0, 1, 3, 3, 13], + [0, 0, 0, 13, 5, 13], + [0, 0, 1, 4, 4, 14], + [0, 0, 1, 5, 1, 15], + [0, 0, 0, 15, 3, 15], + [0, 0, 0, 16, 4, 16], + [0, 0, 0, 17, 5, 17], + [0, 0, 0, 20, 4, 20], + [0, 0, 0, 21, 5, 21], + [0, 0, 0, 25, 5, 25], + ], + 7: [ + [1, 0, 0, 5, 1, 3], + [1, 0, 0, 6, 2, 3], + [1, 0, 0, 4, 4, 3], + [2, 0, 0, 1, 1, 6], + [2, 0, 0, 2, 2, 6], + [2, 0, 0, 3, 3, 6], + [0, 1, 0, 2, 2, 7], + [0, 0, 0, 7, 3, 7], + [1, 1, 0, 0, 0, 8], + [0, 1, 0, 3, 3, 8], + [0, 0, 0, 8, 4, 8], + [3, 0, 0, 0, 0, 9], + [1, 1, 0, 1, 1, 9], + [0, 1, 0, 4, 4, 9], + [0, 0, 0, 9, 5, 9], + [0, 2, 0, 0, 0, 10], + [0, 0, 1, 0, 0, 10], + [0, 1, 0, 5, 1, 10], + [0, 0, 0, 10, 2, 10], + [0, 2, 0, 1, 1, 11], + [0, 0, 1, 1, 1, 11], + [0, 1, 0, 6, 2, 11], + [0, 0, 0, 11, 3, 11], + [0, 0, 1, 2, 2, 12], + [0, 0, 0, 12, 4, 12], + [0, 0, 1, 3, 3, 13], + [0, 0, 0, 13, 5, 13], + [0, 0, 1, 4, 4, 14], + [0, 0, 1, 5, 1, 15], + [0, 0, 0, 15, 3, 15], + [0, 0, 1, 6, 2, 16], + [0, 0, 0, 16, 4, 16], + [0, 0, 0, 17, 5, 17], + [0, 0, 0, 20, 4, 20], + [0, 0, 0, 21, 5, 21], + [0, 0, 0, 25, 5, 25], + ], + 8: [ + [1, 0, 0, 5, 1, 3], + [1, 0, 0, 6, 2, 3], + [1, 0, 0, 7, 3, 3], + [2, 0, 0, 2, 2, 6], + [2, 0, 0, 3, 3, 6], + [1, 1, 0, 0, 0, 8], + [0, 1, 0, 3, 3, 8], + [0, 0, 0, 8, 4, 8], + [3, 0, 0, 0, 0, 9], + [3, 0, 0, 1, 1, 9], + [1, 1, 0, 1, 1, 9], + [0, 1, 0, 4, 4, 9], + [0, 0, 0, 9, 5, 9], + [0, 2, 0, 0, 0, 10], + [0, 0, 1, 0, 0, 10], + [0, 1, 0, 5, 1, 10], + [1, 1, 0, 2, 2, 10], + [0, 0, 0, 10, 2, 10], + [0, 2, 0, 1, 1, 11], + [0, 0, 1, 1, 1, 11], + [0, 1, 0, 6, 2, 11], + [0, 0, 0, 11, 3, 11], + [0, 2, 0, 2, 2, 12], + [0, 0, 1, 2, 2, 12], + [0, 1, 0, 7, 3, 12], + [0, 0, 0, 12, 4, 12], + [0, 0, 1, 3, 3, 13], + [0, 0, 0, 13, 5, 13], + [0, 0, 1, 4, 4, 14], + [0, 0, 1, 5, 1, 15], + [0, 0, 0, 15, 3, 15], + [0, 0, 1, 6, 2, 16], + [0, 0, 0, 16, 4, 16], + [0, 0, 1, 7, 3, 17], + [0, 0, 0, 17, 5, 17], + [0, 0, 0, 20, 4, 20], + [0, 0, 0, 21, 5, 21], + [0, 0, 0, 25, 5, 25], + ], + 9: [ + [1, 0, 0, 6, 2, 3], + [1, 0, 0, 7, 3, 3], + [1, 0, 0, 8, 4, 3], + [2, 0, 0, 5, 1, 6], + [2, 0, 0, 3, 3, 6], + [3, 0, 0, 0, 0, 9], + [3, 0, 0, 1, 1, 9], + [1, 1, 0, 1, 1, 9], + [3, 0, 0, 2, 2, 9], + [0, 1, 0, 4, 4, 9], + [0, 0, 0, 9, 5, 9], + [0, 2, 0, 0, 0, 10], + [0, 0, 1, 0, 0, 10], + [0, 1, 0, 5, 1, 10], + [1, 1, 0, 2, 2, 10], + [0, 0, 0, 10, 2, 10], + [2, 1, 0, 0, 0, 11], + [0, 2, 0, 1, 1, 11], + [0, 0, 1, 1, 1, 11], + [0, 1, 0, 6, 2, 11], + [1, 1, 0, 3, 3, 11], + [0, 0, 0, 11, 3, 11], + [0, 2, 0, 2, 2, 12], + [0, 0, 1, 2, 2, 12], + [0, 1, 0, 7, 3, 12], + [0, 0, 0, 12, 4, 12], + [0, 2, 0, 3, 3, 13], + [0, 0, 1, 3, 3, 13], + [0, 1, 0, 8, 4, 13], + [0, 0, 0, 13, 5, 13], + [0, 0, 1, 4, 4, 14], + [0, 0, 1, 5, 1, 15], + [0, 0, 0, 15, 3, 15], + [0, 0, 1, 6, 2, 16], + [0, 0, 0, 16, 4, 16], + [0, 0, 1, 7, 3, 17], + [0, 0, 0, 17, 5, 17], + [0, 0, 1, 8, 4, 18], + [0, 0, 0, 20, 4, 20], + [0, 0, 0, 21, 5, 21], + [0, 0, 0, 25, 5, 25], + ], + 10: [ + [1, 0, 0, 7, 3, 3], + [1, 0, 0, 8, 4, 3], + [2, 0, 0, 5, 1, 6], + [2, 0, 0, 6, 2, 6], + [3, 0, 0, 1, 1, 9], + [3, 0, 0, 2, 2, 9], + [0, 2, 0, 0, 0, 10], + [0, 0, 1, 0, 0, 10], + [0, 1, 0, 5, 1, 10], + [1, 1, 0, 2, 2, 10], + [0, 0, 0, 10, 2, 10], + [2, 1, 0, 0, 0, 11], + [0, 2, 0, 1, 1, 11], + [0, 0, 1, 1, 1, 11], + [0, 1, 0, 6, 2, 11], + [1, 1, 0, 3, 3, 11], + [0, 0, 0, 11, 3, 11], + [4, 0, 0, 0, 0, 12], + [2, 1, 0, 1, 1, 12], + [0, 2, 0, 2, 2, 12], + [0, 0, 1, 2, 2, 12], + [0, 1, 0, 7, 3, 12], + [1, 1, 0, 4, 4, 12], + [0, 0, 0, 12, 4, 12], + [0, 2, 0, 3, 3, 13], + [0, 0, 1, 3, 3, 13], + [0, 1, 0, 8, 4, 13], + [0, 0, 0, 13, 5, 13], + [0, 2, 0, 4, 4, 14], + [0, 0, 1, 4, 4, 14], + [0, 1, 0, 9, 5, 14], + [0, 0, 1, 5, 1, 15], + [0, 0, 0, 15, 3, 15], + [0, 0, 1, 6, 2, 16], + [0, 0, 0, 16, 4, 16], + [0, 0, 1, 7, 3, 17], + [0, 0, 0, 17, 5, 17], + [0, 0, 1, 8, 4, 18], + [0, 0, 1, 9, 5, 19], + [0, 0, 0, 20, 4, 20], + [0, 0, 0, 21, 5, 21], + [0, 0, 0, 25, 5, 25], + ], + 11: [ + [1, 0, 0, 10, 2, 3], + [1, 0, 0, 8, 4, 3], + [2, 0, 0, 5, 1, 6], + [2, 0, 0, 6, 2, 6], + [2, 0, 0, 7, 3, 6], + [3, 0, 0, 2, 2, 9], + [2, 1, 0, 0, 0, 11], + [0, 2, 0, 1, 1, 11], + [0, 0, 1, 1, 1, 11], + [0, 1, 0, 6, 2, 11], + [1, 1, 0, 3, 3, 11], + [0, 0, 0, 11, 3, 11], + [4, 0, 0, 0, 0, 12], + [4, 0, 0, 1, 1, 12], + [2, 1, 0, 1, 1, 12], + [0, 2, 0, 2, 2, 12], + [0, 0, 1, 2, 2, 12], + [0, 1, 0, 7, 3, 12], + [1, 1, 0, 4, 4, 12], + [0, 0, 0, 12, 4, 12], + [1, 2, 0, 0, 0, 13], + [1, 0, 1, 0, 0, 13], + [1, 1, 0, 5, 1, 13], + [2, 1, 0, 2, 2, 13], + [0, 2, 0, 3, 3, 13], + [0, 0, 1, 3, 3, 13], + [0, 1, 0, 8, 4, 13], + [0, 0, 0, 13, 5, 13], + [0, 2, 0, 4, 4, 14], + [0, 0, 1, 4, 4, 14], + [0, 1, 0, 9, 5, 14], + [0, 3, 0, 0, 0, 15], + [0, 1, 1, 0, 0, 15], + [0, 2, 0, 5, 1, 15], + [0, 0, 1, 5, 1, 15], + [0, 1, 0, 10, 2, 15], + [0, 0, 0, 15, 3, 15], + [0, 0, 1, 6, 2, 16], + [0, 0, 0, 16, 4, 16], + [0, 0, 1, 7, 3, 17], + [0, 0, 0, 17, 5, 17], + [0, 0, 1, 8, 4, 18], + [0, 0, 1, 9, 5, 19], + [0, 0, 2, 0, 0, 20], + [0, 0, 1, 10, 2, 20], + [0, 0, 0, 20, 4, 20], + [0, 0, 0, 21, 5, 21], + [0, 0, 0, 25, 5, 25], + ], + 12: [ + [1, 0, 0, 10, 2, 3], + [1, 0, 0, 11, 3, 3], + [2, 0, 0, 6, 2, 6], + [2, 0, 0, 7, 3, 6], + [3, 0, 0, 5, 1, 9], + [4, 0, 0, 0, 0, 12], + [4, 0, 0, 1, 1, 12], + [2, 1, 0, 1, 1, 12], + [0, 2, 0, 2, 2, 12], + [0, 0, 1, 2, 2, 12], + [0, 1, 0, 7, 3, 12], + [1, 1, 0, 4, 4, 12], + [0, 0, 0, 12, 4, 12], + [1, 2, 0, 0, 0, 13], + [1, 0, 1, 0, 0, 13], + [1, 1, 0, 5, 1, 13], + [2, 1, 0, 2, 2, 13], + [0, 2, 0, 3, 3, 13], + [0, 0, 1, 3, 3, 13], + [0, 1, 0, 8, 4, 13], + [0, 0, 0, 13, 5, 13], + [3, 1, 0, 0, 0, 14], + [1, 2, 0, 1, 1, 14], + [1, 0, 1, 1, 1, 14], + [1, 1, 0, 6, 2, 14], + [2, 1, 0, 3, 3, 14], + [0, 2, 0, 4, 4, 14], + [0, 0, 1, 4, 4, 14], + [0, 1, 0, 9, 5, 14], + [0, 3, 0, 0, 0, 15], + [0, 1, 1, 0, 0, 15], + [0, 2, 0, 5, 1, 15], + [0, 0, 1, 5, 1, 15], + [0, 1, 0, 10, 2, 15], + [0, 0, 0, 15, 3, 15], + [0, 3, 0, 1, 1, 16], + [0, 1, 1, 1, 1, 16], + [0, 2, 0, 6, 2, 16], + [0, 0, 1, 6, 2, 16], + [0, 1, 0, 11, 3, 16], + [0, 0, 0, 16, 4, 16], + [0, 0, 1, 7, 3, 17], + [0, 0, 0, 17, 5, 17], + [0, 0, 1, 8, 4, 18], + [0, 0, 1, 9, 5, 19], + [0, 0, 2, 0, 0, 20], + [0, 0, 1, 10, 2, 20], + [0, 0, 0, 20, 4, 20], + [0, 0, 2, 1, 1, 21], + [0, 0, 1, 11, 3, 21], + [0, 0, 0, 21, 5, 21], + [0, 0, 0, 25, 5, 25], + ], + 13: [ + [1, 0, 0, 10, 2, 3], + [1, 0, 0, 11, 3, 3], + [1, 0, 0, 12, 4, 3], + [2, 0, 0, 7, 3, 6], + [3, 0, 0, 5, 1, 9], + [3, 0, 0, 6, 2, 9], + [4, 0, 0, 1, 1, 12], + [1, 2, 0, 0, 0, 13], + [1, 0, 1, 0, 0, 13], + [1, 1, 0, 5, 1, 13], + [2, 1, 0, 2, 2, 13], + [0, 2, 0, 3, 3, 13], + [0, 0, 1, 3, 3, 13], + [0, 1, 0, 8, 4, 13], + [0, 0, 0, 13, 5, 13], + [3, 1, 0, 0, 0, 14], + [1, 2, 0, 1, 1, 14], + [1, 0, 1, 1, 1, 14], + [1, 1, 0, 6, 2, 14], + [2, 1, 0, 3, 3, 14], + [0, 2, 0, 4, 4, 14], + [0, 0, 1, 4, 4, 14], + [0, 1, 0, 9, 5, 14], + [5, 0, 0, 0, 0, 15], + [0, 3, 0, 0, 0, 15], + [0, 1, 1, 0, 0, 15], + [3, 1, 0, 1, 1, 15], + [0, 2, 0, 5, 1, 15], + [0, 0, 1, 5, 1, 15], + [1, 2, 0, 2, 2, 15], + [1, 0, 1, 2, 2, 15], + [0, 1, 0, 10, 2, 15], + [1, 1, 0, 7, 3, 15], + [0, 0, 0, 15, 3, 15], + [0, 3, 0, 1, 1, 16], + [0, 1, 1, 1, 1, 16], + [0, 2, 0, 6, 2, 16], + [0, 0, 1, 6, 2, 16], + [0, 1, 0, 11, 3, 16], + [0, 0, 0, 16, 4, 16], + [0, 3, 0, 2, 2, 17], + [0, 1, 1, 2, 2, 17], + [0, 2, 0, 7, 3, 17], + [0, 0, 1, 7, 3, 17], + [0, 1, 0, 12, 4, 17], + [0, 0, 0, 17, 5, 17], + [0, 0, 1, 8, 4, 18], + [0, 0, 1, 9, 5, 19], + [0, 0, 2, 0, 0, 20], + [0, 0, 1, 10, 2, 20], + [0, 0, 0, 20, 4, 20], + [0, 0, 2, 1, 1, 21], + [0, 0, 1, 11, 3, 21], + [0, 0, 0, 21, 5, 21], + [0, 0, 2, 2, 2, 22], + [0, 0, 1, 12, 4, 22], + [0, 0, 0, 25, 5, 25], + ], + 14: [ + [1, 0, 0, 11, 3, 3], + [1, 0, 0, 12, 4, 3], + [2, 0, 0, 10, 2, 6], + [3, 0, 0, 5, 1, 9], + [3, 0, 0, 6, 2, 9], + [3, 1, 0, 0, 0, 14], + [1, 2, 0, 1, 1, 14], + [1, 0, 1, 1, 1, 14], + [1, 1, 0, 6, 2, 14], + [2, 1, 0, 3, 3, 14], + [0, 2, 0, 4, 4, 14], + [0, 0, 1, 4, 4, 14], + [0, 1, 0, 9, 5, 14], + [5, 0, 0, 0, 0, 15], + [0, 3, 0, 0, 0, 15], + [0, 1, 1, 0, 0, 15], + [3, 1, 0, 1, 1, 15], + [0, 2, 0, 5, 1, 15], + [0, 0, 1, 5, 1, 15], + [1, 2, 0, 2, 2, 15], + [1, 0, 1, 2, 2, 15], + [0, 1, 0, 10, 2, 15], + [1, 1, 0, 7, 3, 15], + [0, 0, 0, 15, 3, 15], + [2, 2, 0, 0, 0, 16], + [2, 0, 1, 0, 0, 16], + [0, 3, 0, 1, 1, 16], + [0, 1, 1, 1, 1, 16], + [2, 1, 0, 5, 1, 16], + [3, 1, 0, 2, 2, 16], + [0, 2, 0, 6, 2, 16], + [0, 0, 1, 6, 2, 16], + [1, 2, 0, 3, 3, 16], + [1, 0, 1, 3, 3, 16], + [0, 1, 0, 11, 3, 16], + [1, 1, 0, 8, 4, 16], + [0, 0, 0, 16, 4, 16], + [0, 3, 0, 2, 2, 17], + [0, 1, 1, 2, 2, 17], + [0, 2, 0, 7, 3, 17], + [0, 0, 1, 7, 3, 17], + [0, 1, 0, 12, 4, 17], + [0, 0, 0, 17, 5, 17], + [0, 3, 0, 3, 3, 18], + [0, 1, 1, 3, 3, 18], + [0, 2, 0, 8, 4, 18], + [0, 0, 1, 8, 4, 18], + [0, 1, 0, 13, 5, 18], + [0, 0, 1, 9, 5, 19], + [0, 0, 2, 0, 0, 20], + [0, 0, 1, 10, 2, 20], + [0, 0, 0, 20, 4, 20], + [0, 0, 2, 1, 1, 21], + [0, 0, 1, 11, 3, 21], + [0, 0, 0, 21, 5, 21], + [0, 0, 2, 2, 2, 22], + [0, 0, 1, 12, 4, 22], + [0, 0, 2, 3, 3, 23], + [0, 0, 1, 13, 5, 23], + [0, 0, 0, 25, 5, 25], + ], + 15: [ + [1, 0, 0, 12, 4, 3], + [2, 0, 0, 10, 2, 6], + [2, 0, 0, 11, 3, 6], + [3, 0, 0, 6, 2, 9], + [4, 0, 0, 5, 1, 12], + [5, 0, 0, 0, 0, 15], + [0, 3, 0, 0, 0, 15], + [0, 1, 1, 0, 0, 15], + [3, 1, 0, 1, 1, 15], + [0, 2, 0, 5, 1, 15], + [0, 0, 1, 5, 1, 15], + [1, 2, 0, 2, 2, 15], + [1, 0, 1, 2, 2, 15], + [0, 1, 0, 10, 2, 15], + [1, 1, 0, 7, 3, 15], + [0, 0, 0, 15, 3, 15], + [2, 2, 0, 0, 0, 16], + [2, 0, 1, 0, 0, 16], + [0, 3, 0, 1, 1, 16], + [0, 1, 1, 1, 1, 16], + [2, 1, 0, 5, 1, 16], + [3, 1, 0, 2, 2, 16], + [0, 2, 0, 6, 2, 16], + [0, 0, 1, 6, 2, 16], + [1, 2, 0, 3, 3, 16], + [1, 0, 1, 3, 3, 16], + [0, 1, 0, 11, 3, 16], + [1, 1, 0, 8, 4, 16], + [0, 0, 0, 16, 4, 16], + [4, 1, 0, 0, 0, 17], + [2, 2, 0, 1, 1, 17], + [2, 0, 1, 1, 1, 17], + [0, 3, 0, 2, 2, 17], + [0, 1, 1, 2, 2, 17], + [2, 1, 0, 6, 2, 17], + [0, 2, 0, 7, 3, 17], + [0, 0, 1, 7, 3, 17], + [1, 2, 0, 4, 4, 17], + [1, 0, 1, 4, 4, 17], + [0, 1, 0, 12, 4, 17], + [0, 0, 0, 17, 5, 17], + [0, 3, 0, 3, 3, 18], + [0, 1, 1, 3, 3, 18], + [0, 2, 0, 8, 4, 18], + [0, 0, 1, 8, 4, 18], + [0, 1, 0, 13, 5, 18], + [0, 3, 0, 4, 4, 19], + [0, 1, 1, 4, 4, 19], + [0, 2, 0, 9, 5, 19], + [0, 0, 1, 9, 5, 19], + [0, 0, 2, 0, 0, 20], + [0, 0, 1, 10, 2, 20], + [0, 0, 0, 20, 4, 20], + [0, 0, 2, 1, 1, 21], + [0, 0, 1, 11, 3, 21], + [0, 0, 0, 21, 5, 21], + [0, 0, 2, 2, 2, 22], + [0, 0, 1, 12, 4, 22], + [0, 0, 2, 3, 3, 23], + [0, 0, 1, 13, 5, 23], + [0, 0, 2, 4, 4, 24], + [0, 0, 0, 25, 5, 25], + ], + 16: [ + [1, 0, 0, 15, 3, 3], + [2, 0, 0, 10, 2, 6], + [2, 0, 0, 11, 3, 6], + [4, 0, 0, 5, 1, 12], + [2, 2, 0, 0, 0, 16], + [2, 0, 1, 0, 0, 16], + [0, 3, 0, 1, 1, 16], + [0, 1, 1, 1, 1, 16], + [2, 1, 0, 5, 1, 16], + [3, 1, 0, 2, 2, 16], + [0, 2, 0, 6, 2, 16], + [0, 0, 1, 6, 2, 16], + [1, 2, 0, 3, 3, 16], + [1, 0, 1, 3, 3, 16], + [0, 1, 0, 11, 3, 16], + [1, 1, 0, 8, 4, 16], + [0, 0, 0, 16, 4, 16], + [4, 1, 0, 0, 0, 17], + [2, 2, 0, 1, 1, 17], + [2, 0, 1, 1, 1, 17], + [0, 3, 0, 2, 2, 17], + [0, 1, 1, 2, 2, 17], + [2, 1, 0, 6, 2, 17], + [0, 2, 0, 7, 3, 17], + [0, 0, 1, 7, 3, 17], + [1, 2, 0, 4, 4, 17], + [1, 0, 1, 4, 4, 17], + [0, 1, 0, 12, 4, 17], + [0, 0, 0, 17, 5, 17], + [1, 3, 0, 0, 0, 18], + [1, 1, 1, 0, 0, 18], + [4, 1, 0, 1, 1, 18], + [1, 2, 0, 5, 1, 18], + [1, 0, 1, 5, 1, 18], + [2, 2, 0, 2, 2, 18], + [2, 0, 1, 2, 2, 18], + [1, 1, 0, 10, 2, 18], + [0, 3, 0, 3, 3, 18], + [0, 1, 1, 3, 3, 18], + [2, 1, 0, 7, 3, 18], + [0, 2, 0, 8, 4, 18], + [0, 0, 1, 8, 4, 18], + [0, 1, 0, 13, 5, 18], + [0, 3, 0, 4, 4, 19], + [0, 1, 1, 4, 4, 19], + [0, 2, 0, 9, 5, 19], + [0, 0, 1, 9, 5, 19], + [0, 4, 0, 0, 0, 20], + [0, 2, 1, 0, 0, 20], + [0, 0, 2, 0, 0, 20], + [0, 3, 0, 5, 1, 20], + [0, 1, 1, 5, 1, 20], + [0, 2, 0, 10, 2, 20], + [0, 0, 1, 10, 2, 20], + [0, 1, 0, 15, 3, 20], + [0, 0, 0, 20, 4, 20], + [0, 0, 2, 1, 1, 21], + [0, 0, 1, 11, 3, 21], + [0, 0, 0, 21, 5, 21], + [0, 0, 2, 2, 2, 22], + [0, 0, 1, 12, 4, 22], + [0, 0, 2, 3, 3, 23], + [0, 0, 1, 13, 5, 23], + [0, 0, 2, 4, 4, 24], + [0, 0, 2, 5, 1, 25], + [0, 0, 1, 15, 3, 25], + [0, 0, 0, 25, 5, 25], + ], + 17: [ + [1, 0, 0, 15, 3, 3], + [1, 0, 0, 16, 4, 3], + [2, 0, 0, 11, 3, 6], + [3, 0, 0, 10, 2, 9], + [4, 0, 0, 5, 1, 12], + [4, 1, 0, 0, 0, 17], + [2, 2, 0, 1, 1, 17], + [2, 0, 1, 1, 1, 17], + [0, 3, 0, 2, 2, 17], + [0, 1, 1, 2, 2, 17], + [2, 1, 0, 6, 2, 17], + [0, 2, 0, 7, 3, 17], + [0, 0, 1, 7, 3, 17], + [1, 2, 0, 4, 4, 17], + [1, 0, 1, 4, 4, 17], + [0, 1, 0, 12, 4, 17], + [0, 0, 0, 17, 5, 17], + [1, 3, 0, 0, 0, 18], + [1, 1, 1, 0, 0, 18], + [4, 1, 0, 1, 1, 18], + [1, 2, 0, 5, 1, 18], + [1, 0, 1, 5, 1, 18], + [2, 2, 0, 2, 2, 18], + [2, 0, 1, 2, 2, 18], + [1, 1, 0, 10, 2, 18], + [0, 3, 0, 3, 3, 18], + [0, 1, 1, 3, 3, 18], + [2, 1, 0, 7, 3, 18], + [0, 2, 0, 8, 4, 18], + [0, 0, 1, 8, 4, 18], + [0, 1, 0, 13, 5, 18], + [3, 2, 0, 0, 0, 19], + [3, 0, 1, 0, 0, 19], + [1, 3, 0, 1, 1, 19], + [1, 1, 1, 1, 1, 19], + [3, 1, 0, 5, 1, 19], + [1, 2, 0, 6, 2, 19], + [1, 0, 1, 6, 2, 19], + [2, 2, 0, 3, 3, 19], + [2, 0, 1, 3, 3, 19], + [1, 1, 0, 11, 3, 19], + [0, 3, 0, 4, 4, 19], + [0, 1, 1, 4, 4, 19], + [0, 2, 0, 9, 5, 19], + [0, 0, 1, 9, 5, 19], + [0, 4, 0, 0, 0, 20], + [0, 2, 1, 0, 0, 20], + [0, 0, 2, 0, 0, 20], + [0, 3, 0, 5, 1, 20], + [0, 1, 1, 5, 1, 20], + [0, 2, 0, 10, 2, 20], + [0, 0, 1, 10, 2, 20], + [0, 1, 0, 15, 3, 20], + [0, 0, 0, 20, 4, 20], + [0, 4, 0, 1, 1, 21], + [0, 2, 1, 1, 1, 21], + [0, 0, 2, 1, 1, 21], + [0, 3, 0, 6, 2, 21], + [0, 1, 1, 6, 2, 21], + [0, 2, 0, 11, 3, 21], + [0, 0, 1, 11, 3, 21], + [0, 1, 0, 16, 4, 21], + [0, 0, 0, 21, 5, 21], + [0, 0, 2, 2, 2, 22], + [0, 0, 1, 12, 4, 22], + [0, 0, 2, 3, 3, 23], + [0, 0, 1, 13, 5, 23], + [0, 0, 2, 4, 4, 24], + [0, 0, 2, 5, 1, 25], + [0, 0, 1, 15, 3, 25], + [0, 0, 0, 25, 5, 25], + [0, 0, 2, 6, 2, 26], + [0, 0, 1, 16, 4, 26], + ], + 18: [ + [1, 0, 0, 15, 3, 3], + [1, 0, 0, 16, 4, 3], + [3, 0, 0, 10, 2, 9], + [1, 3, 0, 0, 0, 18], + [1, 1, 1, 0, 0, 18], + [4, 1, 0, 1, 1, 18], + [1, 2, 0, 5, 1, 18], + [1, 0, 1, 5, 1, 18], + [2, 2, 0, 2, 2, 18], + [2, 0, 1, 2, 2, 18], + [1, 1, 0, 10, 2, 18], + [0, 3, 0, 3, 3, 18], + [0, 1, 1, 3, 3, 18], + [2, 1, 0, 7, 3, 18], + [0, 2, 0, 8, 4, 18], + [0, 0, 1, 8, 4, 18], + [0, 1, 0, 13, 5, 18], + [3, 2, 0, 0, 0, 19], + [3, 0, 1, 0, 0, 19], + [1, 3, 0, 1, 1, 19], + [1, 1, 1, 1, 1, 19], + [3, 1, 0, 5, 1, 19], + [1, 2, 0, 6, 2, 19], + [1, 0, 1, 6, 2, 19], + [2, 2, 0, 3, 3, 19], + [2, 0, 1, 3, 3, 19], + [1, 1, 0, 11, 3, 19], + [0, 3, 0, 4, 4, 19], + [0, 1, 1, 4, 4, 19], + [0, 2, 0, 9, 5, 19], + [0, 0, 1, 9, 5, 19], + [5, 1, 0, 0, 0, 20], + [0, 4, 0, 0, 0, 20], + [0, 2, 1, 0, 0, 20], + [0, 0, 2, 0, 0, 20], + [3, 2, 0, 1, 1, 20], + [3, 0, 1, 1, 1, 20], + [0, 3, 0, 5, 1, 20], + [0, 1, 1, 5, 1, 20], + [1, 3, 0, 2, 2, 20], + [1, 1, 1, 2, 2, 20], + [3, 1, 0, 6, 2, 20], + [0, 2, 0, 10, 2, 20], + [0, 0, 1, 10, 2, 20], + [1, 2, 0, 7, 3, 20], + [1, 0, 1, 7, 3, 20], + [0, 1, 0, 15, 3, 20], + [1, 1, 0, 12, 4, 20], + [0, 0, 0, 20, 4, 20], + [0, 4, 0, 1, 1, 21], + [0, 2, 1, 1, 1, 21], + [0, 0, 2, 1, 1, 21], + [0, 3, 0, 6, 2, 21], + [0, 1, 1, 6, 2, 21], + [0, 2, 0, 11, 3, 21], + [0, 0, 1, 11, 3, 21], + [0, 1, 0, 16, 4, 21], + [0, 0, 0, 21, 5, 21], + [0, 4, 0, 2, 2, 22], + [0, 2, 1, 2, 2, 22], + [0, 0, 2, 2, 2, 22], + [0, 3, 0, 7, 3, 22], + [0, 1, 1, 7, 3, 22], + [0, 2, 0, 12, 4, 22], + [0, 0, 1, 12, 4, 22], + [0, 1, 0, 17, 5, 22], + [0, 0, 2, 3, 3, 23], + [0, 0, 1, 13, 5, 23], + [0, 0, 2, 4, 4, 24], + [0, 0, 2, 5, 1, 25], + [0, 0, 1, 15, 3, 25], + [0, 0, 0, 25, 5, 25], + [0, 0, 2, 6, 2, 26], + [0, 0, 1, 16, 4, 26], + [0, 0, 2, 7, 3, 27], + [0, 0, 1, 17, 5, 27], + ], + 19: [ + [1, 0, 0, 16, 4, 3], + [2, 0, 0, 15, 3, 6], + [3, 0, 0, 10, 2, 9], + [3, 2, 0, 0, 0, 19], + [3, 0, 1, 0, 0, 19], + [1, 3, 0, 1, 1, 19], + [1, 1, 1, 1, 1, 19], + [3, 1, 0, 5, 1, 19], + [1, 2, 0, 6, 2, 19], + [1, 0, 1, 6, 2, 19], + [2, 2, 0, 3, 3, 19], + [2, 0, 1, 3, 3, 19], + [1, 1, 0, 11, 3, 19], + [0, 3, 0, 4, 4, 19], + [0, 1, 1, 4, 4, 19], + [0, 2, 0, 9, 5, 19], + [0, 0, 1, 9, 5, 19], + [5, 1, 0, 0, 0, 20], + [0, 4, 0, 0, 0, 20], + [0, 2, 1, 0, 0, 20], + [0, 0, 2, 0, 0, 20], + [3, 2, 0, 1, 1, 20], + [3, 0, 1, 1, 1, 20], + [0, 3, 0, 5, 1, 20], + [0, 1, 1, 5, 1, 20], + [1, 3, 0, 2, 2, 20], + [1, 1, 1, 2, 2, 20], + [3, 1, 0, 6, 2, 20], + [0, 2, 0, 10, 2, 20], + [0, 0, 1, 10, 2, 20], + [1, 2, 0, 7, 3, 20], + [1, 0, 1, 7, 3, 20], + [0, 1, 0, 15, 3, 20], + [1, 1, 0, 12, 4, 20], + [0, 0, 0, 20, 4, 20], + [2, 3, 0, 0, 0, 21], + [2, 1, 1, 0, 0, 21], + [0, 4, 0, 1, 1, 21], + [0, 2, 1, 1, 1, 21], + [0, 0, 2, 1, 1, 21], + [2, 2, 0, 5, 1, 21], + [2, 0, 1, 5, 1, 21], + [3, 2, 0, 2, 2, 21], + [3, 0, 1, 2, 2, 21], + [0, 3, 0, 6, 2, 21], + [0, 1, 1, 6, 2, 21], + [2, 1, 0, 10, 2, 21], + [1, 3, 0, 3, 3, 21], + [1, 1, 1, 3, 3, 21], + [0, 2, 0, 11, 3, 21], + [0, 0, 1, 11, 3, 21], + [1, 2, 0, 8, 4, 21], + [1, 0, 1, 8, 4, 21], + [0, 1, 0, 16, 4, 21], + [0, 0, 0, 21, 5, 21], + [0, 4, 0, 2, 2, 22], + [0, 2, 1, 2, 2, 22], + [0, 0, 2, 2, 2, 22], + [0, 3, 0, 7, 3, 22], + [0, 1, 1, 7, 3, 22], + [0, 2, 0, 12, 4, 22], + [0, 0, 1, 12, 4, 22], + [0, 1, 0, 17, 5, 22], + [0, 4, 0, 3, 3, 23], + [0, 2, 1, 3, 3, 23], + [0, 0, 2, 3, 3, 23], + [0, 3, 0, 8, 4, 23], + [0, 1, 1, 8, 4, 23], + [0, 2, 0, 13, 5, 23], + [0, 0, 1, 13, 5, 23], + [0, 0, 2, 4, 4, 24], + [0, 0, 2, 5, 1, 25], + [0, 0, 1, 15, 3, 25], + [0, 0, 0, 25, 5, 25], + [0, 0, 2, 6, 2, 26], + [0, 0, 1, 16, 4, 26], + [0, 0, 2, 7, 3, 27], + [0, 0, 1, 17, 5, 27], + [0, 0, 2, 8, 4, 28], + ], + 20: [ + [2, 0, 0, 15, 3, 6], + [5, 1, 0, 0, 0, 20], + [0, 4, 0, 0, 0, 20], + [0, 2, 1, 0, 0, 20], + [0, 0, 2, 0, 0, 20], + [3, 2, 0, 1, 1, 20], + [3, 0, 1, 1, 1, 20], + [0, 3, 0, 5, 1, 20], + [0, 1, 1, 5, 1, 20], + [1, 3, 0, 2, 2, 20], + [1, 1, 1, 2, 2, 20], + [3, 1, 0, 6, 2, 20], + [0, 2, 0, 10, 2, 20], + [0, 0, 1, 10, 2, 20], + [1, 2, 0, 7, 3, 20], + [1, 0, 1, 7, 3, 20], + [0, 1, 0, 15, 3, 20], + [1, 1, 0, 12, 4, 20], + [0, 0, 0, 20, 4, 20], + [2, 3, 0, 0, 0, 21], + [2, 1, 1, 0, 0, 21], + [0, 4, 0, 1, 1, 21], + [0, 2, 1, 1, 1, 21], + [0, 0, 2, 1, 1, 21], + [2, 2, 0, 5, 1, 21], + [2, 0, 1, 5, 1, 21], + [3, 2, 0, 2, 2, 21], + [3, 0, 1, 2, 2, 21], + [0, 3, 0, 6, 2, 21], + [0, 1, 1, 6, 2, 21], + [2, 1, 0, 10, 2, 21], + [1, 3, 0, 3, 3, 21], + [1, 1, 1, 3, 3, 21], + [0, 2, 0, 11, 3, 21], + [0, 0, 1, 11, 3, 21], + [1, 2, 0, 8, 4, 21], + [1, 0, 1, 8, 4, 21], + [0, 1, 0, 16, 4, 21], + [0, 0, 0, 21, 5, 21], + [4, 2, 0, 0, 0, 22], + [4, 0, 1, 0, 0, 22], + [2, 3, 0, 1, 1, 22], + [2, 1, 1, 1, 1, 22], + [4, 1, 0, 5, 1, 22], + [0, 4, 0, 2, 2, 22], + [0, 2, 1, 2, 2, 22], + [0, 0, 2, 2, 2, 22], + [2, 2, 0, 6, 2, 22], + [2, 0, 1, 6, 2, 22], + [0, 3, 0, 7, 3, 22], + [0, 1, 1, 7, 3, 22], + [2, 1, 0, 11, 3, 22], + [1, 3, 0, 4, 4, 22], + [1, 1, 1, 4, 4, 22], + [0, 2, 0, 12, 4, 22], + [0, 0, 1, 12, 4, 22], + [0, 1, 0, 17, 5, 22], + [0, 4, 0, 3, 3, 23], + [0, 2, 1, 3, 3, 23], + [0, 0, 2, 3, 3, 23], + [0, 3, 0, 8, 4, 23], + [0, 1, 1, 8, 4, 23], + [0, 2, 0, 13, 5, 23], + [0, 0, 1, 13, 5, 23], + [0, 4, 0, 4, 4, 24], + [0, 2, 1, 4, 4, 24], + [0, 0, 2, 4, 4, 24], + [0, 3, 0, 9, 5, 24], + [0, 1, 1, 9, 5, 24], + [0, 0, 2, 5, 1, 25], + [0, 0, 1, 15, 3, 25], + [0, 0, 0, 25, 5, 25], + [0, 0, 2, 6, 2, 26], + [0, 0, 1, 16, 4, 26], + [0, 0, 2, 7, 3, 27], + [0, 0, 1, 17, 5, 27], + [0, 0, 2, 8, 4, 28], + [0, 0, 2, 9, 5, 29], + ], + 21: [ + [1, 0, 0, 20, 4, 3], + [2, 0, 0, 15, 3, 6], + [2, 3, 0, 0, 0, 21], + [2, 1, 1, 0, 0, 21], + [0, 4, 0, 1, 1, 21], + [0, 2, 1, 1, 1, 21], + [0, 0, 2, 1, 1, 21], + [2, 2, 0, 5, 1, 21], + [2, 0, 1, 5, 1, 21], + [3, 2, 0, 2, 2, 21], + [3, 0, 1, 2, 2, 21], + [0, 3, 0, 6, 2, 21], + [0, 1, 1, 6, 2, 21], + [2, 1, 0, 10, 2, 21], + [1, 3, 0, 3, 3, 21], + [1, 1, 1, 3, 3, 21], + [0, 2, 0, 11, 3, 21], + [0, 0, 1, 11, 3, 21], + [1, 2, 0, 8, 4, 21], + [1, 0, 1, 8, 4, 21], + [0, 1, 0, 16, 4, 21], + [0, 0, 0, 21, 5, 21], + [4, 2, 0, 0, 0, 22], + [4, 0, 1, 0, 0, 22], + [2, 3, 0, 1, 1, 22], + [2, 1, 1, 1, 1, 22], + [4, 1, 0, 5, 1, 22], + [0, 4, 0, 2, 2, 22], + [0, 2, 1, 2, 2, 22], + [0, 0, 2, 2, 2, 22], + [2, 2, 0, 6, 2, 22], + [2, 0, 1, 6, 2, 22], + [0, 3, 0, 7, 3, 22], + [0, 1, 1, 7, 3, 22], + [2, 1, 0, 11, 3, 22], + [1, 3, 0, 4, 4, 22], + [1, 1, 1, 4, 4, 22], + [0, 2, 0, 12, 4, 22], + [0, 0, 1, 12, 4, 22], + [0, 1, 0, 17, 5, 22], + [1, 4, 0, 0, 0, 23], + [1, 2, 1, 0, 0, 23], + [1, 0, 2, 0, 0, 23], + [4, 2, 0, 1, 1, 23], + [4, 0, 1, 1, 1, 23], + [1, 3, 0, 5, 1, 23], + [1, 1, 1, 5, 1, 23], + [2, 3, 0, 2, 2, 23], + [2, 1, 1, 2, 2, 23], + [1, 2, 0, 10, 2, 23], + [1, 0, 1, 10, 2, 23], + [0, 4, 0, 3, 3, 23], + [0, 2, 1, 3, 3, 23], + [0, 0, 2, 3, 3, 23], + [2, 2, 0, 7, 3, 23], + [2, 0, 1, 7, 3, 23], + [1, 1, 0, 15, 3, 23], + [0, 3, 0, 8, 4, 23], + [0, 1, 1, 8, 4, 23], + [0, 2, 0, 13, 5, 23], + [0, 0, 1, 13, 5, 23], + [0, 4, 0, 4, 4, 24], + [0, 2, 1, 4, 4, 24], + [0, 0, 2, 4, 4, 24], + [0, 3, 0, 9, 5, 24], + [0, 1, 1, 9, 5, 24], + [0, 5, 0, 0, 0, 25], + [0, 3, 1, 0, 0, 25], + [0, 1, 2, 0, 0, 25], + [0, 4, 0, 5, 1, 25], + [0, 2, 1, 5, 1, 25], + [0, 0, 2, 5, 1, 25], + [0, 3, 0, 10, 2, 25], + [0, 1, 1, 10, 2, 25], + [0, 2, 0, 15, 3, 25], + [0, 0, 1, 15, 3, 25], + [0, 1, 0, 20, 4, 25], + [0, 0, 0, 25, 5, 25], + [0, 0, 2, 6, 2, 26], + [0, 0, 1, 16, 4, 26], + [0, 0, 2, 7, 3, 27], + [0, 0, 1, 17, 5, 27], + [0, 0, 2, 8, 4, 28], + [0, 0, 2, 9, 5, 29], + [0, 0, 3, 0, 0, 30], + [0, 0, 2, 10, 2, 30], + [0, 0, 1, 20, 4, 30], + ], + 22: [ + [1, 0, 0, 20, 4, 3], + [4, 2, 0, 0, 0, 22], + [4, 0, 1, 0, 0, 22], + [2, 3, 0, 1, 1, 22], + [2, 1, 1, 1, 1, 22], + [4, 1, 0, 5, 1, 22], + [0, 4, 0, 2, 2, 22], + [0, 2, 1, 2, 2, 22], + [0, 0, 2, 2, 2, 22], + [2, 2, 0, 6, 2, 22], + [2, 0, 1, 6, 2, 22], + [0, 3, 0, 7, 3, 22], + [0, 1, 1, 7, 3, 22], + [2, 1, 0, 11, 3, 22], + [1, 3, 0, 4, 4, 22], + [1, 1, 1, 4, 4, 22], + [0, 2, 0, 12, 4, 22], + [0, 0, 1, 12, 4, 22], + [0, 1, 0, 17, 5, 22], + [1, 4, 0, 0, 0, 23], + [1, 2, 1, 0, 0, 23], + [1, 0, 2, 0, 0, 23], + [4, 2, 0, 1, 1, 23], + [4, 0, 1, 1, 1, 23], + [1, 3, 0, 5, 1, 23], + [1, 1, 1, 5, 1, 23], + [2, 3, 0, 2, 2, 23], + [2, 1, 1, 2, 2, 23], + [1, 2, 0, 10, 2, 23], + [1, 0, 1, 10, 2, 23], + [0, 4, 0, 3, 3, 23], + [0, 2, 1, 3, 3, 23], + [0, 0, 2, 3, 3, 23], + [2, 2, 0, 7, 3, 23], + [2, 0, 1, 7, 3, 23], + [1, 1, 0, 15, 3, 23], + [0, 3, 0, 8, 4, 23], + [0, 1, 1, 8, 4, 23], + [0, 2, 0, 13, 5, 23], + [0, 0, 1, 13, 5, 23], + [3, 3, 0, 0, 0, 24], + [3, 1, 1, 0, 0, 24], + [1, 4, 0, 1, 1, 24], + [1, 2, 1, 1, 1, 24], + [1, 0, 2, 1, 1, 24], + [3, 2, 0, 5, 1, 24], + [3, 0, 1, 5, 1, 24], + [1, 3, 0, 6, 2, 24], + [1, 1, 1, 6, 2, 24], + [3, 1, 0, 10, 2, 24], + [2, 3, 0, 3, 3, 24], + [2, 1, 1, 3, 3, 24], + [1, 2, 0, 11, 3, 24], + [1, 0, 1, 11, 3, 24], + [0, 4, 0, 4, 4, 24], + [0, 2, 1, 4, 4, 24], + [0, 0, 2, 4, 4, 24], + [1, 1, 0, 16, 4, 24], + [0, 3, 0, 9, 5, 24], + [0, 1, 1, 9, 5, 24], + [0, 5, 0, 0, 0, 25], + [0, 3, 1, 0, 0, 25], + [0, 1, 2, 0, 0, 25], + [0, 4, 0, 5, 1, 25], + [0, 2, 1, 5, 1, 25], + [0, 0, 2, 5, 1, 25], + [0, 3, 0, 10, 2, 25], + [0, 1, 1, 10, 2, 25], + [0, 2, 0, 15, 3, 25], + [0, 0, 1, 15, 3, 25], + [0, 1, 0, 20, 4, 25], + [0, 0, 0, 25, 5, 25], + [0, 5, 0, 1, 1, 26], + [0, 3, 1, 1, 1, 26], + [0, 1, 2, 1, 1, 26], + [0, 4, 0, 6, 2, 26], + [0, 2, 1, 6, 2, 26], + [0, 0, 2, 6, 2, 26], + [0, 3, 0, 11, 3, 26], + [0, 1, 1, 11, 3, 26], + [0, 2, 0, 16, 4, 26], + [0, 0, 1, 16, 4, 26], + [0, 1, 0, 21, 5, 26], + [0, 0, 2, 7, 3, 27], + [0, 0, 1, 17, 5, 27], + [0, 0, 2, 8, 4, 28], + [0, 0, 2, 9, 5, 29], + [0, 0, 3, 0, 0, 30], + [0, 0, 2, 10, 2, 30], + [0, 0, 1, 20, 4, 30], + [0, 0, 3, 1, 1, 31], + [0, 0, 2, 11, 3, 31], + [0, 0, 1, 21, 5, 31], + ], + 23: [ + [1, 0, 0, 20, 4, 3], + [1, 4, 0, 0, 0, 23], + [1, 2, 1, 0, 0, 23], + [1, 0, 2, 0, 0, 23], + [4, 2, 0, 1, 1, 23], + [4, 0, 1, 1, 1, 23], + [1, 3, 0, 5, 1, 23], + [1, 1, 1, 5, 1, 23], + [2, 3, 0, 2, 2, 23], + [2, 1, 1, 2, 2, 23], + [1, 2, 0, 10, 2, 23], + [1, 0, 1, 10, 2, 23], + [0, 4, 0, 3, 3, 23], + [0, 2, 1, 3, 3, 23], + [0, 0, 2, 3, 3, 23], + [2, 2, 0, 7, 3, 23], + [2, 0, 1, 7, 3, 23], + [1, 1, 0, 15, 3, 23], + [0, 3, 0, 8, 4, 23], + [0, 1, 1, 8, 4, 23], + [0, 2, 0, 13, 5, 23], + [0, 0, 1, 13, 5, 23], + [3, 3, 0, 0, 0, 24], + [3, 1, 1, 0, 0, 24], + [1, 4, 0, 1, 1, 24], + [1, 2, 1, 1, 1, 24], + [1, 0, 2, 1, 1, 24], + [3, 2, 0, 5, 1, 24], + [3, 0, 1, 5, 1, 24], + [1, 3, 0, 6, 2, 24], + [1, 1, 1, 6, 2, 24], + [3, 1, 0, 10, 2, 24], + [2, 3, 0, 3, 3, 24], + [2, 1, 1, 3, 3, 24], + [1, 2, 0, 11, 3, 24], + [1, 0, 1, 11, 3, 24], + [0, 4, 0, 4, 4, 24], + [0, 2, 1, 4, 4, 24], + [0, 0, 2, 4, 4, 24], + [1, 1, 0, 16, 4, 24], + [0, 3, 0, 9, 5, 24], + [0, 1, 1, 9, 5, 24], + [5, 2, 0, 0, 0, 25], + [0, 5, 0, 0, 0, 25], + [5, 0, 1, 0, 0, 25], + [0, 3, 1, 0, 0, 25], + [0, 1, 2, 0, 0, 25], + [3, 3, 0, 1, 1, 25], + [3, 1, 1, 1, 1, 25], + [0, 4, 0, 5, 1, 25], + [0, 2, 1, 5, 1, 25], + [0, 0, 2, 5, 1, 25], + [1, 4, 0, 2, 2, 25], + [1, 2, 1, 2, 2, 25], + [1, 0, 2, 2, 2, 25], + [3, 2, 0, 6, 2, 25], + [3, 0, 1, 6, 2, 25], + [0, 3, 0, 10, 2, 25], + [0, 1, 1, 10, 2, 25], + [1, 3, 0, 7, 3, 25], + [1, 1, 1, 7, 3, 25], + [0, 2, 0, 15, 3, 25], + [0, 0, 1, 15, 3, 25], + [1, 2, 0, 12, 4, 25], + [1, 0, 1, 12, 4, 25], + [0, 1, 0, 20, 4, 25], + [0, 0, 0, 25, 5, 25], + [0, 5, 0, 1, 1, 26], + [0, 3, 1, 1, 1, 26], + [0, 1, 2, 1, 1, 26], + [0, 4, 0, 6, 2, 26], + [0, 2, 1, 6, 2, 26], + [0, 0, 2, 6, 2, 26], + [0, 3, 0, 11, 3, 26], + [0, 1, 1, 11, 3, 26], + [0, 2, 0, 16, 4, 26], + [0, 0, 1, 16, 4, 26], + [0, 1, 0, 21, 5, 26], + [0, 5, 0, 2, 2, 27], + [0, 3, 1, 2, 2, 27], + [0, 1, 2, 2, 2, 27], + [0, 4, 0, 7, 3, 27], + [0, 2, 1, 7, 3, 27], + [0, 0, 2, 7, 3, 27], + [0, 3, 0, 12, 4, 27], + [0, 1, 1, 12, 4, 27], + [0, 2, 0, 17, 5, 27], + [0, 0, 1, 17, 5, 27], + [0, 0, 2, 8, 4, 28], + [0, 0, 2, 9, 5, 29], + [0, 0, 3, 0, 0, 30], + [0, 0, 2, 10, 2, 30], + [0, 0, 1, 20, 4, 30], + [0, 0, 3, 1, 1, 31], + [0, 0, 2, 11, 3, 31], + [0, 0, 1, 21, 5, 31], + [0, 0, 3, 2, 2, 32], + [0, 0, 2, 12, 4, 32], + ], + 24: [ + [3, 3, 0, 0, 0, 24], + [3, 1, 1, 0, 0, 24], + [1, 4, 0, 1, 1, 24], + [1, 2, 1, 1, 1, 24], + [1, 0, 2, 1, 1, 24], + [3, 2, 0, 5, 1, 24], + [3, 0, 1, 5, 1, 24], + [1, 3, 0, 6, 2, 24], + [1, 1, 1, 6, 2, 24], + [3, 1, 0, 10, 2, 24], + [2, 3, 0, 3, 3, 24], + [2, 1, 1, 3, 3, 24], + [1, 2, 0, 11, 3, 24], + [1, 0, 1, 11, 3, 24], + [0, 4, 0, 4, 4, 24], + [0, 2, 1, 4, 4, 24], + [0, 0, 2, 4, 4, 24], + [1, 1, 0, 16, 4, 24], + [0, 3, 0, 9, 5, 24], + [0, 1, 1, 9, 5, 24], + [5, 2, 0, 0, 0, 25], + [0, 5, 0, 0, 0, 25], + [5, 0, 1, 0, 0, 25], + [0, 3, 1, 0, 0, 25], + [0, 1, 2, 0, 0, 25], + [3, 3, 0, 1, 1, 25], + [3, 1, 1, 1, 1, 25], + [0, 4, 0, 5, 1, 25], + [0, 2, 1, 5, 1, 25], + [0, 0, 2, 5, 1, 25], + [1, 4, 0, 2, 2, 25], + [1, 2, 1, 2, 2, 25], + [1, 0, 2, 2, 2, 25], + [3, 2, 0, 6, 2, 25], + [3, 0, 1, 6, 2, 25], + [0, 3, 0, 10, 2, 25], + [0, 1, 1, 10, 2, 25], + [1, 3, 0, 7, 3, 25], + [1, 1, 1, 7, 3, 25], + [0, 2, 0, 15, 3, 25], + [0, 0, 1, 15, 3, 25], + [1, 2, 0, 12, 4, 25], + [1, 0, 1, 12, 4, 25], + [0, 1, 0, 20, 4, 25], + [0, 0, 0, 25, 5, 25], + [2, 4, 0, 0, 0, 26], + [2, 2, 1, 0, 0, 26], + [2, 0, 2, 0, 0, 26], + [0, 5, 0, 1, 1, 26], + [0, 3, 1, 1, 1, 26], + [0, 1, 2, 1, 1, 26], + [2, 3, 0, 5, 1, 26], + [2, 1, 1, 5, 1, 26], + [3, 3, 0, 2, 2, 26], + [3, 1, 1, 2, 2, 26], + [0, 4, 0, 6, 2, 26], + [0, 2, 1, 6, 2, 26], + [0, 0, 2, 6, 2, 26], + [2, 2, 0, 10, 2, 26], + [2, 0, 1, 10, 2, 26], + [1, 4, 0, 3, 3, 26], + [1, 2, 1, 3, 3, 26], + [1, 0, 2, 3, 3, 26], + [0, 3, 0, 11, 3, 26], + [0, 1, 1, 11, 3, 26], + [2, 1, 0, 15, 3, 26], + [1, 3, 0, 8, 4, 26], + [1, 1, 1, 8, 4, 26], + [0, 2, 0, 16, 4, 26], + [0, 0, 1, 16, 4, 26], + [0, 1, 0, 21, 5, 26], + [0, 5, 0, 2, 2, 27], + [0, 3, 1, 2, 2, 27], + [0, 1, 2, 2, 2, 27], + [0, 4, 0, 7, 3, 27], + [0, 2, 1, 7, 3, 27], + [0, 0, 2, 7, 3, 27], + [0, 3, 0, 12, 4, 27], + [0, 1, 1, 12, 4, 27], + [0, 2, 0, 17, 5, 27], + [0, 0, 1, 17, 5, 27], + [0, 5, 0, 3, 3, 28], + [0, 3, 1, 3, 3, 28], + [0, 1, 2, 3, 3, 28], + [0, 4, 0, 8, 4, 28], + [0, 2, 1, 8, 4, 28], + [0, 0, 2, 8, 4, 28], + [0, 3, 0, 13, 5, 28], + [0, 1, 1, 13, 5, 28], + [0, 0, 2, 9, 5, 29], + [0, 0, 3, 0, 0, 30], + [0, 0, 2, 10, 2, 30], + [0, 0, 1, 20, 4, 30], + [0, 0, 3, 1, 1, 31], + [0, 0, 2, 11, 3, 31], + [0, 0, 1, 21, 5, 31], + [0, 0, 3, 2, 2, 32], + [0, 0, 2, 12, 4, 32], + [0, 0, 3, 3, 3, 33], + [0, 0, 2, 13, 5, 33], + ], + 25: [ + [5, 2, 0, 0, 0, 25], + [0, 5, 0, 0, 0, 25], + [5, 0, 1, 0, 0, 25], + [0, 3, 1, 0, 0, 25], + [0, 1, 2, 0, 0, 25], + [3, 3, 0, 1, 1, 25], + [3, 1, 1, 1, 1, 25], + [0, 4, 0, 5, 1, 25], + [0, 2, 1, 5, 1, 25], + [0, 0, 2, 5, 1, 25], + [1, 4, 0, 2, 2, 25], + [1, 2, 1, 2, 2, 25], + [1, 0, 2, 2, 2, 25], + [3, 2, 0, 6, 2, 25], + [3, 0, 1, 6, 2, 25], + [0, 3, 0, 10, 2, 25], + [0, 1, 1, 10, 2, 25], + [1, 3, 0, 7, 3, 25], + [1, 1, 1, 7, 3, 25], + [0, 2, 0, 15, 3, 25], + [0, 0, 1, 15, 3, 25], + [1, 2, 0, 12, 4, 25], + [1, 0, 1, 12, 4, 25], + [0, 1, 0, 20, 4, 25], + [0, 0, 0, 25, 5, 25], + [2, 4, 0, 0, 0, 26], + [2, 2, 1, 0, 0, 26], + [2, 0, 2, 0, 0, 26], + [0, 5, 0, 1, 1, 26], + [0, 3, 1, 1, 1, 26], + [0, 1, 2, 1, 1, 26], + [2, 3, 0, 5, 1, 26], + [2, 1, 1, 5, 1, 26], + [3, 3, 0, 2, 2, 26], + [3, 1, 1, 2, 2, 26], + [0, 4, 0, 6, 2, 26], + [0, 2, 1, 6, 2, 26], + [0, 0, 2, 6, 2, 26], + [2, 2, 0, 10, 2, 26], + [2, 0, 1, 10, 2, 26], + [1, 4, 0, 3, 3, 26], + [1, 2, 1, 3, 3, 26], + [1, 0, 2, 3, 3, 26], + [0, 3, 0, 11, 3, 26], + [0, 1, 1, 11, 3, 26], + [2, 1, 0, 15, 3, 26], + [1, 3, 0, 8, 4, 26], + [1, 1, 1, 8, 4, 26], + [0, 2, 0, 16, 4, 26], + [0, 0, 1, 16, 4, 26], + [0, 1, 0, 21, 5, 26], + [4, 3, 0, 0, 0, 27], + [4, 1, 1, 0, 0, 27], + [2, 4, 0, 1, 1, 27], + [2, 2, 1, 1, 1, 27], + [2, 0, 2, 1, 1, 27], + [4, 2, 0, 5, 1, 27], + [4, 0, 1, 5, 1, 27], + [0, 5, 0, 2, 2, 27], + [0, 3, 1, 2, 2, 27], + [0, 1, 2, 2, 2, 27], + [2, 3, 0, 6, 2, 27], + [2, 1, 1, 6, 2, 27], + [0, 4, 0, 7, 3, 27], + [0, 2, 1, 7, 3, 27], + [0, 0, 2, 7, 3, 27], + [2, 2, 0, 11, 3, 27], + [2, 0, 1, 11, 3, 27], + [1, 4, 0, 4, 4, 27], + [1, 2, 1, 4, 4, 27], + [1, 0, 2, 4, 4, 27], + [0, 3, 0, 12, 4, 27], + [0, 1, 1, 12, 4, 27], + [0, 2, 0, 17, 5, 27], + [0, 0, 1, 17, 5, 27], + [0, 5, 0, 3, 3, 28], + [0, 3, 1, 3, 3, 28], + [0, 1, 2, 3, 3, 28], + [0, 4, 0, 8, 4, 28], + [0, 2, 1, 8, 4, 28], + [0, 0, 2, 8, 4, 28], + [0, 3, 0, 13, 5, 28], + [0, 1, 1, 13, 5, 28], + [0, 5, 0, 4, 4, 29], + [0, 3, 1, 4, 4, 29], + [0, 1, 2, 4, 4, 29], + [0, 4, 0, 9, 5, 29], + [0, 2, 1, 9, 5, 29], + [0, 0, 2, 9, 5, 29], + [0, 0, 3, 0, 0, 30], + [0, 0, 2, 10, 2, 30], + [0, 0, 1, 20, 4, 30], + [0, 0, 3, 1, 1, 31], + [0, 0, 2, 11, 3, 31], + [0, 0, 1, 21, 5, 31], + [0, 0, 3, 2, 2, 32], + [0, 0, 2, 12, 4, 32], + [0, 0, 3, 3, 3, 33], + [0, 0, 2, 13, 5, 33], + [0, 0, 3, 4, 4, 34], + ], + 26: [ + [2, 4, 0, 0, 0, 26], + [2, 2, 1, 0, 0, 26], + [2, 0, 2, 0, 0, 26], + [0, 5, 0, 1, 1, 26], + [0, 3, 1, 1, 1, 26], + [0, 1, 2, 1, 1, 26], + [2, 3, 0, 5, 1, 26], + [2, 1, 1, 5, 1, 26], + [3, 3, 0, 2, 2, 26], + [3, 1, 1, 2, 2, 26], + [0, 4, 0, 6, 2, 26], + [0, 2, 1, 6, 2, 26], + [0, 0, 2, 6, 2, 26], + [2, 2, 0, 10, 2, 26], + [2, 0, 1, 10, 2, 26], + [1, 4, 0, 3, 3, 26], + [1, 2, 1, 3, 3, 26], + [1, 0, 2, 3, 3, 26], + [0, 3, 0, 11, 3, 26], + [0, 1, 1, 11, 3, 26], + [2, 1, 0, 15, 3, 26], + [1, 3, 0, 8, 4, 26], + [1, 1, 1, 8, 4, 26], + [0, 2, 0, 16, 4, 26], + [0, 0, 1, 16, 4, 26], + [0, 1, 0, 21, 5, 26], + [4, 3, 0, 0, 0, 27], + [4, 1, 1, 0, 0, 27], + [2, 4, 0, 1, 1, 27], + [2, 2, 1, 1, 1, 27], + [2, 0, 2, 1, 1, 27], + [4, 2, 0, 5, 1, 27], + [4, 0, 1, 5, 1, 27], + [0, 5, 0, 2, 2, 27], + [0, 3, 1, 2, 2, 27], + [0, 1, 2, 2, 2, 27], + [2, 3, 0, 6, 2, 27], + [2, 1, 1, 6, 2, 27], + [0, 4, 0, 7, 3, 27], + [0, 2, 1, 7, 3, 27], + [0, 0, 2, 7, 3, 27], + [2, 2, 0, 11, 3, 27], + [2, 0, 1, 11, 3, 27], + [1, 4, 0, 4, 4, 27], + [1, 2, 1, 4, 4, 27], + [1, 0, 2, 4, 4, 27], + [0, 3, 0, 12, 4, 27], + [0, 1, 1, 12, 4, 27], + [0, 2, 0, 17, 5, 27], + [0, 0, 1, 17, 5, 27], + [1, 5, 0, 0, 0, 28], + [1, 3, 1, 0, 0, 28], + [1, 1, 2, 0, 0, 28], + [4, 3, 0, 1, 1, 28], + [4, 1, 1, 1, 1, 28], + [1, 4, 0, 5, 1, 28], + [1, 2, 1, 5, 1, 28], + [1, 0, 2, 5, 1, 28], + [2, 4, 0, 2, 2, 28], + [2, 2, 1, 2, 2, 28], + [2, 0, 2, 2, 2, 28], + [1, 3, 0, 10, 2, 28], + [1, 1, 1, 10, 2, 28], + [0, 5, 0, 3, 3, 28], + [0, 3, 1, 3, 3, 28], + [0, 1, 2, 3, 3, 28], + [2, 3, 0, 7, 3, 28], + [2, 1, 1, 7, 3, 28], + [1, 2, 0, 15, 3, 28], + [1, 0, 1, 15, 3, 28], + [0, 4, 0, 8, 4, 28], + [0, 2, 1, 8, 4, 28], + [0, 0, 2, 8, 4, 28], + [1, 1, 0, 20, 4, 28], + [0, 3, 0, 13, 5, 28], + [0, 1, 1, 13, 5, 28], + [0, 5, 0, 4, 4, 29], + [0, 3, 1, 4, 4, 29], + [0, 1, 2, 4, 4, 29], + [0, 4, 0, 9, 5, 29], + [0, 2, 1, 9, 5, 29], + [0, 0, 2, 9, 5, 29], + [0, 4, 1, 0, 0, 30], + [0, 2, 2, 0, 0, 30], + [0, 0, 3, 0, 0, 30], + [0, 5, 0, 5, 1, 30], + [0, 3, 1, 5, 1, 30], + [0, 1, 2, 5, 1, 30], + [0, 4, 0, 10, 2, 30], + [0, 2, 1, 10, 2, 30], + [0, 0, 2, 10, 2, 30], + [0, 3, 0, 15, 3, 30], + [0, 1, 1, 15, 3, 30], + [0, 2, 0, 20, 4, 30], + [0, 0, 1, 20, 4, 30], + [0, 1, 0, 25, 5, 30], + [0, 0, 3, 1, 1, 31], + [0, 0, 2, 11, 3, 31], + [0, 0, 1, 21, 5, 31], + [0, 0, 3, 2, 2, 32], + [0, 0, 2, 12, 4, 32], + [0, 0, 3, 3, 3, 33], + [0, 0, 2, 13, 5, 33], + [0, 0, 3, 4, 4, 34], + [0, 0, 3, 5, 1, 35], + [0, 0, 2, 15, 3, 35], + [0, 0, 1, 25, 5, 35], + ], + 27: [ + [4, 3, 0, 0, 0, 27], + [4, 1, 1, 0, 0, 27], + [2, 4, 0, 1, 1, 27], + [2, 2, 1, 1, 1, 27], + [2, 0, 2, 1, 1, 27], + [4, 2, 0, 5, 1, 27], + [4, 0, 1, 5, 1, 27], + [0, 5, 0, 2, 2, 27], + [0, 3, 1, 2, 2, 27], + [0, 1, 2, 2, 2, 27], + [2, 3, 0, 6, 2, 27], + [2, 1, 1, 6, 2, 27], + [0, 4, 0, 7, 3, 27], + [0, 2, 1, 7, 3, 27], + [0, 0, 2, 7, 3, 27], + [2, 2, 0, 11, 3, 27], + [2, 0, 1, 11, 3, 27], + [1, 4, 0, 4, 4, 27], + [1, 2, 1, 4, 4, 27], + [1, 0, 2, 4, 4, 27], + [0, 3, 0, 12, 4, 27], + [0, 1, 1, 12, 4, 27], + [0, 2, 0, 17, 5, 27], + [0, 0, 1, 17, 5, 27], + [1, 5, 0, 0, 0, 28], + [1, 3, 1, 0, 0, 28], + [1, 1, 2, 0, 0, 28], + [4, 3, 0, 1, 1, 28], + [4, 1, 1, 1, 1, 28], + [1, 4, 0, 5, 1, 28], + [1, 2, 1, 5, 1, 28], + [1, 0, 2, 5, 1, 28], + [2, 4, 0, 2, 2, 28], + [2, 2, 1, 2, 2, 28], + [2, 0, 2, 2, 2, 28], + [1, 3, 0, 10, 2, 28], + [1, 1, 1, 10, 2, 28], + [0, 5, 0, 3, 3, 28], + [0, 3, 1, 3, 3, 28], + [0, 1, 2, 3, 3, 28], + [2, 3, 0, 7, 3, 28], + [2, 1, 1, 7, 3, 28], + [1, 2, 0, 15, 3, 28], + [1, 0, 1, 15, 3, 28], + [0, 4, 0, 8, 4, 28], + [0, 2, 1, 8, 4, 28], + [0, 0, 2, 8, 4, 28], + [1, 1, 0, 20, 4, 28], + [0, 3, 0, 13, 5, 28], + [0, 1, 1, 13, 5, 28], + [3, 4, 0, 0, 0, 29], + [3, 2, 1, 0, 0, 29], + [3, 0, 2, 0, 0, 29], + [1, 5, 0, 1, 1, 29], + [1, 3, 1, 1, 1, 29], + [1, 1, 2, 1, 1, 29], + [3, 3, 0, 5, 1, 29], + [3, 1, 1, 5, 1, 29], + [1, 4, 0, 6, 2, 29], + [1, 2, 1, 6, 2, 29], + [1, 0, 2, 6, 2, 29], + [3, 2, 0, 10, 2, 29], + [3, 0, 1, 10, 2, 29], + [2, 4, 0, 3, 3, 29], + [2, 2, 1, 3, 3, 29], + [2, 0, 2, 3, 3, 29], + [1, 3, 0, 11, 3, 29], + [1, 1, 1, 11, 3, 29], + [0, 5, 0, 4, 4, 29], + [0, 3, 1, 4, 4, 29], + [0, 1, 2, 4, 4, 29], + [1, 2, 0, 16, 4, 29], + [1, 0, 1, 16, 4, 29], + [0, 4, 0, 9, 5, 29], + [0, 2, 1, 9, 5, 29], + [0, 0, 2, 9, 5, 29], + [0, 4, 1, 0, 0, 30], + [0, 2, 2, 0, 0, 30], + [0, 0, 3, 0, 0, 30], + [0, 5, 0, 5, 1, 30], + [0, 3, 1, 5, 1, 30], + [0, 1, 2, 5, 1, 30], + [0, 4, 0, 10, 2, 30], + [0, 2, 1, 10, 2, 30], + [0, 0, 2, 10, 2, 30], + [0, 3, 0, 15, 3, 30], + [0, 1, 1, 15, 3, 30], + [0, 2, 0, 20, 4, 30], + [0, 0, 1, 20, 4, 30], + [0, 1, 0, 25, 5, 30], + [0, 4, 1, 1, 1, 31], + [0, 2, 2, 1, 1, 31], + [0, 0, 3, 1, 1, 31], + [0, 5, 0, 6, 2, 31], + [0, 3, 1, 6, 2, 31], + [0, 1, 2, 6, 2, 31], + [0, 4, 0, 11, 3, 31], + [0, 2, 1, 11, 3, 31], + [0, 0, 2, 11, 3, 31], + [0, 3, 0, 16, 4, 31], + [0, 1, 1, 16, 4, 31], + [0, 2, 0, 21, 5, 31], + [0, 0, 1, 21, 5, 31], + [0, 0, 3, 2, 2, 32], + [0, 0, 2, 12, 4, 32], + [0, 0, 3, 3, 3, 33], + [0, 0, 2, 13, 5, 33], + [0, 0, 3, 4, 4, 34], + [0, 0, 3, 5, 1, 35], + [0, 0, 2, 15, 3, 35], + [0, 0, 1, 25, 5, 35], + [0, 0, 3, 6, 2, 36], + [0, 0, 2, 16, 4, 36], + ], + 28: [ + [1, 5, 0, 0, 0, 28], + [1, 3, 1, 0, 0, 28], + [1, 1, 2, 0, 0, 28], + [4, 3, 0, 1, 1, 28], + [4, 1, 1, 1, 1, 28], + [1, 4, 0, 5, 1, 28], + [1, 2, 1, 5, 1, 28], + [1, 0, 2, 5, 1, 28], + [2, 4, 0, 2, 2, 28], + [2, 2, 1, 2, 2, 28], + [2, 0, 2, 2, 2, 28], + [1, 3, 0, 10, 2, 28], + [1, 1, 1, 10, 2, 28], + [0, 5, 0, 3, 3, 28], + [0, 3, 1, 3, 3, 28], + [0, 1, 2, 3, 3, 28], + [2, 3, 0, 7, 3, 28], + [2, 1, 1, 7, 3, 28], + [1, 2, 0, 15, 3, 28], + [1, 0, 1, 15, 3, 28], + [0, 4, 0, 8, 4, 28], + [0, 2, 1, 8, 4, 28], + [0, 0, 2, 8, 4, 28], + [1, 1, 0, 20, 4, 28], + [0, 3, 0, 13, 5, 28], + [0, 1, 1, 13, 5, 28], + [3, 4, 0, 0, 0, 29], + [3, 2, 1, 0, 0, 29], + [3, 0, 2, 0, 0, 29], + [1, 5, 0, 1, 1, 29], + [1, 3, 1, 1, 1, 29], + [1, 1, 2, 1, 1, 29], + [3, 3, 0, 5, 1, 29], + [3, 1, 1, 5, 1, 29], + [1, 4, 0, 6, 2, 29], + [1, 2, 1, 6, 2, 29], + [1, 0, 2, 6, 2, 29], + [3, 2, 0, 10, 2, 29], + [3, 0, 1, 10, 2, 29], + [2, 4, 0, 3, 3, 29], + [2, 2, 1, 3, 3, 29], + [2, 0, 2, 3, 3, 29], + [1, 3, 0, 11, 3, 29], + [1, 1, 1, 11, 3, 29], + [0, 5, 0, 4, 4, 29], + [0, 3, 1, 4, 4, 29], + [0, 1, 2, 4, 4, 29], + [1, 2, 0, 16, 4, 29], + [1, 0, 1, 16, 4, 29], + [0, 4, 0, 9, 5, 29], + [0, 2, 1, 9, 5, 29], + [0, 0, 2, 9, 5, 29], + [5, 3, 0, 0, 0, 30], + [5, 1, 1, 0, 0, 30], + [0, 4, 1, 0, 0, 30], + [0, 2, 2, 0, 0, 30], + [0, 0, 3, 0, 0, 30], + [3, 4, 0, 1, 1, 30], + [3, 2, 1, 1, 1, 30], + [3, 0, 2, 1, 1, 30], + [0, 5, 0, 5, 1, 30], + [0, 3, 1, 5, 1, 30], + [0, 1, 2, 5, 1, 30], + [1, 5, 0, 2, 2, 30], + [1, 3, 1, 2, 2, 30], + [1, 1, 2, 2, 2, 30], + [3, 3, 0, 6, 2, 30], + [3, 1, 1, 6, 2, 30], + [0, 4, 0, 10, 2, 30], + [0, 2, 1, 10, 2, 30], + [0, 0, 2, 10, 2, 30], + [1, 4, 0, 7, 3, 30], + [1, 2, 1, 7, 3, 30], + [1, 0, 2, 7, 3, 30], + [0, 3, 0, 15, 3, 30], + [0, 1, 1, 15, 3, 30], + [1, 3, 0, 12, 4, 30], + [1, 1, 1, 12, 4, 30], + [0, 2, 0, 20, 4, 30], + [0, 0, 1, 20, 4, 30], + [0, 1, 0, 25, 5, 30], + [0, 4, 1, 1, 1, 31], + [0, 2, 2, 1, 1, 31], + [0, 0, 3, 1, 1, 31], + [0, 5, 0, 6, 2, 31], + [0, 3, 1, 6, 2, 31], + [0, 1, 2, 6, 2, 31], + [0, 4, 0, 11, 3, 31], + [0, 2, 1, 11, 3, 31], + [0, 0, 2, 11, 3, 31], + [0, 3, 0, 16, 4, 31], + [0, 1, 1, 16, 4, 31], + [0, 2, 0, 21, 5, 31], + [0, 0, 1, 21, 5, 31], + [0, 4, 1, 2, 2, 32], + [0, 2, 2, 2, 2, 32], + [0, 0, 3, 2, 2, 32], + [0, 5, 0, 7, 3, 32], + [0, 3, 1, 7, 3, 32], + [0, 1, 2, 7, 3, 32], + [0, 4, 0, 12, 4, 32], + [0, 2, 1, 12, 4, 32], + [0, 0, 2, 12, 4, 32], + [0, 3, 0, 17, 5, 32], + [0, 1, 1, 17, 5, 32], + [0, 0, 3, 3, 3, 33], + [0, 0, 2, 13, 5, 33], + [0, 0, 3, 4, 4, 34], + [0, 0, 3, 5, 1, 35], + [0, 0, 2, 15, 3, 35], + [0, 0, 1, 25, 5, 35], + [0, 0, 3, 6, 2, 36], + [0, 0, 2, 16, 4, 36], + [0, 0, 3, 7, 3, 37], + [0, 0, 2, 17, 5, 37], + ], + 29: [ + [3, 4, 0, 0, 0, 29], + [3, 2, 1, 0, 0, 29], + [3, 0, 2, 0, 0, 29], + [1, 5, 0, 1, 1, 29], + [1, 3, 1, 1, 1, 29], + [1, 1, 2, 1, 1, 29], + [3, 3, 0, 5, 1, 29], + [3, 1, 1, 5, 1, 29], + [1, 4, 0, 6, 2, 29], + [1, 2, 1, 6, 2, 29], + [1, 0, 2, 6, 2, 29], + [3, 2, 0, 10, 2, 29], + [3, 0, 1, 10, 2, 29], + [2, 4, 0, 3, 3, 29], + [2, 2, 1, 3, 3, 29], + [2, 0, 2, 3, 3, 29], + [1, 3, 0, 11, 3, 29], + [1, 1, 1, 11, 3, 29], + [0, 5, 0, 4, 4, 29], + [0, 3, 1, 4, 4, 29], + [0, 1, 2, 4, 4, 29], + [1, 2, 0, 16, 4, 29], + [1, 0, 1, 16, 4, 29], + [0, 4, 0, 9, 5, 29], + [0, 2, 1, 9, 5, 29], + [0, 0, 2, 9, 5, 29], + [5, 3, 0, 0, 0, 30], + [5, 1, 1, 0, 0, 30], + [0, 4, 1, 0, 0, 30], + [0, 2, 2, 0, 0, 30], + [0, 0, 3, 0, 0, 30], + [3, 4, 0, 1, 1, 30], + [3, 2, 1, 1, 1, 30], + [3, 0, 2, 1, 1, 30], + [0, 5, 0, 5, 1, 30], + [0, 3, 1, 5, 1, 30], + [0, 1, 2, 5, 1, 30], + [1, 5, 0, 2, 2, 30], + [1, 3, 1, 2, 2, 30], + [1, 1, 2, 2, 2, 30], + [3, 3, 0, 6, 2, 30], + [3, 1, 1, 6, 2, 30], + [0, 4, 0, 10, 2, 30], + [0, 2, 1, 10, 2, 30], + [0, 0, 2, 10, 2, 30], + [1, 4, 0, 7, 3, 30], + [1, 2, 1, 7, 3, 30], + [1, 0, 2, 7, 3, 30], + [0, 3, 0, 15, 3, 30], + [0, 1, 1, 15, 3, 30], + [1, 3, 0, 12, 4, 30], + [1, 1, 1, 12, 4, 30], + [0, 2, 0, 20, 4, 30], + [0, 0, 1, 20, 4, 30], + [0, 1, 0, 25, 5, 30], + [2, 5, 0, 0, 0, 31], + [2, 3, 1, 0, 0, 31], + [2, 1, 2, 0, 0, 31], + [0, 4, 1, 1, 1, 31], + [0, 2, 2, 1, 1, 31], + [0, 0, 3, 1, 1, 31], + [2, 4, 0, 5, 1, 31], + [2, 2, 1, 5, 1, 31], + [2, 0, 2, 5, 1, 31], + [3, 4, 0, 2, 2, 31], + [3, 2, 1, 2, 2, 31], + [3, 0, 2, 2, 2, 31], + [0, 5, 0, 6, 2, 31], + [0, 3, 1, 6, 2, 31], + [0, 1, 2, 6, 2, 31], + [2, 3, 0, 10, 2, 31], + [2, 1, 1, 10, 2, 31], + [1, 5, 0, 3, 3, 31], + [1, 3, 1, 3, 3, 31], + [1, 1, 2, 3, 3, 31], + [0, 4, 0, 11, 3, 31], + [0, 2, 1, 11, 3, 31], + [0, 0, 2, 11, 3, 31], + [2, 2, 0, 15, 3, 31], + [2, 0, 1, 15, 3, 31], + [1, 4, 0, 8, 4, 31], + [1, 2, 1, 8, 4, 31], + [1, 0, 2, 8, 4, 31], + [0, 3, 0, 16, 4, 31], + [0, 1, 1, 16, 4, 31], + [0, 2, 0, 21, 5, 31], + [0, 0, 1, 21, 5, 31], + [0, 4, 1, 2, 2, 32], + [0, 2, 2, 2, 2, 32], + [0, 0, 3, 2, 2, 32], + [0, 5, 0, 7, 3, 32], + [0, 3, 1, 7, 3, 32], + [0, 1, 2, 7, 3, 32], + [0, 4, 0, 12, 4, 32], + [0, 2, 1, 12, 4, 32], + [0, 0, 2, 12, 4, 32], + [0, 3, 0, 17, 5, 32], + [0, 1, 1, 17, 5, 32], + [0, 4, 1, 3, 3, 33], + [0, 2, 2, 3, 3, 33], + [0, 0, 3, 3, 3, 33], + [0, 5, 0, 8, 4, 33], + [0, 3, 1, 8, 4, 33], + [0, 1, 2, 8, 4, 33], + [0, 4, 0, 13, 5, 33], + [0, 2, 1, 13, 5, 33], + [0, 0, 2, 13, 5, 33], + [0, 0, 3, 4, 4, 34], + [0, 0, 3, 5, 1, 35], + [0, 0, 2, 15, 3, 35], + [0, 0, 1, 25, 5, 35], + [0, 0, 3, 6, 2, 36], + [0, 0, 2, 16, 4, 36], + [0, 0, 3, 7, 3, 37], + [0, 0, 2, 17, 5, 37], + [0, 0, 3, 8, 4, 38], + ], + 30: [ + [5, 3, 0, 0, 0, 30], + [5, 1, 1, 0, 0, 30], + [0, 4, 1, 0, 0, 30], + [0, 2, 2, 0, 0, 30], + [0, 0, 3, 0, 0, 30], + [3, 4, 0, 1, 1, 30], + [3, 2, 1, 1, 1, 30], + [3, 0, 2, 1, 1, 30], + [0, 5, 0, 5, 1, 30], + [0, 3, 1, 5, 1, 30], + [0, 1, 2, 5, 1, 30], + [1, 5, 0, 2, 2, 30], + [1, 3, 1, 2, 2, 30], + [1, 1, 2, 2, 2, 30], + [3, 3, 0, 6, 2, 30], + [3, 1, 1, 6, 2, 30], + [0, 4, 0, 10, 2, 30], + [0, 2, 1, 10, 2, 30], + [0, 0, 2, 10, 2, 30], + [1, 4, 0, 7, 3, 30], + [1, 2, 1, 7, 3, 30], + [1, 0, 2, 7, 3, 30], + [0, 3, 0, 15, 3, 30], + [0, 1, 1, 15, 3, 30], + [1, 3, 0, 12, 4, 30], + [1, 1, 1, 12, 4, 30], + [0, 2, 0, 20, 4, 30], + [0, 0, 1, 20, 4, 30], + [0, 1, 0, 25, 5, 30], + [2, 5, 0, 0, 0, 31], + [2, 3, 1, 0, 0, 31], + [2, 1, 2, 0, 0, 31], + [0, 4, 1, 1, 1, 31], + [0, 2, 2, 1, 1, 31], + [0, 0, 3, 1, 1, 31], + [2, 4, 0, 5, 1, 31], + [2, 2, 1, 5, 1, 31], + [2, 0, 2, 5, 1, 31], + [3, 4, 0, 2, 2, 31], + [3, 2, 1, 2, 2, 31], + [3, 0, 2, 2, 2, 31], + [0, 5, 0, 6, 2, 31], + [0, 3, 1, 6, 2, 31], + [0, 1, 2, 6, 2, 31], + [2, 3, 0, 10, 2, 31], + [2, 1, 1, 10, 2, 31], + [1, 5, 0, 3, 3, 31], + [1, 3, 1, 3, 3, 31], + [1, 1, 2, 3, 3, 31], + [0, 4, 0, 11, 3, 31], + [0, 2, 1, 11, 3, 31], + [0, 0, 2, 11, 3, 31], + [2, 2, 0, 15, 3, 31], + [2, 0, 1, 15, 3, 31], + [1, 4, 0, 8, 4, 31], + [1, 2, 1, 8, 4, 31], + [1, 0, 2, 8, 4, 31], + [0, 3, 0, 16, 4, 31], + [0, 1, 1, 16, 4, 31], + [0, 2, 0, 21, 5, 31], + [0, 0, 1, 21, 5, 31], + [4, 4, 0, 0, 0, 32], + [4, 2, 1, 0, 0, 32], + [4, 0, 2, 0, 0, 32], + [2, 5, 0, 1, 1, 32], + [2, 3, 1, 1, 1, 32], + [2, 1, 2, 1, 1, 32], + [4, 3, 0, 5, 1, 32], + [4, 1, 1, 5, 1, 32], + [0, 4, 1, 2, 2, 32], + [0, 2, 2, 2, 2, 32], + [0, 0, 3, 2, 2, 32], + [2, 4, 0, 6, 2, 32], + [2, 2, 1, 6, 2, 32], + [2, 0, 2, 6, 2, 32], + [0, 5, 0, 7, 3, 32], + [0, 3, 1, 7, 3, 32], + [0, 1, 2, 7, 3, 32], + [2, 3, 0, 11, 3, 32], + [2, 1, 1, 11, 3, 32], + [1, 5, 0, 4, 4, 32], + [1, 3, 1, 4, 4, 32], + [1, 1, 2, 4, 4, 32], + [0, 4, 0, 12, 4, 32], + [0, 2, 1, 12, 4, 32], + [0, 0, 2, 12, 4, 32], + [0, 3, 0, 17, 5, 32], + [0, 1, 1, 17, 5, 32], + [0, 4, 1, 3, 3, 33], + [0, 2, 2, 3, 3, 33], + [0, 0, 3, 3, 3, 33], + [0, 5, 0, 8, 4, 33], + [0, 3, 1, 8, 4, 33], + [0, 1, 2, 8, 4, 33], + [0, 4, 0, 13, 5, 33], + [0, 2, 1, 13, 5, 33], + [0, 0, 2, 13, 5, 33], + [0, 4, 1, 4, 4, 34], + [0, 2, 2, 4, 4, 34], + [0, 0, 3, 4, 4, 34], + [0, 5, 0, 9, 5, 34], + [0, 3, 1, 9, 5, 34], + [0, 1, 2, 9, 5, 34], + [0, 0, 3, 5, 1, 35], + [0, 0, 2, 15, 3, 35], + [0, 0, 1, 25, 5, 35], + [0, 0, 3, 6, 2, 36], + [0, 0, 2, 16, 4, 36], + [0, 0, 3, 7, 3, 37], + [0, 0, 2, 17, 5, 37], + [0, 0, 3, 8, 4, 38], + [0, 0, 3, 9, 5, 39], + ], + 31: [ + [2, 5, 0, 0, 0, 31], + [2, 3, 1, 0, 0, 31], + [2, 1, 2, 0, 0, 31], + [0, 4, 1, 1, 1, 31], + [0, 2, 2, 1, 1, 31], + [0, 0, 3, 1, 1, 31], + [2, 4, 0, 5, 1, 31], + [2, 2, 1, 5, 1, 31], + [2, 0, 2, 5, 1, 31], + [3, 4, 0, 2, 2, 31], + [3, 2, 1, 2, 2, 31], + [3, 0, 2, 2, 2, 31], + [0, 5, 0, 6, 2, 31], + [0, 3, 1, 6, 2, 31], + [0, 1, 2, 6, 2, 31], + [2, 3, 0, 10, 2, 31], + [2, 1, 1, 10, 2, 31], + [1, 5, 0, 3, 3, 31], + [1, 3, 1, 3, 3, 31], + [1, 1, 2, 3, 3, 31], + [0, 4, 0, 11, 3, 31], + [0, 2, 1, 11, 3, 31], + [0, 0, 2, 11, 3, 31], + [2, 2, 0, 15, 3, 31], + [2, 0, 1, 15, 3, 31], + [1, 4, 0, 8, 4, 31], + [1, 2, 1, 8, 4, 31], + [1, 0, 2, 8, 4, 31], + [0, 3, 0, 16, 4, 31], + [0, 1, 1, 16, 4, 31], + [0, 2, 0, 21, 5, 31], + [0, 0, 1, 21, 5, 31], + [4, 4, 0, 0, 0, 32], + [4, 2, 1, 0, 0, 32], + [4, 0, 2, 0, 0, 32], + [2, 5, 0, 1, 1, 32], + [2, 3, 1, 1, 1, 32], + [2, 1, 2, 1, 1, 32], + [4, 3, 0, 5, 1, 32], + [4, 1, 1, 5, 1, 32], + [0, 4, 1, 2, 2, 32], + [0, 2, 2, 2, 2, 32], + [0, 0, 3, 2, 2, 32], + [2, 4, 0, 6, 2, 32], + [2, 2, 1, 6, 2, 32], + [2, 0, 2, 6, 2, 32], + [0, 5, 0, 7, 3, 32], + [0, 3, 1, 7, 3, 32], + [0, 1, 2, 7, 3, 32], + [2, 3, 0, 11, 3, 32], + [2, 1, 1, 11, 3, 32], + [1, 5, 0, 4, 4, 32], + [1, 3, 1, 4, 4, 32], + [1, 1, 2, 4, 4, 32], + [0, 4, 0, 12, 4, 32], + [0, 2, 1, 12, 4, 32], + [0, 0, 2, 12, 4, 32], + [0, 3, 0, 17, 5, 32], + [0, 1, 1, 17, 5, 32], + [1, 4, 1, 0, 0, 33], + [1, 2, 2, 0, 0, 33], + [1, 0, 3, 0, 0, 33], + [4, 4, 0, 1, 1, 33], + [4, 2, 1, 1, 1, 33], + [4, 0, 2, 1, 1, 33], + [1, 5, 0, 5, 1, 33], + [1, 3, 1, 5, 1, 33], + [1, 1, 2, 5, 1, 33], + [2, 5, 0, 2, 2, 33], + [2, 3, 1, 2, 2, 33], + [2, 1, 2, 2, 2, 33], + [1, 4, 0, 10, 2, 33], + [1, 2, 1, 10, 2, 33], + [1, 0, 2, 10, 2, 33], + [0, 4, 1, 3, 3, 33], + [0, 2, 2, 3, 3, 33], + [0, 0, 3, 3, 3, 33], + [2, 4, 0, 7, 3, 33], + [2, 2, 1, 7, 3, 33], + [2, 0, 2, 7, 3, 33], + [1, 3, 0, 15, 3, 33], + [1, 1, 1, 15, 3, 33], + [0, 5, 0, 8, 4, 33], + [0, 3, 1, 8, 4, 33], + [0, 1, 2, 8, 4, 33], + [1, 2, 0, 20, 4, 33], + [1, 0, 1, 20, 4, 33], + [0, 4, 0, 13, 5, 33], + [0, 2, 1, 13, 5, 33], + [0, 0, 2, 13, 5, 33], + [0, 4, 1, 4, 4, 34], + [0, 2, 2, 4, 4, 34], + [0, 0, 3, 4, 4, 34], + [0, 5, 0, 9, 5, 34], + [0, 3, 1, 9, 5, 34], + [0, 1, 2, 9, 5, 34], + [0, 3, 2, 0, 0, 35], + [0, 1, 3, 0, 0, 35], + [0, 4, 1, 5, 1, 35], + [0, 2, 2, 5, 1, 35], + [0, 0, 3, 5, 1, 35], + [0, 5, 0, 10, 2, 35], + [0, 3, 1, 10, 2, 35], + [0, 1, 2, 10, 2, 35], + [0, 4, 0, 15, 3, 35], + [0, 2, 1, 15, 3, 35], + [0, 0, 2, 15, 3, 35], + [0, 3, 0, 20, 4, 35], + [0, 1, 1, 20, 4, 35], + [0, 2, 0, 25, 5, 35], + [0, 0, 1, 25, 5, 35], + [0, 0, 3, 6, 2, 36], + [0, 0, 2, 16, 4, 36], + [0, 0, 3, 7, 3, 37], + [0, 0, 2, 17, 5, 37], + [0, 0, 3, 8, 4, 38], + [0, 0, 3, 9, 5, 39], + [0, 0, 4, 0, 0, 40], + [0, 0, 3, 10, 2, 40], + [0, 0, 2, 20, 4, 40], + ], + 32: [ + [4, 4, 0, 0, 0, 32], + [4, 2, 1, 0, 0, 32], + [4, 0, 2, 0, 0, 32], + [2, 5, 0, 1, 1, 32], + [2, 3, 1, 1, 1, 32], + [2, 1, 2, 1, 1, 32], + [4, 3, 0, 5, 1, 32], + [4, 1, 1, 5, 1, 32], + [0, 4, 1, 2, 2, 32], + [0, 2, 2, 2, 2, 32], + [0, 0, 3, 2, 2, 32], + [2, 4, 0, 6, 2, 32], + [2, 2, 1, 6, 2, 32], + [2, 0, 2, 6, 2, 32], + [0, 5, 0, 7, 3, 32], + [0, 3, 1, 7, 3, 32], + [0, 1, 2, 7, 3, 32], + [2, 3, 0, 11, 3, 32], + [2, 1, 1, 11, 3, 32], + [1, 5, 0, 4, 4, 32], + [1, 3, 1, 4, 4, 32], + [1, 1, 2, 4, 4, 32], + [0, 4, 0, 12, 4, 32], + [0, 2, 1, 12, 4, 32], + [0, 0, 2, 12, 4, 32], + [0, 3, 0, 17, 5, 32], + [0, 1, 1, 17, 5, 32], + [1, 4, 1, 0, 0, 33], + [1, 2, 2, 0, 0, 33], + [1, 0, 3, 0, 0, 33], + [4, 4, 0, 1, 1, 33], + [4, 2, 1, 1, 1, 33], + [4, 0, 2, 1, 1, 33], + [1, 5, 0, 5, 1, 33], + [1, 3, 1, 5, 1, 33], + [1, 1, 2, 5, 1, 33], + [2, 5, 0, 2, 2, 33], + [2, 3, 1, 2, 2, 33], + [2, 1, 2, 2, 2, 33], + [1, 4, 0, 10, 2, 33], + [1, 2, 1, 10, 2, 33], + [1, 0, 2, 10, 2, 33], + [0, 4, 1, 3, 3, 33], + [0, 2, 2, 3, 3, 33], + [0, 0, 3, 3, 3, 33], + [2, 4, 0, 7, 3, 33], + [2, 2, 1, 7, 3, 33], + [2, 0, 2, 7, 3, 33], + [1, 3, 0, 15, 3, 33], + [1, 1, 1, 15, 3, 33], + [0, 5, 0, 8, 4, 33], + [0, 3, 1, 8, 4, 33], + [0, 1, 2, 8, 4, 33], + [1, 2, 0, 20, 4, 33], + [1, 0, 1, 20, 4, 33], + [0, 4, 0, 13, 5, 33], + [0, 2, 1, 13, 5, 33], + [0, 0, 2, 13, 5, 33], + [3, 5, 0, 0, 0, 34], + [3, 3, 1, 0, 0, 34], + [3, 1, 2, 0, 0, 34], + [1, 4, 1, 1, 1, 34], + [1, 2, 2, 1, 1, 34], + [1, 0, 3, 1, 1, 34], + [3, 4, 0, 5, 1, 34], + [3, 2, 1, 5, 1, 34], + [3, 0, 2, 5, 1, 34], + [1, 5, 0, 6, 2, 34], + [1, 3, 1, 6, 2, 34], + [1, 1, 2, 6, 2, 34], + [3, 3, 0, 10, 2, 34], + [3, 1, 1, 10, 2, 34], + [2, 5, 0, 3, 3, 34], + [2, 3, 1, 3, 3, 34], + [2, 1, 2, 3, 3, 34], + [1, 4, 0, 11, 3, 34], + [1, 2, 1, 11, 3, 34], + [1, 0, 2, 11, 3, 34], + [0, 4, 1, 4, 4, 34], + [0, 2, 2, 4, 4, 34], + [0, 0, 3, 4, 4, 34], + [1, 3, 0, 16, 4, 34], + [1, 1, 1, 16, 4, 34], + [0, 5, 0, 9, 5, 34], + [0, 3, 1, 9, 5, 34], + [0, 1, 2, 9, 5, 34], + [0, 3, 2, 0, 0, 35], + [0, 1, 3, 0, 0, 35], + [0, 4, 1, 5, 1, 35], + [0, 2, 2, 5, 1, 35], + [0, 0, 3, 5, 1, 35], + [0, 5, 0, 10, 2, 35], + [0, 3, 1, 10, 2, 35], + [0, 1, 2, 10, 2, 35], + [0, 4, 0, 15, 3, 35], + [0, 2, 1, 15, 3, 35], + [0, 0, 2, 15, 3, 35], + [0, 3, 0, 20, 4, 35], + [0, 1, 1, 20, 4, 35], + [0, 2, 0, 25, 5, 35], + [0, 0, 1, 25, 5, 35], + [0, 3, 2, 1, 1, 36], + [0, 1, 3, 1, 1, 36], + [0, 4, 1, 6, 2, 36], + [0, 2, 2, 6, 2, 36], + [0, 0, 3, 6, 2, 36], + [0, 5, 0, 11, 3, 36], + [0, 3, 1, 11, 3, 36], + [0, 1, 2, 11, 3, 36], + [0, 4, 0, 16, 4, 36], + [0, 2, 1, 16, 4, 36], + [0, 0, 2, 16, 4, 36], + [0, 3, 0, 21, 5, 36], + [0, 1, 1, 21, 5, 36], + [0, 0, 3, 7, 3, 37], + [0, 0, 2, 17, 5, 37], + [0, 0, 3, 8, 4, 38], + [0, 0, 3, 9, 5, 39], + [0, 0, 4, 0, 0, 40], + [0, 0, 3, 10, 2, 40], + [0, 0, 2, 20, 4, 40], + [0, 0, 4, 1, 1, 41], + [0, 0, 3, 11, 3, 41], + [0, 0, 2, 21, 5, 41], + ], + 33: [ + [1, 4, 1, 0, 0, 33], + [1, 2, 2, 0, 0, 33], + [1, 0, 3, 0, 0, 33], + [4, 4, 0, 1, 1, 33], + [4, 2, 1, 1, 1, 33], + [4, 0, 2, 1, 1, 33], + [1, 5, 0, 5, 1, 33], + [1, 3, 1, 5, 1, 33], + [1, 1, 2, 5, 1, 33], + [2, 5, 0, 2, 2, 33], + [2, 3, 1, 2, 2, 33], + [2, 1, 2, 2, 2, 33], + [1, 4, 0, 10, 2, 33], + [1, 2, 1, 10, 2, 33], + [1, 0, 2, 10, 2, 33], + [0, 4, 1, 3, 3, 33], + [0, 2, 2, 3, 3, 33], + [0, 0, 3, 3, 3, 33], + [2, 4, 0, 7, 3, 33], + [2, 2, 1, 7, 3, 33], + [2, 0, 2, 7, 3, 33], + [1, 3, 0, 15, 3, 33], + [1, 1, 1, 15, 3, 33], + [0, 5, 0, 8, 4, 33], + [0, 3, 1, 8, 4, 33], + [0, 1, 2, 8, 4, 33], + [1, 2, 0, 20, 4, 33], + [1, 0, 1, 20, 4, 33], + [0, 4, 0, 13, 5, 33], + [0, 2, 1, 13, 5, 33], + [0, 0, 2, 13, 5, 33], + [3, 5, 0, 0, 0, 34], + [3, 3, 1, 0, 0, 34], + [3, 1, 2, 0, 0, 34], + [1, 4, 1, 1, 1, 34], + [1, 2, 2, 1, 1, 34], + [1, 0, 3, 1, 1, 34], + [3, 4, 0, 5, 1, 34], + [3, 2, 1, 5, 1, 34], + [3, 0, 2, 5, 1, 34], + [1, 5, 0, 6, 2, 34], + [1, 3, 1, 6, 2, 34], + [1, 1, 2, 6, 2, 34], + [3, 3, 0, 10, 2, 34], + [3, 1, 1, 10, 2, 34], + [2, 5, 0, 3, 3, 34], + [2, 3, 1, 3, 3, 34], + [2, 1, 2, 3, 3, 34], + [1, 4, 0, 11, 3, 34], + [1, 2, 1, 11, 3, 34], + [1, 0, 2, 11, 3, 34], + [0, 4, 1, 4, 4, 34], + [0, 2, 2, 4, 4, 34], + [0, 0, 3, 4, 4, 34], + [1, 3, 0, 16, 4, 34], + [1, 1, 1, 16, 4, 34], + [0, 5, 0, 9, 5, 34], + [0, 3, 1, 9, 5, 34], + [0, 1, 2, 9, 5, 34], + [5, 4, 0, 0, 0, 35], + [5, 2, 1, 0, 0, 35], + [5, 0, 2, 0, 0, 35], + [0, 3, 2, 0, 0, 35], + [0, 1, 3, 0, 0, 35], + [3, 5, 0, 1, 1, 35], + [3, 3, 1, 1, 1, 35], + [3, 1, 2, 1, 1, 35], + [0, 4, 1, 5, 1, 35], + [0, 2, 2, 5, 1, 35], + [0, 0, 3, 5, 1, 35], + [1, 4, 1, 2, 2, 35], + [1, 2, 2, 2, 2, 35], + [1, 0, 3, 2, 2, 35], + [3, 4, 0, 6, 2, 35], + [3, 2, 1, 6, 2, 35], + [3, 0, 2, 6, 2, 35], + [0, 5, 0, 10, 2, 35], + [0, 3, 1, 10, 2, 35], + [0, 1, 2, 10, 2, 35], + [1, 5, 0, 7, 3, 35], + [1, 3, 1, 7, 3, 35], + [1, 1, 2, 7, 3, 35], + [0, 4, 0, 15, 3, 35], + [0, 2, 1, 15, 3, 35], + [0, 0, 2, 15, 3, 35], + [1, 4, 0, 12, 4, 35], + [1, 2, 1, 12, 4, 35], + [1, 0, 2, 12, 4, 35], + [0, 3, 0, 20, 4, 35], + [0, 1, 1, 20, 4, 35], + [0, 2, 0, 25, 5, 35], + [0, 0, 1, 25, 5, 35], + [0, 3, 2, 1, 1, 36], + [0, 1, 3, 1, 1, 36], + [0, 4, 1, 6, 2, 36], + [0, 2, 2, 6, 2, 36], + [0, 0, 3, 6, 2, 36], + [0, 5, 0, 11, 3, 36], + [0, 3, 1, 11, 3, 36], + [0, 1, 2, 11, 3, 36], + [0, 4, 0, 16, 4, 36], + [0, 2, 1, 16, 4, 36], + [0, 0, 2, 16, 4, 36], + [0, 3, 0, 21, 5, 36], + [0, 1, 1, 21, 5, 36], + [0, 3, 2, 2, 2, 37], + [0, 1, 3, 2, 2, 37], + [0, 4, 1, 7, 3, 37], + [0, 2, 2, 7, 3, 37], + [0, 0, 3, 7, 3, 37], + [0, 5, 0, 12, 4, 37], + [0, 3, 1, 12, 4, 37], + [0, 1, 2, 12, 4, 37], + [0, 4, 0, 17, 5, 37], + [0, 2, 1, 17, 5, 37], + [0, 0, 2, 17, 5, 37], + [0, 0, 3, 8, 4, 38], + [0, 0, 3, 9, 5, 39], + [0, 0, 4, 0, 0, 40], + [0, 0, 3, 10, 2, 40], + [0, 0, 2, 20, 4, 40], + [0, 0, 4, 1, 1, 41], + [0, 0, 3, 11, 3, 41], + [0, 0, 2, 21, 5, 41], + [0, 0, 4, 2, 2, 42], + [0, 0, 3, 12, 4, 42], + ], + 34: [ + [3, 5, 0, 0, 0, 34], + [3, 3, 1, 0, 0, 34], + [3, 1, 2, 0, 0, 34], + [1, 4, 1, 1, 1, 34], + [1, 2, 2, 1, 1, 34], + [1, 0, 3, 1, 1, 34], + [3, 4, 0, 5, 1, 34], + [3, 2, 1, 5, 1, 34], + [3, 0, 2, 5, 1, 34], + [1, 5, 0, 6, 2, 34], + [1, 3, 1, 6, 2, 34], + [1, 1, 2, 6, 2, 34], + [3, 3, 0, 10, 2, 34], + [3, 1, 1, 10, 2, 34], + [2, 5, 0, 3, 3, 34], + [2, 3, 1, 3, 3, 34], + [2, 1, 2, 3, 3, 34], + [1, 4, 0, 11, 3, 34], + [1, 2, 1, 11, 3, 34], + [1, 0, 2, 11, 3, 34], + [0, 4, 1, 4, 4, 34], + [0, 2, 2, 4, 4, 34], + [0, 0, 3, 4, 4, 34], + [1, 3, 0, 16, 4, 34], + [1, 1, 1, 16, 4, 34], + [0, 5, 0, 9, 5, 34], + [0, 3, 1, 9, 5, 34], + [0, 1, 2, 9, 5, 34], + [5, 4, 0, 0, 0, 35], + [5, 2, 1, 0, 0, 35], + [5, 0, 2, 0, 0, 35], + [0, 3, 2, 0, 0, 35], + [0, 1, 3, 0, 0, 35], + [3, 5, 0, 1, 1, 35], + [3, 3, 1, 1, 1, 35], + [3, 1, 2, 1, 1, 35], + [0, 4, 1, 5, 1, 35], + [0, 2, 2, 5, 1, 35], + [0, 0, 3, 5, 1, 35], + [1, 4, 1, 2, 2, 35], + [1, 2, 2, 2, 2, 35], + [1, 0, 3, 2, 2, 35], + [3, 4, 0, 6, 2, 35], + [3, 2, 1, 6, 2, 35], + [3, 0, 2, 6, 2, 35], + [0, 5, 0, 10, 2, 35], + [0, 3, 1, 10, 2, 35], + [0, 1, 2, 10, 2, 35], + [1, 5, 0, 7, 3, 35], + [1, 3, 1, 7, 3, 35], + [1, 1, 2, 7, 3, 35], + [0, 4, 0, 15, 3, 35], + [0, 2, 1, 15, 3, 35], + [0, 0, 2, 15, 3, 35], + [1, 4, 0, 12, 4, 35], + [1, 2, 1, 12, 4, 35], + [1, 0, 2, 12, 4, 35], + [0, 3, 0, 20, 4, 35], + [0, 1, 1, 20, 4, 35], + [0, 2, 0, 25, 5, 35], + [0, 0, 1, 25, 5, 35], + [2, 4, 1, 0, 0, 36], + [2, 2, 2, 0, 0, 36], + [2, 0, 3, 0, 0, 36], + [0, 3, 2, 1, 1, 36], + [0, 1, 3, 1, 1, 36], + [2, 5, 0, 5, 1, 36], + [2, 3, 1, 5, 1, 36], + [2, 1, 2, 5, 1, 36], + [3, 5, 0, 2, 2, 36], + [3, 3, 1, 2, 2, 36], + [3, 1, 2, 2, 2, 36], + [0, 4, 1, 6, 2, 36], + [0, 2, 2, 6, 2, 36], + [0, 0, 3, 6, 2, 36], + [2, 4, 0, 10, 2, 36], + [2, 2, 1, 10, 2, 36], + [2, 0, 2, 10, 2, 36], + [1, 4, 1, 3, 3, 36], + [1, 2, 2, 3, 3, 36], + [1, 0, 3, 3, 3, 36], + [0, 5, 0, 11, 3, 36], + [0, 3, 1, 11, 3, 36], + [0, 1, 2, 11, 3, 36], + [2, 3, 0, 15, 3, 36], + [2, 1, 1, 15, 3, 36], + [1, 5, 0, 8, 4, 36], + [1, 3, 1, 8, 4, 36], + [1, 1, 2, 8, 4, 36], + [0, 4, 0, 16, 4, 36], + [0, 2, 1, 16, 4, 36], + [0, 0, 2, 16, 4, 36], + [0, 3, 0, 21, 5, 36], + [0, 1, 1, 21, 5, 36], + [0, 3, 2, 2, 2, 37], + [0, 1, 3, 2, 2, 37], + [0, 4, 1, 7, 3, 37], + [0, 2, 2, 7, 3, 37], + [0, 0, 3, 7, 3, 37], + [0, 5, 0, 12, 4, 37], + [0, 3, 1, 12, 4, 37], + [0, 1, 2, 12, 4, 37], + [0, 4, 0, 17, 5, 37], + [0, 2, 1, 17, 5, 37], + [0, 0, 2, 17, 5, 37], + [0, 3, 2, 3, 3, 38], + [0, 1, 3, 3, 3, 38], + [0, 4, 1, 8, 4, 38], + [0, 2, 2, 8, 4, 38], + [0, 0, 3, 8, 4, 38], + [0, 5, 0, 13, 5, 38], + [0, 3, 1, 13, 5, 38], + [0, 1, 2, 13, 5, 38], + [0, 0, 3, 9, 5, 39], + [0, 0, 4, 0, 0, 40], + [0, 0, 3, 10, 2, 40], + [0, 0, 2, 20, 4, 40], + [0, 0, 4, 1, 1, 41], + [0, 0, 3, 11, 3, 41], + [0, 0, 2, 21, 5, 41], + [0, 0, 4, 2, 2, 42], + [0, 0, 3, 12, 4, 42], + [0, 0, 4, 3, 3, 43], + [0, 0, 3, 13, 5, 43], + ], + 35: [ + [5, 4, 0, 0, 0, 35], + [5, 2, 1, 0, 0, 35], + [5, 0, 2, 0, 0, 35], + [0, 3, 2, 0, 0, 35], + [0, 1, 3, 0, 0, 35], + [3, 5, 0, 1, 1, 35], + [3, 3, 1, 1, 1, 35], + [3, 1, 2, 1, 1, 35], + [0, 4, 1, 5, 1, 35], + [0, 2, 2, 5, 1, 35], + [0, 0, 3, 5, 1, 35], + [1, 4, 1, 2, 2, 35], + [1, 2, 2, 2, 2, 35], + [1, 0, 3, 2, 2, 35], + [3, 4, 0, 6, 2, 35], + [3, 2, 1, 6, 2, 35], + [3, 0, 2, 6, 2, 35], + [0, 5, 0, 10, 2, 35], + [0, 3, 1, 10, 2, 35], + [0, 1, 2, 10, 2, 35], + [1, 5, 0, 7, 3, 35], + [1, 3, 1, 7, 3, 35], + [1, 1, 2, 7, 3, 35], + [0, 4, 0, 15, 3, 35], + [0, 2, 1, 15, 3, 35], + [0, 0, 2, 15, 3, 35], + [1, 4, 0, 12, 4, 35], + [1, 2, 1, 12, 4, 35], + [1, 0, 2, 12, 4, 35], + [0, 3, 0, 20, 4, 35], + [0, 1, 1, 20, 4, 35], + [0, 2, 0, 25, 5, 35], + [0, 0, 1, 25, 5, 35], + [2, 4, 1, 0, 0, 36], + [2, 2, 2, 0, 0, 36], + [2, 0, 3, 0, 0, 36], + [0, 3, 2, 1, 1, 36], + [0, 1, 3, 1, 1, 36], + [2, 5, 0, 5, 1, 36], + [2, 3, 1, 5, 1, 36], + [2, 1, 2, 5, 1, 36], + [3, 5, 0, 2, 2, 36], + [3, 3, 1, 2, 2, 36], + [3, 1, 2, 2, 2, 36], + [0, 4, 1, 6, 2, 36], + [0, 2, 2, 6, 2, 36], + [0, 0, 3, 6, 2, 36], + [2, 4, 0, 10, 2, 36], + [2, 2, 1, 10, 2, 36], + [2, 0, 2, 10, 2, 36], + [1, 4, 1, 3, 3, 36], + [1, 2, 2, 3, 3, 36], + [1, 0, 3, 3, 3, 36], + [0, 5, 0, 11, 3, 36], + [0, 3, 1, 11, 3, 36], + [0, 1, 2, 11, 3, 36], + [2, 3, 0, 15, 3, 36], + [2, 1, 1, 15, 3, 36], + [1, 5, 0, 8, 4, 36], + [1, 3, 1, 8, 4, 36], + [1, 1, 2, 8, 4, 36], + [0, 4, 0, 16, 4, 36], + [0, 2, 1, 16, 4, 36], + [0, 0, 2, 16, 4, 36], + [0, 3, 0, 21, 5, 36], + [0, 1, 1, 21, 5, 36], + [4, 5, 0, 0, 0, 37], + [4, 3, 1, 0, 0, 37], + [4, 1, 2, 0, 0, 37], + [2, 4, 1, 1, 1, 37], + [2, 2, 2, 1, 1, 37], + [2, 0, 3, 1, 1, 37], + [4, 4, 0, 5, 1, 37], + [4, 2, 1, 5, 1, 37], + [4, 0, 2, 5, 1, 37], + [0, 3, 2, 2, 2, 37], + [0, 1, 3, 2, 2, 37], + [2, 5, 0, 6, 2, 37], + [2, 3, 1, 6, 2, 37], + [2, 1, 2, 6, 2, 37], + [0, 4, 1, 7, 3, 37], + [0, 2, 2, 7, 3, 37], + [0, 0, 3, 7, 3, 37], + [2, 4, 0, 11, 3, 37], + [2, 2, 1, 11, 3, 37], + [2, 0, 2, 11, 3, 37], + [1, 4, 1, 4, 4, 37], + [1, 2, 2, 4, 4, 37], + [1, 0, 3, 4, 4, 37], + [0, 5, 0, 12, 4, 37], + [0, 3, 1, 12, 4, 37], + [0, 1, 2, 12, 4, 37], + [0, 4, 0, 17, 5, 37], + [0, 2, 1, 17, 5, 37], + [0, 0, 2, 17, 5, 37], + [0, 3, 2, 3, 3, 38], + [0, 1, 3, 3, 3, 38], + [0, 4, 1, 8, 4, 38], + [0, 2, 2, 8, 4, 38], + [0, 0, 3, 8, 4, 38], + [0, 5, 0, 13, 5, 38], + [0, 3, 1, 13, 5, 38], + [0, 1, 2, 13, 5, 38], + [0, 3, 2, 4, 4, 39], + [0, 1, 3, 4, 4, 39], + [0, 4, 1, 9, 5, 39], + [0, 2, 2, 9, 5, 39], + [0, 0, 3, 9, 5, 39], + [0, 0, 4, 0, 0, 40], + [0, 0, 3, 10, 2, 40], + [0, 0, 2, 20, 4, 40], + [0, 0, 4, 1, 1, 41], + [0, 0, 3, 11, 3, 41], + [0, 0, 2, 21, 5, 41], + [0, 0, 4, 2, 2, 42], + [0, 0, 3, 12, 4, 42], + [0, 0, 4, 3, 3, 43], + [0, 0, 3, 13, 5, 43], + [0, 0, 4, 4, 4, 44], + ], + 36: [ + [2, 4, 1, 0, 0, 36], + [2, 2, 2, 0, 0, 36], + [2, 0, 3, 0, 0, 36], + [0, 3, 2, 1, 1, 36], + [0, 1, 3, 1, 1, 36], + [2, 5, 0, 5, 1, 36], + [2, 3, 1, 5, 1, 36], + [2, 1, 2, 5, 1, 36], + [3, 5, 0, 2, 2, 36], + [3, 3, 1, 2, 2, 36], + [3, 1, 2, 2, 2, 36], + [0, 4, 1, 6, 2, 36], + [0, 2, 2, 6, 2, 36], + [0, 0, 3, 6, 2, 36], + [2, 4, 0, 10, 2, 36], + [2, 2, 1, 10, 2, 36], + [2, 0, 2, 10, 2, 36], + [1, 4, 1, 3, 3, 36], + [1, 2, 2, 3, 3, 36], + [1, 0, 3, 3, 3, 36], + [0, 5, 0, 11, 3, 36], + [0, 3, 1, 11, 3, 36], + [0, 1, 2, 11, 3, 36], + [2, 3, 0, 15, 3, 36], + [2, 1, 1, 15, 3, 36], + [1, 5, 0, 8, 4, 36], + [1, 3, 1, 8, 4, 36], + [1, 1, 2, 8, 4, 36], + [0, 4, 0, 16, 4, 36], + [0, 2, 1, 16, 4, 36], + [0, 0, 2, 16, 4, 36], + [0, 3, 0, 21, 5, 36], + [0, 1, 1, 21, 5, 36], + [4, 5, 0, 0, 0, 37], + [4, 3, 1, 0, 0, 37], + [4, 1, 2, 0, 0, 37], + [2, 4, 1, 1, 1, 37], + [2, 2, 2, 1, 1, 37], + [2, 0, 3, 1, 1, 37], + [4, 4, 0, 5, 1, 37], + [4, 2, 1, 5, 1, 37], + [4, 0, 2, 5, 1, 37], + [0, 3, 2, 2, 2, 37], + [0, 1, 3, 2, 2, 37], + [2, 5, 0, 6, 2, 37], + [2, 3, 1, 6, 2, 37], + [2, 1, 2, 6, 2, 37], + [0, 4, 1, 7, 3, 37], + [0, 2, 2, 7, 3, 37], + [0, 0, 3, 7, 3, 37], + [2, 4, 0, 11, 3, 37], + [2, 2, 1, 11, 3, 37], + [2, 0, 2, 11, 3, 37], + [1, 4, 1, 4, 4, 37], + [1, 2, 2, 4, 4, 37], + [1, 0, 3, 4, 4, 37], + [0, 5, 0, 12, 4, 37], + [0, 3, 1, 12, 4, 37], + [0, 1, 2, 12, 4, 37], + [0, 4, 0, 17, 5, 37], + [0, 2, 1, 17, 5, 37], + [0, 0, 2, 17, 5, 37], + [1, 3, 2, 0, 0, 38], + [1, 1, 3, 0, 0, 38], + [4, 5, 0, 1, 1, 38], + [4, 3, 1, 1, 1, 38], + [4, 1, 2, 1, 1, 38], + [1, 4, 1, 5, 1, 38], + [1, 2, 2, 5, 1, 38], + [1, 0, 3, 5, 1, 38], + [2, 4, 1, 2, 2, 38], + [2, 2, 2, 2, 2, 38], + [2, 0, 3, 2, 2, 38], + [1, 5, 0, 10, 2, 38], + [1, 3, 1, 10, 2, 38], + [1, 1, 2, 10, 2, 38], + [0, 3, 2, 3, 3, 38], + [0, 1, 3, 3, 3, 38], + [2, 5, 0, 7, 3, 38], + [2, 3, 1, 7, 3, 38], + [2, 1, 2, 7, 3, 38], + [1, 4, 0, 15, 3, 38], + [1, 2, 1, 15, 3, 38], + [1, 0, 2, 15, 3, 38], + [0, 4, 1, 8, 4, 38], + [0, 2, 2, 8, 4, 38], + [0, 0, 3, 8, 4, 38], + [1, 3, 0, 20, 4, 38], + [1, 1, 1, 20, 4, 38], + [0, 5, 0, 13, 5, 38], + [0, 3, 1, 13, 5, 38], + [0, 1, 2, 13, 5, 38], + [0, 3, 2, 4, 4, 39], + [0, 1, 3, 4, 4, 39], + [0, 4, 1, 9, 5, 39], + [0, 2, 2, 9, 5, 39], + [0, 0, 3, 9, 5, 39], + [0, 2, 3, 0, 0, 40], + [0, 0, 4, 0, 0, 40], + [0, 3, 2, 5, 1, 40], + [0, 1, 3, 5, 1, 40], + [0, 4, 1, 10, 2, 40], + [0, 2, 2, 10, 2, 40], + [0, 0, 3, 10, 2, 40], + [0, 5, 0, 15, 3, 40], + [0, 3, 1, 15, 3, 40], + [0, 1, 2, 15, 3, 40], + [0, 4, 0, 20, 4, 40], + [0, 2, 1, 20, 4, 40], + [0, 0, 2, 20, 4, 40], + [0, 3, 0, 25, 5, 40], + [0, 1, 1, 25, 5, 40], + [0, 0, 4, 1, 1, 41], + [0, 0, 3, 11, 3, 41], + [0, 0, 2, 21, 5, 41], + [0, 0, 4, 2, 2, 42], + [0, 0, 3, 12, 4, 42], + [0, 0, 4, 3, 3, 43], + [0, 0, 3, 13, 5, 43], + [0, 0, 4, 4, 4, 44], + [0, 0, 4, 5, 1, 45], + [0, 0, 3, 15, 3, 45], + [0, 0, 2, 25, 5, 45], + ], + 37: [ + [4, 5, 0, 0, 0, 37], + [4, 3, 1, 0, 0, 37], + [4, 1, 2, 0, 0, 37], + [2, 4, 1, 1, 1, 37], + [2, 2, 2, 1, 1, 37], + [2, 0, 3, 1, 1, 37], + [4, 4, 0, 5, 1, 37], + [4, 2, 1, 5, 1, 37], + [4, 0, 2, 5, 1, 37], + [0, 3, 2, 2, 2, 37], + [0, 1, 3, 2, 2, 37], + [2, 5, 0, 6, 2, 37], + [2, 3, 1, 6, 2, 37], + [2, 1, 2, 6, 2, 37], + [0, 4, 1, 7, 3, 37], + [0, 2, 2, 7, 3, 37], + [0, 0, 3, 7, 3, 37], + [2, 4, 0, 11, 3, 37], + [2, 2, 1, 11, 3, 37], + [2, 0, 2, 11, 3, 37], + [1, 4, 1, 4, 4, 37], + [1, 2, 2, 4, 4, 37], + [1, 0, 3, 4, 4, 37], + [0, 5, 0, 12, 4, 37], + [0, 3, 1, 12, 4, 37], + [0, 1, 2, 12, 4, 37], + [0, 4, 0, 17, 5, 37], + [0, 2, 1, 17, 5, 37], + [0, 0, 2, 17, 5, 37], + [1, 3, 2, 0, 0, 38], + [1, 1, 3, 0, 0, 38], + [4, 5, 0, 1, 1, 38], + [4, 3, 1, 1, 1, 38], + [4, 1, 2, 1, 1, 38], + [1, 4, 1, 5, 1, 38], + [1, 2, 2, 5, 1, 38], + [1, 0, 3, 5, 1, 38], + [2, 4, 1, 2, 2, 38], + [2, 2, 2, 2, 2, 38], + [2, 0, 3, 2, 2, 38], + [1, 5, 0, 10, 2, 38], + [1, 3, 1, 10, 2, 38], + [1, 1, 2, 10, 2, 38], + [0, 3, 2, 3, 3, 38], + [0, 1, 3, 3, 3, 38], + [2, 5, 0, 7, 3, 38], + [2, 3, 1, 7, 3, 38], + [2, 1, 2, 7, 3, 38], + [1, 4, 0, 15, 3, 38], + [1, 2, 1, 15, 3, 38], + [1, 0, 2, 15, 3, 38], + [0, 4, 1, 8, 4, 38], + [0, 2, 2, 8, 4, 38], + [0, 0, 3, 8, 4, 38], + [1, 3, 0, 20, 4, 38], + [1, 1, 1, 20, 4, 38], + [0, 5, 0, 13, 5, 38], + [0, 3, 1, 13, 5, 38], + [0, 1, 2, 13, 5, 38], + [3, 4, 1, 0, 0, 39], + [3, 2, 2, 0, 0, 39], + [3, 0, 3, 0, 0, 39], + [1, 3, 2, 1, 1, 39], + [1, 1, 3, 1, 1, 39], + [3, 5, 0, 5, 1, 39], + [3, 3, 1, 5, 1, 39], + [3, 1, 2, 5, 1, 39], + [1, 4, 1, 6, 2, 39], + [1, 2, 2, 6, 2, 39], + [1, 0, 3, 6, 2, 39], + [3, 4, 0, 10, 2, 39], + [3, 2, 1, 10, 2, 39], + [3, 0, 2, 10, 2, 39], + [2, 4, 1, 3, 3, 39], + [2, 2, 2, 3, 3, 39], + [2, 0, 3, 3, 3, 39], + [1, 5, 0, 11, 3, 39], + [1, 3, 1, 11, 3, 39], + [1, 1, 2, 11, 3, 39], + [0, 3, 2, 4, 4, 39], + [0, 1, 3, 4, 4, 39], + [1, 4, 0, 16, 4, 39], + [1, 2, 1, 16, 4, 39], + [1, 0, 2, 16, 4, 39], + [0, 4, 1, 9, 5, 39], + [0, 2, 2, 9, 5, 39], + [0, 0, 3, 9, 5, 39], + [0, 2, 3, 0, 0, 40], + [0, 0, 4, 0, 0, 40], + [0, 3, 2, 5, 1, 40], + [0, 1, 3, 5, 1, 40], + [0, 4, 1, 10, 2, 40], + [0, 2, 2, 10, 2, 40], + [0, 0, 3, 10, 2, 40], + [0, 5, 0, 15, 3, 40], + [0, 3, 1, 15, 3, 40], + [0, 1, 2, 15, 3, 40], + [0, 4, 0, 20, 4, 40], + [0, 2, 1, 20, 4, 40], + [0, 0, 2, 20, 4, 40], + [0, 3, 0, 25, 5, 40], + [0, 1, 1, 25, 5, 40], + [0, 2, 3, 1, 1, 41], + [0, 0, 4, 1, 1, 41], + [0, 3, 2, 6, 2, 41], + [0, 1, 3, 6, 2, 41], + [0, 4, 1, 11, 3, 41], + [0, 2, 2, 11, 3, 41], + [0, 0, 3, 11, 3, 41], + [0, 5, 0, 16, 4, 41], + [0, 3, 1, 16, 4, 41], + [0, 1, 2, 16, 4, 41], + [0, 4, 0, 21, 5, 41], + [0, 2, 1, 21, 5, 41], + [0, 0, 2, 21, 5, 41], + [0, 0, 4, 2, 2, 42], + [0, 0, 3, 12, 4, 42], + [0, 0, 4, 3, 3, 43], + [0, 0, 3, 13, 5, 43], + [0, 0, 4, 4, 4, 44], + [0, 0, 4, 5, 1, 45], + [0, 0, 3, 15, 3, 45], + [0, 0, 2, 25, 5, 45], + [0, 0, 4, 6, 2, 46], + [0, 0, 3, 16, 4, 46], + ], + 38: [ + [1, 3, 2, 0, 0, 38], + [1, 1, 3, 0, 0, 38], + [4, 5, 0, 1, 1, 38], + [4, 3, 1, 1, 1, 38], + [4, 1, 2, 1, 1, 38], + [1, 4, 1, 5, 1, 38], + [1, 2, 2, 5, 1, 38], + [1, 0, 3, 5, 1, 38], + [2, 4, 1, 2, 2, 38], + [2, 2, 2, 2, 2, 38], + [2, 0, 3, 2, 2, 38], + [1, 5, 0, 10, 2, 38], + [1, 3, 1, 10, 2, 38], + [1, 1, 2, 10, 2, 38], + [0, 3, 2, 3, 3, 38], + [0, 1, 3, 3, 3, 38], + [2, 5, 0, 7, 3, 38], + [2, 3, 1, 7, 3, 38], + [2, 1, 2, 7, 3, 38], + [1, 4, 0, 15, 3, 38], + [1, 2, 1, 15, 3, 38], + [1, 0, 2, 15, 3, 38], + [0, 4, 1, 8, 4, 38], + [0, 2, 2, 8, 4, 38], + [0, 0, 3, 8, 4, 38], + [1, 3, 0, 20, 4, 38], + [1, 1, 1, 20, 4, 38], + [0, 5, 0, 13, 5, 38], + [0, 3, 1, 13, 5, 38], + [0, 1, 2, 13, 5, 38], + [3, 4, 1, 0, 0, 39], + [3, 2, 2, 0, 0, 39], + [3, 0, 3, 0, 0, 39], + [1, 3, 2, 1, 1, 39], + [1, 1, 3, 1, 1, 39], + [3, 5, 0, 5, 1, 39], + [3, 3, 1, 5, 1, 39], + [3, 1, 2, 5, 1, 39], + [1, 4, 1, 6, 2, 39], + [1, 2, 2, 6, 2, 39], + [1, 0, 3, 6, 2, 39], + [3, 4, 0, 10, 2, 39], + [3, 2, 1, 10, 2, 39], + [3, 0, 2, 10, 2, 39], + [2, 4, 1, 3, 3, 39], + [2, 2, 2, 3, 3, 39], + [2, 0, 3, 3, 3, 39], + [1, 5, 0, 11, 3, 39], + [1, 3, 1, 11, 3, 39], + [1, 1, 2, 11, 3, 39], + [0, 3, 2, 4, 4, 39], + [0, 1, 3, 4, 4, 39], + [1, 4, 0, 16, 4, 39], + [1, 2, 1, 16, 4, 39], + [1, 0, 2, 16, 4, 39], + [0, 4, 1, 9, 5, 39], + [0, 2, 2, 9, 5, 39], + [0, 0, 3, 9, 5, 39], + [5, 5, 0, 0, 0, 40], + [5, 3, 1, 0, 0, 40], + [5, 1, 2, 0, 0, 40], + [0, 2, 3, 0, 0, 40], + [0, 0, 4, 0, 0, 40], + [3, 4, 1, 1, 1, 40], + [3, 2, 2, 1, 1, 40], + [3, 0, 3, 1, 1, 40], + [0, 3, 2, 5, 1, 40], + [0, 1, 3, 5, 1, 40], + [1, 3, 2, 2, 2, 40], + [1, 1, 3, 2, 2, 40], + [3, 5, 0, 6, 2, 40], + [3, 3, 1, 6, 2, 40], + [3, 1, 2, 6, 2, 40], + [0, 4, 1, 10, 2, 40], + [0, 2, 2, 10, 2, 40], + [0, 0, 3, 10, 2, 40], + [1, 4, 1, 7, 3, 40], + [1, 2, 2, 7, 3, 40], + [1, 0, 3, 7, 3, 40], + [0, 5, 0, 15, 3, 40], + [0, 3, 1, 15, 3, 40], + [0, 1, 2, 15, 3, 40], + [1, 5, 0, 12, 4, 40], + [1, 3, 1, 12, 4, 40], + [1, 1, 2, 12, 4, 40], + [0, 4, 0, 20, 4, 40], + [0, 2, 1, 20, 4, 40], + [0, 0, 2, 20, 4, 40], + [0, 3, 0, 25, 5, 40], + [0, 1, 1, 25, 5, 40], + [0, 2, 3, 1, 1, 41], + [0, 0, 4, 1, 1, 41], + [0, 3, 2, 6, 2, 41], + [0, 1, 3, 6, 2, 41], + [0, 4, 1, 11, 3, 41], + [0, 2, 2, 11, 3, 41], + [0, 0, 3, 11, 3, 41], + [0, 5, 0, 16, 4, 41], + [0, 3, 1, 16, 4, 41], + [0, 1, 2, 16, 4, 41], + [0, 4, 0, 21, 5, 41], + [0, 2, 1, 21, 5, 41], + [0, 0, 2, 21, 5, 41], + [0, 2, 3, 2, 2, 42], + [0, 0, 4, 2, 2, 42], + [0, 3, 2, 7, 3, 42], + [0, 1, 3, 7, 3, 42], + [0, 4, 1, 12, 4, 42], + [0, 2, 2, 12, 4, 42], + [0, 0, 3, 12, 4, 42], + [0, 5, 0, 17, 5, 42], + [0, 3, 1, 17, 5, 42], + [0, 1, 2, 17, 5, 42], + [0, 0, 4, 3, 3, 43], + [0, 0, 3, 13, 5, 43], + [0, 0, 4, 4, 4, 44], + [0, 0, 4, 5, 1, 45], + [0, 0, 3, 15, 3, 45], + [0, 0, 2, 25, 5, 45], + [0, 0, 4, 6, 2, 46], + [0, 0, 3, 16, 4, 46], + [0, 0, 4, 7, 3, 47], + [0, 0, 3, 17, 5, 47], + ], + 39: [ + [3, 4, 1, 0, 0, 39], + [3, 2, 2, 0, 0, 39], + [3, 0, 3, 0, 0, 39], + [1, 3, 2, 1, 1, 39], + [1, 1, 3, 1, 1, 39], + [3, 5, 0, 5, 1, 39], + [3, 3, 1, 5, 1, 39], + [3, 1, 2, 5, 1, 39], + [1, 4, 1, 6, 2, 39], + [1, 2, 2, 6, 2, 39], + [1, 0, 3, 6, 2, 39], + [3, 4, 0, 10, 2, 39], + [3, 2, 1, 10, 2, 39], + [3, 0, 2, 10, 2, 39], + [2, 4, 1, 3, 3, 39], + [2, 2, 2, 3, 3, 39], + [2, 0, 3, 3, 3, 39], + [1, 5, 0, 11, 3, 39], + [1, 3, 1, 11, 3, 39], + [1, 1, 2, 11, 3, 39], + [0, 3, 2, 4, 4, 39], + [0, 1, 3, 4, 4, 39], + [1, 4, 0, 16, 4, 39], + [1, 2, 1, 16, 4, 39], + [1, 0, 2, 16, 4, 39], + [0, 4, 1, 9, 5, 39], + [0, 2, 2, 9, 5, 39], + [0, 0, 3, 9, 5, 39], + [5, 5, 0, 0, 0, 40], + [5, 3, 1, 0, 0, 40], + [5, 1, 2, 0, 0, 40], + [0, 2, 3, 0, 0, 40], + [0, 0, 4, 0, 0, 40], + [3, 4, 1, 1, 1, 40], + [3, 2, 2, 1, 1, 40], + [3, 0, 3, 1, 1, 40], + [0, 3, 2, 5, 1, 40], + [0, 1, 3, 5, 1, 40], + [1, 3, 2, 2, 2, 40], + [1, 1, 3, 2, 2, 40], + [3, 5, 0, 6, 2, 40], + [3, 3, 1, 6, 2, 40], + [3, 1, 2, 6, 2, 40], + [0, 4, 1, 10, 2, 40], + [0, 2, 2, 10, 2, 40], + [0, 0, 3, 10, 2, 40], + [1, 4, 1, 7, 3, 40], + [1, 2, 2, 7, 3, 40], + [1, 0, 3, 7, 3, 40], + [0, 5, 0, 15, 3, 40], + [0, 3, 1, 15, 3, 40], + [0, 1, 2, 15, 3, 40], + [1, 5, 0, 12, 4, 40], + [1, 3, 1, 12, 4, 40], + [1, 1, 2, 12, 4, 40], + [0, 4, 0, 20, 4, 40], + [0, 2, 1, 20, 4, 40], + [0, 0, 2, 20, 4, 40], + [0, 3, 0, 25, 5, 40], + [0, 1, 1, 25, 5, 40], + [2, 3, 2, 0, 0, 41], + [2, 1, 3, 0, 0, 41], + [0, 2, 3, 1, 1, 41], + [0, 0, 4, 1, 1, 41], + [2, 4, 1, 5, 1, 41], + [2, 2, 2, 5, 1, 41], + [2, 0, 3, 5, 1, 41], + [3, 4, 1, 2, 2, 41], + [3, 2, 2, 2, 2, 41], + [3, 0, 3, 2, 2, 41], + [0, 3, 2, 6, 2, 41], + [0, 1, 3, 6, 2, 41], + [2, 5, 0, 10, 2, 41], + [2, 3, 1, 10, 2, 41], + [2, 1, 2, 10, 2, 41], + [1, 3, 2, 3, 3, 41], + [1, 1, 3, 3, 3, 41], + [0, 4, 1, 11, 3, 41], + [0, 2, 2, 11, 3, 41], + [0, 0, 3, 11, 3, 41], + [2, 4, 0, 15, 3, 41], + [2, 2, 1, 15, 3, 41], + [2, 0, 2, 15, 3, 41], + [1, 4, 1, 8, 4, 41], + [1, 2, 2, 8, 4, 41], + [1, 0, 3, 8, 4, 41], + [0, 5, 0, 16, 4, 41], + [0, 3, 1, 16, 4, 41], + [0, 1, 2, 16, 4, 41], + [0, 4, 0, 21, 5, 41], + [0, 2, 1, 21, 5, 41], + [0, 0, 2, 21, 5, 41], + [0, 2, 3, 2, 2, 42], + [0, 0, 4, 2, 2, 42], + [0, 3, 2, 7, 3, 42], + [0, 1, 3, 7, 3, 42], + [0, 4, 1, 12, 4, 42], + [0, 2, 2, 12, 4, 42], + [0, 0, 3, 12, 4, 42], + [0, 5, 0, 17, 5, 42], + [0, 3, 1, 17, 5, 42], + [0, 1, 2, 17, 5, 42], + [0, 2, 3, 3, 3, 43], + [0, 0, 4, 3, 3, 43], + [0, 3, 2, 8, 4, 43], + [0, 1, 3, 8, 4, 43], + [0, 4, 1, 13, 5, 43], + [0, 2, 2, 13, 5, 43], + [0, 0, 3, 13, 5, 43], + [0, 0, 4, 4, 4, 44], + [0, 0, 4, 5, 1, 45], + [0, 0, 3, 15, 3, 45], + [0, 0, 2, 25, 5, 45], + [0, 0, 4, 6, 2, 46], + [0, 0, 3, 16, 4, 46], + [0, 0, 4, 7, 3, 47], + [0, 0, 3, 17, 5, 47], + [0, 0, 4, 8, 4, 48], + ], + 40: [ + [5, 5, 0, 0, 0, 40], + [5, 3, 1, 0, 0, 40], + [5, 1, 2, 0, 0, 40], + [0, 2, 3, 0, 0, 40], + [0, 0, 4, 0, 0, 40], + [3, 4, 1, 1, 1, 40], + [3, 2, 2, 1, 1, 40], + [3, 0, 3, 1, 1, 40], + [0, 3, 2, 5, 1, 40], + [0, 1, 3, 5, 1, 40], + [1, 3, 2, 2, 2, 40], + [1, 1, 3, 2, 2, 40], + [3, 5, 0, 6, 2, 40], + [3, 3, 1, 6, 2, 40], + [3, 1, 2, 6, 2, 40], + [0, 4, 1, 10, 2, 40], + [0, 2, 2, 10, 2, 40], + [0, 0, 3, 10, 2, 40], + [1, 4, 1, 7, 3, 40], + [1, 2, 2, 7, 3, 40], + [1, 0, 3, 7, 3, 40], + [0, 5, 0, 15, 3, 40], + [0, 3, 1, 15, 3, 40], + [0, 1, 2, 15, 3, 40], + [1, 5, 0, 12, 4, 40], + [1, 3, 1, 12, 4, 40], + [1, 1, 2, 12, 4, 40], + [0, 4, 0, 20, 4, 40], + [0, 2, 1, 20, 4, 40], + [0, 0, 2, 20, 4, 40], + [0, 3, 0, 25, 5, 40], + [0, 1, 1, 25, 5, 40], + [2, 3, 2, 0, 0, 41], + [2, 1, 3, 0, 0, 41], + [0, 2, 3, 1, 1, 41], + [0, 0, 4, 1, 1, 41], + [2, 4, 1, 5, 1, 41], + [2, 2, 2, 5, 1, 41], + [2, 0, 3, 5, 1, 41], + [3, 4, 1, 2, 2, 41], + [3, 2, 2, 2, 2, 41], + [3, 0, 3, 2, 2, 41], + [0, 3, 2, 6, 2, 41], + [0, 1, 3, 6, 2, 41], + [2, 5, 0, 10, 2, 41], + [2, 3, 1, 10, 2, 41], + [2, 1, 2, 10, 2, 41], + [1, 3, 2, 3, 3, 41], + [1, 1, 3, 3, 3, 41], + [0, 4, 1, 11, 3, 41], + [0, 2, 2, 11, 3, 41], + [0, 0, 3, 11, 3, 41], + [2, 4, 0, 15, 3, 41], + [2, 2, 1, 15, 3, 41], + [2, 0, 2, 15, 3, 41], + [1, 4, 1, 8, 4, 41], + [1, 2, 2, 8, 4, 41], + [1, 0, 3, 8, 4, 41], + [0, 5, 0, 16, 4, 41], + [0, 3, 1, 16, 4, 41], + [0, 1, 2, 16, 4, 41], + [0, 4, 0, 21, 5, 41], + [0, 2, 1, 21, 5, 41], + [0, 0, 2, 21, 5, 41], + [4, 4, 1, 0, 0, 42], + [4, 2, 2, 0, 0, 42], + [4, 0, 3, 0, 0, 42], + [2, 3, 2, 1, 1, 42], + [2, 1, 3, 1, 1, 42], + [4, 5, 0, 5, 1, 42], + [4, 3, 1, 5, 1, 42], + [4, 1, 2, 5, 1, 42], + [0, 2, 3, 2, 2, 42], + [0, 0, 4, 2, 2, 42], + [2, 4, 1, 6, 2, 42], + [2, 2, 2, 6, 2, 42], + [2, 0, 3, 6, 2, 42], + [0, 3, 2, 7, 3, 42], + [0, 1, 3, 7, 3, 42], + [2, 5, 0, 11, 3, 42], + [2, 3, 1, 11, 3, 42], + [2, 1, 2, 11, 3, 42], + [1, 3, 2, 4, 4, 42], + [1, 1, 3, 4, 4, 42], + [0, 4, 1, 12, 4, 42], + [0, 2, 2, 12, 4, 42], + [0, 0, 3, 12, 4, 42], + [0, 5, 0, 17, 5, 42], + [0, 3, 1, 17, 5, 42], + [0, 1, 2, 17, 5, 42], + [0, 2, 3, 3, 3, 43], + [0, 0, 4, 3, 3, 43], + [0, 3, 2, 8, 4, 43], + [0, 1, 3, 8, 4, 43], + [0, 4, 1, 13, 5, 43], + [0, 2, 2, 13, 5, 43], + [0, 0, 3, 13, 5, 43], + [0, 2, 3, 4, 4, 44], + [0, 0, 4, 4, 4, 44], + [0, 3, 2, 9, 5, 44], + [0, 1, 3, 9, 5, 44], + [0, 0, 4, 5, 1, 45], + [0, 0, 3, 15, 3, 45], + [0, 0, 2, 25, 5, 45], + [0, 0, 4, 6, 2, 46], + [0, 0, 3, 16, 4, 46], + [0, 0, 4, 7, 3, 47], + [0, 0, 3, 17, 5, 47], + [0, 0, 4, 8, 4, 48], + [0, 0, 4, 9, 5, 49], + ], + 41: [ + [2, 3, 2, 0, 0, 41], + [2, 1, 3, 0, 0, 41], + [0, 2, 3, 1, 1, 41], + [0, 0, 4, 1, 1, 41], + [2, 4, 1, 5, 1, 41], + [2, 2, 2, 5, 1, 41], + [2, 0, 3, 5, 1, 41], + [3, 4, 1, 2, 2, 41], + [3, 2, 2, 2, 2, 41], + [3, 0, 3, 2, 2, 41], + [0, 3, 2, 6, 2, 41], + [0, 1, 3, 6, 2, 41], + [2, 5, 0, 10, 2, 41], + [2, 3, 1, 10, 2, 41], + [2, 1, 2, 10, 2, 41], + [1, 3, 2, 3, 3, 41], + [1, 1, 3, 3, 3, 41], + [0, 4, 1, 11, 3, 41], + [0, 2, 2, 11, 3, 41], + [0, 0, 3, 11, 3, 41], + [2, 4, 0, 15, 3, 41], + [2, 2, 1, 15, 3, 41], + [2, 0, 2, 15, 3, 41], + [1, 4, 1, 8, 4, 41], + [1, 2, 2, 8, 4, 41], + [1, 0, 3, 8, 4, 41], + [0, 5, 0, 16, 4, 41], + [0, 3, 1, 16, 4, 41], + [0, 1, 2, 16, 4, 41], + [0, 4, 0, 21, 5, 41], + [0, 2, 1, 21, 5, 41], + [0, 0, 2, 21, 5, 41], + [4, 4, 1, 0, 0, 42], + [4, 2, 2, 0, 0, 42], + [4, 0, 3, 0, 0, 42], + [2, 3, 2, 1, 1, 42], + [2, 1, 3, 1, 1, 42], + [4, 5, 0, 5, 1, 42], + [4, 3, 1, 5, 1, 42], + [4, 1, 2, 5, 1, 42], + [0, 2, 3, 2, 2, 42], + [0, 0, 4, 2, 2, 42], + [2, 4, 1, 6, 2, 42], + [2, 2, 2, 6, 2, 42], + [2, 0, 3, 6, 2, 42], + [0, 3, 2, 7, 3, 42], + [0, 1, 3, 7, 3, 42], + [2, 5, 0, 11, 3, 42], + [2, 3, 1, 11, 3, 42], + [2, 1, 2, 11, 3, 42], + [1, 3, 2, 4, 4, 42], + [1, 1, 3, 4, 4, 42], + [0, 4, 1, 12, 4, 42], + [0, 2, 2, 12, 4, 42], + [0, 0, 3, 12, 4, 42], + [0, 5, 0, 17, 5, 42], + [0, 3, 1, 17, 5, 42], + [0, 1, 2, 17, 5, 42], + [1, 2, 3, 0, 0, 43], + [1, 0, 4, 0, 0, 43], + [4, 4, 1, 1, 1, 43], + [4, 2, 2, 1, 1, 43], + [4, 0, 3, 1, 1, 43], + [1, 3, 2, 5, 1, 43], + [1, 1, 3, 5, 1, 43], + [2, 3, 2, 2, 2, 43], + [2, 1, 3, 2, 2, 43], + [1, 4, 1, 10, 2, 43], + [1, 2, 2, 10, 2, 43], + [1, 0, 3, 10, 2, 43], + [0, 2, 3, 3, 3, 43], + [0, 0, 4, 3, 3, 43], + [2, 4, 1, 7, 3, 43], + [2, 2, 2, 7, 3, 43], + [2, 0, 3, 7, 3, 43], + [1, 5, 0, 15, 3, 43], + [1, 3, 1, 15, 3, 43], + [1, 1, 2, 15, 3, 43], + [0, 3, 2, 8, 4, 43], + [0, 1, 3, 8, 4, 43], + [1, 4, 0, 20, 4, 43], + [1, 2, 1, 20, 4, 43], + [1, 0, 2, 20, 4, 43], + [0, 4, 1, 13, 5, 43], + [0, 2, 2, 13, 5, 43], + [0, 0, 3, 13, 5, 43], + [0, 2, 3, 4, 4, 44], + [0, 0, 4, 4, 4, 44], + [0, 3, 2, 9, 5, 44], + [0, 1, 3, 9, 5, 44], + [0, 1, 4, 0, 0, 45], + [0, 2, 3, 5, 1, 45], + [0, 0, 4, 5, 1, 45], + [0, 3, 2, 10, 2, 45], + [0, 1, 3, 10, 2, 45], + [0, 4, 1, 15, 3, 45], + [0, 2, 2, 15, 3, 45], + [0, 0, 3, 15, 3, 45], + [0, 5, 0, 20, 4, 45], + [0, 3, 1, 20, 4, 45], + [0, 1, 2, 20, 4, 45], + [0, 4, 0, 25, 5, 45], + [0, 2, 1, 25, 5, 45], + [0, 0, 2, 25, 5, 45], + [0, 0, 4, 6, 2, 46], + [0, 0, 3, 16, 4, 46], + [0, 0, 4, 7, 3, 47], + [0, 0, 3, 17, 5, 47], + [0, 0, 4, 8, 4, 48], + [0, 0, 4, 9, 5, 49], + [0, 0, 5, 0, 0, 50], + [0, 0, 4, 10, 2, 50], + [0, 0, 3, 20, 4, 50], + ], + 42: [ + [4, 4, 1, 0, 0, 42], + [4, 2, 2, 0, 0, 42], + [4, 0, 3, 0, 0, 42], + [2, 3, 2, 1, 1, 42], + [2, 1, 3, 1, 1, 42], + [4, 5, 0, 5, 1, 42], + [4, 3, 1, 5, 1, 42], + [4, 1, 2, 5, 1, 42], + [0, 2, 3, 2, 2, 42], + [0, 0, 4, 2, 2, 42], + [2, 4, 1, 6, 2, 42], + [2, 2, 2, 6, 2, 42], + [2, 0, 3, 6, 2, 42], + [0, 3, 2, 7, 3, 42], + [0, 1, 3, 7, 3, 42], + [2, 5, 0, 11, 3, 42], + [2, 3, 1, 11, 3, 42], + [2, 1, 2, 11, 3, 42], + [1, 3, 2, 4, 4, 42], + [1, 1, 3, 4, 4, 42], + [0, 4, 1, 12, 4, 42], + [0, 2, 2, 12, 4, 42], + [0, 0, 3, 12, 4, 42], + [0, 5, 0, 17, 5, 42], + [0, 3, 1, 17, 5, 42], + [0, 1, 2, 17, 5, 42], + [1, 2, 3, 0, 0, 43], + [1, 0, 4, 0, 0, 43], + [4, 4, 1, 1, 1, 43], + [4, 2, 2, 1, 1, 43], + [4, 0, 3, 1, 1, 43], + [1, 3, 2, 5, 1, 43], + [1, 1, 3, 5, 1, 43], + [2, 3, 2, 2, 2, 43], + [2, 1, 3, 2, 2, 43], + [1, 4, 1, 10, 2, 43], + [1, 2, 2, 10, 2, 43], + [1, 0, 3, 10, 2, 43], + [0, 2, 3, 3, 3, 43], + [0, 0, 4, 3, 3, 43], + [2, 4, 1, 7, 3, 43], + [2, 2, 2, 7, 3, 43], + [2, 0, 3, 7, 3, 43], + [1, 5, 0, 15, 3, 43], + [1, 3, 1, 15, 3, 43], + [1, 1, 2, 15, 3, 43], + [0, 3, 2, 8, 4, 43], + [0, 1, 3, 8, 4, 43], + [1, 4, 0, 20, 4, 43], + [1, 2, 1, 20, 4, 43], + [1, 0, 2, 20, 4, 43], + [0, 4, 1, 13, 5, 43], + [0, 2, 2, 13, 5, 43], + [0, 0, 3, 13, 5, 43], + [3, 3, 2, 0, 0, 44], + [3, 1, 3, 0, 0, 44], + [1, 2, 3, 1, 1, 44], + [1, 0, 4, 1, 1, 44], + [3, 4, 1, 5, 1, 44], + [3, 2, 2, 5, 1, 44], + [3, 0, 3, 5, 1, 44], + [1, 3, 2, 6, 2, 44], + [1, 1, 3, 6, 2, 44], + [3, 5, 0, 10, 2, 44], + [3, 3, 1, 10, 2, 44], + [3, 1, 2, 10, 2, 44], + [2, 3, 2, 3, 3, 44], + [2, 1, 3, 3, 3, 44], + [1, 4, 1, 11, 3, 44], + [1, 2, 2, 11, 3, 44], + [1, 0, 3, 11, 3, 44], + [0, 2, 3, 4, 4, 44], + [0, 0, 4, 4, 4, 44], + [1, 5, 0, 16, 4, 44], + [1, 3, 1, 16, 4, 44], + [1, 1, 2, 16, 4, 44], + [0, 3, 2, 9, 5, 44], + [0, 1, 3, 9, 5, 44], + [0, 1, 4, 0, 0, 45], + [0, 2, 3, 5, 1, 45], + [0, 0, 4, 5, 1, 45], + [0, 3, 2, 10, 2, 45], + [0, 1, 3, 10, 2, 45], + [0, 4, 1, 15, 3, 45], + [0, 2, 2, 15, 3, 45], + [0, 0, 3, 15, 3, 45], + [0, 5, 0, 20, 4, 45], + [0, 3, 1, 20, 4, 45], + [0, 1, 2, 20, 4, 45], + [0, 4, 0, 25, 5, 45], + [0, 2, 1, 25, 5, 45], + [0, 0, 2, 25, 5, 45], + [0, 1, 4, 1, 1, 46], + [0, 2, 3, 6, 2, 46], + [0, 0, 4, 6, 2, 46], + [0, 3, 2, 11, 3, 46], + [0, 1, 3, 11, 3, 46], + [0, 4, 1, 16, 4, 46], + [0, 2, 2, 16, 4, 46], + [0, 0, 3, 16, 4, 46], + [0, 5, 0, 21, 5, 46], + [0, 3, 1, 21, 5, 46], + [0, 1, 2, 21, 5, 46], + [0, 0, 4, 7, 3, 47], + [0, 0, 3, 17, 5, 47], + [0, 0, 4, 8, 4, 48], + [0, 0, 4, 9, 5, 49], + [0, 0, 5, 0, 0, 50], + [0, 0, 4, 10, 2, 50], + [0, 0, 3, 20, 4, 50], + [0, 0, 5, 1, 1, 51], + [0, 0, 4, 11, 3, 51], + [0, 0, 3, 21, 5, 51], + ], + 43: [ + [1, 2, 3, 0, 0, 43], + [1, 0, 4, 0, 0, 43], + [4, 4, 1, 1, 1, 43], + [4, 2, 2, 1, 1, 43], + [4, 0, 3, 1, 1, 43], + [1, 3, 2, 5, 1, 43], + [1, 1, 3, 5, 1, 43], + [2, 3, 2, 2, 2, 43], + [2, 1, 3, 2, 2, 43], + [1, 4, 1, 10, 2, 43], + [1, 2, 2, 10, 2, 43], + [1, 0, 3, 10, 2, 43], + [0, 2, 3, 3, 3, 43], + [0, 0, 4, 3, 3, 43], + [2, 4, 1, 7, 3, 43], + [2, 2, 2, 7, 3, 43], + [2, 0, 3, 7, 3, 43], + [1, 5, 0, 15, 3, 43], + [1, 3, 1, 15, 3, 43], + [1, 1, 2, 15, 3, 43], + [0, 3, 2, 8, 4, 43], + [0, 1, 3, 8, 4, 43], + [1, 4, 0, 20, 4, 43], + [1, 2, 1, 20, 4, 43], + [1, 0, 2, 20, 4, 43], + [0, 4, 1, 13, 5, 43], + [0, 2, 2, 13, 5, 43], + [0, 0, 3, 13, 5, 43], + [3, 3, 2, 0, 0, 44], + [3, 1, 3, 0, 0, 44], + [1, 2, 3, 1, 1, 44], + [1, 0, 4, 1, 1, 44], + [3, 4, 1, 5, 1, 44], + [3, 2, 2, 5, 1, 44], + [3, 0, 3, 5, 1, 44], + [1, 3, 2, 6, 2, 44], + [1, 1, 3, 6, 2, 44], + [3, 5, 0, 10, 2, 44], + [3, 3, 1, 10, 2, 44], + [3, 1, 2, 10, 2, 44], + [2, 3, 2, 3, 3, 44], + [2, 1, 3, 3, 3, 44], + [1, 4, 1, 11, 3, 44], + [1, 2, 2, 11, 3, 44], + [1, 0, 3, 11, 3, 44], + [0, 2, 3, 4, 4, 44], + [0, 0, 4, 4, 4, 44], + [1, 5, 0, 16, 4, 44], + [1, 3, 1, 16, 4, 44], + [1, 1, 2, 16, 4, 44], + [0, 3, 2, 9, 5, 44], + [0, 1, 3, 9, 5, 44], + [5, 4, 1, 0, 0, 45], + [5, 2, 2, 0, 0, 45], + [5, 0, 3, 0, 0, 45], + [0, 1, 4, 0, 0, 45], + [3, 3, 2, 1, 1, 45], + [3, 1, 3, 1, 1, 45], + [0, 2, 3, 5, 1, 45], + [0, 0, 4, 5, 1, 45], + [1, 2, 3, 2, 2, 45], + [1, 0, 4, 2, 2, 45], + [3, 4, 1, 6, 2, 45], + [3, 2, 2, 6, 2, 45], + [3, 0, 3, 6, 2, 45], + [0, 3, 2, 10, 2, 45], + [0, 1, 3, 10, 2, 45], + [1, 3, 2, 7, 3, 45], + [1, 1, 3, 7, 3, 45], + [0, 4, 1, 15, 3, 45], + [0, 2, 2, 15, 3, 45], + [0, 0, 3, 15, 3, 45], + [1, 4, 1, 12, 4, 45], + [1, 2, 2, 12, 4, 45], + [1, 0, 3, 12, 4, 45], + [0, 5, 0, 20, 4, 45], + [0, 3, 1, 20, 4, 45], + [0, 1, 2, 20, 4, 45], + [0, 4, 0, 25, 5, 45], + [0, 2, 1, 25, 5, 45], + [0, 0, 2, 25, 5, 45], + [0, 1, 4, 1, 1, 46], + [0, 2, 3, 6, 2, 46], + [0, 0, 4, 6, 2, 46], + [0, 3, 2, 11, 3, 46], + [0, 1, 3, 11, 3, 46], + [0, 4, 1, 16, 4, 46], + [0, 2, 2, 16, 4, 46], + [0, 0, 3, 16, 4, 46], + [0, 5, 0, 21, 5, 46], + [0, 3, 1, 21, 5, 46], + [0, 1, 2, 21, 5, 46], + [0, 1, 4, 2, 2, 47], + [0, 2, 3, 7, 3, 47], + [0, 0, 4, 7, 3, 47], + [0, 3, 2, 12, 4, 47], + [0, 1, 3, 12, 4, 47], + [0, 4, 1, 17, 5, 47], + [0, 2, 2, 17, 5, 47], + [0, 0, 3, 17, 5, 47], + [0, 0, 4, 8, 4, 48], + [0, 0, 4, 9, 5, 49], + [0, 0, 5, 0, 0, 50], + [0, 0, 4, 10, 2, 50], + [0, 0, 3, 20, 4, 50], + [0, 0, 5, 1, 1, 51], + [0, 0, 4, 11, 3, 51], + [0, 0, 3, 21, 5, 51], + [0, 0, 5, 2, 2, 52], + [0, 0, 4, 12, 4, 52], + ], + 44: [ + [3, 3, 2, 0, 0, 44], + [3, 1, 3, 0, 0, 44], + [1, 2, 3, 1, 1, 44], + [1, 0, 4, 1, 1, 44], + [3, 4, 1, 5, 1, 44], + [3, 2, 2, 5, 1, 44], + [3, 0, 3, 5, 1, 44], + [1, 3, 2, 6, 2, 44], + [1, 1, 3, 6, 2, 44], + [3, 5, 0, 10, 2, 44], + [3, 3, 1, 10, 2, 44], + [3, 1, 2, 10, 2, 44], + [2, 3, 2, 3, 3, 44], + [2, 1, 3, 3, 3, 44], + [1, 4, 1, 11, 3, 44], + [1, 2, 2, 11, 3, 44], + [1, 0, 3, 11, 3, 44], + [0, 2, 3, 4, 4, 44], + [0, 0, 4, 4, 4, 44], + [1, 5, 0, 16, 4, 44], + [1, 3, 1, 16, 4, 44], + [1, 1, 2, 16, 4, 44], + [0, 3, 2, 9, 5, 44], + [0, 1, 3, 9, 5, 44], + [5, 4, 1, 0, 0, 45], + [5, 2, 2, 0, 0, 45], + [5, 0, 3, 0, 0, 45], + [0, 1, 4, 0, 0, 45], + [3, 3, 2, 1, 1, 45], + [3, 1, 3, 1, 1, 45], + [0, 2, 3, 5, 1, 45], + [0, 0, 4, 5, 1, 45], + [1, 2, 3, 2, 2, 45], + [1, 0, 4, 2, 2, 45], + [3, 4, 1, 6, 2, 45], + [3, 2, 2, 6, 2, 45], + [3, 0, 3, 6, 2, 45], + [0, 3, 2, 10, 2, 45], + [0, 1, 3, 10, 2, 45], + [1, 3, 2, 7, 3, 45], + [1, 1, 3, 7, 3, 45], + [0, 4, 1, 15, 3, 45], + [0, 2, 2, 15, 3, 45], + [0, 0, 3, 15, 3, 45], + [1, 4, 1, 12, 4, 45], + [1, 2, 2, 12, 4, 45], + [1, 0, 3, 12, 4, 45], + [0, 5, 0, 20, 4, 45], + [0, 3, 1, 20, 4, 45], + [0, 1, 2, 20, 4, 45], + [0, 4, 0, 25, 5, 45], + [0, 2, 1, 25, 5, 45], + [0, 0, 2, 25, 5, 45], + [2, 2, 3, 0, 0, 46], + [2, 0, 4, 0, 0, 46], + [0, 1, 4, 1, 1, 46], + [2, 3, 2, 5, 1, 46], + [2, 1, 3, 5, 1, 46], + [3, 3, 2, 2, 2, 46], + [3, 1, 3, 2, 2, 46], + [0, 2, 3, 6, 2, 46], + [0, 0, 4, 6, 2, 46], + [2, 4, 1, 10, 2, 46], + [2, 2, 2, 10, 2, 46], + [2, 0, 3, 10, 2, 46], + [1, 2, 3, 3, 3, 46], + [1, 0, 4, 3, 3, 46], + [0, 3, 2, 11, 3, 46], + [0, 1, 3, 11, 3, 46], + [2, 5, 0, 15, 3, 46], + [2, 3, 1, 15, 3, 46], + [2, 1, 2, 15, 3, 46], + [1, 3, 2, 8, 4, 46], + [1, 1, 3, 8, 4, 46], + [0, 4, 1, 16, 4, 46], + [0, 2, 2, 16, 4, 46], + [0, 0, 3, 16, 4, 46], + [0, 5, 0, 21, 5, 46], + [0, 3, 1, 21, 5, 46], + [0, 1, 2, 21, 5, 46], + [0, 1, 4, 2, 2, 47], + [0, 2, 3, 7, 3, 47], + [0, 0, 4, 7, 3, 47], + [0, 3, 2, 12, 4, 47], + [0, 1, 3, 12, 4, 47], + [0, 4, 1, 17, 5, 47], + [0, 2, 2, 17, 5, 47], + [0, 0, 3, 17, 5, 47], + [0, 1, 4, 3, 3, 48], + [0, 2, 3, 8, 4, 48], + [0, 0, 4, 8, 4, 48], + [0, 3, 2, 13, 5, 48], + [0, 1, 3, 13, 5, 48], + [0, 0, 4, 9, 5, 49], + [0, 0, 5, 0, 0, 50], + [0, 0, 4, 10, 2, 50], + [0, 0, 3, 20, 4, 50], + [0, 0, 5, 1, 1, 51], + [0, 0, 4, 11, 3, 51], + [0, 0, 3, 21, 5, 51], + [0, 0, 5, 2, 2, 52], + [0, 0, 4, 12, 4, 52], + [0, 0, 5, 3, 3, 53], + [0, 0, 4, 13, 5, 53], + ], + 45: [ + [5, 4, 1, 0, 0, 45], + [5, 2, 2, 0, 0, 45], + [5, 0, 3, 0, 0, 45], + [0, 1, 4, 0, 0, 45], + [3, 3, 2, 1, 1, 45], + [3, 1, 3, 1, 1, 45], + [0, 2, 3, 5, 1, 45], + [0, 0, 4, 5, 1, 45], + [1, 2, 3, 2, 2, 45], + [1, 0, 4, 2, 2, 45], + [3, 4, 1, 6, 2, 45], + [3, 2, 2, 6, 2, 45], + [3, 0, 3, 6, 2, 45], + [0, 3, 2, 10, 2, 45], + [0, 1, 3, 10, 2, 45], + [1, 3, 2, 7, 3, 45], + [1, 1, 3, 7, 3, 45], + [0, 4, 1, 15, 3, 45], + [0, 2, 2, 15, 3, 45], + [0, 0, 3, 15, 3, 45], + [1, 4, 1, 12, 4, 45], + [1, 2, 2, 12, 4, 45], + [1, 0, 3, 12, 4, 45], + [0, 5, 0, 20, 4, 45], + [0, 3, 1, 20, 4, 45], + [0, 1, 2, 20, 4, 45], + [0, 4, 0, 25, 5, 45], + [0, 2, 1, 25, 5, 45], + [0, 0, 2, 25, 5, 45], + [2, 2, 3, 0, 0, 46], + [2, 0, 4, 0, 0, 46], + [0, 1, 4, 1, 1, 46], + [2, 3, 2, 5, 1, 46], + [2, 1, 3, 5, 1, 46], + [3, 3, 2, 2, 2, 46], + [3, 1, 3, 2, 2, 46], + [0, 2, 3, 6, 2, 46], + [0, 0, 4, 6, 2, 46], + [2, 4, 1, 10, 2, 46], + [2, 2, 2, 10, 2, 46], + [2, 0, 3, 10, 2, 46], + [1, 2, 3, 3, 3, 46], + [1, 0, 4, 3, 3, 46], + [0, 3, 2, 11, 3, 46], + [0, 1, 3, 11, 3, 46], + [2, 5, 0, 15, 3, 46], + [2, 3, 1, 15, 3, 46], + [2, 1, 2, 15, 3, 46], + [1, 3, 2, 8, 4, 46], + [1, 1, 3, 8, 4, 46], + [0, 4, 1, 16, 4, 46], + [0, 2, 2, 16, 4, 46], + [0, 0, 3, 16, 4, 46], + [0, 5, 0, 21, 5, 46], + [0, 3, 1, 21, 5, 46], + [0, 1, 2, 21, 5, 46], + [4, 3, 2, 0, 0, 47], + [4, 1, 3, 0, 0, 47], + [2, 2, 3, 1, 1, 47], + [2, 0, 4, 1, 1, 47], + [4, 4, 1, 5, 1, 47], + [4, 2, 2, 5, 1, 47], + [4, 0, 3, 5, 1, 47], + [0, 1, 4, 2, 2, 47], + [2, 3, 2, 6, 2, 47], + [2, 1, 3, 6, 2, 47], + [0, 2, 3, 7, 3, 47], + [0, 0, 4, 7, 3, 47], + [2, 4, 1, 11, 3, 47], + [2, 2, 2, 11, 3, 47], + [2, 0, 3, 11, 3, 47], + [1, 2, 3, 4, 4, 47], + [1, 0, 4, 4, 4, 47], + [0, 3, 2, 12, 4, 47], + [0, 1, 3, 12, 4, 47], + [0, 4, 1, 17, 5, 47], + [0, 2, 2, 17, 5, 47], + [0, 0, 3, 17, 5, 47], + [0, 1, 4, 3, 3, 48], + [0, 2, 3, 8, 4, 48], + [0, 0, 4, 8, 4, 48], + [0, 3, 2, 13, 5, 48], + [0, 1, 3, 13, 5, 48], + [0, 1, 4, 4, 4, 49], + [0, 2, 3, 9, 5, 49], + [0, 0, 4, 9, 5, 49], + [0, 0, 5, 0, 0, 50], + [0, 0, 4, 10, 2, 50], + [0, 0, 3, 20, 4, 50], + [0, 0, 5, 1, 1, 51], + [0, 0, 4, 11, 3, 51], + [0, 0, 3, 21, 5, 51], + [0, 0, 5, 2, 2, 52], + [0, 0, 4, 12, 4, 52], + [0, 0, 5, 3, 3, 53], + [0, 0, 4, 13, 5, 53], + [0, 0, 5, 4, 4, 54], + ], + 46: [ + [2, 2, 3, 0, 0, 46], + [2, 0, 4, 0, 0, 46], + [0, 1, 4, 1, 1, 46], + [2, 3, 2, 5, 1, 46], + [2, 1, 3, 5, 1, 46], + [3, 3, 2, 2, 2, 46], + [3, 1, 3, 2, 2, 46], + [0, 2, 3, 6, 2, 46], + [0, 0, 4, 6, 2, 46], + [2, 4, 1, 10, 2, 46], + [2, 2, 2, 10, 2, 46], + [2, 0, 3, 10, 2, 46], + [1, 2, 3, 3, 3, 46], + [1, 0, 4, 3, 3, 46], + [0, 3, 2, 11, 3, 46], + [0, 1, 3, 11, 3, 46], + [2, 5, 0, 15, 3, 46], + [2, 3, 1, 15, 3, 46], + [2, 1, 2, 15, 3, 46], + [1, 3, 2, 8, 4, 46], + [1, 1, 3, 8, 4, 46], + [0, 4, 1, 16, 4, 46], + [0, 2, 2, 16, 4, 46], + [0, 0, 3, 16, 4, 46], + [0, 5, 0, 21, 5, 46], + [0, 3, 1, 21, 5, 46], + [0, 1, 2, 21, 5, 46], + [4, 3, 2, 0, 0, 47], + [4, 1, 3, 0, 0, 47], + [2, 2, 3, 1, 1, 47], + [2, 0, 4, 1, 1, 47], + [4, 4, 1, 5, 1, 47], + [4, 2, 2, 5, 1, 47], + [4, 0, 3, 5, 1, 47], + [0, 1, 4, 2, 2, 47], + [2, 3, 2, 6, 2, 47], + [2, 1, 3, 6, 2, 47], + [0, 2, 3, 7, 3, 47], + [0, 0, 4, 7, 3, 47], + [2, 4, 1, 11, 3, 47], + [2, 2, 2, 11, 3, 47], + [2, 0, 3, 11, 3, 47], + [1, 2, 3, 4, 4, 47], + [1, 0, 4, 4, 4, 47], + [0, 3, 2, 12, 4, 47], + [0, 1, 3, 12, 4, 47], + [0, 4, 1, 17, 5, 47], + [0, 2, 2, 17, 5, 47], + [0, 0, 3, 17, 5, 47], + [1, 1, 4, 0, 0, 48], + [4, 3, 2, 1, 1, 48], + [4, 1, 3, 1, 1, 48], + [1, 2, 3, 5, 1, 48], + [1, 0, 4, 5, 1, 48], + [2, 2, 3, 2, 2, 48], + [2, 0, 4, 2, 2, 48], + [1, 3, 2, 10, 2, 48], + [1, 1, 3, 10, 2, 48], + [0, 1, 4, 3, 3, 48], + [2, 3, 2, 7, 3, 48], + [2, 1, 3, 7, 3, 48], + [1, 4, 1, 15, 3, 48], + [1, 2, 2, 15, 3, 48], + [1, 0, 3, 15, 3, 48], + [0, 2, 3, 8, 4, 48], + [0, 0, 4, 8, 4, 48], + [1, 5, 0, 20, 4, 48], + [1, 3, 1, 20, 4, 48], + [1, 1, 2, 20, 4, 48], + [0, 3, 2, 13, 5, 48], + [0, 1, 3, 13, 5, 48], + [0, 1, 4, 4, 4, 49], + [0, 2, 3, 9, 5, 49], + [0, 0, 4, 9, 5, 49], + [0, 0, 5, 0, 0, 50], + [0, 1, 4, 5, 1, 50], + [0, 2, 3, 10, 2, 50], + [0, 0, 4, 10, 2, 50], + [0, 3, 2, 15, 3, 50], + [0, 1, 3, 15, 3, 50], + [0, 4, 1, 20, 4, 50], + [0, 2, 2, 20, 4, 50], + [0, 0, 3, 20, 4, 50], + [0, 5, 0, 25, 5, 50], + [0, 3, 1, 25, 5, 50], + [0, 1, 2, 25, 5, 50], + [0, 0, 5, 1, 1, 51], + [0, 0, 4, 11, 3, 51], + [0, 0, 3, 21, 5, 51], + [0, 0, 5, 2, 2, 52], + [0, 0, 4, 12, 4, 52], + [0, 0, 5, 3, 3, 53], + [0, 0, 4, 13, 5, 53], + [0, 0, 5, 4, 4, 54], + [0, 0, 5, 5, 1, 55], + [0, 0, 4, 15, 3, 55], + [0, 0, 3, 25, 5, 55], + ], + 47: [ + [4, 3, 2, 0, 0, 47], + [4, 1, 3, 0, 0, 47], + [2, 2, 3, 1, 1, 47], + [2, 0, 4, 1, 1, 47], + [4, 4, 1, 5, 1, 47], + [4, 2, 2, 5, 1, 47], + [4, 0, 3, 5, 1, 47], + [0, 1, 4, 2, 2, 47], + [2, 3, 2, 6, 2, 47], + [2, 1, 3, 6, 2, 47], + [0, 2, 3, 7, 3, 47], + [0, 0, 4, 7, 3, 47], + [2, 4, 1, 11, 3, 47], + [2, 2, 2, 11, 3, 47], + [2, 0, 3, 11, 3, 47], + [1, 2, 3, 4, 4, 47], + [1, 0, 4, 4, 4, 47], + [0, 3, 2, 12, 4, 47], + [0, 1, 3, 12, 4, 47], + [0, 4, 1, 17, 5, 47], + [0, 2, 2, 17, 5, 47], + [0, 0, 3, 17, 5, 47], + [1, 1, 4, 0, 0, 48], + [4, 3, 2, 1, 1, 48], + [4, 1, 3, 1, 1, 48], + [1, 2, 3, 5, 1, 48], + [1, 0, 4, 5, 1, 48], + [2, 2, 3, 2, 2, 48], + [2, 0, 4, 2, 2, 48], + [1, 3, 2, 10, 2, 48], + [1, 1, 3, 10, 2, 48], + [0, 1, 4, 3, 3, 48], + [2, 3, 2, 7, 3, 48], + [2, 1, 3, 7, 3, 48], + [1, 4, 1, 15, 3, 48], + [1, 2, 2, 15, 3, 48], + [1, 0, 3, 15, 3, 48], + [0, 2, 3, 8, 4, 48], + [0, 0, 4, 8, 4, 48], + [1, 5, 0, 20, 4, 48], + [1, 3, 1, 20, 4, 48], + [1, 1, 2, 20, 4, 48], + [0, 3, 2, 13, 5, 48], + [0, 1, 3, 13, 5, 48], + [3, 2, 3, 0, 0, 49], + [3, 0, 4, 0, 0, 49], + [1, 1, 4, 1, 1, 49], + [3, 3, 2, 5, 1, 49], + [3, 1, 3, 5, 1, 49], + [1, 2, 3, 6, 2, 49], + [1, 0, 4, 6, 2, 49], + [3, 4, 1, 10, 2, 49], + [3, 2, 2, 10, 2, 49], + [3, 0, 3, 10, 2, 49], + [2, 2, 3, 3, 3, 49], + [2, 0, 4, 3, 3, 49], + [1, 3, 2, 11, 3, 49], + [1, 1, 3, 11, 3, 49], + [0, 1, 4, 4, 4, 49], + [1, 4, 1, 16, 4, 49], + [1, 2, 2, 16, 4, 49], + [1, 0, 3, 16, 4, 49], + [0, 2, 3, 9, 5, 49], + [0, 0, 4, 9, 5, 49], + [0, 0, 5, 0, 0, 50], + [0, 1, 4, 5, 1, 50], + [0, 2, 3, 10, 2, 50], + [0, 0, 4, 10, 2, 50], + [0, 3, 2, 15, 3, 50], + [0, 1, 3, 15, 3, 50], + [0, 4, 1, 20, 4, 50], + [0, 2, 2, 20, 4, 50], + [0, 0, 3, 20, 4, 50], + [0, 5, 0, 25, 5, 50], + [0, 3, 1, 25, 5, 50], + [0, 1, 2, 25, 5, 50], + [0, 0, 5, 1, 1, 51], + [0, 1, 4, 6, 2, 51], + [0, 2, 3, 11, 3, 51], + [0, 0, 4, 11, 3, 51], + [0, 3, 2, 16, 4, 51], + [0, 1, 3, 16, 4, 51], + [0, 4, 1, 21, 5, 51], + [0, 2, 2, 21, 5, 51], + [0, 0, 3, 21, 5, 51], + [0, 0, 5, 2, 2, 52], + [0, 0, 4, 12, 4, 52], + [0, 0, 5, 3, 3, 53], + [0, 0, 4, 13, 5, 53], + [0, 0, 5, 4, 4, 54], + [0, 0, 5, 5, 1, 55], + [0, 0, 4, 15, 3, 55], + [0, 0, 3, 25, 5, 55], + [0, 0, 5, 6, 2, 56], + [0, 0, 4, 16, 4, 56], + ], + 48: [ + [1, 1, 4, 0, 0, 48], + [4, 3, 2, 1, 1, 48], + [4, 1, 3, 1, 1, 48], + [1, 2, 3, 5, 1, 48], + [1, 0, 4, 5, 1, 48], + [2, 2, 3, 2, 2, 48], + [2, 0, 4, 2, 2, 48], + [1, 3, 2, 10, 2, 48], + [1, 1, 3, 10, 2, 48], + [0, 1, 4, 3, 3, 48], + [2, 3, 2, 7, 3, 48], + [2, 1, 3, 7, 3, 48], + [1, 4, 1, 15, 3, 48], + [1, 2, 2, 15, 3, 48], + [1, 0, 3, 15, 3, 48], + [0, 2, 3, 8, 4, 48], + [0, 0, 4, 8, 4, 48], + [1, 5, 0, 20, 4, 48], + [1, 3, 1, 20, 4, 48], + [1, 1, 2, 20, 4, 48], + [0, 3, 2, 13, 5, 48], + [0, 1, 3, 13, 5, 48], + [3, 2, 3, 0, 0, 49], + [3, 0, 4, 0, 0, 49], + [1, 1, 4, 1, 1, 49], + [3, 3, 2, 5, 1, 49], + [3, 1, 3, 5, 1, 49], + [1, 2, 3, 6, 2, 49], + [1, 0, 4, 6, 2, 49], + [3, 4, 1, 10, 2, 49], + [3, 2, 2, 10, 2, 49], + [3, 0, 3, 10, 2, 49], + [2, 2, 3, 3, 3, 49], + [2, 0, 4, 3, 3, 49], + [1, 3, 2, 11, 3, 49], + [1, 1, 3, 11, 3, 49], + [0, 1, 4, 4, 4, 49], + [1, 4, 1, 16, 4, 49], + [1, 2, 2, 16, 4, 49], + [1, 0, 3, 16, 4, 49], + [0, 2, 3, 9, 5, 49], + [0, 0, 4, 9, 5, 49], + [5, 3, 2, 0, 0, 50], + [5, 1, 3, 0, 0, 50], + [0, 0, 5, 0, 0, 50], + [3, 2, 3, 1, 1, 50], + [3, 0, 4, 1, 1, 50], + [0, 1, 4, 5, 1, 50], + [1, 1, 4, 2, 2, 50], + [3, 3, 2, 6, 2, 50], + [3, 1, 3, 6, 2, 50], + [0, 2, 3, 10, 2, 50], + [0, 0, 4, 10, 2, 50], + [1, 2, 3, 7, 3, 50], + [1, 0, 4, 7, 3, 50], + [0, 3, 2, 15, 3, 50], + [0, 1, 3, 15, 3, 50], + [1, 3, 2, 12, 4, 50], + [1, 1, 3, 12, 4, 50], + [0, 4, 1, 20, 4, 50], + [0, 2, 2, 20, 4, 50], + [0, 0, 3, 20, 4, 50], + [0, 5, 0, 25, 5, 50], + [0, 3, 1, 25, 5, 50], + [0, 1, 2, 25, 5, 50], + [0, 0, 5, 1, 1, 51], + [0, 1, 4, 6, 2, 51], + [0, 2, 3, 11, 3, 51], + [0, 0, 4, 11, 3, 51], + [0, 3, 2, 16, 4, 51], + [0, 1, 3, 16, 4, 51], + [0, 4, 1, 21, 5, 51], + [0, 2, 2, 21, 5, 51], + [0, 0, 3, 21, 5, 51], + [0, 0, 5, 2, 2, 52], + [0, 1, 4, 7, 3, 52], + [0, 2, 3, 12, 4, 52], + [0, 0, 4, 12, 4, 52], + [0, 3, 2, 17, 5, 52], + [0, 1, 3, 17, 5, 52], + [0, 0, 5, 3, 3, 53], + [0, 0, 4, 13, 5, 53], + [0, 0, 5, 4, 4, 54], + [0, 0, 5, 5, 1, 55], + [0, 0, 4, 15, 3, 55], + [0, 0, 3, 25, 5, 55], + [0, 0, 5, 6, 2, 56], + [0, 0, 4, 16, 4, 56], + [0, 0, 5, 7, 3, 57], + [0, 0, 4, 17, 5, 57], + ], + 49: [ + [3, 2, 3, 0, 0, 49], + [3, 0, 4, 0, 0, 49], + [1, 1, 4, 1, 1, 49], + [3, 3, 2, 5, 1, 49], + [3, 1, 3, 5, 1, 49], + [1, 2, 3, 6, 2, 49], + [1, 0, 4, 6, 2, 49], + [3, 4, 1, 10, 2, 49], + [3, 2, 2, 10, 2, 49], + [3, 0, 3, 10, 2, 49], + [2, 2, 3, 3, 3, 49], + [2, 0, 4, 3, 3, 49], + [1, 3, 2, 11, 3, 49], + [1, 1, 3, 11, 3, 49], + [0, 1, 4, 4, 4, 49], + [1, 4, 1, 16, 4, 49], + [1, 2, 2, 16, 4, 49], + [1, 0, 3, 16, 4, 49], + [0, 2, 3, 9, 5, 49], + [0, 0, 4, 9, 5, 49], + [5, 3, 2, 0, 0, 50], + [5, 1, 3, 0, 0, 50], + [0, 0, 5, 0, 0, 50], + [3, 2, 3, 1, 1, 50], + [3, 0, 4, 1, 1, 50], + [0, 1, 4, 5, 1, 50], + [1, 1, 4, 2, 2, 50], + [3, 3, 2, 6, 2, 50], + [3, 1, 3, 6, 2, 50], + [0, 2, 3, 10, 2, 50], + [0, 0, 4, 10, 2, 50], + [1, 2, 3, 7, 3, 50], + [1, 0, 4, 7, 3, 50], + [0, 3, 2, 15, 3, 50], + [0, 1, 3, 15, 3, 50], + [1, 3, 2, 12, 4, 50], + [1, 1, 3, 12, 4, 50], + [0, 4, 1, 20, 4, 50], + [0, 2, 2, 20, 4, 50], + [0, 0, 3, 20, 4, 50], + [0, 5, 0, 25, 5, 50], + [0, 3, 1, 25, 5, 50], + [0, 1, 2, 25, 5, 50], + [2, 1, 4, 0, 0, 51], + [0, 0, 5, 1, 1, 51], + [2, 2, 3, 5, 1, 51], + [2, 0, 4, 5, 1, 51], + [3, 2, 3, 2, 2, 51], + [3, 0, 4, 2, 2, 51], + [0, 1, 4, 6, 2, 51], + [2, 3, 2, 10, 2, 51], + [2, 1, 3, 10, 2, 51], + [1, 1, 4, 3, 3, 51], + [0, 2, 3, 11, 3, 51], + [0, 0, 4, 11, 3, 51], + [2, 4, 1, 15, 3, 51], + [2, 2, 2, 15, 3, 51], + [2, 0, 3, 15, 3, 51], + [1, 2, 3, 8, 4, 51], + [1, 0, 4, 8, 4, 51], + [0, 3, 2, 16, 4, 51], + [0, 1, 3, 16, 4, 51], + [0, 4, 1, 21, 5, 51], + [0, 2, 2, 21, 5, 51], + [0, 0, 3, 21, 5, 51], + [0, 0, 5, 2, 2, 52], + [0, 1, 4, 7, 3, 52], + [0, 2, 3, 12, 4, 52], + [0, 0, 4, 12, 4, 52], + [0, 3, 2, 17, 5, 52], + [0, 1, 3, 17, 5, 52], + [0, 0, 5, 3, 3, 53], + [0, 1, 4, 8, 4, 53], + [0, 2, 3, 13, 5, 53], + [0, 0, 4, 13, 5, 53], + [0, 0, 5, 4, 4, 54], + [0, 0, 5, 5, 1, 55], + [0, 0, 4, 15, 3, 55], + [0, 0, 3, 25, 5, 55], + [0, 0, 5, 6, 2, 56], + [0, 0, 4, 16, 4, 56], + [0, 0, 5, 7, 3, 57], + [0, 0, 4, 17, 5, 57], + [0, 0, 5, 8, 4, 58], + ], + 50: [ + [5, 3, 2, 0, 0, 50], + [5, 1, 3, 0, 0, 50], + [0, 0, 5, 0, 0, 50], + [3, 2, 3, 1, 1, 50], + [3, 0, 4, 1, 1, 50], + [0, 1, 4, 5, 1, 50], + [1, 1, 4, 2, 2, 50], + [3, 3, 2, 6, 2, 50], + [3, 1, 3, 6, 2, 50], + [0, 2, 3, 10, 2, 50], + [0, 0, 4, 10, 2, 50], + [1, 2, 3, 7, 3, 50], + [1, 0, 4, 7, 3, 50], + [0, 3, 2, 15, 3, 50], + [0, 1, 3, 15, 3, 50], + [1, 3, 2, 12, 4, 50], + [1, 1, 3, 12, 4, 50], + [0, 4, 1, 20, 4, 50], + [0, 2, 2, 20, 4, 50], + [0, 0, 3, 20, 4, 50], + [0, 5, 0, 25, 5, 50], + [0, 3, 1, 25, 5, 50], + [0, 1, 2, 25, 5, 50], + [2, 1, 4, 0, 0, 51], + [0, 0, 5, 1, 1, 51], + [2, 2, 3, 5, 1, 51], + [2, 0, 4, 5, 1, 51], + [3, 2, 3, 2, 2, 51], + [3, 0, 4, 2, 2, 51], + [0, 1, 4, 6, 2, 51], + [2, 3, 2, 10, 2, 51], + [2, 1, 3, 10, 2, 51], + [1, 1, 4, 3, 3, 51], + [0, 2, 3, 11, 3, 51], + [0, 0, 4, 11, 3, 51], + [2, 4, 1, 15, 3, 51], + [2, 2, 2, 15, 3, 51], + [2, 0, 3, 15, 3, 51], + [1, 2, 3, 8, 4, 51], + [1, 0, 4, 8, 4, 51], + [0, 3, 2, 16, 4, 51], + [0, 1, 3, 16, 4, 51], + [0, 4, 1, 21, 5, 51], + [0, 2, 2, 21, 5, 51], + [0, 0, 3, 21, 5, 51], + [4, 2, 3, 0, 0, 52], + [4, 0, 4, 0, 0, 52], + [2, 1, 4, 1, 1, 52], + [4, 3, 2, 5, 1, 52], + [4, 1, 3, 5, 1, 52], + [0, 0, 5, 2, 2, 52], + [2, 2, 3, 6, 2, 52], + [2, 0, 4, 6, 2, 52], + [0, 1, 4, 7, 3, 52], + [2, 3, 2, 11, 3, 52], + [2, 1, 3, 11, 3, 52], + [1, 1, 4, 4, 4, 52], + [0, 2, 3, 12, 4, 52], + [0, 0, 4, 12, 4, 52], + [0, 3, 2, 17, 5, 52], + [0, 1, 3, 17, 5, 52], + [0, 0, 5, 3, 3, 53], + [0, 1, 4, 8, 4, 53], + [0, 2, 3, 13, 5, 53], + [0, 0, 4, 13, 5, 53], + [0, 0, 5, 4, 4, 54], + [0, 1, 4, 9, 5, 54], + [0, 0, 5, 5, 1, 55], + [0, 0, 4, 15, 3, 55], + [0, 0, 3, 25, 5, 55], + [0, 0, 5, 6, 2, 56], + [0, 0, 4, 16, 4, 56], + [0, 0, 5, 7, 3, 57], + [0, 0, 4, 17, 5, 57], + [0, 0, 5, 8, 4, 58], + [0, 0, 5, 9, 5, 59], + ], + 51: [ + [2, 1, 4, 0, 0, 51], + [0, 0, 5, 1, 1, 51], + [2, 2, 3, 5, 1, 51], + [2, 0, 4, 5, 1, 51], + [3, 2, 3, 2, 2, 51], + [3, 0, 4, 2, 2, 51], + [0, 1, 4, 6, 2, 51], + [2, 3, 2, 10, 2, 51], + [2, 1, 3, 10, 2, 51], + [1, 1, 4, 3, 3, 51], + [0, 2, 3, 11, 3, 51], + [0, 0, 4, 11, 3, 51], + [2, 4, 1, 15, 3, 51], + [2, 2, 2, 15, 3, 51], + [2, 0, 3, 15, 3, 51], + [1, 2, 3, 8, 4, 51], + [1, 0, 4, 8, 4, 51], + [0, 3, 2, 16, 4, 51], + [0, 1, 3, 16, 4, 51], + [0, 4, 1, 21, 5, 51], + [0, 2, 2, 21, 5, 51], + [0, 0, 3, 21, 5, 51], + [4, 2, 3, 0, 0, 52], + [4, 0, 4, 0, 0, 52], + [2, 1, 4, 1, 1, 52], + [4, 3, 2, 5, 1, 52], + [4, 1, 3, 5, 1, 52], + [0, 0, 5, 2, 2, 52], + [2, 2, 3, 6, 2, 52], + [2, 0, 4, 6, 2, 52], + [0, 1, 4, 7, 3, 52], + [2, 3, 2, 11, 3, 52], + [2, 1, 3, 11, 3, 52], + [1, 1, 4, 4, 4, 52], + [0, 2, 3, 12, 4, 52], + [0, 0, 4, 12, 4, 52], + [0, 3, 2, 17, 5, 52], + [0, 1, 3, 17, 5, 52], + [1, 0, 5, 0, 0, 53], + [4, 2, 3, 1, 1, 53], + [4, 0, 4, 1, 1, 53], + [1, 1, 4, 5, 1, 53], + [2, 1, 4, 2, 2, 53], + [1, 2, 3, 10, 2, 53], + [1, 0, 4, 10, 2, 53], + [0, 0, 5, 3, 3, 53], + [2, 2, 3, 7, 3, 53], + [2, 0, 4, 7, 3, 53], + [1, 3, 2, 15, 3, 53], + [1, 1, 3, 15, 3, 53], + [0, 1, 4, 8, 4, 53], + [1, 4, 1, 20, 4, 53], + [1, 2, 2, 20, 4, 53], + [1, 0, 3, 20, 4, 53], + [0, 2, 3, 13, 5, 53], + [0, 0, 4, 13, 5, 53], + [0, 0, 5, 4, 4, 54], + [0, 1, 4, 9, 5, 54], + [0, 0, 5, 5, 1, 55], + [0, 1, 4, 10, 2, 55], + [0, 2, 3, 15, 3, 55], + [0, 0, 4, 15, 3, 55], + [0, 3, 2, 20, 4, 55], + [0, 1, 3, 20, 4, 55], + [0, 4, 1, 25, 5, 55], + [0, 2, 2, 25, 5, 55], + [0, 0, 3, 25, 5, 55], + [0, 0, 5, 6, 2, 56], + [0, 0, 4, 16, 4, 56], + [0, 0, 5, 7, 3, 57], + [0, 0, 4, 17, 5, 57], + [0, 0, 5, 8, 4, 58], + [0, 0, 5, 9, 5, 59], + [0, 0, 5, 10, 2, 60], + [0, 0, 4, 20, 4, 60], + ], + 52: [ + [4, 2, 3, 0, 0, 52], + [4, 0, 4, 0, 0, 52], + [2, 1, 4, 1, 1, 52], + [4, 3, 2, 5, 1, 52], + [4, 1, 3, 5, 1, 52], + [0, 0, 5, 2, 2, 52], + [2, 2, 3, 6, 2, 52], + [2, 0, 4, 6, 2, 52], + [0, 1, 4, 7, 3, 52], + [2, 3, 2, 11, 3, 52], + [2, 1, 3, 11, 3, 52], + [1, 1, 4, 4, 4, 52], + [0, 2, 3, 12, 4, 52], + [0, 0, 4, 12, 4, 52], + [0, 3, 2, 17, 5, 52], + [0, 1, 3, 17, 5, 52], + [1, 0, 5, 0, 0, 53], + [4, 2, 3, 1, 1, 53], + [4, 0, 4, 1, 1, 53], + [1, 1, 4, 5, 1, 53], + [2, 1, 4, 2, 2, 53], + [1, 2, 3, 10, 2, 53], + [1, 0, 4, 10, 2, 53], + [0, 0, 5, 3, 3, 53], + [2, 2, 3, 7, 3, 53], + [2, 0, 4, 7, 3, 53], + [1, 3, 2, 15, 3, 53], + [1, 1, 3, 15, 3, 53], + [0, 1, 4, 8, 4, 53], + [1, 4, 1, 20, 4, 53], + [1, 2, 2, 20, 4, 53], + [1, 0, 3, 20, 4, 53], + [0, 2, 3, 13, 5, 53], + [0, 0, 4, 13, 5, 53], + [3, 1, 4, 0, 0, 54], + [1, 0, 5, 1, 1, 54], + [3, 2, 3, 5, 1, 54], + [3, 0, 4, 5, 1, 54], + [1, 1, 4, 6, 2, 54], + [3, 3, 2, 10, 2, 54], + [3, 1, 3, 10, 2, 54], + [2, 1, 4, 3, 3, 54], + [1, 2, 3, 11, 3, 54], + [1, 0, 4, 11, 3, 54], + [0, 0, 5, 4, 4, 54], + [1, 3, 2, 16, 4, 54], + [1, 1, 3, 16, 4, 54], + [0, 1, 4, 9, 5, 54], + [0, 0, 5, 5, 1, 55], + [0, 1, 4, 10, 2, 55], + [0, 2, 3, 15, 3, 55], + [0, 0, 4, 15, 3, 55], + [0, 3, 2, 20, 4, 55], + [0, 1, 3, 20, 4, 55], + [0, 4, 1, 25, 5, 55], + [0, 2, 2, 25, 5, 55], + [0, 0, 3, 25, 5, 55], + [0, 0, 5, 6, 2, 56], + [0, 1, 4, 11, 3, 56], + [0, 2, 3, 16, 4, 56], + [0, 0, 4, 16, 4, 56], + [0, 3, 2, 21, 5, 56], + [0, 1, 3, 21, 5, 56], + [0, 0, 5, 7, 3, 57], + [0, 0, 4, 17, 5, 57], + [0, 0, 5, 8, 4, 58], + [0, 0, 5, 9, 5, 59], + [0, 0, 5, 10, 2, 60], + [0, 0, 4, 20, 4, 60], + [0, 0, 5, 11, 3, 61], + [0, 0, 4, 21, 5, 61], + ], + 53: [ + [1, 0, 5, 0, 0, 53], + [4, 2, 3, 1, 1, 53], + [4, 0, 4, 1, 1, 53], + [1, 1, 4, 5, 1, 53], + [2, 1, 4, 2, 2, 53], + [1, 2, 3, 10, 2, 53], + [1, 0, 4, 10, 2, 53], + [0, 0, 5, 3, 3, 53], + [2, 2, 3, 7, 3, 53], + [2, 0, 4, 7, 3, 53], + [1, 3, 2, 15, 3, 53], + [1, 1, 3, 15, 3, 53], + [0, 1, 4, 8, 4, 53], + [1, 4, 1, 20, 4, 53], + [1, 2, 2, 20, 4, 53], + [1, 0, 3, 20, 4, 53], + [0, 2, 3, 13, 5, 53], + [0, 0, 4, 13, 5, 53], + [3, 1, 4, 0, 0, 54], + [1, 0, 5, 1, 1, 54], + [3, 2, 3, 5, 1, 54], + [3, 0, 4, 5, 1, 54], + [1, 1, 4, 6, 2, 54], + [3, 3, 2, 10, 2, 54], + [3, 1, 3, 10, 2, 54], + [2, 1, 4, 3, 3, 54], + [1, 2, 3, 11, 3, 54], + [1, 0, 4, 11, 3, 54], + [0, 0, 5, 4, 4, 54], + [1, 3, 2, 16, 4, 54], + [1, 1, 3, 16, 4, 54], + [0, 1, 4, 9, 5, 54], + [5, 2, 3, 0, 0, 55], + [5, 0, 4, 0, 0, 55], + [3, 1, 4, 1, 1, 55], + [0, 0, 5, 5, 1, 55], + [1, 0, 5, 2, 2, 55], + [3, 2, 3, 6, 2, 55], + [3, 0, 4, 6, 2, 55], + [0, 1, 4, 10, 2, 55], + [1, 1, 4, 7, 3, 55], + [0, 2, 3, 15, 3, 55], + [0, 0, 4, 15, 3, 55], + [1, 2, 3, 12, 4, 55], + [1, 0, 4, 12, 4, 55], + [0, 3, 2, 20, 4, 55], + [0, 1, 3, 20, 4, 55], + [0, 4, 1, 25, 5, 55], + [0, 2, 2, 25, 5, 55], + [0, 0, 3, 25, 5, 55], + [0, 0, 5, 6, 2, 56], + [0, 1, 4, 11, 3, 56], + [0, 2, 3, 16, 4, 56], + [0, 0, 4, 16, 4, 56], + [0, 3, 2, 21, 5, 56], + [0, 1, 3, 21, 5, 56], + [0, 0, 5, 7, 3, 57], + [0, 1, 4, 12, 4, 57], + [0, 2, 3, 17, 5, 57], + [0, 0, 4, 17, 5, 57], + [0, 0, 5, 8, 4, 58], + [0, 0, 5, 9, 5, 59], + [0, 0, 5, 10, 2, 60], + [0, 0, 4, 20, 4, 60], + [0, 0, 5, 11, 3, 61], + [0, 0, 4, 21, 5, 61], + [0, 0, 5, 12, 4, 62], + ], + 54: [ + [3, 1, 4, 0, 0, 54], + [1, 0, 5, 1, 1, 54], + [3, 2, 3, 5, 1, 54], + [3, 0, 4, 5, 1, 54], + [1, 1, 4, 6, 2, 54], + [3, 3, 2, 10, 2, 54], + [3, 1, 3, 10, 2, 54], + [2, 1, 4, 3, 3, 54], + [1, 2, 3, 11, 3, 54], + [1, 0, 4, 11, 3, 54], + [0, 0, 5, 4, 4, 54], + [1, 3, 2, 16, 4, 54], + [1, 1, 3, 16, 4, 54], + [0, 1, 4, 9, 5, 54], + [5, 2, 3, 0, 0, 55], + [5, 0, 4, 0, 0, 55], + [3, 1, 4, 1, 1, 55], + [0, 0, 5, 5, 1, 55], + [1, 0, 5, 2, 2, 55], + [3, 2, 3, 6, 2, 55], + [3, 0, 4, 6, 2, 55], + [0, 1, 4, 10, 2, 55], + [1, 1, 4, 7, 3, 55], + [0, 2, 3, 15, 3, 55], + [0, 0, 4, 15, 3, 55], + [1, 2, 3, 12, 4, 55], + [1, 0, 4, 12, 4, 55], + [0, 3, 2, 20, 4, 55], + [0, 1, 3, 20, 4, 55], + [0, 4, 1, 25, 5, 55], + [0, 2, 2, 25, 5, 55], + [0, 0, 3, 25, 5, 55], + [2, 0, 5, 0, 0, 56], + [2, 1, 4, 5, 1, 56], + [3, 1, 4, 2, 2, 56], + [0, 0, 5, 6, 2, 56], + [2, 2, 3, 10, 2, 56], + [2, 0, 4, 10, 2, 56], + [1, 0, 5, 3, 3, 56], + [0, 1, 4, 11, 3, 56], + [2, 3, 2, 15, 3, 56], + [2, 1, 3, 15, 3, 56], + [1, 1, 4, 8, 4, 56], + [0, 2, 3, 16, 4, 56], + [0, 0, 4, 16, 4, 56], + [0, 3, 2, 21, 5, 56], + [0, 1, 3, 21, 5, 56], + [0, 0, 5, 7, 3, 57], + [0, 1, 4, 12, 4, 57], + [0, 2, 3, 17, 5, 57], + [0, 0, 4, 17, 5, 57], + [0, 0, 5, 8, 4, 58], + [0, 1, 4, 13, 5, 58], + [0, 0, 5, 9, 5, 59], + [0, 0, 5, 10, 2, 60], + [0, 0, 4, 20, 4, 60], + [0, 0, 5, 11, 3, 61], + [0, 0, 4, 21, 5, 61], + [0, 0, 5, 12, 4, 62], + [0, 0, 5, 13, 5, 63], + ], + 55: [ + [5, 2, 3, 0, 0, 55], + [5, 0, 4, 0, 0, 55], + [3, 1, 4, 1, 1, 55], + [0, 0, 5, 5, 1, 55], + [1, 0, 5, 2, 2, 55], + [3, 2, 3, 6, 2, 55], + [3, 0, 4, 6, 2, 55], + [0, 1, 4, 10, 2, 55], + [1, 1, 4, 7, 3, 55], + [0, 2, 3, 15, 3, 55], + [0, 0, 4, 15, 3, 55], + [1, 2, 3, 12, 4, 55], + [1, 0, 4, 12, 4, 55], + [0, 3, 2, 20, 4, 55], + [0, 1, 3, 20, 4, 55], + [0, 4, 1, 25, 5, 55], + [0, 2, 2, 25, 5, 55], + [0, 0, 3, 25, 5, 55], + [2, 0, 5, 0, 0, 56], + [2, 1, 4, 5, 1, 56], + [3, 1, 4, 2, 2, 56], + [0, 0, 5, 6, 2, 56], + [2, 2, 3, 10, 2, 56], + [2, 0, 4, 10, 2, 56], + [1, 0, 5, 3, 3, 56], + [0, 1, 4, 11, 3, 56], + [2, 3, 2, 15, 3, 56], + [2, 1, 3, 15, 3, 56], + [1, 1, 4, 8, 4, 56], + [0, 2, 3, 16, 4, 56], + [0, 0, 4, 16, 4, 56], + [0, 3, 2, 21, 5, 56], + [0, 1, 3, 21, 5, 56], + [4, 1, 4, 0, 0, 57], + [2, 0, 5, 1, 1, 57], + [4, 2, 3, 5, 1, 57], + [4, 0, 4, 5, 1, 57], + [2, 1, 4, 6, 2, 57], + [0, 0, 5, 7, 3, 57], + [2, 2, 3, 11, 3, 57], + [2, 0, 4, 11, 3, 57], + [1, 0, 5, 4, 4, 57], + [0, 1, 4, 12, 4, 57], + [0, 2, 3, 17, 5, 57], + [0, 0, 4, 17, 5, 57], + [0, 0, 5, 8, 4, 58], + [0, 1, 4, 13, 5, 58], + [0, 0, 5, 9, 5, 59], + [0, 0, 5, 10, 2, 60], + [0, 0, 4, 20, 4, 60], + [0, 0, 5, 11, 3, 61], + [0, 0, 4, 21, 5, 61], + [0, 0, 5, 12, 4, 62], + [0, 0, 5, 13, 5, 63], + ], + 56: [ + [2, 0, 5, 0, 0, 56], + [2, 1, 4, 5, 1, 56], + [3, 1, 4, 2, 2, 56], + [0, 0, 5, 6, 2, 56], + [2, 2, 3, 10, 2, 56], + [2, 0, 4, 10, 2, 56], + [1, 0, 5, 3, 3, 56], + [0, 1, 4, 11, 3, 56], + [2, 3, 2, 15, 3, 56], + [2, 1, 3, 15, 3, 56], + [1, 1, 4, 8, 4, 56], + [0, 2, 3, 16, 4, 56], + [0, 0, 4, 16, 4, 56], + [0, 3, 2, 21, 5, 56], + [0, 1, 3, 21, 5, 56], + [4, 1, 4, 0, 0, 57], + [2, 0, 5, 1, 1, 57], + [4, 2, 3, 5, 1, 57], + [4, 0, 4, 5, 1, 57], + [2, 1, 4, 6, 2, 57], + [0, 0, 5, 7, 3, 57], + [2, 2, 3, 11, 3, 57], + [2, 0, 4, 11, 3, 57], + [1, 0, 5, 4, 4, 57], + [0, 1, 4, 12, 4, 57], + [0, 2, 3, 17, 5, 57], + [0, 0, 4, 17, 5, 57], + [4, 1, 4, 1, 1, 58], + [1, 0, 5, 5, 1, 58], + [2, 0, 5, 2, 2, 58], + [1, 1, 4, 10, 2, 58], + [2, 1, 4, 7, 3, 58], + [1, 2, 3, 15, 3, 58], + [1, 0, 4, 15, 3, 58], + [0, 0, 5, 8, 4, 58], + [1, 3, 2, 20, 4, 58], + [1, 1, 3, 20, 4, 58], + [0, 1, 4, 13, 5, 58], + [0, 0, 5, 9, 5, 59], + [0, 0, 5, 10, 2, 60], + [0, 1, 4, 15, 3, 60], + [0, 2, 3, 20, 4, 60], + [0, 0, 4, 20, 4, 60], + [0, 3, 2, 25, 5, 60], + [0, 1, 3, 25, 5, 60], + [0, 0, 5, 11, 3, 61], + [0, 0, 4, 21, 5, 61], + [0, 0, 5, 12, 4, 62], + [0, 0, 5, 13, 5, 63], + [0, 0, 5, 15, 3, 65], + [0, 0, 4, 25, 5, 65], + ], + 57: [ + [4, 1, 4, 0, 0, 57], + [2, 0, 5, 1, 1, 57], + [4, 2, 3, 5, 1, 57], + [4, 0, 4, 5, 1, 57], + [2, 1, 4, 6, 2, 57], + [0, 0, 5, 7, 3, 57], + [2, 2, 3, 11, 3, 57], + [2, 0, 4, 11, 3, 57], + [1, 0, 5, 4, 4, 57], + [0, 1, 4, 12, 4, 57], + [0, 2, 3, 17, 5, 57], + [0, 0, 4, 17, 5, 57], + [4, 1, 4, 1, 1, 58], + [1, 0, 5, 5, 1, 58], + [2, 0, 5, 2, 2, 58], + [1, 1, 4, 10, 2, 58], + [2, 1, 4, 7, 3, 58], + [1, 2, 3, 15, 3, 58], + [1, 0, 4, 15, 3, 58], + [0, 0, 5, 8, 4, 58], + [1, 3, 2, 20, 4, 58], + [1, 1, 3, 20, 4, 58], + [0, 1, 4, 13, 5, 58], + [3, 0, 5, 0, 0, 59], + [3, 1, 4, 5, 1, 59], + [1, 0, 5, 6, 2, 59], + [3, 2, 3, 10, 2, 59], + [3, 0, 4, 10, 2, 59], + [2, 0, 5, 3, 3, 59], + [1, 1, 4, 11, 3, 59], + [1, 2, 3, 16, 4, 59], + [1, 0, 4, 16, 4, 59], + [0, 0, 5, 9, 5, 59], + [0, 0, 5, 10, 2, 60], + [0, 1, 4, 15, 3, 60], + [0, 2, 3, 20, 4, 60], + [0, 0, 4, 20, 4, 60], + [0, 3, 2, 25, 5, 60], + [0, 1, 3, 25, 5, 60], + [0, 0, 5, 11, 3, 61], + [0, 1, 4, 16, 4, 61], + [0, 2, 3, 21, 5, 61], + [0, 0, 4, 21, 5, 61], + [0, 0, 5, 12, 4, 62], + [0, 0, 5, 13, 5, 63], + [0, 0, 5, 15, 3, 65], + [0, 0, 4, 25, 5, 65], + [0, 0, 5, 16, 4, 66], + ], + 58: [ + [4, 1, 4, 1, 1, 58], + [1, 0, 5, 5, 1, 58], + [2, 0, 5, 2, 2, 58], + [1, 1, 4, 10, 2, 58], + [2, 1, 4, 7, 3, 58], + [1, 2, 3, 15, 3, 58], + [1, 0, 4, 15, 3, 58], + [0, 0, 5, 8, 4, 58], + [1, 3, 2, 20, 4, 58], + [1, 1, 3, 20, 4, 58], + [0, 1, 4, 13, 5, 58], + [3, 0, 5, 0, 0, 59], + [3, 1, 4, 5, 1, 59], + [1, 0, 5, 6, 2, 59], + [3, 2, 3, 10, 2, 59], + [3, 0, 4, 10, 2, 59], + [2, 0, 5, 3, 3, 59], + [1, 1, 4, 11, 3, 59], + [1, 2, 3, 16, 4, 59], + [1, 0, 4, 16, 4, 59], + [0, 0, 5, 9, 5, 59], + [5, 1, 4, 0, 0, 60], + [3, 0, 5, 1, 1, 60], + [3, 1, 4, 6, 2, 60], + [0, 0, 5, 10, 2, 60], + [1, 0, 5, 7, 3, 60], + [0, 1, 4, 15, 3, 60], + [1, 1, 4, 12, 4, 60], + [0, 2, 3, 20, 4, 60], + [0, 0, 4, 20, 4, 60], + [0, 3, 2, 25, 5, 60], + [0, 1, 3, 25, 5, 60], + [0, 0, 5, 11, 3, 61], + [0, 1, 4, 16, 4, 61], + [0, 2, 3, 21, 5, 61], + [0, 0, 4, 21, 5, 61], + [0, 0, 5, 12, 4, 62], + [0, 1, 4, 17, 5, 62], + [0, 0, 5, 13, 5, 63], + [0, 0, 5, 15, 3, 65], + [0, 0, 4, 25, 5, 65], + [0, 0, 5, 16, 4, 66], + [0, 0, 5, 17, 5, 67], + ], + 59: [ + [3, 0, 5, 0, 0, 59], + [3, 1, 4, 5, 1, 59], + [1, 0, 5, 6, 2, 59], + [3, 2, 3, 10, 2, 59], + [3, 0, 4, 10, 2, 59], + [2, 0, 5, 3, 3, 59], + [1, 1, 4, 11, 3, 59], + [1, 2, 3, 16, 4, 59], + [1, 0, 4, 16, 4, 59], + [0, 0, 5, 9, 5, 59], + [5, 1, 4, 0, 0, 60], + [3, 0, 5, 1, 1, 60], + [3, 1, 4, 6, 2, 60], + [0, 0, 5, 10, 2, 60], + [1, 0, 5, 7, 3, 60], + [0, 1, 4, 15, 3, 60], + [1, 1, 4, 12, 4, 60], + [0, 2, 3, 20, 4, 60], + [0, 0, 4, 20, 4, 60], + [0, 3, 2, 25, 5, 60], + [0, 1, 3, 25, 5, 60], + [2, 0, 5, 5, 1, 61], + [3, 0, 5, 2, 2, 61], + [2, 1, 4, 10, 2, 61], + [0, 0, 5, 11, 3, 61], + [2, 2, 3, 15, 3, 61], + [2, 0, 4, 15, 3, 61], + [1, 0, 5, 8, 4, 61], + [0, 1, 4, 16, 4, 61], + [0, 2, 3, 21, 5, 61], + [0, 0, 4, 21, 5, 61], + [0, 0, 5, 12, 4, 62], + [0, 1, 4, 17, 5, 62], + [0, 0, 5, 13, 5, 63], + [0, 0, 5, 15, 3, 65], + [0, 0, 4, 25, 5, 65], + [0, 0, 5, 16, 4, 66], + [0, 0, 5, 17, 5, 67], + ], + 60: [ + [5, 1, 4, 0, 0, 60], + [3, 0, 5, 1, 1, 60], + [3, 1, 4, 6, 2, 60], + [0, 0, 5, 10, 2, 60], + [1, 0, 5, 7, 3, 60], + [0, 1, 4, 15, 3, 60], + [1, 1, 4, 12, 4, 60], + [0, 2, 3, 20, 4, 60], + [0, 0, 4, 20, 4, 60], + [0, 3, 2, 25, 5, 60], + [0, 1, 3, 25, 5, 60], + [2, 0, 5, 5, 1, 61], + [3, 0, 5, 2, 2, 61], + [2, 1, 4, 10, 2, 61], + [0, 0, 5, 11, 3, 61], + [2, 2, 3, 15, 3, 61], + [2, 0, 4, 15, 3, 61], + [1, 0, 5, 8, 4, 61], + [0, 1, 4, 16, 4, 61], + [0, 2, 3, 21, 5, 61], + [0, 0, 4, 21, 5, 61], + [4, 0, 5, 0, 0, 62], + [4, 1, 4, 5, 1, 62], + [2, 0, 5, 6, 2, 62], + [2, 1, 4, 11, 3, 62], + [0, 0, 5, 12, 4, 62], + [0, 1, 4, 17, 5, 62], + [0, 0, 5, 13, 5, 63], + [0, 0, 5, 15, 3, 65], + [0, 0, 4, 25, 5, 65], + [0, 0, 5, 16, 4, 66], + [0, 0, 5, 17, 5, 67], + ], + 61: [ + [2, 0, 5, 5, 1, 61], + [3, 0, 5, 2, 2, 61], + [2, 1, 4, 10, 2, 61], + [0, 0, 5, 11, 3, 61], + [2, 2, 3, 15, 3, 61], + [2, 0, 4, 15, 3, 61], + [1, 0, 5, 8, 4, 61], + [0, 1, 4, 16, 4, 61], + [0, 2, 3, 21, 5, 61], + [0, 0, 4, 21, 5, 61], + [4, 0, 5, 0, 0, 62], + [4, 1, 4, 5, 1, 62], + [2, 0, 5, 6, 2, 62], + [2, 1, 4, 11, 3, 62], + [0, 0, 5, 12, 4, 62], + [0, 1, 4, 17, 5, 62], + [4, 0, 5, 1, 1, 63], + [1, 0, 5, 10, 2, 63], + [2, 0, 5, 7, 3, 63], + [1, 1, 4, 15, 3, 63], + [1, 2, 3, 20, 4, 63], + [1, 0, 4, 20, 4, 63], + [0, 0, 5, 13, 5, 63], + [0, 0, 5, 15, 3, 65], + [0, 1, 4, 20, 4, 65], + [0, 2, 3, 25, 5, 65], + [0, 0, 4, 25, 5, 65], + [0, 0, 5, 16, 4, 66], + [0, 0, 5, 17, 5, 67], + [0, 0, 5, 20, 4, 70], + ], + 62: [ + [4, 0, 5, 0, 0, 62], + [4, 1, 4, 5, 1, 62], + [2, 0, 5, 6, 2, 62], + [2, 1, 4, 11, 3, 62], + [0, 0, 5, 12, 4, 62], + [0, 1, 4, 17, 5, 62], + [4, 0, 5, 1, 1, 63], + [1, 0, 5, 10, 2, 63], + [2, 0, 5, 7, 3, 63], + [1, 1, 4, 15, 3, 63], + [1, 2, 3, 20, 4, 63], + [1, 0, 4, 20, 4, 63], + [0, 0, 5, 13, 5, 63], + [3, 0, 5, 5, 1, 64], + [3, 1, 4, 10, 2, 64], + [1, 0, 5, 11, 3, 64], + [1, 1, 4, 16, 4, 64], + [0, 0, 5, 15, 3, 65], + [0, 1, 4, 20, 4, 65], + [0, 2, 3, 25, 5, 65], + [0, 0, 4, 25, 5, 65], + [0, 0, 5, 16, 4, 66], + [0, 1, 4, 21, 5, 66], + [0, 0, 5, 17, 5, 67], + [0, 0, 5, 20, 4, 70], + [0, 0, 5, 21, 5, 71], + ], + 63: [ + [4, 0, 5, 1, 1, 63], + [1, 0, 5, 10, 2, 63], + [2, 0, 5, 7, 3, 63], + [1, 1, 4, 15, 3, 63], + [1, 2, 3, 20, 4, 63], + [1, 0, 4, 20, 4, 63], + [0, 0, 5, 13, 5, 63], + [3, 0, 5, 5, 1, 64], + [3, 1, 4, 10, 2, 64], + [1, 0, 5, 11, 3, 64], + [1, 1, 4, 16, 4, 64], + [5, 0, 5, 0, 0, 65], + [3, 0, 5, 6, 2, 65], + [0, 0, 5, 15, 3, 65], + [1, 0, 5, 12, 4, 65], + [0, 1, 4, 20, 4, 65], + [0, 2, 3, 25, 5, 65], + [0, 0, 4, 25, 5, 65], + [0, 0, 5, 16, 4, 66], + [0, 1, 4, 21, 5, 66], + [0, 0, 5, 17, 5, 67], + [0, 0, 5, 20, 4, 70], + [0, 0, 5, 21, 5, 71], + ], + 64: [ + [3, 0, 5, 5, 1, 64], + [3, 1, 4, 10, 2, 64], + [1, 0, 5, 11, 3, 64], + [1, 1, 4, 16, 4, 64], + [5, 0, 5, 0, 0, 65], + [3, 0, 5, 6, 2, 65], + [0, 0, 5, 15, 3, 65], + [1, 0, 5, 12, 4, 65], + [0, 1, 4, 20, 4, 65], + [0, 2, 3, 25, 5, 65], + [0, 0, 4, 25, 5, 65], + [2, 0, 5, 10, 2, 66], + [2, 1, 4, 15, 3, 66], + [0, 0, 5, 16, 4, 66], + [0, 1, 4, 21, 5, 66], + [0, 0, 5, 17, 5, 67], + [0, 0, 5, 20, 4, 70], + [0, 0, 5, 21, 5, 71], + ], + 65: [ + [5, 0, 5, 0, 0, 65], + [3, 0, 5, 6, 2, 65], + [0, 0, 5, 15, 3, 65], + [1, 0, 5, 12, 4, 65], + [0, 1, 4, 20, 4, 65], + [0, 2, 3, 25, 5, 65], + [0, 0, 4, 25, 5, 65], + [2, 0, 5, 10, 2, 66], + [2, 1, 4, 15, 3, 66], + [0, 0, 5, 16, 4, 66], + [0, 1, 4, 21, 5, 66], + [4, 0, 5, 5, 1, 67], + [2, 0, 5, 11, 3, 67], + [0, 0, 5, 17, 5, 67], + [0, 0, 5, 20, 4, 70], + [0, 0, 5, 21, 5, 71], + ], + 66: [ + [2, 0, 5, 10, 2, 66], + [2, 1, 4, 15, 3, 66], + [0, 0, 5, 16, 4, 66], + [0, 1, 4, 21, 5, 66], + [4, 0, 5, 5, 1, 67], + [2, 0, 5, 11, 3, 67], + [0, 0, 5, 17, 5, 67], + [1, 0, 5, 15, 3, 68], + [1, 1, 4, 20, 4, 68], + [0, 0, 5, 20, 4, 70], + [0, 1, 4, 25, 5, 70], + [0, 0, 5, 21, 5, 71], + [0, 0, 5, 25, 5, 75], + ], + 67: [ + [4, 0, 5, 5, 1, 67], + [2, 0, 5, 11, 3, 67], + [0, 0, 5, 17, 5, 67], + [1, 0, 5, 15, 3, 68], + [1, 1, 4, 20, 4, 68], + [3, 0, 5, 10, 2, 69], + [1, 0, 5, 16, 4, 69], + [0, 0, 5, 20, 4, 70], + [0, 1, 4, 25, 5, 70], + [0, 0, 5, 21, 5, 71], + [0, 0, 5, 25, 5, 75], + ], + 68: [ + [1, 0, 5, 15, 3, 68], + [1, 1, 4, 20, 4, 68], + [3, 0, 5, 10, 2, 69], + [1, 0, 5, 16, 4, 69], + [0, 0, 5, 20, 4, 70], + [0, 1, 4, 25, 5, 70], + [0, 0, 5, 21, 5, 71], + [0, 0, 5, 25, 5, 75], + ], + 69: [ + [3, 0, 5, 10, 2, 69], + [1, 0, 5, 16, 4, 69], + [0, 0, 5, 20, 4, 70], + [0, 1, 4, 25, 5, 70], + [2, 0, 5, 15, 3, 71], + [0, 0, 5, 21, 5, 71], + [0, 0, 5, 25, 5, 75], + ], + 70: [ + [0, 0, 5, 20, 4, 70], + [0, 1, 4, 25, 5, 70], + [2, 0, 5, 15, 3, 71], + [0, 0, 5, 21, 5, 71], + [0, 0, 5, 25, 5, 75], + ], + 71: [ + [2, 0, 5, 15, 3, 71], + [0, 0, 5, 21, 5, 71], + [1, 0, 5, 20, 4, 73], + [0, 0, 5, 25, 5, 75], + ], + 72: [ + [1, 0, 5, 20, 4, 73], + [0, 0, 5, 25, 5, 75], + ], + 73: [ + [1, 0, 5, 20, 4, 73], + [0, 0, 5, 25, 5, 75], + ], + 74: [[0, 0, 5, 25, 5, 75]], + 75: [[0, 0, 5, 25, 5, 75]], +}; diff --git a/src/app/data/types/IInventoryArmor.ts b/src/app/data/types/IInventoryArmor.ts index 3e13a1dc..be5a0958 100644 --- a/src/app/data/types/IInventoryArmor.ts +++ b/src/app/data/types/IInventoryArmor.ts @@ -63,6 +63,7 @@ export interface IInventoryArmor ITimestampedEntry { // Note: this will be empty for vendor items statPlugHashes?: (number | undefined)[]; + tuningStat: ArmorStat | null; // for armor 3.0, this is the tuning stat hash // exoticPerkHash is now inherited as number[] from IManifestArmor } @@ -89,6 +90,7 @@ export function createArmorItem( intellect: 0, strength: 0, source, + tuningStat: null, // default to null for Armor 3.0 created_at: Date.now(), updated_at: Date.now(), }, @@ -100,7 +102,10 @@ export function createArmorItem( if ( manifestItem.hash == 2545426109 || manifestItem.hash == 199733460 || - manifestItem.hash == 3224066584 + manifestItem.hash == 3224066584 || + manifestItem.hash == 2390807586 || + manifestItem.hash == 2462335932 || + manifestItem.hash == 4095816113 ) { item.slot = ArmorSlot.ArmorSlotHelmet; } diff --git a/src/app/data/types/IPermutatorArmor.ts b/src/app/data/types/IPermutatorArmor.ts index 01e20545..0fc1d4e0 100644 --- a/src/app/data/types/IPermutatorArmor.ts +++ b/src/app/data/types/IPermutatorArmor.ts @@ -1,5 +1,5 @@ import { DestinyClass, TierType } from "bungie-api-ts/destiny2"; -import { ArmorPerkOrSlot } from "../enum/armor-stat"; +import { ArmorPerkOrSlot, ArmorStat } from "../enum/armor-stat"; import { IDestinyArmor } from "./IInventoryArmor"; export interface IPermutatorArmor extends IDestinyArmor { @@ -9,4 +9,5 @@ export interface IPermutatorArmor extends IDestinyArmor { rarity: TierType; isSunset: boolean; exoticPerkHash: number[]; + tuningStat: ArmorStat | null; // for armor 3.0, this is the tuning stat hash } diff --git a/src/app/data/types/IPermutatorArmorSet.ts b/src/app/data/types/IPermutatorArmorSet.ts index f86e2cea..4fba8efe 100644 --- a/src/app/data/types/IPermutatorArmorSet.ts +++ b/src/app/data/types/IPermutatorArmorSet.ts @@ -1,6 +1,6 @@ import { StatModifier } from "../enum/armor-stat"; import { IPermutatorArmor } from "./IPermutatorArmor"; - +export type Tuning = [number, number, number, number, number, number]; export interface IPermutatorArmorSet { armor: number[]; useExoticClassItem: boolean; @@ -8,6 +8,7 @@ export interface IPermutatorArmorSet { usedMods: StatModifier[]; statsWithMods: number[]; statsWithoutMods: number[]; + tuning: Tuning; } export function createArmorSet( @@ -19,7 +20,8 @@ export function createArmorSet( usedArtifice: StatModifier[], usedMods: StatModifier[], statsWithMods: number[], - statsWithoutMods: number[] + statsWithoutMods: number[], + tuning: Tuning ): IPermutatorArmorSet { return { armor: [helmet.id, gauntlet.id, chest.id, leg.id, classItem.id], @@ -28,6 +30,7 @@ export function createArmorSet( usedMods, statsWithMods, statsWithoutMods, + tuning: tuning, }; } diff --git a/src/app/guards/authenticated.guard.ts b/src/app/guards/authenticated.guard.ts index 6f22b27c..df5bcfa4 100644 --- a/src/app/guards/authenticated.guard.ts +++ b/src/app/guards/authenticated.guard.ts @@ -19,6 +19,7 @@ import { Injectable } from "@angular/core"; import { ActivatedRouteSnapshot, Router, RouterStateSnapshot, UrlTree } from "@angular/router"; import { Observable } from "rxjs"; import { AuthService } from "../services/auth.service"; +import { HttpClientService } from "../services/http-client.service"; @Injectable({ providedIn: "root", @@ -26,14 +27,20 @@ import { AuthService } from "../services/auth.service"; export class AuthenticatedGuard { constructor( public auth: AuthService, + public httpClient: HttpClientService, public router: Router ) {} canActivate( route: ActivatedRouteSnapshot, state: RouterStateSnapshot ): Observable | Promise | boolean | UrlTree { - if (!this.auth.isAuthenticated()) { - this.router.navigate(["login"]); + if (!this.httpClient.isAuthenticated()) { + // Check if there's a code parameter in the query string + if (state.url.includes("?code=") || route.queryParams["code"]) { + this.router.navigate(["authenticate"], { queryParams: route.queryParams }); + } else { + this.router.navigate(["login"]); + } return false; } return true; diff --git a/src/app/guards/not-authenticated.guard.ts b/src/app/guards/not-authenticated.guard.ts index 03868405..e837b00b 100644 --- a/src/app/guards/not-authenticated.guard.ts +++ b/src/app/guards/not-authenticated.guard.ts @@ -19,6 +19,7 @@ import { Injectable } from "@angular/core"; import { ActivatedRouteSnapshot, Router, RouterStateSnapshot, UrlTree } from "@angular/router"; import { Observable } from "rxjs"; import { AuthService } from "../services/auth.service"; +import { HttpClientService } from "../services/http-client.service"; @Injectable({ providedIn: "root", @@ -26,13 +27,14 @@ import { AuthService } from "../services/auth.service"; export class NotAuthenticatedGuard { constructor( public auth: AuthService, + public httpClient: HttpClientService, public router: Router ) {} canActivate( route: ActivatedRouteSnapshot, state: RouterStateSnapshot ): Observable | Promise | boolean | UrlTree { - if (this.auth.isAuthenticated()) { + if (this.httpClient.isAuthenticated()) { this.router.navigate(["/"]); return false; } diff --git a/src/app/modules/common-material/common-material.module.ts b/src/app/modules/common-material/common-material.module.ts index eb6981a1..7a1b8285 100644 --- a/src/app/modules/common-material/common-material.module.ts +++ b/src/app/modules/common-material/common-material.module.ts @@ -41,6 +41,7 @@ import { MatProgressSpinnerModule } from "@angular/material/progress-spinner"; import { MatTabsModule } from "@angular/material/tabs"; import { MatChipsModule } from "@angular/material/chips"; import { MatSidenavModule } from "@angular/material/sidenav"; +import { ScrollingModule } from "@angular/cdk/scrolling"; import { FormsModule, ReactiveFormsModule } from "@angular/forms"; @NgModule({ @@ -71,6 +72,7 @@ import { FormsModule, ReactiveFormsModule } from "@angular/forms"; MatTabsModule, MatChipsModule, MatSidenavModule, + ScrollingModule, ReactiveFormsModule, FormsModule, ], @@ -102,6 +104,8 @@ import { FormsModule, ReactiveFormsModule } from "@angular/forms"; MatTabsModule, MatChipsModule, MatSidenavModule, + ScrollingModule, + MatSliderModule, ], }) export class CommonMaterialModule {} diff --git a/src/app/services/armor-calculator.service.ts b/src/app/services/armor-calculator.service.ts new file mode 100644 index 00000000..17e31560 --- /dev/null +++ b/src/app/services/armor-calculator.service.ts @@ -0,0 +1,1003 @@ +/* + * Copyright (c) 2023 D2ArmorPicker by Mijago. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { Injectable, OnDestroy } from "@angular/core"; +import { LoggingProxyService } from "./logging-proxy.service"; +import { Router } from "@angular/router"; +import { DatabaseService } from "./database.service"; +import { IManifestArmor } from "../data/types/IManifestArmor"; +import { BehaviorSubject, Observable, Subject } from "rxjs"; +import { BuildConfiguration } from "../data/buildConfiguration"; +import { STAT_MOD_VALUES, StatModifier } from "../data/enum/armor-stat"; +import { StatusProviderService } from "./status-provider.service"; +import { ConfigurationService } from "./configuration.service"; +import { UserInformationService } from "./user-information.service"; +import { ArmorSlot } from "../data/enum/armor-slot"; +import { + ResultDefinition, + ResultItem, +} from "../components/authenticated-v2/results/results.component"; +import { + IInventoryArmor, + InventoryArmorSource, + isEqualItem, + totalStats, +} from "../data/types/IInventoryArmor"; +import { DestinyClass, TierType } from "bungie-api-ts/destiny2"; +import { IPermutatorArmorSet } from "../data/types/IPermutatorArmorSet"; +import { getSkillTier, getWaste } from "./results-builder.worker"; +import { IPermutatorArmor } from "../data/types/IPermutatorArmor"; +import { FORCE_USE_NO_EXOTIC, MAXIMUM_MASTERWORK_LEVEL } from "../data/constants"; +import { calculateCPUConcurrency } from "../data/commonFunctions"; +import { ModOptimizationStrategy } from "../data/enum/mod-optimization-strategy"; +import { ArmorSystem } from "../data/types/IManifestArmor"; +import { combineLatest, Subscription } from "rxjs"; +import { debounceTime, distinctUntilChanged, catchError, startWith } from "rxjs/operators"; +import { of } from "rxjs"; + +type info = { + results: ResultDefinition[]; + savedResults: number; + totalPermutations: number; + maximumPossibleTiers: number[]; + itemCount: number; + totalTime: number | null; +}; + +interface WorkerMessageData { + // Progress update properties + checkedCalculations: number; + estimatedCalculations: number; + reachableTiers?: number[]; // Available in progress messages + resultLimitReached?: boolean; // Indicates this worker hit its local result cap + + // Runtime data (available when results are sent) + runtime?: { + maximumPossibleTiers: number[]; + }; + + // Results data (available when results are sent) + results?: IPermutatorArmorSet[]; + done?: boolean; + + // Statistics (available in final completion message) + stats?: { + savedResults: number; + computedPermutations: number; + itemCount: number; + totalTime: number; + }; +} + +@Injectable({ + providedIn: "root", +}) +export class ArmorCalculatorService implements OnDestroy { + private _armorResults: BehaviorSubject; + public readonly armorResults: Observable; + private _reachableTiers: BehaviorSubject; + public readonly reachableTiers: Observable; + private _calculationProgress: Subject = new Subject(); + public readonly calculationProgress: Observable = + this._calculationProgress.asObservable(); + private _totalPossibleCombinations: BehaviorSubject = new BehaviorSubject(0); + public readonly totalPossibleCombinations: Observable = + this._totalPossibleCombinations.asObservable(); + + private calculationSubscription?: Subscription; + + // Static properties for calculation state + private static workers: Worker[] = []; + private static results: IPermutatorArmorSet[] = []; + private static savedResultsCount = 0; + private static totalPermutationsCount = 0; + private static resultMaximumTiers: number[][] = []; + private static selectedExotics: IManifestArmor[] = []; + private static endResults: ResultDefinition[] = []; + + // Static thread tracking arrays + private static threadCalculationAmountArr: number[] = []; + private static threadCalculationDoneArr: number[] = []; + private static threadCalculationReachableTiers: number[][] = []; + private static threadResultLimitReachedArr: boolean[] = []; + private static globalMaximumPossibleTiers: number[] = [0, 0, 0, 0, 0, 0]; + private static updateResultsStart: number = 0; + + // Static progress and worker state + private static doneWorkerCount = 0; + private static lastProgressUpdateTime = 0; + private static emittedPossibleCombinations = false; + private static allThreadsResultLimitReached = false; + + // Cancellation handling + private static cancellationRequested = false; + private static cancellationTimeoutId: any = null; + + constructor( + private db: DatabaseService, + private status: StatusProviderService, + private userInfo: UserInformationService, + private config: ConfigurationService, + private logger: LoggingProxyService, + private router: Router + ) { + this.logger.debug( + "ArmorCalculatorService", + "constructor", + "Initializing ArmorCalculatorService" + ); + + this._armorResults = new BehaviorSubject({ + results: ArmorCalculatorService.endResults, + } as info); + this.armorResults = this._armorResults.asObservable(); + + this._reachableTiers = new BehaviorSubject([0, 0, 0, 0, 0, 0]); + this.reachableTiers = this._reachableTiers.asObservable(); + + // Static workers array is already initialized + + // Setup calculation triggers - use longer delay to ensure services are ready + setTimeout(() => this.setupCalculationTriggers(), 100); + + this.logger.debug( + "ArmorCalculatorService", + "constructor", + "Finished initializing ArmorCalculatorService" + ); + } + + ngOnDestroy() { + this.logger.debug("ArmorCalculatorService", "ngOnDestroy", "Destroying ArmorCalculatorService"); + this.calculationSubscription?.unsubscribe(); + this.killWorkers(); + this.logger.debug( + "ArmorCalculatorService", + "ngOnDestroy", + "Finished destroying ArmorCalculatorService" + ); + } + + private setupCalculationTriggers() { + this.logger.debug( + "ArmorCalculatorService", + "setupCalculationTriggers", + "Setting up calculation triggers" + ); + + try { + // Verify services are available + if (!this.userInfo) { + this.logger.error( + "ArmorCalculatorService", + "setupCalculationTriggers", + "UserInformationService not available" + ); + return; + } + + if (!this.config) { + this.logger.error( + "ArmorCalculatorService", + "setupCalculationTriggers", + "ConfigurationService not available" + ); + return; + } + + this.logger.debug( + "ArmorCalculatorService", + "setupCalculationTriggers", + "Services available, setting up observables" + ); + + // this.router.events + // .pipe(filter((event): event is NavigationEnd => event instanceof NavigationEnd)) + // .subscribe((event) => { + // this.logger.debug( + // "ArmorCalculatorService", + // "setupCalculationTriggers", + // "Route changed to: " + event.url + // ); + // }); + + // Set up single subscription that persists throughout the service lifecycle + // Use startWith to ensure all observables have initial values for combineLatest + this.calculationSubscription = combineLatest([ + this.userInfo.inventory.pipe(startWith(null)), // Start with null to ensure emission + this.config.configuration, + ]) + .pipe( + debounceTime(100), + distinctUntilChanged(), + catchError((error) => { + this.logger.error( + "ArmorCalculatorService", + "setupCalculationTriggers", + "Error in observable stream: " + error + ); + return of([null, null]); // Return empty values to continue the stream + }) + ) + .subscribe({ + next: ([inventory, config]) => { + // Only perform calculations if we're on the main page + if (!this.isMainPage()) { + this.logger.debug( + "ArmorCalculatorService", + "setupCalculationTriggers", + "Not on main page, skipping calculation" + ); + return; + } + + if (!config) { + return; + } + + if (this.userInfo.isFetchingManifest || this.userInfo.isRefreshing) { + this.logger.debug( + "ArmorCalculatorService", + "setupCalculationTriggers", + "UserInformationService is still fetching manifest or refreshing, skipping calculation" + ); + return; + } + + const buildConfig = config as BuildConfiguration; + if (buildConfig.characterClass !== DestinyClass.Unknown) { + this.logger.info( + "ArmorCalculatorService", + "setupCalculationTriggers", + "Triggering calculation for class: " + buildConfig.characterClass + ); + this.calculateArmorSetResults(buildConfig, buildConfig.characterClass); + } + }, + error: (error) => { + this.logger.error( + "ArmorCalculatorService", + "setupCalculationTriggers", + "Subscription error: " + error + ); + }, + }); + + this.logger.debug( + "ArmorCalculatorService", + "setupCalculationTriggers", + "Calculation triggers set up successfully" + ); + } catch (error) { + this.logger.error( + "ArmorCalculatorService", + "setupCalculationTriggers", + "Failed to setup triggers: " + error + ); + } + } + + private isMainPage(): boolean { + const currentUrl = this.router.url; + // Main page is either empty path or just '/', check for parametrized (?) routes and (#) fragments + return ( + currentUrl === "/" || + currentUrl === "" || + currentUrl.split("?")[0] === "/" || + currentUrl.split("#")[0] === "/" + ); + } + + private clearResults() { + if (ArmorCalculatorService.endResults.length > 0) { + ArmorCalculatorService.endResults = []; + this._armorResults.next({ + results: ArmorCalculatorService.endResults, + savedResults: 0, + totalPermutations: 0, + totalTime: 0, + itemCount: 0, + maximumPossibleTiers: [0, 0, 0, 0, 0, 0], + }); + } + } + + private killWorkers() { + if (ArmorCalculatorService.workers.length > 0) { + this.logger.debug("ArmorCalculatorService", "killWorkers", "Terminating all workers"); + ArmorCalculatorService.workers.forEach((w) => { + w.terminate(); + }); + ArmorCalculatorService.workers = []; + } + } + + private static estimateCombinationsToBeChecked( + helmets: IPermutatorArmor[], + gauntlets: IPermutatorArmor[], + chests: IPermutatorArmor[], + legs: IPermutatorArmor[] + ) { + let totalCalculations = 0; + const exoticHelmets = helmets.filter((d) => d.isExotic).length; + const legendaryHelmets = helmets.length - exoticHelmets; + const exoticGauntlets = gauntlets.filter((d) => d.isExotic).length; + const legendaryGauntlets = gauntlets.length - exoticGauntlets; + const exoticChests = chests.filter((d) => d.isExotic).length; + const legendaryChests = chests.length - exoticChests; + const exoticLegs = legs.filter((d) => d.isExotic).length; + const legendaryLegs = legs.length - exoticLegs; + + totalCalculations += exoticHelmets * legendaryGauntlets * legendaryChests * legendaryLegs; + totalCalculations += legendaryHelmets * exoticGauntlets * legendaryChests * legendaryLegs; + totalCalculations += legendaryHelmets * legendaryGauntlets * exoticChests * legendaryLegs; + totalCalculations += legendaryHelmets * legendaryGauntlets * legendaryChests * exoticLegs; + totalCalculations += legendaryHelmets * legendaryGauntlets * legendaryChests * legendaryLegs; + return totalCalculations; + } + + public cancelCalculation() { + this.logger.info("ArmorCalculatorService", "cancelCalculation", "Cancelling calculation"); + ArmorCalculatorService.cancellationRequested = true; + + // Mark calculation as cancelled immediately for the UI + this.status.modifyStatus((s) => (s.calculatingResults = false)); + this.status.modifyStatus((s) => (s.cancelledCalculation = true)); + + this._calculationProgress.next(0); + this._totalPossibleCombinations.next(0); + // Do NOT clear existing results here; keep the last + // successfully computed table visible even after cancel. + + // Ask all active workers to cancel gracefully + ArmorCalculatorService.workers.forEach((w, index) => { + try { + w.postMessage({ type: "cancel" }); + } catch (error) { + this.logger.error( + "ArmorCalculatorService", + "cancelCalculation", + `Failed to send cancel message to worker ${index}: ${error}` + ); + } + }); + + // Clear any existing cancellation timeout + if (ArmorCalculatorService.cancellationTimeoutId != null) { + clearTimeout(ArmorCalculatorService.cancellationTimeoutId); + ArmorCalculatorService.cancellationTimeoutId = null; + } + + // Give workers up to 10 seconds to finish gracefully + ArmorCalculatorService.cancellationTimeoutId = setTimeout(() => { + if (ArmorCalculatorService.cancellationRequested) { + this.logger.info( + "ArmorCalculatorService", + "cancelCalculation", + "Force terminating workers after 10s cancellation grace period" + ); + this.killWorkers(); + ArmorCalculatorService.cancellationRequested = false; + } + }, 10000); + } + + private static estimateRequiredThreads( + config: BuildConfiguration, + permutatorArmorItems: IPermutatorArmor[] + ): number { + const helmets = permutatorArmorItems.filter((d) => d.slot == ArmorSlot.ArmorSlotHelmet); + const gauntlets = permutatorArmorItems.filter((d) => d.slot == ArmorSlot.ArmorSlotGauntlet); + const chests = permutatorArmorItems.filter((d) => d.slot == ArmorSlot.ArmorSlotChest); + const legs = permutatorArmorItems.filter((d) => d.slot == ArmorSlot.ArmorSlotLegs); + const estimatedCalculations = ArmorCalculatorService.estimateCombinationsToBeChecked( + helmets, + gauntlets, + chests, + legs + ); + + const largestArmorBucket = Math.max( + helmets.length, + gauntlets.length, + chests.length, + legs.length + ); + + let calculationMultiplier = 1.0; + // very expensive calculations reduce the amount per thread + if ( + config.tryLimitWastedStats && + config.modOptimizationStrategy != ModOptimizationStrategy.None + ) { + calculationMultiplier = 0.7; + } + + let minimumCalculationPerThread = calculationMultiplier * 5e4; + let maximumCalculationPerThread = calculationMultiplier * 2.5e5; + + const nthreads = Math.min( + Math.max(1, Math.ceil(estimatedCalculations / minimumCalculationPerThread)), + Math.ceil(estimatedCalculations / maximumCalculationPerThread), + calculateCPUConcurrency(), // estimated physical cores minus 1, minimum of 3 for desktop + largestArmorBucket // limit it to the largest armor bucket, as we will split the work by this value + ); + + return nthreads; + } + + private processWorkerMessage( + data: WorkerMessageData, + workerIndex: number, + totalThreads: number, + inventoryArmorItems: IInventoryArmor[] + ): void { + // Update calculation progress tracking (available in all message types) + ArmorCalculatorService.threadCalculationDoneArr[workerIndex] = data.checkedCalculations; + ArmorCalculatorService.threadCalculationAmountArr[workerIndex] = data.estimatedCalculations; + ArmorCalculatorService.threadCalculationReachableTiers[workerIndex] = data.reachableTiers || + data.runtime?.maximumPossibleTiers || [0, 0, 0, 0, 0, 0]; + + if (data.resultLimitReached) { + ArmorCalculatorService.threadResultLimitReachedArr[workerIndex] = true; + } + + // Aggregate per-stat maximum tiers across all workers (each worker can max different stats) + const globalMaxTiers = ArmorCalculatorService.threadCalculationReachableTiers + .slice(0, totalThreads) + .reduce( + (maxArr, currArr) => maxArr.map((val, idx) => Math.max(val, currArr?.[idx] ?? 0)), + [0, 0, 0, 0, 0, 0] + ); + + const foundHigher = globalMaxTiers.some( + (val, idx) => val > ArmorCalculatorService.globalMaximumPossibleTiers[idx] + ); + + if (foundHigher) { + ArmorCalculatorService.globalMaximumPossibleTiers = [...globalMaxTiers]; + for (let i = 0; i < ArmorCalculatorService.workers.length; i++) { + if (i !== workerIndex && ArmorCalculatorService.workers[i]) { + ArmorCalculatorService.workers[i].postMessage({ + type: "siblingUpdate", + threadId: workerIndex, + maximumPossibleTiers: [...ArmorCalculatorService.globalMaximumPossibleTiers], + }); + } + } + } + + const sumDone = ArmorCalculatorService.threadCalculationDoneArr.reduce((a, b) => a + b, 0); + const sumTotal = ArmorCalculatorService.threadCalculationAmountArr.reduce((a, b) => a + b, 0); + + // Emit total possible combinations once all workers have reported their estimates + if ( + !ArmorCalculatorService.emittedPossibleCombinations && + ArmorCalculatorService.threadCalculationAmountArr + .slice(0, totalThreads) + .every((val) => val > 0) + ) { + ArmorCalculatorService.emittedPossibleCombinations = true; + this._totalPossibleCombinations.next(sumTotal); + } + const reachableTiers = globalMaxTiers.map((k) => Math.min(200, k) / 10); + this._reachableTiers.next(reachableTiers); + + // Check if all threads have started working (all elements > 0) + if ( + ArmorCalculatorService.threadCalculationDoneArr.slice(0, totalThreads).every((val) => val > 0) + ) { + const newProgress = (sumDone / sumTotal) * 100; + const now = performance.now(); + if (now - ArmorCalculatorService.lastProgressUpdateTime > 150) { + // Update every 150ms + ArmorCalculatorService.lastProgressUpdateTime = now; + this._calculationProgress.next(newProgress); + } + } + + // Process results data (only available when runtime is present - partial/final results messages) + if (data.runtime == null) return; + + // Add partial results to the collection + ArmorCalculatorService.results.push(...(data.results as IPermutatorArmorSet[])); + + // When every worker has hit its local result limit, + if ( + !ArmorCalculatorService.allThreadsResultLimitReached && + ArmorCalculatorService.threadResultLimitReachedArr.every((val) => val) + ) { + console.log("All threads have reached their local result limit"); + console.log( + ArmorCalculatorService.results.length + + " results found, " + + sumDone + + " calculations done out of estimated " + + sumTotal + ); + this.processIntermediateResults(inventoryArmorItems); + + ArmorCalculatorService.allThreadsResultLimitReached = true; + } + + // Handle completion of individual worker threads + if (data.done == true) { + ArmorCalculatorService.doneWorkerCount++; + ArmorCalculatorService.savedResultsCount += data.stats!.savedResults; // stats only available when done=true + ArmorCalculatorService.totalPermutationsCount += data.stats!.computedPermutations; + ArmorCalculatorService.resultMaximumTiers.push(data.runtime.maximumPossibleTiers); + } + + if (data.done == true && ArmorCalculatorService.doneWorkerCount == totalThreads) { + this.processCompleteResults(inventoryArmorItems); + ArmorCalculatorService.workers[workerIndex].terminate(); + } else if (data.done == true && ArmorCalculatorService.doneWorkerCount != totalThreads) { + ArmorCalculatorService.workers[workerIndex].terminate(); + } + } + + private processCompleteResults(inventoryArmorItems: IInventoryArmor[]): void { + this.status.modifyStatus((s) => (s.calculatingResults = false)); + this._calculationProgress.next(0); + + ArmorCalculatorService.endResults = []; + + for (let armorSet of ArmorCalculatorService.results) { + let items = armorSet.armor.map((x) => + inventoryArmorItems.find((y) => y.id == x) + ) as IInventoryArmor[]; + let exotic = items.find((x) => x.isExotic); + let v: ResultDefinition = { + loaded: false, // TODO check if loaded is even needed + tuningStats: armorSet.tuning, + exotic: + exotic == null + ? undefined + : { + icon: exotic?.icon, + watermark: exotic?.watermarkIcon, + name: exotic?.name, + hash: exotic?.hash, + }, + artifice: armorSet.usedArtifice, + modCount: armorSet.usedMods.length, + modCost: armorSet.usedMods.reduce((p, d: StatModifier) => p + STAT_MOD_VALUES[d][2], 0), + mods: armorSet.usedMods, + stats: armorSet.statsWithMods, + statsNoMods: armorSet.statsWithoutMods, + tiers: getSkillTier(armorSet.statsWithMods), + waste: getWaste(armorSet.statsWithMods), + items: items.map( + (instance): ResultItem => ({ + tuningStat: instance.tuningStat, + energyLevel: instance.energyLevel, + hash: instance.hash, + itemInstanceId: instance.itemInstanceId, + name: instance.name, + exotic: !!instance.isExotic, + masterworked: instance.masterworkLevel == MAXIMUM_MASTERWORK_LEVEL, + archetypeStats: instance.archetypeStats, + armorSystem: instance.armorSystem, // 2 = Armor 2.0, 3 = Armor 3.0 + masterworkLevel: instance.masterworkLevel, + slot: instance.slot, + perk: instance.perk, + transferState: 0, // TRANSFER_NONE + tier: instance.tier, + stats: [ + instance.mobility, + instance.resilience, + instance.recovery, + instance.discipline, + instance.intellect, + instance.strength, + ], + source: instance.source, + statsNoMods: [], + }) + ), + usesCollectionRoll: items.some((y) => y.source === InventoryArmorSource.Collections), + usesVendorRoll: items.some((y) => y.source === InventoryArmorSource.Vendor), + }; + ArmorCalculatorService.endResults.push(v); + } + + this._armorResults.next({ + results: ArmorCalculatorService.endResults, + savedResults: ArmorCalculatorService.savedResultsCount, // Total amount of results, differs from the real amount if the memory save setting is active + totalPermutations: ArmorCalculatorService.totalPermutationsCount, + itemCount: inventoryArmorItems.length, + totalTime: performance.now() - ArmorCalculatorService.updateResultsStart, + maximumPossibleTiers: ArmorCalculatorService.resultMaximumTiers + .reduce( + (p, v) => { + for (let k = 0; k < 6; k++) if (p[k] < v[k]) p[k] = v[k]; + return p; + }, + [0, 0, 0, 0, 0, 0] + ) + .map((k) => Math.min(200, k) / 10), + }); + const updateResultsEnd = performance.now(); + this.logger.info( + "ArmorCalculatorService", + "updateResults", + `updateResults with WebWorker took ${updateResultsEnd - ArmorCalculatorService.updateResultsStart} ms` + ); + } + + private processIntermediateResults(inventoryArmorItems: IInventoryArmor[]): void { + // Do not toggle calculatingResults or reset progress; workers are still running. + + ArmorCalculatorService.endResults = []; + + for (let armorSet of ArmorCalculatorService.results) { + const items = armorSet.armor.map((x) => + inventoryArmorItems.find((y) => y.id == x) + ) as IInventoryArmor[]; + const exotic = items.find((x) => x.isExotic); + const v: ResultDefinition = { + loaded: false, + tuningStats: armorSet.tuning, + exotic: + exotic == null + ? undefined + : { + icon: exotic.icon, + watermark: exotic.watermarkIcon, + name: exotic.name, + hash: exotic.hash, + }, + artifice: armorSet.usedArtifice, + modCount: armorSet.usedMods.length, + modCost: armorSet.usedMods.reduce((p, d: StatModifier) => p + STAT_MOD_VALUES[d][2], 0), + mods: armorSet.usedMods, + stats: armorSet.statsWithMods, + statsNoMods: armorSet.statsWithoutMods, + tiers: getSkillTier(armorSet.statsWithMods), + waste: getWaste(armorSet.statsWithMods), + items: items.map( + (instance): ResultItem => ({ + tuningStat: instance.tuningStat, + energyLevel: instance.energyLevel, + hash: instance.hash, + itemInstanceId: instance.itemInstanceId, + name: instance.name, + exotic: !!instance.isExotic, + masterworked: instance.masterworkLevel == MAXIMUM_MASTERWORK_LEVEL, + archetypeStats: instance.archetypeStats, + armorSystem: instance.armorSystem, + masterworkLevel: instance.masterworkLevel, + slot: instance.slot, + perk: instance.perk, + transferState: 0, + tier: instance.tier, + stats: [ + instance.mobility, + instance.resilience, + instance.recovery, + instance.discipline, + instance.intellect, + instance.strength, + ], + source: instance.source, + statsNoMods: [], + }) + ), + usesCollectionRoll: items.some((y) => y.source === InventoryArmorSource.Collections), + usesVendorRoll: items.some((y) => y.source === InventoryArmorSource.Vendor), + }; + ArmorCalculatorService.endResults.push(v); + } + + this._armorResults.next({ + results: ArmorCalculatorService.endResults, + savedResults: ArmorCalculatorService.results.length, + totalPermutations: ArmorCalculatorService.totalPermutationsCount, + itemCount: inventoryArmorItems.length, + totalTime: null, + maximumPossibleTiers: ArmorCalculatorService.globalMaximumPossibleTiers.map( + (k) => Math.min(200, k) / 10 + ), + }); + + this.logger.info( + "ArmorCalculatorService", + "processIntermediateResults", + "Published intermediate results after all workers reached result limit" + ); + } + + // Manual trigger method for testing + manualTriggerCalculation() { + this.logger.info( + "ArmorCalculatorService", + "manualTriggerCalculation", + "Manually triggering calculation" + ); + const config = this.config.readonlyConfigurationSnapshot; + if (config && config.characterClass !== DestinyClass.Unknown) { + this.calculateArmorSetResults(config, config.characterClass); + } else { + this.logger.warn( + "ArmorCalculatorService", + "manualTriggerCalculation", + "No valid config available for manual trigger" + ); + } + } + + private async filterAndPrepareInventoryItems(config: BuildConfiguration) { + let inventoryArmorItems: IInventoryArmor[] = (await this.db.inventoryArmor + .where("clazz") + .equals(config.characterClass) + .distinct() + .toArray()) as IInventoryArmor[]; + + inventoryArmorItems = inventoryArmorItems + // only armor :) + .filter((item) => item.slot != ArmorSlot.ArmorSlotNone) + // filter disabled items + .filter((item) => config.disabledItems.indexOf(item.itemInstanceId) == -1) + // filter armor 3.0 + .filter((item) => item.isExotic || !config.enforceFeaturedLegendaryArmor || item.isFeatured) + .filter((item) => !item.isExotic || !config.enforceFeaturedExoticArmor || item.isFeatured) + .filter( + (item) => + item.armorSystem === ArmorSystem.Armor3 || + item.isExotic || + config.allowLegacyLegendaryArmor + ) + .filter( + (item) => + item.armorSystem === ArmorSystem.Armor3 || !item.isExotic || config.allowLegacyExoticArmor + ) + // filter collection/vendor rolls if not allowed + .filter((item) => { + switch (item.source) { + case InventoryArmorSource.Collections: + return config.includeCollectionRolls; + case InventoryArmorSource.Vendor: + return config.includeVendorRolls; + default: + return true; + } + }) + // filter the selected exotic right here + .filter((item) => config.selectedExotics.indexOf(FORCE_USE_NO_EXOTIC) == -1 || !item.isExotic) + .filter( + (item) => + ArmorCalculatorService.selectedExotics.length === 0 || + (item.isExotic && + ArmorCalculatorService.selectedExotics.some( + (exotic: IManifestArmor) => exotic.hash === item.hash + )) || + (!item.isExotic && + ArmorCalculatorService.selectedExotics.every( + (exotic: IManifestArmor) => exotic.slot !== item.slot + )) + ) + + // config.OnlyUseMasterworkedExotics - only keep exotics that are masterworked + .filter( + (item) => + !config.onlyUseMasterworkedExotics || + !(item.rarity == TierType.Exotic && item.masterworkLevel != MAXIMUM_MASTERWORK_LEVEL) + ) + + // config.OnlyUseMasterworkedLegendaries - only keep legendaries that are masterworked + .filter( + (item) => + !config.onlyUseMasterworkedLegendaries || + !(item.rarity == TierType.Superior && item.masterworkLevel != MAXIMUM_MASTERWORK_LEVEL) + ) + + // non-legendaries and non-exotics + .filter( + (item) => + config.allowBlueArmorPieces || + item.rarity == TierType.Exotic || + item.rarity == TierType.Superior + ) + // sunset armor + .filter((item) => !config.ignoreSunsetArmor || !item.isSunset); + // this.logger.debug("ArmorCalculatorService", "updateResults", items.map(d => "id:'"+d.itemInstanceId+"'").join(" or ")) + // Remove collection items if they are in inventory + inventoryArmorItems = inventoryArmorItems.filter((item) => { + if (item.source === InventoryArmorSource.Inventory) return true; + + const purchasedItemInstance = inventoryArmorItems.find( + (rhs) => rhs.source === InventoryArmorSource.Inventory && isEqualItem(item, rhs) + ); + + // If this item is a collection/vendor item, ignore it if the player + // already has a real copy of the same item. + return purchasedItemInstance === undefined; + }); + return inventoryArmorItems; + } + + static convertInventoryArmorToPermutatorArmor(armor: IInventoryArmor): IPermutatorArmor { + return { + id: armor.id, + hash: armor.hash, + slot: armor.slot, + clazz: armor.clazz, + perk: armor.perk, + isExotic: armor.isExotic, + rarity: armor.rarity, + isSunset: armor.isSunset, + masterworkLevel: armor.masterworkLevel, + archetypeStats: armor.archetypeStats, + mobility: armor.mobility, + resilience: armor.resilience, + recovery: armor.recovery, + discipline: armor.discipline, + intellect: armor.intellect, + strength: armor.strength, + source: armor.source, + exoticPerkHash: armor.exoticPerkHash, + + gearSetHash: armor.gearSetHash ?? null, + tuningStat: armor.tuningStat, + + //icon: armor.icon, + //watermarkIcon: armor.watermarkIcon, + //name: armor.name, + //energyLevel: armor.energyLevel, + tier: armor.tier, + armorSystem: armor.armorSystem, + }; + } + + async calculateArmorSetResults( + config: BuildConfiguration, + currentClass: DestinyClass, + nthreads: number = 3 + ) { + if (config.characterClass == DestinyClass.Unknown) { + this.logger.info( + "ArmorCalculatorService", + "calculateArmorSetResults", + "Character class is unknown, probably not loaded yet, skipping calculation" + ); + return; + } + this.clearResults(); + this._totalPossibleCombinations.next(0); + this.killWorkers(); + + // Reset cancellation state for the new calculation + ArmorCalculatorService.cancellationRequested = false; + if (ArmorCalculatorService.cancellationTimeoutId != null) { + clearTimeout(ArmorCalculatorService.cancellationTimeoutId); + ArmorCalculatorService.cancellationTimeoutId = null; + } + + try { + ArmorCalculatorService.updateResultsStart = performance.now(); + this.status.modifyStatus((s) => (s.calculatingResults = true)); + this.status.modifyStatus((s) => (s.cancelledCalculation = false)); + + ArmorCalculatorService.results = []; + ArmorCalculatorService.savedResultsCount = 0; + ArmorCalculatorService.totalPermutationsCount = 0; + ArmorCalculatorService.resultMaximumTiers = []; + + // Reset progress and worker state + ArmorCalculatorService.doneWorkerCount = 0; + ArmorCalculatorService.lastProgressUpdateTime = performance.now(); + + const tempSelectedExotics = await Promise.all( + config.selectedExotics + .filter((hash) => hash != FORCE_USE_NO_EXOTIC) + .map( + async (hash) => + (await this.db.manifestArmor.where("hash").equals(hash).first()) as IManifestArmor + ) + ); + ArmorCalculatorService.selectedExotics = tempSelectedExotics.filter( + (i: IManifestArmor) => !!i + ); + + let inventoryArmorItems: IInventoryArmor[] = + await this.filterAndPrepareInventoryItems(config); + + let permutatorArmorItems: IPermutatorArmor[] = inventoryArmorItems.map((armor) => + ArmorCalculatorService.convertInventoryArmorToPermutatorArmor(armor) + ); + + if ( + permutatorArmorItems.length == 0 || + permutatorArmorItems.filter((d) => d.slot == ArmorSlot.ArmorSlotHelmet).length == 0 || + permutatorArmorItems.filter((d) => d.slot == ArmorSlot.ArmorSlotGauntlet).length == 0 || + permutatorArmorItems.filter((d) => d.slot == ArmorSlot.ArmorSlotChest).length == 0 || + permutatorArmorItems.filter((d) => d.slot == ArmorSlot.ArmorSlotLegs).length == 0 + ) { + this.logger.warn( + "ArmorCalculatorService", + "updateResults", + "Incomplete armor items available for permutation, skipping calculation" + ); + this.status.modifyStatus((s) => (s.calculatingResults = false)); + return; + } + nthreads = ArmorCalculatorService.estimateRequiredThreads(config, permutatorArmorItems); + this.logger.info("ArmorCalculatorService", "updateResults", "Estimated threads: " + nthreads); + + // Initialize static thread tracking arrays + ArmorCalculatorService.emittedPossibleCombinations = false; + ArmorCalculatorService.threadCalculationAmountArr = [...Array(nthreads).keys()].map(() => 0); + ArmorCalculatorService.threadCalculationDoneArr = [...Array(nthreads).keys()].map(() => 0); + ArmorCalculatorService.threadCalculationReachableTiers = [...Array(nthreads).keys()].map(() => + Array(6).fill(0) + ); + ArmorCalculatorService.globalMaximumPossibleTiers = [0, 0, 0, 0, 0, 0]; + ArmorCalculatorService.threadResultLimitReachedArr = [...Array(nthreads).keys()].map( + () => false + ); + ArmorCalculatorService.allThreadsResultLimitReached = false; + + // Improve per thread performance by shuffling the inventory + // sorting is a naive aproach that can be optimized + // in my test is better than the default order from the db + permutatorArmorItems = permutatorArmorItems.sort((a, b) => totalStats(b) - totalStats(a)); + this._calculationProgress.next(0); + + for (let n = 0; n < nthreads; n++) { + ArmorCalculatorService.workers[n] = new Worker( + new URL("./results-builder.worker", import.meta.url), + { + name: n.toString(), + } + ); + ArmorCalculatorService.workers[n].onmessage = (ev: MessageEvent) => { + this.processWorkerMessage(ev.data, n, nthreads, inventoryArmorItems); + }; + ArmorCalculatorService.workers[n].onerror = (ev) => { + this.logger.error( + "ArmorCalculatorService", + "updateResults", + `Worker ${n} error: ${ev.message} at ${ev.filename}:${ev.lineno}:${ev.colno}` + ); + ArmorCalculatorService.workers[n].terminate(); + }; + + ArmorCalculatorService.workers[n].postMessage({ + type: "builderRequest", + currentClass: currentClass, + config: config, + threadSplit: { + count: nthreads, + current: n, + }, + items: permutatorArmorItems, + selectedExotics: ArmorCalculatorService.selectedExotics, + }); + } + } catch (error) { + this.logger.error( + "ArmorCalculatorService", + "calculateArmorSetResults", + "Error during calculation: " + error + ); + this.status.modifyStatus((s) => (s.calculatingResults = false)); + this._calculationProgress.next(0); + this.clearResults(); + } finally { + } + } +} diff --git a/src/app/services/auth.service.ts b/src/app/services/auth.service.ts index 05e7aa7d..3a78e3d7 100644 --- a/src/app/services/auth.service.ts +++ b/src/app/services/auth.service.ts @@ -15,208 +15,52 @@ * along with this program. If not, see . */ -import { Injectable } from "@angular/core"; -import { NGXLogger } from "ngx-logger"; -import { HttpClient } from "@angular/common/http"; +import { Injectable, OnDestroy } from "@angular/core"; +import { LoggingProxyService } from "./logging-proxy.service"; import { environment } from "../../environments/environment"; -import { Router } from "@angular/router"; import { Observable, ReplaySubject } from "rxjs"; -import { StatusProviderService } from "./status-provider.service"; @Injectable({ providedIn: "root", }) -export class AuthService { +export class AuthService implements OnDestroy { private _logoutEvent: ReplaySubject; public readonly logoutEvent: Observable; - constructor( - private http: HttpClient, - private router: Router, - private status: StatusProviderService, - private logger: NGXLogger - ) { + constructor(private logger: LoggingProxyService) { + this.logger.debug("AuthService", "constructor", "Initializing AuthService"); this._logoutEvent = new ReplaySubject(1); this.logoutEvent = this._logoutEvent.asObservable(); } - get refreshTokenExpired() { - return this.refreshTokenExpiringAt < Date.now(); - } - - async autoRegenerateTokens() { - const timing = 1000 * 3600 * 0.5; // Refresh every half hour - this.logger.debug( - "AuthService", - "autoRegenerateTokens", - JSON.stringify({ - tokenInfo: { - /*refreshToken: this.refreshToken,*/ - refreshTokenExpiringAt: this.refreshTokenExpiringAt, - lastRefresh: this.lastRefresh, - dateNow: Date.now(), - }, - }) - ); - - if ( - this.refreshToken && - Date.now() < this.refreshTokenExpiringAt && - Date.now() > this.lastRefresh + timing - ) { - return await this.generateTokens(true); - } - return true; + ngOnDestroy(): void { + this.logger.debug("AuthService", "ngOnDestroy", "Destroying AuthService"); } async getCurrentMembershipData(): Promise { - const item = JSON.parse(localStorage.getItem("auth-membershipInfo") || "null"); + const item = JSON.parse(localStorage.getItem("user-membershipInfo") || "null"); if (item == null) { const currentMembershipData = this.getCurrentMembershipData(); - localStorage.setItem("auth-membershipInfo", JSON.stringify(currentMembershipData)); + localStorage.setItem("user-membershipInfo", JSON.stringify(currentMembershipData)); return currentMembershipData; } else return item; } - async generateTokens(refresh = false): Promise { - this.logger.info( - "AuthService", - "generateTokens", - `Generate auth tokens, refresh based on refresh_token: ${refresh}` - ); - const CLIENT_ID = environment.clientId; - const CLIENT_SECRET = environment.client_secret; - const grant_type = "authorization_code"; - const TOKEN = this.authCode; - - let body = `grant_type=${grant_type}&code=${TOKEN}&client_id=${CLIENT_ID}&client_secret=${CLIENT_SECRET}`; - if (refresh) { - body = `grant_type=refresh_token&refresh_token=${this.refreshToken}&client_id=${CLIENT_ID}&client_secret=${CLIENT_SECRET}`; - } - - return await this.http - .post(`https://www.bungie.net/Platform/App/OAuth/Token/`, body, { - headers: { - "Content-Type": "application/x-www-form-urlencoded", - "X-API-Key": environment.apiKey, - }, - }) - .toPromise() - .then((value) => { - this.logger.info( - "AuthService", - "generateTokens", - `generateTokens: ${JSON.stringify(value)}` - ); - this.accessToken = value.access_token; - this.refreshToken = value.refresh_token; - this.refreshTokenExpiringAt = Date.now() + value.refresh_expires_in * 1000 - 10 * 1000; - this.lastRefresh = Date.now(); - this.status.modifyStatus((s) => (s.authError = false)); - return true; - }) - .catch(async (err) => { - this.logger.error("AuthService", "generateTokens", JSON.stringify({ err })); - this.status.modifyStatus((s) => (s.authError = true)); - return false; - }); - } - - isAuthenticated() { - return !!this.accessToken; - } - - get authCode() { - return localStorage.getItem("code"); - } - - set authCode(newCode: string | null) { - if (!newCode) { - this.logger.info("AuthService", "authCode", "Clearing auth code"); - localStorage.removeItem("code"); - } else { - this.logger.info("AuthService", "authCode", "Setting new auth code"); - localStorage.setItem("code", "" + newCode); - } - } - - get accessToken() { - return localStorage.getItem("accessToken"); - } - - set accessToken(newCode: string | null) { - if (!newCode) { - this.logger.info("AuthService", "accessToken", "Clearing access token"); - localStorage.removeItem("accessToken"); - } else { - this.logger.info("AuthService", "accessToken", "Setting new access token"); - localStorage.setItem("accessToken", "" + newCode); - } - } - - get refreshToken() { - return localStorage.getItem("refreshToken"); - } - - set refreshToken(newCode: string | null) { - if (!newCode) { - this.logger.info("AuthService", "refreshToken", "Clearing refresh token"); - localStorage.removeItem("refreshToken"); - } else { - this.logger.info("AuthService", "refreshToken", "Setting new refresh token"); - localStorage.setItem("refreshToken", "" + newCode); - } - } - - get refreshTokenExpiringAt(): number { - let l = localStorage.getItem("refreshTokenExpiringAt") || "0"; - return l ? Number.parseInt(l) : 0; - } - - set refreshTokenExpiringAt(newCode: number | null) { - if (!newCode) { - this.logger.info("AuthService", "refreshTokenExpiringAt", "Clearing refresh token"); - localStorage.removeItem("refreshTokenExpiringAt"); - } else { - this.logger.info("AuthService", "refreshTokenExpiringAt", "Setting new refresh token"); - localStorage.setItem("refreshTokenExpiringAt", "" + newCode); - } - } - - get lastRefresh(): number { - let l = localStorage.getItem("lastRefresh") || "0"; - return l ? Number.parseInt(l) : 0; - } - - set lastRefresh(newCode: number | null) { - if (!newCode) localStorage.removeItem("lastRefresh"); - else localStorage.setItem("lastRefresh", newCode.toString()); - } - - clearManifestInfo() { - localStorage.removeItem("LastArmorUpdate"); - localStorage.removeItem("LastManifestUpdate"); - } - - private clearLoginInfo() { - this.lastRefresh = null; - this.refreshTokenExpiringAt = null; - this.authCode = null; - this.accessToken = null; - this.refreshToken = null; - } - async logout() { if (environment.offlineMode) { this.logger.debug("AuthService", "logout", "Offline mode, skipping logout"); return; } try { - this._logoutEvent.next(null); - this.clearManifestInfo(); - this.clearLoginInfo(); + localStorage.removeItem("auth-accessToken"); + localStorage.removeItem("auth-refreshToken"); + localStorage.removeItem("auth-refreshToken-expireDate"); + localStorage.removeItem("auth-refreshToken-lastRefreshDate"); + localStorage.removeItem("user-currentConfig"); + } catch (e) { + this.logger.error("AuthService", "logout", "Error during logout", e); } finally { - await this.router.navigate(["login"]); + this._logoutEvent.next(null); } } } diff --git a/src/app/services/bungie-api.service.ts b/src/app/services/bungie-api.service.ts index 3911ccec..3fa1d72e 100644 --- a/src/app/services/bungie-api.service.ts +++ b/src/app/services/bungie-api.service.ts @@ -15,8 +15,8 @@ * along with this program. If not, see . */ -import { Injectable } from "@angular/core"; -import { NGXLogger } from "ngx-logger"; +import { Injectable, OnDestroy } from "@angular/core"; +import { LoggingProxyService } from "./logging-proxy.service"; import { DestinyComponentType, DestinyInventoryItemDefinition, @@ -35,6 +35,14 @@ import { DestinyItemComponent, DestinyManifestComponentName, AllDestinyManifestComponents, + DestinyManifest, + ServerResponse, + DestinyProfileResponse, + DestinyItemInstanceComponent, + DestinySandboxPerkDefinition, + DestinySocketTypeDefinition, + DestinyPresentationNodeDefinition, + DestinyCollectibleDefinition, } from "bungie-api-ts/destiny2"; import { DatabaseService } from "./database.service"; import { environment } from "../../environments/environment"; @@ -51,6 +59,7 @@ import { ArmorPerkOrSlot, ArmorPerkSocketHashes, ArmorStat, + ArmorStatFromHash, ArmorStatHashes, MapAlternativeSocketTypeToArmorPerkOrSlot, MapAlternativeToArmorPerkOrSlot, @@ -71,6 +80,9 @@ import { SubclassHashes } from "../data/enum/armor-stat"; import { ModInformation } from "../data/ModInformation"; import { Subject, Observable } from "rxjs"; +// Database type for SQLite operations +type Database = any; + // TODO :Remove once DIM API is updated export interface DestinyEquipableItemSetDefinition { @@ -151,7 +163,7 @@ function collectInvestmentStats( @Injectable({ providedIn: "root", }) -export class BungieApiService { +export class BungieApiService implements OnDestroy { /** * Emits after a manifest update. */ @@ -171,8 +183,14 @@ export class BungieApiService { private db: DatabaseService, private config: ConfigurationService, private membership: MembershipService, - private logger: NGXLogger - ) {} + private logger: LoggingProxyService + ) { + this.logger.debug("BungieApiService", "constructor", "Initializing BungieApiService"); + } + + ngOnDestroy(): void { + this.logger.debug("BungieApiService", "ngOnDestroy", "Destroying BungieApiService"); + } async transferItem( itemInstanceId: string, @@ -287,28 +305,41 @@ export class BungieApiService { return new Set(itemHashes); } - async updateArmorItems(force = false) { + async updateInventory(force = false): Promise { + // Check if the user is authenticated before getting the membership data, if not, return null to avoid unnecessary API calls and errors + if (!this.http.isAuthenticated()) { + this.logger.warn("BungieApiService", "updateInventory", "User is not authenticated"); + return null; + } if (environment.offlineMode) { - this.logger.info("BungieApiService", "updateArmorItems", "offline mode, skipping"); - return; + this.logger.info("BungieApiService", "updateInventory", "offline mode, skipping"); + return null; } - if (!force && localStorage.getItem("LastArmorUpdate")) - if (localStorage.getItem("last-armor-db-name") == this.db.inventoryArmor.db.name) + if (!force && localStorage.getItem("d2ap-inventory-lastDate")) + if (localStorage.getItem("d2ap-db-lastName") == this.db.inventoryArmor.db.name) if ( - Date.now() - Number.parseInt(localStorage.getItem("LastArmorUpdate") || "0") < - (1000 * 3600) / 2 - ) - return; + Date.now() - Number.parseInt(localStorage.getItem("d2ap-inventory-lastDate") || "0") < + 1000 * 3600 * 0.5 + ) { + // Do not update if inventory was updated less than 30 minutes ago, unless forced to update. This is to avoid hitting rate limits and unnecessary processing + this.logger.info( + "BungieApiService", + "updateInventory", + "Inventory recently updated, skipping" + ); + return null; + } + let destinyMembership = await this.membership.getMembershipDataForCurrentUser(); if (!destinyMembership) { if (!this.status.getStatus().apiError) this.status.setAuthError(); - return []; + return null; } this.status.clearAuthError(); this.status.clearApiError(); - this.logger.info("BungieApiService", "updateArmorItems", "Requesting Profile"); + this.logger.info("BungieApiService", "updateInventory", "Requesting Profile"); let profile = await getProfile((d) => this.http.$http(d, true), { components: [ DestinyComponentType.CharacterEquipment, @@ -320,6 +351,7 @@ export class BungieApiService { DestinyComponentType.ItemPerks, DestinyComponentType.ItemSockets, DestinyComponentType.ItemPlugStates, + DestinyComponentType.ItemReusablePlugs, DestinyComponentType.Collectibles, ], membershipType: destinyMembership.membershipType, @@ -340,6 +372,78 @@ export class BungieApiService { allItems = allItems.concat(i); } + // Update materials + this.updateMaterials(allItems, profile); + + // Collect a list of all armor item hashes that we need to look up in the manifest + const idSet = new Set(allItems.map((d) => d.itemHash)); + // Add all exotics owned by the player, as they can always be found from collections + unlockedExoticArmorItemHashes.forEach((id) => idSet.add(id)); + + // Check if inventory has changed by comparing item hashes + const currentItemHashesKey = Array.from(idSet).sort().join(","); + const cachedItemHashesKey = localStorage.getItem("user-armorItems") || ""; + + if (!force && currentItemHashesKey == cachedItemHashesKey && currentItemHashesKey.length > 0) { + this.logger.info( + "BungieApiService", + "updateInventory", + "Item hashes unchanged, and items are present, skipping armor processing" + ); + // Still update the timestamp since we checked + localStorage.setItem("d2ap-inventory-lastDate", Date.now().toString()); + this.status.clearApiError(); + + // No changes in inventory + this.logger.info( + "BungieApiService", + "updateInventory", + "No changes in inventory detected, skipping processing" + ); + return null; + } else { + this.logger.info( + "BungieApiService", + "updateInventory", + "Changes detected in inventory, processing armor items" + ); + } + + // Do not search directly in the DB, as it is VERY slow. + let manifestArmor = await this.db.manifestArmor.toArray(); + const validManifestArmor = manifestArmor.filter((d) => idSet.has(d.hash)); + const modsData = manifestArmor.filter((d) => d.itemType == 19); + const validManifestArmorMap = Object.fromEntries(validManifestArmor.map((_) => [_.hash, _])); + const modsMap = Object.fromEntries(modsData.map((_) => [_.hash, _])); + + // Process armor items + let filteredItems = this.updateArmor(allItems, profile, validManifestArmorMap, modsMap); + + // Add collection rolls for exotics + const collectionRollItems = this.updateCollectionRolls( + unlockedExoticArmorItemHashes, + validManifestArmorMap, + modsMap + ); + filteredItems = filteredItems.concat(collectionRollItems); + // filteredItems = filteredItems.filter( + // (k) => !k["statPlugHashes"] || k["statPlugHashes"][0] != null + // ); + + await this.updateDatabaseItems(filteredItems); + + // Cache the current item hashes for future comparisons + localStorage.setItem("user-armorItems", currentItemHashesKey); + localStorage.setItem("d2ap-inventory-lastDate", Date.now().toString()); + + this.status.clearApiError(); + return filteredItems; + } + + private updateMaterials( + allItems: DestinyItemComponent[], + profile: ServerResponse + ): void { // get amount of materials // 3853748946 enhancement core // 4257549984 enhancement prism @@ -358,22 +462,16 @@ export class BungieApiService { profile.Response.profileCurrencies.data?.items.filter((k) => k.itemHash == 3159615086) || []; if (glimmerEntry.length > 0) materials["3159615086"] = glimmerEntry[0].quantity; else materials["3159615086"] = 0; - localStorage.setItem("stored-materials", JSON.stringify(materials)); - - // Collect a list of all armor item hashes that we need to look up in the manifest - const idSet = new Set(allItems.map((d) => d.itemHash)); - // Add all exotics owned by the player, as they can always be found from collections - unlockedExoticArmorItemHashes.forEach((id) => idSet.add(id)); - - // Do not search directly in the DB, as it is VERY slow. - let manifestArmor = await this.db.manifestArmor.toArray(); - const validManifestArmor = manifestArmor.filter((d) => idSet.has(d.hash)); - const modsData = manifestArmor.filter((d) => d.itemType == 19); - const validManifestArmorMap = Object.fromEntries(validManifestArmor.map((_) => [_.hash, _])); - const modsMap = Object.fromEntries(modsData.map((_) => [_.hash, _])); + localStorage.setItem("user-materials", JSON.stringify(materials)); + } - let filteredItems = allItems - //.filter(d => ids.indexOf(d.itemHash) > -1) + private updateArmor( + allItems: DestinyItemComponent[], + profile: ServerResponse, + validManifestArmorMap: Record, + modsMap: Record + ): IInventoryArmor[] { + return allItems .filter((d) => !!d.itemInstanceId) .filter((d) => d.bucketHash !== 3284755031) // Filter out subclasses .filter((d) => { @@ -394,7 +492,7 @@ export class BungieApiService { if (!validManifestArmorMap[d.itemHash]) { this.logger.warn( "BungieApiService", - "updateArmorItems", + "updateInventory", `Missing manifest item for item hash: ${d.itemHash}` ); return null; @@ -404,18 +502,11 @@ export class BungieApiService { d.itemInstanceId || "", InventoryArmorSource.Inventory ); - // 3.0 - // TODO replace the (as any) once DIM Api is updated - - if (!!(instance as any).gearTier) { - armorItem.armorSystem = ArmorSystem.Armor3; - armorItem.tier = (instance as any).gearTier; - } else if (armorItem.isExotic && armorItem.slot === ArmorSlot.ArmorSlotClass) { - armorItem.armorSystem = ArmorSystem.Armor3; - } else { - armorItem.armorSystem = ArmorSystem.Armor2; - } + // Process armor system and tuning stats + this.processArmorSystemAndTuning(armorItem, instance, profile, d, modsMap); + + // Process exotic class items if (armorItem.isExotic && armorItem.slot === ArmorSlot.ArmorSlotClass) { armorItem.exoticPerkHash = []; const sockets = @@ -430,95 +521,158 @@ export class BungieApiService { } } + // Set energy level armorItem.energyLevel = !!instance.energy ? instance.energy.energyCapacity : 0; - const sockets = profile.Response.itemComponents.sockets.data || {}; - const socketsList = - sockets[d.itemInstanceId!]?.sockets.map((socket) => socket.plugHash) ?? []; - collectInvestmentStats( - armorItem, - validManifestArmorMap[d.itemHash]?.investmentStats ?? [], - socketsList, - modsMap - ); - if (armorItem.isExotic && armorItem.slot === ArmorSlot.ArmorSlotClass) { - let statData = profile.Response.itemComponents.stats.data || {}; - let stats = statData[d.itemInstanceId || ""]?.stats || {}; - for (let n = 0; n < 7; n++) { - const sock = sockets[d.itemInstanceId!]?.sockets[n]; - if (!sock || !sock.plugHash) continue; - const mod = modsMap[sock.plugHash]; - if (!mod) continue; - if (mod.investmentStats.length == 0) continue; - for (const stat of mod.investmentStats) { - if (stat.statTypeHash in stats) { - (stats[stat.statTypeHash] as any).value -= stat.value; - } - } - } - // Sort the stats by value in descending order and get the third highest value - const sortedStats = Object.entries(stats) - .map(([hash, statObj]) => ({ hash: parseInt(hash), value: (statObj as any).value })) - .sort((a, b) => b.value - a.value); - - if (sortedStats.length >= 3) { - const thirdHighestStatHash = sortedStats[2].hash; - // Use thirdHighestStatHash as needed - armorItem.archetypeStats.push( - Object.values(ArmorStatHashes).indexOf(thirdHighestStatHash) - ); + // Process investment stats, masterwork, and perks + this.processStatsAndPerks(armorItem, d, profile, validManifestArmorMap, modsMap); - const investmentStat = getInvestmentStats(armorItem); - // TODO: This must be tiered - investmentStat[thirdHighestStatHash] += 13; - applyInvestmentStats(armorItem, investmentStat); - } - } + return armorItem as IInventoryArmor; + }) + .filter(Boolean) as IInventoryArmor[]; + } - for (let socket of socketsList) { - if (!socket) continue; - // grab the mod instance - const mod = modsMap[socket]; - if (!mod || mod.name !== "Upgrade Armor") continue; - const mmod = mod.investmentStats.find( - (k: DestinyItemInvestmentStatDefinition) => - k.statTypeHash == ArmorStatHashes[ArmorStat.StatWeapon] - ); - if (mmod) { - if (armorItem.armorSystem == ArmorSystem.Armor3) armorItem.masterworkLevel = mmod.value; - else if (armorItem.armorSystem == ArmorSystem.Armor2) { - armorItem.masterworkLevel = mmod.value == 2 ? 5 : 0; + private processArmorSystemAndTuning( + armorItem: IInventoryArmor, + instance: DestinyItemInstanceComponent, + profile: ServerResponse, + d: DestinyItemComponent, + modsMap: Record + ): void { + // 3.0 armor system detection and tuning stat processing + if (!!(instance as any).gearTier) { + armorItem.armorSystem = ArmorSystem.Armor3; + armorItem.tier = (instance as any).gearTier; + + // Grab the tuning stat from the reusable plugs + try { + const plugs = + profile.Response.itemComponents.reusablePlugs.data?.[d.itemInstanceId!]?.plugs; + if (plugs) { + const availablePlugs = Object.values(plugs).find((value: any) => { + return value.length > 1 && value.some((p: any) => p.plugItemHash == 3122197216); // 3122197216 is the balanced tuning stat + }) as any[]; + + if (availablePlugs && availablePlugs.length > 1) { + const pickedPlug = availablePlugs.find((p: any) => p.plugItemHash != 3122197216); + if (pickedPlug) { + const statCheckHash = pickedPlug.plugItemHash; + const mod = modsMap[statCheckHash]; + const tuningStatHash = mod?.investmentStats.find((p) => p.value > 0)?.statTypeHash; + if (tuningStatHash) armorItem.tuningStat = ArmorStatFromHash[tuningStatHash]; } } } + } catch (e) { + this.logger.error( + "BungieApiService", + "updateInventory", + `Error while getting tuning stat for item ${d.itemInstanceId}: ${e}` + ); + } + } else if (armorItem.isExotic && armorItem.slot === ArmorSlot.ArmorSlotClass) { + armorItem.armorSystem = ArmorSystem.Armor3; + } else { + armorItem.armorSystem = ArmorSystem.Armor2; + } + } + + private processStatsAndPerks( + armorItem: IInventoryArmor, + d: DestinyItemComponent, + profile: ServerResponse, + validManifestArmorMap: Record, + modsMap: Record + ): void { + const sockets = profile.Response.itemComponents.sockets.data || {}; + const socketsList = + sockets[d.itemInstanceId!]?.sockets.map((socket: any) => socket.plugHash) ?? []; + + // Collect investment stats + collectInvestmentStats( + armorItem, + validManifestArmorMap[d.itemHash]?.investmentStats ?? [], + socketsList, + modsMap + ); - if (armorItem.perk == ArmorPerkOrSlot.SlotArtifice) { - // Take a look if it really has the artifice perk - let statData = profile.Response.itemComponents.perks.data || {}; - let perks = (statData[d.itemInstanceId || ""] || {})["perks"] || []; - const hasPerk = perks.filter((p) => p.perkHash == 229248542).length > 0; - if (!hasPerk) armorItem.perk = ArmorPerkOrSlot.None; - } else if (armorItem.isExotic && armorItem.slot !== ArmorSlot.ArmorSlotClass) { - // 720825311 is "UNLOCKED exotic artifice slot" - // 1656746282 is "LOCKED exotic artifice slot" - const hasPerk = socketsList.filter((d) => d == 720825311).length > 0; - if (hasPerk) { - armorItem.perk = ArmorPerkOrSlot.SlotArtifice; + // Process exotic class item archetype stats + if (armorItem.isExotic && armorItem.slot === ArmorSlot.ArmorSlotClass) { + let statData = profile.Response.itemComponents.stats.data || {}; + let stats = statData[d.itemInstanceId || ""]?.stats || {}; + + for (let n = 0; n < 7; n++) { + const sock = sockets[d.itemInstanceId!]?.sockets[n]; + if (!sock || !sock.plugHash) continue; + const mod = modsMap[sock.plugHash]; + if (!mod) continue; + if (mod.investmentStats.length == 0) continue; + for (const stat of mod.investmentStats) { + if (stat.statTypeHash in stats) { + (stats[stat.statTypeHash] as any).value -= stat.value; } } + } + // Sort the stats by value in descending order and get the third highest value + const sortedStats = Object.entries(stats) + .map(([hash, statObj]) => ({ hash: parseInt(hash), value: (statObj as any).value })) + .sort((a, b) => b.value - a.value); + + if (sortedStats.length >= 3) { + const thirdHighestStatHash = sortedStats[2].hash; + armorItem.archetypeStats.push(Object.values(ArmorStatHashes).indexOf(thirdHighestStatHash)); + + const investmentStat = getInvestmentStats(armorItem); + investmentStat[thirdHighestStatHash] += 13; + applyInvestmentStats(armorItem, investmentStat); + } + } - return armorItem as IInventoryArmor; - }) - .filter(Boolean) as IInventoryArmor[]; + // Process masterwork level + for (let socket of socketsList) { + if (!socket) continue; + const mod = modsMap[socket]; + if (!mod || mod.name !== "Upgrade Armor") continue; + const mmod = mod.investmentStats.find( + (k: DestinyItemInvestmentStatDefinition) => + k.statTypeHash == ArmorStatHashes[ArmorStat.StatWeapon] + ); + if (mmod) { + if (armorItem.armorSystem == ArmorSystem.Armor3) armorItem.masterworkLevel = mmod.value; + else if (armorItem.armorSystem == ArmorSystem.Armor2) { + armorItem.masterworkLevel = mmod.value == 2 ? 5 : 0; + } + } + } + + // Process artifice perk + if (armorItem.perk == ArmorPerkOrSlot.SlotArtifice) { + let statData = profile.Response.itemComponents.perks.data || {}; + let perks = (statData[d.itemInstanceId || ""] || {})["perks"] || []; + const hasPerk = perks.filter((p: any) => p.perkHash == 229248542).length > 0; + if (!hasPerk) armorItem.perk = ArmorPerkOrSlot.None; + if (armorItem.isExotic && armorItem.slot !== ArmorSlot.ArmorSlotClass) { + // 720825311 is "UNLOCKED exotic artifice slot" + const hasPerk = socketsList.filter((d: any) => d == 720825311).length > 0; + if (hasPerk) { + armorItem.perk = ArmorPerkOrSlot.SlotArtifice; + } + } + } + } - // Now add the collection rolls for exotics - const collectionRollItems = Array.from(unlockedExoticArmorItemHashes) + private updateCollectionRolls( + unlockedExoticArmorItemHashes: Set, + validManifestArmorMap: Record, + modsMap: Record + ): IInventoryArmor[] { + return Array.from(unlockedExoticArmorItemHashes) .map((exoticItemHash) => { const manifestArmorItem = validManifestArmorMap[exoticItemHash]; if (!manifestArmorItem) { this.logger.error( "BungieApiService", - "updateArmorItems", + "updateInventory", `Couldn't find manifest item for exotic: ${exoticItemHash}` ); return null; @@ -540,19 +694,6 @@ export class BungieApiService { return collectionItem; }) .filter(Boolean) as IInventoryArmor[]; - - filteredItems = filteredItems.concat(collectionRollItems); - // filteredItems = filteredItems.filter( - // (k) => !k["statPlugHashes"] || k["statPlugHashes"][0] != null - // ); - - await this.updateDatabaseItems(filteredItems); - - localStorage.setItem("LastArmorUpdate", Date.now().toString()); - localStorage.setItem("last-armor-db-name", this.db.inventoryArmor.db.name); - - this.status.clearApiError(); - return filteredItems; } private async updateDatabaseItems(newItems: IInventoryArmor[]) { @@ -671,7 +812,507 @@ export class BungieApiService { return ArmorPerkOrSlot.None; } - private async updateVendorNames( + /** + * Check if WASM is supported in this environment + */ + private canUseWASM(): boolean { + try { + // Check for WebAssembly support + if (typeof WebAssembly !== "object" || WebAssembly === null) { + this.logger.warn("BungieApiService", "canUseWASM", "WebAssembly not supported"); + return false; + } + + // Check for required APIs + if (typeof WebAssembly.instantiate !== "function") { + this.logger.warn("BungieApiService", "canUseWASM", "WebAssembly.instantiate not available"); + return false; + } + + // Check if we're in a web worker or other restricted environment + if (typeof window === "undefined" && typeof self === "undefined") { + this.logger.warn("BungieApiService", "canUseWASM", "No global context available"); + return false; + } + + return true; + } catch (error) { + this.logger.warn( + "BungieApiService", + "canUseWASM", + `WASM compatibility check failed: ${error}` + ); + return false; + } + } + + private async downloadAndProcessSQLiteManifest( + manifest: DestinyManifest, + language: string + ): Promise { + const sqlitePath = manifest.mobileWorldContentPaths[language]; + const fullUrl = `https://www.bungie.net${sqlitePath}`; + + this.logger.info( + "BungieApiService", + "downloadAndProcessSQLiteManifest", + `Downloading SQLite manifest ZIP from: ${fullUrl}` + ); + + // Download the ZIP file containing the SQLite database + const response = await fetch(fullUrl); + const zipArrayBuffer = await response.arrayBuffer(); + + // Extract SQLite database from ZIP + const JSZipModule = await import("jszip"); + // Handle different export patterns (ESM default vs CJS) + const JSZip = + (JSZipModule as any).default || + (JSZipModule as any).JSZip || + (JSZipModule as any) || + JSZipModule; + const zip = new JSZip(); + const loadedZip = await zip.loadAsync(zipArrayBuffer); + + // Find the SQLite database file in the ZIP (usually has .content extension) + const dbFileName = Object.keys(loadedZip.files).find( + (name) => name.endsWith(".content") || name.endsWith(".db") || name.endsWith(".sqlite") + ); + + if (!dbFileName) { + throw new Error("Could not find SQLite database file in manifest ZIP"); + } + + this.logger.info( + "BungieApiService", + "downloadAndProcessSQLiteManifest", + `Extracting SQLite database: ${dbFileName}` + ); + + // Extract the SQLite database + const dbFile = loadedZip.files[dbFileName]; + const uint8Array = await dbFile.async("uint8array"); + + // Initialize SQL.js with explicit WASM loading + try { + const initSqlJs = await import("sql.js"); + + // Try loading WASM file explicitly + const wasmResponse = await fetch("assets/sql-wasm.wasm"); + const wasmBuffer = await wasmResponse.arrayBuffer(); + + const SQL = await initSqlJs.default({ + wasmBinary: wasmBuffer, + }); + const db = new SQL.Database(uint8Array); + + this.logger.info( + "BungieApiService", + "downloadAndProcessSQLiteManifest", + "SQLite database initialized successfully" + ); + + return db; + } catch (error) { + this.logger.error( + "BungieApiService", + "downloadAndProcessSQLiteManifest", + `Failed to initialize SQL.js with WASM: ${error}` + ); + throw error; + } + } + + private async updateVendorNamesFromSQLite(db: Database) { + const result = db.exec("SELECT json FROM DestinyVendorDefinition"); + const vendorInfo: IVendorInfo[] = []; + + if (result.length > 0) { + for (const row of result[0].values) { + const vendor = JSON.parse(row[0] as string); + vendorInfo.push({ + vendorId: vendor.hash, + vendorName: vendor.displayProperties.name, + vendorDescription: vendor.displayProperties.description, + vendorIdentifier: vendor.vendorIdentifier, + }); + } + } + + this.logger.info( + "BungieApiService", + "updateVendorNamesFromSQLite", + `Storing ${vendorInfo.length} vendor names in localStorage` + ); + await this.db.vendorNames.clear(); + await this.db.vendorNames.bulkAdd(vendorInfo); + } + + private async updateVendorItemSubScreensFromSQLite(db: Database) { + const result = db.exec(` + SELECT id, json FROM DestinyInventoryItemDefinition + WHERE json LIKE '%"preview":%' + AND json LIKE '%"previewVendorHash":%' + AND JSON_EXTRACT(json, '$.preview.previewVendorHash') IS NOT NULL + AND JSON_EXTRACT(json, '$.preview.previewVendorHash') != 0 + `); + + const vendorItemSubscreen: IVendorItemSubscreen[] = []; + if (result.length > 0) { + for (const row of result[0].values) { + const item = JSON.parse(row[1] as string); + vendorItemSubscreen.push({ + itemHash: item.hash, + vendorHash: item.preview.previewVendorHash, + }); + } + } + + await this.db.vendorItemSubscreen.clear(); + this.logger.info( + "BungieApiService", + "updateVendorItemSubScreensFromSQLite", + `Storing ${vendorItemSubscreen.length} vendor item subscreens in localStorage` + ); + await this.db.vendorItemSubscreen.bulkPut(vendorItemSubscreen); + } + + private async updateAbilitiesFromSQLite(db: Database) { + const result = db.exec(` + SELECT json FROM DestinyInventoryItemDefinition + WHERE json LIKE '%"plugCategoryIdentifier":%' + AND ( + json LIKE '%".supers"%' OR + json LIKE '%".grenades"%' OR + json LIKE '%".class_abilities"%' OR + json LIKE '%".melee"%' OR + json LIKE '%".aspects"%' OR + json LIKE '%".fragments"%' + ) + `); + + const allAbilities: any[] = []; + if (result.length > 0) { + for (const row of result[0].values) { + const item = JSON.parse(row[0] as string); + allAbilities.push(item); + } + } + + this.logger.info( + "BungieApiService", + "updateAbilitiesFromSQLite", + `Storing ${allAbilities.length} ability hashes in database` + ); + await this.db.writeCharacterAbilities(allAbilities); + } + + private async updateExoticCollectiblesFromSQLite(db: Database) { + const result = db.exec(` + SELECT c.id as collectible_hash, c.json as collectible_json, i.json as item_json + FROM DestinyCollectibleDefinition c + JOIN DestinyInventoryItemDefinition i ON JSON_EXTRACT(c.json, '$.itemHash') = i.id + WHERE JSON_EXTRACT(i.json, '$.inventory.tierType') = 6 + AND JSON_EXTRACT(i.json, '$.itemType') = 2 + `); + + const exoticArmorCollectibles: IManifestCollectible[] = []; + if (result.length > 0) { + for (const row of result[0].values) { + const collectibleHash = row[0] as number; + const collectibleData = JSON.parse(row[1] as string); + exoticArmorCollectibles.push({ + hash: collectibleHash, + itemHash: collectibleData.itemHash, + }); + } + } + + this.logger.info( + "BungieApiService", + "updateExoticCollectiblesFromSQLite", + `Storing ${exoticArmorCollectibles.length} exotic armor hashes` + ); + await this.db.manifestCollectibles.clear(); + await this.db.manifestCollectibles.bulkPut(exoticArmorCollectibles); + } + + private updateSandboxPerksFromSQLite(db: Database) { + const result = db.exec("SELECT json FROM DestinySandboxPerkDefinition"); + const mappedSandboxPerks: DestinySandboxPerkDefinition[] = []; + + if (result.length > 0) { + for (const row of result[0].values) { + mappedSandboxPerks.push(JSON.parse(row[0] as string)); + } + } + + if (mappedSandboxPerks.length === 0) { + this.logger.warn( + "BungieApiService", + "updateSandboxPerksFromSQLite", + "No sandbox perks found in database" + ); + return; + } + + this.db.sandboxPerkDefinition.clear(); + this.logger.info( + "BungieApiService", + "updateSandboxPerksFromSQLite", + `Storing ${mappedSandboxPerks.length} sandbox perks in localStorage` + ); + this.db.sandboxPerkDefinition.bulkPut(mappedSandboxPerks); + } + + private updateEquipableItemSetDefinitionsFromSQLite(db: Database) { + const result = db.exec("SELECT json FROM DestinyEquipableItemSetDefinition"); + const mapped: DestinyEquipableItemSetDefinition[] = []; + + if (result.length > 0) { + for (const row of result[0].values) { + mapped.push(JSON.parse(row[0] as string)); + } + } + + if (mapped.length === 0) { + this.logger.warn( + "BungieApiService", + "updateEquipableItemSetDefinitionsFromSQLite", + "No equipable item set definitions found in database" + ); + return; + } + this.db.equipableItemSetDefinition.clear(); + this.logger.info( + "BungieApiService", + "updateEquipableItemSetDefinitionsFromSQLite", + `Storing ${mapped.length} equipable item set definitions in localStorage` + ); + this.db.equipableItemSetDefinition.bulkPut(mapped); + } + + private async extractArmorDataFromSQLiteManifest(db: Database) { + // Load supporting tables for lookups during processing + const collectiblesMap: Record = {}; + const presentationNodesMap: Record = {}; + const socketTypesMap: Record = {}; + const equipableItemSetsArray: DestinyEquipableItemSetDefinition[] = []; + + // Load supporting data efficiently + const collectiblesResult = db.exec("SELECT json FROM DestinyCollectibleDefinition"); + if (collectiblesResult.length > 0) { + for (const row of collectiblesResult[0].values) { + const collectible = JSON.parse(row[0] as string); + collectiblesMap[collectible.hash] = collectible; + } + } + + const presentationNodesResult = db.exec("SELECT json FROM DestinyPresentationNodeDefinition"); + if (presentationNodesResult.length > 0) { + for (const row of presentationNodesResult[0].values) { + const node = JSON.parse(row[0] as string); + presentationNodesMap[node.hash] = node; + } + } + + const socketTypesResult = db.exec("SELECT json FROM DestinySocketTypeDefinition"); + if (socketTypesResult.length > 0) { + for (const row of socketTypesResult[0].values) { + const socketType = JSON.parse(row[0] as string); + socketTypesMap[socketType.hash] = socketType; + } + } + + const equipableItemSetsResult = db.exec("SELECT json FROM DestinyEquipableItemSetDefinition"); + if (equipableItemSetsResult.length > 0) { + for (const row of equipableItemSetsResult[0].values) { + const equipableItemSet = JSON.parse(row[0] as string); + equipableItemSetsArray.push(equipableItemSet); + } + } + + // Query for relevant items using SQL filtering + const result = db.exec(` + SELECT json FROM DestinyInventoryItemDefinition + WHERE + JSON_EXTRACT(json, '$.itemType') = 19 OR -- mods + (JSON_EXTRACT(json, '$.itemType') = 16 AND json LIKE '%"itemCategoryHashes"%' AND json LIKE '%50%') OR -- subclasses + JSON_EXTRACT(json, '$.itemType') = 2 OR -- armor + JSON_EXTRACT(json, '$.inventory.bucketTypeHash') = 3448274439 OR -- helmets + JSON_EXTRACT(json, '$.inventory.bucketTypeHash') = 3551918588 OR -- gauntlets + JSON_EXTRACT(json, '$.inventory.bucketTypeHash') = 14239492 OR -- chest + JSON_EXTRACT(json, '$.inventory.bucketTypeHash') = 20886954 OR -- legs + (JSON_EXTRACT(json, '$.inventory.bucketTypeHash') = 1585787867 AND JSON_EXTRACT(json, '$.inventory.tierType') = 6) -- exotic class items + `); + + // NOTE: This is also storing emotes, as these have itemType 19 (mods) + const entries: IManifestArmor[] = []; + + if (result.length > 0) { + for (const row of result[0].values) { + const v: DestinyInventoryItemDefinition = JSON.parse(row[0] as string); + + if ( + v.itemType == 16 && + (!v.itemCategoryHashes || v.itemCategoryHashes.indexOf(50) === -1) + ) { + continue; + } + + let slot = ArmorSlot.ArmorSlotNone; + if ( + v.inventory?.bucketTypeHash == 3448274439 || + (v.itemCategoryHashes?.indexOf(45) || -1) > -1 + ) + slot = ArmorSlot.ArmorSlotHelmet; + if ( + v.inventory?.bucketTypeHash == 3551918588 || + (v.itemCategoryHashes?.indexOf(46) || -1) > -1 + ) + slot = ArmorSlot.ArmorSlotGauntlet; + if ( + v.inventory?.bucketTypeHash == 14239492 || + (v.itemCategoryHashes?.indexOf(47) || -1) > -1 + ) + slot = ArmorSlot.ArmorSlotChest; + if ( + v.inventory?.bucketTypeHash == 20886954 || + (v.itemCategoryHashes?.indexOf(48) || -1) > -1 + ) + slot = ArmorSlot.ArmorSlotLegs; + if ( + v.inventory?.bucketTypeHash == 1585787867 || + (v.itemCategoryHashes?.indexOf(49) || -1) > -1 + ) + slot = ArmorSlot.ArmorSlotClass; + + const isArmor2 = + ( + v.sockets?.socketEntries.filter((d) => { + return ( + d.socketTypeHash == 2512726577 || // general + d.socketTypeHash == 1108765570 || // arms + d.socketTypeHash == 959256494 || // chest + d.socketTypeHash == 2512726577 || // class + d.socketTypeHash == 3219375296 || // legs + d.socketTypeHash == 968742181 // head + ); + }) || [] + ).length > 0; + + const isExotic = v.inventory?.tierType == 6; + let exoticPerkHash: number[] = []; + if (isExotic) { + const perks = + v.sockets?.socketEntries + .filter((s) => s.socketTypeHash == 965959289) + .map((d) => d.singleInitialItemHash) || []; + exoticPerkHash = perks.filter((p) => p !== undefined && p !== null); + } + + var sunsetPowerCaps = [ + 1862490585, // 1260 + 1862490584, // 1060 + 1862490584, // 1060 + 1862490583, // 1060 + 2471437758, // 1010 + ]; + // if every entry is sunset, so is this item. + var isSunset = + v.quality?.versions.filter((k) => sunsetPowerCaps.includes(k.powerCapHash)).length == + v.quality?.versions.length; + + var clasz = v.classType; + if (clasz == DestinyClass.Unknown && isArmor2) { + if (v.collectibleHash != undefined) { + let presentationParentNode = collectiblesMap[v.collectibleHash]?.parentNodeHashes; + if (presentationParentNode !== undefined) { + if ( + presentationParentNode.findIndex( + (x) => presentationNodesMap[x]?.displayProperties.name == "Warlock" + ) != -1 + ) + clasz = DestinyClass.Warlock; + if ( + presentationParentNode.findIndex( + (x) => presentationNodesMap[x]?.displayProperties.name == "Titan" + ) != -1 + ) + clasz = DestinyClass.Titan; + if ( + presentationParentNode.findIndex( + (x) => presentationNodesMap[x]?.displayProperties.name == "Hunter" + ) != -1 + ) + clasz = DestinyClass.Hunter; + } + } + + if (clasz == DestinyClass.Unknown && isArmor2) { + v.sockets?.socketEntries.forEach((a) => { + let socketDef = socketTypesMap[a.socketTypeHash]; + if (socketDef !== undefined) { + if ( + socketDef.plugWhitelist.findIndex((x) => + x.categoryIdentifier.includes("warlock") + ) != -1 + ) { + clasz = DestinyClass.Warlock; + return; + } + if ( + socketDef.plugWhitelist.findIndex((x) => + x.categoryIdentifier.includes("titan") + ) != -1 + ) { + clasz = DestinyClass.Titan; + return; + } + if ( + socketDef.plugWhitelist.findIndex((x) => + x.categoryIdentifier.includes("hunter") + ) != -1 + ) { + clasz = DestinyClass.Hunter; + return; + } + } + }); + } + } + + const isFeatured = !!(v as any)?.isFeaturedItem; + + entries.push({ + hash: v.hash, + icon: v.displayProperties.icon, + watermarkIcon: isFeatured ? (v as any).iconWatermarkFeatured : v.iconWatermark, + name: v.displayProperties.name, + description: v.displayProperties.description, + clazz: clasz, + armorSystem: isArmor2 ? 2 : 1, // TODO: There may be a smarter way + slot: slot, + isExotic: isExotic ? 1 : 0, + isSunset: isSunset, + rarity: v.inventory?.tierType, + exoticPerkHash: exoticPerkHash, + itemType: v.itemType, + itemSubType: v.itemSubType, + investmentStats: v.investmentStats, + // TODO: fix as soon as DIM Api is updated + perk: this.getArmorPerk(v), + gearSetHash: this.getGearSet(v, equipableItemSetsArray), + socketEntries: v.sockets?.socketEntries ?? [], + isFeatured: isFeatured, + } as IManifestArmor); + } + } + + return entries; + } + + private async updateVendorNamesFromJSON( manifestTables: DestinyManifestSlice<"DestinyVendorDefinition"[]> ) { const vendors = manifestTables.DestinyVendorDefinition; @@ -685,12 +1326,16 @@ export class BungieApiService { vendorIdentifier: v.vendorIdentifier, } as IVendorInfo; }); - + this.logger.info( + "BungieApiService", + "updateVendorNames", + `Storing ${vendorInfo.length} vendor names in localStorage` + ); await this.db.vendorNames.clear(); await this.db.vendorNames.bulkAdd(vendorInfo); } - private async updateVendorItemSubScreens( + private async updateVendorItemSubScreensFromJSON( manifestTables: DestinyManifestSlice<"DestinyInventoryItemDefinition"[]> ) { const items = Object.values(manifestTables.DestinyInventoryItemDefinition); @@ -704,10 +1349,15 @@ export class BungieApiService { } as IVendorItemSubscreen; }); await this.db.vendorItemSubscreen.clear(); + this.logger.info( + "BungieApiService", + "updateVendorItemSubScreens", + `Storing ${vendorItemSubscreen.length} vendor item subscreens in localStorage` + ); await this.db.vendorItemSubscreen.bulkPut(vendorItemSubscreen); } - private async updateAbilities( + private async updateAbilitiesFromJSON( manifestTables: DestinyManifestSlice<"DestinyInventoryItemDefinition"[]> ) { const allAbilities = Object.values(manifestTables.DestinyInventoryItemDefinition).filter( @@ -718,13 +1368,17 @@ export class BungieApiService { ); } ); - - localStorage.setItem("allAbilities", JSON.stringify(allAbilities)); + this.logger.info( + "BungieApiService", + "updateAbilities", + `Storing ${allAbilities.length} ability hashes in database` + ); + await this.db.writeCharacterAbilities(allAbilities); } // Collect the data for exotic armor collectibles // this allows us to map a collection entry hash to the associated armor inventory item hash - private async updateExoticCollectibles( + private async updateExoticCollectiblesFromJSON( manifestTables: DestinyManifestSlice< ("DestinyCollectibleDefinition" | "DestinyInventoryItemDefinition")[] > @@ -752,51 +1406,171 @@ export class BungieApiService { await this.db.manifestCollectibles.bulkPut(exoticArmorCollectibles); } - async updateManifest(force = false) { + isManifestCacheValid(manifestCache: { updatedAt: number; version: string }) { if (environment.offlineMode) { - this.logger.info("BungieApiService", "updateManifest", "offline mode, skipping"); - if (!this.manifestAlreadyUpdated) { - this.manifestAlreadyUpdated = true; - this.manifestUpdatedSubject.next(); - } - return; + this.logger.debug( + "BungieApiService", + "isManifestCacheValid", + "marking manifest cache as valid due to offline mode" + ); + return true; + } + if (Date.now() - manifestCache.updatedAt < 1000 * 3600 * 24) { + this.logger.debug( + "BungieApiService", + "isManifestCacheValid", + "marking manifest cache as valid, Manifest is less than a day old" + ); + return true; } + return false; + } - const manifestCache = this.db.lastManifestUpdate(); + isCharacterCacheValid(characterCache: { updatedAt: number; characters: any[] }) { + if (environment.offlineMode) { + this.logger.debug( + "BungieApiService", + "isCharacterCacheValid", + "marking character cache as valid due to offline mode" + ); + return true; + } + // Check if we have cached characters data + if (!characterCache || !characterCache.characters || characterCache.characters.length === 0) { + this.logger.debug( + "BungieApiService", + "isCharacterCacheValid", + "no character data in cache, marking as invalid" + ); + return false; + } + + // Character data is considered valid for 24 hours (same as manifest cache) + if (Date.now() - characterCache.updatedAt < 1000 * 3600 * 24) { + this.logger.debug( + "BungieApiService", + "isCharacterCacheValid", + "marking character cache as valid, Character data is less than a day old" + ); + return true; + } + return false; + } + + async updateManifest(force = false): Promise { + const manifestCache = this.db.lastManifestUpdate(); let destinyManifest = null; if (manifestCache && !force) { - if (Date.now() - manifestCache.updatedAt > 1000 * 3600 * 0.25) { + let isCacheValid = this.isManifestCacheValid(manifestCache); + + if (!isCacheValid) { + this.logger.info( + "BungieApiService", + "updateManifest", + "Manifest Cache is considered invalid, Checking manifest version" + ); destinyManifest = await getDestinyManifest((d) => this.http.$httpWithoutBearerToken(d)); const version = destinyManifest.Response.version; if (manifestCache.version == version) { this.logger.info("BungieApiService", "updateManifest", "Manifest is last version"); - if (!this.manifestAlreadyUpdated) { - this.manifestAlreadyUpdated = true; - this.manifestUpdatedSubject.next(); - } - return; + isCacheValid = true; + } else { + this.logger.info( + "BungieApiService", + "updateManifest", + `Manifest version has changed. Cache version: ${manifestCache.version}, Current version: ${version}` + ); } } - - if (Date.now() - manifestCache.updatedAt < 1000 * 3600 * 24) { - this.logger.info("BungieApiService", "updateManifest", "Manifest is less than a day old"); + if (isCacheValid) { + this.logger.info( + "BungieApiService", + "updateManifest", + "Manifest cache is valid, skipping update" + ); if (!this.manifestAlreadyUpdated) { this.manifestAlreadyUpdated = true; this.manifestUpdatedSubject.next(); } - return; + return false; } } + this.logger.info("BungieApiService", "updateManifest", "Requesting manifest"); if (destinyManifest == null) { destinyManifest = await getDestinyManifest((d) => this.http.$httpWithoutBearerToken(d)); } + this.logger.info( + "BungieApiService", + "updateManifest", + `Manifest version: ${destinyManifest.Response.version}` + ); - const manifestVersion = destinyManifest.Response.version; + // Try SQLite first if WASM is supported, fallback to slice manifest + const canUseSQLite = this.canUseWASM(); + let manifestTables: DestinyManifestSlice<(keyof AllDestinyManifestComponents)[]> | null = null; + + if (canUseSQLite) { + try { + this.logger.info( + "BungieApiService", + "updateManifest", + "WASM supported, attempting SQLite manifest download" + ); + + // Download SQLite database + const db = await this.downloadAndProcessSQLiteManifest(destinyManifest.Response, "en"); + + this.logger.info( + "BungieApiService", + "updateManifest", + "SQLite manifest database downloaded successfully, processing data" + ); + + try { + // Process all data using SQLite methods + await this.updateAbilitiesFromSQLite(db); + await this.updateExoticCollectiblesFromSQLite(db); + await this.updateVendorNamesFromSQLite(db); + await this.updateVendorItemSubScreensFromSQLite(db); + this.updateEquipableItemSetDefinitionsFromSQLite(db); + this.updateSandboxPerksFromSQLite(db); + + const manifestVersion = destinyManifest.Response.version; + let entries = await this.extractArmorDataFromSQLiteManifest(db); + + await this.db.writeManifestArmor(entries, manifestVersion); + } finally { + // Clean up database connection + if (db && typeof db.close === "function") { + db.close(); + } + } + + this.manifestUpdatedSubject.next(); + return true; + } catch (sqliteError) { + this.logger.warn( + "BungieApiService", + "updateManifest", + `SQLite manifest processing failed, falling back to slice manifest: ${sqliteError}` + ); + // Continue to fallback method below + } + } else { + this.logger.info( + "BungieApiService", + "updateManifest", + "WASM not supported or disabled, using slice manifest" + ); + } + + // Fallback to original slice manifest method + this.logger.info("BungieApiService", "updateManifest", "Using slice manifest method"); - const manifestTables = await getDestinyManifestSlice((d) => this.http.$httpWithoutApiKey(d), { + manifestTables = await getDestinyManifestSlice((d) => this.http.$httpWithoutApiKey(d), { destinyManifest: destinyManifest.Response, tableNames: [ "DestinyInventoryItemDefinition", @@ -808,20 +1582,32 @@ export class BungieApiService { ] as any as DestinyManifestComponentName[], language: "en", }); + this.logger.info( + "BungieApiService", + "updateManifest", + `Fetched manifest tables: ${Object.keys(manifestTables).join(", ")}` + ); - const enManifestTables = await getDestinyManifestSlice((d) => this.http.$httpWithoutApiKey(d), { - destinyManifest: destinyManifest.Response, - tableNames: ["DestinyCollectibleDefinition", "DestinyPresentationNodeDefinition"], - language: "en", - }); + // Call updates on individual manifest tables + await this.updateAbilitiesFromJSON(manifestTables); + await this.updateExoticCollectiblesFromJSON(manifestTables); + await this.updateVendorNamesFromJSON(manifestTables); + await this.updateVendorItemSubScreensFromJSON(manifestTables); + this.updateEquipableItemSetDefinitionsFromJSON(manifestTables); + this.updateSandboxPerksFromJSON(manifestTables); + + const manifestVersion = destinyManifest.Response.version; - await this.updateExoticCollectibles(manifestTables); - await this.updateVendorNames(manifestTables); - await this.updateAbilities(manifestTables); - await this.updateVendorItemSubScreens(manifestTables); - await this.updateEquipableItemSetDefinitions(manifestTables); - await this.updateSandboxPerks(manifestTables); + let entries = await this.extractArmorDataFromJSONManifest(manifestTables); + + await this.db.writeManifestArmor(entries, manifestVersion); + this.manifestUpdatedSubject.next(); + return true; + } + private async extractArmorDataFromJSONManifest( + manifestTables: DestinyManifestSlice<(keyof AllDestinyManifestComponents)[]> + ) { // NOTE: This is also storing emotes, as these have itemType 19 (mods) let entries = Object.entries(manifestTables.DestinyInventoryItemDefinition) .filter(([k, v]) => { @@ -904,12 +1690,12 @@ export class BungieApiService { if (clasz == DestinyClass.Unknown && isArmor2) { if (v.collectibleHash != undefined) { let presentationParentNode = - enManifestTables.DestinyCollectibleDefinition[v.collectibleHash].parentNodeHashes; + manifestTables.DestinyCollectibleDefinition[v.collectibleHash].parentNodeHashes; if (presentationParentNode !== undefined) { if ( presentationParentNode.findIndex( (x) => - enManifestTables.DestinyPresentationNodeDefinition[x].displayProperties.name == + manifestTables.DestinyPresentationNodeDefinition[x].displayProperties.name == "Warlock" ) != -1 ) @@ -917,7 +1703,7 @@ export class BungieApiService { if ( presentationParentNode.findIndex( (x) => - enManifestTables.DestinyPresentationNodeDefinition[x].displayProperties.name == + manifestTables.DestinyPresentationNodeDefinition[x].displayProperties.name == "Titan" ) != -1 ) @@ -925,7 +1711,7 @@ export class BungieApiService { if ( presentationParentNode.findIndex( (x) => - enManifestTables.DestinyPresentationNodeDefinition[x].displayProperties.name == + manifestTables.DestinyPresentationNodeDefinition[x].displayProperties.name == "Hunter" ) != -1 ) @@ -993,11 +1779,9 @@ export class BungieApiService { isFeatured: isFeatured, } as IManifestArmor; }); - - await this.db.writeManifestArmor(entries, manifestVersion); - this.manifestUpdatedSubject.next(); - return manifestTables; + return entries; } + getGearSet( v: DestinyInventoryItemDefinition, itemSetDefinitions: DestinyEquipableItemSetDefinition[] @@ -1009,12 +1793,15 @@ export class BungieApiService { } return null; } - updateSandboxPerks(manifestTables: DestinyManifestSlice<"DestinySandboxPerkDefinition"[]>) { + + updateSandboxPerksFromJSON( + manifestTables: DestinyManifestSlice<"DestinySandboxPerkDefinition"[]> + ) { const sandboxPerks = manifestTables.DestinySandboxPerkDefinition; if (!sandboxPerks) { this.logger.warn( "BungieApiService", - "updateSandboxPerks", + "updateSandboxPerksFromJSON", "No sandbox perks found in manifest" ); return; @@ -1025,9 +1812,15 @@ export class BungieApiService { }); this.db.sandboxPerkDefinition.clear(); + this.logger.info( + "BungieApiService", + "updateSandboxPerks", + `Storing ${mappedSandboxPerks.length} sandbox perks in localStorage` + ); this.db.sandboxPerkDefinition.bulkPut(mappedSandboxPerks); } - updateEquipableItemSetDefinitions( + + updateEquipableItemSetDefinitionsFromJSON( manifestTables: DestinyManifestSlice<(keyof AllDestinyManifestComponents)[]> ) { const equipableItemSetDefinitions = (manifestTables as any) @@ -1035,7 +1828,7 @@ export class BungieApiService { if (!equipableItemSetDefinitions) { this.logger.warn( "BungieApiService", - "updateEquipableItemSetDefinitions", + "updateEquipableItemSetDefinitionsFromJSON", "No equipable item set definitions found in manifest" ); return; @@ -1045,6 +1838,11 @@ export class BungieApiService { const mapped = Object.entries(equipableItemSetDefinitions).map(([key, value]) => { return value as DestinyEquipableItemSetDefinition; }); + this.logger.info( + "BungieApiService", + "updateEquipableItemSetDefinitions", + `Storing ${mapped.length} equipable item set definitions in localStorage` + ); this.db.equipableItemSetDefinition.bulkPut(mapped); } diff --git a/src/app/services/changelog.service.ts b/src/app/services/changelog.service.ts index 2accb5d2..b624ade2 100644 --- a/src/app/services/changelog.service.ts +++ b/src/app/services/changelog.service.ts @@ -15,33 +15,56 @@ * along with this program. If not, see . */ -import { Injectable } from "@angular/core"; +import { Injectable, OnDestroy } from "@angular/core"; import { CHANGELOG_DATA } from "../data/changelog"; import { ChangelogDialogComponent } from "../components/authenticated-v2/components/changelog-dialog/changelog-dialog.component"; import { MatDialog } from "@angular/material/dialog"; +import { LoggingProxyService } from "./logging-proxy.service"; @Injectable({ providedIn: "root", }) -export class ChangelogService { - constructor(public dialog: MatDialog) {} +export class ChangelogService implements OnDestroy { + private hasCheckedForChangelog = false; + + constructor( + public dialog: MatDialog, + private logger: LoggingProxyService + ) { + this.logger.debug("ChangelogService", "constructor", "Initializing ChangelogService"); + } + + ngOnDestroy(): void { + this.logger.debug("ChangelogService", "ngOnDestroy", "Destroying ChangelogService"); + } setChangelogSeenFlag() { - return localStorage.setItem("last-changelog-version", this.changelogData[0].version); + return localStorage.setItem("d2ap-changelogVersion-lastRead", this.changelogData[0].version); + } + + setlastWipeManifestVersion() { + return localStorage.setItem( + "d2ap-changelogVersion-lastWipeManifest", + this.changelogData[0].version + ); + } + + get lastWipeManifestVersion() { + return localStorage.getItem("d2ap-changelogVersion-lastWipeManifest"); } get lastViewedChangelog() { - return localStorage.getItem("last-changelog-version"); + return localStorage.getItem("d2ap-changelogVersion-lastRead"); } get mustShowChangelog() { return this.changelogData[0].version !== this.lastViewedChangelog; } - get wipeManifest() { + get shouldWipeManifest() { return ( - this.changelogData[0].version !== this.lastViewedChangelog && - (this.changelogData[0].clearManifest ?? false) + (this.changelogData[0].clearManifest ?? false) && + this.changelogData[0].version !== this.lastWipeManifestVersion ); } @@ -55,4 +78,15 @@ export class ChangelogService { this.setChangelogSeenFlag(); }); } + + /** + * Automatically shows the changelog dialog if needed. + * Should be called once during app initialization. + */ + checkAndShowChangelog() { + if (!this.hasCheckedForChangelog && this.mustShowChangelog) { + this.hasCheckedForChangelog = true; + this.openChangelogDialog(); + } + } } diff --git a/src/app/services/character-stats.service.ts b/src/app/services/character-stats.service.ts index 35e9652a..f8aab9c7 100644 --- a/src/app/services/character-stats.service.ts +++ b/src/app/services/character-stats.service.ts @@ -15,11 +15,13 @@ * along with this program. If not, see . */ -import { Injectable } from "@angular/core"; +import { Injectable, OnDestroy } from "@angular/core"; import { ClarityService } from "./clarity.service"; import { ModifierType } from "../data/enum/modifierType"; import type { CharacterStats, Override } from "../data/character_stats/schema"; import { DestinyClass, DestinyInventoryItemDefinition } from "bungie-api-ts/destiny2"; +import { DatabaseService } from "./database.service"; +import { LoggingProxyService } from "./logging-proxy.service"; export enum CharacterStatType { Speed = 1, @@ -50,26 +52,32 @@ export interface CooldownEntry { @Injectable({ providedIn: "root", }) -export class CharacterStatsService { +export class CharacterStatsService implements OnDestroy { private allStatEntries: Partial> = {}; private overrides: Override[] = []; - constructor(private clarity: ClarityService) { - this.clarity.characterStats.subscribe((data) => { - if (data) this.updateCharacterStats(data); + constructor( + private clarity: ClarityService, + private db: DatabaseService, + private logger: LoggingProxyService + ) { + this.logger.debug("CharacterStatsService", "constructor", "Initializing CharacterStatsService"); + this.clarity.characterStats.subscribe(async (data) => { + if (data) await this.updateCharacterStats(data); }); } + ngOnDestroy(): void { + this.logger.debug("CharacterStatsService", "ngOnDestroy", "Destroying CharacterStatsService"); + } + loadCharacterStats() { this.clarity.load(); } - private updateCharacterStats(data: CharacterStats) { - const allAbilities = ( - (JSON.parse( - window.localStorage.getItem("allAbilities")! - ) as DestinyInventoryItemDefinition[]) || [] - ).reduce((acc, ability) => { + private async updateCharacterStats(data: CharacterStats) { + const abilities = await this.db.getCharacterAbilities(); + const allAbilities = abilities.reduce((acc, ability) => { acc.set(ability.hash, ability); return acc; }, new Map()); diff --git a/src/app/services/clarity.service.spec.ts b/src/app/services/clarity.service.spec.ts index d44ec626..ad8675f6 100644 --- a/src/app/services/clarity.service.spec.ts +++ b/src/app/services/clarity.service.spec.ts @@ -26,7 +26,7 @@ import { SUPPORTED_SCHEMA_VERSION, UpdateData, } from "./clarity.service"; -import { NGXLogger } from "ngx-logger"; +import { LoggingProxyService } from "./logging-proxy.service"; import { MatDialogModule } from "@angular/material/dialog"; describe("ClarityService", () => { @@ -37,7 +37,7 @@ describe("ClarityService", () => { beforeEach(() => { TestBed.configureTestingModule({ imports: [HttpClientTestingModule, MatDialogModule], - providers: [NGXLogger], + providers: [LoggingProxyService], }); httpTestingController = TestBed.inject(HttpTestingController); @@ -106,7 +106,7 @@ describe("ClarityService", () => { it("should not fetch live data the schema does not match our supported version", (done) => { setCachedDataWithVersion(1); - const logger = TestBed.inject(NGXLogger); + const logger = TestBed.inject(LoggingProxyService); spyOn(logger, "warn"); service.load().then(() => { expect(logger.warn).toHaveBeenCalledWith( @@ -121,7 +121,7 @@ describe("ClarityService", () => { }); it("should fail gracefully if version fetch fails", (done) => { - const logger = TestBed.inject(NGXLogger); + const logger = TestBed.inject(LoggingProxyService); spyOn(logger, "warn"); service .load() @@ -139,7 +139,7 @@ describe("ClarityService", () => { }); it("should fail gracefully if stats fetch fails", (done) => { - const logger = TestBed.inject(NGXLogger); + const logger = TestBed.inject(LoggingProxyService); spyOn(logger, "warn"); setTimeout(() => { diff --git a/src/app/services/clarity.service.ts b/src/app/services/clarity.service.ts index f653dd99..93fb3d54 100644 --- a/src/app/services/clarity.service.ts +++ b/src/app/services/clarity.service.ts @@ -15,13 +15,13 @@ * along with this program. If not, see . */ -import { Injectable } from "@angular/core"; -import { NGXLogger } from "ngx-logger"; +import { Injectable, OnDestroy } from "@angular/core"; +import { LoggingProxyService } from "./logging-proxy.service"; import { HttpClient } from "@angular/common/http"; import { Observable, BehaviorSubject } from "rxjs"; import type { CharacterStats } from "../data/character_stats/schema"; -import { InventoryService } from "./inventory.service"; +import { UserInformationService } from "src/app/services/user-information.service"; const BASE_URL = "https://Database-Clarity.github.io/Character-Stats"; export const SUPPORTED_SCHEMA_VERSION = "1.9"; @@ -44,7 +44,7 @@ export type UpdateData = { @Injectable({ providedIn: "root", }) -export class ClarityService { +export class ClarityService implements OnDestroy { private _characterStats: BehaviorSubject = new BehaviorSubject(null); public readonly characterStats: Observable = @@ -52,11 +52,16 @@ export class ClarityService { constructor( private http: HttpClient, - private inv: InventoryService, - private logger: NGXLogger + private userInfo: UserInformationService, + private logger: LoggingProxyService ) { + this.logger.debug("ClarityService", "constructor", "Initializing ClarityService"); // trigger a clarity reload on manifest change - this.inv.manifest.subscribe((_) => this.load()); + this.userInfo.manifest.subscribe((_) => this.load()); + } + + ngOnDestroy(): void { + this.logger.debug("ClarityService", "ngOnDestroy", "Destroying ClarityService"); } async load() { @@ -68,7 +73,12 @@ export class ClarityService { } private async fetchUpdateData() { - return this.http.get(UPDATES_URL).toPromise(); + try { + return await this.http.get(UPDATES_URL).toPromise(); + } catch (error) { + this.logger.warn("ClarityService", "fetchUpdateData", "Failed to fetch update data", error); + return null; + } } // Load data from cache or fetch live data if necessary @@ -90,17 +100,34 @@ export class ClarityService { liveVersion.schemaVersion ); } else if (liveVersion && liveVersion.lastUpdate !== undefined) { - await this.fetchLiveCharacterStats().then((data) => { + try { + const data = await this.fetchLiveCharacterStats(); localStorage.setItem(LOCAL_STORAGE_STATS_KEY, JSON.stringify(data)); localStorage.setItem(LOCAL_STORAGE_STATS_VERSION_KEY, liveVersion.lastUpdate.toString()); - this._characterStats.next(data); - }); + } catch (error) { + this.logger.warn( + "ClarityService", + "loadCharacterStats", + "Failed to load live character stats", + error + ); + } } } } private async fetchLiveCharacterStats() { - return this.http.get(CHARACTER_STATS_URL).toPromise(); + try { + return await this.http.get(CHARACTER_STATS_URL).toPromise(); + } catch (error) { + this.logger.warn( + "ClarityService", + "fetchLiveCharacterStats", + "Failed to fetch live character stats", + error + ); + throw error; + } } } diff --git a/src/app/services/configuration.service.ts b/src/app/services/configuration.service.ts index 0d13f114..a39c5fb9 100644 --- a/src/app/services/configuration.service.ts +++ b/src/app/services/configuration.service.ts @@ -15,8 +15,8 @@ * along with this program. If not, see . */ -import { Injectable } from "@angular/core"; -import { NGXLogger } from "ngx-logger"; +import { Injectable, OnDestroy } from "@angular/core"; +import { LoggingProxyService } from "./logging-proxy.service"; import { BuildConfiguration } from "../data/buildConfiguration"; import { BehaviorSubject, Observable } from "rxjs"; import { ModOrAbility } from "../data/enum/modOrAbility"; @@ -47,7 +47,10 @@ const lzDecompOptions = { @Injectable({ providedIn: "root", }) -export class ConfigurationService { +export class ConfigurationService implements OnDestroy { + get currentConfiguration() { + return this.__configuration; + } private __configuration: BuildConfiguration; private __LastConfiguration: BuildConfiguration; @@ -61,7 +64,8 @@ export class ConfigurationService { private _storedConfigurations: BehaviorSubject; public readonly storedConfigurations: Observable; - constructor(private logger: NGXLogger) { + constructor(private logger: LoggingProxyService) { + this.logger.debug("ConfigurationService", "constructor", "Initializing ConfigurationService"); this.__configuration = this.loadCurrentConfiguration(); this.__LastConfiguration = this.loadCurrentConfiguration(); @@ -72,6 +76,10 @@ export class ConfigurationService { this.storedConfigurations = this._storedConfigurations.asObservable(); } + ngOnDestroy(): void { + this.logger.debug("ConfigurationService", "ngOnDestroy", "Destroying ConfigurationService"); + } + modifyConfiguration(cb: (configuration: BuildConfiguration) => void) { cb(this.__configuration); if (_isEqual(this.__configuration, this.__LastConfiguration)) return; @@ -183,7 +191,7 @@ export class ConfigurationService { } saveCurrentConfiguration(configuration: BuildConfiguration) { - this.logger.debug("Writing configuration", { configuration: configuration }); + this.logger.debug("Writing configuration", JSON.stringify({ configuration: configuration })); // deep copy it this.__configuration = Object.assign( BuildConfiguration.buildEmptyConfiguration(), @@ -198,7 +206,7 @@ export class ConfigurationService { ); const compressed = lzutf8.compress(JSON.stringify(this.__configuration), lzCompOptions); - localStorage.setItem("currentConfig", compressed); + localStorage.setItem("user-currentConfig", compressed); this._configuration.next(Object.assign({}, this.__configuration)); } @@ -206,19 +214,19 @@ export class ConfigurationService { try { let config; try { - config = localStorage.getItem("currentConfig") || "{}"; + config = localStorage.getItem("user-currentConfig") || "{}"; if (config.substr(0, 1) != "{") config = lzutf8.decompress(config, lzDecompOptions); } catch (e) { config = {}; } - var dummy: StoredConfiguration = { + var storedConfiguration: StoredConfiguration = { name: "dummy", version: "1", configuration: JSON.parse(config), }; - this.checkAndFixOldSavedConfigurations(dummy); - return dummy.configuration; + this.checkAndFixOldSavedConfigurations(storedConfiguration); + return storedConfiguration.configuration; } catch (e) { this.logger.error( "ConfigurationService", @@ -231,7 +239,7 @@ export class ConfigurationService { } getCurrentConfigBase64Compressed(): string { - let config = localStorage.getItem("currentConfig") || "{}"; + let config = localStorage.getItem("user-currentConfig") || "{}"; if (config.substr(0, 1) == "{") config = lzutf8.compress(config, { outputEncoding: "Base64" }); return config; } diff --git a/src/app/services/database.service.ts b/src/app/services/database.service.ts index 6bd24a27..62782a35 100644 --- a/src/app/services/database.service.ts +++ b/src/app/services/database.service.ts @@ -15,71 +15,111 @@ * along with this program. If not, see . */ -import { Injectable } from "@angular/core"; -import { NGXLogger } from "ngx-logger"; +import { Injectable, OnDestroy } from "@angular/core"; +import { LoggingProxyService } from "./logging-proxy.service"; import { AuthService } from "./auth.service"; -import { Database } from "../data/database"; +import { D2APDatabase } from "../data/database"; import { IManifestArmor } from "../data/types/IManifestArmor"; -import { environment } from "../../environments/environment"; import { ChangelogService } from "./changelog.service"; +import { DestinyInventoryItemDefinition } from "bungie-api-ts/destiny2"; @Injectable({ providedIn: "root", }) -export class DatabaseService extends Database { +export class DatabaseService extends D2APDatabase implements OnDestroy { constructor( private auth: AuthService, private changelog: ChangelogService, - private logger: NGXLogger + private logger: LoggingProxyService ) { super(); - - if (this.changelog.wipeManifest) { - this.logger.log("Wiping manifest due to changelog request"); - this.auth.clearManifestInfo(); + this.logger.debug("DatabaseService", "constructor", "Initializing DatabaseService"); + + if (this.changelog.shouldWipeManifest) { + this.logger.info( + "DatabaseService", + "constructor", + "Wiping manifest due to changelog request" + ); + this.changelog.setlastWipeManifestVersion(); + this.clearManifestInfo(); } this.version(this.verno).upgrade(async (tx) => { - this.auth.clearManifestInfo(); + this.clearManifestInfo(); }); this.auth.logoutEvent.subscribe(async (k) => { - await this.clearDatabase(); + await this.clearInventoryCache(); }); } + ngOnDestroy(): void { + this.logger.debug("DatabaseService", "ngOnDestroy", "Destroying DatabaseService"); + } + + async clearInventoryCache() { + this.logger.debug("DatabaseService", "clearInventoryCache", "Clearing inventory cache"); + localStorage.removeItem("user-armorItems"); + localStorage.removeItem("d2ap-inventory-lastDate"); + await this.inventoryArmor.clear(); + await this.vendorItemSubscreen.clear(); + } + private initialize() { this.open(); - this.auth.clearManifestInfo(); + this.clearManifestInfo(); } async writeManifestArmor(items: IManifestArmor[], version: string) { await this.manifestArmor.clear(); - await this.manifestArmor.bulkPut(items); - localStorage.setItem("LastManifestUpdate", Date.now().toString()); - localStorage.setItem("last-manifest-db-name", this.manifestArmor.db.name); - localStorage.setItem("last-manifest-revision", environment.revision); - localStorage.setItem("last-manifest-version", version); + await this.manifestArmor.bulkPut(items).catch((e) => { + this.logger.error( + "DatabaseService", + "writeManifestArmor", + "Error writing manifest armor to database: " + e + ); + }); + localStorage.setItem("d2ap-manifest-lastDate", Date.now().toString()); + localStorage.setItem("d2ap-db-lastName", this.manifestArmor.db.name); + localStorage.setItem("d2ap-manifest-lastVersion", version); } - private async clearDatabase() { - localStorage.removeItem("LastManifestUpdate"); - localStorage.removeItem("LastArmorUpdate"); - localStorage.removeItem("last-manifest-revision"); - localStorage.removeItem("last-manifest-db-name"); - await this.inventoryArmor.clear(); + public async clearManifestInfo() { + localStorage.removeItem("d2ap-manifest-lastDate"); + localStorage.removeItem("d2ap-inventory-lastDate"); + localStorage.removeItem("d2ap-db-lastName"); } async resetDatabase(initialize = true) { - localStorage.removeItem("LastManifestUpdate"); - localStorage.removeItem("last-manifest-revision"); - localStorage.removeItem("last-manifest-db-name"); - localStorage.removeItem("vendor-next-refresh-time"); - - localStorage.removeItem("LastArmorUpdate"); - localStorage.removeItem("last-armor-db-name"); + localStorage.removeItem("d2ap-manifest-lastDate"); + localStorage.removeItem("d2ap-db-lastName"); + localStorage.removeItem("user-vendor-nextRefreshTime"); + localStorage.removeItem("d2ap-inventory-lastDate"); await this.delete(); + await window.indexedDB + .databases() + .then((dbs) => { + dbs.forEach((idb) => { + if (idb.name) { + window.indexedDB.deleteDatabase(idb.name); + this.logger.debug( + "DatabaseService", + "resetDatabase", + `Deleted IndexedDB database: ${idb.name}` + ); + } + }); + }) + .catch((error) => { + this.logger.error( + "DatabaseService", + "resetDatabase", + "Failed to get database list or delete databases", + error + ); + }); if (initialize) this.initialize(); } @@ -88,26 +128,16 @@ export class DatabaseService extends Database { * if it exists and is still valid. */ lastManifestUpdate(): { updatedAt: number; version: string } | undefined { - const lastManifestUpdate = localStorage.getItem("LastManifestUpdate"); - const lastManifestVersion = localStorage.getItem("last-manifest-version"); - - const lastManifestRevision = localStorage.getItem("last-manifest-revision"); - const lastManifestDbName = localStorage.getItem("last-manifest-db-name"); - - if ( - !lastManifestUpdate || - !lastManifestRevision || - !lastManifestDbName || - !lastManifestVersion - ) { - return; - } + const lastManifestUpdate = localStorage.getItem("d2ap-manifest-lastDate"); + const lastManifestVersion = localStorage.getItem("d2ap-manifest-lastVersion"); + + const lastManifestDbName = localStorage.getItem("d2ap-db-lastName"); - if (localStorage.getItem("last-manifest-revision") !== environment.revision) { + if (!lastManifestUpdate || !lastManifestDbName || !lastManifestVersion) { return; } - if (lastManifestDbName !== this.inventoryArmor.db.name) { + if (lastManifestDbName !== this.name) { return; } @@ -139,4 +169,17 @@ export class DatabaseService extends Database { item.exoticPerkHash = []; } } + + async writeCharacterAbilities(abilities: DestinyInventoryItemDefinition[]) { + await this.sandboxAbilities.clear(); + await this.sandboxAbilities.bulkPut(abilities); + } + + async getCharacterAbilities(): Promise { + return await this.sandboxAbilities.toArray(); + } + + async clearCharacterAbilities() { + await this.sandboxAbilities.clear(); + } } diff --git a/src/app/services/dim.service.ts b/src/app/services/dim.service.ts index 3be8944b..958a4097 100644 --- a/src/app/services/dim.service.ts +++ b/src/app/services/dim.service.ts @@ -18,7 +18,7 @@ * bhollis (adaption of the DIM links) */ -import { Injectable } from "@angular/core"; +import { Injectable, OnDestroy } from "@angular/core"; import { ConfigurationService } from "./configuration.service"; import { ArmorStat, @@ -37,20 +37,30 @@ import { LoadoutParameters, } from "@destinyitemmanager/dim-api-types"; import { DestinyClass } from "bungie-api-ts/destiny2"; +import { LoggingProxyService } from "./logging-proxy.service"; @Injectable({ providedIn: "root", }) -export class DimService { +export class DimService implements OnDestroy { private armorStatIds = ARMORSTAT_ORDER; - constructor(private configService: ConfigurationService) {} + constructor( + private configService: ConfigurationService, + private logger: LoggingProxyService + ) { + this.logger.debug("DimService", "constructor", "Initializing DimService"); + } + + ngOnDestroy(): void { + this.logger.debug("DimService", "ngOnDestroy", "Destroying DimService"); + } /** * Generate a DIM search query for the given result */ generateDIMQuery(result: ResultDefinition): string { - let query = result.items.map((d) => `id:'${d.itemInstanceId}'`).join(" OR "); + let query = result.items.map((d) => `(id:'${d.itemInstanceId}')`).join(" OR "); return query; } @@ -61,10 +71,32 @@ export class DimService { async copyDIMQuery(result: ResultDefinition): Promise { try { const query = this.generateDIMQuery(result); - await navigator.clipboard.writeText(query); - return true; - } catch (error) { + + // Use ClipboardItem with explicit text/plain type for better iOS compatibility + if (navigator.clipboard && navigator.clipboard.write) { + const clipboardItem = new ClipboardItem({ + "text/plain": new Blob([query], { type: "text/plain" }), + }); + await navigator.clipboard.write([clipboardItem]); + return true; + } + + // Fallback to writeText if ClipboardItem is not supported + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(query); + return true; + } + return false; + } catch (error) { + // Fallback to writeText on error + try { + const query = this.generateDIMQuery(result); + await navigator.clipboard.writeText(query); + return true; + } catch (fallbackError) { + return false; + } } } diff --git a/src/app/services/http-client.service.ts b/src/app/services/http-client.service.ts index 011f5f2d..addd80dd 100644 --- a/src/app/services/http-client.service.ts +++ b/src/app/services/http-client.service.ts @@ -1,5 +1,5 @@ -import { Injectable } from "@angular/core"; -import { NGXLogger } from "ngx-logger"; +import { Injectable, NgZone, OnDestroy } from "@angular/core"; +import { LoggingProxyService } from "./logging-proxy.service"; import { HttpClientConfig } from "bungie-api-ts/destiny2"; import { AuthService } from "./auth.service"; import { HttpClient } from "@angular/common/http"; @@ -7,16 +7,191 @@ import { environment } from "../../environments/environment"; import { StatusProviderService } from "./status-provider.service"; import { retry } from "rxjs/operators"; +interface OAuthTokenResponse { + access_token: string; + token_type: string; + expires_in: number; + refresh_token: string; + refresh_expires_in: number; + membership_id: string; +} + @Injectable({ providedIn: "root", }) -export class HttpClientService { +export class HttpClientService implements OnDestroy { constructor( private authService: AuthService, private http: HttpClient, private status: StatusProviderService, - private logger: NGXLogger - ) {} + private logger: LoggingProxyService, + private ngZone: NgZone + ) { + this.logger.debug("HttpClientService", "constructor", "Initializing HttpClientService"); + } + + ngOnDestroy(): void { + this.logger.debug("HttpClientService", "ngOnDestroy", "Destroying HttpClientService"); + } + + get refreshTokenExpired() { + return this.refreshTokenExpiringAt < Date.now(); + } + + get authCode() { + return localStorage.getItem("code"); + } + + set authCode(newCode: string | null) { + if (!newCode) { + this.logger.info("HttpClientService", "authCode", "Clearing auth code from storage"); + localStorage.removeItem("code"); + } else { + this.logger.info("HttpClientService", "authCode", "Setting new auth code"); + localStorage.setItem("code", "" + newCode); + } + } + + get accessToken() { + return localStorage.getItem("auth-accessToken"); + } + + set accessToken(newCode: string | null) { + if (!newCode) { + this.logger.info("HttpClientService", "auth-accessToken", "Clearing access token"); + localStorage.removeItem("auth-accessToken"); + } else { + this.logger.info( + "HttpClientService", + "auth-accessToken", + "Setting new access token: [REDACTED]" + ); + localStorage.setItem("auth-accessToken", "" + newCode); + } + } + + get refreshToken() { + return localStorage.getItem("auth-refreshToken"); + } + + set refreshToken(newCode: string | null) { + if (!newCode) { + this.logger.info("HttpClientService", "auth-refreshToken", "Clearing refresh token"); + localStorage.removeItem("auth-refreshToken"); + } else { + this.logger.info( + "HttpClientService", + "auth-refreshToken", + "Setting new refresh token: [REDACTED]" + ); + localStorage.setItem("auth-refreshToken", "" + newCode); + } + } + + get refreshTokenExpiringAt(): number { + let l = localStorage.getItem("auth-refreshToken-expireDate") || "0"; + return l ? Number.parseInt(l) : 0; + } + + set refreshTokenExpiringAt(newCode: number | null) { + if (!newCode) { + this.logger.info( + "HttpClientService", + "auth-refreshToken-expireDate", + "Clearing refresh token" + ); + localStorage.removeItem("auth-refreshToken-expireDate"); + } else { + this.logger.info( + "HttpClientService", + "auth-refreshToken-expireDate", + "Setting new refresh token" + ); + localStorage.setItem("auth-refreshToken-expireDate", "" + newCode); + } + } + + get lastAuthRefresh(): number { + let l = localStorage.getItem("auth-refreshToken-lastRefreshDate") || "0"; + return l ? Number.parseInt(l) : 0; + } + + set lastAuthRefresh(newCode: number | null) { + if (!newCode) localStorage.removeItem("auth-refreshToken-lastRefreshDate"); + else localStorage.setItem("auth-refreshToken-lastRefreshDate", newCode.toString()); + } + + isAuthenticated() { + return !!this.accessToken; + } + + async autoRegenerateTokens() { + const timing = 1000 * 3600 * 0.5; // Refresh every half hour + if ( + this.refreshToken && + Date.now() < this.refreshTokenExpiringAt && + Date.now() > this.lastAuthRefresh + timing + ) { + return await this.generateTokens(true); + } + return true; + } + + async generateTokens(refresh = false): Promise { + this.logger.info( + "HttpClientService", + "generateTokens", + `Generate auth tokens, refresh based on refresh_token: ${refresh}` + ); + const CLIENT_ID = environment.clientId; + const CLIENT_SECRET = environment.client_secret; + const grant_type = "authorization_code"; + const TOKEN = this.authCode; + + let body = `grant_type=${grant_type}&code=${TOKEN}&client_id=${CLIENT_ID}&client_secret=${CLIENT_SECRET}`; + if (refresh) { + body = `grant_type=refresh_token&refresh_token=${this.refreshToken}&client_id=${CLIENT_ID}&client_secret=${CLIENT_SECRET}`; + } + + return await this.http + .post(`https://www.bungie.net/Platform/App/OAuth/Token/`, body, { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "X-API-Key": environment.apiKey, + }, + }) + .toPromise() + .then((value) => { + this.logger.info( + "HttpClientService", + "generateTokens", + `generateTokens: {"access_token":"[REDACTED]","token_type":"${value.token_type}","expires_in":${value.expires_in},"refresh_token":"[REDACTED]","refresh_expires_in":${value.refresh_expires_in},"membership_id":"${value.membership_id}"}` + ); + this.accessToken = value.access_token; + this.refreshToken = value.refresh_token; + this.refreshTokenExpiringAt = Date.now() + value.refresh_expires_in * 1000 - 10 * 1000; + this.lastAuthRefresh = Date.now(); + this.ngZone.run(() => { + this.status.modifyStatus((s) => (s.authError = false)); + }); + return true; + }) + .catch(async (err) => { + this.logger.error("HttpClientService", "generateTokens", JSON.stringify({ err })); + this.ngZone.run(() => { + this.status.modifyStatus((s) => (s.authError = true)); + }); + return false; + }); + } + + private clearLoginInfo() { + this.lastAuthRefresh = null; + this.refreshTokenExpiringAt = null; + this.authCode = null; + this.accessToken = null; + this.refreshToken = null; + } async $httpWithoutBearerToken(config: HttpClientConfig) { return this.$http(config, false, true, false, 2); @@ -31,7 +206,7 @@ export class HttpClientService { params: config.params, headers: { "X-API-Key": environment.apiKey, - Authorization: "Bearer " + this.authService.accessToken, + Authorization: "Bearer " + this.accessToken, }, }) .pipe(retry(2)) @@ -48,14 +223,45 @@ export class HttpClientService { bearerToken = true, retryCount = 2 ) { + // Check and refresh tokens if needed when bearer token is required + if (bearerToken) { + if (this.refreshTokenExpired || !(await this.autoRegenerateTokens())) { + // before logging out, check if the user is actually authenticated, if not, just clear the auth state without logging out, to avoid infinite loops of failed requests triggering logouts + if (!this.isAuthenticated()) { + this.logger.warn("HttpClientService", "$http", "User is not authenticated"); + return null; + } + this.logger.warn( + "HttpClientService", + "$http", + "Refresh token expired or token generation failed, user should be logged out" + ); + this.status.setAuthError(); + if (logoutOnFailure) { + this.clearLoginInfo(); + this.authService.logout(); + } + return null; + } + } + let options = { params: config.params, headers: {} as any, }; if (apiKey) options.headers["X-API-Key"] = environment.apiKey; - if (bearerToken) options.headers["Authorization"] = "Bearer " + this.authService.accessToken; - + if (bearerToken) { + if (!this.accessToken) { + this.logger.error( + "HttpClientService", + "$http", + "No access token available for authenticated request" + ); + } else { + options.headers["Authorization"] = "Bearer " + this.accessToken; + } + } return this.http .get(config.url, options) .pipe(retry(retryCount)) @@ -66,16 +272,15 @@ export class HttpClientService { return res; }) .catch(async (err) => { + console.error("HTTP Error: ", config.url, "Options: ", options); this.logger.error("HttpClientService", "$http", err); if (environment.offlineMode) { this.logger.debug("HttpClientService", "$http", "Offline mode, ignoring API error"); - return; - } - if (err.error?.ErrorStatus == "SystemDisabled") { + } else if (err.error?.ErrorStatus == "SystemDisabled") { this.logger.info( "HttpClientService", "$http", - "System is disabled. Revoking auth, must re-login" + "System is disabled. Not revoking auth, system is probably down for maintenance." ); this.status.setApiError(); } @@ -84,13 +289,30 @@ export class HttpClientService { this.logger.info("HttpClientService", "$http", "Auth Error, probably expired token"); if (logoutOnFailure) { this.status.setAuthError(); + this.clearLoginInfo(); this.authService.logout(); } } - if (err.ErrorStatus != "Internal Server Error") { + // if error 401, log out + else if (err.status == 401) { + this.logger.info( + "HttpClientService", + "$http", + "Invalid credentials error, probably expired token" + ); + if (logoutOnFailure) { + this.status.setAuthError(); + this.clearLoginInfo(); + this.authService.logout(); + } + } else if (err.ErrorStatus != "Internal Server Error") { this.logger.info("HttpClientService", "$http", "API-Error"); //this.status.setApiError(); + } else { + this.logger.info("HttpClientService", "$http", "Generic API-Error"); + this.status.setApiError(); } + return null; }); } } diff --git a/src/app/services/inventory.service.ts b/src/app/services/inventory.service.ts deleted file mode 100644 index 0d968a0e..00000000 --- a/src/app/services/inventory.service.ts +++ /dev/null @@ -1,794 +0,0 @@ -/* - * Copyright (c) 2023 D2ArmorPicker by Mijago. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -import { Injectable } from "@angular/core"; -import { NGXLogger } from "ngx-logger"; -import { DatabaseService } from "./database.service"; -import { ArmorSystem, IManifestArmor } from "../data/types/IManifestArmor"; -import { ConfigurationService } from "./configuration.service"; -import { debounceTime } from "rxjs/operators"; -import { BehaviorSubject, Observable, ReplaySubject, Subject } from "rxjs"; -import { BuildConfiguration } from "../data/buildConfiguration"; -import { STAT_MOD_VALUES, StatModifier } from "../data/enum/armor-stat"; -import { StatusProviderService } from "./status-provider.service"; -import { BungieApiService } from "./bungie-api.service"; -import { AuthService } from "./auth.service"; -import { ArmorSlot } from "../data/enum/armor-slot"; -import { NavigationEnd, Router } from "@angular/router"; -import { - ResultDefinition, - ResultItem, -} from "../components/authenticated-v2/results/results.component"; -import { - IInventoryArmor, - InventoryArmorSource, - isEqualItem, - totalStats, -} from "../data/types/IInventoryArmor"; -import { DestinyClass, TierType } from "bungie-api-ts/destiny2"; -import { IPermutatorArmorSet } from "../data/types/IPermutatorArmorSet"; -import { getSkillTier, getWaste } from "./results-builder.worker"; -import { IPermutatorArmor } from "../data/types/IPermutatorArmor"; -import { FORCE_USE_NO_EXOTIC, MAXIMUM_MASTERWORK_LEVEL } from "../data/constants"; -import { VendorsService } from "./vendors.service"; -import { ModOptimizationStrategy } from "../data/enum/mod-optimization-strategy"; -import { isEqual as _isEqual } from "lodash"; -import { getDifferences } from "../data/commonFunctions"; - -type info = { - results: ResultDefinition[]; - totalResults: number; - maximumPossibleTiers: number[]; - itemCount: number; - totalTime: number; -}; - -export type ClassExoticInfo = { - inInventory: boolean; - inCollection: boolean; - inVendor: boolean; - items: IManifestArmor[]; - instances: IInventoryArmor[]; -}; - -@Injectable({ - providedIn: "root", -}) -export class InventoryService { - /** - * An Int32Array that holds all permutations for the currently selected class, before filters are applied. - * It consists of N items of length 11: - * helmetHash, gauntletHash, chestHash, legHash, mobility, resilience, recovery, discipline, intellect, strength, exoticHash - * @private - */ - private allArmorResults: ResultDefinition[] = []; - private currentClass: DestinyClass = DestinyClass.Unknown; - private initialized: boolean = false; - - private _manifest: ReplaySubject; - public readonly manifest: Observable; - private _inventory: ReplaySubject; - public readonly inventory: Observable; - - private _armorResults: BehaviorSubject; - public readonly armorResults: Observable; - - private _reachableTiers: BehaviorSubject; - public readonly reachableTiers: Observable; - - private _calculationProgress: Subject = new Subject(); - public readonly calculationProgress: Observable = - this._calculationProgress.asObservable(); - - private _config: BuildConfiguration = BuildConfiguration.buildEmptyConfiguration(); - private workers: Worker[]; - - private results: IPermutatorArmorSet[] = []; - private totalPermutationCount = 0; - private resultMaximumTiers: number[][] = []; - private selectedExotics: IManifestArmor[] = []; - private inventoryArmorItems: IInventoryArmor[] = []; - private permutatorArmorItems: IPermutatorArmor[] = []; - private endResults: ResultDefinition[] = []; - - constructor( - private db: DatabaseService, - private config: ConfigurationService, - private status: StatusProviderService, - private api: BungieApiService, - private auth: AuthService, - private router: Router, - private vendors: VendorsService, - private logger: NGXLogger - ) { - this._inventory = new ReplaySubject(1); - this.inventory = this._inventory.asObservable(); - this._manifest = new ReplaySubject(1); - this.manifest = this._manifest.asObservable(); - - this._armorResults = new BehaviorSubject({ - results: this.allArmorResults, - } as info); - this.armorResults = this._armorResults.asObservable(); - - this._reachableTiers = new BehaviorSubject([0, 0, 0, 0, 0, 0]); - this.reachableTiers = this._reachableTiers.asObservable(); - - this.workers = []; - let dataAlreadyFetched = false; - - // TODO: This gives a race condition on some parts. - router.events.pipe(debounceTime(5)).subscribe(async (val) => { - if (this.auth.refreshTokenExpired || !(await this.auth.autoRegenerateTokens())) { - this.logger.warn( - "InventoryService", - "router.events", - "Refresh token expired, we should probably log the user out" - ); - this.status.setAuthError(); - } - if (!auth.isAuthenticated()) { - this.logger.warn( - "InventoryService", - "router.events", - "User is not authenticated, skipping router event handling" - ); - return; - } - - if (val instanceof NavigationEnd) { - if (this._config == null) { - this._config = structuredClone(this.config.readonlyConfigurationSnapshot); - } - - this.killWorkers(); - this.clearResults(); - this.logger.debug( - "InventoryService", - "router.events", - "Trigger refreshAll due to router.events" - ); - this.initialized = true; - await this.refreshAll(!dataAlreadyFetched); - dataAlreadyFetched = true; - } - }); - - this.config.configuration.pipe(debounceTime(500)).subscribe(async (c) => { - if (this.auth.refreshTokenExpired || !(await this.auth.autoRegenerateTokens())) { - this.logger.warn( - "InventoryService", - "config.configuration", - "Refresh token expired, we should probably log the user out" - ); - this.status.setAuthError(); - //await this.auth.logout(); - //return; - } - if (!auth.isAuthenticated()) { - this.logger.warn( - "InventoryService", - "config.configuration", - "User is not authenticated, skipping config change handling" - ); - return; - } - - if (_isEqual(c, this._config)) return; - this.logger.debug( - "InventoryService", - "config.configuration", - "Build configuration changed: " + JSON.stringify(getDifferences(this._config, c)) - ); - - this._config = structuredClone(c); - this.initialized = true; - await this.refreshAll(!dataAlreadyFetched); - dataAlreadyFetched = true; - }); - } - - private clearResults() { - this.allArmorResults = []; - this._armorResults.next({ - results: this.allArmorResults, - totalResults: 0, - totalTime: 0, - itemCount: 0, - maximumPossibleTiers: [0, 0, 0, 0, 0, 0], - }); - } - - shouldCalculateResults(): boolean { - return this.router.url == "/"; - } - - private refreshing: boolean = false; - - async refreshAll(forceArmor: boolean = false, forceManifest = false) { - if (this.refreshing) { - this.logger.warn( - "InventoryService", - "refreshAll", - "Refresh already in progress, skipping new refresh request" - ); - return; - } - this.logger.debug("InventoryService", "refreshAll", "Refreshing inventory and manifest"); - try { - this.refreshing = true; - if (this.auth.refreshTokenExpired && !(await this.auth.autoRegenerateTokens())) { - this.refreshing = false; - this.status.setAuthError(); // Better way to logout the user? - if (!this.status.getStatus().apiError) await this.auth.logout(); - return; - } - let armorUpdated = false; - try { - let manifestUpdated = await this.updateManifest(forceManifest); - armorUpdated = await this.updateInventoryItems(manifestUpdated || forceArmor); - this.updateVendorsAsync(); - } catch (e) { - this.logger.error("InventoryService", "refreshAll", "Error: " + e); - } - - await this.triggerArmorUpdateAndUpdateResults(armorUpdated); - } finally { - this.refreshing = false; - } - } - - private async triggerArmorUpdateAndUpdateResults( - triggerInventoryUpdate: boolean = false, - triggerResultsUpdate: boolean = true - ) { - // trigger armor update behaviour - if (triggerInventoryUpdate) this._inventory.next(null); - - // Do not update results in Help and Cluster pages - if (this.shouldCalculateResults()) { - await this.updateResults(); - } - } - - private updateVendorsAsync() { - if (this.status.getStatus().updatingVendors) return; - - if (!this.vendors.isVendorCacheValid()) { - this.status.modifyStatus((s) => (s.updatingVendors = true)); - this.vendors - .updateVendorArmorItemsCache() - .then((success) => { - if (!success) return; - this.triggerArmorUpdateAndUpdateResults(success, this._config.includeVendorRolls); - }) - .finally(() => { - this.status.modifyStatus((s) => (s.updatingVendors = false)); - }); - } - } - - private killWorkers() { - this.logger.debug("InventoryService", "killWorkers", "Terminating all workers"); - this.workers.forEach((w) => { - w.terminate(); - }); - this.workers = []; - } - - private estimateCombinationsToBeChecked( - helmets: IPermutatorArmor[], - gauntlets: IPermutatorArmor[], - chests: IPermutatorArmor[], - legs: IPermutatorArmor[] - ) { - let totalCalculations = 0; - const exoticHelmets = helmets.filter((d) => d.isExotic).length; - const legendaryHelmets = helmets.length - exoticHelmets; - const exoticGauntlets = gauntlets.filter((d) => d.isExotic).length; - const legendaryGauntlets = gauntlets.length - exoticGauntlets; - const exoticChests = chests.filter((d) => d.isExotic).length; - const legendaryChests = chests.length - exoticChests; - const exoticLegs = legs.filter((d) => d.isExotic).length; - const legendaryLegs = legs.length - exoticLegs; - - totalCalculations += exoticHelmets * legendaryGauntlets * legendaryChests * legendaryLegs; - totalCalculations += legendaryHelmets * exoticGauntlets * legendaryChests * legendaryLegs; - totalCalculations += legendaryHelmets * legendaryGauntlets * exoticChests * legendaryLegs; - totalCalculations += legendaryHelmets * legendaryGauntlets * legendaryChests * exoticLegs; - totalCalculations += legendaryHelmets * legendaryGauntlets * legendaryChests * legendaryLegs; - return totalCalculations; - } - - cancelCalculation() { - this.logger.info("InventoryService", "cancelCalculation", "Cancelling calculation"); - this.killWorkers(); - this.status.modifyStatus((s) => (s.calculatingResults = false)); - this.status.modifyStatus((s) => (s.cancelledCalculation = true)); - - this._calculationProgress.next(0); - this.clearResults(); - } - - async updateResults(nthreads: number = 3) { - let config = this._config; - this.logger.debug( - "InventoryService", - "updateResults", - "Using config for Workers: " + JSON.stringify({ configuration: config }) - ); - this.clearResults(); - this.killWorkers(); - - try { - const updateResultsStart = performance.now(); - this.status.modifyStatus((s) => (s.calculatingResults = true)); - this.status.modifyStatus((s) => (s.cancelledCalculation = false)); - let doneWorkerCount = 0; - - this.results = []; - this.totalPermutationCount = 0; - this.resultMaximumTiers = []; - const startTime = Date.now(); - - this.selectedExotics = await Promise.all( - config.selectedExotics - .filter((hash) => hash != FORCE_USE_NO_EXOTIC) - .map( - async (hash) => - (await this.db.manifestArmor.where("hash").equals(hash).first()) as IManifestArmor - ) - ); - this.selectedExotics = this.selectedExotics.filter((i) => !!i); - - this.inventoryArmorItems = (await this.db.inventoryArmor - .where("clazz") - .equals(config.characterClass) - .distinct() - .toArray()) as IInventoryArmor[]; - - this.inventoryArmorItems = this.inventoryArmorItems - // only armor :) - .filter((item) => item.slot != ArmorSlot.ArmorSlotNone) - // filter disabled items - .filter((item) => config.disabledItems.indexOf(item.itemInstanceId) == -1) - // filter armor 3.0 - .filter((item) => item.isExotic || !config.enforceFeaturedLegendaryArmor || item.isFeatured) - .filter((item) => !item.isExotic || !config.enforceFeaturedExoticArmor || item.isFeatured) - .filter( - (item) => - item.armorSystem === ArmorSystem.Armor3 || - item.isExotic || - config.allowLegacyLegendaryArmor - ) - .filter( - (item) => - item.armorSystem === ArmorSystem.Armor3 || - !item.isExotic || - config.allowLegacyExoticArmor - ) - // filter collection/vendor rolls if not allowed - .filter((item) => { - switch (item.source) { - case InventoryArmorSource.Collections: - return config.includeCollectionRolls; - case InventoryArmorSource.Vendor: - return config.includeVendorRolls; - default: - return true; - } - }) - // filter the selected exotic right here - .filter( - (item) => config.selectedExotics.indexOf(FORCE_USE_NO_EXOTIC) == -1 || !item.isExotic - ) - .filter( - (item) => - this.selectedExotics.length === 0 || - (item.isExotic && this.selectedExotics.some((exotic) => exotic.hash === item.hash)) || - (!item.isExotic && this.selectedExotics.every((exotic) => exotic.slot !== item.slot)) - ) - - // config.OnlyUseMasterworkedExotics - only keep exotics that are masterworked - .filter( - (item) => - !config.onlyUseMasterworkedExotics || - !(item.rarity == TierType.Exotic && item.masterworkLevel != MAXIMUM_MASTERWORK_LEVEL) - ) - - // config.OnlyUseMasterworkedLegendaries - only keep legendaries that are masterworked - .filter( - (item) => - !config.onlyUseMasterworkedLegendaries || - !(item.rarity == TierType.Superior && item.masterworkLevel != MAXIMUM_MASTERWORK_LEVEL) - ) - - // non-legendaries and non-exotics - .filter( - (item) => - config.allowBlueArmorPieces || - item.rarity == TierType.Exotic || - item.rarity == TierType.Superior - ) - // sunset armor - .filter((item) => !config.ignoreSunsetArmor || !item.isSunset); - // this.logger.debug("InventoryService", "updateResults", items.map(d => "id:'"+d.itemInstanceId+"'").join(" or ")) - - // Remove collection items if they are in inventory - this.inventoryArmorItems = this.inventoryArmorItems.filter((item) => { - if (item.source === InventoryArmorSource.Inventory) return true; - - const purchasedItemInstance = this.inventoryArmorItems.find( - (rhs) => rhs.source === InventoryArmorSource.Inventory && isEqualItem(item, rhs) - ); - - // If this item is a collection/vendor item, ignore it if the player - // already has a real copy of the same item. - return purchasedItemInstance === undefined; - }); - this.permutatorArmorItems = this.inventoryArmorItems.map((armor) => { - return { - id: armor.id, - hash: armor.hash, - slot: armor.slot, - clazz: armor.clazz, - perk: armor.perk, - isExotic: armor.isExotic, - rarity: armor.rarity, - isSunset: armor.isSunset, - masterworkLevel: armor.masterworkLevel, - archetypeStats: armor.archetypeStats, - mobility: armor.mobility, - resilience: armor.resilience, - recovery: armor.recovery, - discipline: armor.discipline, - intellect: armor.intellect, - strength: armor.strength, - source: armor.source, - exoticPerkHash: armor.exoticPerkHash, - - gearSetHash: armor.gearSetHash ?? null, - - icon: armor.icon, - watermarkIcon: armor.watermarkIcon, - name: armor.name, - energyLevel: armor.energyLevel, - tier: armor.tier, - armorSystem: armor.armorSystem, - }; - }); - - nthreads = this.estimateRequiredThreads(); - this.logger.info("InventoryService", "updateResults", "Estimated threads: " + nthreads); - - // Values to calculate ETA - const threadCalculationAmountArr = [...Array(nthreads).keys()].map(() => 0); - const threadCalculationDoneArr = [...Array(nthreads).keys()].map(() => 0); - const threadCalculationReachableTiers: number[][] = [...Array(nthreads).keys()].map(() => - Array(6).fill(0) - ); - let oldProgressValue = 0; - - // Improve per thread performance by shuffling the inventory - // sorting is a naive aproach that can be optimized - // in my test is better than the default order from the db - this.permutatorArmorItems = this.permutatorArmorItems.sort( - (a, b) => totalStats(b) - totalStats(a) - ); - this._calculationProgress.next(0); - - for (let n = 0; n < nthreads; n++) { - this.workers[n] = new Worker(new URL("./results-builder.worker", import.meta.url), { - name: n.toString(), - }); - this.workers[n].onmessage = async (ev) => { - let data = ev.data; - threadCalculationDoneArr[n] = data.checkedCalculations; - threadCalculationAmountArr[n] = data.estimatedCalculations; - threadCalculationReachableTiers[n] = - data.reachableTiers || data.runtime.maximumPossibleTiers; - const sumTotal = threadCalculationAmountArr.reduce((a, b) => a + b, 0); - const sumDone = threadCalculationDoneArr.reduce((a, b) => a + b, 0); - const minReachableTiers = threadCalculationReachableTiers - .reduce((minArr, currArr) => { - // Using MAX would be more accurate, but using min is more visually appealing as it leads to larger jumps - return minArr.map((val, idx) => Math.max(val, currArr[idx])); - }) - .map((k) => Math.min(200, k) / 10); - this._reachableTiers.next(minReachableTiers); - - if ( - threadCalculationDoneArr[0] > 0 && - threadCalculationDoneArr[1] > 0 && - threadCalculationDoneArr[2] > 0 - ) { - const newProgress = (sumDone / sumTotal) * 100; - if (newProgress > oldProgressValue + 0.25) { - oldProgressValue = newProgress; - this._calculationProgress.next(newProgress); - } - } - if (data.runtime == null) return; - - this.results.push(...(data.results as IPermutatorArmorSet[])); - if (data.done == true) { - doneWorkerCount++; - this.totalPermutationCount += data.stats.permutationCount; - this.resultMaximumTiers.push(data.runtime.maximumPossibleTiers); - } - if (data.done == true && doneWorkerCount == nthreads) { - this.status.modifyStatus((s) => (s.calculatingResults = false)); - this._calculationProgress.next(0); - - this.endResults = []; - - for (let armorSet of this.results) { - let items = armorSet.armor.map((x) => - this.inventoryArmorItems.find((y) => y.id == x) - ) as IInventoryArmor[]; - let exotic = items.find((x) => x.isExotic); - let v: ResultDefinition = { - exotic: - exotic == null - ? undefined - : { - icon: exotic?.icon, - watermark: exotic?.watermarkIcon, - name: exotic?.name, - hash: exotic?.hash, - }, - artifice: armorSet.usedArtifice, - modCount: armorSet.usedMods.length, - modCost: armorSet.usedMods.reduce( - (p, d: StatModifier) => p + STAT_MOD_VALUES[d][2], - 0 - ), - mods: armorSet.usedMods, - stats: armorSet.statsWithMods, - statsNoMods: armorSet.statsWithoutMods, - tiers: getSkillTier(armorSet.statsWithMods), - waste: getWaste(armorSet.statsWithMods), - items: items.map( - (instance): ResultItem => ({ - energyLevel: instance.energyLevel, - hash: instance.hash, - itemInstanceId: instance.itemInstanceId, - name: instance.name, - exotic: !!instance.isExotic, - masterworked: instance.masterworkLevel == MAXIMUM_MASTERWORK_LEVEL, - archetypeStats: instance.archetypeStats, - armorSystem: instance.armorSystem, // 2 = Armor 2.0, 3 = Armor 3.0 - masterworkLevel: instance.masterworkLevel, - slot: instance.slot, - perk: instance.perk, - transferState: 0, // TRANSFER_NONE - tier: instance.tier, - stats: [ - instance.mobility, - instance.resilience, - instance.recovery, - instance.discipline, - instance.intellect, - instance.strength, - ], - source: instance.source, - statsNoMods: [], - }) - ), - usesCollectionRoll: items.some( - (y) => y.source === InventoryArmorSource.Collections - ), - usesVendorRoll: items.some((y) => y.source === InventoryArmorSource.Vendor), - } as ResultDefinition; - this.endResults.push(v); - } - - this._armorResults.next({ - results: this.endResults, - totalResults: this.totalPermutationCount, // Total amount of results, differs from the real amount if the memory save setting is active - itemCount: data.stats.itemCount, - totalTime: Date.now() - startTime, - maximumPossibleTiers: this.resultMaximumTiers - .reduce( - (p, v) => { - for (let k = 0; k < 6; k++) if (p[k] < v[k]) p[k] = v[k]; - return p; - }, - [0, 0, 0, 0, 0, 0] - ) - .map((k) => Math.min(200, k) / 10), - }); - const updateResultsEnd = performance.now(); - this.logger.info( - "InventoryService", - "updateResults", - `updateResults with WebWorker took ${updateResultsEnd - updateResultsStart} ms` - ); - this.workers[n].terminate(); - } else if (data.done == true && doneWorkerCount != nthreads) this.workers[n].terminate(); - }; - this.workers[n].onerror = (ev) => { - this.workers[n].terminate(); - }; - - this.workers[n].postMessage({ - type: "builderRequest", - currentClass: this.currentClass, - config: this._config, - threadSplit: { - count: nthreads, - current: n, - }, - items: this.permutatorArmorItems, - selectedExotics: this.selectedExotics, - }); - } - } finally { - } - } - - estimateRequiredThreads(): number { - const helmets = this.permutatorArmorItems.filter((d) => d.slot == ArmorSlot.ArmorSlotHelmet); - const gauntlets = this.permutatorArmorItems.filter( - (d) => d.slot == ArmorSlot.ArmorSlotGauntlet - ); - const chests = this.permutatorArmorItems.filter((d) => d.slot == ArmorSlot.ArmorSlotChest); - const legs = this.permutatorArmorItems.filter((d) => d.slot == ArmorSlot.ArmorSlotLegs); - const estimatedCalculations = this.estimateCombinationsToBeChecked( - helmets, - gauntlets, - chests, - legs - ); - - const largestArmorBucket = Math.max( - helmets.length, - gauntlets.length, - chests.length, - legs.length - ); - - let calculationMultiplier = 1.0; - // very expensive calculations reduce the amount per thread - if ( - this._config.tryLimitWastedStats && - this._config.modOptimizationStrategy != ModOptimizationStrategy.None - ) { - calculationMultiplier = 0.7; - } - - let minimumCalculationPerThread = calculationMultiplier * 5e4; - let maximumCalculationPerThread = calculationMultiplier * 2.5e5; - - const nthreads = Math.max( - 3, // Enforce a minimum of 3 threads - Math.min( - Math.max(1, Math.ceil(estimatedCalculations / minimumCalculationPerThread)), - Math.ceil(estimatedCalculations / maximumCalculationPerThread), - Math.floor((navigator.hardwareConcurrency || 2) * 0.75), // limit it to the amount of cores, and only use 75% - 20, // limit it to a maximum of 20 threads - largestArmorBucket // limit it to the largest armor bucket, as we will split the work by this value - ) - ); - - return nthreads; - } - - async getItemCountForClass(clazz: DestinyClass, slot?: ArmorSlot) { - let pieces = await this.db.inventoryArmor.where("clazz").equals(clazz).toArray(); - if (!!slot) pieces = pieces.filter((i) => i.slot == slot); - //if (!this._config.includeVendorRolls) pieces = pieces.filter((i) => i.source != InventoryArmorSource.Vendor); - //if (!this._config.includeCollectionRolls) pieces = pieces.filter((i) => i.source != InventoryArmorSource.Collections); - pieces = pieces.filter((i) => i.source == InventoryArmorSource.Inventory); - return pieces.length; - } - - async getExoticsForClass(clazz: DestinyClass, slot?: ArmorSlot): Promise { - let inventory = await this.db.inventoryArmor.where("isExotic").equals(1).toArray(); - inventory = inventory.filter( - (d) => - d.clazz == clazz && - (d.armorSystem == ArmorSystem.Armor2 || d.armorSystem == ArmorSystem.Armor3) && - (!slot || d.slot == slot) - ); - - let exotics = await this.db.manifestArmor.where("isExotic").equals(1).toArray(); - exotics = exotics.filter( - (d) => - d.clazz == clazz && - (d.armorSystem == ArmorSystem.Armor2 || d.armorSystem == ArmorSystem.Armor3) && - (!slot || d.slot == slot) - ); - - return exotics - .map((ex) => { - const instances = inventory.filter((i) => i.hash == ex.hash); - return { - items: [ex], - instances: instances, - inCollection: - instances.find((i) => i.source === InventoryArmorSource.Collections) !== undefined, - inInventory: - instances.find((i) => i.source === InventoryArmorSource.Inventory) !== undefined, - inVendor: instances.find((i) => i.source === InventoryArmorSource.Vendor) !== undefined, - }; - }) - .reduce((acc: ClassExoticInfo[], curr: ClassExoticInfo) => { - const existing = acc.find((e) => e.items[0].name === curr.items[0].name); - if (existing) { - existing.items.push(curr.items[0]); - existing.instances.push(...curr.instances); - existing.inCollection = existing.inCollection || curr.inCollection; - existing.inInventory = existing.inInventory || curr.inInventory; - existing.inVendor = existing.inVendor || curr.inVendor; - } else { - acc.push({ ...curr, items: [curr.items[0]] }); - } - return acc; - }, [] as ClassExoticInfo[]) - .sort((x, y) => x.items[0].name.localeCompare(y.items[0].name)); - } - - async updateManifest(force: boolean = false): Promise { - if (this.status.getStatus().updatingManifest) { - this.logger.error( - "InventoryService", - "updateManifest", - "Already updating the manifest - abort" - ); - return false; - } - this.status.modifyStatus((s) => (s.updatingManifest = true)); - let r = await this.api.updateManifest(force).finally(() => { - this.status.modifyStatus((s) => (s.updatingManifest = false)); - }); - if (!!r) this._manifest.next(null); - return !!r; - } - - async updateInventoryItems(force: boolean = false, errorLoop = 0): Promise { - this.status.modifyStatus((s) => (s.updatingInventory = true)); - - try { - let r = await this.api.updateArmorItems(force).finally(() => { - this.status.modifyStatus((s) => (s.updatingInventory = false)); - }); - return !!r; - } catch (e) { - // After three tries, call it a day. - if (errorLoop > 3) { - alert( - "You encountered a strange error with the inventory update. Please log out and log in again. If that does not fix it, please message Mijago." - ); - return false; - } - - this.status.modifyStatus((s) => (s.updatingInventory = false)); - this.logger.error("InventoryService", "updateInventoryItems", "Error: " + e); - - await this.status.setApiError(); - - //await this.updateManifest(true); - //return await this.updateInventoryItems(true, errorLoop++); - return false; - } - } - - get isInitialized(): boolean { - return this.initialized; - } -} diff --git a/src/app/services/item-icon-service.service.ts b/src/app/services/item-icon-service.service.ts index ba5be554..d3f9198f 100644 --- a/src/app/services/item-icon-service.service.ts +++ b/src/app/services/item-icon-service.service.ts @@ -20,7 +20,7 @@ import { DatabaseService } from "./database.service"; import { IManifestArmor } from "../data/types/IManifestArmor"; import { DestinySandboxPerkDefinition } from "bungie-api-ts/destiny2"; import { BehaviorSubject } from "rxjs"; - +import { LoggingProxyService } from "./logging-proxy.service"; export interface ItemIconData { icon: string | undefined; watermark: string | undefined; @@ -36,19 +36,32 @@ export class ItemIconServiceService { private sandboxperkIconLookup = new Map(); private gearsetIconLookup = new Map(); - constructor(private db: DatabaseService) {} + constructor( + private db: DatabaseService, + private logger: LoggingProxyService + ) {} async getItemCached(hash: number): Promise { if (this.itemLookup.has(hash)) - return new Promise((resolve) => { + return new Promise((resolve, reject) => { this.itemLookup .get(hash)! .asObservable() - .subscribe((item) => { - if (item) { - resolve(item); - return; - } + .subscribe({ + next: (item) => { + if (item) { + resolve(item); + return; + } + }, + error: (error) => { + this.logger.error( + "ItemIconServiceService", + "getItemCached", + "Error fetching item from cache: " + error + ); + reject(error); + }, }); }); diff --git a/src/app/services/logging-proxy.service.ts b/src/app/services/logging-proxy.service.ts new file mode 100644 index 00000000..2c6564c5 --- /dev/null +++ b/src/app/services/logging-proxy.service.ts @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2023 D2ArmorPicker by Mijago. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { Injectable } from "@angular/core"; +import { NGXLogger, NgxLoggerLevel } from "ngx-logger"; +import { BehaviorSubject, Observable } from "rxjs"; + +export interface LogEntry { + level: NgxLoggerLevel; + message: string; + timestamp: Date; + additional?: any[]; +} + +/** + * LoggingProxy service that wraps NGXLogger and allows preprocessing of log messages + * before they are sent to the underlying logger. + */ +@Injectable({ + providedIn: "root", +}) +export class LoggingProxyService { + private recentLogs: LogEntry[] = []; + private maxRecentLogs = 5; + private logsSubject = new BehaviorSubject([]); + + constructor(private ngxLogger: NGXLogger) {} + + getRecentLogs(): Observable { + return this.logsSubject.asObservable(); + } + + private addToRecentLogs(level: NgxLoggerLevel, message: any, ...additional: any[]): void { + let fullMessage = ""; + if (typeof message === "string") { + fullMessage += message; + } else { + fullMessage += JSON.stringify(message); + } + if (additional && additional.length > 0) { + fullMessage += + " " + + additional + .map((item) => (typeof item === "string" ? item : JSON.stringify(item))) + .join(" "); + } + const logEntry: LogEntry = { + level, + message: fullMessage, + timestamp: new Date(), + }; + + this.recentLogs.unshift(logEntry); + if (this.recentLogs.length > this.maxRecentLogs) { + this.recentLogs.pop(); + } + + // Defer the emission to the next tick to avoid ExpressionChangedAfterItHasBeenCheckedError + Promise.resolve().then(() => { + this.logsSubject.next([...this.recentLogs]); + }); + } + + clearRecentLogs(): void { + this.recentLogs = []; + // Defer the emission to the next tick to avoid ExpressionChangedAfterItHasBeenCheckedError + Promise.resolve().then(() => { + this.logsSubject.next([]); + }); + } + + setMaxRecentLogs(max: number): void { + this.maxRecentLogs = Math.max(1, max); + while (this.recentLogs.length > this.maxRecentLogs) { + this.recentLogs.pop(); + } + // Defer the emission to the next tick to avoid ExpressionChangedAfterItHasBeenCheckedError + Promise.resolve().then(() => { + this.logsSubject.next([...this.recentLogs]); + }); + } + + private beforeLog(level: NgxLoggerLevel, message: any, ...additional: any[]): boolean { + this.addToRecentLogs(level, message, ...additional); + return true; + } + + trace(message: any, ...additional: any[]): void { + if (this.beforeLog(NgxLoggerLevel.TRACE, message, ...additional)) { + this.ngxLogger.trace(message, ...additional); + } + } + + debug(message: any, ...additional: any[]): void { + if (this.beforeLog(NgxLoggerLevel.DEBUG, message, ...additional)) { + this.ngxLogger.debug(message, ...additional); + } + } + + info(message: any, ...additional: any[]): void { + if (this.beforeLog(NgxLoggerLevel.INFO, message, ...additional)) { + this.ngxLogger.info(message, ...additional); + } + } + + warn(message: any, ...additional: any[]): void { + if (this.beforeLog(NgxLoggerLevel.WARN, message, ...additional)) { + this.ngxLogger.warn(message, ...additional); + } + } + + error(message: any, ...additional: any[]): void { + if (this.beforeLog(NgxLoggerLevel.ERROR, message, ...additional)) { + this.ngxLogger.error(message, ...additional); + } + } + + fatal(message: any, ...additional: any[]): void { + if (this.beforeLog(NgxLoggerLevel.FATAL, message, ...additional)) { + this.ngxLogger.fatal(message, ...additional); + } + } + + log(level: NgxLoggerLevel, message: any, ...additional: any[]): void { + if (this.beforeLog(level, message, ...additional)) { + this.ngxLogger.log(level, message, ...additional); + } + } +} diff --git a/src/app/services/membership.service.ts b/src/app/services/membership.service.ts index 6380403e..3a9ed881 100644 --- a/src/app/services/membership.service.ts +++ b/src/app/services/membership.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from "@angular/core"; +import { Injectable, OnDestroy } from "@angular/core"; import { DestinyComponentType, getProfile, @@ -10,123 +10,135 @@ import { GroupUserInfoCard } from "bungie-api-ts/groupv2"; import { getMembershipDataForCurrentUser } from "bungie-api-ts/user"; import { HttpClientService } from "./http-client.service"; import { StatusProviderService } from "./status-provider.service"; -import { NGXLogger } from "ngx-logger"; -import { H } from "highlight.run"; +import { LoggingProxyService } from "./logging-proxy.service"; +import { identifyUserWithTracker } from "../app.module"; +// import { H } from "highlight.run"; @Injectable({ providedIn: "root", }) -export class MembershipService { +export class MembershipService implements OnDestroy { constructor( private http: HttpClientService, private status: StatusProviderService, private auth: AuthService, - private logger: NGXLogger + private logger: LoggingProxyService ) { + this.logger.debug("MembershipService", "constructor", "Initializing MembershipService"); this.auth.logoutEvent.subscribe((k) => this.clearCachedData()); } + ngOnDestroy(): void { + this.logger.debug("MembershipService", "ngOnDestroy", "Destroying MembershipService"); + } + private clearCachedData() { - localStorage.removeItem("auth-membershipInfo"); - localStorage.removeItem("auth-membershipInfo-date"); + this.logger.debug("MembershipService", "clearCachedData", "Clearing cached membership data"); + localStorage.removeItem("user-membershipInfo"); + localStorage.removeItem("user-membershipInfo-lastDate"); } - async getMembershipDataForCurrentUser(): Promise { + async getMembershipDataForCurrentUser(): Promise { + // check if user is authenticated before making the API call, if not, return undefined to avoid unnecessary API calls and errors + if (!this.http.isAuthenticated()) { + this.logger.warn( + "MembershipService", + "getMembershipDataForCurrentUser", + "User is not authenticated" + ); + return undefined; + } var membershipData: GroupUserInfoCard = JSON.parse( - localStorage.getItem("auth-membershipInfo") || "null" + localStorage.getItem("user-membershipInfo") || "null" ); - var membershipDataAge = JSON.parse(localStorage.getItem("auth-membershipInfo-date") || "0"); + var membershipDataAge = JSON.parse(localStorage.getItem("user-membershipInfo-lastDate") || "0"); if (membershipData && Date.now() - membershipDataAge < 1000 * 60 * 60 * 24) { - H.identify(`I${membershipData.membershipId}T${membershipData.membershipType}`, { - highlightDisplayName: `${membershipData.displayName}(I${membershipData.membershipId}T${membershipData.membershipType})`, - avatar: `https://bungie.net${membershipData.iconPath}`, - bungieGlobalDisplayName: membershipData.bungieGlobalDisplayName, - bungieGlobalDisplayNameCode: membershipData.bungieGlobalDisplayNameCode ?? -1, - membershipType: membershipData.membershipType, - applicableMembershipTypes: JSON.stringify(membershipData.applicableMembershipTypes), - }); + identifyUserWithTracker(membershipData); + return membershipData; } - this.logger.info( "MembershipService", "getMembershipDataForCurrentUser", "Fetching membership data for current user" ); let response = await getMembershipDataForCurrentUser((d) => this.http.$http(d, true)); - let memberships = response?.Response.destinyMemberships; - this.logger.info( - "MembershipService", - "getMembershipDataForCurrentUser", - `Memberships: ${JSON.stringify(memberships)}` - ); - memberships = memberships.filter( - (m) => - (m.crossSaveOverride == 0 && - m.membershipType != BungieMembershipType.TigerStadia) /*stadia is dead, ignore it*/ || - m.crossSaveOverride == m.membershipType - ); - this.logger.info( - "MembershipService", - "getMembershipDataForCurrentUser", - `Filtered Memberships: ${JSON.stringify(memberships)}` - ); - - let result = null; - if (memberships?.length == 1) { - // This guardian only has one account linked, so we can proceed as normal - result = memberships?.[0]; - } else { - // This guardian has multiple accounts linked. - // Fetch the last login time for each account, and use the one that was most recently used, default to primaryMembershipId - let lastLoggedInProfileIndex: any = memberships.findIndex( - (x) => x.membershipId == response?.Response.primaryMembershipId + if (response) { + let memberships = response?.Response.destinyMemberships; + this.logger.info( + "MembershipService", + "getMembershipDataForCurrentUser", + `Memberships: ${JSON.stringify(memberships)}` ); - let lastPlayed = 0; - for (let id in memberships) { - const membership = memberships?.[id]; - const profile = await getProfile((d) => this.http.$http(d, false), { - components: [DestinyComponentType.Profiles], - membershipType: membership.membershipType, - destinyMembershipId: membership.membershipId, - }); - if (!!profile && profile.Response?.profile.data?.dateLastPlayed) { - let date = Date.parse(profile.Response?.profile.data?.dateLastPlayed); - if (date > lastPlayed) { - lastPlayed = date; - lastLoggedInProfileIndex = id; + memberships = memberships.filter( + (m) => + (m.crossSaveOverride == 0 && + m.membershipType != BungieMembershipType.TigerStadia) /*stadia is dead, ignore it*/ || + m.crossSaveOverride == m.membershipType + ); + this.logger.info( + "MembershipService", + "getMembershipDataForCurrentUser", + `Filtered Memberships: ${JSON.stringify(memberships)}` + ); + + let result = null; + if (memberships?.length == 1) { + // This guardian only has one account linked, so we can proceed as normal + result = memberships?.[0]; + } else { + // This guardian has multiple accounts linked. + // Fetch the last login time for each account, and use the one that was most recently used, default to primaryMembershipId + let lastLoggedInProfileIndex: any = memberships.findIndex( + (x) => x.membershipId == response?.Response.primaryMembershipId + ); + let lastPlayed = 0; + for (let id in memberships) { + const membership = memberships?.[id]; + const profile = await getProfile((d) => this.http.$http(d, false), { + components: [DestinyComponentType.Profiles], + membershipType: membership.membershipType, + destinyMembershipId: membership.membershipId, + }); + if (!!profile && profile.Response?.profile.data?.dateLastPlayed) { + let date = Date.parse(profile.Response?.profile.data?.dateLastPlayed); + if (date > lastPlayed) { + lastPlayed = date; + lastLoggedInProfileIndex = id; + } } } - } - if (lastLoggedInProfileIndex < 0) { - this.logger.error( + if (lastLoggedInProfileIndex < 0) { + this.logger.error( + "MembershipService", + "getMembershipDataForCurrentUser", + "PrimaryMembershipId was not found" + ); + lastLoggedInProfileIndex = 0; + this.status.setAuthError(); + //this.authService.logout(); + } + result = memberships?.[lastLoggedInProfileIndex]; + this.logger.info( "MembershipService", "getMembershipDataForCurrentUser", - "PrimaryMembershipId was not found" + "Selected membership data for the last logged in membership." ); - lastLoggedInProfileIndex = 0; - this.status.setAuthError(); - //this.authService.logout(); } - result = memberships?.[lastLoggedInProfileIndex]; - this.logger.info( + + localStorage.setItem("user-membershipInfo", JSON.stringify(result)); + localStorage.setItem("user-membershipInfo-lastDate", JSON.stringify(Date.now())); + identifyUserWithTracker(result); + return result; + } else { + this.logger.error( "MembershipService", "getMembershipDataForCurrentUser", - "Selected membership data for the last logged in membership." + "Failed to fetch membership data for current user" ); + if (!this.status.getStatus().apiError) this.status.setApiError(); + return undefined; } - - localStorage.setItem("auth-membershipInfo", JSON.stringify(result)); - localStorage.setItem("auth-membershipInfo-date", JSON.stringify(Date.now())); - H.identify(`I${result.membershipId}T${result.membershipType}`, { - highlightDisplayName: `${result.displayName}(I${result.membershipId}T${result.membershipType})`, - avatar: `https://bungie.net${result.iconPath}`, - bungieGlobalDisplayName: result.bungieGlobalDisplayName, - bungieGlobalDisplayNameCode: result.bungieGlobalDisplayNameCode ?? -1, - membershipType: result.membershipType, - applicableMembershipTypes: JSON.stringify(result.applicableMembershipTypes), - }); - return result; } async getCharacters() { diff --git a/src/app/services/results-builder.worker.spec.ts b/src/app/services/results-builder.worker.spec.ts deleted file mode 100644 index 273a6139..00000000 --- a/src/app/services/results-builder.worker.spec.ts +++ /dev/null @@ -1,784 +0,0 @@ -/* - * Copyright (c) 2023 D2ArmorPicker by Mijago. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -import { getSkillTier, getWaste, handlePermutation } from "./results-builder.worker"; -import { DestinyClass, TierType } from "bungie-api-ts/destiny2"; -import { ArmorSlot } from "../data/enum/armor-slot"; -import { - ArmorPerkOrSlot, - ArmorStat, - ARMORSTAT_ORDER, - STAT_MOD_VALUES, - StatModifier, -} from "../data/enum/armor-stat"; -import { BuildConfiguration } from "../data/buildConfiguration"; -import { IInventoryArmor, InventoryArmorSource } from "../data/types/IInventoryArmor"; -import { IPermutatorArmor } from "../data/types/IPermutatorArmor"; -import { IPermutatorArmorSet } from "../data/types/IPermutatorArmorSet"; -import { - ResultDefinition, - ResultItem, -} from "../components/authenticated-v2/results/results.component"; - -const plugs = [ - [1, 1, 10], - [1, 1, 11], - [1, 1, 12], - [1, 1, 13], - [1, 1, 14], - [1, 1, 15], - [1, 5, 5], - [1, 5, 6], - [1, 5, 7], - [1, 5, 8], - [1, 5, 9], - [1, 5, 10], - [1, 5, 11], - [1, 6, 5], - [1, 6, 6], - [1, 6, 7], - [1, 6, 8], - [1, 6, 9], - [1, 7, 5], - [1, 7, 6], - [1, 7, 7], - [1, 7, 8], - [1, 8, 5], - [1, 8, 6], - [1, 8, 7], - [1, 9, 5], - [1, 9, 6], - [1, 10, 1], - [1, 10, 5], - [1, 11, 1], - [1, 11, 5], - [1, 12, 1], - [1, 13, 1], - [1, 14, 1], - [1, 15, 1], - [5, 1, 5], - [5, 1, 6], - [5, 1, 7], - [5, 1, 8], - [5, 1, 9], - [5, 1, 10], - [5, 1, 11], - [5, 5, 1], - [5, 5, 5], - [5, 6, 1], - [5, 7, 1], - [5, 8, 1], - [5, 9, 1], - [5, 10, 1], - [5, 11, 1], - [6, 1, 5], - [6, 1, 6], - [6, 1, 7], - [6, 1, 8], - [6, 1, 9], - [6, 5, 1], - [6, 6, 1], - [6, 7, 1], - [6, 8, 1], - [6, 9, 1], - [7, 1, 5], - [7, 1, 6], - [7, 1, 7], - [7, 1, 8], - [7, 5, 1], - [7, 6, 1], - [7, 7, 1], - [7, 8, 1], - [8, 1, 5], - [8, 1, 6], - [8, 1, 7], - [8, 5, 1], - [8, 6, 1], - [8, 7, 1], - [9, 1, 5], - [9, 1, 6], - [9, 5, 1], - [9, 6, 1], - [10, 1, 1], - [10, 1, 5], - [10, 5, 1], - [11, 1, 1], - [11, 1, 5], - [11, 5, 1], - [12, 1, 1], - [13, 1, 1], - [14, 1, 1], - [15, 1, 1], -]; - -function buildTestItem( - slot: ArmorSlot, - isExotic: boolean, - stats: number[], - perk: ArmorPerkOrSlot = ArmorPerkOrSlot.Any -): IInventoryArmor { - return { - name: "item_" + slot, - armorSystem: 3, - clazz: DestinyClass.Titan, - source: InventoryArmorSource.Inventory, - description: "", - slot: slot, - mobility: stats[0], - resilience: stats[1], - recovery: stats[2], - discipline: stats[3], - intellect: stats[4], - strength: stats[5], - energyLevel: 10, - hash: 0, - icon: "", - exoticPerkHash: 0, - id: 0, - investmentStats: [], - itemInstanceId: "", - isExotic: isExotic ? 1 : 0, - isSunset: false, - itemType: 0, - itemSubType: 0, - masterworked: true, - perk: perk, - mayBeBugged: false, - rarity: TierType.Superior, - rawData: undefined, - statPlugHashes: [], - socketEntries: [], - watermarkIcon: "", - created_at: Date.now(), - updated_at: Date.now(), - }; -} - -function generateRandomStats() { - // pick 4 random plugs - const randomPlugs = []; - for (let i = 0; i < 4; i++) { - randomPlugs.push(plugs[Math.floor(Math.random() * plugs.length)]); - } - - // calculate the stats - const stats = [ - randomPlugs[0][0] + randomPlugs[1][0], - randomPlugs[0][1] + randomPlugs[1][1], - randomPlugs[0][2] + randomPlugs[1][2], - randomPlugs[2][0] + randomPlugs[3][0], - randomPlugs[2][1] + randomPlugs[3][1], - randomPlugs[2][2] + randomPlugs[3][2], - ]; - return stats; -} - -function randomPerk() { - // pick random number - const random = Math.floor(Math.random() * 100); - if (random < 50) { - return ArmorPerkOrSlot.SlotArtifice; - } - return undefined; -} - -function generateRandomBuild() { - return [ - buildTestItem(ArmorSlot.ArmorSlotHelmet, false, generateRandomStats(), randomPerk()), - buildTestItem(ArmorSlot.ArmorSlotGauntlet, false, generateRandomStats(), randomPerk()), - buildTestItem(ArmorSlot.ArmorSlotChest, false, generateRandomStats(), randomPerk()), - buildTestItem(ArmorSlot.ArmorSlotLegs, false, generateRandomStats(), randomPerk()), - ]; -} - -function buildRuntime() { - return { - maximumPossibleTiers: [0, 0, 0, 0, 0, 0], - }; -} - -describe("Results Worker", () => { - it("should swap mods around to see replace old mods", () => { - // this is an edge case in which the artifice mod, which initially will be applied to - // mobility, must be moved to Recovery. Otherwise, this set would not be possible. - - const runtime = buildRuntime(); - - const mockItems: IInventoryArmor[] = [ - buildTestItem(ArmorSlot.ArmorSlotHelmet, false, [2, 12, 20, 20, 9, 2]), - buildTestItem(ArmorSlot.ArmorSlotGauntlet, false, [2, 30, 2, 26, 6, 2]), - buildTestItem(ArmorSlot.ArmorSlotChest, true, [2, 11, 21, 17, 10, 8]), - buildTestItem(ArmorSlot.ArmorSlotLegs, false, [2, 7, 24, 15, 15, 2]), - ]; - - const config = new BuildConfiguration(); - config.minimumStatTiers[ArmorStat.StatWeapon].value = 2; - config.minimumStatTiers[ArmorStat.StatHealth].value = 10; - config.minimumStatTiers[ArmorStat.StatClass].value = 8; - config.minimumStatTiers[ArmorStat.StatGrenade].value = 9; - config.minimumStatTiers[ArmorStat.StatSuper].value = 5; - config.minimumStatTiers[ArmorStat.StatMelee].value = 2; - - let presult = handlePermutation( - runtime, - config, - mockItems[0] as IPermutatorArmor, - mockItems[1] as IPermutatorArmor, - mockItems[2] as IPermutatorArmor, - mockItems[3] as IPermutatorArmor, - [0, 0, 0, 0, 0, 0], // constant bonus - [5, 5, 5, 1, 1], // availableModCost - false, // doNotOutput - true, // hasArtificeClassItem, - true // and masterwoked class item - ) as IPermutatorArmorSet; - let result = CreateResultDefinition(presult, mockItems); - expect(result).toBeDefined(); - expect(result.mods.length).toEqual(5); - expect(result.artifice.length).toEqual(1); - expect(result.stats[0]).toBeGreaterThanOrEqual( - config.minimumStatTiers[ArmorStat.StatWeapon].value * 10 - ); - expect(result.stats[1]).toBeGreaterThanOrEqual( - config.minimumStatTiers[ArmorStat.StatHealth].value * 10 - ); - expect(result.stats[2]).toBeGreaterThanOrEqual( - config.minimumStatTiers[ArmorStat.StatClass].value * 10 - ); - expect(result.stats[3]).toBeGreaterThanOrEqual( - config.minimumStatTiers[ArmorStat.StatGrenade].value * 10 - ); - expect(result.stats[4]).toBeGreaterThanOrEqual( - config.minimumStatTiers[ArmorStat.StatSuper].value * 10 - ); - expect(result.stats[5]).toBeGreaterThanOrEqual( - config.minimumStatTiers[ArmorStat.StatMelee].value * 10 - ); - }); - it("should swap 3x artifice mods around to replace old mods", () => { - // this is an edge case in which the artifice mod, which initially will be applied to - // mobility, must be moved to Recovery. Otherwise, this set would not be possible. - - const runtime = buildRuntime(); - - const mockItems: IInventoryArmor[] = [ - buildTestItem(ArmorSlot.ArmorSlotHelmet, true, [6, 27, 3, 19, 7, 6]), - buildTestItem( - ArmorSlot.ArmorSlotGauntlet, - false, - [2, 10, 21, 24, 2, 7], - ArmorPerkOrSlot.SlotArtifice - ), - buildTestItem( - ArmorSlot.ArmorSlotChest, - false, - [6, 2, 23, 28, 2, 2], - ArmorPerkOrSlot.SlotArtifice - ), - buildTestItem( - ArmorSlot.ArmorSlotLegs, - false, - [11, 12, 10, 21, 8, 2], - ArmorPerkOrSlot.SlotArtifice - ), - ]; - - const config = new BuildConfiguration(); - config.minimumStatTiers[ArmorStat.StatWeapon].value = 6; - config.minimumStatTiers[ArmorStat.StatHealth].value = 6; - config.minimumStatTiers[ArmorStat.StatClass].value = 10; - config.minimumStatTiers[ArmorStat.StatGrenade].value = 10; - config.minimumStatTiers[ArmorStat.StatSuper].value = 0; - config.minimumStatTiers[ArmorStat.StatMelee].value = 0; - - let presult = handlePermutation( - runtime, - config, - mockItems[0] as IPermutatorArmor, - mockItems[1] as IPermutatorArmor, - mockItems[2] as IPermutatorArmor, - mockItems[3] as IPermutatorArmor, - [0, 0, 0, 0, 0, 0], // constant bonus - [5, 5, 5, 5, 5], // availableModCost - false, // doNotOutput - true, // hasArtificeClassItem - true // and masterwoked class item - ) as IPermutatorArmorSet; - let result = CreateResultDefinition(presult, mockItems); - expect(result).toBeDefined(); - expect(result.stats[0]).toBeGreaterThanOrEqual( - config.minimumStatTiers[ArmorStat.StatWeapon].value * 10 - ); - expect(result.stats[1]).toBeGreaterThanOrEqual( - config.minimumStatTiers[ArmorStat.StatHealth].value * 10 - ); - expect(result.stats[2]).toBeGreaterThanOrEqual( - config.minimumStatTiers[ArmorStat.StatClass].value * 10 - ); - expect(result.stats[3]).toBeGreaterThanOrEqual( - config.minimumStatTiers[ArmorStat.StatGrenade].value * 10 - ); - expect(result.stats[4]).toBeGreaterThanOrEqual( - config.minimumStatTiers[ArmorStat.StatSuper].value * 10 - ); - expect(result.stats[5]).toBeGreaterThanOrEqual( - config.minimumStatTiers[ArmorStat.StatMelee].value * 10 - ); - }); - it("should swap 3x artifice mods around to replace old mods v2", () => { - // this is an edge case in which the artifice mod, which initially will be applied to - // mobility, must be moved to Recovery. Otherwise, this set would not be possible. - - const runtime = buildRuntime(); - - const mockItems: IInventoryArmor[] = [ - buildTestItem( - ArmorSlot.ArmorSlotHelmet, - false, - [13, 16, 2, 24, 2, 7], - ArmorPerkOrSlot.SlotArtifice - ), - buildTestItem( - ArmorSlot.ArmorSlotGauntlet, - false, - [26, 6, 2, 26, 2, 2], - ArmorPerkOrSlot.SlotArtifice - ), - buildTestItem(ArmorSlot.ArmorSlotChest, true, [6, 24, 2, 17, 7, 7]), - buildTestItem( - ArmorSlot.ArmorSlotLegs, - false, - [22, 9, 2, 24, 2, 6], - ArmorPerkOrSlot.SlotArtifice - ), - ]; - - const config = new BuildConfiguration(); - config.minimumStatTiers[ArmorStat.StatWeapon].value = 9; - config.minimumStatTiers[ArmorStat.StatHealth].value = 10; - config.minimumStatTiers[ArmorStat.StatClass].value = 0; - config.minimumStatTiers[ArmorStat.StatGrenade].value = 10; - config.minimumStatTiers[ArmorStat.StatSuper].value = 0; - config.minimumStatTiers[ArmorStat.StatMelee].value = 0; - - const constantBonus = [-10, 0, 10, 0, 0, -10]; - let presult = handlePermutation( - runtime, - config, - mockItems[0] as IPermutatorArmor, - mockItems[1] as IPermutatorArmor, - mockItems[2] as IPermutatorArmor, - mockItems[3] as IPermutatorArmor, - constantBonus, // constant bonus - [5, 5, 5, 5, 5], // availableModCost - false, // doNotOutput - true, // hasArtificeClassItem - true // and masterwoked class item - ) as IPermutatorArmorSet; - let result = CreateResultDefinition(presult, mockItems); - expect(result).toBeDefined(); - console.log(result); - expect(result.stats[0]).toBeGreaterThanOrEqual( - config.minimumStatTiers[ArmorStat.StatWeapon].value * 10 - ); - expect(result.stats[1]).toBeGreaterThanOrEqual( - config.minimumStatTiers[ArmorStat.StatHealth].value * 10 - ); - expect(result.stats[2]).toBeGreaterThanOrEqual( - config.minimumStatTiers[ArmorStat.StatClass].value * 10 - ); - expect(result.stats[3]).toBeGreaterThanOrEqual( - config.minimumStatTiers[ArmorStat.StatGrenade].value * 10 - ); - expect(result.stats[4]).toBeGreaterThanOrEqual( - config.minimumStatTiers[ArmorStat.StatSuper].value * 10 - ); - expect(result.stats[5]).toBeGreaterThanOrEqual( - config.minimumStatTiers[ArmorStat.StatMelee].value * 10 - ); - - for (let n = 0; n < 6; n++) { - const minor = - 1 * result.mods.filter((mod: number) => Math.floor(mod / 3) == n && mod % 3 == 1).length; - const major = - 1 * result.mods.filter((mod: number) => Math.floor(mod / 3) == n && mod % 3 == 2).length; - const artif = - 1 * - result.artifice.filter((mod: number) => Math.floor(mod / 3) - 1 == n && mod % 3 == 0) - .length; - expect(result.stats[n]).toEqual( - result.statsNoMods[n] + 5 * minor + 10 * major + 3 * artif + constantBonus[n] - ); - } - }); - - it("should be able to keep plain zero-waste builds", () => { - const runtime = buildRuntime(); - - const mockItems: IInventoryArmor[] = [ - buildTestItem(ArmorSlot.ArmorSlotHelmet, false, [8, 9, 16, 23, 2, 8]), - buildTestItem(ArmorSlot.ArmorSlotGauntlet, false, [2, 9, 20, 26, 6, 2]), - buildTestItem(ArmorSlot.ArmorSlotChest, true, [7, 2, 23, 21, 10, 2]), - buildTestItem(ArmorSlot.ArmorSlotLegs, false, [3, 20, 11, 20, 2, 8]), - ]; - - const config = BuildConfiguration.buildEmptyConfiguration(); - config.tryLimitWastedStats = true; - config.onlyShowResultsWithNoWastedStats = true; - - let result = handlePermutation( - runtime, - config, - mockItems[0] as IPermutatorArmor, - mockItems[1] as IPermutatorArmor, - mockItems[2] as IPermutatorArmor, - mockItems[3] as IPermutatorArmor, - [0, 0, 0, 0, 0, 0], // constant bonus - [5, 5, 5, 5, 5], // availableModCost - false, // doNotOutput - true, // hasArtificeClassItem - true // and masterwoked class item - ); - expect(result).toBeDefined(); - expect(result).not.toBeNull(); - }); - - it("should be able to solve complex zero-waste builds", () => { - // this is an edge case in which the artifice mod, which initially will be applied to - // mobility, must be moved to Recovery. Otherwise, this set would not be possible. - - const runtime = buildRuntime(); - - const mockItems: IInventoryArmor[] = [ - buildTestItem(ArmorSlot.ArmorSlotHelmet, false, [8, 9, 16, 23, 2, 8]), - buildTestItem( - ArmorSlot.ArmorSlotGauntlet, - false, - [2, 9, 20, 26, 6, 2], - ArmorPerkOrSlot.SlotArtifice - ), - buildTestItem( - ArmorSlot.ArmorSlotChest, - false, - [7, 2, 23, 21, 10, 2], - ArmorPerkOrSlot.SlotArtifice - ), - buildTestItem(ArmorSlot.ArmorSlotLegs, true, [3, 20, 11, 20, 2, 8]), - ]; - - // the numbers currently sum to 0; now we artifically reduce them to enforce wasted stats calculation - mockItems[0].mobility -= 0; - mockItems[0].resilience -= 5 + 3 + 3; // minor mod + two artifice mods - mockItems[0].recovery -= 5; // minor mod - mockItems[0].discipline -= 5; // minor mod - mockItems[0].intellect -= 5; // minor mod - mockItems[0].strength -= 5 + 3; // minor mod + artifice mod - - const config = new BuildConfiguration(); - config.tryLimitWastedStats = true; - config.onlyShowResultsWithNoWastedStats = true; - - let presult = handlePermutation( - runtime, - config, - mockItems[0] as IPermutatorArmor, - mockItems[1] as IPermutatorArmor, - mockItems[2] as IPermutatorArmor, - mockItems[3] as IPermutatorArmor, - [0, 0, 0, 0, 0, 0], // constant bonus - [5, 5, 5, 5, 5], // availableModCost - false, // doNotOutput - true, // hasArtificeClassItem - true // and masterwoked class item - ) as IPermutatorArmorSet; - let result = CreateResultDefinition(presult, mockItems); - expect(result).toBeDefined(); - expect(result).not.toBeNull(); - expect(result.waste).toEqual(0); - }); - - it("should be able to give correct build presets", () => { - // this is an edge case in which the artifice mod, which initially will be applied to - // mobility, must be moved to Recovery. Otherwise, this set would not be possible. - - for (let n = 0; n < 10000; n++) { - let runtime = buildRuntime(); - const mockItems = generateRandomBuild(); - - const config = new BuildConfiguration(); - config.tryLimitWastedStats = true; - //config.onlyShowResultsWithNoWastedStats = true - - const constantBonus1 = [0, 0, 0, 0, 0, 0]; - let availableModCost = [ - // random 0-5 - Math.floor(Math.random() * 6), - Math.floor(Math.random() * 6), - Math.floor(Math.random() * 6), - Math.floor(Math.random() * 6), - Math.floor(Math.random() * 6), - ]; - availableModCost = [5, 5, 5, 5, 5]; - handlePermutation( - runtime, - config, - mockItems[0] as IPermutatorArmor, - mockItems[1] as IPermutatorArmor, - mockItems[2] as IPermutatorArmor, - mockItems[3] as IPermutatorArmor, - constantBonus1, - availableModCost, - false, - true, // hasArtificeClassItem - true // and masterwoked class item - ); - - // grab the runtime.maximumPossibleTiers and iterate over them to see if it correctly fills them - // first, pick a random order - const order = ARMORSTAT_ORDER.sort(() => Math.random() - 0.5); - - for (let statId of order) { - config.minimumStatTiers[statId as ArmorStat].value = - runtime.maximumPossibleTiers[statId] / 10; - - runtime = buildRuntime(); - let presult = handlePermutation( - runtime, - config, - mockItems[0] as IPermutatorArmor, - mockItems[1] as IPermutatorArmor, - mockItems[2] as IPermutatorArmor, - mockItems[3] as IPermutatorArmor, - constantBonus1, - availableModCost, - false, - true, // hasArtificeClassItem - true // and masterwoked class item - ) as IPermutatorArmorSet; - let result = CreateResultDefinition(presult, mockItems); - expect(result).toBeDefined(); - expect(result).not.toBeNull(); - expect(result.mods.length).toBeLessThanOrEqual(5); - if (!result) { - console.log("Failed to find a build with minimumStatTiers", config.minimumStatTiers); - console.log("RUN", n); - console.log("availableModCost", availableModCost); - console.log("base stats", [ - 10 + - mockItems[0].mobility + - mockItems[1].mobility + - mockItems[2].mobility + - mockItems[3].mobility, - 10 + - mockItems[0].resilience + - mockItems[1].resilience + - mockItems[2].resilience + - mockItems[3].resilience, - 10 + - mockItems[0].recovery + - mockItems[1].recovery + - mockItems[2].recovery + - mockItems[3].recovery, - 10 + - mockItems[0].discipline + - mockItems[1].discipline + - mockItems[2].discipline + - mockItems[3].discipline, - 10 + - mockItems[0].intellect + - mockItems[1].intellect + - mockItems[2].intellect + - mockItems[3].intellect, - 10 + - mockItems[0].strength + - mockItems[1].strength + - mockItems[2].strength + - mockItems[3].strength, - ]); - console.log("target stats", [ - config.minimumStatTiers[ArmorStat.StatWeapon].value * 10, - config.minimumStatTiers[ArmorStat.StatHealth].value * 10, - config.minimumStatTiers[ArmorStat.StatClass].value * 10, - config.minimumStatTiers[ArmorStat.StatGrenade].value * 10, - config.minimumStatTiers[ArmorStat.StatSuper].value * 10, - config.minimumStatTiers[ArmorStat.StatMelee].value * 10, - ]); - console.log( - "Available artifice mods", - mockItems.map((item) => (item.perk > 0 ? 1 : 0)).reduce((a, b) => a + b, 0 as number) - ); - console.log("------------------------------------------------------------------------"); - console.log("------------------------------------------------------------------------"); - console.log("------------------------------------------------------------------------"); - break; - } - } - } - }); - - it("should swap mods around", () => { - // this is an edge case in which the artifice mod, which initially will be applied to - // mobility, must be moved to Recovery. Otherwise, this set would not be possible. - - const runtime = buildRuntime(); - - const mockItems: IInventoryArmor[] = [ - buildTestItem(ArmorSlot.ArmorSlotHelmet, false, [13, 14, 4, 17, 9, 8]), - buildTestItem(ArmorSlot.ArmorSlotGauntlet, false, [8, 16, 11, 22, 4, 14]), - buildTestItem(ArmorSlot.ArmorSlotChest, true, [9, 13, 10, 18, 4, 8]), - buildTestItem(ArmorSlot.ArmorSlotLegs, false, [19, 4, 9, 12, 4, 17]), - ]; - - const config = new BuildConfiguration(); - config.assumeLegendariesMasterworked = true; - config.assumeExoticsMasterworked = true; - config.minimumStatTiers[ArmorStat.StatWeapon].value = 0; - config.minimumStatTiers[ArmorStat.StatHealth].value = 9; - config.minimumStatTiers[ArmorStat.StatClass].value = 6; - config.minimumStatTiers[ArmorStat.StatGrenade].value = 7; - config.minimumStatTiers[ArmorStat.StatSuper].value = 0; - config.minimumStatTiers[ArmorStat.StatMelee].value = 0; - - // calculate the stat sum of mockItems - const statSum = [ - mockItems[0].mobility + mockItems[1].mobility + mockItems[2].mobility + mockItems[3].mobility, - mockItems[0].resilience + - mockItems[1].resilience + - mockItems[2].resilience + - mockItems[3].resilience, - mockItems[0].recovery + mockItems[1].recovery + mockItems[2].recovery + mockItems[3].recovery, - mockItems[0].discipline + - mockItems[1].discipline + - mockItems[2].discipline + - mockItems[3].discipline, - mockItems[0].intellect + - mockItems[1].intellect + - mockItems[2].intellect + - mockItems[3].intellect, - mockItems[0].strength + mockItems[1].strength + mockItems[2].strength + mockItems[3].strength, - ]; - console.log("statSum", statSum); - - //const constantBonus = [-10, -10, -10, -10, -10, -10]; - const constantBonus = [0, 0, 0, 0, 0, 0]; - let presult = handlePermutation( - runtime, - config, - mockItems[0] as IPermutatorArmor, - mockItems[1] as IPermutatorArmor, - mockItems[2] as IPermutatorArmor, - mockItems[3] as IPermutatorArmor, - constantBonus, // constant bonus - [5, 5, 5, 5, 5], // availableModCost - false, // doNotOutput - true, // hasArtificeClassItem - true // and masterwoked class item - ) as IPermutatorArmorSet; - let result = CreateResultDefinition(presult, mockItems); - expect(result).toBeDefined(); - console.log(result); - expect(result.mods.length).toBeLessThanOrEqual(5); - expect(result.stats[0]).toBeGreaterThanOrEqual( - config.minimumStatTiers[ArmorStat.StatWeapon].value * 10 - ); - expect(result.stats[1]).toBeGreaterThanOrEqual( - config.minimumStatTiers[ArmorStat.StatHealth].value * 10 - ); - expect(result.stats[2]).toBeGreaterThanOrEqual( - config.minimumStatTiers[ArmorStat.StatClass].value * 10 - ); - expect(result.stats[3]).toBeGreaterThanOrEqual( - config.minimumStatTiers[ArmorStat.StatGrenade].value * 10 - ); - expect(result.stats[4]).toBeGreaterThanOrEqual( - config.minimumStatTiers[ArmorStat.StatSuper].value * 10 - ); - expect(result.stats[5]).toBeGreaterThanOrEqual( - config.minimumStatTiers[ArmorStat.StatMelee].value * 10 - ); - - for (let n = 0; n < 6; n++) { - const minor = - 1 * result.mods.filter((mod: number) => Math.floor(mod / 3) == n && mod % 3 == 1).length; - const major = - 1 * result.mods.filter((mod: number) => Math.floor(mod / 3) == n && mod % 3 == 2).length; - const artif = - 1 * - result.artifice.filter((mod: number) => Math.floor(mod / 3) - 1 == n && mod % 3 == 0) - .length; - expect(result.stats[n]).toEqual( - result.statsNoMods[n] + 5 * minor + 10 * major + 3 * artif + constantBonus[n] - ); - } - }); -}); - -function CreateResultDefinition( - armorSet: IPermutatorArmorSet, - items: IInventoryArmor[] -): ResultDefinition { - let exotic = items.find((x) => x.isExotic); - - if (armorSet == null) { - console.error("ArmorSet is null", items); - } - - return { - exotic: - exotic == null - ? undefined - : { - icon: exotic?.icon, - watermark: exotic?.watermarkIcon, - name: exotic?.name, - hash: exotic?.hash, - }, - artifice: armorSet.usedArtifice, - modCount: armorSet.usedMods.length, - modCost: armorSet.usedMods.reduce((p, d: StatModifier) => p + STAT_MOD_VALUES[d][2], 0), - mods: armorSet.usedMods, - stats: armorSet.statsWithMods, - statsNoMods: armorSet.statsWithoutMods, - tiers: getSkillTier(armorSet.statsWithMods), - waste: getWaste(armorSet.statsWithMods), - items: items.map( - (instance): ResultItem => ({ - energyLevel: instance.energyLevel, - hash: instance.hash, - itemInstanceId: instance.itemInstanceId, - name: instance.name, - exotic: !!instance.isExotic, - masterworked: instance.masterworked, - slot: instance.slot, - perk: instance.perk, - transferState: 0, // TRANSFER_NONE - stats: [ - instance.mobility, - instance.resilience, - instance.recovery, - instance.discipline, - instance.intellect, - instance.strength, - ], - source: instance.source, - statsNoMods: [], - }) - ), - usesCollectionRoll: items.some((v) => v.source === InventoryArmorSource.Collections), - usesVendorRoll: items.some((v) => v.source === InventoryArmorSource.Vendor), - } as ResultDefinition; -} diff --git a/src/app/services/results-builder.worker.ts b/src/app/services/results-builder.worker.ts index 9d49116a..98f1e3cf 100644 --- a/src/app/services/results-builder.worker.ts +++ b/src/app/services/results-builder.worker.ts @@ -17,9 +17,13 @@ // region Imports import { BuildConfiguration, FixableSelection } from "../data/buildConfiguration"; -import { IDestinyArmor, InventoryArmorSource } from "../data/types/IInventoryArmor"; +import { IDestinyArmor } from "../data/types/IInventoryArmor"; import { ArmorSlot } from "../data/enum/armor-slot"; -import { FORCE_USE_ANY_EXOTIC, MAXIMUM_MASTERWORK_LEVEL } from "../data/constants"; +import { + FORCE_USE_ANY_EXOTIC, + FORCE_USE_NO_EXOTIC, + MAXIMUM_MASTERWORK_LEVEL, +} from "../data/constants"; import { ModInformation } from "../data/ModInformation"; import { ArmorPerkOrSlot, @@ -32,54 +36,136 @@ import { import { environment } from "../../environments/environment"; -import { precalculatedZeroWasteModCombinations } from "../data/generated/precalculatedZeroWasteModCombinations"; -import { precalculatedModCombinations } from "../data/generated/precalculatedModCombinations"; -import { ModOptimizationStrategy } from "../data/enum/mod-optimization-strategy"; import { IPermutatorArmor } from "../data/types/IPermutatorArmor"; -import { - IPermutatorArmorSet, - createArmorSet, - isIPermutatorArmorSet, -} from "../data/types/IPermutatorArmorSet"; +import { IPermutatorArmorSet, Tuning, createArmorSet } from "../data/types/IPermutatorArmorSet"; import { ArmorSystem } from "../data/types/IManifestArmor"; + +import { precalculatedTuningModCombinations } from "../data/generated/precalculatedModCombinationsWithTunings"; + // endregion Imports +let runtime: { + maximumPossibleTiers: number[]; +} = { + maximumPossibleTiers: [0, 0, 0, 0, 0, 0], +}; + +// Cancellation flag, controlled via messages from the main thread +let cancelRequested = false; + +// Module-level configuration to avoid passing around +let assumeEveryLegendaryIsArtifice: boolean; +let assumeEveryExoticIsArtifice: boolean; +let assumeClassItemIsArtifice: boolean; +let calculateTierFiveTuning: boolean; +let onlyShowResultsWithNoWastedStats: boolean; +let tryLimitWastedStats: boolean; +let addConstent1Health: boolean; +let assumeExoticsMasterworked: boolean; +let assumeLegendariesMasterworked: boolean; +let maxMajorMods: number; +let maxMods: number; +let minimumStatTierValues: number[]; + +// Module-level constants for performance +let enabledModBonuses: number[]; +let requiredPerkSlotCounts: Map; +let targetVals: number[]; +let targetFixed: boolean[]; +let possibleIncreaseByMod: number; +let resultLimitReached: boolean = false; + +type t5Improvement = { + tuningStat: ArmorStat; + archetypeStats: ArmorStat[]; +}; + +function isT5WithTuning(i: IPermutatorArmor): boolean { + return ( + i.armorSystem == ArmorSystem.Armor3 && + i.tier >= 5 && + i.archetypeStats && + i.tuningStat !== undefined + ); +} + +function mapItemToTuning(i: IPermutatorArmor): t5Improvement { + return { + tuningStat: i.tuningStat!, + archetypeStats: i.archetypeStats, + }; +} + +/** + * Applies masterwork stat bonuses to the stats array and returns whether the item + * counts as an artifice slot. Combines two operations that were previously separate + * loops over an items array. Uses direct index comparisons instead of .includes() + * for archetypeStats (always exactly 3 elements). + */ +function applyMWAndCheckArtifice(item: IPermutatorArmor, stats: number[]): boolean { + if (item.armorSystem === ArmorSystem.Armor2) { + if ( + item.masterworkLevel === MAXIMUM_MASTERWORK_LEVEL || + (item.isExotic ? assumeExoticsMasterworked : assumeLegendariesMasterworked) + ) { + stats[0] += 2; + stats[1] += 2; + stats[2] += 2; + stats[3] += 2; + stats[4] += 2; + stats[5] += 2; + } + return ( + item.perk === ArmorPerkOrSlot.SlotArtifice || + (item.isExotic ? assumeEveryExoticIsArtifice : assumeEveryLegendaryIsArtifice) + ); + } + if (item.armorSystem === ArmorSystem.Armor3) { + let mult = item.masterworkLevel; + if (item.isExotic ? assumeExoticsMasterworked : assumeLegendariesMasterworked) + mult = MAXIMUM_MASTERWORK_LEVEL; + if (mult > 0) { + const a = item.archetypeStats; + const a0 = a[0], + a1 = a[1], + a2 = a[2]; + if (a0 !== 0 && a1 !== 0 && a2 !== 0) stats[0] += mult; + if (a0 !== 1 && a1 !== 1 && a2 !== 1) stats[1] += mult; + if (a0 !== 2 && a1 !== 2 && a2 !== 2) stats[2] += mult; + if (a0 !== 3 && a1 !== 3 && a2 !== 3) stats[3] += mult; + if (a0 !== 4 && a1 !== 4 && a2 !== 4) stats[4] += mult; + if (a0 !== 5 && a1 !== 5 && a2 !== 5) stats[5] += mult; + } + return item.perk === ArmorPerkOrSlot.SlotArtifice; + } + return item.perk === ArmorPerkOrSlot.SlotArtifice; +} // region Validation and Preparation Functions function checkSlots( - config: BuildConfiguration, - constantModslotRequirement: Map, - availableClassItemTypes: Set, helmet: IPermutatorArmor, gauntlet: IPermutatorArmor, chest: IPermutatorArmor, - leg: IPermutatorArmor -) { - let requirements = new Map(constantModslotRequirement); - const slots = [ - { slot: ArmorSlot.ArmorSlotHelmet, item: helmet }, - { slot: ArmorSlot.ArmorSlotGauntlet, item: gauntlet }, - { slot: ArmorSlot.ArmorSlotChest, item: chest }, - { slot: ArmorSlot.ArmorSlotLegs, item: leg }, - ]; + leg: IPermutatorArmor, + classItem: IPermutatorArmor +): boolean { + let requirements = new Map(requiredPerkSlotCounts); + const items = [helmet, gauntlet, chest, leg, classItem]; + + for (let item of items) { + let effectivePerk = item.perk; - for (let { item } of slots) { if (item.armorSystem === ArmorSystem.Armor2) { if ( - (item.isExotic && config.assumeEveryLegendaryIsArtifice) || - (!item.isExotic && config.assumeEveryLegendaryIsArtifice) || + (item.isExotic && assumeEveryExoticIsArtifice) || (!item.isExotic && - item.slot == ArmorSlot.ArmorSlotClass && - config.assumeClassItemIsArtifice) + (assumeEveryLegendaryIsArtifice || + (item.slot == ArmorSlot.ArmorSlotClass && assumeClassItemIsArtifice))) ) { - requirements.set( - ArmorPerkOrSlot.SlotArtifice, - (requirements.get(ArmorPerkOrSlot.SlotArtifice) ?? 0) - 1 - ); - continue; + effectivePerk = ArmorPerkOrSlot.SlotArtifice; } } - requirements.set(item.perk, (requirements.get(item.perk) ?? 0) - 1); + requirements.set(effectivePerk, (requirements.get(effectivePerk) ?? 0) - 1); if (item.gearSetHash != null) requirements.set(item.gearSetHash, (requirements.get(item.gearSetHash) ?? 0) - 1); } @@ -90,19 +176,11 @@ function checkSlots( SlotRequirements += Math.max(0, requirements.get(key) ?? 0); } - if (SlotRequirements > 1) return { valid: false }; - if (SlotRequirements == 0) return { valid: true, requiredClassItemType: ArmorPerkOrSlot.Any }; - - const requiredClassItemPerk = [...requirements.entries()].find((c) => c[1] > 0)?.[0]; - if (!requiredClassItemPerk) return { valid: false, requiredClassItemType: ArmorPerkOrSlot.Any }; - return { - valid: availableClassItemTypes.has(requiredClassItemPerk), - requiredClassItemType: requiredClassItemPerk, - }; + return SlotRequirements === 0; } -function prepareConstantStatBonus(config: BuildConfiguration) { - const constantBonus = [0, 0, 0, 0, 0, 0]; +function computeEnabledModBonuses(config: BuildConfiguration) { + const enabledModBonuses = [0, 0, 0, 0, 0, 0]; // Apply configurated mods to the stat value // Apply mods for (const mod of config.enabledMods) { @@ -111,22 +189,22 @@ function prepareConstantStatBonus(config: BuildConfiguration) { bonus.stat == SpecialArmorStat.ClassAbilityRegenerationStat ? [1, 0, 2][config.characterClass] : bonus.stat; - constantBonus[statId] += bonus.value; + enabledModBonuses[statId] += bonus.value; } } - return constantBonus; + return enabledModBonuses; } -function prepareConstantModslotRequirement(config: BuildConfiguration) { +function calculateRequiredPerkCounts(config: BuildConfiguration) { let constantPerkRequirement = new Map(); for (let [key] of constantPerkRequirement) { constantPerkRequirement.set(key, 0); } - for (const req of config.armorRequirements) { - if ("perk" in req) { - let perk = req.perk; + for (const requirement of config.armorRequirements) { + if ("perk" in requirement) { + let perk = requirement.perk; const e = Object.entries(ArmorPerkSocketHashes).find(([, value]) => value == perk); if (e) perk = Number.parseInt(e[0]) as any as ArmorPerkOrSlot; @@ -134,11 +212,11 @@ function prepareConstantModslotRequirement(config: BuildConfiguration) { if (perk != ArmorPerkOrSlot.Any && perk != ArmorPerkOrSlot.None) { constantPerkRequirement.set(perk, (constantPerkRequirement.get(perk) ?? 0) + 1); } - } else if ("gearSetHash" in req) { + } else if ("gearSetHash" in requirement) { // Gear set requirement constantPerkRequirement.set( - req.gearSetHash, - (constantPerkRequirement.get(req.gearSetHash) ?? 0) + 1 + requirement.gearSetHash, + (constantPerkRequirement.get(requirement.gearSetHash) ?? 0) + 1 ); } } @@ -150,25 +228,68 @@ function* generateArmorCombinations( gauntlets: IPermutatorArmor[], chests: IPermutatorArmor[], legs: IPermutatorArmor[], - requiresAtLeastOneExotic: boolean + classItems: IPermutatorArmor[], + yieldExoticCombinations: boolean, + yieldAllLegendary: boolean ) { - for (let helmet of helmets) { - for (let gauntlet of gauntlets) { - if (helmet.isExotic && gauntlet.isExotic) continue; - for (let chest of chests) { - if ((helmet.isExotic || gauntlet.isExotic) && chest.isExotic) continue; - for (let leg of legs) { - if ((helmet.isExotic || gauntlet.isExotic || chest.isExotic) && leg.isExotic) continue; - if ( - requiresAtLeastOneExotic && - !(helmet.isExotic || gauntlet.isExotic || chest.isExotic || leg.isExotic) - ) - continue; - - yield [helmet, gauntlet, chest, leg]; - } - } - } + const legendaryHelmets = helmets.filter((h) => !h.isExotic); + const legendaryGauntlets = gauntlets.filter((g) => !g.isExotic); + const legendaryChests = chests.filter((c) => !c.isExotic); + const legendaryLegs = legs.filter((l) => !l.isExotic); + const legendaryClassItems = classItems.filter((d) => !d.isExotic); + + // Yield combinations with exactly one exotic item and legendaries in all other slots + if (yieldExoticCombinations) { + const exoticHelmets = helmets.filter((h) => h.isExotic); + const exoticGauntlets = gauntlets.filter((g) => g.isExotic); + const exoticChests = chests.filter((c) => c.isExotic); + const exoticLegs = legs.filter((l) => l.isExotic); + const exoticClassItems = classItems.filter((d) => d.isExotic); + + for (const helmet of exoticHelmets) + for (const gauntlet of legendaryGauntlets) + for (const chest of legendaryChests) + for (const leg of legendaryLegs) + for (const classItem of legendaryClassItems) + yield [helmet, gauntlet, chest, leg, classItem] as const; + + for (const helmet of legendaryHelmets) + for (const gauntlet of exoticGauntlets) + for (const chest of legendaryChests) + for (const leg of legendaryLegs) + for (const classItem of legendaryClassItems) + yield [helmet, gauntlet, chest, leg, classItem] as const; + + for (const helmet of legendaryHelmets) + for (const gauntlet of legendaryGauntlets) + for (const chest of exoticChests) + for (const leg of legendaryLegs) + for (const classItem of legendaryClassItems) + yield [helmet, gauntlet, chest, leg, classItem] as const; + + for (const helmet of legendaryHelmets) + for (const gauntlet of legendaryGauntlets) + for (const chest of legendaryChests) + for (const leg of exoticLegs) + for (const classItem of legendaryClassItems) + yield [helmet, gauntlet, chest, leg, classItem] as const; + + for (const helmet of legendaryHelmets) + for (const gauntlet of legendaryGauntlets) + for (const chest of legendaryChests) + for (const leg of legendaryLegs) + for (const classItem of exoticClassItems) + yield [helmet, gauntlet, chest, leg, classItem] as const; + } + + // Yield all-legendary combinations + if (yieldAllLegendary) { + for (const helmet of legendaryHelmets) + for (const gauntlet of legendaryGauntlets) + for (const chest of legendaryChests) + for (const leg of legendaryLegs) + for (const classItem of legendaryClassItems) + yield [helmet, gauntlet, chest, leg, classItem] as const; } } @@ -176,7 +297,10 @@ function estimateCombinationsToBeChecked( helmets: IPermutatorArmor[], gauntlets: IPermutatorArmor[], chests: IPermutatorArmor[], - legs: IPermutatorArmor[] + legs: IPermutatorArmor[], + classItems: IPermutatorArmor[], + yieldExoticCombinations: boolean, + yieldAllLegendary: boolean ) { let totalCalculations = 0; const exoticHelmets = helmets.filter((d) => d.isExotic).length; @@ -187,18 +311,59 @@ function estimateCombinationsToBeChecked( const legendaryChests = chests.length - exoticChests; const exoticLegs = legs.filter((d) => d.isExotic).length; const legendaryLegs = legs.length - exoticLegs; - totalCalculations += exoticHelmets * legendaryGauntlets * legendaryChests * legendaryLegs; - totalCalculations += legendaryHelmets * exoticGauntlets * legendaryChests * legendaryLegs; - totalCalculations += legendaryHelmets * legendaryGauntlets * exoticChests * legendaryLegs; - totalCalculations += legendaryHelmets * legendaryGauntlets * legendaryChests * exoticLegs; - totalCalculations += legendaryHelmets * legendaryGauntlets * legendaryChests * legendaryLegs; + const exoticClassItemCount = classItems.filter((d) => d.isExotic).length; + const legendaryClassItemCount = classItems.length - exoticClassItemCount; + + if (yieldExoticCombinations) { + totalCalculations += + exoticHelmets * + legendaryGauntlets * + legendaryChests * + legendaryLegs * + legendaryClassItemCount; + totalCalculations += + legendaryHelmets * + exoticGauntlets * + legendaryChests * + legendaryLegs * + legendaryClassItemCount; + totalCalculations += + legendaryHelmets * + legendaryGauntlets * + exoticChests * + legendaryLegs * + legendaryClassItemCount; + totalCalculations += + legendaryHelmets * + legendaryGauntlets * + legendaryChests * + exoticLegs * + legendaryClassItemCount; + totalCalculations += + legendaryHelmets * + legendaryGauntlets * + legendaryChests * + legendaryLegs * + exoticClassItemCount; + } + + if (yieldAllLegendary) { + totalCalculations += + legendaryHelmets * + legendaryGauntlets * + legendaryChests * + legendaryLegs * + legendaryClassItemCount; + } + return totalCalculations; } // endregion Validation and Preparation Functions // region Main Worker Event Handler -addEventListener("message", async ({ data }) => { - if (data.type != "builderRequest") return; +async function handleArmorBuilderRequest(data: any): Promise { + // Reset cancellation flag at the beginning of each run + cancelRequested = false; const threadSplit = data.threadSplit as { count: number; current: number }; const config = data.config as BuildConfiguration; @@ -212,7 +377,8 @@ addEventListener("message", async ({ data }) => { } const startTime = Date.now(); - console.time(`Total run thread#${threadSplit.current}`); + console.log(`Thread ${threadSplit.current} started with ${items.length} items to process.`); + console.time(`Total run thread #${threadSplit.current}`); // toggle feature flags config.onlyShowResultsWithNoWastedStats = environment.featureFlags.enableZeroWaste && config.onlyShowResultsWithNoWastedStats; @@ -232,38 +398,21 @@ addEventListener("message", async ({ data }) => { 199733460, // titan masq 2545426109, // warlock 3224066584, // hunter + 2390807586, // titan new fotl + 2462335932, // hunter new fotl + 4095816113, // warlock new fotl ].indexOf(k.hash) > -1 ); }); let gauntlets = items.filter((i) => i.slot == ArmorSlot.ArmorSlotGauntlet); let chests = items.filter((i) => i.slot == ArmorSlot.ArmorSlotChest); let legs = items.filter((i) => i.slot == ArmorSlot.ArmorSlotLegs); - - // Support multithreading. find the largest set and split it by N. - if (threadSplit.count > 1) { - var splitEntry = ( - [ - [helmets, helmets.length], - [gauntlets, gauntlets.length], - [chests, chests.length], - [legs, legs.length], - ] as [IPermutatorArmor[], number][] - ).sort((a, b) => b[1] - a[1])[0][0]; - - var keepLength = Math.round(splitEntry.length / threadSplit.count); - var startIndex = keepLength * threadSplit.current; // we can delete everything before this - var endIndex = startIndex + keepLength; // we can delete everything after this - // if we have rounding issues, let the last thread do the rest - if (threadSplit.current == threadSplit.count - 1) endIndex = splitEntry.length; - - // remove data at the end - splitEntry.splice(endIndex); - splitEntry.splice(0, startIndex); - } - let classItems = items.filter((i) => i.slot == ArmorSlot.ArmorSlotClass); + // Sort by Masterwork, descending - classItems = classItems.sort((a, b) => (b.masterworkLevel ?? 0) - (a.masterworkLevel ?? 0)); + classItems = classItems.sort( + (a, b) => (b.tier ?? 0) - (a.tier ?? 0) || (b.masterworkLevel ?? 0) - (a.masterworkLevel ?? 0) + ); // Filter exotic class items based on selected exotic perks if they are not "Any" if (config.selectedExoticPerks && config.selectedExoticPerks.length >= 2) { @@ -320,6 +469,8 @@ addEventListener("message", async ({ data }) => { i.intellect === item.intellect && i.strength === item.strength && i.isExotic === item.isExotic && + //i.tier >= (item.tier ?? 0) && + ((i.tier < 5 && item.tier < 5) || i.tuningStat == item.tuningStat) && ((i.isExotic && config.assumeExoticsMasterworked) || (!i.isExotic && config.assumeLegendariesMasterworked) || // If there is any stat fixed, we check if the masterwork level is the same as the first item @@ -327,160 +478,258 @@ addEventListener("message", async ({ data }) => { // If there is no stat fixed, then we just use the masterwork level of the first item. // As it is already sorted descending, we can just check if the masterwork level is the same !anyStatFixed) && - (doesNotRequireArmorPerks || i.perk === item.perk) + (doesNotRequireArmorPerks || (i.perk === item.perk && i.gearSetHash === item.gearSetHash)) ) ); //*/ - const exoticClassItems = classItems.filter((d) => d.isExotic); - const legendaryClassItems = classItems.filter((d) => !d.isExotic); - const exoticClassItemIsEnforced = exoticClassItems.some( - (item) => config.selectedExotics.indexOf(item.hash) > -1 - ); - let availableClassItemPerkTypes = new Set(classItems.map((d) => d.gearSetHash || d.perk)); - // let availableClassItemPerkTypes1 = new Set(classItems.map((d) => d.gearSetHash)); - - // runtime variables - const runtime = { - maximumPossibleTiers: [0, 0, 0, 0, 0, 0], - }; + // Support multithreading. Find the largest set and split it by N, ensuring even exotic distribution. + if (threadSplit.count > 1) { + // Find the largest slot array + const slotArrays: [IPermutatorArmor[], number, string][] = [ + [helmets, helmets.length, "helmets"], + [gauntlets, gauntlets.length, "gauntlets"], + [chests, chests.length, "chests"], + [legs, legs.length, "legs"], + [classItems, classItems.length, "class"], + ]; + slotArrays.sort((a, b) => b[1] - a[1]); + const splitEntry = slotArrays[0][0]; + const splitEntryName = slotArrays[0][2]; + + // Separate exotics and non-exotics + const exotics = splitEntry.filter((i) => i.isExotic); + const nonExotics = splitEntry.filter((i) => !i.isExotic); + + // Deterministically sort both groups (by hash, then by masterworkLevel, then by name if available) + const stableSort = (arr: IPermutatorArmor[]) => + arr.slice().sort((a, b) => { + if (a.hash !== b.hash) return a.hash - b.hash; + if ((a.masterworkLevel ?? 0) !== (b.masterworkLevel ?? 0)) + return (a.masterworkLevel ?? 0) - (b.masterworkLevel ?? 0); + return 0; + }); + const sortedExotics = stableSort(exotics); + const sortedNonExotics = stableSort(nonExotics); + + // Helper to split an array into N nearly equal, deterministic batches + function splitIntoBatches(arr: T[], batchCount: number): T[][] { + const batches: T[][] = Array.from({ length: batchCount }, () => []); + for (let i = 0; i < arr.length; ++i) { + // Distribute round-robin for determinism + batches[i % batchCount].push(arr[i]); + } + return batches; + } - if (classItems.length == 0) { - console.warn( - `Thread#${threadSplit.current} - No class items found with the current configuration.` - ); - postMessage({ - runtime: runtime, - results: [], - done: true, - checkedCalculations: 0, - estimatedCalculations: 0, - stats: { - permutationCount: 0, - itemCount: items.length - classItems.length, - totalTime: Date.now() - startTime, - }, - }); - return; - } else if (exoticClassItems.length > 0 && legendaryClassItems.length == 0) { - // If we do not have legendary class items, we can not use any exotic armor in other slots - helmets = helmets.filter((d) => !d.isExotic); - gauntlets = gauntlets.filter((d) => !d.isExotic); - chests = chests.filter((d) => !d.isExotic); - legs = legs.filter((d) => !d.isExotic); + const exoticBatches = splitIntoBatches(sortedExotics, threadSplit.count); + const nonExoticBatches = splitIntoBatches(sortedNonExotics, threadSplit.count); + + // For this thread, combine the corresponding exotic and non-exotic batch + const batch = [...exoticBatches[threadSplit.current], ...nonExoticBatches[threadSplit.current]]; + + // Replace the original slot array with the batch for this thread + switch (splitEntryName) { + case "helmets": + helmets = batch; + break; + case "gauntlets": + gauntlets = batch; + break; + case "chests": + chests = batch; + break; + case "legs": + legs = batch; + break; + case "class": + classItems = batch; + break; + } } - const constantBonus = prepareConstantStatBonus(config); - const constantModslotRequirement = prepareConstantModslotRequirement(config); - - const requiresAtLeastOneExotic = config.selectedExotics.indexOf(FORCE_USE_ANY_EXOTIC) > -1; + // Reset runtime state for this calculation + runtime.maximumPossibleTiers = [0, 0, 0, 0, 0, 0]; + + // Initialize module-level constants directly from config + enabledModBonuses = computeEnabledModBonuses(config); + requiredPerkSlotCounts = calculateRequiredPerkCounts(config); + + // Initialize target values and configuration flags + targetVals = [0, 0, 0, 0, 0, 0]; + targetFixed = [false, false, false, false, false, false]; + minimumStatTierValues = [0, 0, 0, 0, 0, 0]; + for (let n = 0; n < 6; n++) { + targetVals[n] = (config.minimumStatTiers[n as ArmorStat].value || 0) * 10; + targetFixed[n] = !!config.minimumStatTiers[n as ArmorStat].fixed; + minimumStatTierValues[n] = config.minimumStatTiers[n as ArmorStat].value || 0; + } + maxMajorMods = config.statModLimits?.maxMajorMods || 0; + maxMods = config.statModLimits?.maxMods || 0; + possibleIncreaseByMod = 10 * maxMajorMods + 5 * Math.max(0, maxMods - maxMajorMods); + assumeEveryLegendaryIsArtifice = !!config.assumeEveryLegendaryIsArtifice; + assumeEveryExoticIsArtifice = !!config.assumeEveryExoticIsArtifice; + assumeClassItemIsArtifice = !!config.assumeClassItemIsArtifice; + calculateTierFiveTuning = !!config.calculateTierFiveTuning; + onlyShowResultsWithNoWastedStats = !!config.onlyShowResultsWithNoWastedStats; + tryLimitWastedStats = !!config.tryLimitWastedStats; + addConstent1Health = !!config.addConstent1Health; + assumeExoticsMasterworked = !!config.assumeExoticsMasterworked; + assumeLegendariesMasterworked = !!config.assumeLegendariesMasterworked; let results: IPermutatorArmorSet[] = []; let resultsLength = 0; let listedResults = 0; - let totalResults = 0; - let doNotOutput = false; + let resultsSent = 0; + let computedResults = 0; + + let bestResult: IPermutatorArmorSet | null = null; + let bestResultSent = false; + let bestSkillTier = -1; + let bestWaste = Infinity; + + // Determine exotic combination mode from selectedExotics: + // - FORCE_USE_ANY_EXOTIC or specific exotic hash(es): yield only 1-exotic combinations + // - FORCE_USE_NO_EXOTIC: yield only all-legendary combinations + // - Empty array (no selection): yield both + const hasForceNoExotic = config.selectedExotics[0] === FORCE_USE_NO_EXOTIC; + const hasForceAnyExotic = config.selectedExotics[0] === FORCE_USE_ANY_EXOTIC; + const hasSpecificExotic = + config.selectedExotics.length > 0 && !hasForceNoExotic && !hasForceAnyExotic; + const noSelection = config.selectedExotics.length === 0; + + const yieldExoticCombinations = hasForceAnyExotic || hasSpecificExotic || noSelection; + const yieldAllLegendary = hasForceNoExotic || noSelection; + + let estimatedCalculations = estimateCombinationsToBeChecked( + helmets, + gauntlets, + chests, + legs, + classItems, + yieldExoticCombinations, + yieldAllLegendary + ); - // contains the value of the total amount of combinations to be checked - let estimatedCalculations = estimateCombinationsToBeChecked(helmets, gauntlets, chests, legs); let checkedCalculations = 0; let lastProgressReportTime = 0; + // define the delay; it can be 75ms if the estimated calculations are low // if the estimated calculations >= 1e6, then we will use 125ms let progressBarDelay = estimatedCalculations >= 1e6 ? 125 : 75; - for (let [helmet, gauntlet, chest, leg] of generateArmorCombinations( + resultLimitReached = false; + + for (let [helmet, gauntlet, chest, leg, classItem] of generateArmorCombinations( helmets, gauntlets, chests, legs, - // if exotic class items are enforced, we can not use any other exotic armor piece - requiresAtLeastOneExotic && !exoticClassItemIsEnforced + classItems, + yieldExoticCombinations, + yieldAllLegendary )) { - checkedCalculations++; - /** - * At this point we already have: - * - Masterworked Exotic/Legendaries, if they must be masterworked (config.onlyUseMasterworkedExotics/config.onlyUseMasterworkedLegendaries) - * - disabled items were already removed (config.disabledItems) - */ - const slotCheckResult = checkSlots( - config, - constantModslotRequirement, - availableClassItemPerkTypes, - helmet, - gauntlet, - chest, - leg - ); - if (!slotCheckResult.valid) continue; - - const hasOneExotic = helmet.isExotic || gauntlet.isExotic || chest.isExotic || leg.isExotic; - // TODO This check should be in the generator - if (hasOneExotic && exoticClassItemIsEnforced) continue; - - let classItemsToUse: IPermutatorArmor[] = classItems; - if (hasOneExotic) { - // if we have an exotic armor piece, we can not use the exotic class item - classItemsToUse = legendaryClassItems; - } else if (config.selectedExotics[0] == FORCE_USE_ANY_EXOTIC || exoticClassItemIsEnforced) { - // if we have no exotic armor piece, we can use the exotic class item - classItemsToUse = exoticClassItems; - } - if (slotCheckResult.requiredClassItemType != ArmorPerkOrSlot.Any) { - classItemsToUse = classItems.filter( - (item) => - item.perk == slotCheckResult.requiredClassItemType || - item.gearSetHash == slotCheckResult.requiredClassItemType + if (cancelRequested) { + console.log( + `Thread #${threadSplit.current} received cancel request, stopping calculation early.` ); + break; } - if (classItemsToUse.length == 0) { - // If we have no class items, we do not need to calculate the permutation - continue; + + if (resultLimitReached && runtime.maximumPossibleTiers.every((tier) => tier >= 200)) { + console.log( + `Thread #${threadSplit.current} reached result limit and maximum possible tiers are all 200, stopping calculation early.` + ); + break; } - const result = handlePermutation( - runtime, - config, - helmet, - gauntlet, - chest, - leg, - classItemsToUse, - constantBonus, - doNotOutput - ); + checkedCalculations++; + if (!checkSlots(helmet, gauntlet, chest, leg, classItem)) continue; + + // Only calculate more permutations if the results limit has not been reached yet and + const result = handlePermutation(helmet, gauntlet, chest, leg, classItem); // Only add 50k to the list if the setting is activated. // We will still calculate the rest so that we get accurate results for the runtime values - if (isIPermutatorArmorSet(result)) { - totalResults++; - - results.push(result); - resultsLength++; - listedResults++; - doNotOutput = - doNotOutput || - (config.limitParsedResults && listedResults >= 3e4 / threadSplit.count) || - listedResults >= 1e6 / threadSplit.count; + if (!!result) { + computedResults++; + // Track the best result + const resultSkillTier = getSkillTier(result.statsWithMods); + const resultWaste = getWaste(result.statsWithMods); + if ( + bestResult === null || + resultSkillTier > bestSkillTier || + (resultSkillTier === bestSkillTier && resultWaste < bestWaste) + ) { + bestResult = result; + bestSkillTier = resultSkillTier; + bestWaste = resultWaste; + bestResultSent = false; // Reset since we have a new best + } + + if (!resultLimitReached) { + resultsSent++; + results.push(result); + resultsLength++; + listedResults++; + + // Check if we just added the best result + if (result === bestResult) { + bestResultSent = true; + } + + resultLimitReached = config.limitParsedResults && listedResults >= 3e4 / threadSplit.count; + if (resultLimitReached) { + console.log( + `Thread #${threadSplit.current} reached result limit of ${listedResults} results` + ); + } + } } - if (totalResults % 5000 == 0 && lastProgressReportTime + progressBarDelay < Date.now()) { - lastProgressReportTime = Date.now(); + if (resultsLength >= 5000 || (resultLimitReached && resultsLength > 0)) { + // Check if the best result is in this batch + if (bestResult && results.includes(bestResult)) { + bestResultSent = true; + } + + // @ts-ignore postMessage({ + runtime, + results, + done: false, checkedCalculations, estimatedCalculations, - reachableTiers: runtime.maximumPossibleTiers, + resultLimitReached, }); - } - - if (resultsLength >= 5000) { - // @ts-ignore - postMessage({ runtime, results, done: false, checkedCalculations, estimatedCalculations }); results = []; resultsLength = 0; + await new Promise((resolve) => setTimeout(resolve, 0)); + } else if (lastProgressReportTime + progressBarDelay < performance.now()) { + lastProgressReportTime = performance.now(); + postMessage({ + checkedCalculations, + estimatedCalculations, + reachableTiers: runtime.maximumPossibleTiers, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); } } - console.timeEnd(`Total run thread#${threadSplit.current}`); + console.timeEnd(`Total run thread #${threadSplit.current}`); + + // Check if the best result is in the final batch + if (bestResult && results.includes(bestResult)) { + bestResultSent = true; + } + + // If we have a best result that wasn't sent yet, add it to the final batch + if (bestResult && !bestResultSent) { + resultsSent++; + results.push(bestResult); + console.log( + `Thread #${threadSplit.current} adding best result (T${bestSkillTier}, W${bestWaste}) to final batch` + ); + } // @ts-ignore postMessage({ @@ -489,12 +738,40 @@ addEventListener("message", async ({ data }) => { done: true, checkedCalculations, estimatedCalculations, + resultLimitReached, stats: { - permutationCount: totalResults, + savedResults: resultsSent, + computedPermutations: computedResults, itemCount: items.length - classItems.length, totalTime: Date.now() - startTime, }, }); +} + +addEventListener("message", async ({ data }) => { + switch (data.type) { + case "builderRequest": + await handleArmorBuilderRequest(data); + break; + case "siblingUpdate": + // Update maximumPossibleTiers from other workers' discoveries + if (data.maximumPossibleTiers && Array.isArray(data.maximumPossibleTiers)) { + for (let i = 0; i < 6; i++) { + runtime.maximumPossibleTiers[i] = Math.max( + runtime.maximumPossibleTiers[i], + data.maximumPossibleTiers[i] || 0 + ); + } + } + break; + case "cancel": + // Request graceful cancellation; the main loop checks this flag + cancelRequested = true; + break; + default: + console.warn(`Unknown message type: ${data.type}`); + break; + } }); // endregion Main Worker Event Handler @@ -502,334 +779,356 @@ addEventListener("message", async ({ data }) => { export function getStatSum( items: IDestinyArmor[] ): [number, number, number, number, number, number] { - return [ - items[0].mobility + items[1].mobility + items[2].mobility + items[3].mobility, - items[0].resilience + items[1].resilience + items[2].resilience + items[3].resilience, - items[0].recovery + items[1].recovery + items[2].recovery + items[3].recovery, - items[0].discipline + items[1].discipline + items[2].discipline + items[3].discipline, - items[0].intellect + items[1].intellect + items[2].intellect + items[3].intellect, - items[0].strength + items[1].strength + items[2].strength + items[3].strength, - ]; + let mob = 0, + res = 0, + rec = 0, + dis = 0, + int_ = 0, + str = 0; + for (const item of items) { + mob += item.mobility; + res += item.resilience; + rec += item.recovery; + dis += item.discipline; + int_ += item.intellect; + str += item.strength; + } + return [mob, res, rec, dis, int_, str]; } -function applyMasterworkStats( - item: IPermutatorArmor, - config: BuildConfiguration, - stats: number[] = [0, 0, 0, 0, 0, 0] -): void { - if (item.armorSystem == ArmorSystem.Armor2) { - if ( - item.masterworkLevel == MAXIMUM_MASTERWORK_LEVEL || - (item.isExotic && config.assumeExoticsMasterworked) || - (!item.isExotic && config.assumeLegendariesMasterworked) - ) { - // Armor 2.0 Masterworked items give +10 to all stats - for (let i = 0; i < 6; i++) { - stats[i] += 2; - } +function generate_tunings(possibleImprovements: t5Improvement[]): Tuning[] { + const impValues = possibleImprovements.map((imp) => { + let l = [[0, 0, 0, 0, 0, 0]]; + + let balancedTuning = [0, 0, 0, 0, 0, 0]; + for (let n = 0; n < 6; n++) { + if (!imp.archetypeStats.includes(n)) balancedTuning[n] = 1; + + if (n == imp.tuningStat) continue; + let p = [0, 0, 0, 0, 0, 0]; + p[imp.tuningStat] = 5; + p[n] = -5; + l.push(p); } - } else if (item.armorSystem == ArmorSystem.Armor3) { - let multiplier = item.masterworkLevel; - if ( - (item.isExotic && config.assumeExoticsMasterworked) || - (!item.isExotic && config.assumeLegendariesMasterworked) - ) - multiplier = MAXIMUM_MASTERWORK_LEVEL; - if (multiplier == 0) return; - - // item.archetypeStats contains three stat indices. The OTHER THREE get +1 per multiplier - for (let i = 0; i < 6; i++) { - if (item.archetypeStats.includes(i)) continue; - stats[i] += multiplier; + l.push(balancedTuning); + + return l; + }); + const tunings: Tuning[] = []; + + const seen = new Set(); + + function addUniqueTuning(tuning: Tuning) { + const key = tuning.join(","); + if (!seen.has(key)) { + tunings.push(tuning as Tuning); + seen.add(key); } } + + if (impValues.length === 0) { + addUniqueTuning([0, 0, 0, 0, 0, 0]); + } else { + function recurse(idx: number, acc: number[]) { + if (idx === impValues.length) { + addUniqueTuning(acc as Tuning); + return; + } + for (const v of impValues[idx]) { + const next = [ + acc[0] + v[0], + acc[1] + v[1], + acc[2] + v[2], + acc[3] + v[3], + acc[4] + v[4], + acc[5] + v[5], + ]; + recurse(idx + 1, next); + } + } + recurse(0, [0, 0, 0, 0, 0, 0]); + } + + return tunings; } export function handlePermutation( - runtime: any, - config: BuildConfiguration, helmet: IPermutatorArmor, gauntlet: IPermutatorArmor, chest: IPermutatorArmor, leg: IPermutatorArmor, - classItems: IPermutatorArmor[], - constantBonus: number[], - doNotOutput = false -): never[] | IPermutatorArmorSet | null { - const items = [helmet, gauntlet, chest, leg]; - const stats = getStatSum(items); - stats[1] += !items[2].isExotic && config.addConstent1Health ? 1 : 0; - - for (let item of items) applyMasterworkStats(item, config, stats); - - const statsWithoutMods = [stats[0], stats[1], stats[2], stats[3], stats[4], stats[5]]; - stats[0] += constantBonus[0]; - stats[1] += constantBonus[1]; - stats[2] += constantBonus[2]; - stats[3] += constantBonus[3]; - stats[4] += constantBonus[4]; - stats[5] += constantBonus[5]; - - for (let n: ArmorStat = 0; n < 6; n++) { - // Abort here if we are already above the limit, in case of fixed stat tiers - if (config.minimumStatTiers[n].fixed) { - if (stats[n] > config.minimumStatTiers[n].value * 10) return null; - } + classItem: IPermutatorArmor +): IPermutatorArmorSet | null { + // Inline stat summation (without mod bonuses) + const b0 = enabledModBonuses[0], + b1 = enabledModBonuses[1], + b2 = enabledModBonuses[2], + b3 = enabledModBonuses[3], + b4 = enabledModBonuses[4], + b5 = enabledModBonuses[5]; + + const statsWithoutMods: number[] = [ + helmet.mobility + gauntlet.mobility + chest.mobility + leg.mobility + classItem.mobility, + helmet.resilience + + gauntlet.resilience + + chest.resilience + + leg.resilience + + classItem.resilience + + (!chest.isExotic && addConstent1Health ? 1 : 0), + helmet.recovery + gauntlet.recovery + chest.recovery + leg.recovery + classItem.recovery, + helmet.discipline + + gauntlet.discipline + + chest.discipline + + leg.discipline + + classItem.discipline, + helmet.intellect + gauntlet.intellect + chest.intellect + leg.intellect + classItem.intellect, + helmet.strength + gauntlet.strength + chest.strength + leg.strength + classItem.strength, + ]; + + // Add mod bonuses to get the working stats array + const stats: number[] = [ + statsWithoutMods[0] + b0, + statsWithoutMods[1] + b1, + statsWithoutMods[2] + b2, + statsWithoutMods[3] + b3, + statsWithoutMods[4] + b4, + statsWithoutMods[5] + b5, + ]; + + let artificeCount = 0; + if (applyMWAndCheckArtifice(helmet, stats)) artificeCount++; + if (applyMWAndCheckArtifice(gauntlet, stats)) artificeCount++; + if (applyMWAndCheckArtifice(chest, stats)) artificeCount++; + if (applyMWAndCheckArtifice(leg, stats)) artificeCount++; + if (applyMWAndCheckArtifice(classItem, stats)) artificeCount++; + + // Early abort: fixed tiers exceeded + for (let n = 0; n < 6; n++) { + if (targetFixed[n] && stats[n] > targetVals[n]) return null; } - // get the amount of armor with artifice slot - let availableArtificeCount = items.filter( - (d) => - d.perk == ArmorPerkOrSlot.SlotArtifice || - (d.armorSystem === ArmorSystem.Armor2 && - ((config.assumeEveryLegendaryIsArtifice && !d.isExotic) || - (config.assumeEveryExoticIsArtifice && d.isExotic))) - ).length; - - // get distance - const distances = [ - Math.max(0, config.minimumStatTiers[0].value * 10 - stats[0]), - Math.max(0, config.minimumStatTiers[1].value * 10 - stats[1]), - Math.max(0, config.minimumStatTiers[2].value * 10 - stats[2]), - Math.max(0, config.minimumStatTiers[3].value * 10 - stats[3]), - Math.max(0, config.minimumStatTiers[4].value * 10 - stats[4]), - Math.max(0, config.minimumStatTiers[5].value * 10 - stats[5]), + // Distances to target (using array literal for V8 SMI optimization) + const distances: number[] = [ + Math.max(0, targetVals[0] - stats[0]), + Math.max(0, targetVals[1] - stats[1]), + Math.max(0, targetVals[2] - stats[2]), + Math.max(0, targetVals[3] - stats[3]), + Math.max(0, targetVals[4] - stats[4]), + Math.max(0, targetVals[5] - stats[5]), ]; - if (config.onlyShowResultsWithNoWastedStats) { - for (let stat: ArmorStat = 0; stat < 6; stat++) { + if (onlyShowResultsWithNoWastedStats) { + for (let stat = 0; stat < 6; stat++) { const v = 10 - (stats[stat] % 10); - distances[stat] = Math.max(distances[stat], v < 10 ? v : 0); + if (v < 10 && v > distances[stat]) distances[stat] = v; + } + } + + // Quick distance sum check before T5 work + // This early check avoids computing T5 improvements and tuningMax when the + // total distance already exceeds the maximum possible from mods + artifice alone. + const distanceSum = + distances[0] + distances[1] + distances[2] + distances[3] + distances[4] + distances[5]; + + if (distanceSum > 50 + 3 * artificeCount) { + // Even with max T5 tuning (5 per item * 5 items = 25), still too far? + // This is a conservative pre-check; the full check follows after T5 computation. + if (!calculateTierFiveTuning || distanceSum > 50 + 3 * artificeCount + 25) { + return null; + } + } + + // T5 tuning improvements (without items array, with direct index comparisons) + let t5Count = 0; + const t5Improvements: t5Improvement[] = []; + const tuningMax: number[] = [0, 0, 0, 0, 0, 0]; + + if (calculateTierFiveTuning) { + if (isT5WithTuning(helmet)) t5Improvements.push(mapItemToTuning(helmet)); + if (isT5WithTuning(gauntlet)) t5Improvements.push(mapItemToTuning(gauntlet)); + if (isT5WithTuning(chest)) t5Improvements.push(mapItemToTuning(chest)); + if (isT5WithTuning(leg)) t5Improvements.push(mapItemToTuning(leg)); + if (isT5WithTuning(classItem)) t5Improvements.push(mapItemToTuning(classItem)); + t5Count = t5Improvements.length; + + for (const t5 of t5Improvements) { + const arch = t5.archetypeStats; + const a0 = arch[0], + a1 = arch[1], + a2 = arch[2]; + // Compute balanced values inline (1 if stat NOT in archetypeStats) + const bal0 = a0 !== 0 && a1 !== 0 && a2 !== 0 ? 1 : 0; + const bal1 = a0 !== 1 && a1 !== 1 && a2 !== 1 ? 1 : 0; + const bal2 = a0 !== 2 && a1 !== 2 && a2 !== 2 ? 1 : 0; + const bal3 = a0 !== 3 && a1 !== 3 && a2 !== 3 ? 1 : 0; + const bal4 = a0 !== 4 && a1 !== 4 && a2 !== 4 ? 1 : 0; + const bal5 = a0 !== 5 && a1 !== 5 && a2 !== 5 ? 1 : 0; + const bal = [bal0, bal1, bal2, bal3, bal4, bal5]; + + for (let n = 0; n < 6; n++) { + if (n === t5.tuningStat) continue; + // p[tuningStat]=5, p[n]=-5, rest=0 → accumulate max(balanced[i], p[i]) + for (let i = 0; i < 6; i++) { + const pVal = i === t5.tuningStat ? 5 : i === n ? -5 : 0; + tuningMax[i] += Math.max(bal[i], pVal); + } + } } } - // distances required to reduce wasted stat points :) + // Full global bound check with T5 + if (distanceSum > 50 + 3 * artificeCount + 5 * t5Count) { + return null; + } + + // Optional distances for waste limiting const optionalDistances = [0, 0, 0, 0, 0, 0]; - if (config.tryLimitWastedStats) - for (let stat: ArmorStat = 0; stat < 6; stat++) { + if (tryLimitWastedStats) { + for (let stat = 0; stat < 6; stat++) { if ( - distances[stat] == 0 && - !config.minimumStatTiers[stat].fixed && + distances[stat] === 0 && + !targetFixed[stat] && stats[stat] < 200 && stats[stat] % 10 > 0 ) { optionalDistances[stat] = 10 - (stats[stat] % 10); } } + } - // Greedy class item selection with early termination - // Sort class items by their stat contribution to current gaps - const sortedClassItems = [...classItems].sort((a, b) => { - let scoreA = 0, - scoreB = 0; - - // add 10 if the class item is Armor 3.0 - if (a.armorSystem == ArmorSystem.Armor3) scoreA += 10; - if (b.armorSystem == ArmorSystem.Armor3) scoreB += 10; - - // add 10 if the class item has an artifice slot - if (a.perk == ArmorPerkOrSlot.SlotArtifice) scoreA += 10; - if (b.perk == ArmorPerkOrSlot.SlotArtifice) scoreB += 10; - - // vendor and collection rolls last - if (a.source === InventoryArmorSource.Inventory) scoreA += 5; - if (b.source === InventoryArmorSource.Inventory) scoreB += 5; - - for (let i = 0; i < 6; i++) { - if (distances[i] > 0) { - scoreA += Math.min( - distances[i], - a.mobility * (i === 0 ? 1 : 0) + - a.resilience * (i === 1 ? 1 : 0) + - a.recovery * (i === 2 ? 1 : 0) + - a.discipline * (i === 3 ? 1 : 0) + - a.intellect * (i === 4 ? 1 : 0) + - a.strength * (i === 5 ? 1 : 0) - ); - scoreB += Math.min( - distances[i], - b.mobility * (i === 0 ? 1 : 0) + - b.resilience * (i === 1 ? 1 : 0) + - b.recovery * (i === 2 ? 1 : 0) + - b.discipline * (i === 3 ? 1 : 0) + - b.intellect * (i === 4 ? 1 : 0) + - b.strength * (i === 5 ? 1 : 0) - ); - } - } - return scoreB - scoreA; // Higher contribution first - }); + const totalOptionalDistances = + optionalDistances[0] + + optionalDistances[1] + + optionalDistances[2] + + optionalDistances[3] + + optionalDistances[4] + + optionalDistances[5]; - // Try each class item with early termination - let finalResult: IPermutatorArmorSet | never[] = []; - for (const classItem of sortedClassItems) { - const adjustedStats = [...stats]; - const tmpArtificeCount = - availableArtificeCount + (classItem.perk == ArmorPerkOrSlot.SlotArtifice ? 1 : 0); - - adjustedStats[0] += classItem.mobility; - adjustedStats[1] += classItem.resilience; - adjustedStats[2] += classItem.recovery; - adjustedStats[3] += classItem.discipline; - adjustedStats[4] += classItem.intellect; - adjustedStats[5] += classItem.strength; - applyMasterworkStats(classItem, config, adjustedStats); - - for (let n: ArmorStat = 0; n < 6; n++) { - // Abort here if we are already above the limit, in case of fixed stat tiers - if (config.minimumStatTiers[n].fixed) { - if (adjustedStats[n] > config.minimumStatTiers[n].value * 10) return null; - } + // Per-stat quick feasibility check (uses precomputed possibleIncreaseByMod) + for (let stat = 0; stat < 6; stat++) { + if (possibleIncreaseByMod + tuningMax[stat] + 3 * artificeCount < distances[stat]) { + return null; } + } - const adjustedStatsWithoutMods = [ - statsWithoutMods[0] + classItem.mobility, - statsWithoutMods[1] + classItem.resilience, - statsWithoutMods[2] + classItem.recovery, - statsWithoutMods[3] + classItem.discipline, - statsWithoutMods[4] + classItem.intellect, - statsWithoutMods[5] + classItem.strength, - ]; - applyMasterworkStats(classItem, config, adjustedStatsWithoutMods); - - // Recalculate distances with class item included - const newDistances = [ - Math.max(0, config.minimumStatTiers[0].value * 10 - adjustedStats[0]), - Math.max(0, config.minimumStatTiers[1].value * 10 - adjustedStats[1]), - Math.max(0, config.minimumStatTiers[2].value * 10 - adjustedStats[2]), - Math.max(0, config.minimumStatTiers[3].value * 10 - adjustedStats[3]), - Math.max(0, config.minimumStatTiers[4].value * 10 - adjustedStats[4]), - Math.max(0, config.minimumStatTiers[5].value * 10 - adjustedStats[5]), - ]; + let availableTunings: Tuning[] = [[0, 0, 0, 0, 0, 0]]; + if (calculateTierFiveTuning) { + availableTunings = generate_tunings(t5Improvements); + } - if (config.onlyShowResultsWithNoWastedStats) { - for (let stat: ArmorStat = 0; stat < 6; stat++) { - const v = 10 - (adjustedStats[stat] % 10); - newDistances[stat] = Math.max(newDistances[stat], v < 10 ? v : 0); - } - } + // heavy work: mod precalc + let result: StatModifierPrecalc | null; + if (distanceSum === 0 && totalOptionalDistances === 0) { + result = { mods: [], tuning: [0, 0, 0, 0, 0, 0], modBonus: [0, 0, 0, 0, 0, 0] }; + } else { + result = get_mods_precalc(stats, distances, optionalDistances, artificeCount, availableTunings); + } - // Recalculate optional distances - const newOptionalDistances = [0, 0, 0, 0, 0, 0]; - if (config.tryLimitWastedStats) - for (let stat: ArmorStat = 0; stat < 6; stat++) { - if ( - newDistances[stat] == 0 && - !config.minimumStatTiers[stat].fixed && - adjustedStats[stat] < 200 && - adjustedStats[stat] % 10 > 0 - ) { - newOptionalDistances[stat] = 10 - (adjustedStats[stat] % 10); - } - } + if (result === null) return null; - const newDistanceSum = - newDistances[0] + - newDistances[1] + - newDistances[2] + - newDistances[3] + - newDistances[4] + - newDistances[5]; - const newTotalOptionalDistances = newOptionalDistances.reduce((a, b) => a + b, 0); - - if (newDistanceSum > 10 * 5 + 3 * tmpArtificeCount) continue; - - let result: StatModifier[] | null; - if (newDistanceSum == 0 && newTotalOptionalDistances == 0) result = []; - else - result = get_mods_precalc( - config, - newDistances, - newOptionalDistances, - tmpArtificeCount, - config.modOptimizationStrategy - ); + performTierAvailabilityTesting(stats, distances, artificeCount, availableTunings); - if (result !== null) { - // Perform Tier Availability Testing with this class item - performTierAvailabilityTesting( - runtime, - config, - adjustedStats, - newDistances, - tmpArtificeCount - ); + const usedArtifice = result.mods.filter((d: StatModifier) => 0 == d % 3); + const usedMods = result.mods.filter((d: StatModifier) => 0 != d % 3); - // This may lead to issues later. - // The performTierAvailabilityTesting must be executed for each class item. - // Found a working combination - return immediately with this class item - if (finalResult instanceof Array && finalResult.length == 0) { - finalResult = tryCreateArmorSetWithClassItem( - runtime, - config, - helmet, - gauntlet, - chest, - leg, - classItem, - result, - adjustedStats, - adjustedStatsWithoutMods, - newDistances, - tmpArtificeCount, - doNotOutput - ); - } - } + // Apply mods to stats for final calculation + const finalStats = [...stats]; + for (let statModifier of result.mods) { + const stat = Math.floor((statModifier - 1) / 3); + finalStats[stat] += STAT_MOD_VALUES[statModifier][1]; } - return finalResult; + for (let n = 0; n < 6; n++) finalStats[n] += result.tuning[n]; + + const waste1 = getWaste(finalStats); + if (onlyShowResultsWithNoWastedStats && waste1 > 0) return null; + + return createArmorSet( + helmet, + gauntlet, + chest, + leg, + classItem, + usedArtifice, + usedMods, + finalStats, + statsWithoutMods, + result.tuning + ); +} + +function getStatVal(statId: ArmorStat, mods: StatModifierPrecalc, start: number) { + return start + mods.tuning[statId] + mods.modBonus[statId]; } // region Tier Availability Testing function performTierAvailabilityTesting( - runtime: any, - config: BuildConfiguration, stats: number[], distances: number[], - availableArtificeCount: number + availableArtificeCount: number, + availableTunings: Tuning[] ): void { for (let stat = 0; stat < 6; stat++) { - if (runtime.maximumPossibleTiers[stat] < stats[stat]) { - runtime.maximumPossibleTiers[stat] = stats[stat]; + const minimumTuning = availableTunings.map((t) => t[stat]).reduce((a, b) => Math.min(a, b), 0); + const minStat = stats[stat]; + + const tmpTunings = availableTunings.slice().sort((a, b) => { + const aVal = a[stat]; + const bVal = b[stat]; + const aNeg = aVal < 0; + const bNeg = bVal < 0; + if (aNeg && bNeg) { + // Both negative: sort descending + return bVal - aVal; + } else if (!aNeg && !bNeg) { + // Both zero or positive: sort ascending + return aVal - bVal; + } else { + // Zero/positive first, then negative + return aNeg ? 1 : -1; + } + }); + + if (runtime.maximumPossibleTiers[stat] < stats[stat] + minimumTuning) { + runtime.maximumPossibleTiers[stat] = stats[stat] + minimumTuning; } + //const tuningsWithoutNegatives = tmpTunings.filter((t) => t[stat] >= 0); - if (stats[stat] >= 200) continue; // Already at max value, no need to test + if (minStat >= 200) continue; // Already at max value, no need to test - const minTier = config.minimumStatTiers[stat as ArmorStat].value * 10; + const minTier = minimumStatTierValues[stat] * 10; // Binary search to find maximum possible value let low = Math.max(runtime.maximumPossibleTiers[stat], minTier); let high = 200; - while (low < high) { + while (low <= high) { // Try middle value, rounded to nearest 10 for tier optimization const mid = Math.min(200, Math.ceil((low + high) / 2)); - if (stats[stat] >= mid) { + if (minStat >= mid && minimumTuning == 0) { // We can already reach this value naturally low = mid + 1; continue; } // Calculate distance needed to reach this value - const v = 10 - (stats[stat] % 10); const testDistances = [...distances]; - testDistances[stat] = Math.max(v < 10 ? v : 0, mid - stats[stat]); + testDistances[stat] = Math.max(0, mid - minStat); // Check if this value is achievable with mods const mods = get_mods_precalc( - config, + stats, testDistances, [0, 0, 0, 0, 0, 0], availableArtificeCount, - ModOptimizationStrategy.None + tmpTunings ); if (mods != null) { - // This value is achievable, try higher - low = mid + 1; - runtime.maximumPossibleTiers[stat] = mid; + let val = getStatVal(stat, mods, minStat); + runtime.maximumPossibleTiers[stat] = Math.max(val, runtime.maximumPossibleTiers[stat]); + low = Math.max(runtime.maximumPossibleTiers[stat], mid) + 1; } else { // This value is not achievable, try lower high = mid - 1; @@ -838,221 +1137,204 @@ function performTierAvailabilityTesting( // Verify the final value if (low > runtime.maximumPossibleTiers[stat] && low <= 200) { - const v = 10 - (stats[stat] % 10); const testDistances = [...distances]; - testDistances[stat] = Math.max(v < 10 ? v : 0, low - stats[stat]); + testDistances[stat] = Math.max(low - minStat, 0); const mods = get_mods_precalc( - config, + stats, testDistances, [0, 0, 0, 0, 0, 0], availableArtificeCount, - ModOptimizationStrategy.None + tmpTunings ); if (mods != null) { runtime.maximumPossibleTiers[stat] = low; + // also set the other stats + // This may reduce the amount of required calculations for the stats that will be checked later on + for (let otherStat = stat + 1; otherStat < 6; otherStat++) { + runtime.maximumPossibleTiers[otherStat] = Math.max( + getStatVal(otherStat, mods, stats[otherStat]), + runtime.maximumPossibleTiers[otherStat] + ); + } } } } } -function tryCreateArmorSetWithClassItem( - runtime: any, - config: BuildConfiguration, - helmet: IPermutatorArmor, - gauntlet: IPermutatorArmor, - chest: IPermutatorArmor, - leg: IPermutatorArmor, - classItem: IPermutatorArmor, - result: StatModifier[], - adjustedStats: number[], - statsWithoutMods: number[], - newDistances: number[], +// region Mod Calculation Functions +function get_mods_recursive( + currentStats: number[], + targetStats: number[], + + distances_to_check: number[], + availableTunings: Tuning[], + statIdx: number, availableArtificeCount: number, - doNotOutput: boolean -): IPermutatorArmorSet | never[] { - if (doNotOutput) return []; + availableMajorMods: number, + availableMods: number +): number[][] | null { + if (statIdx > 5) { + // Now we have a valid set of mods and tunings, but we still have to check -5 values. This will happen in innermost loop + // statIdx is no longer useful here + + // 1. If there is any tuning with no negative in any value, then return [] + // if (availableTunings.some(tuning => tuning.every(v => v >= 0))) { + // return []; + // } + + // Now there are only tunings with negative values left. + // 2.1 If there is any stat where (currentStat - tuningValue) >= target value, then return + const validTuning = availableTunings.find((tuning) => { + for (let i = 0; i < 6; i++) { + if (tuning[i] >= 0) continue; + if (currentStats[i] + tuning[i] < targetStats[i]) return false; + } + return true; + }); + if (validTuning) { + return [validTuning]; + } - const usedArtifice = result.filter((d: StatModifier) => 0 == d % 3); - const usedMods = result.filter((d: StatModifier) => 0 != d % 3); + // 2.2 if we still have a few mods left, we can simply call the recursion again, but with the new "temp" stats + if (availableMods > 0) { + for (let tuning of availableTunings) { + const newStats = currentStats.map((s, i) => s + tuning[i]); + const newDists = distances_to_check.map((d, i) => + Math.max(0, targetStats[i] - newStats[i]) + ); + const otherMods = get_mods_recursive( + newStats, + targetStats, + newDists, + [], + 0, + availableArtificeCount, + availableMajorMods, + availableMods + ); + if (otherMods !== null) { + return [...otherMods, tuning]; + } + } + } - // Apply mods to stats for final calculation - const finalStats = [...adjustedStats]; - for (let statModifier of result) { - const stat = Math.floor((statModifier - 1) / 3); - finalStats[stat] += STAT_MOD_VALUES[statModifier][1]; + return null; } - const waste1 = getWaste(finalStats); - if (config.onlyShowResultsWithNoWastedStats && waste1 > 0) return []; - - return createArmorSet( - helmet, - gauntlet, - chest, - leg, - classItem, - usedArtifice, - usedMods, - finalStats, - statsWithoutMods + const maxValueOfAvailableTunings = availableTunings.reduce( + (max, tuning) => Math.max(max, tuning[statIdx]), + 0 ); -} -// region Mod Calculation Functions -function get_mods_precalc( - config: BuildConfiguration, - distances: number[], - optionalDistances: number[], - availableArtificeCount: number, - optimize: ModOptimizationStrategy = ModOptimizationStrategy.None -): StatModifier[] | null { - // check distances <= 65 - const totalDistance = - distances[0] + distances[1] + distances[2] + distances[3] + distances[4] + distances[5]; - if (totalDistance > 65) return null; + const distance = distances_to_check[statIdx]; - if (totalDistance == 0 && optionalDistances.every((d) => d == 0)) { - // no mods needed, return empty array - return []; - } - - const modCombinations = config.onlyShowResultsWithNoWastedStats - ? precalculatedZeroWasteModCombinations - : precalculatedModCombinations; - - // grab the precalculated mods for the distances - const precalculatedMods = [ - modCombinations[distances[0]] || [[0, 0, 0, 0]], // mobility - modCombinations[distances[1]] || [[0, 0, 0, 0]], // resilience - modCombinations[distances[2]] || [[0, 0, 0, 0]], // recovery - modCombinations[distances[3]] || [[0, 0, 0, 0]], // discipline - modCombinations[distances[4]] || [[0, 0, 0, 0]], // intellect - modCombinations[distances[5]] || [[0, 0, 0, 0]], // strength - ]; + //let precalculatedMods = precalculatedModCombinations[distance] || [[0, 0, 0, 0, 0, 0]]; + let precalculatedMods = precalculatedTuningModCombinations[distance] || [[0, 0, 0, 0, 0, 0]]; + precalculatedMods = precalculatedMods.filter( + (mod) => + mod[0] <= availableArtificeCount && + mod[2] <= availableMajorMods && + mod[2] + mod[1] <= availableMods && + mod[3] <= maxValueOfAvailableTunings + ); - // we handle locked exact stats as zero-waste in terms of the mod selection - for (let i = 0; i < 6; i++) { - if (config.minimumStatTiers[i as ArmorStat].fixed && distances[i] > 0) { - precalculatedMods[i] = precalculatedZeroWasteModCombinations[distances[i]] || [[0, 0, 0, 0]]; - // and now also remove every solution with >= 10 points of "overshoot" - precalculatedMods[i] = precalculatedMods[i].filter((d) => d[3] - distances[i] < 10); - } + if (precalculatedMods.length == 0) { + return null; } - // add optional distances to the precalculated mods - const limit = 3; - for (let i = 0; i < optionalDistances.length; i++) { - if (optionalDistances[i] > 0) { - const additionalCombosA = modCombinations[optionalDistances[i]].filter( - (d) => - d[2] == 0 && // disallow major mods - d[3] % 10 > 0 && // we do not want to add exact stat tiers - (optionalDistances[i] + d[3]) % 10 < optionalDistances[i] // and the changes must have less waste than before + for (const pickedMod of precalculatedMods) { + const totalMods = Math.max(0, availableMods - pickedMod[1] - pickedMod[2]); + const majorMods = Math.min(totalMods, Math.max(0, availableMajorMods - pickedMod[2])); + const artifice = Math.max(0, availableArtificeCount - pickedMod[0]); + + let selectedTuningsInner = availableTunings; + const requiredTuningCount = pickedMod[4]; + const requiredTuningValue = pickedMod[3]; + if (requiredTuningCount > 0) { + selectedTuningsInner = availableTunings.filter( + (tuning) => tuning[statIdx] >= requiredTuningValue ); - //(d) => d[3] % 10 > 0); - if (additionalCombosA != null) { - precalculatedMods[i] = additionalCombosA.slice(0, limit).concat(precalculatedMods[i]); + if (selectedTuningsInner.length == 0) { + continue; + // return null; // we could also return, if the table is sorted ascending to tuningCount } } - } - for (let i = 0; i < 6; i++) { - precalculatedMods[i] = precalculatedMods[i].filter( - (d) => - d[2] <= config.statModLimits.maxMajorMods && d[1] + d[2] <= config.statModLimits.maxMods + const otherMods = get_mods_recursive( + currentStats, + targetStats, + distances_to_check, //.slice(1), + selectedTuningsInner, + statIdx + 1, + artifice, + majorMods, + totalMods ); - - if (precalculatedMods[i] == null || precalculatedMods[i].length == 0) { - // if there are no mods for this distance, we can not calculate anything - return null; + if (otherMods !== null) { + return [pickedMod, ...otherMods]; } } + return null; +} - let bestMods: any = null; - let bestScore = 1000; - - function score(entries: [number, number, number, number][]) { - if (optimize == ModOptimizationStrategy.ReduceUsedModSockets) { - const n1 = entries.reduce((a, b) => a + b[1] + b[2], 0); - return n1; - } else if (optimize == ModOptimizationStrategy.ReduceUsedModPoints) { - return entries.reduce((a, b, currentIndex) => a + 1 * b[1] + 3 * b[2], 0); - } - return entries.reduce((a, b) => a + b[3], 0); - } - - function validate(entries: [number, number, number, number][]): boolean { - // sum up the stats - const sum = entries.reduce( - (a, b, i) => [a[0] + b[0], a[1] + b[1], a[2] + b[2], a[3] + b[3] - distances[i]], - [0, 0, 0, 0] - ); +type StatModifierPrecalc = { + mods: StatModifier[]; + modBonus: number[]; + tuning: Tuning; +}; - if (score(entries) > bestScore) return false; - if (sum[0] > availableArtificeCount) return false; - if (sum[1] + sum[2] > config.statModLimits.maxMods) return false; - if (sum[2] > config.statModLimits.maxMajorMods) return false; - if (sum[3] < 0) return false; +function get_mods_precalc( + currentStats: number[], + distances: number[], + optionalDistances: number[], + availableArtificeCount: number, + availableTunings: Tuning[] +): StatModifierPrecalc | null { + const totalDistance = + distances[0] + distances[1] + distances[2] + distances[3] + distances[4] + distances[5]; + if (totalDistance > 50 + 25) return null; - return true; + if (totalDistance == 0 && optionalDistances.every((d) => d == 0)) { + // no mods needed, return empty array + return { mods: [], tuning: [0, 0, 0, 0, 0, 0], modBonus: [0, 0, 0, 0, 0, 0] }; } - const mustExecuteOptimization = totalDistance > 0 && optimize != ModOptimizationStrategy.None; - root: for (let mobility of precalculatedMods[0]) { - if (!validate([mobility])) continue; - for (let resilience of precalculatedMods[1]) { - if (!validate([mobility, resilience])) continue; - for (let recovery of precalculatedMods[2]) { - if (!validate([mobility, resilience, recovery])) continue; - if (mustExecuteOptimization && score([mobility, resilience, recovery]) >= bestScore) - continue; - for (let discipline of precalculatedMods[3]) { - if (!validate([mobility, resilience, recovery, discipline])) continue; - if ( - mustExecuteOptimization && - score([mobility, resilience, recovery, discipline]) >= bestScore - ) - continue; - for (let intellect of precalculatedMods[4]) { - if (!validate([mobility, resilience, recovery, discipline, intellect])) continue; - if ( - mustExecuteOptimization && - score([mobility, resilience, recovery, discipline, intellect]) >= bestScore - ) - continue; - inner: for (let strength of precalculatedMods[5]) { - let mods = [mobility, resilience, recovery, discipline, intellect, strength]; - - if (!validate(mods)) continue; - - // Fill optional distances - for (let m = 0; m < 6; m++) - if (optionalDistances[m] > 0 && mods[m][3] == 0 && bestMods != null) continue inner; - - let scoreVal = score(mods); - if (scoreVal < bestScore) { - bestScore = scoreVal; - bestMods = mods; - if (!mustExecuteOptimization) { - break root; - } - } - } - } - } - } - } - } - if (bestMods === null) return null; + let pickedMods = get_mods_recursive( + currentStats, + targetVals, + distances, + availableTunings, + 0, + availableArtificeCount, + maxMajorMods, + maxMods + ); + + if (pickedMods === null) return null; const usedMods = []; - for (let i = 0; i < bestMods.length; i++) { - for (let n = 0; n < bestMods[i][0]; n++) usedMods.push(3 + 3 * i); - for (let n = 0; n < bestMods[i][1]; n++) usedMods.push(1 + 3 * i); - for (let n = 0; n < bestMods[i][2]; n++) usedMods.push(2 + 3 * i); + const modBonus = [0, 0, 0, 0, 0, 0]; + // The last entry is always the tuning + for (let i = 0; i < pickedMods.length - 1; i++) { + for (let n = 0; n < pickedMods[i][1]; n++) { + usedMods.push(1 + 3 * i); + modBonus[i] += 5; + } + for (let n = 0; n < pickedMods[i][2]; n++) { + usedMods.push(2 + 3 * i); + modBonus[i] += 10; + } + for (let n = 0; n < pickedMods[i][0]; n++) { + usedMods.push(3 + 3 * i); + modBonus[i] += 3; + } } - return usedMods; + return { + mods: usedMods, + modBonus: modBonus, + tuning: pickedMods[pickedMods.length - 1] as Tuning, + }; } export function getSkillTier(stats: number[]) { diff --git a/src/app/services/status-provider.service.ts b/src/app/services/status-provider.service.ts index 6249194a..fae11c49 100644 --- a/src/app/services/status-provider.service.ts +++ b/src/app/services/status-provider.service.ts @@ -15,11 +15,11 @@ * along with this program. If not, see . */ -import { Injectable } from "@angular/core"; -import { NGXLogger } from "ngx-logger"; +import { Injectable, OnDestroy } from "@angular/core"; +import { LoggingProxyService } from "./logging-proxy.service"; import { BehaviorSubject, Observable } from "rxjs"; import { isEqual as _isEqual } from "lodash"; -import { getDifferences } from "../data/commonFunctions"; +import { getHumanReadableDifferences } from "../data/commonFunctions"; export interface Status { cancelledCalculation: boolean; @@ -37,7 +37,7 @@ export interface Status { @Injectable({ providedIn: "root", }) -export class StatusProviderService { +export class StatusProviderService implements OnDestroy { private __status: Status = { cancelledCalculation: false, calculatingResults: false, @@ -56,11 +56,16 @@ export class StatusProviderService { private _status: BehaviorSubject; public readonly status: Observable; - constructor(private logger: NGXLogger) { + constructor(private logger: LoggingProxyService) { + this.logger.debug("StatusProviderService", "constructor", "Initializing StatusProviderService"); this._status = new BehaviorSubject(this.__status); this.status = this._status.asObservable(); } + ngOnDestroy(): void { + this.logger.debug("StatusProviderService", "ngOnDestroy", "Destroying StatusProviderService"); + } + getStatus() { return this.__status; } @@ -71,11 +76,15 @@ export class StatusProviderService { this.logger.debug( "StatusProviderService", "modifyStatus", - `Status changed: ${JSON.stringify(getDifferences(this.__last_Status, this.__status))}` + `Status changed: ${getHumanReadableDifferences(this.__last_Status, this.__status)}` ); } this.__last_Status = structuredClone(this.__status); - this._status.next(this.__status); + + // Push status update to next microtask to avoid change detection conflicts + setTimeout(() => { + this._status.next(this.__status); + }, 0); } setApiError() { diff --git a/src/app/services/user-information.service.ts b/src/app/services/user-information.service.ts new file mode 100644 index 00000000..45e21b47 --- /dev/null +++ b/src/app/services/user-information.service.ts @@ -0,0 +1,500 @@ +/* + * Copyright (c) 2023 D2ArmorPicker by Mijago. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { Injectable, OnDestroy } from "@angular/core"; +import { LoggingProxyService } from "./logging-proxy.service"; +import { DatabaseService } from "./database.service"; +import { ArmorSystem, IManifestArmor } from "../data/types/IManifestArmor"; +import { ConfigurationService } from "./configuration.service"; +import { debounceTime } from "rxjs/operators"; +import { Observable, ReplaySubject } from "rxjs"; +import { StatusProviderService } from "./status-provider.service"; +import { BungieApiService } from "./bungie-api.service"; +import { AuthService } from "./auth.service"; +import { HttpClientService } from "./http-client.service"; +import { ArmorSlot } from "../data/enum/armor-slot"; +import { IInventoryArmor, InventoryArmorSource } from "../data/types/IInventoryArmor"; +import { DestinyClass } from "bungie-api-ts/destiny2"; +import { VendorsService } from "./vendors.service"; +import { isEqual as _isEqual } from "lodash"; +import { MembershipService } from "./membership.service"; + +export type ClassExoticInfo = { + inInventory: boolean; + inCollection: boolean; + inVendor: boolean; + items: IManifestArmor[]; + instances: IInventoryArmor[]; +}; + +@Injectable({ + providedIn: "root", +}) +export class UserInformationService implements OnDestroy { + private initialized: boolean = false; + private fetchingManifest: boolean = false; + + private _characters: ReplaySubject< + { emblemUrl: string; characterId: string; clazz: DestinyClass; lastPlayed: number }[] + >; + public readonly characters: Observable< + { emblemUrl: string; characterId: string; clazz: DestinyClass; lastPlayed: number }[] + >; + private _manifest: ReplaySubject; + public readonly manifest: Observable; + private _inventory: ReplaySubject; + public readonly inventory: Observable; + + private refreshing: boolean = false; + + constructor( + private db: DatabaseService, + private config: ConfigurationService, + private status: StatusProviderService, + private api: BungieApiService, + private auth: AuthService, + private httpClient: HttpClientService, + private vendors: VendorsService, + private membership: MembershipService, + private logger: LoggingProxyService + ) { + logger.debug("UserInformationService", "constructor", "Initializing UserInformationService"); + this._characters = new ReplaySubject(1); + this.characters = this._characters.asObservable(); + this._inventory = new ReplaySubject(1); + this.inventory = this._inventory.asObservable(); + this._manifest = new ReplaySubject(1); + this.manifest = this._manifest.asObservable(); + + // Clear character data on logout + this.auth.logoutEvent.subscribe((k) => this.clearCachedCharacterData()); + + this.loadCachedCharacterData(); + // Only initialize if user is already authenticated + if (this.httpClient.isAuthenticated()) { + this.updateCharacterData(); + } + + this.config.configuration.pipe(debounceTime(1000)).subscribe(async (c) => { + this.logger.debug( + "UserInformationService", + "Config Observable", + "Configuration changed, requesting manifest/inventory refresh if needed" + ); + this.requestRefreshManifestAndInventoryOnUserInteraction(); + }); + + logger.debug( + "UserInformationService", + "constructor", + "Finished initializing UserInformationService" + ); + } + + /** + * Initialize data loading for authenticated users + * This should only be called after successful authentication + */ + public initializeForAuthenticatedUser(): void { + this.logger.debug( + "UserInformationService", + "initializeForAuthenticatedUser", + "Starting initialization for authenticated user" + ); + this.updateCharacterData(); + } + + private async requestRefreshManifestAndInventoryOnUserInteraction() { + if (!this.httpClient.isAuthenticated()) { + this.logger.info( + "UserInformationService", + "requestRefreshManifestAndInventoryOnUserInteraction", + "User is not authenticated, skipping router event handling" + ); + return; + } + + if (this.fetchingManifest) { + this.logger.warn( + "UserInformationService", + "requestRefreshManifestAndInventoryOnUserInteraction", + "Manifest fetch request in progress, skipping" + ); + return; + } + this.fetchingManifest = true; + await this.refreshManifestAndInventory(); + this.fetchingManifest = false; + this.initialized = true; + } + + async refreshManifestAndInventory( + forceUpdateManifest: boolean = false, + forceUpdateInventoryArmor: boolean = false + ) { + if (this.refreshing) { + this.logger.warn( + "UserInformationService", + "refreshManifestAndInventory", + "Refresh already in progress, skipping new refresh request" + ); + return; + } + this.refreshing = true; + this.logger.debug( + "UserInformationService", + "refreshManifestAndInventory", + "Refreshing inventory and manifest" + ); + try { + let manifestUpdated = false; + //let armorUpdated = false; + try { + manifestUpdated = await this.updateManifestItems(forceUpdateManifest); + await this.updateInventoryItems(manifestUpdated || forceUpdateInventoryArmor); + this.updateVendorsAsync(); + } catch (e) { + this.logger.error("UserInformationService", "refreshManifestAndInventory", "Error: " + e); + } + } finally { + this.refreshing = false; + } + } + + private async triggerInventoryUpdate( + triggerInventoryUpdate: boolean = false, + triggerResultsUpdate: boolean = true + ) { + // trigger armor update behaviour + try { + if (triggerInventoryUpdate) { + this.logger.debug( + "UserInformationService", + "triggerInventoryUpdate", + "Inventory update triggered, refreshing inventory observable" + ); + this._inventory.next(null); + } + } catch (e) { + this.logger.error("UserInformationService", "triggerInventoryUpdate", "Error: " + e); + } + } + + private updateVendorsAsync() { + if (this.status.getStatus().updatingVendors) return; + + if (!this.vendors.isVendorCacheValid()) { + // Check if the user is authenticated before attempting to update vendor cache, if not, skip the update to avoid unnecessary API calls and errors + if (!this.httpClient.isAuthenticated()) { + this.logger.debug( + "UserInformationService", + "updateVendorsAsync", + "User is not authenticated, skipping vendor cache update" + ); + return; + } + + this.status.modifyStatus((s) => (s.updatingVendors = true)); + this.vendors + .updateVendorArmorItemsCache() + .then((success) => { + const config = this.config.currentConfiguration; + if (success && config.includeVendorRolls) { + this.triggerInventoryUpdate(success); + } + }) + .catch((e) => { + this.logger.error( + "UserInformationService", + "updateVendorsAsync", + "Error updating vendor cache: " + e + ); + }) + .finally(() => { + this.status.modifyStatus((s) => (s.updatingVendors = false)); + }); + } + } + + async getItemCountForClass(clazz: DestinyClass, slot?: ArmorSlot) { + let pieces = await this.db.inventoryArmor.where("clazz").equals(clazz).toArray(); + if (!!slot) pieces = pieces.filter((i) => i.slot == slot); + //if (!this._config.includeVendorRolls) pieces = pieces.filter((i) => i.source != InventoryArmorSource.Vendor); + //if (!this._config.includeCollectionRolls) pieces = pieces.filter((i) => i.source != InventoryArmorSource.Collections); + pieces = pieces.filter((i) => i.source == InventoryArmorSource.Inventory); + return pieces.length; + } + + async getExoticsForClass(clazz: DestinyClass, slot?: ArmorSlot): Promise { + let inventory = await this.db.inventoryArmor.where("isExotic").equals(1).toArray(); + inventory = inventory.filter( + (d) => + d.clazz == clazz && + (d.armorSystem == ArmorSystem.Armor2 || d.armorSystem == ArmorSystem.Armor3) && + (!slot || d.slot == slot) + ); + + let exotics = await this.db.manifestArmor.where("isExotic").equals(1).toArray(); + exotics = exotics.filter( + (d) => + d.clazz == clazz && + (d.armorSystem == ArmorSystem.Armor2 || d.armorSystem == ArmorSystem.Armor3) && + (!slot || d.slot == slot) + ); + + return exotics + .map((ex) => { + const instances = inventory.filter((i) => i.hash == ex.hash); + return { + items: [ex], + instances: instances, + inCollection: + instances.find((i) => i.source === InventoryArmorSource.Collections) !== undefined, + inInventory: + instances.find((i) => i.source === InventoryArmorSource.Inventory) !== undefined, + inVendor: instances.find((i) => i.source === InventoryArmorSource.Vendor) !== undefined, + }; + }) + .reduce((acc: ClassExoticInfo[], curr: ClassExoticInfo) => { + const existing = acc.find((e) => e.items[0].name === curr.items[0].name); + if (existing) { + existing.items.push(curr.items[0]); + existing.instances.push(...curr.instances); + existing.inCollection = existing.inCollection || curr.inCollection; + existing.inInventory = existing.inInventory || curr.inInventory; + existing.inVendor = existing.inVendor || curr.inVendor; + } else { + acc.push({ ...curr, items: [curr.items[0]] }); + } + return acc; + }, [] as ClassExoticInfo[]) + .sort((x, y) => x.items[0].name.localeCompare(y.items[0].name)); + } + + async updateManifestItems(force: boolean = false): Promise { + if (this.status.getStatus().updatingManifest) { + this.logger.error( + "UserInformationService", + "executeUpdateManifest", + "Already updating the manifest - abort" + ); + return false; + } + this.status.modifyStatus((s) => (s.updatingManifest = true)); + + try { + // Update manifest only + const manifestResult = await this.api.updateManifest(force); + + if (!!manifestResult) { + this._manifest.next(null); + } + + return !!manifestResult; + } finally { + this.status.modifyStatus((s) => (s.updatingManifest = false)); + } + } + + public clearCachedCharacterData() { + this.logger.debug( + "UserInformationService", + "clearCachedCharacterData", + "Clearing cached character data" + ); + localStorage.removeItem("user-characters"); + localStorage.removeItem("user-characters-lastDate"); + localStorage.removeItem("user-materials"); + this._characters.next([]); + } + + public isCharacterCacheValid(): boolean { + const characterCache = this.getCharacterCache(); + return characterCache ? this.api.isCharacterCacheValid(characterCache) : false; + } + + private loadCachedCharacterData() { + const characterCache = this.getCharacterCache(); + if (characterCache && this.api.isCharacterCacheValid(characterCache)) { + this._characters.next(characterCache.characters); + } else { + this._characters.next([]); + this.logger.info( + "UserInformationService", + "loadCachedCharacterData", + "No valid cached character data found" + ); + } + } + + private getCharacterCache(): { updatedAt: number; characters: any[] } | null { + const charactersData = localStorage.getItem("user-characters"); + const timestamp = localStorage.getItem("user-characters-lastDate"); + + if (!charactersData || !timestamp) { + return null; + } + + try { + return { + updatedAt: parseInt(timestamp), + characters: JSON.parse(charactersData), + }; + } catch (e) { + this.logger.warn( + "UserInformationService", + "getCharacterCache", + "Failed to parse cached character data: " + e + ); + return null; + } + } + + private async updateCharacterData() { + // Don't update character data if user is not authenticated + if (!this.httpClient.isAuthenticated()) { + this.logger.debug( + "UserInformationService", + "updateCharacterData", + "User not authenticated, skipping character data update" + ); + return; + } + + // Check if character cache is still valid + const characterCache = this.getCharacterCache(); + if (characterCache && this.api.isCharacterCacheValid(characterCache)) { + this.logger.info( + "UserInformationService", + "updateCharacterData", + "Character cache is still valid, skipping update" + ); + this._characters.next(characterCache.characters); + return; + } + + this.logger.info( + "UserInformationService", + "updateCharacterData", + "Fetching fresh character data" + ); + + const fetchedCharacters = await this.membership.getCharacters(); + this._characters.next(fetchedCharacters); + this.config.modifyConfiguration((d) => { + if (d.characterClass == DestinyClass.Unknown && fetchedCharacters.length > 0) { + d.characterClass = fetchedCharacters[0].clazz; + } + }); + + // Store both the data and timestamp + localStorage.setItem("user-characters", JSON.stringify(fetchedCharacters)); + localStorage.setItem("user-characters-lastDate", Date.now().toString()); + } + + public async forceUpdateCharacterData(): Promise { + // Don't update character data if user is not authenticated + if (!this.httpClient.isAuthenticated()) { + this.logger.debug( + "UserInformationService", + "forceUpdateCharacterData", + "User not authenticated, skipping forced character data update" + ); + return; + } + + this.logger.info( + "UserInformationService", + "forceUpdateCharacterData", + "Forcing character data refresh" + ); + + const fetchedCharacters = await this.membership.getCharacters(); + this._characters.next(fetchedCharacters); + this.config.modifyConfiguration((d) => { + if (d.characterClass == DestinyClass.Unknown && fetchedCharacters.length > 0) { + d.characterClass = fetchedCharacters[0].clazz; + } + }); + + // Store both the data and timestamp + localStorage.setItem("user-characters", JSON.stringify(fetchedCharacters)); + localStorage.setItem("user-characters-lastDate", Date.now().toString()); + } + + async updateInventoryItems(force: boolean = false, errorLoop = 0): Promise { + // Don't update inventory data if user is not authenticated + if (!this.httpClient.isAuthenticated()) { + this.logger.debug( + "UserInformationService", + "updateInventoryItems", + "User not authenticated, skipping inventory update" + ); + return false; + } + + this.status.modifyStatus((s) => (s.updatingInventory = true)); + + try { + let inventory = await this.api.updateInventory(force).finally(() => { + this.status.modifyStatus((s) => (s.updatingInventory = false)); + }); + if (!!inventory) { + this.logger.info( + "UserInformationService", + "updateInventoryItems", + "Inventory updated successfully" + ); + this._inventory.next(null); + } + return !!inventory; + } catch (e) { + // After three tries, call it a day. + if (errorLoop > 3) { + alert( + "You encountered a strange error with the inventory update. Please log out and log in again. If that does not fix it, please message Mijago." + ); + return false; + } + + this.status.modifyStatus((s) => (s.updatingInventory = false)); + this.logger.error("UserInformationService", "updateInventoryItems", "Error: " + e); + + await this.status.setApiError(); + + //await this.updateManifest(true); + //return await this.updateInventoryItems(true, errorLoop++); + return false; + } + } + + get isInitialized(): boolean { + return this.initialized; + } + + get isRefreshing(): boolean { + return this.refreshing; + } + + get isFetchingManifest(): boolean { + return this.fetchingManifest; + } + + ngOnDestroy(): void { + this.logger.debug("UserInformationService", "ngOnDestroy", "Destroying UserInformationService"); + } +} diff --git a/src/app/services/userdata.service.ts b/src/app/services/userdata.service.ts deleted file mode 100644 index 751f9265..00000000 --- a/src/app/services/userdata.service.ts +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright (c) 2023 D2ArmorPicker by Mijago. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -import { Injectable } from "@angular/core"; -import { AuthService } from "./auth.service"; -import { InventoryService } from "./inventory.service"; -import { DestinyClass } from "bungie-api-ts/destiny2/interfaces"; -import { MembershipService } from "./membership.service"; -import { ConfigurationService } from "./configuration.service"; - -@Injectable({ - providedIn: "root", -}) -export class UserdataService { - public characters: - | any[] - | { emblemUrl: string; characterId: string; clazz: DestinyClass; lastPlayed: number }[] = []; - - constructor( - private auth: AuthService, - private config: ConfigurationService, - private membership: MembershipService, - private inventory: InventoryService - ) { - this.loadCachedData(); - - this.auth.logoutEvent.subscribe((k) => this.clearCachedData()); - - this.inventory.inventory.subscribe(async () => { - await this.updateCharacterData(); - }); - } - - public clearCachedData() { - this.characters = []; - localStorage.removeItem("cachedCharacters"); - } - - private loadCachedData() { - let item = localStorage.getItem("cachedCharacters") || "[]"; - this.characters = JSON.parse(item); - } - - private async updateCharacterData() { - this.characters = await this.membership.getCharacters(); - this.config.modifyConfiguration((d) => { - if (d.characterClass == DestinyClass.Unknown) d.characterClass = this.characters[0].clazz; - }); - localStorage.setItem("cachedCharacters", JSON.stringify(this.characters)); - } -} diff --git a/src/app/services/vendors.service.ts b/src/app/services/vendors.service.ts index 296f8afc..f6a2d3b6 100644 --- a/src/app/services/vendors.service.ts +++ b/src/app/services/vendors.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from "@angular/core"; +import { Injectable, OnDestroy } from "@angular/core"; import { getVendors, getVendor, @@ -19,9 +19,9 @@ import { HttpClientService } from "./http-client.service"; import { DatabaseService } from "./database.service"; import { AuthService } from "./auth.service"; import { intersection as _intersection } from "lodash"; -import { NGXLogger } from "ngx-logger"; +import { LoggingProxyService } from "./logging-proxy.service"; -const VENDOR_NEXT_REFRESH_KEY = "vendor-next-refresh-time"; +const VENDOR_NEXT_REFRESH_KEY = "user-vendor-nextRefreshTime"; interface VendorWithParent { vendorHash: number; @@ -31,20 +31,25 @@ interface VendorWithParent { @Injectable({ providedIn: "root", }) -export class VendorsService { +export class VendorsService implements OnDestroy { constructor( private membership: MembershipService, private http: HttpClientService, private db: DatabaseService, private auth: AuthService, - private logger: NGXLogger + private logger: LoggingProxyService ) { + this.logger.debug("VendorsService", "constructor", "Initializing VendorsService"); this.auth.logoutEvent.subscribe((k) => this.clearCachedData()); } + ngOnDestroy(): void { + this.logger.debug("VendorsService", "ngOnDestroy", "Destroying VendorsService"); + } + private clearCachedData() { localStorage.removeItem(VENDOR_NEXT_REFRESH_KEY); - this.db.inventoryArmor.where({ source: InventoryArmorSource.Vendor }).delete(); + //this.db.inventoryArmor.where({ source: InventoryArmorSource.Vendor }).delete(); } private async getVendorArmorItemsForCharacter( @@ -241,17 +246,40 @@ export class VendorsService { try { const vendorArmorItems = await Promise.all( - characters.map(({ characterId }) => - this.getVendorArmorItemsForCharacter(manifestItems, destinyMembership, characterId) - ) + characters.map(({ characterId }) => { + if (destinyMembership) { + return this.getVendorArmorItemsForCharacter( + manifestItems, + destinyMembership, + characterId + ); + } else { + this.logger.error( + "VendorsService", + "updateVendorArmorItemsCache", + "No destiny membership found, cannot fetch vendor items" + ); + return { items: [], nextRefreshDate: Date.now() }; + } + }) ); const allItems = vendorArmorItems.flatMap(({ items }) => items); - const nextRefreshDate = Math.max( - Math.min(...vendorArmorItems.map(({ nextRefreshDate }) => nextRefreshDate)), - Date.now() + 1000 * 60 * 10 + const vendorItemsNextRefreshDate = Math.min( + ...vendorArmorItems.map(({ nextRefreshDate }) => nextRefreshDate) ); - this.writeVendorCache(allItems, new Date(nextRefreshDate)); + if (vendorItemsNextRefreshDate <= 0 || vendorItemsNextRefreshDate === Infinity) { + this.logger.warn("VendorsService", "updateVendorArmorItemsCache", "No vendor items found"); + return false; + } + const nextRefreshDate = Math.max(vendorItemsNextRefreshDate, Date.now() + 1000 * 60 * 10); + await this.writeVendorCache(allItems, new Date(nextRefreshDate)).catch((e) => { + this.logger.error( + "VendorsService", + "updateVendorArmorItemsCache", + `Failed to write vendor cache: ${e}` + ); + }); return true; } catch (e) { this.logger.error( @@ -262,7 +290,13 @@ export class VendorsService { // refresh sooner if we failed to update the cache const nextRefreshDate = new Date(); nextRefreshDate.setMinutes(nextRefreshDate.getMinutes() + 5); - this.writeVendorCache([], new Date(nextRefreshDate)); + await this.writeVendorCache([], new Date(nextRefreshDate)).catch((e) => { + this.logger.error( + "VendorsService", + "updateVendorArmorItemsCache", + `Failed to write vendor cache after error: ${e}` + ); + }); return false; } } diff --git a/src/index.html b/src/index.html index e856538a..059c0421 100644 --- a/src/index.html +++ b/src/index.html @@ -21,7 +21,7 @@ D2ArmorPicker - + diff --git a/src/main.ts b/src/main.ts index 89387cc5..d5e2b684 100644 --- a/src/main.ts +++ b/src/main.ts @@ -21,6 +21,31 @@ import { platformBrowserDynamic } from "@angular/platform-browser-dynamic"; import { AppModule } from "./app/app.module"; import { environment } from "./environments/environment"; +import * as Sentry from "@sentry/angular"; + +// Only initialize Sentry if DSN is provided and valid +if (environment.sentryDsn && environment.sentryDsn.trim() !== "") { + try { + Sentry.init({ + dsn: environment.sentryDsn, + environment: environment.version, + // Setting this option to true will send default PII data to Sentry. + // For example, automatic IP address collection on events + sendDefaultPii: true, + integrations: [ + // send console.log, console.warn, and console.error calls as logs to Sentry + Sentry.consoleLoggingIntegration({ levels: ["warn", "error", "log", "info", "debug"] }), // Reduced log levels + ], + enableLogs: true, + // Add transport options to help with CORS + }); + } catch (error) { + console.warn("Failed to initialize Sentry:", error); + } +} else { + console.log("Sentry DSN not provided, skipping Sentry initialization"); +} + if (environment.production) { enableProdMode(); }
- {{ ["", "Weapon", "Health", "Class", "Grenade", "Super", "Melee"][idx1] }} + {{ ArmorStatNames[stat] }}
+ [style.width]="(clusterInformation[idx].mean[stat] / 40) * 100 + '%'">
- {{ clusterInformation[idx].mean[idx1] | number: "1.0-0" }} + {{ clusterInformation[idx].mean[stat] | number: "1.0-0" }}