commit 0b6f9bb0266d2ba600451cfb4e9d088df2b23593
Author: 1921703654 <1921703654@qq.com>
Date: Wed Sep 3 02:27:44 2025 +0800
Initial commit
diff --git a/.coderabbit.yaml b/.coderabbit.yaml
new file mode 100644
index 00000000..29d9043a
--- /dev/null
+++ b/.coderabbit.yaml
@@ -0,0 +1,19 @@
+# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
+language: "en-US"
+early_access: false
+reviews:
+ profile: "chill"
+ request_changes_workflow: false
+ changed_files_summary: false
+ high_level_summary: false
+ poem: false
+ review_status: true
+ commit_status: false
+ collapse_walkthrough: false
+ sequence_diagrams: false
+ related_prs: false
+ auto_review:
+ enabled: true
+ drafts: false
+chat:
+ auto_reply: true
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 00000000..a9a423e6
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,8 @@
+**/.dockerignore
+**/.git
+Dockerfile*
+.*
+docker-data/
+node_modules/
+static/data/
+logs/
diff --git a/.eslintrc b/.eslintrc
new file mode 100644
index 00000000..d21f4aa7
--- /dev/null
+++ b/.eslintrc
@@ -0,0 +1,47 @@
+{
+ "plugins": ["@typescript-eslint", "prettier", "import"],
+ "extends": [
+ "eslint:recommended",
+ "plugin:@typescript-eslint/recommended",
+ "plugin:@typescript-eslint/recommended-requiring-type-checking",
+ "plugin:import/recommended",
+ "plugin:import/typescript"
+ ],
+ "env": {
+ "browser": true,
+ "es6": true,
+ "node": true
+ },
+ "rules": {
+ "@typescript-eslint/consistent-type-imports": "error",
+ "@typescript-eslint/explicit-function-return-type": "error",
+ "@typescript-eslint/restrict-template-expressions": "error",
+ "@typescript-eslint/restrict-plus-operands": "error",
+ "@typescript-eslint/no-unsafe-member-access": "error",
+ "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", "caughtErrors": "none" }],
+ "@typescript-eslint/no-unsafe-argument": "error",
+ "@typescript-eslint/no-unsafe-call": "error",
+ "@typescript-eslint/no-unsafe-assignment": "error",
+ "@typescript-eslint/no-explicit-any": "off",
+ "no-loss-of-precision": "error",
+ "@typescript-eslint/no-unnecessary-condition": "error",
+ "@typescript-eslint/no-base-to-string": "off",
+ "no-case-declarations": "error",
+ "prettier/prettier": "error",
+ "no-mixed-spaces-and-tabs": "error",
+ "@typescript-eslint/require-await": "error",
+ "import/no-named-as-default-member": "off",
+ "import/no-cycle": "warn"
+ },
+ "parser": "@typescript-eslint/parser",
+ "parserOptions": {
+ "project": "./tsconfig.json"
+ },
+ "settings": {
+ "import/extensions": [ ".ts" ],
+ "import/resolver": {
+ "typescript": true,
+ "node": true
+ }
+ }
+}
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 00000000..b3bfce24
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,4 @@
+# Auto detect text files and perform LF normalization
+* text=auto eol=lf
+
+static/webui/libs/ linguist-vendored
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 00000000..55e09ee1
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,6 @@
+version: 2
+updates:
+ - package-ecosystem: "npm"
+ directory: "/"
+ schedule:
+ interval: "weekly"
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 00000000..10fe1c52
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,29 @@
+name: Build
+on:
+ push:
+ branches: ["main"]
+ pull_request: {}
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4.1.2
+ - name: Setup Node.js environment
+ uses: actions/setup-node@v4.0.2
+ with:
+ node-version: ">=20.18.1"
+ - run: npm ci
+ - run: cp config-vanilla.json config.json
+ - run: npm run verify
+ - run: npm run lint:ci
+ - run: npm run prettier
+ - run: npm run update-translations
+ - name: Fail if there are uncommitted changes
+ run: |
+ if [[ -n "$(git status --porcelain)" ]]; then
+ echo "Uncommitted changes detected:"
+ git status
+ git --no-pager diff
+ exit 1
+ fi
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
new file mode 100644
index 00000000..4a97729c
--- /dev/null
+++ b/.github/workflows/docker.yml
@@ -0,0 +1,44 @@
+name: Build Docker image
+on:
+ push:
+ branches:
+ - main
+jobs:
+ docker-amd64:
+ if: github.repository == 'OpenWF/SpaceNinjaServer'
+ runs-on: amd64
+ steps:
+ - name: Set up Docker buildx
+ uses: docker/setup-buildx-action@v3
+ - name: Log in to container registry
+ uses: docker/login-action@v3
+ with:
+ username: openwf
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+ - name: Build and push
+ uses: docker/build-push-action@v6
+ with:
+ platforms: linux/amd64
+ push: true
+ tags: |
+ openwf/spaceninjaserver:latest
+ openwf/spaceninjaserver:${{ github.sha }}
+ docker-arm64:
+ if: github.repository == 'OpenWF/SpaceNinjaServer'
+ runs-on: arm64
+ steps:
+ - name: Set up Docker buildx
+ uses: docker/setup-buildx-action@v3
+ - name: Log in to container registry
+ uses: docker/login-action@v3
+ with:
+ username: openwf
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+ - name: Build and push
+ uses: docker/build-push-action@v6
+ with:
+ platforms: linux/arm64
+ push: true
+ tags: |
+ openwf/spaceninjaserver:latest-arm64
+ openwf/spaceninjaserver:${{ github.sha }}-arm64
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..2661a52a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,21 @@
+/node_modules
+/build
+
+/.env
+/config.json
+/static/data/**
+!/static/data/.gitkeep
+yarn.lock
+/tmp
+
+# JetBrains/webstorm configs
+.idea/
+
+# logs
+/logs
+
+# MongoDB VSCode extension playground scripts
+/database_scripts
+
+# Default Docker directory
+/docker-data
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 00000000..4fd02195
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1 @@
+engine-strict=true
\ No newline at end of file
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 00000000..9de22568
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+lts/iron
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 00000000..e1ab4819
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,5 @@
+src/routes/api.ts
+static/webui/libs/
+*.html
+*.md
+config-vanilla.json
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 00000000..47912fcd
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,27 @@
+{
+ "tabWidth": 4,
+ "useTabs": false,
+ "endOfLine": "auto",
+ "trailingComma": "none",
+ "htmlWhitespaceSensitivity": "css",
+ "insertPragma": false,
+ "jsxSingleQuote": false,
+ "printWidth": 120,
+ "proseWrap": "never",
+ "quoteProps": "as-needed",
+ "requirePragma": false,
+ "semi": true,
+ "singleQuote": false,
+ "vueIndentScriptAndStyle": true,
+ "arrowParens": "avoid",
+ "bracketSpacing": true,
+ "overrides": [
+ {
+ "files": "*.json",
+ "options": {
+ "tabWidth": 2,
+ "printWidth": 200
+ }
+ }
+ ]
+}
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 00000000..940260d8
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,3 @@
+{
+ "recommendations": ["dbaeumer.vscode-eslint"]
+}
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 00000000..fbeaa0be
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,18 @@
+// Use IntelliSense to learn about possible attributes.
+// Hover to view descriptions of existing attributes.
+// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
+{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "type": "node",
+ "request": "launch",
+ "name": "Debug and Watch",
+ "args": ["${workspaceFolder}/scripts/dev.js"],
+ "console": "integratedTerminal"
+ }
+ ]
+}
+
+//can use "console": "internalConsole" for VS Code's Debug Console. For that, forceConsole in logger.ts is needed to be true
+//"internalConsoleOptions": "openOnSessionStart" can be useful then
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 00000000..e3cf3d5a
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,3 @@
+{
+ "typescript.preferences.preferTypeOnlyAutoImports": true
+}
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000..7a1b6292
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,17 @@
+## In General
+
+### Prerequisites
+
+Use `npm i` or `npm ci` to install all dependencies.
+
+### Testing
+
+Use `npm run verify` to verify that your changes pass TypeScript's checks.
+
+### Formatting
+
+Use `npm run prettier` to ensure your formatting matches the expected format. Failing to do so will cause CI failure.
+
+## WebUI Specific
+
+The translation system is designed around additions being made to `static/webui/translations/en.js`. They are copied over for translation via `npm run update-translations`. DO NOT produce non-English strings; we want them to be translated by humans who can understand the full context.
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 00000000..d4d7b1a0
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,11 @@
+FROM node:24-alpine3.21
+
+RUN apk add --no-cache bash jq
+
+COPY . /app
+WORKDIR /app
+
+RUN npm i --omit=dev --omit=optional
+RUN date '+%d %B %Y' > BUILD_DATE
+
+ENTRYPOINT ["/app/docker-entrypoint.sh"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 00000000..e62ec04c
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,674 @@
+GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+.
diff --git a/README.md b/README.md
new file mode 100644
index 00000000..cd136b9f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,38 @@
+# Space Ninja Server
+
+More information for the moment here: [https://discord.gg/PNNZ3asUuY](https://discord.gg/PNNZ3asUuY)
+
+## Project Status
+
+This project is in active development at .
+
+To get an idea of what functionality you can expect to be missing [have a look through the issues](https://onlyg.it/OpenWF/SpaceNinjaServer/issues?q=&type=all&state=open&labels=-4%2C-10&milestone=0&assignee=0&poster=). However, many things have been implemented and *should* work as expected. Please open an issue for anything where that's not the case and/or the server is reporting errors.
+
+## config.json
+
+SpaceNinjaServer requires a `config.json`. To set it up, you can copy the [config-vanilla.json](config-vanilla.json), which has most cheats disabled.
+
+- `logger.level` can be `fatal`, `error`, `warn`, `info`, `http`, `debug`, or `trace`.
+- `myIrcAddresses` can be used to point to an IRC server. If not provided, defaults to `[ myAddress ]`.
+- `worldState.eidolonOverride` can be set to `day` or `night` to lock the time to day/fass and night/vome on Plains of Eidolon/Cambion Drift.
+- `worldState.vallisOverride` can be set to `warm` or `cold` to lock the temperature on Orb Vallis.
+- `worldState.duviriOverride` can be set to `joy`, `anger`, `envy`, `sorrow`, or `fear` to lock the Duviri spiral.
+- `worldState.nightwaveOverride` will lock the nightwave season, assuming the client is new enough for it. Valid values:
+ - `RadioLegionIntermission13Syndicate` for Nora's Mix Vol. 9
+ - `RadioLegionIntermission12Syndicate` for Nora's Mix Vol. 8
+ - `RadioLegionIntermission11Syndicate` for Nora's Mix Vol. 7
+ - `RadioLegionIntermission10Syndicate` for Nora's Mix Vol. 6
+ - `RadioLegionIntermission9Syndicate` for Nora's Mix Vol. 5
+ - `RadioLegionIntermission8Syndicate` for Nora's Mix Vol. 4
+ - `RadioLegionIntermission7Syndicate` for Nora's Mix Vol. 3
+ - `RadioLegionIntermission6Syndicate` for Nora's Mix Vol. 2
+ - `RadioLegionIntermission5Syndicate` for Nora's Mix Vol. 1
+ - `RadioLegionIntermission4Syndicate` for Nora's Choice
+ - `RadioLegionIntermission3Syndicate` for Intermission III
+ - `RadioLegion3Syndicate` for Glassmaker
+ - `RadioLegionIntermission2Syndicate` for Intermission II
+ - `RadioLegion2Syndicate` for The Emissary
+ - `RadioLegionIntermissionSyndicate` for Intermission I
+ - `RadioLegionSyndicate` for The Wolf of Saturn Six
+- `allTheFissures` can be set to `normal` or `hard` to enable all fissures either in normal or steel path, respectively.
+- `worldState.circuitGameModes` can be set to an array of game modes which will override the otherwise-random pattern in The Circuit. Valid element values are `Survival`, `VoidFlood`, `Excavation`, `Defense`, `Exterminate`, `Assassination`, and `Alchemy`.
diff --git a/UPDATE AND START SERVER.bat b/UPDATE AND START SERVER.bat
new file mode 100644
index 00000000..ac8b2515
--- /dev/null
+++ b/UPDATE AND START SERVER.bat
@@ -0,0 +1,28 @@
+@echo off
+
+echo Updating SpaceNinjaServer...
+git fetch --prune
+if %errorlevel% == 0 (
+ git stash
+ git checkout -f origin/main
+
+ if exist static\data\0\ (
+ echo Updating stripped assets...
+ cd static\data\0\
+ git pull
+ cd ..\..\..\
+ )
+
+ echo Updating dependencies...
+ call npm i --omit=dev
+
+ call npm run build
+ if %errorlevel% == 0 (
+ call npm run start
+ echo SpaceNinjaServer seems to have crashed.
+ )
+)
+
+:a
+pause > nul
+goto a
diff --git a/UPDATE AND START SERVER.sh b/UPDATE AND START SERVER.sh
new file mode 100644
index 00000000..fd77f973
--- /dev/null
+++ b/UPDATE AND START SERVER.sh
@@ -0,0 +1,24 @@
+#!/bin/bash
+
+echo "Updating SpaceNinjaServer..."
+git fetch --prune
+if [ $? -eq 0 ]; then
+ git stash
+ git checkout -f origin/main
+
+ if [ -d "static/data/0/" ]; then
+ echo "Updating stripped assets..."
+ cd static/data/0/
+ git pull
+ cd ../../../
+ fi
+
+ echo "Updating dependencies..."
+ npm i --omit=dev
+
+ npm run build
+ if [ $? -eq 0 ]; then
+ npm run start
+ echo "SpaceNinjaServer seems to have crashed."
+ fi
+fi
diff --git a/config-vanilla.json b/config-vanilla.json
new file mode 100644
index 00000000..37efdda5
--- /dev/null
+++ b/config-vanilla.json
@@ -0,0 +1,77 @@
+{
+ "mongodbUrl": "mongodb://127.0.0.1:27017/openWF",
+ "logger": {
+ "files": true,
+ "level": "trace"
+ },
+ "myAddress": "localhost",
+ "httpPort": 80,
+ "httpsPort": 443,
+ "administratorNames": [],
+ "autoCreateAccount": true,
+ "skipTutorial": false,
+ "unlockAllScans": false,
+ "unlockAllShipFeatures": false,
+ "unlockAllShipDecorations": false,
+ "unlockAllFlavourItems": false,
+ "unlockAllSkins": false,
+ "unlockAllCapturaScenes": false,
+ "fullyStockedVendors": false,
+ "skipClanKeyCrafting": false,
+ "noDojoRoomBuildStage": false,
+ "noDojoDecoBuildStage": false,
+ "fastDojoRoomDestruction": false,
+ "noDojoResearchCosts": false,
+ "noDojoResearchTime": false,
+ "fastClanAscension": false,
+ "spoofMasteryRank": -1,
+ "relicRewardItemCountMultiplier": 1,
+ "nightwaveStandingMultiplier": 1,
+ "unfaithfulBugFixes": {
+ "ignore1999LastRegionPlayed": false,
+ "fixXtraCheeseTimer": false,
+ "useAnniversaryTagForOldGoals": true
+ },
+ "worldState": {
+ "creditBoost": false,
+ "affinityBoost": false,
+ "resourceBoost": false,
+ "tennoLiveRelay": false,
+ "baroTennoConRelay": false,
+ "baroAlwaysAvailable": false,
+ "baroFullyStocked": false,
+ "varziaFullyStocked": false,
+ "wolfHunt": false,
+ "orphixVenom": false,
+ "longShadow": false,
+ "hallowedFlame": false,
+ "anniversary": null,
+ "hallowedNightmares": false,
+ "hallowedNightmaresRewardsOverride": 0,
+ "proxyRebellion": false,
+ "proxyRebellionRewardsOverride": 0,
+ "galleonOfGhouls": 0,
+ "ghoulEmergenceOverride": null,
+ "plagueStarOverride": null,
+ "starDaysOverride": null,
+ "dogDaysOverride": null,
+ "dogDaysRewardsOverride": null,
+ "bellyOfTheBeast": false,
+ "bellyOfTheBeastProgressOverride": 0,
+ "eightClaw": false,
+ "eightClawProgressOverride": 0,
+ "thermiaFracturesOverride": null,
+ "thermiaFracturesProgressOverride": 0,
+ "eidolonOverride": "",
+ "vallisOverride": "",
+ "duviriOverride": "",
+ "nightwaveOverride": "",
+ "allTheFissures": "",
+ "varziaOverride": "",
+ "circuitGameModes": null,
+ "darvoStockMultiplier": 1
+ },
+ "dev": {
+ "keepVendorsExpired": false
+ }
+}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 00000000..d9f89348
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,27 @@
+services:
+ spaceninjaserver:
+ # The image to use. If you have an ARM CPU, replace 'latest' with 'latest-arm64'.
+ image: openwf/spaceninjaserver:latest
+
+ volumes:
+ - ./docker-data/conf:/app/conf
+ - ./docker-data/static-data:/app/static/data
+ - ./docker-data/logs:/app/logs
+ ports:
+ - 80:80
+ - 443:443
+
+ # Normally, the image is fetched from Docker Hub, but you can use the local Dockerfile by removing "image" above and adding this:
+ #build: .
+ # Works best when using `docker-compose up --force-recreate --build`.
+
+ depends_on:
+ - mongodb
+ mongodb:
+ image: docker.io/library/mongo:8.0.0-noble
+ environment:
+ MONGO_INITDB_ROOT_USERNAME: openwfagent
+ MONGO_INITDB_ROOT_PASSWORD: spaceninjaserver
+ volumes:
+ - ./docker-data/database:/data/db
+ command: mongod --quiet --logpath /dev/null
diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh
new file mode 100644
index 00000000..e67508c2
--- /dev/null
+++ b/docker-entrypoint.sh
@@ -0,0 +1,8 @@
+#!/bin/bash
+set -e
+
+if [ ! -f conf/config.json ]; then
+ jq --arg value "mongodb://openwfagent:spaceninjaserver@mongodb:27017/" '.mongodbUrl = $value' /app/config-vanilla.json > /app/conf/config.json
+fi
+
+exec npm run raw -- --configPath conf/config.json
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 00000000..da5a0fcc
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,5776 @@
+{
+ "name": "wf-emulator",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "wf-emulator",
+ "version": "0.1.0",
+ "license": "GNU",
+ "dependencies": {
+ "chokidar": "^4.0.3",
+ "crc-32": "^1.2.2",
+ "express": "^5",
+ "json-with-bigint": "^3.4.4",
+ "mongoose": "^8.11.0",
+ "morgan": "^1.10.0",
+ "ncp": "^2.0.0",
+ "undici": "^7.10.0",
+ "warframe-public-export-plus": "^0.5.83",
+ "warframe-riven-info": "^0.1.2",
+ "winston": "^3.17.0",
+ "winston-daily-rotate-file": "^5.0.0",
+ "ws": "^8.18.2"
+ },
+ "devDependencies": {
+ "@typescript-eslint/eslint-plugin": "^8.28.0",
+ "@typescript-eslint/parser": "^8.28.0",
+ "eslint": "^8",
+ "eslint-import-resolver-typescript": "^4.4.4",
+ "eslint-plugin-import": "^2.32.0",
+ "eslint-plugin-prettier": "^5.2.5",
+ "prettier": "^3.5.3",
+ "tree-kill": "^1.2.2"
+ },
+ "engines": {
+ "node": ">=20.18.1"
+ },
+ "optionalDependencies": {
+ "@types/express": "^5",
+ "@types/morgan": "^1.9.9",
+ "@types/websocket": "^1.0.10",
+ "@types/ws": "^8.18.1",
+ "@typescript/native-preview": "^7.0.0-dev.20250625.1",
+ "typescript": "^5.7"
+ }
+ },
+ "node_modules/@colors/colors": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz",
+ "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.1.90"
+ }
+ },
+ "node_modules/@dabh/diagnostics": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz",
+ "integrity": "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==",
+ "license": "MIT",
+ "dependencies": {
+ "colorspace": "1.1.x",
+ "enabled": "2.0.x",
+ "kuler": "^2.0.0"
+ }
+ },
+ "node_modules/@emnapi/core": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.5.tgz",
+ "integrity": "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.0.4",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.5.tgz",
+ "integrity": "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.4.tgz",
+ "integrity": "sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
+ "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ }
+ },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.12.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz",
+ "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint/eslintrc": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
+ "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^6.12.4",
+ "debug": "^4.3.2",
+ "espree": "^9.6.0",
+ "globals": "^13.19.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^4.1.0",
+ "minimatch": "^3.1.2",
+ "strip-json-comments": "^3.1.1"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/@eslint/eslintrc/node_modules/ignore": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@eslint/eslintrc/node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/@eslint/js": {
+ "version": "8.57.1",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz",
+ "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@humanwhocodes/config-array": {
+ "version": "0.13.0",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
+ "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==",
+ "deprecated": "Use @eslint/config-array instead",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanwhocodes/object-schema": "^2.0.3",
+ "debug": "^4.3.1",
+ "minimatch": "^3.0.5"
+ },
+ "engines": {
+ "node": ">=10.10.0"
+ }
+ },
+ "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/@humanwhocodes/config-array/node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/object-schema": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz",
+ "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
+ "deprecated": "Use @eslint/object-schema instead",
+ "dev": true,
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@mongodb-js/saslprep": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.3.0.tgz",
+ "integrity": "sha512-zlayKCsIjYb7/IdfqxorK5+xUMyi4vOKcFy10wKJYc63NSdKI8mNME+uJqfatkPmOSMMUiojrL58IePKBm3gvQ==",
+ "license": "MIT",
+ "dependencies": {
+ "sparse-bitfield": "^3.0.3"
+ }
+ },
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "0.2.12",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
+ "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.4.3",
+ "@emnapi/runtime": "^1.4.3",
+ "@tybys/wasm-util": "^0.10.0"
+ }
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@pkgr/core": {
+ "version": "0.2.9",
+ "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz",
+ "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.20.0 || ^14.18.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/pkgr"
+ }
+ },
+ "node_modules/@rtsao/scc": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
+ "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz",
+ "integrity": "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/body-parser": {
+ "version": "1.19.6",
+ "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
+ "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@types/connect": "*",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/connect": {
+ "version": "3.4.38",
+ "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
+ "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/express": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz",
+ "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@types/body-parser": "*",
+ "@types/express-serve-static-core": "^5.0.0",
+ "@types/serve-static": "*"
+ }
+ },
+ "node_modules/@types/express-serve-static-core": {
+ "version": "5.0.7",
+ "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.7.tgz",
+ "integrity": "sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@types/node": "*",
+ "@types/qs": "*",
+ "@types/range-parser": "*",
+ "@types/send": "*"
+ }
+ },
+ "node_modules/@types/http-errors": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
+ "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/@types/json5": {
+ "version": "0.0.29",
+ "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
+ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/mime": {
+ "version": "1.3.5",
+ "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
+ "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/@types/morgan": {
+ "version": "1.9.10",
+ "resolved": "https://registry.npmjs.org/@types/morgan/-/morgan-1.9.10.tgz",
+ "integrity": "sha512-sS4A1zheMvsADRVfT0lYbJ4S9lmsey8Zo2F7cnbYjWHP67Q0AwMYuuzLlkIM2N8gAbb9cubhIVFwcIN2XyYCkA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "24.3.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz",
+ "integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "undici-types": "~7.10.0"
+ }
+ },
+ "node_modules/@types/qs": {
+ "version": "6.14.0",
+ "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
+ "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/@types/range-parser": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
+ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/@types/send": {
+ "version": "0.17.5",
+ "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz",
+ "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@types/mime": "^1",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/serve-static": {
+ "version": "1.15.8",
+ "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz",
+ "integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@types/http-errors": "*",
+ "@types/node": "*",
+ "@types/send": "*"
+ }
+ },
+ "node_modules/@types/triple-beam": {
+ "version": "1.3.5",
+ "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz",
+ "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/webidl-conversions": {
+ "version": "7.0.3",
+ "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
+ "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/websocket": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/@types/websocket/-/websocket-1.0.10.tgz",
+ "integrity": "sha512-svjGZvPB7EzuYS94cI7a+qhwgGU1y89wUgjT6E2wVUfmAGIvRfT7obBvRtnhXCSsoMdlG4gBFGE7MfkIXZLoww==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/whatwg-url": {
+ "version": "11.0.5",
+ "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz",
+ "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/webidl-conversions": "*"
+ }
+ },
+ "node_modules/@types/ws": {
+ "version": "8.18.1",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
+ "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "8.41.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.41.0.tgz",
+ "integrity": "sha512-8fz6oa6wEKZrhXWro/S3n2eRJqlRcIa6SlDh59FXJ5Wp5XRZ8B9ixpJDcjadHq47hMx0u+HW6SNa6LjJQ6NLtw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.10.0",
+ "@typescript-eslint/scope-manager": "8.41.0",
+ "@typescript-eslint/type-utils": "8.41.0",
+ "@typescript-eslint/utils": "8.41.0",
+ "@typescript-eslint/visitor-keys": "8.41.0",
+ "graphemer": "^1.4.0",
+ "ignore": "^7.0.0",
+ "natural-compare": "^1.4.0",
+ "ts-api-utils": "^2.1.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/parser": "^8.41.0",
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/parser": {
+ "version": "8.41.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.41.0.tgz",
+ "integrity": "sha512-gTtSdWX9xiMPA/7MV9STjJOOYtWwIJIYxkQxnSV1U3xcE+mnJSH3f6zI0RYP+ew66WSlZ5ed+h0VCxsvdC1jJg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "8.41.0",
+ "@typescript-eslint/types": "8.41.0",
+ "@typescript-eslint/typescript-estree": "8.41.0",
+ "@typescript-eslint/visitor-keys": "8.41.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/project-service": {
+ "version": "8.41.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.41.0.tgz",
+ "integrity": "sha512-b8V9SdGBQzQdjJ/IO3eDifGpDBJfvrNTp2QD9P2BeqWTGrRibgfgIlBSw6z3b6R7dPzg752tOs4u/7yCLxksSQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.41.0",
+ "@typescript-eslint/types": "^8.41.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.41.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.41.0.tgz",
+ "integrity": "sha512-n6m05bXn/Cd6DZDGyrpXrELCPVaTnLdPToyhBoFkLIMznRUQUEQdSp96s/pcWSQdqOhrgR1mzJ+yItK7T+WPMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.41.0",
+ "@typescript-eslint/visitor-keys": "8.41.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/tsconfig-utils": {
+ "version": "8.41.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.41.0.tgz",
+ "integrity": "sha512-TDhxYFPUYRFxFhuU5hTIJk+auzM/wKvWgoNYOPcOf6i4ReYlOoYN8q1dV5kOTjNQNJgzWN3TUUQMtlLOcUgdUw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils": {
+ "version": "8.41.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.41.0.tgz",
+ "integrity": "sha512-63qt1h91vg3KsjVVonFJWjgSK7pZHSQFKH6uwqxAH9bBrsyRhO6ONoKyXxyVBzG1lJnFAJcKAcxLS54N1ee1OQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.41.0",
+ "@typescript-eslint/typescript-estree": "8.41.0",
+ "@typescript-eslint/utils": "8.41.0",
+ "debug": "^4.3.4",
+ "ts-api-utils": "^2.1.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/types": {
+ "version": "8.41.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.41.0.tgz",
+ "integrity": "sha512-9EwxsWdVqh42afLbHP90n2VdHaWU/oWgbH2P0CfcNfdKL7CuKpwMQGjwev56vWu9cSKU7FWSu6r9zck6CVfnag==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.41.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.41.0.tgz",
+ "integrity": "sha512-D43UwUYJmGhuwHfY7MtNKRZMmfd8+p/eNSfFe6tH5mbVDto+VQCayeAt35rOx3Cs6wxD16DQtIKw/YXxt5E0UQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.41.0",
+ "@typescript-eslint/tsconfig-utils": "8.41.0",
+ "@typescript-eslint/types": "8.41.0",
+ "@typescript-eslint/visitor-keys": "8.41.0",
+ "debug": "^4.3.4",
+ "fast-glob": "^3.3.2",
+ "is-glob": "^4.0.3",
+ "minimatch": "^9.0.4",
+ "semver": "^7.6.0",
+ "ts-api-utils": "^2.1.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "8.41.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.41.0.tgz",
+ "integrity": "sha512-udbCVstxZ5jiPIXrdH+BZWnPatjlYwJuJkDA4Tbo3WyYLh8NvB+h/bKeSZHDOFKfphsZYJQqaFtLeXEqurQn1A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.7.0",
+ "@typescript-eslint/scope-manager": "8.41.0",
+ "@typescript-eslint/types": "8.41.0",
+ "@typescript-eslint/typescript-estree": "8.41.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.41.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.41.0.tgz",
+ "integrity": "sha512-+GeGMebMCy0elMNg67LRNoVnUFPIm37iu5CmHESVx56/9Jsfdpsvbv605DQ81Pi/x11IdKUsS5nzgTYbCQU9fg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.41.0",
+ "eslint-visitor-keys": "^4.2.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@typescript/native-preview": {
+ "version": "7.0.0-dev.20250826.1",
+ "resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20250826.1.tgz",
+ "integrity": "sha512-+NuzOfk/lu6pLYSCio+R7uzJ9pfOasc1fshxVmLp6wgcB8yuUYYvBaT7CoHapUnNBYZXkJ9u0UOECnq3dbzgSQ==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "bin": {
+ "tsgo": "bin/tsgo.js"
+ },
+ "engines": {
+ "node": ">=20.6.0"
+ },
+ "optionalDependencies": {
+ "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20250826.1",
+ "@typescript/native-preview-darwin-x64": "7.0.0-dev.20250826.1",
+ "@typescript/native-preview-linux-arm": "7.0.0-dev.20250826.1",
+ "@typescript/native-preview-linux-arm64": "7.0.0-dev.20250826.1",
+ "@typescript/native-preview-linux-x64": "7.0.0-dev.20250826.1",
+ "@typescript/native-preview-win32-arm64": "7.0.0-dev.20250826.1",
+ "@typescript/native-preview-win32-x64": "7.0.0-dev.20250826.1"
+ }
+ },
+ "node_modules/@typescript/native-preview-darwin-arm64": {
+ "version": "7.0.0-dev.20250826.1",
+ "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20250826.1.tgz",
+ "integrity": "sha512-UTkmzj0+NpraW4GcgG5PECJZ25SASkiK7TP90lUx9/RZRidrClhHqyf/A3NPpI+1pqoKqGXxIt3+jV+BY15KyQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=20.6.0"
+ }
+ },
+ "node_modules/@typescript/native-preview-darwin-x64": {
+ "version": "7.0.0-dev.20250826.1",
+ "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20250826.1.tgz",
+ "integrity": "sha512-9UyLnKkJMW2T118FmS8Jj1djWDXgXGz2d9gUA/d+AdTAOaTBc1TmWhiUVcyKgIzG3+OAFIY+pVLeBWxNpFQu4Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=20.6.0"
+ }
+ },
+ "node_modules/@typescript/native-preview-linux-arm": {
+ "version": "7.0.0-dev.20250826.1",
+ "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20250826.1.tgz",
+ "integrity": "sha512-CRH103rrFme+V917lftxtcttiT69fDNZPcnNzHeuj1z6/G3eIY6Hmon4xQb/Q4CjgblcO/BIEz6zDVphlas3dA==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.6.0"
+ }
+ },
+ "node_modules/@typescript/native-preview-linux-arm64": {
+ "version": "7.0.0-dev.20250826.1",
+ "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20250826.1.tgz",
+ "integrity": "sha512-GFcWo//t482UhZW+K1Nqlp/eOO2NS1uAIR77UyPn1FK/NOQBaxg8w4AZk0OG5ZHzvut4iZwQa2Gp+8zbNnfAIA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.6.0"
+ }
+ },
+ "node_modules/@typescript/native-preview-linux-x64": {
+ "version": "7.0.0-dev.20250826.1",
+ "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20250826.1.tgz",
+ "integrity": "sha512-ePQiUwa9YpUUc5g8lu77kCOgZyAseJn14B+5Uwz7n7CFPJn48L0mcKvjr3jVP8sER0r6rFmndEr2uyHnc+Qj2w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.6.0"
+ }
+ },
+ "node_modules/@typescript/native-preview-win32-arm64": {
+ "version": "7.0.0-dev.20250826.1",
+ "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20250826.1.tgz",
+ "integrity": "sha512-kL3qH6CeOPdtAgZw1cuFgwpyup3VSkAM+ayZGk08SM42Zd90VMTDf736kXm/xxAcpvmCQ0YrH1FDIG0dPiRCAw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=20.6.0"
+ }
+ },
+ "node_modules/@typescript/native-preview-win32-x64": {
+ "version": "7.0.0-dev.20250826.1",
+ "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20250826.1.tgz",
+ "integrity": "sha512-WPiCnQDz64ZtwUEZ/ezXLHjhZTOetdfZI16ZbjZGkeZ5O3DNMaHrwzvvqqIq7gPxICEOK+6pM9eQ92I6wDq0qQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=20.6.0"
+ }
+ },
+ "node_modules/@ungap/structured-clone": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
+ "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/@unrs/resolver-binding-android-arm-eabi": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz",
+ "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-android-arm64": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz",
+ "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-darwin-arm64": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz",
+ "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-darwin-x64": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz",
+ "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-freebsd-x64": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz",
+ "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz",
+ "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz",
+ "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-arm64-gnu": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz",
+ "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-arm64-musl": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz",
+ "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz",
+ "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz",
+ "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-riscv64-musl": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz",
+ "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-s390x-gnu": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz",
+ "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-x64-gnu": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz",
+ "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-x64-musl": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz",
+ "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-wasm32-wasi": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz",
+ "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@napi-rs/wasm-runtime": "^0.2.11"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@unrs/resolver-binding-win32-arm64-msvc": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz",
+ "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-win32-ia32-msvc": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz",
+ "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-win32-x64-msvc": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz",
+ "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/accepts": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+ "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "^3.0.0",
+ "negotiator": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.15.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
+ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.12.6",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true,
+ "license": "Python-2.0"
+ },
+ "node_modules/array-buffer-byte-length": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz",
+ "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "is-array-buffer": "^3.0.5"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array-includes": {
+ "version": "3.1.9",
+ "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz",
+ "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.24.0",
+ "es-object-atoms": "^1.1.1",
+ "get-intrinsic": "^1.3.0",
+ "is-string": "^1.1.1",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.findlastindex": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz",
+ "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.9",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "es-shim-unscopables": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.flat": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz",
+ "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.5",
+ "es-shim-unscopables": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.flatmap": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz",
+ "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.5",
+ "es-shim-unscopables": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/arraybuffer.prototype.slice": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz",
+ "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-buffer-byte-length": "^1.0.1",
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.5",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "is-array-buffer": "^3.0.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/async": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
+ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
+ "license": "MIT"
+ },
+ "node_modules/async-function": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
+ "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/available-typed-arrays": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
+ "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "possible-typed-array-names": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/basic-auth": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
+ "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.1.2"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/basic-auth/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/body-parser": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
+ "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "^3.1.2",
+ "content-type": "^1.0.5",
+ "debug": "^4.4.0",
+ "http-errors": "^2.0.0",
+ "iconv-lite": "^0.6.3",
+ "on-finished": "^2.4.1",
+ "qs": "^6.14.0",
+ "raw-body": "^3.0.0",
+ "type-is": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "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/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/bson": {
+ "version": "6.10.4",
+ "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz",
+ "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=16.20.1"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
+ "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.0",
+ "es-define-property": "^1.0.0",
+ "get-intrinsic": "^1.2.4",
+ "set-function-length": "^1.2.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
+ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
+ "license": "MIT",
+ "dependencies": {
+ "readdirp": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 14.16.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/color": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz",
+ "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==",
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^1.9.3",
+ "color-string": "^1.6.0"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "license": "MIT"
+ },
+ "node_modules/color-string": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
+ "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "^1.0.0",
+ "simple-swizzle": "^0.2.2"
+ }
+ },
+ "node_modules/color/node_modules/color-convert": {
+ "version": "1.9.3",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+ "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "1.1.3"
+ }
+ },
+ "node_modules/color/node_modules/color-name": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+ "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
+ "license": "MIT"
+ },
+ "node_modules/colorspace": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz",
+ "integrity": "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==",
+ "license": "MIT",
+ "dependencies": {
+ "color": "^3.1.3",
+ "text-hex": "1.0.x"
+ }
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/content-disposition": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz",
+ "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+ "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.6.0"
+ }
+ },
+ "node_modules/crc-32": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
+ "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
+ "license": "Apache-2.0",
+ "bin": {
+ "crc32": "bin/crc32.njs"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/data-view-buffer": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
+ "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "is-data-view": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/data-view-byte-length": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz",
+ "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "is-data-view": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/inspect-js"
+ }
+ },
+ "node_modules/data-view-byte-offset": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz",
+ "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "is-data-view": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
+ "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/define-data-property": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
+ "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/define-properties": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
+ "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.0.1",
+ "has-property-descriptors": "^1.0.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/doctrine": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
+ "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "esutils": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/enabled": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz",
+ "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-abstract": {
+ "version": "1.24.0",
+ "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz",
+ "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-buffer-byte-length": "^1.0.2",
+ "arraybuffer.prototype.slice": "^1.0.4",
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "data-view-buffer": "^1.0.2",
+ "data-view-byte-length": "^1.0.2",
+ "data-view-byte-offset": "^1.0.1",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "es-set-tostringtag": "^2.1.0",
+ "es-to-primitive": "^1.3.0",
+ "function.prototype.name": "^1.1.8",
+ "get-intrinsic": "^1.3.0",
+ "get-proto": "^1.0.1",
+ "get-symbol-description": "^1.1.0",
+ "globalthis": "^1.0.4",
+ "gopd": "^1.2.0",
+ "has-property-descriptors": "^1.0.2",
+ "has-proto": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "internal-slot": "^1.1.0",
+ "is-array-buffer": "^3.0.5",
+ "is-callable": "^1.2.7",
+ "is-data-view": "^1.0.2",
+ "is-negative-zero": "^2.0.3",
+ "is-regex": "^1.2.1",
+ "is-set": "^2.0.3",
+ "is-shared-array-buffer": "^1.0.4",
+ "is-string": "^1.1.1",
+ "is-typed-array": "^1.1.15",
+ "is-weakref": "^1.1.1",
+ "math-intrinsics": "^1.1.0",
+ "object-inspect": "^1.13.4",
+ "object-keys": "^1.1.1",
+ "object.assign": "^4.1.7",
+ "own-keys": "^1.0.1",
+ "regexp.prototype.flags": "^1.5.4",
+ "safe-array-concat": "^1.1.3",
+ "safe-push-apply": "^1.0.0",
+ "safe-regex-test": "^1.1.0",
+ "set-proto": "^1.0.0",
+ "stop-iteration-iterator": "^1.1.0",
+ "string.prototype.trim": "^1.2.10",
+ "string.prototype.trimend": "^1.0.9",
+ "string.prototype.trimstart": "^1.0.8",
+ "typed-array-buffer": "^1.0.3",
+ "typed-array-byte-length": "^1.0.3",
+ "typed-array-byte-offset": "^1.0.4",
+ "typed-array-length": "^1.0.7",
+ "unbox-primitive": "^1.1.0",
+ "which-typed-array": "^1.1.19"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-shim-unscopables": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz",
+ "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-to-primitive": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz",
+ "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-callable": "^1.2.7",
+ "is-date-object": "^1.0.5",
+ "is-symbol": "^1.0.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "8.57.1",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz",
+ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
+ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.2.0",
+ "@eslint-community/regexpp": "^4.6.1",
+ "@eslint/eslintrc": "^2.1.4",
+ "@eslint/js": "8.57.1",
+ "@humanwhocodes/config-array": "^0.13.0",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@nodelib/fs.walk": "^1.2.8",
+ "@ungap/structured-clone": "^1.2.0",
+ "ajv": "^6.12.4",
+ "chalk": "^4.0.0",
+ "cross-spawn": "^7.0.2",
+ "debug": "^4.3.2",
+ "doctrine": "^3.0.0",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^7.2.2",
+ "eslint-visitor-keys": "^3.4.3",
+ "espree": "^9.6.1",
+ "esquery": "^1.4.2",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^6.0.1",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "globals": "^13.19.0",
+ "graphemer": "^1.4.0",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "is-path-inside": "^3.0.3",
+ "js-yaml": "^4.1.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "levn": "^0.4.1",
+ "lodash.merge": "^4.6.2",
+ "minimatch": "^3.1.2",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3",
+ "strip-ansi": "^6.0.1",
+ "text-table": "^0.2.0"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-import-context": {
+ "version": "0.1.9",
+ "resolved": "https://registry.npmjs.org/eslint-import-context/-/eslint-import-context-0.1.9.tgz",
+ "integrity": "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "get-tsconfig": "^4.10.1",
+ "stable-hash-x": "^0.2.0"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.18.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint-import-context"
+ },
+ "peerDependencies": {
+ "unrs-resolver": "^1.0.0"
+ },
+ "peerDependenciesMeta": {
+ "unrs-resolver": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-import-resolver-node": {
+ "version": "0.3.9",
+ "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz",
+ "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^3.2.7",
+ "is-core-module": "^2.13.0",
+ "resolve": "^1.22.4"
+ }
+ },
+ "node_modules/eslint-import-resolver-node/node_modules/debug": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+ "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.1"
+ }
+ },
+ "node_modules/eslint-import-resolver-typescript": {
+ "version": "4.4.4",
+ "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-4.4.4.tgz",
+ "integrity": "sha512-1iM2zeBvrYmUNTj2vSC/90JTHDth+dfOfiNKkxApWRsTJYNrc8rOdxxIf5vazX+BiAXTeOT0UvWpGI/7qIWQOw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "debug": "^4.4.1",
+ "eslint-import-context": "^0.1.8",
+ "get-tsconfig": "^4.10.1",
+ "is-bun-module": "^2.0.0",
+ "stable-hash-x": "^0.2.0",
+ "tinyglobby": "^0.2.14",
+ "unrs-resolver": "^1.7.11"
+ },
+ "engines": {
+ "node": "^16.17.0 || >=18.6.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint-import-resolver-typescript"
+ },
+ "peerDependencies": {
+ "eslint": "*",
+ "eslint-plugin-import": "*",
+ "eslint-plugin-import-x": "*"
+ },
+ "peerDependenciesMeta": {
+ "eslint-plugin-import": {
+ "optional": true
+ },
+ "eslint-plugin-import-x": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-module-utils": {
+ "version": "2.12.1",
+ "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz",
+ "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^3.2.7"
+ },
+ "engines": {
+ "node": ">=4"
+ },
+ "peerDependenciesMeta": {
+ "eslint": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-module-utils/node_modules/debug": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+ "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.1"
+ }
+ },
+ "node_modules/eslint-plugin-import": {
+ "version": "2.32.0",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
+ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rtsao/scc": "^1.1.0",
+ "array-includes": "^3.1.9",
+ "array.prototype.findlastindex": "^1.2.6",
+ "array.prototype.flat": "^1.3.3",
+ "array.prototype.flatmap": "^1.3.3",
+ "debug": "^3.2.7",
+ "doctrine": "^2.1.0",
+ "eslint-import-resolver-node": "^0.3.9",
+ "eslint-module-utils": "^2.12.1",
+ "hasown": "^2.0.2",
+ "is-core-module": "^2.16.1",
+ "is-glob": "^4.0.3",
+ "minimatch": "^3.1.2",
+ "object.fromentries": "^2.0.8",
+ "object.groupby": "^1.0.3",
+ "object.values": "^1.2.1",
+ "semver": "^6.3.1",
+ "string.prototype.trimend": "^1.0.9",
+ "tsconfig-paths": "^3.15.0"
+ },
+ "engines": {
+ "node": ">=4"
+ },
+ "peerDependencies": {
+ "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9"
+ }
+ },
+ "node_modules/eslint-plugin-import/node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/eslint-plugin-import/node_modules/debug": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+ "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.1"
+ }
+ },
+ "node_modules/eslint-plugin-import/node_modules/doctrine": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
+ "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "esutils": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/eslint-plugin-import/node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/eslint-plugin-import/node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/eslint-plugin-prettier": {
+ "version": "5.5.4",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.4.tgz",
+ "integrity": "sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prettier-linter-helpers": "^1.0.0",
+ "synckit": "^0.11.7"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint-plugin-prettier"
+ },
+ "peerDependencies": {
+ "@types/eslint": ">=8.0.0",
+ "eslint": ">=8.0.0",
+ "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0",
+ "prettier": ">=3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/eslint": {
+ "optional": true
+ },
+ "eslint-config-prettier": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "7.2.2",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
+ "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint/node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/eslint/node_modules/ignore": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/eslint/node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/espree": {
+ "version": "9.6.1",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
+ "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.9.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^3.4.1"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz",
+ "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
+ "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "^2.0.0",
+ "body-parser": "^2.2.0",
+ "content-disposition": "^1.0.0",
+ "content-type": "^1.0.5",
+ "cookie": "^0.7.1",
+ "cookie-signature": "^1.2.1",
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "finalhandler": "^2.1.0",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "merge-descriptors": "^2.0.0",
+ "mime-types": "^3.0.0",
+ "on-finished": "^2.4.1",
+ "once": "^1.4.0",
+ "parseurl": "^1.3.3",
+ "proxy-addr": "^2.0.7",
+ "qs": "^6.14.0",
+ "range-parser": "^1.2.1",
+ "router": "^2.2.0",
+ "send": "^1.1.0",
+ "serve-static": "^2.2.0",
+ "statuses": "^2.0.1",
+ "type-is": "^2.0.1",
+ "vary": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-diff": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
+ "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/fast-glob": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-glob/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fastq": {
+ "version": "1.19.1",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
+ "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/fecha": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz",
+ "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==",
+ "license": "MIT"
+ },
+ "node_modules/file-entry-cache": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
+ "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^3.0.4"
+ },
+ "engines": {
+ "node": "^10.12.0 || >=12.0.0"
+ }
+ },
+ "node_modules/file-stream-rotator": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/file-stream-rotator/-/file-stream-rotator-0.6.1.tgz",
+ "integrity": "sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==",
+ "license": "MIT",
+ "dependencies": {
+ "moment": "^2.29.1"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
+ "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "on-finished": "^2.4.1",
+ "parseurl": "^1.3.3",
+ "statuses": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
+ "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flatted": "^3.2.9",
+ "keyv": "^4.5.3",
+ "rimraf": "^3.0.2"
+ },
+ "engines": {
+ "node": "^10.12.0 || >=12.0.0"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
+ "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/fn.name": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz",
+ "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==",
+ "license": "MIT"
+ },
+ "node_modules/for-each": {
+ "version": "0.3.5",
+ "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
+ "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-callable": "^1.2.7"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+ "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/fs.realpath": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/function.prototype.name": {
+ "version": "1.1.8",
+ "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz",
+ "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "functions-have-names": "^1.2.3",
+ "hasown": "^2.0.2",
+ "is-callable": "^1.2.7"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/functions-have-names": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
+ "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/get-symbol-description": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz",
+ "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-tsconfig": {
+ "version": "4.10.1",
+ "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz",
+ "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "resolve-pkg-maps": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
+ }
+ },
+ "node_modules/glob": {
+ "version": "7.2.3",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+ "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+ "deprecated": "Glob versions prior to v9 are no longer supported",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.1.1",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ },
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/glob/node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/glob/node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/globals": {
+ "version": "13.24.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
+ "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "type-fest": "^0.20.2"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/globalthis": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
+ "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-properties": "^1.2.1",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/graphemer": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
+ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/has-bigints": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
+ "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/has-property-descriptors": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+ "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-proto": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz",
+ "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
+ "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "2.0.0",
+ "inherits": "2.0.4",
+ "setprototypeof": "1.2.0",
+ "statuses": "2.0.1",
+ "toidentifier": "1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/http-errors/node_modules/statuses": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
+ "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "7.0.5",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
+ "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
+ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/inflight": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+ "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
+ "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "once": "^1.3.0",
+ "wrappy": "1"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/internal-slot": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
+ "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "hasown": "^2.0.2",
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/is-array-buffer": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
+ "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "get-intrinsic": "^1.2.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-arrayish": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
+ "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==",
+ "license": "MIT"
+ },
+ "node_modules/is-async-function": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz",
+ "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "async-function": "^1.0.0",
+ "call-bound": "^1.0.3",
+ "get-proto": "^1.0.1",
+ "has-tostringtag": "^1.0.2",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-bigint": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz",
+ "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-bigints": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-boolean-object": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz",
+ "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-bun-module": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz",
+ "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.7.1"
+ }
+ },
+ "node_modules/is-callable": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
+ "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.16.1",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
+ "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-data-view": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz",
+ "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "get-intrinsic": "^1.2.6",
+ "is-typed-array": "^1.1.13"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-date-object": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz",
+ "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-finalizationregistry": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz",
+ "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-generator-function": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz",
+ "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "get-proto": "^1.0.0",
+ "has-tostringtag": "^1.0.2",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-map": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
+ "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-negative-zero": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz",
+ "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/is-number-object": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz",
+ "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-path-inside": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
+ "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-promise": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+ "license": "MIT"
+ },
+ "node_modules/is-regex": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
+ "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "gopd": "^1.2.0",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-set": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz",
+ "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-shared-array-buffer": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz",
+ "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-stream": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
+ "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-string": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz",
+ "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-symbol": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz",
+ "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-symbols": "^1.1.0",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-typed-array": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz",
+ "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "which-typed-array": "^1.1.16"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-weakmap": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
+ "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-weakref": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz",
+ "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-weakset": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz",
+ "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "get-intrinsic": "^1.2.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/isarray": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
+ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/js-yaml": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+ "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-with-bigint": {
+ "version": "3.4.4",
+ "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.4.4.tgz",
+ "integrity": "sha512-AhpYAAaZsPjU7smaBomDt1SOQshi9rEm6BlTbfVwsG1vNmeHKtEedJi62sHZzJTyKNtwzmNnrsd55kjwJ7054A==",
+ "license": "MIT"
+ },
+ "node_modules/json5": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
+ "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "minimist": "^1.2.0"
+ },
+ "bin": {
+ "json5": "lib/cli.js"
+ }
+ },
+ "node_modules/kareem": {
+ "version": "2.6.3",
+ "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz",
+ "integrity": "sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/kuler": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz",
+ "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==",
+ "license": "MIT"
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lodash.merge": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/logform": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz",
+ "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@colors/colors": "1.6.0",
+ "@types/triple-beam": "^1.3.2",
+ "fecha": "^4.2.0",
+ "ms": "^2.1.1",
+ "safe-stable-stringify": "^2.3.1",
+ "triple-beam": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
+ "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/memory-pager": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
+ "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==",
+ "license": "MIT"
+ },
+ "node_modules/merge-descriptors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+ "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
+ "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "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/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/moment": {
+ "version": "2.30.1",
+ "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
+ "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/mongodb": {
+ "version": "6.18.0",
+ "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.18.0.tgz",
+ "integrity": "sha512-fO5ttN9VC8P0F5fqtQmclAkgXZxbIkYRTUi1j8JO6IYwvamkhtYDilJr35jOPELR49zqCJgXZWwCtW7B+TM8vQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@mongodb-js/saslprep": "^1.1.9",
+ "bson": "^6.10.4",
+ "mongodb-connection-string-url": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=16.20.1"
+ },
+ "peerDependencies": {
+ "@aws-sdk/credential-providers": "^3.188.0",
+ "@mongodb-js/zstd": "^1.1.0 || ^2.0.0",
+ "gcp-metadata": "^5.2.0",
+ "kerberos": "^2.0.1",
+ "mongodb-client-encryption": ">=6.0.0 <7",
+ "snappy": "^7.2.2",
+ "socks": "^2.7.1"
+ },
+ "peerDependenciesMeta": {
+ "@aws-sdk/credential-providers": {
+ "optional": true
+ },
+ "@mongodb-js/zstd": {
+ "optional": true
+ },
+ "gcp-metadata": {
+ "optional": true
+ },
+ "kerberos": {
+ "optional": true
+ },
+ "mongodb-client-encryption": {
+ "optional": true
+ },
+ "snappy": {
+ "optional": true
+ },
+ "socks": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/mongodb-connection-string-url": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz",
+ "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/whatwg-url": "^11.0.2",
+ "whatwg-url": "^14.1.0 || ^13.0.0"
+ }
+ },
+ "node_modules/mongoose": {
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.18.0.tgz",
+ "integrity": "sha512-3TixPihQKBdyaYDeJqRjzgb86KbilEH07JmzV8SoSjgoskNTpa6oTBmDxeoF9p8YnWQoz7shnCyPkSV/48y3yw==",
+ "license": "MIT",
+ "dependencies": {
+ "bson": "^6.10.4",
+ "kareem": "2.6.3",
+ "mongodb": "~6.18.0",
+ "mpath": "0.9.0",
+ "mquery": "5.0.0",
+ "ms": "2.1.3",
+ "sift": "17.1.3"
+ },
+ "engines": {
+ "node": ">=16.20.1"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/mongoose"
+ }
+ },
+ "node_modules/morgan": {
+ "version": "1.10.1",
+ "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz",
+ "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==",
+ "license": "MIT",
+ "dependencies": {
+ "basic-auth": "~2.0.1",
+ "debug": "2.6.9",
+ "depd": "~2.0.0",
+ "on-finished": "~2.3.0",
+ "on-headers": "~1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/morgan/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/morgan/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/morgan/node_modules/on-finished": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
+ "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/mpath": {
+ "version": "0.9.0",
+ "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz",
+ "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/mquery": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz",
+ "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "4.x"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/napi-postinstall": {
+ "version": "0.3.3",
+ "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.3.tgz",
+ "integrity": "sha512-uTp172LLXSxuSYHv/kou+f6KW3SMppU9ivthaVTXian9sOt3XM/zHYHpRZiLgQoxeWfYUnslNWQHF1+G71xcow==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "napi-postinstall": "lib/cli.js"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.18.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/napi-postinstall"
+ }
+ },
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/ncp": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz",
+ "integrity": "sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA==",
+ "license": "MIT",
+ "bin": {
+ "ncp": "bin/ncp"
+ }
+ },
+ "node_modules/negotiator": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+ "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/object-hash": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
+ "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object-keys": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+ "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/object.assign": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz",
+ "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0",
+ "has-symbols": "^1.1.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object.fromentries": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz",
+ "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.2",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object.groupby": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz",
+ "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/object.values": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz",
+ "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/on-headers": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
+ "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/one-time": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz",
+ "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==",
+ "license": "MIT",
+ "dependencies": {
+ "fn.name": "1.x.x"
+ }
+ },
+ "node_modules/optionator": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/own-keys": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
+ "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "get-intrinsic": "^1.2.6",
+ "object-keys": "^1.1.1",
+ "safe-push-apply": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-is-absolute": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+ "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/path-to-regexp": {
+ "version": "8.2.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz",
+ "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/possible-typed-array-names": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
+ "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/prettier": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
+ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
+ "node_modules/prettier-linter-helpers": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz",
+ "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-diff": "^1.1.2"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.14.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
+ "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz",
+ "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "3.1.2",
+ "http-errors": "2.0.0",
+ "iconv-lite": "0.6.3",
+ "unpipe": "1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
+ "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.18.0"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/reflect.getprototypeof": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
+ "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.9",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0",
+ "get-intrinsic": "^1.2.7",
+ "get-proto": "^1.0.1",
+ "which-builtin-type": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/regexp.prototype.flags": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
+ "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-errors": "^1.3.0",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "set-function-name": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.22.10",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
+ "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-core-module": "^2.16.0",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/resolve-pkg-maps": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
+ "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rimraf": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+ "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+ "deprecated": "Rimraf versions prior to v4 are no longer supported",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/router": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+ "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "is-promise": "^4.0.0",
+ "parseurl": "^1.3.3",
+ "path-to-regexp": "^8.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/safe-array-concat": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
+ "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.2",
+ "get-intrinsic": "^1.2.6",
+ "has-symbols": "^1.1.0",
+ "isarray": "^2.0.5"
+ },
+ "engines": {
+ "node": ">=0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safe-push-apply": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
+ "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "isarray": "^2.0.5"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/safe-regex-test": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
+ "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "is-regex": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/safe-stable-stringify": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
+ "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.7.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
+ "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/send": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
+ "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.3.5",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "mime-types": "^3.0.1",
+ "ms": "^2.1.3",
+ "on-finished": "^2.4.1",
+ "range-parser": "^1.2.1",
+ "statuses": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/serve-static": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
+ "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "parseurl": "^1.3.3",
+ "send": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/set-function-length": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
+ "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2",
+ "get-intrinsic": "^1.2.4",
+ "gopd": "^1.0.1",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/set-function-name": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz",
+ "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "functions-have-names": "^1.2.3",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/set-proto": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz",
+ "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
+ "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/sift": {
+ "version": "17.1.3",
+ "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz",
+ "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==",
+ "license": "MIT"
+ },
+ "node_modules/simple-swizzle": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
+ "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==",
+ "license": "MIT",
+ "dependencies": {
+ "is-arrayish": "^0.3.1"
+ }
+ },
+ "node_modules/sparse-bitfield": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
+ "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
+ "license": "MIT",
+ "dependencies": {
+ "memory-pager": "^1.0.2"
+ }
+ },
+ "node_modules/stable-hash-x": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz",
+ "integrity": "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/stack-trace": {
+ "version": "0.0.10",
+ "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
+ "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==",
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/stop-iteration-iterator": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
+ "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "internal-slot": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "node_modules/string.prototype.trim": {
+ "version": "1.2.10",
+ "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz",
+ "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.2",
+ "define-data-property": "^1.1.4",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.5",
+ "es-object-atoms": "^1.0.0",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string.prototype.trimend": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz",
+ "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.2",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string.prototype.trimstart": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz",
+ "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-bom": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
+ "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/synckit": {
+ "version": "0.11.11",
+ "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz",
+ "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@pkgr/core": "^0.2.9"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/synckit"
+ }
+ },
+ "node_modules/text-hex": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz",
+ "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==",
+ "license": "MIT"
+ },
+ "node_modules/text-table": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
+ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.14",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
+ "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyglobby/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
+ "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tree-kill": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
+ "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "tree-kill": "cli.js"
+ }
+ },
+ "node_modules/triple-beam": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz",
+ "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.0.0"
+ }
+ },
+ "node_modules/ts-api-utils": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
+ "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.12"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4"
+ }
+ },
+ "node_modules/tsconfig-paths": {
+ "version": "3.15.0",
+ "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
+ "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/json5": "^0.0.29",
+ "json5": "^1.0.2",
+ "minimist": "^1.2.6",
+ "strip-bom": "^3.0.0"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/type-fest": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
+ "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
+ "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
+ "license": "MIT",
+ "dependencies": {
+ "content-type": "^1.0.5",
+ "media-typer": "^1.1.0",
+ "mime-types": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/typed-array-buffer": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
+ "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "is-typed-array": "^1.1.14"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/typed-array-byte-length": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz",
+ "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "for-each": "^0.3.3",
+ "gopd": "^1.2.0",
+ "has-proto": "^1.2.0",
+ "is-typed-array": "^1.1.14"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/typed-array-byte-offset": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz",
+ "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.8",
+ "for-each": "^0.3.3",
+ "gopd": "^1.2.0",
+ "has-proto": "^1.2.0",
+ "is-typed-array": "^1.1.15",
+ "reflect.getprototypeof": "^1.0.9"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/typed-array-length": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz",
+ "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "for-each": "^0.3.3",
+ "gopd": "^1.0.1",
+ "is-typed-array": "^1.1.13",
+ "possible-typed-array-names": "^1.0.0",
+ "reflect.getprototypeof": "^1.0.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.2",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
+ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/unbox-primitive": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
+ "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-bigints": "^1.0.2",
+ "has-symbols": "^1.1.0",
+ "which-boxed-primitive": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/undici": {
+ "version": "7.15.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.15.0.tgz",
+ "integrity": "sha512-7oZJCPvvMvTd0OlqWsIxTuItTpJBpU1tcbVl24FMn3xt3+VSunwUasmfPJRE57oNO1KsZ4PgA1xTdAX4hq8NyQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.10.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz",
+ "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/unrs-resolver": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz",
+ "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "napi-postinstall": "^0.3.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/unrs-resolver"
+ },
+ "optionalDependencies": {
+ "@unrs/resolver-binding-android-arm-eabi": "1.11.1",
+ "@unrs/resolver-binding-android-arm64": "1.11.1",
+ "@unrs/resolver-binding-darwin-arm64": "1.11.1",
+ "@unrs/resolver-binding-darwin-x64": "1.11.1",
+ "@unrs/resolver-binding-freebsd-x64": "1.11.1",
+ "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1",
+ "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1",
+ "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1",
+ "@unrs/resolver-binding-linux-arm64-musl": "1.11.1",
+ "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1",
+ "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1",
+ "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1",
+ "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1",
+ "@unrs/resolver-binding-linux-x64-gnu": "1.11.1",
+ "@unrs/resolver-binding-linux-x64-musl": "1.11.1",
+ "@unrs/resolver-binding-wasm32-wasi": "1.11.1",
+ "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1",
+ "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1",
+ "@unrs/resolver-binding-win32-x64-msvc": "1.11.1"
+ }
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/warframe-public-export-plus": {
+ "version": "0.5.84",
+ "resolved": "https://registry.npmjs.org/warframe-public-export-plus/-/warframe-public-export-plus-0.5.84.tgz",
+ "integrity": "sha512-ZpI1Y5CgWDmCwM4/oQpv9u0GD6KFvsJ9f1vJVXYhm5VD9DdOJcFzXgXgg98HXJ5JHbO16ZGIj83117qdpd0RQA=="
+ },
+ "node_modules/warframe-riven-info": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/warframe-riven-info/-/warframe-riven-info-0.1.2.tgz",
+ "integrity": "sha512-j09BbfWGyCEKv19jP8c0/Eb0gupJsyLNGaHgCIg3wYkTx5dlr8jSPfTpB1rkMOUiURpWKAOrfnPGrgFPbfNtWw=="
+ },
+ "node_modules/webidl-conversions": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "14.2.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
+ "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "^5.1.0",
+ "webidl-conversions": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/which-boxed-primitive": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz",
+ "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-bigint": "^1.1.0",
+ "is-boolean-object": "^1.2.1",
+ "is-number-object": "^1.1.1",
+ "is-string": "^1.1.1",
+ "is-symbol": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/which-builtin-type": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz",
+ "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "function.prototype.name": "^1.1.6",
+ "has-tostringtag": "^1.0.2",
+ "is-async-function": "^2.0.0",
+ "is-date-object": "^1.1.0",
+ "is-finalizationregistry": "^1.1.0",
+ "is-generator-function": "^1.0.10",
+ "is-regex": "^1.2.1",
+ "is-weakref": "^1.0.2",
+ "isarray": "^2.0.5",
+ "which-boxed-primitive": "^1.1.0",
+ "which-collection": "^1.0.2",
+ "which-typed-array": "^1.1.16"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/which-collection": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz",
+ "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-map": "^2.0.3",
+ "is-set": "^2.0.3",
+ "is-weakmap": "^2.0.2",
+ "is-weakset": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/which-typed-array": {
+ "version": "1.1.19",
+ "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",
+ "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "for-each": "^0.3.5",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/winston": {
+ "version": "3.17.0",
+ "resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz",
+ "integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==",
+ "license": "MIT",
+ "dependencies": {
+ "@colors/colors": "^1.6.0",
+ "@dabh/diagnostics": "^2.0.2",
+ "async": "^3.2.3",
+ "is-stream": "^2.0.0",
+ "logform": "^2.7.0",
+ "one-time": "^1.0.0",
+ "readable-stream": "^3.4.0",
+ "safe-stable-stringify": "^2.3.1",
+ "stack-trace": "0.0.x",
+ "triple-beam": "^1.3.0",
+ "winston-transport": "^4.9.0"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ }
+ },
+ "node_modules/winston-daily-rotate-file": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/winston-daily-rotate-file/-/winston-daily-rotate-file-5.0.0.tgz",
+ "integrity": "sha512-JDjiXXkM5qvwY06733vf09I2wnMXpZEhxEVOSPenZMii+g7pcDcTBt2MRugnoi8BwVSuCT2jfRXBUy+n1Zz/Yw==",
+ "license": "MIT",
+ "dependencies": {
+ "file-stream-rotator": "^0.6.1",
+ "object-hash": "^3.0.0",
+ "triple-beam": "^1.4.1",
+ "winston-transport": "^4.7.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "peerDependencies": {
+ "winston": "^3"
+ }
+ },
+ "node_modules/winston-transport": {
+ "version": "4.9.0",
+ "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz",
+ "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==",
+ "license": "MIT",
+ "dependencies": {
+ "logform": "^2.7.0",
+ "readable-stream": "^3.6.2",
+ "triple-beam": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ }
+ },
+ "node_modules/word-wrap": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ },
+ "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/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 00000000..1659e0aa
--- /dev/null
+++ b/package.json
@@ -0,0 +1,65 @@
+{
+ "name": "wf-emulator",
+ "version": "0.1.0",
+ "description": "WF Emulator",
+ "main": "index.ts",
+ "scripts": {
+ "start": "node --enable-source-maps build/src/index.js",
+ "build": "tsgo --sourceMap && ncp static/webui build/static/webui",
+ "build:tsc": "tsc --incremental --sourceMap && ncp static/webui build/static/webui",
+ "build:dev": "tsgo --sourceMap",
+ "build:dev:tsc": "tsc --incremental --sourceMap",
+ "build-and-start": "npm run build && npm run start",
+ "build-and-start:bun": "npm run verify && npm run bun-run",
+ "dev": "node scripts/dev.cjs",
+ "dev:bun": "bun scripts/dev.cjs",
+ "verify": "tsgo --noEmit",
+ "verify:tsc": "tsc --noEmit",
+ "raw": "node --experimental-transform-types src/index.ts",
+ "raw:bun": "bun src/index.ts",
+ "lint": "eslint --ext .ts .",
+ "lint:ci": "eslint --ext .ts --rule \"prettier/prettier: off\" .",
+ "lint:fix": "eslint --fix --ext .ts .",
+ "prettier": "prettier --write .",
+ "update-translations": "cd scripts && node update-translations.cjs",
+ "fix": "npm run update-translations && npm run lint:fix"
+ },
+ "license": "GNU",
+ "type": "module",
+ "dependencies": {
+ "chokidar": "^4.0.3",
+ "crc-32": "^1.2.2",
+ "express": "^5",
+ "json-with-bigint": "^3.4.4",
+ "mongoose": "^8.11.0",
+ "morgan": "^1.10.0",
+ "ncp": "^2.0.0",
+ "undici": "^7.10.0",
+ "warframe-public-export-plus": "^0.5.83",
+ "warframe-riven-info": "^0.1.2",
+ "winston": "^3.17.0",
+ "winston-daily-rotate-file": "^5.0.0",
+ "ws": "^8.18.2"
+ },
+ "optionalDependencies": {
+ "@types/express": "^5",
+ "@types/morgan": "^1.9.9",
+ "@types/websocket": "^1.0.10",
+ "@types/ws": "^8.18.1",
+ "@typescript/native-preview": "^7.0.0-dev.20250625.1",
+ "typescript": "^5.7"
+ },
+ "devDependencies": {
+ "@typescript-eslint/eslint-plugin": "^8.28.0",
+ "@typescript-eslint/parser": "^8.28.0",
+ "eslint": "^8",
+ "eslint-import-resolver-typescript": "^4.4.4",
+ "eslint-plugin-import": "^2.32.0",
+ "eslint-plugin-prettier": "^5.2.5",
+ "prettier": "^3.5.3",
+ "tree-kill": "^1.2.2"
+ },
+ "engines": {
+ "node": ">=20.18.1"
+ }
+}
diff --git a/scripts/dev.cjs b/scripts/dev.cjs
new file mode 100644
index 00000000..7931df6b
--- /dev/null
+++ b/scripts/dev.cjs
@@ -0,0 +1,58 @@
+/* eslint-disable */
+const { spawn } = require("child_process");
+const chokidar = require("chokidar");
+const kill = require("tree-kill");
+
+let secret = "";
+for (let i = 0; i != 10; ++i) {
+ secret += String.fromCharCode(Math.floor(Math.random() * 26) + 0x41);
+}
+
+const args = [...process.argv].splice(2);
+args.push("--dev");
+args.push("--secret");
+args.push(secret);
+
+let buildproc, runproc;
+const spawnopts = { stdio: "inherit", shell: true };
+function run(changedFile) {
+ if (changedFile) {
+ console.log(`Change to ${changedFile} detected`);
+ }
+
+ if (buildproc) {
+ kill(buildproc.pid);
+ buildproc = undefined;
+ }
+ if (runproc) {
+ kill(runproc.pid);
+ runproc = undefined;
+ }
+
+ const thisbuildproc = spawn("npm", ["run", process.versions.bun ? "verify" : "build:dev"], spawnopts);
+ const thisbuildstart = Date.now();
+ buildproc = thisbuildproc;
+ buildproc.on("exit", code => {
+ if (buildproc !== thisbuildproc) {
+ return;
+ }
+ buildproc = undefined;
+ if (code === 0) {
+ console.log(`${process.versions.bun ? "Verified" : "Built"} in ${Date.now() - thisbuildstart} ms`);
+ runproc = spawn("npm", ["run", process.versions.bun ? "raw:bun" : "start", "--", ...args], spawnopts);
+ runproc.on("exit", () => {
+ runproc = undefined;
+ });
+ }
+ });
+}
+
+run();
+chokidar.watch("src").on("change", run);
+chokidar.watch("static/fixed_responses").on("change", run);
+
+chokidar.watch("static/webui").on("change", async () => {
+ try {
+ await fetch("http://localhost/custom/webuiFileChangeDetected?secret=" + secret);
+ } catch (e) {}
+});
diff --git a/scripts/update-translations.cjs b/scripts/update-translations.cjs
new file mode 100644
index 00000000..6c840a5d
--- /dev/null
+++ b/scripts/update-translations.cjs
@@ -0,0 +1,47 @@
+// Based on https://onlyg.it/OpenWF/Translations/src/branch/main/update.php
+// Converted via ChatGPT-4o
+
+/* eslint-disable */
+const fs = require("fs");
+
+function extractStrings(content) {
+ const regex = /([a-zA-Z0-9_]+): `([^`]*)`,/g;
+ let matches;
+ const strings = {};
+ while ((matches = regex.exec(content)) !== null) {
+ strings[matches[1]] = matches[2];
+ }
+ return strings;
+}
+
+const source = fs.readFileSync("../static/webui/translations/en.js", "utf8");
+const sourceStrings = extractStrings(source);
+const sourceLines = source.substring(0, source.length - 1).split("\n");
+
+fs.readdirSync("../static/webui/translations").forEach(file => {
+ if (fs.lstatSync(`../static/webui/translations/${file}`).isFile() && file !== "en.js") {
+ const content = fs.readFileSync(`../static/webui/translations/${file}`, "utf8");
+ const targetStrings = extractStrings(content);
+ const contentLines = content.split("\n");
+
+ const fileHandle = fs.openSync(`../static/webui/translations/${file}`, "w");
+ fs.writeSync(fileHandle, contentLines[0] + "\n");
+
+ sourceLines.forEach(line => {
+ const strings = extractStrings(line);
+ if (Object.keys(strings).length > 0) {
+ Object.entries(strings).forEach(([key, value]) => {
+ if (targetStrings.hasOwnProperty(key) && !targetStrings[key].startsWith("[UNTRANSLATED]")) {
+ fs.writeSync(fileHandle, ` ${key}: \`${targetStrings[key]}\`,\n`);
+ } else {
+ fs.writeSync(fileHandle, ` ${key}: \`[UNTRANSLATED] ${value}\`,\n`);
+ }
+ });
+ } else {
+ fs.writeSync(fileHandle, line + "\n");
+ }
+ });
+
+ fs.closeSync(fileHandle);
+ }
+});
diff --git a/src/app.ts b/src/app.ts
new file mode 100644
index 00000000..971fe210
--- /dev/null
+++ b/src/app.ts
@@ -0,0 +1,51 @@
+import express from "express";
+
+import bodyParser from "body-parser";
+import { unknownEndpointHandler } from "./middleware/middleware.ts";
+import { requestLogger } from "./middleware/morgenMiddleware.ts";
+import { errorHandler } from "./middleware/errorHandler.ts";
+
+import { apiRouter } from "./routes/api.ts";
+import { cacheRouter } from "./routes/cache.ts";
+import { customRouter } from "./routes/custom.ts";
+import { dynamicController } from "./routes/dynamic.ts";
+import { payRouter } from "./routes/pay.ts";
+import { statsRouter } from "./routes/stats.ts";
+import { webuiRouter } from "./routes/webui.ts";
+
+const app = express();
+
+app.use((req, _res, next) => {
+ // 38.5.0 introduced "ezip" for encrypted body blobs and "e" for request verification only (encrypted body blobs with no application data).
+ // The bootstrapper decrypts it for us but having an unsupported Content-Encoding here would still be an issue for Express, so removing it.
+ if (req.headers["content-encoding"] == "ezip" || req.headers["content-encoding"] == "e") {
+ req.headers["content-encoding"] = undefined;
+ }
+
+ // U18 uses application/x-www-form-urlencoded even tho the data is JSON which Express doesn't like.
+ // U17 sets no Content-Type at all, which Express also doesn't like.
+ if (!req.headers["content-type"] || req.headers["content-type"] == "application/x-www-form-urlencoded") {
+ req.headers["content-type"] = "application/octet-stream";
+ }
+
+ next();
+});
+
+app.use(bodyParser.raw());
+app.use(express.json({ limit: "4mb" }));
+app.use(bodyParser.text({ limit: "4mb" }));
+app.use(requestLogger);
+
+app.use("/api", apiRouter);
+app.use("/", cacheRouter);
+app.use("/custom", customRouter);
+app.use("/dynamic", dynamicController);
+app.use("/:id/dynamic", dynamicController);
+app.use("/pay", payRouter);
+app.use("/stats", statsRouter);
+app.use("/", webuiRouter);
+
+app.use(unknownEndpointHandler);
+app.use(errorHandler);
+
+export { app };
diff --git a/src/constants/timeConstants.ts b/src/constants/timeConstants.ts
new file mode 100644
index 00000000..4411f556
--- /dev/null
+++ b/src/constants/timeConstants.ts
@@ -0,0 +1,19 @@
+const millisecondsPerSecond = 1000;
+const secondsPerMinute = 60;
+const minutesPerHour = 60;
+const hoursPerDay = 24;
+const daysPerWeek = 7;
+
+const unixSecond = millisecondsPerSecond;
+const unixMinute = secondsPerMinute * millisecondsPerSecond;
+const unixHour = unixMinute * minutesPerHour;
+const unixDay = hoursPerDay * unixHour;
+const unixWeek = daysPerWeek * unixDay;
+
+export const unixTimesInMs = {
+ second: unixSecond,
+ minute: unixMinute,
+ hour: unixHour,
+ day: unixDay,
+ week: unixWeek
+};
diff --git a/src/controllers/api/abandonLibraryDailyTaskController.ts b/src/controllers/api/abandonLibraryDailyTaskController.ts
new file mode 100644
index 00000000..0b44a2ee
--- /dev/null
+++ b/src/controllers/api/abandonLibraryDailyTaskController.ts
@@ -0,0 +1,11 @@
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { RequestHandler } from "express";
+
+export const abandonLibraryDailyTaskController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId);
+ inventory.LibraryActiveDailyTaskInfo = undefined;
+ await inventory.save();
+ res.status(200).end();
+};
diff --git a/src/controllers/api/abortDojoComponentController.ts b/src/controllers/api/abortDojoComponentController.ts
new file mode 100644
index 00000000..803c8baa
--- /dev/null
+++ b/src/controllers/api/abortDojoComponentController.ts
@@ -0,0 +1,46 @@
+import {
+ getDojoClient,
+ getGuildForRequestEx,
+ hasAccessToDojo,
+ hasGuildPermission,
+ removeDojoDeco,
+ removeDojoRoom
+} from "../../services/guildService.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { GuildPermission } from "../../types/guildTypes.ts";
+import type { RequestHandler } from "express";
+
+export const abortDojoComponentController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId, "GuildId LevelKeys");
+ const guild = await getGuildForRequestEx(req, inventory);
+ const request = JSON.parse(String(req.body)) as IAbortDojoComponentRequest;
+
+ if (
+ !hasAccessToDojo(inventory) ||
+ !(await hasGuildPermission(
+ guild,
+ accountId,
+ request.DecoId ? GuildPermission.Decorator : GuildPermission.Architect
+ ))
+ ) {
+ res.json({ DojoRequestStatus: -1 });
+ return;
+ }
+
+ if (request.DecoId) {
+ removeDojoDeco(guild, request.ComponentId, request.DecoId);
+ } else {
+ await removeDojoRoom(guild, request.ComponentId);
+ }
+
+ await guild.save();
+ res.json(await getDojoClient(guild, 0, request.ComponentId));
+};
+
+interface IAbortDojoComponentRequest {
+ DecoType?: string;
+ ComponentId: string;
+ DecoId?: string;
+}
diff --git a/src/controllers/api/abortDojoComponentDestructionController.ts b/src/controllers/api/abortDojoComponentDestructionController.ts
new file mode 100644
index 00000000..8ea79363
--- /dev/null
+++ b/src/controllers/api/abortDojoComponentDestructionController.ts
@@ -0,0 +1,26 @@
+import {
+ getDojoClient,
+ getGuildForRequestEx,
+ hasAccessToDojo,
+ hasGuildPermission
+} from "../../services/guildService.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { GuildPermission } from "../../types/guildTypes.ts";
+import type { RequestHandler } from "express";
+
+export const abortDojoComponentDestructionController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId, "GuildId LevelKeys");
+ const guild = await getGuildForRequestEx(req, inventory);
+ if (!hasAccessToDojo(inventory) || !(await hasGuildPermission(guild, accountId, GuildPermission.Architect))) {
+ res.json({ DojoRequestStatus: -1 });
+ return;
+ }
+ const componentId = req.query.componentId as string;
+
+ guild.DojoComponents.id(componentId)!.DestructionTime = undefined;
+
+ await guild.save();
+ res.json(await getDojoClient(guild, 0, componentId));
+};
diff --git a/src/controllers/api/activateRandomModController.ts b/src/controllers/api/activateRandomModController.ts
new file mode 100644
index 00000000..ce452b37
--- /dev/null
+++ b/src/controllers/api/activateRandomModController.ts
@@ -0,0 +1,46 @@
+import { toOid } from "../../helpers/inventoryHelpers.ts";
+import {
+ createVeiledRivenFingerprint,
+ createUnveiledRivenFingerprint,
+ rivenRawToRealWeighted
+} from "../../helpers/rivenHelper.ts";
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { addMods, getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { getRandomElement } from "../../services/rngService.ts";
+import type { RequestHandler } from "express";
+import { ExportUpgrades } from "warframe-public-export-plus";
+
+export const activateRandomModController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId, "RawUpgrades Upgrades instantFinishRivenChallenge");
+ const request = getJSONfromString(String(req.body));
+ addMods(inventory, [
+ {
+ ItemType: request.ItemType,
+ ItemCount: -1
+ }
+ ]);
+ const rivenType = getRandomElement(rivenRawToRealWeighted[request.ItemType])!;
+ const fingerprint = inventory.instantFinishRivenChallenge
+ ? createUnveiledRivenFingerprint(ExportUpgrades[rivenType])
+ : createVeiledRivenFingerprint(ExportUpgrades[rivenType]);
+ const upgradeIndex =
+ inventory.Upgrades.push({
+ ItemType: rivenType,
+ UpgradeFingerprint: JSON.stringify(fingerprint)
+ }) - 1;
+ await inventory.save();
+ // For some reason, in this response, the UpgradeFingerprint is simply a nested object and not a string
+ res.json({
+ NewMod: {
+ UpgradeFingerprint: fingerprint,
+ ItemType: rivenType,
+ ItemId: toOid(inventory.Upgrades[upgradeIndex]._id)
+ }
+ });
+};
+
+interface IActiveRandomModRequest {
+ ItemType: string;
+}
diff --git a/src/controllers/api/addFriendController.ts b/src/controllers/api/addFriendController.ts
new file mode 100644
index 00000000..c6a24e20
--- /dev/null
+++ b/src/controllers/api/addFriendController.ts
@@ -0,0 +1,60 @@
+import { toOid } from "../../helpers/inventoryHelpers.ts";
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { Friendship } from "../../models/friendModel.ts";
+import { addAccountDataToFriendInfo, addInventoryDataToFriendInfo } from "../../services/friendService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { IFriendInfo } from "../../types/friendTypes.ts";
+import type { RequestHandler } from "express";
+
+export const addFriendController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const payload = getJSONfromString(String(req.body));
+ const promises: Promise[] = [];
+ const newFriends: IFriendInfo[] = [];
+ if (payload.friend == "all") {
+ const [internalFriendships, externalFriendships] = await Promise.all([
+ Friendship.find({ owner: accountId }, "friend"),
+ Friendship.find({ friend: accountId }, "owner")
+ ]);
+ for (const externalFriendship of externalFriendships) {
+ if (!internalFriendships.find(x => x.friend.equals(externalFriendship.owner))) {
+ promises.push(
+ Friendship.insertOne({
+ owner: accountId,
+ friend: externalFriendship.owner,
+ Note: externalFriendship.Note // TOVERIFY: Should the note be copied when accepting a friend request?
+ }) as unknown as Promise
+ );
+ newFriends.push({
+ _id: toOid(externalFriendship.owner)
+ });
+ }
+ }
+ } else {
+ const externalFriendship = await Friendship.findOne({ owner: payload.friend, friend: accountId }, "Note");
+ if (externalFriendship) {
+ promises.push(
+ Friendship.insertOne({
+ owner: accountId,
+ friend: payload.friend,
+ Note: externalFriendship.Note
+ }) as unknown as Promise
+ );
+ newFriends.push({
+ _id: { $oid: payload.friend }
+ });
+ }
+ }
+ for (const newFriend of newFriends) {
+ promises.push(addAccountDataToFriendInfo(newFriend));
+ promises.push(addInventoryDataToFriendInfo(newFriend));
+ }
+ await Promise.all(promises);
+ res.json({
+ Friends: newFriends
+ });
+};
+
+interface IAddFriendRequest {
+ friend: string; // oid or "all" in which case all=1 is also a query parameter
+}
diff --git a/src/controllers/api/addFriendImageController.ts b/src/controllers/api/addFriendImageController.ts
new file mode 100644
index 00000000..d3702c12
--- /dev/null
+++ b/src/controllers/api/addFriendImageController.ts
@@ -0,0 +1,25 @@
+import type { RequestHandler } from "express";
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { Inventory } from "../../models/inventoryModels/inventoryModel.ts";
+
+export const addFriendImageController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const json = getJSONfromString(String(req.body));
+
+ await Inventory.updateOne(
+ {
+ accountOwnerId: accountId
+ },
+ {
+ ActiveAvatarImageType: json.AvatarImageType
+ }
+ );
+
+ res.json({});
+};
+
+interface IUpdateGlyphRequest {
+ AvatarImageType: string;
+ AvatarImage: string;
+}
diff --git a/src/controllers/api/addIgnoredUserController.ts b/src/controllers/api/addIgnoredUserController.ts
new file mode 100644
index 00000000..b268e498
--- /dev/null
+++ b/src/controllers/api/addIgnoredUserController.ts
@@ -0,0 +1,30 @@
+import { toOid } from "../../helpers/inventoryHelpers.ts";
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { Account, Ignore } from "../../models/loginModel.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { IFriendInfo } from "../../types/friendTypes.ts";
+import type { RequestHandler } from "express";
+
+export const addIgnoredUserController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const data = getJSONfromString(String(req.body));
+ const ignoreeAccount = await Account.findOne(
+ { DisplayName: data.playerName.substring(0, data.playerName.length - 1) },
+ "_id"
+ );
+ if (ignoreeAccount) {
+ await Ignore.create({ ignorer: accountId, ignoree: ignoreeAccount._id });
+ res.json({
+ Ignored: {
+ _id: toOid(ignoreeAccount._id),
+ DisplayName: data.playerName
+ } satisfies IFriendInfo
+ });
+ } else {
+ res.status(400).end();
+ }
+};
+
+interface IAddIgnoredUserRequest {
+ playerName: string;
+}
diff --git a/src/controllers/api/addPendingFriendController.ts b/src/controllers/api/addPendingFriendController.ts
new file mode 100644
index 00000000..fc1e06c9
--- /dev/null
+++ b/src/controllers/api/addPendingFriendController.ts
@@ -0,0 +1,52 @@
+import { toMongoDate, toOid } from "../../helpers/inventoryHelpers.ts";
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { Friendship } from "../../models/friendModel.ts";
+import { Account } from "../../models/loginModel.ts";
+import { addInventoryDataToFriendInfo, areFriendsOfFriends } from "../../services/friendService.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { IFriendInfo } from "../../types/friendTypes.ts";
+import type { RequestHandler } from "express";
+
+export const addPendingFriendController: RequestHandler = async (req, res) => {
+ const payload = getJSONfromString(String(req.body));
+
+ const account = await Account.findOne({ DisplayName: payload.friend });
+ if (!account) {
+ res.status(400).end();
+ return;
+ }
+
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(account._id.toString(), "Settings");
+ if (
+ inventory.Settings?.FriendInvRestriction == "GIFT_MODE_NONE" ||
+ (inventory.Settings?.FriendInvRestriction == "GIFT_MODE_FRIENDS" &&
+ !(await areFriendsOfFriends(account._id, accountId)))
+ ) {
+ res.status(400).send("Friend Invite Restriction");
+ return;
+ }
+
+ await Friendship.insertOne({
+ owner: accountId,
+ friend: account._id,
+ Note: payload.message
+ });
+
+ const friendInfo: IFriendInfo = {
+ _id: toOid(account._id),
+ DisplayName: account.DisplayName,
+ LastLogin: toMongoDate(account.LastLogin),
+ Note: payload.message
+ };
+ await addInventoryDataToFriendInfo(friendInfo);
+ res.json({
+ Friend: friendInfo
+ });
+};
+
+interface IAddPendingFriendRequest {
+ friend: string;
+ message: string;
+}
diff --git a/src/controllers/api/addToAllianceController.ts b/src/controllers/api/addToAllianceController.ts
new file mode 100644
index 00000000..6e004bf2
--- /dev/null
+++ b/src/controllers/api/addToAllianceController.ts
@@ -0,0 +1,117 @@
+import { getJSONfromString, regexEscape } from "../../helpers/stringHelpers.ts";
+import { Alliance, AllianceMember, Guild, GuildMember } from "../../models/guildModel.ts";
+import { createMessage } from "../../services/inboxService.ts";
+import { getEffectiveAvatarImageType, getInventory } from "../../services/inventoryService.ts";
+import { getAccountForRequest, getSuffixedName } from "../../services/loginService.ts";
+import { GuildPermission } from "../../types/guildTypes.ts";
+import { logger } from "../../utils/logger.ts";
+import type { RequestHandler } from "express";
+import { ExportFlavour } from "warframe-public-export-plus";
+
+export const addToAllianceController: RequestHandler = async (req, res) => {
+ // Check requester is a warlord in their guild
+ const account = await getAccountForRequest(req);
+ const guildMember = (await GuildMember.findOne({ accountId: account._id, status: 0 }))!;
+ if (guildMember.rank > 1) {
+ res.status(400).json({ Error: 104 });
+ return;
+ }
+
+ // Check guild has invite permissions in the alliance
+ const allianceMember = (await AllianceMember.findOne({
+ allianceId: req.query.allianceId,
+ guildId: guildMember.guildId
+ }))!;
+ if (!(allianceMember.Permissions & GuildPermission.Recruiter)) {
+ res.status(400).json({ Error: 104 });
+ return;
+ }
+
+ // Find clan to invite
+ const payload = getJSONfromString(String(req.body));
+ const guilds = await Guild.find(
+ {
+ Name:
+ payload.clanName.indexOf("#") == -1
+ ? new RegExp("^" + regexEscape(payload.clanName) + "#...$")
+ : payload.clanName
+ },
+ "Name"
+ );
+ if (guilds.length == 0) {
+ res.status(400).json({ Error: 101 });
+ return;
+ }
+ if (guilds.length > 1) {
+ const choices: IGuildChoice[] = [];
+ for (const guild of guilds) {
+ choices.push({
+ OriginalPlatform: 0,
+ Name: guild.Name
+ });
+ }
+ res.json(choices);
+ return;
+ }
+
+ // Add clan as a pending alliance member
+ try {
+ await AllianceMember.insertOne({
+ allianceId: req.query.allianceId,
+ guildId: guilds[0]._id,
+ Pending: true,
+ Permissions: 0
+ });
+ } catch (e) {
+ logger.debug(`alliance invite failed due to ${String(e)}`);
+ res.status(400).json({ Error: 102 });
+ return;
+ }
+
+ // Send inbox message to founding warlord
+ // TOVERIFY: Should other warlords get this as well?
+ // TOVERIFY: Who/what should the sender be?
+ // TOVERIFY: Should this message be highPriority?
+ const invitedClanOwnerMember = (await GuildMember.findOne({ guildId: guilds[0]._id, rank: 0 }))!;
+ const senderInventory = await getInventory(account._id.toString(), "ActiveAvatarImageType");
+ const senderGuild = (await Guild.findById(allianceMember.guildId, "Name"))!;
+ const alliance = (await Alliance.findById(req.query.allianceId as string, "Name"))!;
+ await createMessage(invitedClanOwnerMember.accountId, [
+ {
+ sndr: getSuffixedName(account),
+ msg: "/Lotus/Language/Menu/Mailbox_AllianceInvite_Body",
+ arg: [
+ {
+ Key: "THEIR_CLAN",
+ Tag: senderGuild.Name
+ },
+ {
+ Key: "CLAN",
+ Tag: guilds[0].Name
+ },
+ {
+ Key: "ALLIANCE",
+ Tag: alliance.Name
+ }
+ ],
+ sub: "/Lotus/Language/Menu/Mailbox_AllianceInvite_Title",
+ icon: ExportFlavour[getEffectiveAvatarImageType(senderInventory)].icon,
+ contextInfo: alliance._id.toString(),
+ highPriority: true,
+ acceptAction: "ALLIANCE_INVITE",
+ declineAction: "ALLIANCE_INVITE",
+ hasAccountAction: true
+ }
+ ]);
+
+ res.end();
+};
+
+interface IAddToAllianceRequest {
+ clanName: string;
+}
+
+interface IGuildChoice {
+ OriginalPlatform: number;
+ Name: string;
+}
diff --git a/src/controllers/api/addToGuildController.ts b/src/controllers/api/addToGuildController.ts
new file mode 100644
index 00000000..57fb6193
--- /dev/null
+++ b/src/controllers/api/addToGuildController.ts
@@ -0,0 +1,112 @@
+import { toMongoDate } from "../../helpers/inventoryHelpers.ts";
+import { Guild, GuildMember } from "../../models/guildModel.ts";
+import { Account } from "../../models/loginModel.ts";
+import { addInventoryDataToFriendInfo, areFriends } from "../../services/friendService.ts";
+import { hasGuildPermission } from "../../services/guildService.ts";
+import { createMessage } from "../../services/inboxService.ts";
+import { getEffectiveAvatarImageType, getInventory } from "../../services/inventoryService.ts";
+import { getAccountForRequest, getAccountIdForRequest, getSuffixedName } from "../../services/loginService.ts";
+import type { IOid } from "../../types/commonTypes.ts";
+import type { IGuildMemberClient } from "../../types/guildTypes.ts";
+import { GuildPermission } from "../../types/guildTypes.ts";
+import { logger } from "../../utils/logger.ts";
+import type { RequestHandler } from "express";
+import { ExportFlavour } from "warframe-public-export-plus";
+
+export const addToGuildController: RequestHandler = async (req, res) => {
+ const payload = JSON.parse(String(req.body)) as IAddToGuildRequest;
+
+ if ("UserName" in payload) {
+ // Clan recruiter sending an invite
+
+ const account = await Account.findOne({ DisplayName: payload.UserName });
+ if (!account) {
+ res.status(400).json("Username does not exist");
+ return;
+ }
+
+ const senderAccount = await getAccountForRequest(req);
+ const inventory = await getInventory(account._id.toString(), "Settings");
+ if (
+ inventory.Settings?.GuildInvRestriction == "GIFT_MODE_NONE" ||
+ (inventory.Settings?.GuildInvRestriction == "GIFT_MODE_FRIENDS" &&
+ !(await areFriends(account._id, senderAccount._id)))
+ ) {
+ res.status(400).json("Invite restricted");
+ return;
+ }
+
+ const guild = (await Guild.findById(payload.GuildId.$oid, "Name Ranks"))!;
+ if (!(await hasGuildPermission(guild, senderAccount._id.toString(), GuildPermission.Recruiter))) {
+ res.status(400).json("Invalid permission");
+ }
+
+ try {
+ await GuildMember.insertOne({
+ accountId: account._id,
+ guildId: payload.GuildId.$oid,
+ status: 2 // outgoing invite
+ });
+ } catch (e) {
+ logger.debug(`guild invite failed due to ${String(e)}`);
+ res.status(400).json("User already invited to clan");
+ return;
+ }
+
+ const senderInventory = await getInventory(senderAccount._id.toString(), "ActiveAvatarImageType");
+ await createMessage(account._id, [
+ {
+ sndr: getSuffixedName(senderAccount),
+ msg: "/Lotus/Language/Menu/Mailbox_ClanInvite_Body",
+ arg: [
+ {
+ Key: "clan",
+ Tag: guild.Name
+ }
+ ],
+ sub: "/Lotus/Language/Menu/Mailbox_ClanInvite_Title",
+ icon: ExportFlavour[getEffectiveAvatarImageType(senderInventory)].icon,
+ contextInfo: payload.GuildId.$oid,
+ highPriority: true,
+ acceptAction: "GUILD_INVITE",
+ declineAction: "GUILD_INVITE",
+ hasAccountAction: true
+ }
+ ]);
+
+ const member: IGuildMemberClient = {
+ _id: { $oid: account._id.toString() },
+ DisplayName: account.DisplayName,
+ LastLogin: toMongoDate(account.LastLogin),
+ Rank: 7,
+ Status: 2
+ };
+ await addInventoryDataToFriendInfo(member);
+ res.json({ NewMember: member });
+ } else if ("RequestMsg" in payload) {
+ // Player applying to join a clan
+ const accountId = await getAccountIdForRequest(req);
+ try {
+ await GuildMember.insertOne({
+ accountId,
+ guildId: payload.GuildId.$oid,
+ status: 1, // incoming invite
+ RequestMsg: payload.RequestMsg,
+ RequestExpiry: new Date(Date.now() + 14 * 86400 * 1000) // TOVERIFY: I can't find any good information about this with regards to live, but 2 weeks seem reasonable.
+ });
+ } catch (e) {
+ logger.debug(`guild invite failed due to ${String(e)}`);
+ res.status(400).send("Already requested");
+ }
+ res.end();
+ } else {
+ logger.error(`data provided to ${req.path}: ${String(req.body)}`);
+ res.status(400).end();
+ }
+};
+
+interface IAddToGuildRequest {
+ UserName?: string;
+ GuildId: IOid;
+ RequestMsg?: string;
+}
diff --git a/src/controllers/api/adoptPetController.ts b/src/controllers/api/adoptPetController.ts
new file mode 100644
index 00000000..607ea5d2
--- /dev/null
+++ b/src/controllers/api/adoptPetController.ts
@@ -0,0 +1,27 @@
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { RequestHandler } from "express";
+
+export const adoptPetController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId, "KubrowPets");
+ const data = getJSONfromString(String(req.body));
+ const details = inventory.KubrowPets.id(data.petId)!.Details!;
+ details.Name = data.name;
+ await inventory.save();
+ res.json({
+ petId: data.petId,
+ newName: data.name
+ } satisfies IAdoptPetResponse);
+};
+
+interface IAdoptPetRequest {
+ petId: string;
+ name: string;
+}
+
+interface IAdoptPetResponse {
+ petId: string;
+ newName: string;
+}
diff --git a/src/controllers/api/apartmentController.ts b/src/controllers/api/apartmentController.ts
new file mode 100644
index 00000000..247d5b10
--- /dev/null
+++ b/src/controllers/api/apartmentController.ts
@@ -0,0 +1,22 @@
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { getPersonalRooms } from "../../services/personalRoomsService.ts";
+import type { RequestHandler } from "express";
+
+export const apartmentController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const personalRooms = await getPersonalRooms(accountId, "Apartment");
+ const response: IApartmentResponse = {};
+ if (req.query.backdrop !== undefined) {
+ response.NewBackdropItem = personalRooms.Apartment.VideoWallBackdrop = req.query.backdrop as string;
+ }
+ if (req.query.soundscape !== undefined) {
+ response.NewSoundscapeItem = personalRooms.Apartment.Soundscape = req.query.soundscape as string;
+ }
+ await personalRooms.save();
+ res.json(response);
+};
+
+interface IApartmentResponse {
+ NewBackdropItem?: string;
+ NewSoundscapeItem?: string;
+}
diff --git a/src/controllers/api/arcaneCommonController.ts b/src/controllers/api/arcaneCommonController.ts
new file mode 100644
index 00000000..299b1b22
--- /dev/null
+++ b/src/controllers/api/arcaneCommonController.ts
@@ -0,0 +1,76 @@
+import type { RequestHandler } from "express";
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { getInventory, addMods } from "../../services/inventoryService.ts";
+import type { IOid } from "../../types/commonTypes.ts";
+
+export const arcaneCommonController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const json = getJSONfromString(String(req.body));
+ const inventory = await getInventory(accountId);
+ const upgrade = inventory.Upgrades.id(json.arcane.ItemId.$oid);
+ if (json.newRank == -1) {
+ // Break down request?
+ if (!upgrade || !upgrade.UpgradeFingerprint) {
+ throw new Error(`Failed to find upgrade with OID ${json.arcane.ItemId.$oid}`);
+ }
+
+ // Remove Upgrade
+ inventory.Upgrades.pull({ _id: json.arcane.ItemId.$oid });
+
+ // Add RawUpgrades
+ const numRawUpgradesToGive = arcaneLevelCounts[(JSON.parse(upgrade.UpgradeFingerprint) as { lvl: number }).lvl];
+ addMods(inventory, [
+ {
+ ItemType: json.arcane.ItemType,
+ ItemCount: numRawUpgradesToGive
+ }
+ ]);
+
+ res.json({ upgradeId: json.arcane.ItemId.$oid, numConsumed: numRawUpgradesToGive });
+ } else {
+ // Upgrade request?
+ let numConsumed = arcaneLevelCounts[json.newRank];
+ let upgradeId = json.arcane.ItemId.$oid;
+ if (upgrade) {
+ // Have an existing Upgrade item?
+ if (upgrade.UpgradeFingerprint) {
+ const existingLevel = (JSON.parse(upgrade.UpgradeFingerprint) as { lvl: number }).lvl;
+ numConsumed -= arcaneLevelCounts[existingLevel];
+ }
+ upgrade.UpgradeFingerprint = JSON.stringify({ lvl: json.newRank });
+ } else {
+ const newLength = inventory.Upgrades.push({
+ ItemType: json.arcane.ItemType,
+ UpgradeFingerprint: JSON.stringify({ lvl: json.newRank })
+ });
+ upgradeId = inventory.Upgrades[newLength - 1]._id.toString();
+ }
+
+ // Remove RawUpgrades
+ addMods(inventory, [
+ {
+ ItemType: json.arcane.ItemType,
+ ItemCount: numConsumed * -1
+ }
+ ]);
+
+ res.json({ newLevel: json.newRank, numConsumed, upgradeId });
+ }
+ await inventory.save();
+};
+
+const arcaneLevelCounts = [0, 3, 6, 10, 15, 21];
+
+interface IArcaneCommonRequest {
+ arcane: {
+ ItemType: string;
+ ItemId: IOid;
+ FromSKU: boolean;
+ UpgradeFingerprint: string;
+ PendingRerollFingerprint: string;
+ ItemCount: number;
+ LastAdded: IOid;
+ };
+ newRank: number;
+}
diff --git a/src/controllers/api/archonFusionController.ts b/src/controllers/api/archonFusionController.ts
new file mode 100644
index 00000000..3c046d41
--- /dev/null
+++ b/src/controllers/api/archonFusionController.ts
@@ -0,0 +1,51 @@
+import type { RequestHandler } from "express";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { addMiscItems, getInventory } from "../../services/inventoryService.ts";
+import type { IMiscItem } from "../../types/inventoryTypes/inventoryTypes.ts";
+import { colorToShard, combineColors, shardToColor } from "../../helpers/shardHelper.ts";
+
+export const archonFusionController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const request = JSON.parse(String(req.body)) as IArchonFusionRequest;
+ const inventory = await getInventory(accountId);
+ request.Consumed.forEach(x => {
+ x.ItemCount *= -1;
+ });
+ addMiscItems(inventory, request.Consumed);
+ const newArchons: IMiscItem[] = [];
+ switch (request.FusionType) {
+ case "AFT_ASCENT":
+ newArchons.push({
+ ItemType: request.Consumed[0].ItemType + "Mythic",
+ ItemCount: 1
+ });
+ break;
+
+ case "AFT_COALESCENT":
+ newArchons.push({
+ ItemType:
+ colorToShard[
+ combineColors(
+ shardToColor[request.Consumed[0].ItemType],
+ shardToColor[request.Consumed[1].ItemType]
+ )
+ ],
+ ItemCount: 1
+ });
+ break;
+
+ default:
+ throw new Error(`unknown archon fusion type: ${request.FusionType}`);
+ }
+ addMiscItems(inventory, newArchons);
+ await inventory.save();
+ res.json({
+ NewArchons: newArchons
+ });
+};
+
+interface IArchonFusionRequest {
+ Consumed: IMiscItem[];
+ FusionType: string;
+ StatResultType: "SRT_NEW_STAT"; // ???
+}
diff --git a/src/controllers/api/artifactTransmutationController.ts b/src/controllers/api/artifactTransmutationController.ts
new file mode 100644
index 00000000..69cc41ee
--- /dev/null
+++ b/src/controllers/api/artifactTransmutationController.ts
@@ -0,0 +1,169 @@
+import { fromOid, toOid } from "../../helpers/inventoryHelpers.ts";
+import { createVeiledRivenFingerprint, rivenRawToRealWeighted } from "../../helpers/rivenHelper.ts";
+import { addMiscItems, addMods, getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { getRandomElement, getRandomWeightedReward, getRandomWeightedRewardUc } from "../../services/rngService.ts";
+import type { IUpgradeFromClient } from "../../types/inventoryTypes/inventoryTypes.ts";
+import type { RequestHandler } from "express";
+import type { TRarity } from "warframe-public-export-plus";
+import { ExportBoosterPacks, ExportUpgrades } from "warframe-public-export-plus";
+
+export const artifactTransmutationController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId);
+ const payload = JSON.parse(String(req.body)) as IArtifactTransmutationRequest;
+
+ inventory.RegularCredits -= payload.Cost;
+ inventory.FusionPoints -= payload.FusionPointCost;
+
+ if (payload.RivenTransmute) {
+ addMiscItems(inventory, [
+ {
+ ItemType: "/Lotus/Types/Gameplay/Eidolon/Resources/SentientSecretItem",
+ ItemCount: -1
+ }
+ ]);
+
+ payload.Consumed.forEach(upgrade => {
+ inventory.Upgrades.pull({ _id: fromOid(upgrade.ItemId) });
+ });
+
+ const rawRivenType = getRandomRawRivenType();
+ const rivenType = getRandomElement(rivenRawToRealWeighted[rawRivenType])!;
+ const fingerprint = createVeiledRivenFingerprint(ExportUpgrades[rivenType]);
+
+ const upgradeIndex =
+ inventory.Upgrades.push({
+ ItemType: rivenType,
+ UpgradeFingerprint: JSON.stringify(fingerprint)
+ }) - 1;
+ await inventory.save();
+ res.json({
+ NewMods: [
+ {
+ ItemId: toOid(inventory.Upgrades[upgradeIndex]._id),
+ ItemType: rivenType,
+ UpgradeFingerprint: fingerprint
+ }
+ ]
+ });
+ } else {
+ const counts: Record = {
+ COMMON: 0,
+ UNCOMMON: 0,
+ RARE: 0,
+ LEGENDARY: 0
+ };
+ let forcedPolarity: string | undefined;
+ payload.Consumed.forEach(upgrade => {
+ const meta = ExportUpgrades[upgrade.ItemType];
+ counts[meta.rarity] += upgrade.ItemCount;
+ if (fromOid(upgrade.ItemId) != "000000000000000000000000") {
+ inventory.Upgrades.pull({ _id: fromOid(upgrade.ItemId) });
+ } else {
+ addMods(inventory, [
+ {
+ ItemType: upgrade.ItemType,
+ ItemCount: upgrade.ItemCount * -1
+ }
+ ]);
+ }
+ if (upgrade.ItemType == "/Lotus/Upgrades/Mods/TransmuteCores/AttackTransmuteCore") {
+ forcedPolarity = "AP_ATTACK";
+ } else if (upgrade.ItemType == "/Lotus/Upgrades/Mods/TransmuteCores/DefenseTransmuteCore") {
+ forcedPolarity = "AP_DEFENSE";
+ } else if (upgrade.ItemType == "/Lotus/Upgrades/Mods/TransmuteCores/TacticTransmuteCore") {
+ forcedPolarity = "AP_TACTIC";
+ }
+ });
+
+ let newModType: string | undefined;
+ for (const specialModSet of specialModSets) {
+ if (specialModSet.indexOf(payload.Consumed[0].ItemType) != -1) {
+ newModType = getRandomElement(specialModSet);
+ break;
+ }
+ }
+
+ if (!newModType) {
+ // Based on the table on https://wiki.warframe.com/w/Transmutation
+ const weights: Record = {
+ COMMON: counts.COMMON * 95 + counts.UNCOMMON * 15 + counts.RARE * 4,
+ UNCOMMON: counts.COMMON * 4 + counts.UNCOMMON * 80 + counts.RARE * 10,
+ RARE: counts.COMMON * 1 + counts.UNCOMMON * 5 + counts.RARE * 50,
+ LEGENDARY: 0
+ };
+
+ const options: { uniqueName: string; rarity: TRarity }[] = [];
+ Object.entries(ExportUpgrades).forEach(([uniqueName, upgrade]) => {
+ if (upgrade.canBeTransmutation && (!forcedPolarity || upgrade.polarity == forcedPolarity)) {
+ options.push({ uniqueName, rarity: upgrade.rarity });
+ }
+ });
+
+ newModType = getRandomWeightedReward(options, weights)!.uniqueName;
+ }
+
+ addMods(inventory, [
+ {
+ ItemType: newModType,
+ ItemCount: 1
+ }
+ ]);
+
+ await inventory.save();
+ res.json({
+ NewMods: [
+ {
+ ItemType: newModType,
+ ItemCount: 1
+ }
+ ]
+ });
+ }
+};
+
+const getRandomRawRivenType = (): string => {
+ const pack = ExportBoosterPacks["/Lotus/Types/BoosterPacks/CalendarRivenPack"];
+ return getRandomWeightedRewardUc(pack.components, pack.rarityWeightsPerRoll[0])!.Item;
+};
+
+interface IArtifactTransmutationRequest {
+ Upgrade: IUpgradeFromClient;
+ LevelDiff: number;
+ Consumed: IUpgradeFromClient[];
+ Cost: number;
+ FusionPointCost: number;
+ RivenTransmute?: boolean;
+}
+
+const specialModSets: string[][] = [
+ [
+ "/Lotus/Upgrades/Mods/Immortal/ImmortalOneMod",
+ "/Lotus/Upgrades/Mods/Immortal/ImmortalTwoMod",
+ "/Lotus/Upgrades/Mods/Immortal/ImmortalThreeMod",
+ "/Lotus/Upgrades/Mods/Immortal/ImmortalFourMod",
+ "/Lotus/Upgrades/Mods/Immortal/ImmortalFiveMod",
+ "/Lotus/Upgrades/Mods/Immortal/ImmortalSixMod",
+ "/Lotus/Upgrades/Mods/Immortal/ImmortalSevenMod",
+ "/Lotus/Upgrades/Mods/Immortal/ImmortalEightMod",
+ "/Lotus/Upgrades/Mods/Immortal/ImmortalWildcardMod"
+ ],
+ [
+ "/Lotus/Upgrades/Mods/Immortal/AntivirusOneMod",
+ "/Lotus/Upgrades/Mods/Immortal/AntivirusTwoMod",
+ "/Lotus/Upgrades/Mods/Immortal/AntivirusThreeMod",
+ "/Lotus/Upgrades/Mods/Immortal/AntivirusFourMod",
+ "/Lotus/Upgrades/Mods/Immortal/AntivirusFiveMod",
+ "/Lotus/Upgrades/Mods/Immortal/AntivirusSixMod",
+ "/Lotus/Upgrades/Mods/Immortal/AntivirusSevenMod",
+ "/Lotus/Upgrades/Mods/Immortal/AntivirusEightMod"
+ ],
+ [
+ "/Lotus/Upgrades/Mods/DataSpike/Potency/GainAntivirusAndSpeedOnUseMod",
+ "/Lotus/Upgrades/Mods/DataSpike/Potency/GainAntivirusAndWeaponDamageOnUseMod",
+ "/Lotus/Upgrades/Mods/DataSpike/Potency/GainAntivirusLargeOnSingleUseMod",
+ "/Lotus/Upgrades/Mods/DataSpike/Potency/GainAntivirusOnUseMod",
+ "/Lotus/Upgrades/Mods/DataSpike/Potency/GainAntivirusSmallOnSingleUseMod"
+ ]
+];
diff --git a/src/controllers/api/artifactsController.ts b/src/controllers/api/artifactsController.ts
new file mode 100644
index 00000000..4fb2155d
--- /dev/null
+++ b/src/controllers/api/artifactsController.ts
@@ -0,0 +1,68 @@
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { RequestHandler } from "express";
+import type { IInventoryClient, IUpgradeClient } from "../../types/inventoryTypes/inventoryTypes.ts";
+import { addMods, getInventory } from "../../services/inventoryService.ts";
+
+export const artifactsController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const artifactsData = getJSONfromString(String(req.body));
+
+ const { Upgrade, LevelDiff, Cost, FusionPointCost } = artifactsData;
+
+ const inventory = await getInventory(accountId);
+ const { Upgrades } = inventory;
+ const { ItemType, UpgradeFingerprint, ItemId } = Upgrade;
+
+ const safeUpgradeFingerprint = UpgradeFingerprint || '{"lvl":0}';
+ const parsedUpgradeFingerprint = JSON.parse(safeUpgradeFingerprint) as { lvl: number };
+ parsedUpgradeFingerprint.lvl += LevelDiff;
+ const stringifiedUpgradeFingerprint = JSON.stringify(parsedUpgradeFingerprint);
+
+ let itemIndex = Upgrades.findIndex(upgrade => upgrade._id.equals(ItemId.$oid));
+
+ if (itemIndex !== -1) {
+ Upgrades[itemIndex].UpgradeFingerprint = stringifiedUpgradeFingerprint;
+ } else {
+ itemIndex =
+ Upgrades.push({
+ UpgradeFingerprint: stringifiedUpgradeFingerprint,
+ ItemType
+ }) - 1;
+
+ addMods(inventory, [{ ItemType, ItemCount: -1 }]);
+ }
+
+ if (!inventory.infiniteCredits) {
+ inventory.RegularCredits -= Cost;
+ }
+ if (!inventory.infiniteEndo) {
+ inventory.FusionPoints -= FusionPointCost;
+ }
+
+ if (artifactsData.LegendaryFusion) {
+ addMods(inventory, [
+ {
+ ItemType: "/Lotus/Upgrades/Mods/Fusers/LegendaryModFuser",
+ ItemCount: -1
+ }
+ ]);
+ }
+
+ const changedInventory = await inventory.save();
+ const itemId = changedInventory.toJSON().Upgrades[itemIndex].ItemId.$oid;
+
+ if (!itemId) {
+ throw new Error("Item Id not found in upgradeMod");
+ }
+
+ res.send(itemId);
+};
+
+interface IArtifactsRequest {
+ Upgrade: IUpgradeClient;
+ LevelDiff: number;
+ Cost: number;
+ FusionPointCost: number;
+ LegendaryFusion?: boolean;
+}
diff --git a/src/controllers/api/cancelGuildAdvertisementController.ts b/src/controllers/api/cancelGuildAdvertisementController.ts
new file mode 100644
index 00000000..a94fd2d0
--- /dev/null
+++ b/src/controllers/api/cancelGuildAdvertisementController.ts
@@ -0,0 +1,20 @@
+import { GuildAd } from "../../models/guildModel.ts";
+import { getGuildForRequestEx, hasGuildPermission } from "../../services/guildService.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { GuildPermission } from "../../types/guildTypes.ts";
+import type { RequestHandler } from "express";
+
+export const cancelGuildAdvertisementController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId, "GuildId");
+ const guild = await getGuildForRequestEx(req, inventory);
+ if (!(await hasGuildPermission(guild, accountId, GuildPermission.Advertiser))) {
+ res.status(400).end();
+ return;
+ }
+
+ await GuildAd.deleteOne({ GuildId: guild._id });
+
+ res.end();
+};
diff --git a/src/controllers/api/changeDojoRootController.ts b/src/controllers/api/changeDojoRootController.ts
new file mode 100644
index 00000000..f4ab62ce
--- /dev/null
+++ b/src/controllers/api/changeDojoRootController.ts
@@ -0,0 +1,99 @@
+import type { RequestHandler } from "express";
+import {
+ getDojoClient,
+ getGuildForRequestEx,
+ hasAccessToDojo,
+ hasGuildPermission
+} from "../../services/guildService.ts";
+import { logger } from "../../utils/logger.ts";
+import type { IDojoComponentDatabase } from "../../types/guildTypes.ts";
+import { GuildPermission } from "../../types/guildTypes.ts";
+import { Types } from "mongoose";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+
+export const changeDojoRootController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId, "GuildId LevelKeys");
+ const guild = await getGuildForRequestEx(req, inventory);
+ if (!hasAccessToDojo(inventory) || !(await hasGuildPermission(guild, accountId, GuildPermission.Architect))) {
+ res.json({ DojoRequestStatus: -1 });
+ return;
+ }
+
+ // Example POST body: {"pivot":[0, 0, -64],"components":"{\"670429301ca0a63848ccc467\":{\"R\":[0,0,0],\"P\":[0,3,32]},\"6704254a1ca0a63848ccb33c\":{\"R\":[0,0,0],\"P\":[0,9.25,-32]},\"670429461ca0a63848ccc731\":{\"R\":[-90,0,0],\"P\":[-47.999992370605,3,16]}}"}
+ if (req.body) {
+ logger.debug(`data provided to ${req.path}: ${String(req.body)}`);
+ throw new Error("dojo reparent operation should not need deco repositioning"); // because we always provide SortId
+ }
+
+ const idToNode: Record = {};
+ guild.DojoComponents.forEach(x => {
+ idToNode[x._id.toString()] = {
+ component: x,
+ parent: undefined,
+ children: []
+ };
+ });
+
+ let oldRoot: INode | undefined;
+ guild.DojoComponents.forEach(x => {
+ const node = idToNode[x._id.toString()];
+ if (x.pi) {
+ idToNode[x.pi.toString()].children.push(node);
+ node.parent = idToNode[x.pi.toString()];
+ } else {
+ oldRoot = node;
+ }
+ });
+ logger.debug("Old tree:\n" + treeToString(oldRoot!));
+
+ const newRoot = idToNode[req.query.newRoot as string];
+ recursivelyTurnParentsIntoChildren(newRoot);
+ newRoot.component.pi = undefined;
+ newRoot.component.op = undefined;
+ newRoot.component.pp = undefined;
+ newRoot.parent = undefined;
+
+ // Set/update SortId in top-to-bottom order
+ const stack: INode[] = [newRoot];
+ while (stack.length != 0) {
+ const top = stack.shift()!;
+ top.component.SortId = new Types.ObjectId();
+ top.children.forEach(x => stack.push(x));
+ }
+
+ logger.debug("New tree:\n" + treeToString(newRoot));
+
+ await guild.save();
+
+ res.json(await getDojoClient(guild, 0));
+};
+
+interface INode {
+ component: IDojoComponentDatabase;
+ parent: INode | undefined;
+ children: INode[];
+}
+
+const treeToString = (root: INode, depth: number = 0): string => {
+ let str = " ".repeat(depth * 4) + root.component.pf + " (" + root.component._id.toString() + ")\n";
+ root.children.forEach(x => {
+ str += treeToString(x, depth + 1);
+ });
+ return str;
+};
+
+const recursivelyTurnParentsIntoChildren = (node: INode): void => {
+ if (node.parent!.parent) {
+ recursivelyTurnParentsIntoChildren(node.parent!);
+ }
+
+ node.parent!.component.pi = node.component._id;
+ node.parent!.component.op = node.component.pp;
+ node.parent!.component.pp = node.component.op;
+
+ node.parent!.parent = node;
+ node.parent!.children.splice(node.parent!.children.indexOf(node), 1);
+ node.children.push(node.parent!);
+};
diff --git a/src/controllers/api/changeGuildRankController.ts b/src/controllers/api/changeGuildRankController.ts
new file mode 100644
index 00000000..30ebe2c3
--- /dev/null
+++ b/src/controllers/api/changeGuildRankController.ts
@@ -0,0 +1,38 @@
+import { GuildMember } from "../../models/guildModel.ts";
+import { getGuildForRequest, hasGuildPermissionEx } from "../../services/guildService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { GuildPermission } from "../../types/guildTypes.ts";
+import type { RequestHandler } from "express";
+
+export const changeGuildRankController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const member = (await GuildMember.findOne({
+ accountId: accountId,
+ guildId: req.query.guildId as string
+ }))!;
+ const newRank: number = parseInt(req.query.rankChange as string);
+
+ const guild = await getGuildForRequest(req);
+ if (newRank < member.rank || !hasGuildPermissionEx(guild, member, GuildPermission.Promoter)) {
+ res.status(400).json("Invalid permission");
+ return;
+ }
+
+ const target = (await GuildMember.findOne({
+ guildId: req.query.guildId as string,
+ accountId: req.query.targetId as string
+ }))!;
+ target.rank = parseInt(req.query.rankChange as string);
+ await target.save();
+
+ if (newRank == 0) {
+ // If we just promoted someone else to Founding Warlord, we need to demote ourselves to Warlord.
+ member.rank = 1;
+ await member.save();
+ }
+
+ res.json({
+ _id: req.query.targetId as string,
+ Rank: newRank
+ });
+};
diff --git a/src/controllers/api/checkDailyMissionBonusController.ts b/src/controllers/api/checkDailyMissionBonusController.ts
new file mode 100644
index 00000000..4bdcb628
--- /dev/null
+++ b/src/controllers/api/checkDailyMissionBonusController.ts
@@ -0,0 +1,12 @@
+import { getAccountForRequest } from "../../services/loginService.ts";
+import type { RequestHandler } from "express";
+
+export const checkDailyMissionBonusController: RequestHandler = async (req, res) => {
+ const account = await getAccountForRequest(req);
+ const today = Math.trunc(Date.now() / 86400000) * 86400;
+ if (account.DailyFirstWinDate != today) {
+ res.send("DailyMissionBonus:1-DailyPVPWinBonus:1\n");
+ } else {
+ res.send("DailyMissionBonus:0-DailyPVPWinBonus:1\n");
+ }
+};
diff --git a/src/controllers/api/claimCompletedRecipeController.ts b/src/controllers/api/claimCompletedRecipeController.ts
new file mode 100644
index 00000000..39782101
--- /dev/null
+++ b/src/controllers/api/claimCompletedRecipeController.ts
@@ -0,0 +1,307 @@
+//this is a controller for the claimCompletedRecipe route
+//it will claim a recipe for the user
+
+import type { RequestHandler } from "express";
+import { logger } from "../../utils/logger.ts";
+import { getRecipe } from "../../services/itemDataService.ts";
+import type { IOidWithLegacySupport } from "../../types/commonTypes.ts";
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import type { TAccountDocument } from "../../services/loginService.ts";
+import { getAccountForRequest } from "../../services/loginService.ts";
+import {
+ getInventory,
+ updateCurrency,
+ addItem,
+ addRecipes,
+ occupySlot,
+ combineInventoryChanges,
+ addKubrowPetPrint,
+ addPowerSuit,
+ addEquipment
+} from "../../services/inventoryService.ts";
+import type { IInventoryChanges } from "../../types/purchaseTypes.ts";
+import type { IPendingRecipeDatabase } from "../../types/inventoryTypes/inventoryTypes.ts";
+import { InventorySlot } from "../../types/inventoryTypes/inventoryTypes.ts";
+import { fromOid, toOid2 } from "../../helpers/inventoryHelpers.ts";
+import type { TInventoryDatabaseDocument } from "../../models/inventoryModels/inventoryModel.ts";
+import type { IRecipe } from "warframe-public-export-plus";
+import type { IEquipmentClient } from "../../types/equipmentTypes.ts";
+import { EquipmentFeatures, Status } from "../../types/equipmentTypes.ts";
+
+interface IClaimCompletedRecipeRequest {
+ RecipeIds: IOidWithLegacySupport[];
+}
+
+interface IClaimCompletedRecipeResponse {
+ InventoryChanges: IInventoryChanges;
+ BrandedSuits?: IOidWithLegacySupport[];
+}
+
+export const claimCompletedRecipeController: RequestHandler = async (req, res) => {
+ const claimCompletedRecipeRequest = getJSONfromString(String(req.body));
+ const account = await getAccountForRequest(req);
+ const inventory = await getInventory(account._id.toString());
+ const resp: IClaimCompletedRecipeResponse = {
+ InventoryChanges: {}
+ };
+ for (const recipeId of claimCompletedRecipeRequest.RecipeIds) {
+ const pendingRecipe = inventory.PendingRecipes.id(fromOid(recipeId));
+ if (!pendingRecipe) {
+ throw new Error(`no pending recipe found with id ${fromOid(recipeId)}`);
+ }
+
+ //check recipe is indeed ready to be completed
+ // if (pendingRecipe.CompletionDate > new Date()) {
+ // throw new Error(`recipe ${pendingRecipe._id} is not ready to be completed`);
+ // }
+
+ inventory.PendingRecipes.pull(pendingRecipe._id);
+
+ const recipe = getRecipe(pendingRecipe.ItemType);
+ if (!recipe) {
+ throw new Error(`no completed item found for recipe ${pendingRecipe._id.toString()}`);
+ }
+
+ if (req.query.cancel) {
+ const inventoryChanges: IInventoryChanges = {};
+ await refundRecipeIngredients(inventory, inventoryChanges, recipe, pendingRecipe);
+ await inventory.save();
+ res.json(inventoryChanges); // Not a bug: In the specific case of cancelling a recipe, InventoryChanges are expected to be the root.
+ return;
+ }
+
+ await claimCompletedRecipe(account, inventory, recipe, pendingRecipe, resp, req.query.rush);
+ }
+ await inventory.save();
+ res.json(resp);
+};
+
+const claimCompletedRecipe = async (
+ account: TAccountDocument,
+ inventory: TInventoryDatabaseDocument,
+ recipe: IRecipe,
+ pendingRecipe: IPendingRecipeDatabase,
+ resp: IClaimCompletedRecipeResponse,
+ rush: any
+): Promise => {
+ logger.debug("Claiming Recipe", { recipe, pendingRecipe });
+
+ if (recipe.secretIngredientAction == "SIA_SPECTRE_LOADOUT_COPY") {
+ inventory.PendingSpectreLoadouts ??= [];
+ inventory.SpectreLoadouts ??= [];
+
+ const pendingLoadoutIndex = inventory.PendingSpectreLoadouts.findIndex(x => x.ItemType == recipe.resultType);
+ if (pendingLoadoutIndex != -1) {
+ const loadoutIndex = inventory.SpectreLoadouts.findIndex(x => x.ItemType == recipe.resultType);
+ if (loadoutIndex != -1) {
+ inventory.SpectreLoadouts.splice(loadoutIndex, 1);
+ }
+ logger.debug(
+ "moving spectre loadout from pending to active",
+ inventory.toJSON().PendingSpectreLoadouts![pendingLoadoutIndex]
+ );
+ inventory.SpectreLoadouts.push(inventory.PendingSpectreLoadouts[pendingLoadoutIndex]);
+ inventory.PendingSpectreLoadouts.splice(pendingLoadoutIndex, 1);
+ }
+ } else if (recipe.secretIngredientAction == "SIA_UNBRAND") {
+ inventory.BrandedSuits!.splice(
+ inventory.BrandedSuits!.findIndex(x => x.equals(pendingRecipe.SuitToUnbrand)),
+ 1
+ );
+ resp.BrandedSuits = [toOid2(pendingRecipe.SuitToUnbrand!, account.BuildLabel)];
+ }
+
+ if (recipe.consumeOnUse) {
+ addRecipes(inventory, [
+ {
+ ItemType: pendingRecipe.ItemType,
+ ItemCount: -1
+ }
+ ]);
+ }
+
+ if (rush) {
+ const end = Math.trunc(pendingRecipe.CompletionDate.getTime() / 1000);
+ const start = end - recipe.buildTime;
+ const secondsElapsed = Math.trunc(Date.now() / 1000) - start;
+ const progress = secondsElapsed / recipe.buildTime;
+ logger.debug(`rushing recipe at ${Math.trunc(progress * 100)}% completion`);
+ const cost =
+ progress > 0.5 ? Math.round(recipe.skipBuildTimePrice * (1 - (progress - 0.5))) : recipe.skipBuildTimePrice;
+ combineInventoryChanges(resp.InventoryChanges, updateCurrency(inventory, cost, true));
+ }
+
+ if (recipe.secretIngredientAction == "SIA_CREATE_KUBROW") {
+ const pet = inventory.KubrowPets.id(pendingRecipe.KubrowPet!)!;
+ if (pet.Details!.HatchDate!.getTime() > Date.now()) {
+ pet.Details!.HatchDate = new Date();
+ }
+ let canSetActive = true;
+ for (const pet of inventory.KubrowPets) {
+ if (pet.Details!.Status == Status.StatusAvailable) {
+ canSetActive = false;
+ break;
+ }
+ }
+ pet.Details!.Status = canSetActive ? Status.StatusAvailable : Status.StatusStasis;
+ } else if (recipe.secretIngredientAction == "SIA_DISTILL_PRINT") {
+ const pet = inventory.KubrowPets.id(pendingRecipe.KubrowPet!)!;
+ addKubrowPetPrint(inventory, pet, resp.InventoryChanges);
+ } else if (recipe.secretIngredientAction != "SIA_UNBRAND") {
+ if (recipe.resultType == "/Lotus/Powersuits/Excalibur/ExcaliburUmbra") {
+ // Quite the special case here...
+ // We don't just get Umbra, but also Skiajati and Umbra Mods. Both items are max rank, potatoed, and with the mods are pre-installed.
+ // Source: https://wiki.warframe.com/w/The_Sacrifice, https://wiki.warframe.com/w/Excalibur/Umbra, https://wiki.warframe.com/w/Skiajati
+
+ const umbraModA = (
+ await addItem(
+ inventory,
+ "/Lotus/Upgrades/Mods/Sets/Umbra/WarframeUmbraModA",
+ 1,
+ false,
+ undefined,
+ `{"lvl":5}`
+ )
+ ).Upgrades![0];
+ const umbraModB = (
+ await addItem(
+ inventory,
+ "/Lotus/Upgrades/Mods/Sets/Umbra/WarframeUmbraModB",
+ 1,
+ false,
+ undefined,
+ `{"lvl":5}`
+ )
+ ).Upgrades![0];
+ const umbraModC = (
+ await addItem(
+ inventory,
+ "/Lotus/Upgrades/Mods/Sets/Umbra/WarframeUmbraModC",
+ 1,
+ false,
+ undefined,
+ `{"lvl":5}`
+ )
+ ).Upgrades![0];
+ const sacrificeModA = (
+ await addItem(
+ inventory,
+ "/Lotus/Upgrades/Mods/Sets/Sacrifice/MeleeSacrificeModA",
+ 1,
+ false,
+ undefined,
+ `{"lvl":5}`
+ )
+ ).Upgrades![0];
+ const sacrificeModB = (
+ await addItem(
+ inventory,
+ "/Lotus/Upgrades/Mods/Sets/Sacrifice/MeleeSacrificeModB",
+ 1,
+ false,
+ undefined,
+ `{"lvl":5}`
+ )
+ ).Upgrades![0];
+ resp.InventoryChanges.Upgrades ??= [];
+ resp.InventoryChanges.Upgrades.push(umbraModA, umbraModB, umbraModC, sacrificeModA, sacrificeModB);
+
+ await addPowerSuit(
+ inventory,
+ "/Lotus/Powersuits/Excalibur/ExcaliburUmbra",
+ {
+ Configs: [
+ {
+ Upgrades: [
+ "",
+ "",
+ "",
+ "",
+ "",
+ umbraModA.ItemId.$oid,
+ umbraModB.ItemId.$oid,
+ umbraModC.ItemId.$oid
+ ]
+ }
+ ],
+ XP: 900_000,
+ Features: EquipmentFeatures.DOUBLE_CAPACITY
+ },
+ resp.InventoryChanges
+ );
+ inventory.XPInfo.push({
+ ItemType: "/Lotus/Powersuits/Excalibur/ExcaliburUmbra",
+ XP: 900_000
+ });
+
+ addEquipment(
+ inventory,
+ "Melee",
+ "/Lotus/Weapons/Tenno/Melee/Swords/UmbraKatana/UmbraKatana",
+ {
+ Configs: [
+ { Upgrades: ["", "", "", "", "", "", sacrificeModA.ItemId.$oid, sacrificeModB.ItemId.$oid] }
+ ],
+ XP: 450_000,
+ Features: EquipmentFeatures.DOUBLE_CAPACITY
+ },
+ resp.InventoryChanges
+ );
+ inventory.XPInfo.push({
+ ItemType: "/Lotus/Weapons/Tenno/Melee/Swords/UmbraKatana/UmbraKatana",
+ XP: 450_000
+ });
+ } else {
+ combineInventoryChanges(
+ resp.InventoryChanges,
+ await addItem(
+ inventory,
+ recipe.resultType,
+ recipe.num,
+ false,
+ undefined,
+ pendingRecipe.TargetFingerprint
+ )
+ );
+ }
+ }
+ if (
+ inventory.claimingBlueprintRefundsIngredients &&
+ recipe.secretIngredientAction != "SIA_CREATE_KUBROW" // Can't refund the egg
+ ) {
+ await refundRecipeIngredients(inventory, resp.InventoryChanges, recipe, pendingRecipe);
+ }
+};
+
+const refundRecipeIngredients = async (
+ inventory: TInventoryDatabaseDocument,
+ inventoryChanges: IInventoryChanges,
+ recipe: IRecipe,
+ pendingRecipe: IPendingRecipeDatabase
+): Promise => {
+ updateCurrency(inventory, recipe.buildPrice * -1, false, inventoryChanges);
+
+ const equipmentIngredients = new Set();
+ for (const category of ["LongGuns", "Pistols", "Melee"] as const) {
+ if (pendingRecipe[category]) {
+ pendingRecipe[category].forEach(item => {
+ const index = inventory[category].push(item) - 1;
+ inventoryChanges[category] ??= [];
+ inventoryChanges[category].push(inventory[category][index].toJSON());
+ equipmentIngredients.add(item.ItemType);
+
+ occupySlot(inventory, InventorySlot.WEAPONS, false);
+ inventoryChanges.WeaponBin ??= { Slots: 0 };
+ inventoryChanges.WeaponBin.Slots -= 1;
+ });
+ }
+ }
+ for (const ingredient of recipe.ingredients) {
+ if (!equipmentIngredients.has(ingredient.ItemType)) {
+ combineInventoryChanges(
+ inventoryChanges,
+ await addItem(inventory, ingredient.ItemType, ingredient.ItemCount)
+ );
+ }
+ }
+};
diff --git a/src/controllers/api/claimJunctionChallengeRewardController.ts b/src/controllers/api/claimJunctionChallengeRewardController.ts
new file mode 100644
index 00000000..4e45852e
--- /dev/null
+++ b/src/controllers/api/claimJunctionChallengeRewardController.ts
@@ -0,0 +1,35 @@
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { combineInventoryChanges, getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { handleStoreItemAcquisition } from "../../services/purchaseService.ts";
+import type { RequestHandler } from "express";
+import { ExportChallenges } from "warframe-public-export-plus";
+
+export const claimJunctionChallengeRewardController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId);
+ const data = getJSONfromString(String(req.body));
+ const challengeProgress = inventory.ChallengeProgress.find(x => x.Name == data.Challenge)!;
+ if (challengeProgress.ReceivedJunctionReward) {
+ throw new Error(`attempt to double-claim junction reward`);
+ }
+ challengeProgress.ReceivedJunctionReward = true;
+ inventory.ClaimedJunctionChallengeRewards ??= [];
+ inventory.ClaimedJunctionChallengeRewards.push(data.Challenge);
+ const challengeMeta = Object.entries(ExportChallenges).find(arr => arr[0].endsWith("/" + data.Challenge))![1];
+ const inventoryChanges = {};
+ for (const reward of challengeMeta.countedRewards!) {
+ combineInventoryChanges(
+ inventoryChanges,
+ (await handleStoreItemAcquisition(reward.StoreItem, inventory, reward.ItemCount)).InventoryChanges
+ );
+ }
+ await inventory.save();
+ res.json({
+ inventoryChanges: inventoryChanges // Yeah, it's "inventoryChanges" in the response here.
+ });
+};
+
+interface IClaimJunctionChallengeRewardRequest {
+ Challenge: string;
+}
diff --git a/src/controllers/api/claimLibraryDailyTaskRewardController.ts b/src/controllers/api/claimLibraryDailyTaskRewardController.ts
new file mode 100644
index 00000000..423c3ba2
--- /dev/null
+++ b/src/controllers/api/claimLibraryDailyTaskRewardController.ts
@@ -0,0 +1,31 @@
+import { addFusionPoints, getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { RequestHandler } from "express";
+
+export const claimLibraryDailyTaskRewardController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId);
+
+ const rewardQuantity = inventory.LibraryActiveDailyTaskInfo!.RewardQuantity;
+ const rewardStanding = inventory.LibraryActiveDailyTaskInfo!.RewardStanding;
+ inventory.LibraryActiveDailyTaskInfo = undefined;
+ inventory.LibraryAvailableDailyTaskInfo = undefined;
+
+ let syndicate = inventory.Affiliations.find(x => x.Tag == "LibrarySyndicate");
+ if (!syndicate) {
+ syndicate = inventory.Affiliations[inventory.Affiliations.push({ Tag: "LibrarySyndicate", Standing: 0 }) - 1];
+ }
+ syndicate.Standing += rewardStanding;
+
+ addFusionPoints(inventory, 80 * rewardQuantity);
+ await inventory.save();
+
+ res.json({
+ RewardItem: "/Lotus/StoreItems/Upgrades/Mods/FusionBundles/RareFusionBundle",
+ RewardQuantity: rewardQuantity,
+ StandingAwarded: rewardStanding,
+ InventoryChanges: {
+ FusionPoints: 80 * rewardQuantity
+ }
+ });
+};
diff --git a/src/controllers/api/clearDialogueHistoryController.ts b/src/controllers/api/clearDialogueHistoryController.ts
new file mode 100644
index 00000000..4a657dfe
--- /dev/null
+++ b/src/controllers/api/clearDialogueHistoryController.ts
@@ -0,0 +1,25 @@
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { RequestHandler } from "express";
+
+export const clearDialogueHistoryController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId);
+ const request = JSON.parse(String(req.body)) as IClearDialogueRequest;
+ if (inventory.DialogueHistory && inventory.DialogueHistory.Dialogues) {
+ inventory.DialogueHistory.Resets ??= 0;
+ inventory.DialogueHistory.Resets += 1;
+ for (const dialogueName of request.Dialogues) {
+ const index = inventory.DialogueHistory.Dialogues.findIndex(x => x.DialogueName == dialogueName);
+ if (index != -1) {
+ inventory.DialogueHistory.Dialogues.splice(index, 1);
+ }
+ }
+ }
+ await inventory.save();
+ res.end();
+};
+
+interface IClearDialogueRequest {
+ Dialogues: string[];
+}
diff --git a/src/controllers/api/clearNewEpisodeRewardController.ts b/src/controllers/api/clearNewEpisodeRewardController.ts
new file mode 100644
index 00000000..dcd8e46c
--- /dev/null
+++ b/src/controllers/api/clearNewEpisodeRewardController.ts
@@ -0,0 +1,6 @@
+import type { RequestHandler } from "express";
+
+// example req.body: {"NewEpisodeReward":true,"crossPlaySetting":"ENABLED"}
+export const clearNewEpisodeRewardController: RequestHandler = (_req, res) => {
+ res.status(200).end();
+};
diff --git a/src/controllers/api/completeCalendarEventController.ts b/src/controllers/api/completeCalendarEventController.ts
new file mode 100644
index 00000000..5faef69c
--- /dev/null
+++ b/src/controllers/api/completeCalendarEventController.ts
@@ -0,0 +1,37 @@
+import { checkCalendarAutoAdvance, getCalendarProgress, getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { handleStoreItemAcquisition } from "../../services/purchaseService.ts";
+import { getWorldState } from "../../services/worldStateService.ts";
+import type { IInventoryChanges } from "../../types/purchaseTypes.ts";
+import type { RequestHandler } from "express";
+
+// GET request; query parameters: CompletedEventIdx=0&Iteration=4&Version=19&Season=CST_SUMMER
+export const completeCalendarEventController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId);
+ const calendarProgress = getCalendarProgress(inventory);
+ const currentSeason = getWorldState().KnownCalendarSeasons[0];
+ let inventoryChanges: IInventoryChanges = {};
+ const dayIndex = calendarProgress.SeasonProgress.LastCompletedDayIdx + 1;
+ const day = currentSeason.Days[dayIndex];
+ if (day.events.length != 0) {
+ if (day.events[0].type == "CET_CHALLENGE") {
+ throw new Error(`completeCalendarEvent should not be used for challenges`);
+ }
+ const selection = day.events[parseInt(req.query.CompletedEventIdx as string)];
+ if (selection.type == "CET_REWARD") {
+ inventoryChanges = (await handleStoreItemAcquisition(selection.reward!, inventory)).InventoryChanges;
+ } else if (selection.type == "CET_UPGRADE") {
+ calendarProgress.YearProgress.Upgrades.push(selection.upgrade!);
+ } else if (selection.type != "CET_PLOT") {
+ throw new Error(`unexpected selection type: ${selection.type}`);
+ }
+ }
+ calendarProgress.SeasonProgress.LastCompletedDayIdx = dayIndex;
+ checkCalendarAutoAdvance(inventory, currentSeason);
+ await inventory.save();
+ res.json({
+ InventoryChanges: inventoryChanges,
+ CalendarProgress: inventory.CalendarProgress
+ });
+};
diff --git a/src/controllers/api/completeRandomModChallengeController.ts b/src/controllers/api/completeRandomModChallengeController.ts
new file mode 100644
index 00000000..d3c0055a
--- /dev/null
+++ b/src/controllers/api/completeRandomModChallengeController.ts
@@ -0,0 +1,45 @@
+import type { RequestHandler } from "express";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { addMiscItems, getInventory, updateCurrency } from "../../services/inventoryService.ts";
+import type { IInventoryChanges } from "../../types/purchaseTypes.ts";
+import type { IMiscItem } from "../../types/inventoryTypes/inventoryTypes.ts";
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import type { IVeiledRivenFingerprint } from "../../helpers/rivenHelper.ts";
+
+export const completeRandomModChallengeController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId);
+ const request = getJSONfromString(String(req.body));
+ let inventoryChanges: IInventoryChanges = {};
+
+ // Remove 20 plat or riven cipher
+ if ((req.query.p as string) == "1") {
+ inventoryChanges = { ...updateCurrency(inventory, 20, true) };
+ } else {
+ const miscItemChanges: IMiscItem[] = [
+ {
+ ItemType: "/Lotus/Types/Items/MiscItems/RivenIdentifier",
+ ItemCount: -1
+ }
+ ];
+ addMiscItems(inventory, miscItemChanges);
+ inventoryChanges.MiscItems = miscItemChanges;
+ }
+
+ // Complete the riven challenge
+ const upgrade = inventory.Upgrades.id(request.ItemId)!;
+ const fp = JSON.parse(upgrade.UpgradeFingerprint!) as IVeiledRivenFingerprint;
+ fp.challenge.Progress = fp.challenge.Required;
+ upgrade.UpgradeFingerprint = JSON.stringify(fp);
+
+ await inventory.save();
+
+ res.json({
+ InventoryChanges: inventoryChanges,
+ Fingerprint: upgrade.UpgradeFingerprint
+ });
+};
+
+interface ICompleteRandomModChallengeRequest {
+ ItemId: string;
+}
diff --git a/src/controllers/api/confirmAllianceInvitationController.ts b/src/controllers/api/confirmAllianceInvitationController.ts
new file mode 100644
index 00000000..095fa165
--- /dev/null
+++ b/src/controllers/api/confirmAllianceInvitationController.ts
@@ -0,0 +1,37 @@
+import { Alliance, AllianceMember, Guild, GuildMember } from "../../models/guildModel.ts";
+import { getAllianceClient } from "../../services/guildService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { RequestHandler } from "express";
+
+export const confirmAllianceInvitationController: RequestHandler = async (req, res) => {
+ // Check requester is a warlord in their guild
+ const accountId = await getAccountIdForRequest(req);
+ const guildMember = (await GuildMember.findOne({ accountId, status: 0 }))!;
+ if (guildMember.rank > 1) {
+ res.status(400).json({ Error: 104 });
+ return;
+ }
+
+ const allianceMember = await AllianceMember.findOne({
+ allianceId: req.query.allianceId,
+ guildId: guildMember.guildId
+ });
+ if (!allianceMember || !allianceMember.Pending) {
+ res.status(400);
+ return;
+ }
+ allianceMember.Pending = false;
+
+ const guild = (await Guild.findById(guildMember.guildId))!;
+ guild.AllianceId = allianceMember.allianceId;
+
+ await Promise.all([allianceMember.save(), guild.save()]);
+
+ // Give client the new alliance data which uses "AllianceId" instead of "_id" in this response
+ const alliance = (await Alliance.findById(allianceMember.allianceId))!;
+ const { _id, ...rest } = await getAllianceClient(alliance, guild);
+ res.json({
+ AllianceId: _id,
+ ...rest
+ });
+};
diff --git a/src/controllers/api/confirmGuildInvitationController.ts b/src/controllers/api/confirmGuildInvitationController.ts
new file mode 100644
index 00000000..bfe30fb2
--- /dev/null
+++ b/src/controllers/api/confirmGuildInvitationController.ts
@@ -0,0 +1,121 @@
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { Guild, GuildMember } from "../../models/guildModel.ts";
+import { Account } from "../../models/loginModel.ts";
+import {
+ deleteGuild,
+ getGuildClient,
+ giveClanKey,
+ hasGuildPermission,
+ removeDojoKeyItems
+} from "../../services/guildService.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountForRequest, getAccountIdForRequest, getSuffixedName } from "../../services/loginService.ts";
+import { GuildPermission } from "../../types/guildTypes.ts";
+import type { IInventoryChanges } from "../../types/purchaseTypes.ts";
+import type { RequestHandler } from "express";
+import { Types } from "mongoose";
+
+// GET request: A player accepting an invite they got in their inbox.
+export const confirmGuildInvitationGetController: RequestHandler = async (req, res) => {
+ const account = await getAccountForRequest(req);
+ const invitedGuildMember = await GuildMember.findOne({
+ accountId: account._id,
+ guildId: req.query.clanId as string
+ });
+ if (invitedGuildMember && invitedGuildMember.status == 2) {
+ let inventoryChanges: IInventoryChanges = {};
+
+ // If this account is already in a guild, we need to do cleanup first.
+ const guildMember = await GuildMember.findOneAndDelete({ accountId: account._id, status: 0 });
+ if (guildMember) {
+ const inventory = await getInventory(account._id.toString(), "LevelKeys Recipes");
+ inventoryChanges = removeDojoKeyItems(inventory);
+ await inventory.save();
+
+ if (guildMember.rank == 0) {
+ await deleteGuild(guildMember.guildId);
+ }
+ }
+
+ // Now that we're sure this account is not in a guild right now, we can just proceed with the normal updates.
+ invitedGuildMember.status = 0;
+ await invitedGuildMember.save();
+
+ // Remove pending applications for this account
+ await GuildMember.deleteMany({ accountId: account._id, status: 1 });
+
+ // Update inventory of new member
+ const inventory = await getInventory(account._id.toString(), "GuildId LevelKeys Recipes");
+ inventory.GuildId = new Types.ObjectId(req.query.clanId as string);
+ giveClanKey(inventory, inventoryChanges);
+ await inventory.save();
+
+ const guild = (await Guild.findById(req.query.clanId as string))!;
+
+ // Add join to clan log
+ guild.RosterActivity ??= [];
+ guild.RosterActivity.push({
+ dateTime: new Date(),
+ entryType: 6,
+ details: getSuffixedName(account)
+ });
+ await guild.save();
+
+ res.json({
+ ...(await getGuildClient(guild, account)),
+ InventoryChanges: inventoryChanges
+ });
+ } else {
+ res.end();
+ }
+};
+
+// POST request: Clan representative accepting invite(s).
+export const confirmGuildInvitationPostController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const guild = (await Guild.findById(req.query.clanId as string, "Ranks RosterActivity"))!;
+ if (!(await hasGuildPermission(guild, accountId, GuildPermission.Recruiter))) {
+ res.status(400).json("Invalid permission");
+ return;
+ }
+ const payload = getJSONfromString<{ userId: string }>(String(req.body));
+ const filter: { accountId?: string; status: number } = { status: 1 };
+ if (payload.userId != "all") {
+ filter.accountId = payload.userId;
+ }
+ const guildMembers = await GuildMember.find(filter);
+ const newMembers: string[] = [];
+ for (const guildMember of guildMembers) {
+ guildMember.status = 0;
+ guildMember.RequestMsg = undefined;
+ guildMember.RequestExpiry = undefined;
+ await guildMember.save();
+
+ // Remove other pending applications for this account
+ await GuildMember.deleteMany({ accountId: guildMember.accountId, status: 1 });
+
+ // Update inventory of new member
+ const inventory = await getInventory(
+ guildMember.accountId.toString(),
+ "GuildId LevelKeys Recipes skipClanKeyCrafting"
+ );
+ inventory.GuildId = new Types.ObjectId(req.query.clanId as string);
+ giveClanKey(inventory);
+ await inventory.save();
+
+ // Add join to clan log
+ const account = (await Account.findOne({ _id: guildMember.accountId }))!;
+ guild.RosterActivity ??= [];
+ guild.RosterActivity.push({
+ dateTime: new Date(),
+ entryType: 6,
+ details: getSuffixedName(account)
+ });
+
+ newMembers.push(account._id.toString());
+ }
+ await guild.save();
+ res.json({
+ NewMembers: newMembers
+ });
+};
diff --git a/src/controllers/api/contributeGuildClassController.ts b/src/controllers/api/contributeGuildClassController.ts
new file mode 100644
index 00000000..3a04ee6d
--- /dev/null
+++ b/src/controllers/api/contributeGuildClassController.ts
@@ -0,0 +1,67 @@
+import { toMongoDate } from "../../helpers/inventoryHelpers.ts";
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { Guild } from "../../models/guildModel.ts";
+import { checkClanAscensionHasRequiredContributors } from "../../services/guildService.ts";
+import { addFusionPoints, getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { RequestHandler } from "express";
+import { Types } from "mongoose";
+
+export const contributeGuildClassController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const payload = getJSONfromString(String(req.body));
+ const guild = (await Guild.findById(payload.GuildId))!;
+
+ // First contributor initiates ceremony and locks the pending class.
+ if (!guild.CeremonyContributors) {
+ guild.CeremonyContributors = [];
+ guild.CeremonyClass = guildXpToClass(guild.XP);
+ guild.CeremonyEndo = 0;
+ for (let i = guild.Class; i != guild.CeremonyClass; ++i) {
+ guild.CeremonyEndo += (i + 1) * 1000;
+ }
+ guild.ClassChanges ??= [];
+ guild.ClassChanges.push({
+ dateTime: new Date(),
+ entryType: 13,
+ details: guild.CeremonyClass
+ });
+ }
+
+ guild.CeremonyContributors.push(new Types.ObjectId(accountId));
+
+ await checkClanAscensionHasRequiredContributors(guild);
+
+ await guild.save();
+
+ // Either way, endo is given to the contributor.
+ const inventory = await getInventory(accountId, "FusionPoints");
+ addFusionPoints(inventory, guild.CeremonyEndo!);
+ await inventory.save();
+
+ res.json({
+ NumContributors: guild.CeremonyContributors.length,
+ FusionPointReward: guild.CeremonyEndo,
+ Class: guild.Class,
+ CeremonyResetDate: guild.CeremonyResetDate ? toMongoDate(guild.CeremonyResetDate) : undefined
+ });
+};
+
+interface IContributeGuildClassRequest {
+ GuildId: string;
+ RequiredContributors: number;
+}
+
+const guildXpToClass = (xp: number): number => {
+ const cummXp = [
+ 0, 11000, 34000, 69000, 114000, 168000, 231000, 302000, 381000, 68000, 563000, 665000, 774000, 891000
+ ];
+ let highest = 0;
+ for (let i = 0; i != cummXp.length; ++i) {
+ if (xp < cummXp[i]) {
+ break;
+ }
+ highest = i;
+ }
+ return highest;
+};
diff --git a/src/controllers/api/contributeToDojoComponentController.ts b/src/controllers/api/contributeToDojoComponentController.ts
new file mode 100644
index 00000000..14c43878
--- /dev/null
+++ b/src/controllers/api/contributeToDojoComponentController.ts
@@ -0,0 +1,170 @@
+import type { TGuildDatabaseDocument } from "../../models/guildModel.ts";
+import { GuildMember } from "../../models/guildModel.ts";
+import type { TInventoryDatabaseDocument } from "../../models/inventoryModels/inventoryModel.ts";
+import {
+ addGuildMemberMiscItemContribution,
+ getDojoClient,
+ getGuildForRequestEx,
+ hasAccessToDojo,
+ processDojoBuildMaterialsGathered,
+ scaleRequiredCount,
+ setDojoRoomLogFunded
+} from "../../services/guildService.ts";
+import { addMiscItems, getInventory, updateCurrency } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { IDojoContributable, IGuildMemberDatabase } from "../../types/guildTypes.ts";
+import type { IMiscItem } from "../../types/inventoryTypes/inventoryTypes.ts";
+import type { IInventoryChanges } from "../../types/purchaseTypes.ts";
+import type { RequestHandler } from "express";
+import type { IDojoBuild } from "warframe-public-export-plus";
+import { ExportDojoRecipes } from "warframe-public-export-plus";
+
+interface IContributeToDojoComponentRequest {
+ ComponentId: string;
+ DecoId?: string;
+ DecoType?: string;
+ IngredientContributions: IMiscItem[];
+ RegularCredits: number;
+ VaultIngredientContributions: IMiscItem[];
+ VaultCredits: number;
+}
+
+export const contributeToDojoComponentController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId);
+ // Any clan member should have permission to contribute although notably permission is denied if they have not crafted the dojo key and were simply invited in.
+ if (!hasAccessToDojo(inventory)) {
+ res.json({ DojoRequestStatus: -1 });
+ return;
+ }
+ const guild = await getGuildForRequestEx(req, inventory);
+ const guildMember = (await GuildMember.findOne(
+ { accountId, guildId: guild._id },
+ "RegularCreditsContributed MiscItemsContributed"
+ ))!;
+ const request = JSON.parse(String(req.body)) as IContributeToDojoComponentRequest;
+ const component = guild.DojoComponents.id(request.ComponentId)!;
+
+ const inventoryChanges: IInventoryChanges = {};
+ if (!component.CompletionTime) {
+ // Room is in "Collecting Materials" state
+ if (request.DecoId) {
+ throw new Error("attempt to contribute to a deco in an unfinished room?!");
+ }
+ const meta = Object.values(ExportDojoRecipes.rooms).find(x => x.resultType == component.pf)!;
+ processContribution(guild, guildMember, request, inventory, inventoryChanges, meta, component);
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ if (component.CompletionTime) {
+ setDojoRoomLogFunded(guild, component);
+ }
+ } else {
+ // Room is past "Collecting Materials"
+ if (request.DecoId) {
+ const deco = component.Decos!.find(x => x._id.equals(request.DecoId))!;
+ const meta = Object.values(ExportDojoRecipes.decos).find(x => x.resultType == deco.Type)!;
+ processContribution(guild, guildMember, request, inventory, inventoryChanges, meta, deco);
+ }
+ }
+
+ await Promise.all([guild.save(), inventory.save(), guildMember.save()]);
+ res.json({
+ ...(await getDojoClient(guild, 0, component._id)),
+ InventoryChanges: inventoryChanges
+ });
+};
+
+const processContribution = (
+ guild: TGuildDatabaseDocument,
+ guildMember: IGuildMemberDatabase,
+ request: IContributeToDojoComponentRequest,
+ inventory: TInventoryDatabaseDocument,
+ inventoryChanges: IInventoryChanges,
+ meta: IDojoBuild,
+ component: IDojoContributable
+): void => {
+ component.RegularCredits ??= 0;
+ if (request.RegularCredits) {
+ component.RegularCredits += request.RegularCredits;
+ inventoryChanges.RegularCredits = -request.RegularCredits;
+ updateCurrency(inventory, request.RegularCredits, false);
+
+ guildMember.RegularCreditsContributed ??= 0;
+ guildMember.RegularCreditsContributed += request.RegularCredits;
+ }
+ if (request.VaultCredits) {
+ component.RegularCredits += request.VaultCredits;
+ guild.VaultRegularCredits! -= request.VaultCredits;
+ }
+ if (component.RegularCredits > scaleRequiredCount(guild.Tier, meta.price)) {
+ guild.VaultRegularCredits ??= 0;
+ guild.VaultRegularCredits += component.RegularCredits - scaleRequiredCount(guild.Tier, meta.price);
+ component.RegularCredits = scaleRequiredCount(guild.Tier, meta.price);
+ }
+
+ component.MiscItems ??= [];
+ if (request.VaultIngredientContributions.length) {
+ for (const ingredientContribution of request.VaultIngredientContributions) {
+ const componentMiscItem = component.MiscItems.find(x => x.ItemType == ingredientContribution.ItemType);
+ if (componentMiscItem) {
+ const ingredientMeta = meta.ingredients.find(x => x.ItemType == ingredientContribution.ItemType)!;
+ if (
+ componentMiscItem.ItemCount + ingredientContribution.ItemCount >
+ scaleRequiredCount(guild.Tier, ingredientMeta.ItemCount)
+ ) {
+ ingredientContribution.ItemCount =
+ scaleRequiredCount(guild.Tier, ingredientMeta.ItemCount) - componentMiscItem.ItemCount;
+ }
+ componentMiscItem.ItemCount += ingredientContribution.ItemCount;
+ } else {
+ component.MiscItems.push(ingredientContribution);
+ }
+ const vaultMiscItem = guild.VaultMiscItems!.find(x => x.ItemType == ingredientContribution.ItemType)!;
+ vaultMiscItem.ItemCount -= ingredientContribution.ItemCount;
+ }
+ }
+ if (request.IngredientContributions.length) {
+ const miscItemChanges: IMiscItem[] = [];
+ for (const ingredientContribution of request.IngredientContributions) {
+ const componentMiscItem = component.MiscItems.find(x => x.ItemType == ingredientContribution.ItemType);
+ if (componentMiscItem) {
+ const ingredientMeta = meta.ingredients.find(x => x.ItemType == ingredientContribution.ItemType)!;
+ if (
+ componentMiscItem.ItemCount + ingredientContribution.ItemCount >
+ scaleRequiredCount(guild.Tier, ingredientMeta.ItemCount)
+ ) {
+ ingredientContribution.ItemCount =
+ scaleRequiredCount(guild.Tier, ingredientMeta.ItemCount) - componentMiscItem.ItemCount;
+ }
+ componentMiscItem.ItemCount += ingredientContribution.ItemCount;
+ } else {
+ component.MiscItems.push(ingredientContribution);
+ }
+ miscItemChanges.push({
+ ItemType: ingredientContribution.ItemType,
+ ItemCount: ingredientContribution.ItemCount * -1
+ });
+
+ addGuildMemberMiscItemContribution(guildMember, ingredientContribution);
+ }
+ addMiscItems(inventory, miscItemChanges);
+ inventoryChanges.MiscItems = miscItemChanges;
+ }
+
+ if (component.RegularCredits >= scaleRequiredCount(guild.Tier, meta.price)) {
+ let fullyFunded = true;
+ for (const ingredient of meta.ingredients) {
+ const componentMiscItem = component.MiscItems.find(x => x.ItemType == ingredient.ItemType);
+ if (
+ !componentMiscItem ||
+ componentMiscItem.ItemCount < scaleRequiredCount(guild.Tier, ingredient.ItemCount)
+ ) {
+ fullyFunded = false;
+ break;
+ }
+ }
+ if (fullyFunded) {
+ component.CompletionTime = new Date(Date.now() + meta.time * 1000);
+ processDojoBuildMaterialsGathered(guild, meta);
+ }
+ }
+};
diff --git a/src/controllers/api/contributeToVaultController.ts b/src/controllers/api/contributeToVaultController.ts
new file mode 100644
index 00000000..d65930ce
--- /dev/null
+++ b/src/controllers/api/contributeToVaultController.ts
@@ -0,0 +1,109 @@
+import type { TGuildDatabaseDocument, TGuildMemberDatabaseDocument } from "../../models/guildModel.ts";
+import { Alliance, Guild, GuildMember } from "../../models/guildModel.ts";
+import {
+ addGuildMemberMiscItemContribution,
+ addGuildMemberShipDecoContribution,
+ addVaultFusionTreasures,
+ addVaultMiscItems,
+ addVaultShipDecos,
+ getGuildForRequestEx
+} from "../../services/guildService.ts";
+import {
+ addFusionTreasures,
+ addMiscItems,
+ addShipDecorations,
+ getInventory,
+ updateCurrency
+} from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { ITypeCount } from "../../types/commonTypes.ts";
+import type { IFusionTreasure, IMiscItem } from "../../types/inventoryTypes/inventoryTypes.ts";
+import type { RequestHandler } from "express";
+
+export const contributeToVaultController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId, "GuildId RegularCredits MiscItems ShipDecorations FusionTreasures");
+ const request = JSON.parse(String(req.body)) as IContributeToVaultRequest;
+
+ if (request.Alliance) {
+ const guild = await getGuildForRequestEx(req, inventory);
+ const alliance = (await Alliance.findById(guild.AllianceId!))!;
+ alliance.VaultRegularCredits ??= 0;
+ alliance.VaultRegularCredits += request.RegularCredits;
+ if (request.FromVault) {
+ guild.VaultRegularCredits! -= request.RegularCredits;
+ await Promise.all([guild.save(), alliance.save()]);
+ } else {
+ updateCurrency(inventory, request.RegularCredits, false);
+ await Promise.all([inventory.save(), alliance.save()]);
+ }
+ res.end();
+ return;
+ }
+
+ let guild: TGuildDatabaseDocument;
+ let guildMember: TGuildMemberDatabaseDocument | undefined;
+ if (request.GuildVault) {
+ guild = (await Guild.findById(request.GuildVault))!;
+ } else {
+ guild = await getGuildForRequestEx(req, inventory);
+ guildMember = (await GuildMember.findOne(
+ { accountId, guildId: guild._id },
+ "RegularCreditsContributed MiscItemsContributed ShipDecorationsContributed"
+ ))!;
+ }
+
+ if (request.RegularCredits) {
+ updateCurrency(inventory, request.RegularCredits, false);
+
+ guild.VaultRegularCredits ??= 0;
+ guild.VaultRegularCredits += request.RegularCredits;
+
+ if (guildMember) {
+ guildMember.RegularCreditsContributed ??= 0;
+ guildMember.RegularCreditsContributed += request.RegularCredits;
+ }
+ }
+ if (request.MiscItems.length) {
+ addVaultMiscItems(guild, request.MiscItems);
+ for (const item of request.MiscItems) {
+ if (guildMember) {
+ addGuildMemberMiscItemContribution(guildMember, item);
+ }
+ addMiscItems(inventory, [{ ...item, ItemCount: item.ItemCount * -1 }]);
+ }
+ }
+ if (request.ShipDecorations.length) {
+ addVaultShipDecos(guild, request.ShipDecorations);
+ for (const item of request.ShipDecorations) {
+ if (guildMember) {
+ addGuildMemberShipDecoContribution(guildMember, item);
+ }
+ addShipDecorations(inventory, [{ ...item, ItemCount: item.ItemCount * -1 }]);
+ }
+ }
+ if (request.FusionTreasures.length) {
+ addVaultFusionTreasures(guild, request.FusionTreasures);
+ for (const item of request.FusionTreasures) {
+ addFusionTreasures(inventory, [{ ...item, ItemCount: item.ItemCount * -1 }]);
+ }
+ }
+
+ const promises: Promise[] = [guild.save(), inventory.save()];
+ if (guildMember) {
+ promises.push(guildMember.save());
+ }
+ await Promise.all(promises);
+
+ res.end();
+};
+
+interface IContributeToVaultRequest {
+ RegularCredits: number;
+ MiscItems: IMiscItem[];
+ ShipDecorations: ITypeCount[];
+ FusionTreasures: IFusionTreasure[];
+ Alliance?: boolean;
+ FromVault?: boolean;
+ GuildVault?: string;
+}
diff --git a/src/controllers/api/createAllianceController.ts b/src/controllers/api/createAllianceController.ts
new file mode 100644
index 00000000..48fdd5cb
--- /dev/null
+++ b/src/controllers/api/createAllianceController.ts
@@ -0,0 +1,50 @@
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { Alliance, AllianceMember, Guild, GuildMember } from "../../models/guildModel.ts";
+import { getAllianceClient } from "../../services/guildService.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { GuildPermission } from "../../types/guildTypes.ts";
+import type { RequestHandler } from "express";
+
+export const createAllianceController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId, "GuildId");
+ const guild = (await Guild.findById(inventory.GuildId!, "Name Tier AllianceId"))!;
+ if (guild.AllianceId) {
+ res.status(400).send("Guild is already in an alliance").end();
+ return;
+ }
+ const guildMember = (await GuildMember.findOne({ guildId: guild._id, accountId }, "rank"))!;
+ if (guildMember.rank > 1) {
+ res.status(400).send("Invalid permission").end();
+ return;
+ }
+ const data = getJSONfromString(String(req.body));
+ const alliance = new Alliance({ Name: data.allianceName });
+ try {
+ await alliance.save();
+ } catch (e) {
+ res.status(400).send("Alliance name already in use").end();
+ return;
+ }
+ guild.AllianceId = alliance._id;
+ await Promise.all([
+ guild.save(),
+ AllianceMember.insertOne({
+ allianceId: alliance._id,
+ guildId: guild._id,
+ Pending: false,
+ Permissions:
+ GuildPermission.Ruler |
+ GuildPermission.Promoter |
+ GuildPermission.Recruiter |
+ GuildPermission.Treasurer |
+ GuildPermission.ChatModerator
+ })
+ ]);
+ res.json(await getAllianceClient(alliance, guild));
+};
+
+interface ICreateAllianceRequest {
+ allianceName: string;
+}
diff --git a/src/controllers/api/createGuildController.ts b/src/controllers/api/createGuildController.ts
new file mode 100644
index 00000000..239b3318
--- /dev/null
+++ b/src/controllers/api/createGuildController.ts
@@ -0,0 +1,44 @@
+import type { RequestHandler } from "express";
+import { getAccountForRequest } from "../../services/loginService.ts";
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { Guild, GuildMember } from "../../models/guildModel.ts";
+import { createUniqueClanName, getGuildClient, giveClanKey } from "../../services/guildService.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import type { IInventoryChanges } from "../../types/purchaseTypes.ts";
+
+export const createGuildController: RequestHandler = async (req, res) => {
+ const account = await getAccountForRequest(req);
+ const payload = getJSONfromString(String(req.body));
+
+ // Remove pending applications for this account
+ await GuildMember.deleteMany({ accountId: account._id, status: 1 });
+
+ // Create guild on database
+ const guild = new Guild({
+ Name: await createUniqueClanName(payload.guildName)
+ });
+ await guild.save();
+
+ // Create guild member on database
+ await GuildMember.insertOne({
+ accountId: account._id,
+ guildId: guild._id,
+ status: 0,
+ rank: 0
+ });
+
+ const inventory = await getInventory(account._id.toString(), "GuildId LevelKeys Recipes skipClanKeyCrafting");
+ inventory.GuildId = guild._id;
+ const inventoryChanges: IInventoryChanges = {};
+ giveClanKey(inventory, inventoryChanges);
+ await inventory.save();
+
+ res.json({
+ ...(await getGuildClient(guild, account)),
+ InventoryChanges: inventoryChanges
+ });
+};
+
+interface ICreateGuildRequest {
+ guildName: string;
+}
diff --git a/src/controllers/api/creditsController.ts b/src/controllers/api/creditsController.ts
new file mode 100644
index 00000000..8b6a5474
--- /dev/null
+++ b/src/controllers/api/creditsController.ts
@@ -0,0 +1,32 @@
+import type { RequestHandler } from "express";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+
+export const creditsController: RequestHandler = async (req, res) => {
+ const inventory = (
+ await Promise.all([
+ getAccountIdForRequest(req),
+ getInventory(
+ req.query.accountId as string,
+ "RegularCredits TradesRemaining PremiumCreditsFree PremiumCredits infiniteCredits infinitePlatinum"
+ )
+ ])
+ )[1];
+
+ const response = {
+ RegularCredits: inventory.RegularCredits,
+ TradesRemaining: inventory.TradesRemaining,
+ PremiumCreditsFree: inventory.PremiumCreditsFree,
+ PremiumCredits: inventory.PremiumCredits
+ };
+
+ if (inventory.infiniteCredits) {
+ response.RegularCredits = 999999999;
+ }
+ if (inventory.infinitePlatinum) {
+ response.PremiumCreditsFree = 0;
+ response.PremiumCredits = 999999999;
+ }
+
+ res.json(response);
+};
diff --git a/src/controllers/api/crewMembersController.ts b/src/controllers/api/crewMembersController.ts
new file mode 100644
index 00000000..6f95836d
--- /dev/null
+++ b/src/controllers/api/crewMembersController.ts
@@ -0,0 +1,54 @@
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import type { TInventoryDatabaseDocument } from "../../models/inventoryModels/inventoryModel.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { ICrewMemberClient } from "../../types/inventoryTypes/inventoryTypes.ts";
+import type { RequestHandler } from "express";
+import { Types } from "mongoose";
+
+export const crewMembersController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId, "CrewMembers NemesisHistory");
+ const data = getJSONfromString(String(req.body));
+ if (data.crewMember.SecondInCommand) {
+ clearOnCall(inventory);
+ }
+ if (data.crewMember.ItemId.$oid == "000000000000000000000000") {
+ const convertedNemesis = inventory.NemesisHistory!.find(x => x.fp == data.crewMember.NemesisFingerprint)!;
+ convertedNemesis.SecondInCommand = data.crewMember.SecondInCommand;
+ } else {
+ const dbCrewMember = inventory.CrewMembers.id(data.crewMember.ItemId.$oid)!;
+ dbCrewMember.AssignedRole = data.crewMember.AssignedRole;
+ dbCrewMember.SkillEfficiency = data.crewMember.SkillEfficiency;
+ dbCrewMember.WeaponConfigIdx = data.crewMember.WeaponConfigIdx;
+ dbCrewMember.WeaponId = new Types.ObjectId(data.crewMember.WeaponId.$oid);
+ dbCrewMember.Configs = data.crewMember.Configs;
+ dbCrewMember.SecondInCommand = data.crewMember.SecondInCommand;
+ }
+ await inventory.save();
+ res.json({
+ crewMemberId: data.crewMember.ItemId.$oid,
+ NemesisFingerprint: data.crewMember.NemesisFingerprint
+ });
+};
+
+interface ICrewMembersRequest {
+ crewMember: ICrewMemberClient;
+}
+
+const clearOnCall = (inventory: TInventoryDatabaseDocument): void => {
+ for (const cm of inventory.CrewMembers) {
+ if (cm.SecondInCommand) {
+ cm.SecondInCommand = false;
+ return;
+ }
+ }
+ if (inventory.NemesisHistory) {
+ for (const cm of inventory.NemesisHistory) {
+ if (cm.SecondInCommand) {
+ cm.SecondInCommand = false;
+ return;
+ }
+ }
+ }
+};
diff --git a/src/controllers/api/crewShipFusionController.ts b/src/controllers/api/crewShipFusionController.ts
new file mode 100644
index 00000000..a7b697c3
--- /dev/null
+++ b/src/controllers/api/crewShipFusionController.ts
@@ -0,0 +1,107 @@
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { addMiscItems, freeUpSlot, getInventory, updateCurrency } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { IOid } from "../../types/commonTypes.ts";
+import type { ICrewShipComponentFingerprint } from "../../types/inventoryTypes/inventoryTypes.ts";
+import { InventorySlot } from "../../types/inventoryTypes/inventoryTypes.ts";
+import type { IInventoryChanges } from "../../types/purchaseTypes.ts";
+import type { RequestHandler } from "express";
+import { ExportCustoms, ExportDojoRecipes } from "warframe-public-export-plus";
+
+export const crewShipFusionController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId);
+ const payload = getJSONfromString(String(req.body));
+
+ const isWeapon = inventory.CrewShipWeapons.id(payload.PartA.$oid);
+ const itemA = isWeapon ?? inventory.CrewShipWeaponSkins.id(payload.PartA.$oid)!;
+ const category = isWeapon ? "CrewShipWeapons" : "CrewShipWeaponSkins";
+ const salvageCategory = isWeapon ? "CrewShipSalvagedWeapons" : "CrewShipSalvagedWeaponSkins";
+ const itemB = inventory[payload.SourceRecipe ? salvageCategory : category].id(payload.PartB.$oid)!;
+ const tierA = itemA.ItemType.charCodeAt(itemA.ItemType.length - 1) - 65;
+ const tierB = itemB.ItemType.charCodeAt(itemB.ItemType.length - 1) - 65;
+
+ const inventoryChanges: IInventoryChanges = {};
+
+ // Charge partial repair cost if fusing with an identified but unrepaired part
+ if (payload.SourceRecipe) {
+ const recipe = ExportDojoRecipes.research[payload.SourceRecipe];
+ updateCurrency(inventory, Math.round(recipe.price * 0.4), false, inventoryChanges);
+ const miscItemChanges = recipe.ingredients.map(x => ({ ...x, ItemCount: Math.round(x.ItemCount * -0.4) }));
+ addMiscItems(inventory, miscItemChanges);
+ inventoryChanges.MiscItems = miscItemChanges;
+ }
+
+ // Remove inferior item
+ if (payload.SourceRecipe) {
+ inventory[salvageCategory].pull({ _id: payload.PartB.$oid });
+ inventoryChanges.RemovedIdItems = [{ ItemId: payload.PartB }];
+ } else {
+ const inferiorId = tierA < tierB ? payload.PartA : payload.PartB;
+ inventory[category].pull({ _id: inferiorId.$oid });
+ inventoryChanges.RemovedIdItems = [{ ItemId: inferiorId }];
+ freeUpSlot(inventory, InventorySlot.RJ_COMPONENT_AND_ARMAMENTS);
+ inventoryChanges[InventorySlot.RJ_COMPONENT_AND_ARMAMENTS] = { count: -1, platinum: 0, Slots: 1 };
+ }
+
+ // Upgrade superior item
+ const superiorItem = tierA < tierB ? itemB : itemA;
+ const inferiorItem = tierA < tierB ? itemA : itemB;
+ const fingerprint: ICrewShipComponentFingerprint = JSON.parse(
+ superiorItem.UpgradeFingerprint!
+ ) as ICrewShipComponentFingerprint;
+ const inferiorFingerprint: ICrewShipComponentFingerprint = inferiorItem.UpgradeFingerprint
+ ? (JSON.parse(inferiorItem.UpgradeFingerprint) as ICrewShipComponentFingerprint)
+ : { compat: "", buffs: [] };
+ if (isWeapon) {
+ for (let i = 0; i != fingerprint.buffs.length; ++i) {
+ const buffA = fingerprint.buffs[i];
+ const buffB = i < inferiorFingerprint.buffs.length ? inferiorFingerprint.buffs[i] : undefined;
+ const fvalA = buffA.Value / 0x3fffffff;
+ const fvalB = (buffB?.Value ?? 0) / 0x3fffffff;
+ const percA = 0.3 + fvalA * (0.6 - 0.3);
+ const percB = 0.3 + fvalB * (0.6 - 0.3);
+ const newPerc = Math.min(0.6, Math.max(percA, percB) * FUSE_MULTIPLIERS[Math.abs(tierA - tierB)]);
+ const newFval = (newPerc - 0.3) / (0.6 - 0.3);
+ buffA.Value = Math.trunc(newFval * 0x3fffffff);
+ }
+ } else {
+ const superiorMeta = ExportCustoms[superiorItem.ItemType].randomisedUpgrades ?? [];
+ const inferiorMeta = ExportCustoms[inferiorItem.ItemType].randomisedUpgrades ?? [];
+ for (let i = 0; i != inferiorFingerprint.buffs.length; ++i) {
+ const buffA = fingerprint.buffs[i];
+ const buffB = inferiorFingerprint.buffs[i];
+ const fvalA = buffA.Value / 0x3fffffff;
+ const fvalB = buffB.Value / 0x3fffffff;
+ const rangeA = superiorMeta[i].range;
+ const rangeB = inferiorMeta[i].range;
+ const percA = rangeA[0] + fvalA * (rangeA[1] - rangeA[0]);
+ const percB = rangeB[0] + fvalB * (rangeB[1] - rangeB[0]);
+ const newPerc = Math.min(rangeA[1], Math.max(percA, percB) * FUSE_MULTIPLIERS[Math.abs(tierA - tierB)]);
+ const newFval = (newPerc - rangeA[0]) / (rangeA[1] - rangeA[0]);
+ buffA.Value = Math.trunc(newFval * 0x3fffffff);
+ }
+ if (inferiorFingerprint.SubroutineIndex !== undefined) {
+ const useSuperiorSubroutine = tierA < tierB ? !payload.UseSubroutineA : payload.UseSubroutineA;
+ if (!useSuperiorSubroutine) {
+ fingerprint.SubroutineIndex = inferiorFingerprint.SubroutineIndex;
+ }
+ }
+ }
+ superiorItem.UpgradeFingerprint = JSON.stringify(fingerprint);
+ inventoryChanges[category] = [superiorItem.toJSON() as any];
+
+ await inventory.save();
+ res.json({
+ InventoryChanges: inventoryChanges
+ });
+};
+
+interface ICrewShipFusionRequest {
+ PartA: IOid;
+ PartB: IOid;
+ SourceRecipe: string;
+ UseSubroutineA: boolean;
+}
+
+const FUSE_MULTIPLIERS = [1.1, 1.05, 1.02];
diff --git a/src/controllers/api/crewShipIdentifySalvageController.ts b/src/controllers/api/crewShipIdentifySalvageController.ts
new file mode 100644
index 00000000..317da572
--- /dev/null
+++ b/src/controllers/api/crewShipIdentifySalvageController.ts
@@ -0,0 +1,87 @@
+import {
+ addCrewShipSalvagedWeaponSkin,
+ addCrewShipRawSalvage,
+ getInventory,
+ addEquipment
+} from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { RequestHandler } from "express";
+import type {
+ ICrewShipComponentFingerprint,
+ IInnateDamageFingerprint
+} from "../../types/inventoryTypes/inventoryTypes.ts";
+import { ExportCustoms, ExportRailjackWeapons, ExportUpgrades } from "warframe-public-export-plus";
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import type { IInventoryChanges } from "../../types/purchaseTypes.ts";
+import { getRandomInt } from "../../services/rngService.ts";
+import type { IFingerprintStat } from "../../helpers/rivenHelper.ts";
+import type { IEquipmentDatabase } from "../../types/equipmentTypes.ts";
+
+export const crewShipIdentifySalvageController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(
+ accountId,
+ "CrewShipSalvagedWeaponSkins CrewShipSalvagedWeapons CrewShipRawSalvage"
+ );
+ const payload = getJSONfromString(String(req.body));
+
+ const inventoryChanges: IInventoryChanges = {};
+ if (payload.ItemType in ExportCustoms) {
+ const meta = ExportCustoms[payload.ItemType];
+ let upgradeFingerprint: ICrewShipComponentFingerprint = { compat: payload.ItemType, buffs: [] };
+ if (meta.subroutines) {
+ upgradeFingerprint = {
+ SubroutineIndex: getRandomInt(0, meta.subroutines.length - 1),
+ ...upgradeFingerprint
+ };
+ }
+ for (const upgrade of meta.randomisedUpgrades!) {
+ upgradeFingerprint.buffs.push({ Tag: upgrade.tag, Value: Math.trunc(Math.random() * 0x40000000) });
+ }
+ addCrewShipSalvagedWeaponSkin(
+ inventory,
+ payload.ItemType,
+ JSON.stringify(upgradeFingerprint),
+ inventoryChanges
+ );
+ } else {
+ const meta = ExportRailjackWeapons[payload.ItemType];
+ let defaultOverwrites: Partial | undefined;
+ if (meta.defaultUpgrades?.[0]) {
+ const upgradeType = meta.defaultUpgrades[0].ItemType;
+ const upgradeMeta = ExportUpgrades[upgradeType];
+ const buffs: IFingerprintStat[] = [];
+ for (const buff of upgradeMeta.upgradeEntries!) {
+ buffs.push({
+ Tag: buff.tag,
+ Value: Math.trunc(Math.random() * 0x40000000)
+ });
+ }
+ defaultOverwrites = {
+ UpgradeType: upgradeType,
+ UpgradeFingerprint: JSON.stringify({
+ compat: payload.ItemType,
+ buffs
+ } satisfies IInnateDamageFingerprint)
+ };
+ }
+ addEquipment(inventory, "CrewShipSalvagedWeapons", payload.ItemType, defaultOverwrites, inventoryChanges);
+ }
+
+ inventoryChanges.CrewShipRawSalvage = [
+ {
+ ItemType: payload.ItemType,
+ ItemCount: -1
+ }
+ ];
+ addCrewShipRawSalvage(inventory, inventoryChanges.CrewShipRawSalvage);
+
+ await inventory.save();
+ res.json({
+ InventoryChanges: inventoryChanges
+ });
+};
+
+interface ICrewShipIdentifySalvageRequest {
+ ItemType: string;
+}
diff --git a/src/controllers/api/customObstacleCourseLeaderboardController.ts b/src/controllers/api/customObstacleCourseLeaderboardController.ts
new file mode 100644
index 00000000..616407c3
--- /dev/null
+++ b/src/controllers/api/customObstacleCourseLeaderboardController.ts
@@ -0,0 +1,64 @@
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { Guild } from "../../models/guildModel.ts";
+import { hasAccessToDojo, hasGuildPermission } from "../../services/guildService.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountForRequest, getAccountIdForRequest } from "../../services/loginService.ts";
+import { GuildPermission } from "../../types/guildTypes.ts";
+import { logger } from "../../utils/logger.ts";
+import type { RequestHandler } from "express";
+
+export const customObstacleCourseLeaderboardController: RequestHandler = async (req, res) => {
+ const data = getJSONfromString(String(req.body));
+ const guild = (await Guild.findById(data.g, "DojoComponents Ranks"))!;
+ const component = guild.DojoComponents.id(data.c)!;
+ if (req.query.act == "f") {
+ res.json({
+ results: component.Leaderboard ?? []
+ });
+ } else if (req.query.act == "p") {
+ const account = await getAccountForRequest(req);
+ component.Leaderboard ??= [];
+ const entry = component.Leaderboard.find(x => x.n == account.DisplayName);
+ if (entry) {
+ entry.s = data.s!;
+ } else {
+ component.Leaderboard.push({
+ s: data.s!,
+ n: account.DisplayName,
+ r: 0
+ });
+ }
+ component.Leaderboard.sort((a, b) => a.s - b.s); // In this case, the score is the time in milliseconds, so smaller is better.
+ if (component.Leaderboard.length > 10) {
+ component.Leaderboard.shift();
+ }
+ let r = 0;
+ for (const entry of component.Leaderboard) {
+ entry.r = ++r;
+ }
+ await guild.save();
+ res.status(200).end();
+ } else if (req.query.act == "c") {
+ // TOVERIFY: What clan permission is actually needed for this?
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId, "GuildId LevelKeys");
+ if (!hasAccessToDojo(inventory) || !(await hasGuildPermission(guild, accountId, GuildPermission.Decorator))) {
+ res.status(400).end();
+ return;
+ }
+
+ component.Leaderboard = undefined;
+ await guild.save();
+
+ res.status(200).end();
+ } else {
+ logger.debug(`data provided to ${req.path}: ${String(req.body)}`);
+ throw new Error(`unknown customObstacleCourseLeaderboard act: ${String(req.query.act)}`);
+ }
+};
+
+interface ICustomObstacleCourseLeaderboardRequest {
+ g: string;
+ c: string;
+ s?: number; // act=p
+}
diff --git a/src/controllers/api/customizeGuildRanksController.ts b/src/controllers/api/customizeGuildRanksController.ts
new file mode 100644
index 00000000..afc7225c
--- /dev/null
+++ b/src/controllers/api/customizeGuildRanksController.ts
@@ -0,0 +1,22 @@
+import { getGuildForRequest, hasGuildPermission } from "../../services/guildService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { IGuildRank } from "../../types/guildTypes.ts";
+import { GuildPermission } from "../../types/guildTypes.ts";
+import type { RequestHandler } from "express";
+
+export const customizeGuildRanksController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const guild = await getGuildForRequest(req);
+ const payload = JSON.parse(String(req.body)) as ICustomizeGuildRanksRequest;
+ if (!(await hasGuildPermission(guild, accountId, GuildPermission.Ruler))) {
+ res.status(400).json("Invalid permission");
+ return;
+ }
+ guild.Ranks = payload.GuildRanks;
+ await guild.save();
+ res.end();
+};
+
+interface ICustomizeGuildRanksRequest {
+ GuildRanks: IGuildRank[];
+}
diff --git a/src/controllers/api/declineAllianceInviteController.ts b/src/controllers/api/declineAllianceInviteController.ts
new file mode 100644
index 00000000..4c8953ed
--- /dev/null
+++ b/src/controllers/api/declineAllianceInviteController.ts
@@ -0,0 +1,17 @@
+import { AllianceMember, GuildMember } from "../../models/guildModel.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { RequestHandler } from "express";
+
+export const declineAllianceInviteController: RequestHandler = async (req, res) => {
+ // Check requester is a warlord in their guild
+ const accountId = await getAccountIdForRequest(req);
+ const guildMember = (await GuildMember.findOne({ accountId, status: 0 }))!;
+ if (guildMember.rank > 1) {
+ res.status(400).json({ Error: 104 });
+ return;
+ }
+
+ await AllianceMember.deleteOne({ allianceId: req.query.allianceId, guildId: guildMember.guildId });
+
+ res.end();
+};
diff --git a/src/controllers/api/declineGuildInviteController.ts b/src/controllers/api/declineGuildInviteController.ts
new file mode 100644
index 00000000..4fca79bd
--- /dev/null
+++ b/src/controllers/api/declineGuildInviteController.ts
@@ -0,0 +1,14 @@
+import { GuildMember } from "../../models/guildModel.ts";
+import { getAccountForRequest } from "../../services/loginService.ts";
+import type { RequestHandler } from "express";
+
+export const declineGuildInviteController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountForRequest(req);
+
+ await GuildMember.deleteOne({
+ accountId: accountId,
+ guildId: req.query.clanId as string
+ });
+
+ res.end();
+};
diff --git a/src/controllers/api/deleteSessionController.ts b/src/controllers/api/deleteSessionController.ts
new file mode 100644
index 00000000..52967522
--- /dev/null
+++ b/src/controllers/api/deleteSessionController.ts
@@ -0,0 +1,9 @@
+import type { RequestHandler } from "express";
+import { deleteSession } from "../../managers/sessionManager.ts";
+
+const deleteSessionController: RequestHandler = (_req, res) => {
+ deleteSession(_req.query.sessionId as string);
+ res.sendStatus(200);
+};
+
+export { deleteSessionController };
diff --git a/src/controllers/api/destroyDojoDecoController.ts b/src/controllers/api/destroyDojoDecoController.ts
new file mode 100644
index 00000000..c47dfde3
--- /dev/null
+++ b/src/controllers/api/destroyDojoDecoController.ts
@@ -0,0 +1,51 @@
+import {
+ getDojoClient,
+ getGuildForRequestEx,
+ hasAccessToDojo,
+ hasGuildPermission,
+ refundDojoDeco,
+ removeDojoDeco
+} from "../../services/guildService.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { GuildPermission } from "../../types/guildTypes.ts";
+import { logger } from "../../utils/logger.ts";
+import type { RequestHandler } from "express";
+
+export const destroyDojoDecoController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId, "GuildId LevelKeys");
+ const guild = await getGuildForRequestEx(req, inventory);
+ if (!hasAccessToDojo(inventory) || !(await hasGuildPermission(guild, accountId, GuildPermission.Decorator))) {
+ res.json({ DojoRequestStatus: -1 });
+ return;
+ }
+ const request = JSON.parse(String(req.body)) as IDestroyDojoDecoRequest | IClearObstacleCourseRequest;
+ if ("DecoType" in request) {
+ removeDojoDeco(guild, request.ComponentId, request.DecoId);
+ } else if (request.Act == "cObst") {
+ const component = guild.DojoComponents.id(request.ComponentId)!;
+ if (component.Decos) {
+ for (const deco of component.Decos) {
+ refundDojoDeco(guild, component, deco);
+ }
+ component.Decos.splice(0, component.Decos.length);
+ }
+ } else {
+ logger.error(`unhandled destroyDojoDeco request`, request);
+ }
+
+ await guild.save();
+ res.json(await getDojoClient(guild, 0, request.ComponentId));
+};
+
+interface IDestroyDojoDecoRequest {
+ DecoType: string;
+ ComponentId: string;
+ DecoId: string;
+}
+
+interface IClearObstacleCourseRequest {
+ ComponentId: string;
+ Act: "cObst" | "maybesomethingelsewedontknowabout";
+}
diff --git a/src/controllers/api/divvyAllianceVaultController.ts b/src/controllers/api/divvyAllianceVaultController.ts
new file mode 100644
index 00000000..0ebdcf67
--- /dev/null
+++ b/src/controllers/api/divvyAllianceVaultController.ts
@@ -0,0 +1,67 @@
+import { Alliance, AllianceMember, Guild, GuildMember } from "../../models/guildModel.ts";
+import { getAccountForRequest } from "../../services/loginService.ts";
+import { GuildPermission } from "../../types/guildTypes.ts";
+import { parallelForeach } from "../../utils/async-utils.ts";
+import { logger } from "../../utils/logger.ts";
+import type { RequestHandler } from "express";
+
+export const divvyAllianceVaultController: RequestHandler = async (req, res) => {
+ // Afaict, there's no way to put anything other than credits in the alliance vault (anymore?), so just no-op if this is not a request to divvy credits.
+ if (req.query.credits == "1") {
+ // Check requester is a warlord in their guild
+ const account = await getAccountForRequest(req);
+ const guildMember = (await GuildMember.findOne({ accountId: account._id, status: 0 }))!;
+ if (guildMember.rank > 1) {
+ res.status(400).end();
+ return;
+ }
+
+ // Check guild has treasurer permissions in the alliance
+ const allianceMember = (await AllianceMember.findOne({
+ allianceId: req.query.allianceId,
+ guildId: guildMember.guildId
+ }))!;
+ if (!(allianceMember.Permissions & GuildPermission.Treasurer)) {
+ res.status(400).end();
+ return;
+ }
+
+ const allianceMembers = await AllianceMember.find({ allianceId: req.query.allianceId });
+ const memberCounts: Record = {};
+ let totalMembers = 0;
+ await parallelForeach(allianceMembers, async allianceMember => {
+ const memberCount = await GuildMember.countDocuments({
+ guildId: allianceMember.guildId
+ });
+ memberCounts[allianceMember.guildId.toString()] = memberCount;
+ totalMembers += memberCount;
+ });
+ logger.debug(`alliance has ${totalMembers} members between all its clans`);
+
+ const alliance = (await Alliance.findById(allianceMember.allianceId, "VaultRegularCredits"))!;
+ if (alliance.VaultRegularCredits) {
+ let creditsHandedOutInTotal = 0;
+ await parallelForeach(allianceMembers, async allianceMember => {
+ const memberCount = memberCounts[allianceMember.guildId.toString()];
+ const cutPercentage = memberCount / totalMembers;
+ const creditsToHandOut = Math.trunc(alliance.VaultRegularCredits! * cutPercentage);
+ logger.debug(
+ `${allianceMember.guildId.toString()} has ${memberCount} member(s) = ${Math.trunc(cutPercentage * 100)}% of alliance; giving ${creditsToHandOut} credit(s)`
+ );
+ if (creditsToHandOut != 0) {
+ await Guild.updateOne(
+ { _id: allianceMember.guildId },
+ { $inc: { VaultRegularCredits: creditsToHandOut } }
+ );
+ creditsHandedOutInTotal += creditsToHandOut;
+ }
+ });
+ alliance.VaultRegularCredits -= creditsHandedOutInTotal;
+ logger.debug(
+ `handed out ${creditsHandedOutInTotal} credits; alliance vault now has ${alliance.VaultRegularCredits} credit(s)`
+ );
+ }
+ await alliance.save();
+ }
+ res.end();
+};
diff --git a/src/controllers/api/dojoComponentRushController.ts b/src/controllers/api/dojoComponentRushController.ts
new file mode 100644
index 00000000..4bee8424
--- /dev/null
+++ b/src/controllers/api/dojoComponentRushController.ts
@@ -0,0 +1,83 @@
+import type { TGuildDatabaseDocument } from "../../models/guildModel.ts";
+import { GuildMember } from "../../models/guildModel.ts";
+import {
+ getDojoClient,
+ getGuildForRequestEx,
+ hasAccessToDojo,
+ scaleRequiredCount
+} from "../../services/guildService.ts";
+import { getInventory, updateCurrency } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { IDojoContributable } from "../../types/guildTypes.ts";
+import type { RequestHandler } from "express";
+import type { IDojoBuild } from "warframe-public-export-plus";
+import { ExportDojoRecipes } from "warframe-public-export-plus";
+
+interface IDojoComponentRushRequest {
+ DecoType?: string;
+ DecoId?: string;
+ ComponentId: string;
+ Amount: number;
+ VaultAmount: number;
+ AllianceVaultAmount: number;
+}
+
+export const dojoComponentRushController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId);
+ if (!hasAccessToDojo(inventory)) {
+ res.json({ DojoRequestStatus: -1 });
+ return;
+ }
+ const guild = await getGuildForRequestEx(req, inventory);
+ const request = JSON.parse(String(req.body)) as IDojoComponentRushRequest;
+ const component = guild.DojoComponents.id(request.ComponentId)!;
+
+ let platinumDonated = request.Amount;
+ const inventoryChanges = updateCurrency(inventory, request.Amount, true);
+ if (request.VaultAmount) {
+ platinumDonated += request.VaultAmount;
+ guild.VaultPremiumCredits! -= request.VaultAmount;
+ }
+
+ if (request.DecoId) {
+ const deco = component.Decos!.find(x => x._id.equals(request.DecoId))!;
+ const meta = Object.values(ExportDojoRecipes.decos).find(x => x.resultType == deco.Type)!;
+ processContribution(guild, deco, meta, platinumDonated);
+ } else {
+ const meta = Object.values(ExportDojoRecipes.rooms).find(x => x.resultType == component.pf)!;
+ processContribution(guild, component, meta, platinumDonated);
+
+ const entry = guild.RoomChanges?.find(x => x.componentId.equals(component._id));
+ if (entry) {
+ entry.dateTime = component.CompletionTime!;
+ }
+ }
+
+ const guildMember = (await GuildMember.findOne({ accountId, guildId: guild._id }, "PremiumCreditsContributed"))!;
+ guildMember.PremiumCreditsContributed ??= 0;
+ guildMember.PremiumCreditsContributed += request.Amount;
+
+ await Promise.all([guild.save(), inventory.save(), guildMember.save()]);
+
+ res.json({
+ ...(await getDojoClient(guild, 0, component._id)),
+ InventoryChanges: inventoryChanges
+ });
+};
+
+const processContribution = (
+ guild: TGuildDatabaseDocument,
+ component: IDojoContributable,
+ meta: IDojoBuild,
+ platinumDonated: number
+): void => {
+ const fullPlatinumCost = scaleRequiredCount(guild.Tier, meta.skipTimePrice);
+ const fullDurationSeconds = meta.time;
+ const secondsPerPlatinum = fullDurationSeconds / fullPlatinumCost;
+ component.CompletionTime = new Date(
+ component.CompletionTime!.getTime() - secondsPerPlatinum * platinumDonated * 1000
+ );
+ component.RushPlatinum ??= 0;
+ component.RushPlatinum += platinumDonated;
+};
diff --git a/src/controllers/api/dojoController.ts b/src/controllers/api/dojoController.ts
new file mode 100644
index 00000000..913a2176
--- /dev/null
+++ b/src/controllers/api/dojoController.ts
@@ -0,0 +1,11 @@
+import type { RequestHandler } from "express";
+
+// Arbiter Dojo endpoints, not really used by us as we don't provide a ContentURL.
+
+export const dojoController: RequestHandler = (_req, res) => {
+ res.json("-1"); // Tell client to use authorised request.
+};
+
+export const setDojoURLController: RequestHandler = (_req, res) => {
+ res.end();
+};
diff --git a/src/controllers/api/dronesController.ts b/src/controllers/api/dronesController.ts
new file mode 100644
index 00000000..07cbaedd
--- /dev/null
+++ b/src/controllers/api/dronesController.ts
@@ -0,0 +1,145 @@
+import { toMongoDate, toOid } from "../../helpers/inventoryHelpers.ts";
+import { addMiscItems, getInventory } from "../../services/inventoryService.ts";
+import { fromStoreItem } from "../../services/itemDataService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { getRandomInt, getRandomWeightedRewardUc } from "../../services/rngService.ts";
+import type { IMongoDate, IOid } from "../../types/commonTypes.ts";
+import type { IDroneClient } from "../../types/inventoryTypes/inventoryTypes.ts";
+import type { IInventoryChanges } from "../../types/purchaseTypes.ts";
+import type { RequestHandler } from "express";
+import { ExportDrones, ExportResources, ExportSystems } from "warframe-public-export-plus";
+
+export const dronesController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ if ("GetActive" in req.query) {
+ const inventory = await getInventory(accountId, "Drones");
+ const activeDrones: IActiveDrone[] = [];
+ for (const drone of inventory.Drones) {
+ if (drone.DeployTime) {
+ activeDrones.push({
+ DeployTime: toMongoDate(drone.DeployTime),
+ System: drone.System!,
+ ItemId: toOid(drone._id),
+ ItemType: drone.ItemType,
+ CurrentHP: drone.CurrentHP,
+ DamageTime: toMongoDate(drone.DamageTime!),
+ PendingDamage: drone.PendingDamage!,
+ Resources: [
+ {
+ ItemType: drone.ResourceType!,
+ BinTotal: drone.ResourceCount!,
+ StartTime: toMongoDate(drone.DeployTime)
+ }
+ ]
+ });
+ }
+ }
+ res.json({
+ ActiveDrones: activeDrones
+ });
+ } else if ("droneId" in req.query && "systemIndex" in req.query) {
+ const inventory = await getInventory(
+ accountId,
+ "Drones instantResourceExtractorDrones noResourceExtractorDronesDamage"
+ );
+ const drone = inventory.Drones.id(req.query.droneId as string)!;
+ const droneMeta = ExportDrones[drone.ItemType];
+ drone.DeployTime = inventory.instantResourceExtractorDrones ? new Date(0) : new Date();
+ if (drone.RepairStart) {
+ const repairMinutes = (Date.now() - drone.RepairStart.getTime()) / 60_000;
+ const hpPerMinute = droneMeta.repairRate / 60;
+ drone.CurrentHP = Math.min(drone.CurrentHP + Math.round(repairMinutes * hpPerMinute), droneMeta.durability);
+ drone.RepairStart = undefined;
+ }
+ drone.System = parseInt(req.query.systemIndex as string);
+ const system = ExportSystems[drone.System - 1];
+ drone.DamageTime = inventory.instantResourceExtractorDrones
+ ? new Date()
+ : new Date(Date.now() + getRandomInt(3 * 3600 * 1000, 4 * 3600 * 1000));
+ drone.PendingDamage =
+ !inventory.noResourceExtractorDronesDamage && Math.random() < system.damageChance
+ ? getRandomInt(system.droneDamage.minValue, system.droneDamage.maxValue)
+ : 0;
+ const resource = getRandomWeightedRewardUc(system.resources, droneMeta.probabilities)!;
+ //logger.debug(`drone rolled`, resource);
+ drone.ResourceType = fromStoreItem(resource.StoreItem);
+ const resourceMeta = ExportResources[drone.ResourceType];
+ if (resourceMeta.pickupQuantity) {
+ const pickupsToCollect = droneMeta.binCapacity * droneMeta.capacityMultipliers[resource.Rarity];
+ drone.ResourceCount = 0;
+ for (let i = 0; i != pickupsToCollect; ++i) {
+ drone.ResourceCount += getRandomInt(
+ resourceMeta.pickupQuantity.minValue,
+ resourceMeta.pickupQuantity.maxValue
+ );
+ }
+ } else {
+ drone.ResourceCount = droneMeta.binCapacity * droneMeta.capacityMultipliers[resource.Rarity];
+ }
+ await inventory.save();
+ res.json({});
+ } else if ("collectDroneId" in req.query) {
+ const inventory = await getInventory(accountId);
+ const drone = inventory.Drones.id(req.query.collectDroneId as string)!;
+
+ if (new Date() >= drone.DamageTime!) {
+ drone.CurrentHP -= drone.PendingDamage!;
+ drone.RepairStart = new Date();
+ }
+
+ const inventoryChanges: IInventoryChanges = {};
+ if (drone.CurrentHP <= 0) {
+ inventory.RegularCredits += 100;
+ inventoryChanges.RegularCredits = 100;
+ inventory.Drones.pull({ _id: req.query.collectDroneId as string });
+ inventoryChanges.RemovedIdItems = [
+ {
+ ItemId: { $oid: req.query.collectDroneId }
+ }
+ ];
+ } else {
+ const completionTime = drone.DeployTime!.getTime() + ExportDrones[drone.ItemType].fillRate * 3600_000;
+ if (Date.now() >= completionTime) {
+ const miscItemChanges = [
+ {
+ ItemType: drone.ResourceType!,
+ ItemCount: drone.ResourceCount!
+ }
+ ];
+ addMiscItems(inventory, miscItemChanges);
+ inventoryChanges.MiscItems = miscItemChanges;
+ }
+
+ drone.DeployTime = undefined;
+ drone.System = undefined;
+ drone.DamageTime = undefined;
+ drone.PendingDamage = undefined;
+ drone.ResourceType = undefined;
+ drone.ResourceCount = undefined;
+
+ inventoryChanges.Drones = [drone.toJSON()];
+ }
+
+ await inventory.save();
+ res.json({
+ InventoryChanges: inventoryChanges
+ });
+ } else {
+ throw new Error(`drones.php query not handled`);
+ }
+};
+
+interface IActiveDrone {
+ DeployTime: IMongoDate;
+ System: number;
+ ItemId: IOid;
+ ItemType: string;
+ CurrentHP: number;
+ DamageTime: IMongoDate;
+ PendingDamage: number;
+ Resources: {
+ ItemType: string;
+ BinTotal: number;
+ StartTime: IMongoDate;
+ }[];
+}
diff --git a/src/controllers/api/endlessXpController.ts b/src/controllers/api/endlessXpController.ts
new file mode 100644
index 00000000..1bcdc06d
--- /dev/null
+++ b/src/controllers/api/endlessXpController.ts
@@ -0,0 +1,534 @@
+import type { RequestHandler } from "express";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { combineInventoryChanges, getInventory } from "../../services/inventoryService.ts";
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import type {
+ IEndlessXpReward,
+ IInventoryClient,
+ TEndlessXpCategory
+} from "../../types/inventoryTypes/inventoryTypes.ts";
+import { logger } from "../../utils/logger.ts";
+import type { ICountedStoreItem } from "warframe-public-export-plus";
+import { ExportRewards } from "warframe-public-export-plus";
+import { getRandomElement } from "../../services/rngService.ts";
+import { handleStoreItemAcquisition } from "../../services/purchaseService.ts";
+import type { IInventoryChanges } from "../../types/purchaseTypes.ts";
+
+export const endlessXpController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const payload = getJSONfromString(String(req.body));
+ if (payload.Mode == "r") {
+ const inventory = await getInventory(accountId, "EndlessXP");
+ inventory.EndlessXP ??= [];
+ let entry = inventory.EndlessXP.find(x => x.Category == payload.Category);
+ if (!entry) {
+ entry = {
+ Category: payload.Category,
+ Earn: 0,
+ Claim: 0,
+ Choices: payload.Choices,
+ PendingRewards: []
+ };
+ inventory.EndlessXP.push(entry);
+ }
+
+ const weekStart = 1734307200_000 + Math.trunc((Date.now() - 1734307200_000) / 604800000) * 604800000;
+ const weekEnd = weekStart + 604800000;
+
+ entry.Earn = 0;
+ entry.Claim = 0;
+ entry.BonusAvailable = new Date(weekStart);
+ entry.Expiry = new Date(weekEnd);
+ entry.Choices = payload.Choices;
+ entry.PendingRewards =
+ payload.Category == "EXC_HARD"
+ ? generateHardModeRewards(payload.Choices)
+ : generateNormalModeRewards(payload.Choices);
+
+ await inventory.save();
+ res.json({
+ NewProgress: inventory.toJSON().EndlessXP!.find(x => x.Category == payload.Category)!
+ });
+ } else if (payload.Mode == "c") {
+ const inventory = await getInventory(accountId);
+ const entry = inventory.EndlessXP!.find(x => x.Category == payload.Category)!;
+ const inventoryChanges: IInventoryChanges = {};
+ for (const reward of entry.PendingRewards) {
+ if (entry.Claim < reward.RequiredTotalXp && reward.RequiredTotalXp <= entry.Earn) {
+ combineInventoryChanges(
+ inventoryChanges,
+ (
+ await handleStoreItemAcquisition(
+ reward.Rewards[0].StoreItem,
+ inventory,
+ reward.Rewards[0].ItemCount
+ )
+ ).InventoryChanges
+ );
+ }
+ }
+ entry.Claim = entry.Earn;
+ await inventory.save();
+ res.json({
+ InventoryChanges: inventoryChanges,
+ ClaimedXp: entry.Claim
+ });
+ } else {
+ logger.debug(`data provided to ${req.path}: ${String(req.body)}`);
+ throw new Error(`unexpected endlessXp mode: ${payload.Mode}`);
+ }
+};
+
+type IEndlessXpRequest =
+ | {
+ Mode: "r";
+ Category: TEndlessXpCategory;
+ Choices: string[];
+ }
+ | {
+ Mode: "c" | "something else";
+ Category: TEndlessXpCategory;
+ };
+
+const generateRandomRewards = (deckName: string): ICountedStoreItem[] => {
+ const reward = getRandomElement(ExportRewards[deckName][0])!;
+ return [
+ {
+ StoreItem: reward.type,
+ ItemCount: reward.itemCount
+ }
+ ];
+};
+
+const normalModeChosenRewards: Record = {
+ Excalibur: [
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/ExcaliburHelmetBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/ExcaliburChassisBlueprint",
+ "/Lotus/StoreItems/Powersuits/Excalibur/RadialJavelinAugmentCard",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/ExcaliburSystemsBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/ExcaliburBlueprint"
+ ],
+ Trinity: [
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/TrinityHelmetBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/TrinityChassisBlueprint",
+ "/Lotus/StoreItems/Powersuits/Trinity/EnergyVampireAugmentCard",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/TrinitySystemsBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/TrinityBlueprint"
+ ],
+ Ember: [
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/EmberHelmetBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/EmberChassisBlueprint",
+ "/Lotus/StoreItems/Powersuits/Ember/WorldOnFireAugmentCard",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/EmberSystemsBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/EmberBlueprint"
+ ],
+ Loki: [
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/LOKIHelmetBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/LOKIChassisBlueprint",
+ "/Lotus/StoreItems/Powersuits/Loki/InvisibilityAugmentCard",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/LOKISystemsBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/LOKIBlueprint"
+ ],
+ Mag: [
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/MagHelmetBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/MagChassisBlueprint",
+ "/Lotus/StoreItems/Powersuits/Mag/CrushAugmentCard",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/MagSystemsBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/MagBlueprint"
+ ],
+ Rhino: [
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/RhinoHelmetBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/RhinoChassisBlueprint",
+ "/Lotus/StoreItems/Powersuits/Rhino/RhinoChargeAugmentCard",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/RhinoSystemsBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/RhinoBlueprint"
+ ],
+ Ash: [
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/AshHelmetBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/AshChassisBlueprint",
+ "/Lotus/StoreItems/Powersuits/Ninja/GlaiveAugmentCard",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/AshSystemsBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/AshBlueprint"
+ ],
+ Frost: [
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/FrostHelmetBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/FrostChassisBlueprint",
+ "/Lotus/StoreItems/Powersuits/Frost/IceShieldAugmentCard",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/FrostSystemsBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/FrostBlueprint"
+ ],
+ Nyx: [
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/NyxHelmetBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/NyxChassisBlueprint",
+ "/Lotus/StoreItems/Powersuits/Jade/SelfBulletAttractorAugmentCard",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/NyxSystemsBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/NyxBlueprint"
+ ],
+ Saryn: [
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/SarynHelmetBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/SarynChassisBlueprint",
+ "/Lotus/StoreItems/Powersuits/Saryn/PoisonAugmentCard",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/SarynSystemsBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/SarynBlueprint"
+ ],
+ Vauban: [
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/TrapperHelmetBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/TrapperChassisBlueprint",
+ "/Lotus/StoreItems/Powersuits/Trapper/LevTrapAugmentCard",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/TrapperSystemsBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/TrapperBlueprint"
+ ],
+ Nova: [
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/NovaHelmetBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/NovaChassisBlueprint",
+ "/Lotus/StoreItems/Powersuits/AntiMatter/MolecularPrimeAugmentCard",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/NovaSystemsBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/NovaBlueprint"
+ ],
+ Nekros: [
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/NecroHelmetBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/NecroChassisBlueprint",
+ "/Lotus/StoreItems/Powersuits/Necro/CloneTheDeadAugmentCard",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/NecroSystemsBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/NecroBlueprint"
+ ],
+ Valkyr: [
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/BerserkerHelmetBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/BerserkerChassisBlueprint",
+ "/Lotus/StoreItems/Powersuits/Berserker/IntimidateAugmentCard",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/BerserkerSystemsBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/BerserkerBlueprint"
+ ],
+ Oberon: [
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/PaladinHelmetBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/PaladinChassisBlueprint",
+ "/Lotus/StoreItems/Powersuits/Paladin/RegenerationAugmentCard",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/PaladinSystemsBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/PaladinBlueprint"
+ ],
+ Hydroid: [
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/HydroidHelmetBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/HydroidChassisBlueprint",
+ "/Lotus/StoreItems/Powersuits/Pirate/CannonBarrageAugmentCard",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/HydroidSystemsBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/HydroidBlueprint"
+ ],
+ Mirage: [
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/HarlequinHelmetBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/HarlequinChassisBlueprint",
+ "/Lotus/StoreItems/Powersuits/Harlequin/LightAugmentCard",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/HarlequinSystemsBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/HarlequinBlueprint"
+ ],
+ Limbo: [
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/MagicianHelmetBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/MagicianChassisBlueprint",
+ "/Lotus/StoreItems/Powersuits/Magician/TearInSpaceAugmentCard",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/MagicianSystemsBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/MagicianBlueprint"
+ ],
+ Mesa: [
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/GunslingerHelmetBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/GunslingerChassisBlueprint",
+ "/Lotus/StoreItems/Powersuits/Cowgirl/GunFuPvPAugmentCard",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/GunslingerSystemsBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/GunslingerBlueprint"
+ ],
+ Chroma: [
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/ChromaHelmetBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/ChromaChassisBlueprint",
+ "/Lotus/StoreItems/Powersuits/Dragon/DragonLuckAugmentCard",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/ChromaSystemsBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/ChromaBlueprint"
+ ],
+ Atlas: [
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/BrawlerHelmetBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/BrawlerChassisBlueprint",
+ "/Lotus/StoreItems/Powersuits/Brawler/BrawlerPassiveAugmentCard",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/BrawlerSystemsBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/BrawlerBlueprint"
+ ],
+ Ivara: [
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/RangerHelmetBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/RangerChassisBlueprint",
+ "/Lotus/StoreItems/Powersuits/Ranger/RangerStealAugmentCard",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/RangerSystemsBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/RangerBlueprint"
+ ],
+ Inaros: [
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/MummyHelmetBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/MummyChassisBlueprint",
+ "/Lotus/StoreItems/Powersuits/Sandman/SandmanSwarmAugmentCard",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/MummySystemsBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/MummyBlueprint"
+ ],
+ Titania: [
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/FairyHelmetBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/FairyChassisBlueprint",
+ "/Lotus/StoreItems/Powersuits/Fairy/FairyFlightAugmentCard",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/FairySystemsBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/FairyBlueprint"
+ ],
+ Nidus: [
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/NidusHelmetBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/NidusChassisBlueprint",
+ "/Lotus/StoreItems/Powersuits/Infestation/InfestPodsAugmentCard",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/NidusSystemsBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/NidusBlueprint"
+ ],
+ Octavia: [
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/OctaviaHelmetBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/OctaviaChassisBlueprint",
+ "/Lotus/StoreItems/Powersuits/Bard/BardCharmAugmentCard",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/OctaviaSystemsBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/OctaviaBlueprint"
+ ],
+ Harrow: [
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/PriestHelmetBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/PriestChassisBlueprint",
+ "/Lotus/StoreItems/Powersuits/Priest/PriestPactAugmentCard",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/PriestSystemsBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/PriestBlueprint"
+ ],
+ Gara: [
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/GlassHelmetBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/GlassChassisBlueprint",
+ "/Lotus/StoreItems/Powersuits/Glass/GlassFragmentAugmentCard",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/GlassSystemsBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/GlassBlueprint"
+ ],
+ Khora: [
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/KhoraHelmetBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/KhoraChassisBlueprint",
+ "/Lotus/StoreItems/Powersuits/Khora/KhoraCrackAugmentCard",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/KhoraSystemsBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/KhoraBlueprint"
+ ],
+ Revenant: [
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/RevenantHelmetBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/RevenantChassisBlueprint",
+ "/Lotus/StoreItems/Powersuits/Revenant/RevenantMarkAugmentCard",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/RevenantSystemsBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/RevenantBlueprint"
+ ],
+ Garuda: [
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/GarudaHelmetBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/GarudaChassisBlueprint",
+ "/Lotus/StoreItems/Powersuits/Garuda/GarudaUnstoppableAugmentCard",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/GarudaSystemsBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/GarudaBlueprint"
+ ],
+ Baruuk: [
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/PacifistHelmetBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/PacifistChassisBlueprint",
+ "/Lotus/StoreItems/Powersuits/Pacifist/PacifistFistAugmentCard",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/PacifistSystemsBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/PacifistBlueprint"
+ ],
+ Hildryn: [
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/IronframeHelmetBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/IronframeChassisBlueprint",
+ "/Lotus/StoreItems/Powersuits/IronFrame/IronFrameStripAugmentCard",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/IronframeSystemsBlueprint",
+ "/Lotus/StoreItems/Types/Recipes/WarframeRecipes/IronframeBlueprint"
+ ]
+};
+
+const generateNormalModeRewards = (choices: string[]): IEndlessXpReward[] => {
+ const choiceRewards = normalModeChosenRewards[choices[0]];
+ return [
+ {
+ RequiredTotalXp: 190,
+ Rewards: generateRandomRewards(
+ "/Lotus/Types/Game/MissionDecks/DuviriEndlessCircuitRewards/DuviriEndlessNormalSilverRewards"
+ )
+ },
+ {
+ RequiredTotalXp: 400,
+ Rewards: [
+ {
+ StoreItem: choiceRewards[0],
+ ItemCount: 1
+ }
+ ]
+ },
+ {
+ RequiredTotalXp: 630,
+ Rewards: generateRandomRewards(
+ "/Lotus/Types/Game/MissionDecks/DuviriEndlessCircuitRewards/DuviriEndlessNormalSilverRewards"
+ )
+ },
+ {
+ RequiredTotalXp: 890,
+ Rewards: generateRandomRewards(
+ "/Lotus/Types/Game/MissionDecks/DuviriEndlessCircuitRewards/DuviriEndlessNormalMODRewards"
+ )
+ },
+ {
+ RequiredTotalXp: 1190,
+ Rewards: [
+ {
+ StoreItem: choiceRewards[1],
+ ItemCount: 1
+ }
+ ]
+ },
+ {
+ RequiredTotalXp: 1540,
+ Rewards: generateRandomRewards(
+ "/Lotus/Types/Game/MissionDecks/DuviriEndlessCircuitRewards/DuviriEndlessNormalGoldRewards"
+ )
+ },
+ {
+ RequiredTotalXp: 1950,
+ Rewards: [
+ {
+ StoreItem: choiceRewards[2],
+ ItemCount: 1
+ }
+ ]
+ },
+ {
+ RequiredTotalXp: 2430,
+ Rewards: [
+ {
+ StoreItem: choiceRewards[3],
+ ItemCount: 1
+ }
+ ]
+ },
+ {
+ RequiredTotalXp: 2990,
+ Rewards: generateRandomRewards(
+ "/Lotus/Types/Game/MissionDecks/DuviriEndlessCircuitRewards/DuviriEndlessNormalArcaneRewards"
+ )
+ },
+ {
+ RequiredTotalXp: 3640,
+ Rewards: [
+ {
+ StoreItem: choiceRewards[4],
+ ItemCount: 1
+ }
+ ]
+ }
+ ];
+};
+
+const hardModeChosenRewards: Record = {
+ Braton: "/Lotus/StoreItems/Types/Items/MiscItems/IncarnonAdapters/Primary/BratonIncarnonUnlocker",
+ Lato: "/Lotus/StoreItems/Types/Items/MiscItems/IncarnonAdapters/Secondary/LatoIncarnonUnlocker",
+ Skana: "/Lotus/StoreItems/Types/Items/MiscItems/IncarnonAdapters/Melee/SkanaIncarnonUnlocker",
+ Paris: "/Lotus/StoreItems/Types/Items/MiscItems/IncarnonAdapters/Primary/ParisIncarnonUnlocker",
+ Kunai: "/Lotus/StoreItems/Types/Items/MiscItems/IncarnonAdapters/Secondary/KunaiIncarnonUnlocker",
+ Boar: "/Lotus/StoreItems/Types/Items/MiscItems/IncarnonAdapters/Primary/BoarIncarnonUnlocker",
+ Gammacor: "/Lotus/StoreItems/Types/Items/MiscItems/IncarnonAdapters/Secondary/GammacorIncarnonUnlocker",
+ Anku: "/Lotus/StoreItems/Types/Items/MiscItems/IncarnonAdapters/Melee/AnkuIncarnonUnlocker",
+ Gorgon: "/Lotus/StoreItems/Types/Items/MiscItems/IncarnonAdapters/Primary/GorgonIncarnonUnlocker",
+ Angstrum: "/Lotus/StoreItems/Types/Items/MiscItems/IncarnonAdapters/Secondary/AngstrumIncarnonUnlocker",
+ Bo: "/Lotus/StoreItems/Types/Items/MiscItems/IncarnonAdapters/Melee/BoIncarnonUnlocker",
+ Latron: "/Lotus/StoreItems/Types/Items/MiscItems/IncarnonAdapters/Primary/LatronIncarnonUnlocker",
+ Furis: "/Lotus/StoreItems/Types/Items/MiscItems/IncarnonAdapters/Secondary/FurisIncarnonUnlocker",
+ Furax: "/Lotus/StoreItems/Types/Items/MiscItems/IncarnonAdapters/Melee/FuraxIncarnonUnlocker",
+ Strun: "/Lotus/StoreItems/Types/Items/MiscItems/IncarnonAdapters/Primary/StrunIncarnonUnlocker",
+ Lex: "/Lotus/StoreItems/Types/Items/MiscItems/IncarnonAdapters/Secondary/LexIncarnonUnlocker",
+ Magistar: "/Lotus/StoreItems/Types/Items/MiscItems/IncarnonAdapters/Melee/MagistarIncarnonUnlocker",
+ Boltor: "/Lotus/StoreItems/Types/Items/MiscItems/IncarnonAdapters/Primary/BoltorIncarnonUnlocker",
+ Bronco: "/Lotus/StoreItems/Types/Items/MiscItems/IncarnonAdapters/Secondary/BroncoIncarnonUnlocker",
+ CeramicDagger: "/Lotus/StoreItems/Types/Items/MiscItems/IncarnonAdapters/Melee/CeramicDaggerIncarnonUnlocker",
+ Torid: "/Lotus/StoreItems/Types/Items/MiscItems/IncarnonAdapters/Primary/ToridIncarnonUnlocker",
+ DualToxocyst: "/Lotus/StoreItems/Types/Items/MiscItems/IncarnonAdapters/Secondary/DualToxocystIncarnonUnlocker",
+ DualIchor: "/Lotus/StoreItems/Types/Items/MiscItems/IncarnonAdapters/Melee/DualIchorIncarnonUnlocker",
+ Miter: "/Lotus/StoreItems/Types/Items/MiscItems/IncarnonAdapters/Primary/MiterIncarnonUnlocker",
+ Atomos: "/Lotus/StoreItems/Types/Items/MiscItems/IncarnonAdapters/Secondary/AtomosIncarnonUnlocker",
+ AckAndBrunt: "/Lotus/StoreItems/Types/Items/MiscItems/IncarnonAdapters/Melee/AckAndBruntIncarnonUnlocker",
+ Soma: "/Lotus/StoreItems/Types/Items/MiscItems/IncarnonAdapters/Primary/SomaIncarnonUnlocker",
+ Vasto: "/Lotus/StoreItems/Types/Items/MiscItems/IncarnonAdapters/Secondary/VastoIncarnonUnlocker",
+ NamiSolo: "/Lotus/StoreItems/Types/Items/MiscItems/IncarnonAdapters/Melee/NamiSoloIncarnonUnlocker",
+ Burston: "/Lotus/StoreItems/Types/Items/MiscItems/IncarnonAdapters/Primary/BurstonIncarnonUnlocker",
+ Zylok: "/Lotus/StoreItems/Types/Items/MiscItems/IncarnonAdapters/Secondary/ZylokIncarnonUnlocker",
+ Sibear: "/Lotus/StoreItems/Types/Items/MiscItems/IncarnonAdapters/Melee/SibearIncarnonUnlocker",
+ Dread: "/Lotus/StoreItems/Types/Items/MiscItems/IncarnonAdapters/Primary/DreadIncarnonUnlocker",
+ Despair: "/Lotus/StoreItems/Types/Items/MiscItems/IncarnonAdapters/Secondary/DespairIncarnonUnlocker",
+ Hate: "/Lotus/StoreItems/Types/Items/MiscItems/IncarnonAdapters/Melee/HateIncarnonUnlocker",
+ Dera: "/Lotus/StoreItems/Types/Items/MiscItems/IncarnonAdapters/Primary/DeraIncarnonUnlocker",
+ Cestra: "/Lotus/StoreItems/Types/Items/MiscItems/IncarnonAdapters/Secondary/CestraIncarnonUnlocker",
+ Okina: "/Lotus/StoreItems/Types/Items/MiscItems/IncarnonAdapters/Melee/OkinaIncarnonUnlocker",
+ Sybaris: "/Lotus/StoreItems/Types/Items/MiscItems/IncarnonAdapters/Primary/SybarisIncarnonUnlocker",
+ Sicarus: "/Lotus/StoreItems/Types/Items/MiscItems/IncarnonAdapters/Secondary/SicarusIncarnonUnlocker",
+ RivenPrimary: "/Lotus/StoreItems/Upgrades/Mods/Randomized/RawRifleRandomMod",
+ RivenSecondary: "/Lotus/StoreItems/Upgrades/Mods/Randomized/RawPistolRandomMod",
+ RivenMelee: "/Lotus/StoreItems/Upgrades/Mods/Randomized/RawMeleeRandomMod",
+ Kuva: "/Lotus/Types/Game/DuviriEndless/CircuitSteelPathBIGKuvaReward"
+};
+
+const generateHardModeRewards = (choices: string[]): IEndlessXpReward[] => {
+ return [
+ {
+ RequiredTotalXp: 285,
+ Rewards: generateRandomRewards(
+ "/Lotus/Types/Game/MissionDecks/DuviriEndlessCircuitRewards/DuviriEndlessSteelPathSilverRewards"
+ )
+ },
+ {
+ RequiredTotalXp: 600,
+ Rewards: generateRandomRewards(
+ "/Lotus/Types/Game/MissionDecks/DuviriEndlessCircuitRewards/DuviriEndlessSteelPathArcaneRewards"
+ )
+ },
+ {
+ RequiredTotalXp: 945,
+ Rewards: generateRandomRewards(
+ "/Lotus/Types/Game/MissionDecks/DuviriEndlessCircuitRewards/DuviriEndlessSteelPathSilverRewards"
+ )
+ },
+ {
+ RequiredTotalXp: 1335,
+ Rewards: generateRandomRewards(
+ "/Lotus/Types/Game/MissionDecks/DuviriEndlessCircuitRewards/DuviriEndlessSteelPathSilverRewards"
+ )
+ },
+ {
+ RequiredTotalXp: 1785,
+ Rewards: [
+ {
+ StoreItem: hardModeChosenRewards[choices[0]],
+ ItemCount: 1
+ }
+ ]
+ },
+ {
+ RequiredTotalXp: 2310,
+ Rewards: generateRandomRewards(
+ "/Lotus/Types/Game/MissionDecks/DuviriEndlessCircuitRewards/DuviriEndlessSteelPathGoldRewards"
+ )
+ },
+ {
+ RequiredTotalXp: 2925,
+ Rewards: generateRandomRewards(
+ "/Lotus/Types/Game/MissionDecks/DuviriEndlessCircuitRewards/DuviriEndlessSteelPathGoldRewards"
+ )
+ },
+ {
+ RequiredTotalXp: 3645,
+ Rewards: generateRandomRewards(
+ "/Lotus/Types/Game/MissionDecks/DuviriEndlessCircuitRewards/DuviriEndlessSteelPathArcaneRewards"
+ )
+ },
+ {
+ RequiredTotalXp: 4485,
+ Rewards: generateRandomRewards(
+ "/Lotus/Types/Game/MissionDecks/DuviriEndlessCircuitRewards/DuviriEndlessSteelPathSteelEssenceRewards"
+ )
+ },
+ {
+ RequiredTotalXp: 5460,
+ Rewards: [
+ {
+ StoreItem: hardModeChosenRewards[choices[1]],
+ ItemCount: 1
+ }
+ ]
+ }
+ ];
+};
diff --git a/src/controllers/api/entratiLabConquestModeController.ts b/src/controllers/api/entratiLabConquestModeController.ts
new file mode 100644
index 00000000..93f0dbb7
--- /dev/null
+++ b/src/controllers/api/entratiLabConquestModeController.ts
@@ -0,0 +1,52 @@
+import { toMongoDate } from "../../helpers/inventoryHelpers.ts";
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { getInventory, updateEntratiVault } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { RequestHandler } from "express";
+
+export const entratiLabConquestModeController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(
+ accountId,
+ "EntratiVaultCountResetDate EntratiVaultCountLastPeriod EntratiLabConquestUnlocked EchoesHexConquestUnlocked EchoesHexConquestActiveFrameVariants EchoesHexConquestActiveStickers EntratiLabConquestActiveFrameVariants EntratiLabConquestCacheScoreMission EchoesHexConquestCacheScoreMission"
+ );
+ const body = getJSONfromString(String(req.body));
+ updateEntratiVault(inventory);
+ if (body.BuyMode) {
+ inventory.EntratiVaultCountLastPeriod! += 2;
+ if (body.IsEchoesDeepArchemedea) {
+ inventory.EchoesHexConquestUnlocked = 1;
+ } else {
+ inventory.EntratiLabConquestUnlocked = 1;
+ }
+ }
+ if (body.IsEchoesDeepArchemedea) {
+ if (inventory.EchoesHexConquestUnlocked) {
+ inventory.EchoesHexConquestActiveFrameVariants = body.EchoesHexConquestActiveFrameVariants!;
+ inventory.EchoesHexConquestActiveStickers = body.EchoesHexConquestActiveStickers!;
+ }
+ } else {
+ if (inventory.EntratiLabConquestUnlocked) {
+ inventory.EntratiLabConquestActiveFrameVariants = body.EntratiLabConquestActiveFrameVariants!;
+ }
+ }
+ await inventory.save();
+ res.json({
+ EntratiVaultCountResetDate: toMongoDate(inventory.EntratiVaultCountResetDate!),
+ EntratiVaultCountLastPeriod: inventory.EntratiVaultCountLastPeriod,
+ EntratiLabConquestUnlocked: inventory.EntratiLabConquestUnlocked,
+ EntratiLabConquestCacheScoreMission: inventory.EntratiLabConquestCacheScoreMission,
+ EchoesHexConquestUnlocked: inventory.EchoesHexConquestUnlocked,
+ EchoesHexConquestCacheScoreMission: inventory.EchoesHexConquestCacheScoreMission
+ });
+};
+
+interface IEntratiLabConquestModeRequest {
+ BuyMode?: number;
+ IsEchoesDeepArchemedea?: number;
+ EntratiLabConquestUnlocked?: number;
+ EntratiLabConquestActiveFrameVariants?: string[];
+ EchoesHexConquestUnlocked?: number;
+ EchoesHexConquestActiveFrameVariants?: string[];
+ EchoesHexConquestActiveStickers?: string[];
+}
diff --git a/src/controllers/api/evolveWeaponController.ts b/src/controllers/api/evolveWeaponController.ts
new file mode 100644
index 00000000..9ed211c9
--- /dev/null
+++ b/src/controllers/api/evolveWeaponController.ts
@@ -0,0 +1,59 @@
+import type { RequestHandler } from "express";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { addMiscItems, getInventory } from "../../services/inventoryService.ts";
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import type { WeaponTypeInternal } from "../../services/itemDataService.ts";
+import { getRecipe } from "../../services/itemDataService.ts";
+import { EquipmentFeatures } from "../../types/equipmentTypes.ts";
+
+export const evolveWeaponController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId);
+ const payload = getJSONfromString(String(req.body));
+
+ const recipe = getRecipe(payload.Recipe)!;
+ if (payload.Action == "EWA_INSTALL") {
+ addMiscItems(
+ inventory,
+ recipe.ingredients.map(x => ({ ItemType: x.ItemType, ItemCount: x.ItemCount * -1 }))
+ );
+
+ const item = inventory[payload.Category].id(req.query.ItemId as string)!;
+ item.Features ??= 0;
+ item.Features |= EquipmentFeatures.INCARNON_GENESIS;
+
+ item.SkillTree = "0";
+
+ inventory.EvolutionProgress ??= [];
+ if (!inventory.EvolutionProgress.find(entry => entry.ItemType == payload.EvoType)) {
+ inventory.EvolutionProgress.push({
+ Progress: 0,
+ Rank: 1,
+ ItemType: payload.EvoType
+ });
+ }
+ } else if (payload.Action == "EWA_UNINSTALL") {
+ addMiscItems(inventory, [
+ {
+ ItemType: recipe.resultType,
+ ItemCount: 1
+ }
+ ]);
+
+ const item = inventory[payload.Category].id(req.query.ItemId as string)!;
+ item.Features! &= ~EquipmentFeatures.INCARNON_GENESIS;
+ } else {
+ throw new Error(`unexpected evolve weapon action: ${payload.Action}`);
+ }
+
+ await inventory.save();
+ res.end();
+};
+
+interface IEvolveWeaponRequest {
+ Action: string;
+ Category: WeaponTypeInternal;
+ Recipe: string; // e.g. "/Lotus/Types/Items/MiscItems/IncarnonAdapters/UnlockerBlueprints/DespairIncarnonBlueprint"
+ UninstallRecipe: "";
+ EvoType: string; // e.g. "/Lotus/Weapons/Tenno/ThrowingWeapons/StalkerKunai"
+}
diff --git a/src/controllers/api/findSessionsController.ts b/src/controllers/api/findSessionsController.ts
new file mode 100644
index 00000000..5dd819f7
--- /dev/null
+++ b/src/controllers/api/findSessionsController.ts
@@ -0,0 +1,28 @@
+import type { RequestHandler } from "express";
+import { getSession } from "../../managers/sessionManager.ts";
+import { logger } from "../../utils/logger.ts";
+import type { IFindSessionRequest } from "../../types/session.ts";
+
+export const findSessionsController: RequestHandler = (_req, res) => {
+ const req = JSON.parse(String(_req.body)) as IFindSessionRequest;
+ logger.debug("FindSession Request ", req);
+ if (req.id != undefined) {
+ logger.debug("Found ID");
+ const session = getSession(req.id);
+
+ if (session.length) res.json({ queryId: req.queryId, Sessions: session });
+ else res.json({});
+ } else if (req.originalSessionId != undefined) {
+ logger.debug("Found OriginalSessionID");
+
+ const session = getSession(req.originalSessionId);
+ if (session.length) res.json({ queryId: req.queryId, Sessions: session });
+ else res.json({});
+ } else {
+ logger.debug("Found SessionRequest");
+
+ const session = getSession(req);
+ if (session.length) res.json({ queryId: req.queryId, Sessions: session });
+ else res.json({});
+ }
+};
diff --git a/src/controllers/api/fishmongerController.ts b/src/controllers/api/fishmongerController.ts
new file mode 100644
index 00000000..85502d0b
--- /dev/null
+++ b/src/controllers/api/fishmongerController.ts
@@ -0,0 +1,46 @@
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { addMiscItems, addStanding, getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { IMiscItem } from "../../types/inventoryTypes/inventoryTypes.ts";
+import type { RequestHandler } from "express";
+import { ExportResources } from "warframe-public-export-plus";
+
+export const fishmongerController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId);
+ const body = getJSONfromString(String(req.body));
+ const miscItemChanges: IMiscItem[] = [];
+ let syndicateTag: string | undefined;
+ let gainedStanding = 0;
+ for (const fish of body.Fish) {
+ const fishData = ExportResources[fish.ItemType];
+ if (req.query.dissect == "1") {
+ for (const part of fishData.dissectionParts!) {
+ const partItem = miscItemChanges.find(x => x.ItemType == part.ItemType);
+ if (partItem) {
+ partItem.ItemCount += part.ItemCount * fish.ItemCount;
+ } else {
+ miscItemChanges.push({ ItemType: part.ItemType, ItemCount: part.ItemCount * fish.ItemCount });
+ }
+ }
+ } else {
+ syndicateTag = fishData.syndicateTag!;
+ gainedStanding += fishData.standingBonus! * fish.ItemCount;
+ }
+ miscItemChanges.push({ ItemType: fish.ItemType, ItemCount: fish.ItemCount * -1 });
+ }
+ addMiscItems(inventory, miscItemChanges);
+ if (gainedStanding && syndicateTag) addStanding(inventory, syndicateTag, gainedStanding);
+ await inventory.save();
+ res.json({
+ InventoryChanges: {
+ MiscItems: miscItemChanges
+ },
+ SyndicateTag: syndicateTag,
+ StandingChange: gainedStanding
+ });
+};
+
+interface IFishmongerRequest {
+ Fish: IMiscItem[];
+}
diff --git a/src/controllers/api/focusController.ts b/src/controllers/api/focusController.ts
new file mode 100644
index 00000000..415f84e0
--- /dev/null
+++ b/src/controllers/api/focusController.ts
@@ -0,0 +1,242 @@
+import type { RequestHandler } from "express";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { getInventory, addMiscItems, addEquipment, occupySlot } from "../../services/inventoryService.ts";
+import type { IMiscItem, TFocusPolarity, TEquipmentKey } from "../../types/inventoryTypes/inventoryTypes.ts";
+import { InventorySlot } from "../../types/inventoryTypes/inventoryTypes.ts";
+import { logger } from "../../utils/logger.ts";
+import { ExportFocusUpgrades } from "warframe-public-export-plus";
+import { Inventory } from "../../models/inventoryModels/inventoryModel.ts";
+
+export const focusController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ switch (req.query.op) {
+ default:
+ logger.error("Unhandled focus op type: " + String(req.query.op));
+ logger.debug(String(req.body));
+ res.end();
+ break;
+ case FocusOperation.InstallLens: {
+ const request = JSON.parse(String(req.body)) as ILensInstallRequest;
+ const inventory = await getInventory(accountId);
+ const item = inventory[request.Category].id(request.WeaponId);
+ if (item) {
+ item.FocusLens = request.LensType;
+ addMiscItems(inventory, [
+ {
+ ItemType: request.LensType,
+ ItemCount: -1
+ } satisfies IMiscItem
+ ]);
+ }
+ await inventory.save();
+ res.json({
+ weaponId: request.WeaponId,
+ lensType: request.LensType
+ });
+ break;
+ }
+ case FocusOperation.UnlockWay: {
+ const focusType = (JSON.parse(String(req.body)) as IWayRequest).FocusType;
+ const focusPolarity = focusTypeToPolarity(focusType);
+ const inventory = await getInventory(accountId, "FocusAbility FocusUpgrades FocusXP");
+ const cost = inventory.FocusAbility ? 50_000 : 0;
+ inventory.FocusAbility ??= focusType;
+ inventory.FocusUpgrades.push({ ItemType: focusType });
+ if (cost) {
+ inventory.FocusXP![focusPolarity]! -= cost;
+ }
+ await inventory.save();
+ res.json({
+ FocusUpgrade: { ItemType: focusType },
+ FocusPointCosts: { [focusPolarity]: cost }
+ });
+ break;
+ }
+ case FocusOperation.ActivateWay: {
+ const focusType = (JSON.parse(String(req.body)) as IWayRequest).FocusType;
+
+ await Inventory.updateOne(
+ {
+ accountOwnerId: accountId
+ },
+ {
+ FocusAbility: focusType
+ }
+ );
+
+ res.json({
+ FocusUpgrade: { ItemType: focusType }
+ });
+ break;
+ }
+ case FocusOperation.UnlockUpgrade: {
+ const request = JSON.parse(String(req.body)) as IUnlockUpgradeRequest;
+ const focusPolarity = focusTypeToPolarity(request.FocusTypes[0]);
+ const inventory = await getInventory(accountId);
+ let cost = 0;
+ for (const focusType of request.FocusTypes) {
+ cost += ExportFocusUpgrades[focusType].baseFocusPointCost;
+ inventory.FocusUpgrades.push({ ItemType: focusType, Level: 0 });
+ }
+ inventory.FocusXP![focusPolarity]! -= cost;
+ await inventory.save();
+ res.json({
+ FocusTypes: request.FocusTypes,
+ FocusPointCosts: { [focusPolarity]: cost }
+ });
+ break;
+ }
+ case FocusOperation.LevelUpUpgrade: {
+ const request = JSON.parse(String(req.body)) as ILevelUpUpgradeRequest;
+ const focusPolarity = focusTypeToPolarity(request.FocusInfos[0].ItemType);
+ const inventory = await getInventory(accountId);
+ let cost = 0;
+ for (const focusUpgrade of request.FocusInfos) {
+ cost += focusUpgrade.FocusXpCost;
+ const focusUpgradeDb = inventory.FocusUpgrades.find(entry => entry.ItemType == focusUpgrade.ItemType)!;
+ focusUpgradeDb.Level = focusUpgrade.Level;
+ }
+ inventory.FocusXP![focusPolarity]! -= cost;
+ await inventory.save();
+ res.json({
+ FocusInfos: request.FocusInfos,
+ FocusPointCosts: { [focusPolarity]: cost }
+ });
+ break;
+ }
+ case FocusOperation.SentTrainingAmplifier: {
+ const request = JSON.parse(String(req.body)) as ISentTrainingAmplifierRequest;
+ const inventory = await getInventory(accountId);
+ const inventoryChanges = addEquipment(inventory, "OperatorAmps", request.StartingWeaponType, {
+ ModularParts: [
+ "/Lotus/Weapons/Sentients/OperatorAmplifiers/SentTrainingAmplifier/SentAmpTrainingGrip",
+ "/Lotus/Weapons/Sentients/OperatorAmplifiers/SentTrainingAmplifier/SentAmpTrainingChassis",
+ "/Lotus/Weapons/Sentients/OperatorAmplifiers/SentTrainingAmplifier/SentAmpTrainingBarrel"
+ ]
+ });
+ occupySlot(inventory, InventorySlot.AMPS, false);
+ await inventory.save();
+ res.json(inventoryChanges.OperatorAmps![0]);
+ break;
+ }
+ case FocusOperation.UnbindUpgrade: {
+ const request = JSON.parse(String(req.body)) as IUnbindUpgradeRequest;
+ const focusPolarity = focusTypeToPolarity(request.FocusTypes[0]);
+ const inventory = await getInventory(accountId);
+ inventory.FocusXP![focusPolarity]! -= 750_000 * request.FocusTypes.length;
+ addMiscItems(inventory, [
+ {
+ ItemType: "/Lotus/Types/Gameplay/Eidolon/Resources/SentientShards/SentientShardBrilliantItem",
+ ItemCount: request.FocusTypes.length * -1
+ }
+ ]);
+ request.FocusTypes.forEach(type => {
+ const focusUpgradeDb = inventory.FocusUpgrades.find(entry => entry.ItemType == type)!;
+ focusUpgradeDb.IsUniversal = true;
+ });
+ await inventory.save();
+ res.json({
+ FocusTypes: request.FocusTypes,
+ FocusPointCosts: {
+ [focusPolarity]: 750_000 * request.FocusTypes.length
+ },
+ MiscItemCosts: [
+ {
+ ItemType: "/Lotus/Types/Gameplay/Eidolon/Resources/SentientShards/SentientShardBrilliantItem",
+ ItemCount: request.FocusTypes.length
+ }
+ ]
+ });
+ break;
+ }
+ case FocusOperation.ConvertShard: {
+ const request = JSON.parse(String(req.body)) as IConvertShardRequest;
+ // Tally XP
+ let xp = 0;
+ for (const shard of request.Shards) {
+ xp += shardValues[shard.ItemType as keyof typeof shardValues] * shard.ItemCount;
+ }
+ // Send response
+ res.json({
+ FocusPointGains: {
+ [request.Polarity]: xp
+ },
+ MiscItemCosts: request.Shards
+ });
+ // Commit added XP and removed shards to DB
+ for (const shard of request.Shards) {
+ shard.ItemCount *= -1;
+ }
+ const inventory = await getInventory(accountId);
+ const polarity = request.Polarity;
+ inventory.FocusXP ??= {};
+ inventory.FocusXP[polarity] ??= 0;
+ inventory.FocusXP[polarity] += xp;
+ addMiscItems(inventory, request.Shards);
+ await inventory.save();
+ break;
+ }
+ }
+};
+
+enum FocusOperation {
+ InstallLens = "1",
+ UnlockWay = "2",
+ UnlockUpgrade = "3",
+ LevelUpUpgrade = "4",
+ ActivateWay = "5",
+ SentTrainingAmplifier = "7",
+ UnbindUpgrade = "8",
+ ConvertShard = "9"
+}
+
+// For UnlockWay & ActivateWay
+interface IWayRequest {
+ FocusType: string;
+}
+
+interface IUnlockUpgradeRequest {
+ FocusTypes: string[];
+}
+
+interface ILevelUpUpgradeRequest {
+ FocusInfos: {
+ ItemType: string;
+ FocusXpCost: number;
+ IsUniversal: boolean;
+ Level: number;
+ IsActiveAbility: boolean;
+ }[];
+}
+
+interface IUnbindUpgradeRequest {
+ ShardTypes: string[];
+ FocusTypes: string[];
+}
+
+interface IConvertShardRequest {
+ Shards: IMiscItem[];
+ Polarity: TFocusPolarity;
+}
+
+interface ISentTrainingAmplifierRequest {
+ StartingWeaponType: string;
+}
+
+interface ILensInstallRequest {
+ LensType: string;
+ Category: TEquipmentKey;
+ WeaponId: string;
+}
+
+// Works for ways & upgrades
+const focusTypeToPolarity = (type: string): TFocusPolarity => {
+ return ("AP_" + type.substr(1).split("/")[3].toUpperCase()) as TFocusPolarity;
+};
+
+const shardValues = {
+ "/Lotus/Types/Gameplay/Eidolon/Resources/SentientShards/SentientShardCommonItem": 2_500,
+ "/Lotus/Types/Gameplay/Eidolon/Resources/SentientShards/SentientShardSynthesizedItem": 5_000,
+ "/Lotus/Types/Gameplay/Eidolon/Resources/SentientShards/SentientShardBrilliantItem": 25_000,
+ "/Lotus/Types/Gameplay/Eidolon/Resources/SentientShards/SentientShardBrilliantTierTwoItem": 40_000
+};
diff --git a/src/controllers/api/fusionTreasuresController.ts b/src/controllers/api/fusionTreasuresController.ts
new file mode 100644
index 00000000..86e33034
--- /dev/null
+++ b/src/controllers/api/fusionTreasuresController.ts
@@ -0,0 +1,44 @@
+import type { RequestHandler } from "express";
+import { ExportResources } from "warframe-public-export-plus";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { addFusionTreasures, addMiscItems, getInventory } from "../../services/inventoryService.ts";
+import type { IMiscItem } from "../../types/inventoryTypes/inventoryTypes.ts";
+import { parseFusionTreasure } from "../../helpers/inventoryHelpers.ts";
+
+interface IFusionTreasureRequest {
+ oldTreasureName: string;
+ newTreasureName: string;
+}
+
+export const fusionTreasuresController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId);
+ const request = JSON.parse(String(req.body)) as IFusionTreasureRequest;
+
+ // Swap treasures
+ const oldTreasure = parseFusionTreasure(request.oldTreasureName, -1);
+ const newTreasure = parseFusionTreasure(request.newTreasureName, 1);
+ const fusionTreasureChanges = [oldTreasure, newTreasure];
+ addFusionTreasures(inventory, fusionTreasureChanges);
+
+ // Remove consumed stars
+ const miscItemChanges: IMiscItem[] = [];
+ const filledSockets = newTreasure.Sockets & ~oldTreasure.Sockets;
+ for (let i = 0; filledSockets >> i; ++i) {
+ if ((filledSockets >> i) & 1) {
+ //console.log("Socket", i, "has been filled with", ExportResources[oldTreasure.ItemType].sockets![i]);
+ miscItemChanges.push({
+ ItemType: ExportResources[oldTreasure.ItemType].sockets![i],
+ ItemCount: -1
+ });
+ }
+ }
+ addMiscItems(inventory, miscItemChanges);
+
+ await inventory.save();
+ // The response itself is the inventory changes for this endpoint.
+ res.json({
+ MiscItems: miscItemChanges,
+ FusionTreasures: fusionTreasureChanges
+ });
+};
diff --git a/src/controllers/api/gardeningController.ts b/src/controllers/api/gardeningController.ts
new file mode 100644
index 00000000..e98c8a6a
--- /dev/null
+++ b/src/controllers/api/gardeningController.ts
@@ -0,0 +1,83 @@
+import { toMongoDate } from "../../helpers/inventoryHelpers.ts";
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { addMiscItem, getInventory } from "../../services/inventoryService.ts";
+import { toStoreItem } from "../../services/itemDataService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { createGarden, getPersonalRooms } from "../../services/personalRoomsService.ts";
+import type { IMongoDate } from "../../types/commonTypes.ts";
+import type { IMissionReward } from "../../types/missionTypes.ts";
+import type { IGardeningClient, IPersonalRoomsClient } from "../../types/personalRoomsTypes.ts";
+import type { IInventoryChanges } from "../../types/purchaseTypes.ts";
+import type { RequestHandler } from "express";
+import { dict_en, ExportResources } from "warframe-public-export-plus";
+
+export const gardeningController: RequestHandler = async (req, res) => {
+ const data = getJSONfromString(String(req.body));
+ if (data.Mode != "HarvestAll") {
+ throw new Error(`unexpected gardening mode: ${data.Mode}`);
+ }
+
+ const accountId = await getAccountIdForRequest(req);
+ const [inventory, personalRooms] = await Promise.all([
+ getInventory(accountId, "MiscItems"),
+ getPersonalRooms(accountId, "Apartment")
+ ]);
+
+ // Harvest plants
+ const inventoryChanges: IInventoryChanges = {};
+ const rewards: Record = {};
+ for (const planter of personalRooms.Apartment.Gardening.Planters) {
+ rewards[planter.Name] = [];
+ for (const plant of planter.Plants) {
+ const itemType =
+ "/Lotus/Types/Gameplay/Duviri/Resource/DuviriPlantItem" +
+ plant.PlantType.substring(plant.PlantType.length - 1);
+ const itemCount = Math.random() < 0.775 ? 2 : 4;
+
+ addMiscItem(inventory, itemType, itemCount, inventoryChanges);
+
+ rewards[planter.Name].push([
+ {
+ StoreItem: toStoreItem(itemType),
+ TypeName: itemType,
+ ItemCount: itemCount,
+ DailyCooldown: false,
+ Rarity: itemCount == 2 ? 0.7743589743589744 : 0.22564102564102564,
+ TweetText: `${itemCount}x ${dict_en[ExportResources[itemType].name]} (Resource)`,
+ ProductCategory: "MiscItems"
+ }
+ ]);
+ }
+ }
+
+ // Refresh garden
+ personalRooms.Apartment.Gardening = createGarden();
+
+ await Promise.all([inventory.save(), personalRooms.save()]);
+
+ const planter = personalRooms.Apartment.Gardening.Planters[personalRooms.Apartment.Gardening.Planters.length - 1];
+ const plant = planter.Plants[planter.Plants.length - 1];
+ res.json({
+ GardenTagName: planter.Name,
+ PlantType: plant.PlantType,
+ PlotIndex: plant.PlotIndex,
+ EndTime: toMongoDate(plant.EndTime),
+ InventoryChanges: inventoryChanges,
+ Gardening: personalRooms.toJSON().Apartment.Gardening,
+ Rewards: rewards
+ } satisfies IGardeningResponse);
+};
+
+interface IGardeningRequest {
+ Mode: string;
+}
+
+interface IGardeningResponse {
+ GardenTagName: string;
+ PlantType: string;
+ PlotIndex: number;
+ EndTime: IMongoDate;
+ InventoryChanges: IInventoryChanges;
+ Gardening: IGardeningClient;
+ Rewards: Record;
+}
diff --git a/src/controllers/api/genericUpdateController.ts b/src/controllers/api/genericUpdateController.ts
new file mode 100644
index 00000000..3a602661
--- /dev/null
+++ b/src/controllers/api/genericUpdateController.ts
@@ -0,0 +1,16 @@
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { updateGeneric } from "../../services/inventoryService.ts";
+import type { RequestHandler } from "express";
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import type { IGenericUpdate } from "../../types/genericUpdate.ts";
+
+// This endpoint used to be /api/genericUpdate.php, but sometime around the Jade Shadows update, it was changed to /api/updateNodeIntros.php.
+// SpaceNinjaServer supports both endpoints right now.
+
+const genericUpdateController: RequestHandler = async (request, response) => {
+ const accountId = await getAccountIdForRequest(request);
+ const update = getJSONfromString(String(request.body));
+ response.json(await updateGeneric(update, accountId));
+};
+
+export { genericUpdateController };
diff --git a/src/controllers/api/getAllianceController.ts b/src/controllers/api/getAllianceController.ts
new file mode 100644
index 00000000..928a06e4
--- /dev/null
+++ b/src/controllers/api/getAllianceController.ts
@@ -0,0 +1,26 @@
+import { Alliance, Guild } from "../../models/guildModel.ts";
+import { getAllianceClient } from "../../services/guildService.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { RequestHandler } from "express";
+
+export const getAllianceController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId, "GuildId");
+ if (inventory.GuildId) {
+ const guild = (await Guild.findById(inventory.GuildId, "Name Tier AllianceId"))!;
+ if (guild.AllianceId) {
+ const alliance = (await Alliance.findById(guild.AllianceId))!;
+ res.json(await getAllianceClient(alliance, guild));
+ return;
+ }
+ }
+ res.end();
+};
+
+// POST request since U27
+/*interface IGetAllianceRequest {
+ memberCount: number;
+ clanLeaderName: string;
+ clanLeaderId: string;
+}*/
diff --git a/src/controllers/api/getDailyDealStockLevelsController.ts b/src/controllers/api/getDailyDealStockLevelsController.ts
new file mode 100644
index 00000000..b53d8fbc
--- /dev/null
+++ b/src/controllers/api/getDailyDealStockLevelsController.ts
@@ -0,0 +1,10 @@
+import { DailyDeal } from "../../models/worldStateModel.ts";
+import type { RequestHandler } from "express";
+
+export const getDailyDealStockLevelsController: RequestHandler = async (req, res) => {
+ const dailyDeal = (await DailyDeal.findOne({ StoreItem: req.query.productName }, "AmountSold"))!;
+ res.json({
+ StoreItem: req.query.productName,
+ AmountSold: dailyDeal.AmountSold
+ });
+};
diff --git a/src/controllers/api/getFriendsController.ts b/src/controllers/api/getFriendsController.ts
new file mode 100644
index 00000000..797b44b9
--- /dev/null
+++ b/src/controllers/api/getFriendsController.ts
@@ -0,0 +1,54 @@
+import { toOid } from "../../helpers/inventoryHelpers.ts";
+import { Friendship } from "../../models/friendModel.ts";
+import { addAccountDataToFriendInfo, addInventoryDataToFriendInfo } from "../../services/friendService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { IFriendInfo } from "../../types/friendTypes.ts";
+import type { Request, RequestHandler, Response } from "express";
+
+// POST with {} instead of GET as of 38.5.0
+export const getFriendsController: RequestHandler = async (req: Request, res: Response) => {
+ const accountId = await getAccountIdForRequest(req);
+ const response: IGetFriendsResponse = {
+ Current: [],
+ IncomingFriendRequests: [],
+ OutgoingFriendRequests: []
+ };
+ const [internalFriendships, externalFriendships] = await Promise.all([
+ Friendship.find({ owner: accountId }),
+ Friendship.find({ friend: accountId }, "owner Note")
+ ]);
+ for (const externalFriendship of externalFriendships) {
+ if (!internalFriendships.find(x => x.friend.equals(externalFriendship.owner))) {
+ response.IncomingFriendRequests.push({
+ _id: toOid(externalFriendship.owner),
+ Note: externalFriendship.Note
+ });
+ }
+ }
+ for (const internalFriendship of internalFriendships) {
+ const friendInfo: IFriendInfo = {
+ _id: toOid(internalFriendship.friend)
+ };
+ if (externalFriendships.find(x => x.owner.equals(internalFriendship.friend))) {
+ response.Current.push(friendInfo);
+ } else {
+ response.OutgoingFriendRequests.push(friendInfo);
+ }
+ }
+ const promises: Promise[] = [];
+ for (const arr of Object.values(response)) {
+ for (const friendInfo of arr) {
+ promises.push(addAccountDataToFriendInfo(friendInfo));
+ promises.push(addInventoryDataToFriendInfo(friendInfo));
+ }
+ }
+ await Promise.all(promises);
+ res.json(response);
+};
+
+// interface IGetFriendsResponse {
+// Current: IFriendInfo[];
+// IncomingFriendRequests: IFriendInfo[];
+// OutgoingFriendRequests: IFriendInfo[];
+// }
+type IGetFriendsResponse = Record<"Current" | "IncomingFriendRequests" | "OutgoingFriendRequests", IFriendInfo[]>;
diff --git a/src/controllers/api/getGuildContributionsController.ts b/src/controllers/api/getGuildContributionsController.ts
new file mode 100644
index 00000000..201a3767
--- /dev/null
+++ b/src/controllers/api/getGuildContributionsController.ts
@@ -0,0 +1,19 @@
+import { GuildMember } from "../../models/guildModel.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { IGuildMemberClient } from "../../types/guildTypes.ts";
+import type { RequestHandler } from "express";
+
+export const getGuildContributionsController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const guildId = (await getInventory(accountId, "GuildId")).GuildId;
+ const guildMember = (await GuildMember.findOne({ guildId, accountId: req.query.buddyId }))!;
+ res.json({
+ _id: { $oid: req.query.buddyId as string },
+ RegularCreditsContributed: guildMember.RegularCreditsContributed,
+ PremiumCreditsContributed: guildMember.PremiumCreditsContributed,
+ MiscItemsContributed: guildMember.MiscItemsContributed,
+ ConsumablesContributed: [], // ???
+ ShipDecorationsContributed: guildMember.ShipDecorationsContributed
+ } satisfies Partial);
+};
diff --git a/src/controllers/api/getGuildController.ts b/src/controllers/api/getGuildController.ts
new file mode 100644
index 00000000..a8003a33
--- /dev/null
+++ b/src/controllers/api/getGuildController.ts
@@ -0,0 +1,32 @@
+import type { RequestHandler } from "express";
+import { Guild } from "../../models/guildModel.ts";
+import { getAccountForRequest } from "../../services/loginService.ts";
+import { logger } from "../../utils/logger.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import { createUniqueClanName, getGuildClient } from "../../services/guildService.ts";
+
+export const getGuildController: RequestHandler = async (req, res) => {
+ const account = await getAccountForRequest(req);
+ const inventory = await getInventory(account._id.toString(), "GuildId");
+ if (inventory.GuildId) {
+ const guild = await Guild.findById(inventory.GuildId);
+ if (guild) {
+ // Handle guilds created before we added discriminators
+ if (guild.Name.indexOf("#") == -1) {
+ guild.Name = await createUniqueClanName(guild.Name);
+ await guild.save();
+ }
+
+ if (guild.CeremonyResetDate && Date.now() >= guild.CeremonyResetDate.getTime()) {
+ logger.debug(`ascension ceremony is over`);
+ guild.CeremonyEndo = undefined;
+ guild.CeremonyContributors = undefined;
+ guild.CeremonyResetDate = undefined;
+ await guild.save();
+ }
+ res.json(await getGuildClient(guild, account));
+ return;
+ }
+ }
+ res.end();
+};
diff --git a/src/controllers/api/getGuildDojoController.ts b/src/controllers/api/getGuildDojoController.ts
new file mode 100644
index 00000000..05d4685c
--- /dev/null
+++ b/src/controllers/api/getGuildDojoController.ts
@@ -0,0 +1,35 @@
+import type { RequestHandler } from "express";
+import { Types } from "mongoose";
+import { Guild } from "../../models/guildModel.ts";
+import { getDojoClient } from "../../services/guildService.ts";
+import { Account } from "../../models/loginModel.ts";
+
+export const getGuildDojoController: RequestHandler = async (req, res) => {
+ const guildId = req.query.guildId as string;
+
+ const guild = await Guild.findById(guildId);
+ if (!guild) {
+ res.status(404).end();
+ return;
+ }
+
+ // Populate dojo info if not present
+ if (guild.DojoComponents.length == 0) {
+ guild.DojoComponents.push({
+ _id: new Types.ObjectId(),
+ pf: "/Lotus/Levels/ClanDojo/DojoHall.level",
+ ppf: "",
+ CompletionTime: new Date(Date.now()),
+ DecoCapacity: 600
+ });
+ await guild.save();
+ }
+
+ const payload: IGetGuildDojoRequest = req.body ? (JSON.parse(String(req.body)) as IGetGuildDojoRequest) : {};
+ const account = await Account.findById(req.query.accountId as string);
+ res.json(await getDojoClient(guild, 0, payload.ComponentId, account?.BuildLabel));
+};
+
+interface IGetGuildDojoRequest {
+ ComponentId?: string;
+}
diff --git a/src/controllers/api/getGuildEventScoreController.ts b/src/controllers/api/getGuildEventScoreController.ts
new file mode 100644
index 00000000..a0b229b5
--- /dev/null
+++ b/src/controllers/api/getGuildEventScoreController.ts
@@ -0,0 +1,26 @@
+import type { RequestHandler } from "express";
+import { getAccountForRequest } from "../../services/loginService.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import { Guild } from "../../models/guildModel.ts";
+
+export const getGuildEventScoreController: RequestHandler = async (req, res) => {
+ const account = await getAccountForRequest(req);
+ const inventory = await getInventory(account._id.toString(), "GuildId");
+ const guild = await Guild.findById(inventory.GuildId);
+ const goalId = req.query.goalId as string;
+ if (guild && guild.GoalProgress && goalId) {
+ const goal = guild.GoalProgress.find(x => x.goalId.toString() == goalId);
+ if (goal) {
+ res.json({
+ Tier: guild.Tier,
+ GoalProgress: {
+ Count: goal.Count,
+ Tag: goal.Tag,
+ _id: { $oid: goal.goalId }
+ }
+ });
+ return;
+ }
+ }
+ res.json({});
+};
diff --git a/src/controllers/api/getGuildLogController.ts b/src/controllers/api/getGuildLogController.ts
new file mode 100644
index 00000000..0e5daf1f
--- /dev/null
+++ b/src/controllers/api/getGuildLogController.ts
@@ -0,0 +1,60 @@
+import { toMongoDate } from "../../helpers/inventoryHelpers.ts";
+import { Guild } from "../../models/guildModel.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { IMongoDate } from "../../types/commonTypes.ts";
+import type { RequestHandler } from "express";
+
+export const getGuildLogController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId, "GuildId");
+ if (inventory.GuildId) {
+ const guild = await Guild.findById(inventory.GuildId);
+ if (guild) {
+ const log: Record = {
+ RoomChanges: [],
+ TechChanges: [],
+ RosterActivity: [],
+ StandingsUpdates: [],
+ ClassChanges: []
+ };
+ guild.RoomChanges?.forEach(entry => {
+ log.RoomChanges.push({
+ dateTime: toMongoDate(entry.dateTime ?? new Date()),
+ entryType: entry.entryType,
+ details: entry.details
+ });
+ });
+ guild.TechChanges?.forEach(entry => {
+ log.TechChanges.push({
+ dateTime: toMongoDate(entry.dateTime ?? new Date()),
+ entryType: entry.entryType,
+ details: entry.details
+ });
+ });
+ guild.RosterActivity?.forEach(entry => {
+ log.RosterActivity.push({
+ dateTime: toMongoDate(entry.dateTime),
+ entryType: entry.entryType,
+ details: entry.details
+ });
+ });
+ guild.ClassChanges?.forEach(entry => {
+ log.ClassChanges.push({
+ dateTime: toMongoDate(entry.dateTime),
+ entryType: entry.entryType,
+ details: entry.details
+ });
+ });
+ res.json(log);
+ return;
+ }
+ }
+ res.sendStatus(200);
+};
+
+interface IGuildLogEntryClient {
+ dateTime: IMongoDate;
+ entryType: number;
+ details: number | string;
+}
diff --git a/src/controllers/api/getIgnoredUsersController.ts b/src/controllers/api/getIgnoredUsersController.ts
new file mode 100644
index 00000000..de46cfec
--- /dev/null
+++ b/src/controllers/api/getIgnoredUsersController.ts
@@ -0,0 +1,20 @@
+import { toOid } from "../../helpers/inventoryHelpers.ts";
+import { Account, Ignore } from "../../models/loginModel.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { IFriendInfo } from "../../types/friendTypes.ts";
+import { parallelForeach } from "../../utils/async-utils.ts";
+import type { RequestHandler } from "express";
+
+export const getIgnoredUsersController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const ignores = await Ignore.find({ ignorer: accountId });
+ const ignoredUsers: IFriendInfo[] = [];
+ await parallelForeach(ignores, async ignore => {
+ const ignoreeAccount = (await Account.findById(ignore.ignoree, "DisplayName"))!;
+ ignoredUsers.push({
+ _id: toOid(ignore.ignoree),
+ DisplayName: ignoreeAccount.DisplayName + ""
+ });
+ });
+ res.json({ IgnoredUsers: ignoredUsers });
+};
diff --git a/src/controllers/api/getNewRewardSeedController.ts b/src/controllers/api/getNewRewardSeedController.ts
new file mode 100644
index 00000000..b069a07e
--- /dev/null
+++ b/src/controllers/api/getNewRewardSeedController.ts
@@ -0,0 +1,19 @@
+import { Inventory } from "../../models/inventoryModels/inventoryModel.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { generateRewardSeed } from "../../services/rngService.ts";
+import type { RequestHandler } from "express";
+
+export const getNewRewardSeedController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+
+ const rewardSeed = generateRewardSeed();
+ await Inventory.updateOne(
+ {
+ accountOwnerId: accountId
+ },
+ {
+ RewardSeed: rewardSeed
+ }
+ );
+ res.json({ rewardSeed: rewardSeed });
+};
diff --git a/src/controllers/api/getPastWeeklyChallengesController.ts b/src/controllers/api/getPastWeeklyChallengesController.ts
new file mode 100644
index 00000000..806198c8
--- /dev/null
+++ b/src/controllers/api/getPastWeeklyChallengesController.ts
@@ -0,0 +1,62 @@
+import type { RequestHandler } from "express";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import { EPOCH, getSeasonChallengePools, getWorldState, pushWeeklyActs } from "../../services/worldStateService.ts";
+import { unixTimesInMs } from "../../constants/timeConstants.ts";
+import type { ISeasonChallenge } from "../../types/worldStateTypes.ts";
+import { ExportChallenges } from "warframe-public-export-plus";
+
+export const getPastWeeklyChallengesController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId, "SeasonChallengeHistory ChallengeProgress");
+ const worldState = getWorldState(undefined);
+
+ if (worldState.SeasonInfo) {
+ const pools = getSeasonChallengePools(worldState.SeasonInfo.AffiliationTag);
+ const nightwaveStartTimestamp = Number(worldState.SeasonInfo.Activation.$date.$numberLong);
+ const nightwaveSeason = worldState.SeasonInfo.Season;
+ const timeMs = worldState.Time * 1000;
+ const completedChallengesIds = new Set();
+
+ inventory.SeasonChallengeHistory.forEach(challengeHistory => {
+ const entryNightwaveSeason = parseInt(challengeHistory.id.slice(0, 4), 10) - 1;
+ if (nightwaveSeason == entryNightwaveSeason) {
+ const meta = Object.entries(ExportChallenges).find(
+ ([key]) => key.split("/").pop() === challengeHistory.challenge
+ );
+ if (meta) {
+ const [, challengeMeta] = meta;
+ const challengeProgress = inventory.ChallengeProgress.find(
+ c => c.Name === challengeHistory.challenge
+ );
+
+ if (challengeProgress && challengeProgress.Progress >= (challengeMeta.requiredCount ?? 1)) {
+ completedChallengesIds.add(challengeHistory.id);
+ }
+ }
+ }
+ });
+
+ const PastWeeklyChallenges: ISeasonChallenge[] = [];
+
+ let week = Math.trunc((timeMs - EPOCH) / unixTimesInMs.week) - 1;
+
+ while (EPOCH + week * unixTimesInMs.week >= nightwaveStartTimestamp && PastWeeklyChallenges.length < 3) {
+ const tempActs: ISeasonChallenge[] = [];
+ pushWeeklyActs(tempActs, pools, week, nightwaveStartTimestamp, nightwaveSeason);
+
+ for (const act of tempActs) {
+ if (!completedChallengesIds.has(act._id.$oid) && PastWeeklyChallenges.length < 3) {
+ if (act.Challenge.startsWith("/Lotus/Types/Challenges/Seasons/Weekly/SeasonWeeklyPermanent")) {
+ act.Permanent = true;
+ }
+ PastWeeklyChallenges.push(act);
+ }
+ }
+
+ week--;
+ }
+
+ res.json({ PastWeeklyChallenges: PastWeeklyChallenges });
+ }
+};
diff --git a/src/controllers/api/getShipController.ts b/src/controllers/api/getShipController.ts
new file mode 100644
index 00000000..3166655c
--- /dev/null
+++ b/src/controllers/api/getShipController.ts
@@ -0,0 +1,39 @@
+import type { RequestHandler } from "express";
+import { config } from "../../services/configService.ts";
+import allShipFeatures from "../../../static/fixed_responses/allShipFeatures.json" with { type: "json" };
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { createGarden, getPersonalRooms } from "../../services/personalRoomsService.ts";
+import type { IGetShipResponse, IPersonalRoomsClient } from "../../types/personalRoomsTypes.ts";
+import { getLoadout } from "../../services/loadoutService.ts";
+import { toOid } from "../../helpers/inventoryHelpers.ts";
+
+export const getShipController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const personalRoomsDb = await getPersonalRooms(accountId);
+
+ // Setup gardening if it's missing. Maybe should be done as part of some quest completion in the future.
+ if (personalRoomsDb.Apartment.Gardening.Planters.length == 0) {
+ personalRoomsDb.Apartment.Gardening = createGarden();
+ await personalRoomsDb.save();
+ }
+
+ const personalRooms = personalRoomsDb.toJSON();
+ const loadout = await getLoadout(accountId);
+
+ const getShipResponse: IGetShipResponse = {
+ ShipOwnerId: accountId,
+ LoadOutInventory: { LoadOutPresets: loadout.toJSON() },
+ Ship: {
+ ...personalRooms.Ship,
+ ShipId: toOid(personalRoomsDb.activeShipId)
+ },
+ Apartment: personalRooms.Apartment,
+ TailorShop: personalRooms.TailorShop
+ };
+
+ if (config.unlockAllShipFeatures) {
+ getShipResponse.Ship.Features = allShipFeatures;
+ }
+
+ res.json(getShipResponse);
+};
diff --git a/src/controllers/api/getVendorInfoController.ts b/src/controllers/api/getVendorInfoController.ts
new file mode 100644
index 00000000..52a3f5cb
--- /dev/null
+++ b/src/controllers/api/getVendorInfoController.ts
@@ -0,0 +1,29 @@
+import type { RequestHandler } from "express";
+import { applyStandingToVendorManifest, getVendorManifestByTypeName } from "../../services/serversideVendorsService.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { config } from "../../services/configService.ts";
+
+export const getVendorInfoController: RequestHandler = async (req, res) => {
+ let manifest = getVendorManifestByTypeName(req.query.vendor as string);
+ if (!manifest) {
+ throw new Error(`Unknown vendor: ${req.query.vendor as string}`);
+ }
+
+ // For testing purposes, authenticating with this endpoint is optional here, but would be required on live.
+ if (req.query.accountId) {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId);
+ manifest = applyStandingToVendorManifest(inventory, manifest);
+ if (config.dev?.keepVendorsExpired) {
+ manifest = {
+ VendorInfo: {
+ ...manifest.VendorInfo,
+ Expiry: { $date: { $numberLong: "0" } }
+ }
+ };
+ }
+ }
+
+ res.json(manifest);
+};
diff --git a/src/controllers/api/getVoidProjectionRewardsController.ts b/src/controllers/api/getVoidProjectionRewardsController.ts
new file mode 100644
index 00000000..46e960e0
--- /dev/null
+++ b/src/controllers/api/getVoidProjectionRewardsController.ts
@@ -0,0 +1,38 @@
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { crackRelic } from "../../helpers/relicHelper.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { IVoidTearParticipantInfo } from "../../types/requestTypes.ts";
+import type { RequestHandler } from "express";
+
+export const getVoidProjectionRewardsController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const data = getJSONfromString(String(req.body));
+
+ if (data.ParticipantInfo.QualifiesForReward && !data.ParticipantInfo.HaveRewardResponse) {
+ const inventory = await getInventory(accountId);
+ await crackRelic(inventory, data.ParticipantInfo);
+ await inventory.save();
+ }
+
+ const response: IVoidProjectionRewardResponse = {
+ CurrentWave: data.CurrentWave,
+ ParticipantInfo: data.ParticipantInfo,
+ DifficultyTier: data.DifficultyTier
+ };
+ res.json(response);
+};
+
+interface IVoidProjectionRewardRequest {
+ CurrentWave: number;
+ ParticipantInfo: IVoidTearParticipantInfo;
+ VoidTier: string;
+ DifficultyTier: number;
+ VoidProjectionRemovalHash: string;
+}
+
+interface IVoidProjectionRewardResponse {
+ CurrentWave: number;
+ ParticipantInfo: IVoidTearParticipantInfo;
+ DifficultyTier: number;
+}
diff --git a/src/controllers/api/giftingController.ts b/src/controllers/api/giftingController.ts
new file mode 100644
index 00000000..9de09482
--- /dev/null
+++ b/src/controllers/api/giftingController.ts
@@ -0,0 +1,126 @@
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { Account } from "../../models/loginModel.ts";
+import { areFriends } from "../../services/friendService.ts";
+import { createMessage } from "../../services/inboxService.ts";
+import {
+ combineInventoryChanges,
+ getEffectiveAvatarImageType,
+ getInventory,
+ updateCurrency
+} from "../../services/inventoryService.ts";
+import { getAccountForRequest, getSuffixedName } from "../../services/loginService.ts";
+import { handleDailyDealPurchase, handleStoreItemAcquisition } from "../../services/purchaseService.ts";
+import type { IOid } from "../../types/commonTypes.ts";
+import type { IPurchaseParams, IPurchaseResponse } from "../../types/purchaseTypes.ts";
+import { PurchaseSource } from "../../types/purchaseTypes.ts";
+import type { RequestHandler } from "express";
+import { ExportBundles, ExportFlavour } from "warframe-public-export-plus";
+
+const checkPurchaseParams = (params: IPurchaseParams): boolean => {
+ switch (params.Source) {
+ case PurchaseSource.Market:
+ return params.UsePremium;
+
+ case PurchaseSource.DailyDeal:
+ return true;
+ }
+ return false;
+};
+
+export const giftingController: RequestHandler = async (req, res) => {
+ const data = getJSONfromString(String(req.body));
+ if (!checkPurchaseParams(data.PurchaseParams)) {
+ throw new Error(`unexpected purchase params in gifting request: ${String(req.body)}`);
+ }
+
+ const account = await Account.findOne(
+ data.RecipientId ? { _id: data.RecipientId.$oid } : { DisplayName: data.Recipient }
+ );
+ if (!account) {
+ res.status(400).send("9").end();
+ return;
+ }
+ const inventory = await getInventory(account._id.toString(), "Suits Settings");
+
+ // Cannot gift items to players that have not completed the tutorial.
+ if (inventory.Suits.length == 0) {
+ res.status(400).send("14").end();
+ return;
+ }
+
+ // Cannot gift to players who have gifting disabled.
+ const senderAccount = await getAccountForRequest(req);
+ if (
+ inventory.Settings?.GiftMode == "GIFT_MODE_NONE" ||
+ (inventory.Settings?.GiftMode == "GIFT_MODE_FRIENDS" && !(await areFriends(account._id, senderAccount._id)))
+ ) {
+ res.status(400).send("17").end();
+ return;
+ }
+
+ // TODO: Cannot gift items with mastery requirement to players who are too low level. (Code 2)
+ // TODO: Cannot gift archwing items to players that have not completed the archwing quest. (Code 7)
+ // TODO: Cannot gift necramechs to players that have not completed heart of deimos. (Code 20)
+
+ const senderInventory = await getInventory(senderAccount._id.toString());
+
+ if (senderInventory.GiftsRemaining == 0) {
+ res.status(400).send("10").end();
+ return;
+ }
+ senderInventory.GiftsRemaining -= 1;
+
+ const response: IPurchaseResponse = {
+ InventoryChanges: {}
+ };
+ if (data.PurchaseParams.Source == PurchaseSource.DailyDeal) {
+ await handleDailyDealPurchase(senderInventory, data.PurchaseParams, response);
+ } else {
+ updateCurrency(senderInventory, data.PurchaseParams.ExpectedPrice, true, response.InventoryChanges);
+ }
+ if (data.PurchaseParams.StoreItem in ExportBundles) {
+ const bundle = ExportBundles[data.PurchaseParams.StoreItem];
+ if (bundle.giftingBonus) {
+ combineInventoryChanges(
+ response.InventoryChanges,
+ (await handleStoreItemAcquisition(bundle.giftingBonus, senderInventory)).InventoryChanges
+ );
+ }
+ }
+ await senderInventory.save();
+
+ const senderName = getSuffixedName(senderAccount);
+ await createMessage(account._id, [
+ {
+ sndr: senderName,
+ msg: data.Message || "/Lotus/Language/Menu/GiftReceivedBody_NoCustomMessage",
+ arg: [
+ {
+ Key: "GIFTER_NAME",
+ Tag: senderName
+ },
+ {
+ Key: "GIFT_QUANTITY",
+ Tag: data.PurchaseParams.Quantity
+ }
+ ],
+ sub: "/Lotus/Language/Menu/GiftReceivedSubject",
+ icon: ExportFlavour[getEffectiveAvatarImageType(senderInventory)].icon,
+ gifts: [
+ {
+ GiftType: data.PurchaseParams.StoreItem
+ }
+ ]
+ }
+ ]);
+
+ res.json(response);
+};
+
+interface IGiftingRequest {
+ PurchaseParams: IPurchaseParams;
+ Message?: string;
+ Recipient?: string;
+ RecipientId?: IOid;
+ buildLabel: string;
+}
diff --git a/src/controllers/api/gildWeaponController.ts b/src/controllers/api/gildWeaponController.ts
new file mode 100644
index 00000000..0bd921b3
--- /dev/null
+++ b/src/controllers/api/gildWeaponController.ts
@@ -0,0 +1,79 @@
+import type { RequestHandler } from "express";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { sendWsBroadcastTo } from "../../services/wsService.ts";
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { addMiscItems, getInventory } from "../../services/inventoryService.ts";
+import type { TEquipmentKey } from "../../types/inventoryTypes/inventoryTypes.ts";
+import type { ArtifactPolarity } from "../../types/inventoryTypes/commonInventoryTypes.ts";
+import { ExportRecipes } from "warframe-public-export-plus";
+import type { IInventoryChanges } from "../../types/purchaseTypes.ts";
+import type { IEquipmentClient } from "../../types/equipmentTypes.ts";
+import { EquipmentFeatures } from "../../types/equipmentTypes.ts";
+
+interface IGildWeaponRequest {
+ ItemName: string;
+ Recipe: string; // e.g. /Lotus/Weapons/SolarisUnited/LotusGildKitgunBlueprint
+ PolarizeSlot?: number;
+ PolarizeValue?: ArtifactPolarity;
+ ItemId: string;
+ Category: TEquipmentKey;
+}
+
+export const gildWeaponController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const data = getJSONfromString(String(req.body));
+ data.ItemId = String(req.query.ItemId);
+ data.Category = req.query.Category as TEquipmentKey;
+
+ const inventory = await getInventory(accountId);
+ const weaponIndex = inventory[data.Category].findIndex(x => String(x._id) === data.ItemId);
+ if (weaponIndex === -1) {
+ throw new Error(`Weapon with ${data.ItemId} not found in category ${String(req.query.Category)}`);
+ }
+
+ const weapon = inventory[data.Category][weaponIndex];
+ weapon.Features ??= 0;
+ weapon.Features |= EquipmentFeatures.GILDED;
+ if (data.Recipe != "webui") {
+ weapon.ItemName = data.ItemName;
+ weapon.XP = 0;
+ }
+ if (data.Category != "OperatorAmps" && data.PolarizeSlot && data.PolarizeValue) {
+ weapon.Polarity = [
+ {
+ Slot: data.PolarizeSlot,
+ Value: data.PolarizeValue
+ }
+ ];
+ }
+ inventory[data.Category][weaponIndex] = weapon;
+ const inventoryChanges: IInventoryChanges = {};
+ inventoryChanges[data.Category] = [weapon.toJSON()];
+
+ const affiliationMods = [];
+
+ if (data.Recipe != "webui") {
+ const recipe = ExportRecipes[data.Recipe];
+ inventoryChanges.MiscItems = recipe.secretIngredients!.map(ingredient => ({
+ ItemType: ingredient.ItemType,
+ ItemCount: ingredient.ItemCount * -1
+ }));
+ addMiscItems(inventory, inventoryChanges.MiscItems);
+
+ if (recipe.syndicateStandingChange) {
+ const affiliation = inventory.Affiliations.find(x => x.Tag == recipe.syndicateStandingChange!.tag)!;
+ affiliation.Standing += recipe.syndicateStandingChange.value;
+ affiliationMods.push({
+ Tag: recipe.syndicateStandingChange.tag,
+ Standing: recipe.syndicateStandingChange.value
+ });
+ }
+ }
+
+ await inventory.save();
+ res.json({
+ InventoryChanges: inventoryChanges,
+ AffiliationMods: affiliationMods
+ });
+ sendWsBroadcastTo(accountId, { update_inventory: true });
+};
diff --git a/src/controllers/api/giveKeyChainTriggeredItemsController.ts b/src/controllers/api/giveKeyChainTriggeredItemsController.ts
new file mode 100644
index 00000000..22d2fccc
--- /dev/null
+++ b/src/controllers/api/giveKeyChainTriggeredItemsController.ts
@@ -0,0 +1,17 @@
+import type { RequestHandler } from "express";
+import { parseString } from "../../helpers/general.ts";
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import { giveKeyChainItem } from "../../services/questService.ts";
+import type { IKeyChainRequest } from "../../types/requestTypes.ts";
+
+export const giveKeyChainTriggeredItemsController: RequestHandler = async (req, res) => {
+ const accountId = parseString(req.query.accountId);
+ const keyChainInfo = getJSONfromString((req.body as string).toString());
+
+ const inventory = await getInventory(accountId);
+ const inventoryChanges = await giveKeyChainItem(inventory, keyChainInfo);
+ await inventory.save();
+
+ res.send(inventoryChanges);
+};
diff --git a/src/controllers/api/giveKeyChainTriggeredMessageController.ts b/src/controllers/api/giveKeyChainTriggeredMessageController.ts
new file mode 100644
index 00000000..e4d243be
--- /dev/null
+++ b/src/controllers/api/giveKeyChainTriggeredMessageController.ts
@@ -0,0 +1,16 @@
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { giveKeyChainMessage } from "../../services/questService.ts";
+import type { IKeyChainRequest } from "../../types/requestTypes.ts";
+import type { RequestHandler } from "express";
+
+export const giveKeyChainTriggeredMessageController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const keyChainInfo = JSON.parse((req.body as Buffer).toString()) as IKeyChainRequest;
+
+ const inventory = await getInventory(accountId, "QuestKeys");
+ await giveKeyChainMessage(inventory, accountId, keyChainInfo);
+ await inventory.save();
+
+ res.send(1);
+};
diff --git a/src/controllers/api/giveQuestKeyRewardController.ts b/src/controllers/api/giveQuestKeyRewardController.ts
new file mode 100644
index 00000000..9cbdfd40
--- /dev/null
+++ b/src/controllers/api/giveQuestKeyRewardController.ts
@@ -0,0 +1,45 @@
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { addItem, getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { IOid } from "../../types/commonTypes.ts";
+import type { RequestHandler } from "express";
+
+export const giveQuestKeyRewardController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const rewardRequest = getJSONfromString((req.body as Buffer).toString());
+
+ if (Array.isArray(rewardRequest.reward)) {
+ throw new Error("Multiple rewards not expected");
+ }
+
+ const reward = rewardRequest.reward;
+ const inventory = await getInventory(accountId);
+ const inventoryChanges = await addItem(inventory, reward.ItemType, reward.Amount);
+ await inventory.save();
+ res.json(inventoryChanges);
+ //TODO: consider whishlist changes
+};
+
+interface IQuestKeyRewardRequest {
+ reward: IQuestKeyReward;
+}
+
+interface IQuestKeyReward {
+ RewardType: string;
+ CouponType: string;
+ Icon: string;
+ ItemType: string;
+ StoreItemType: string;
+ ProductCategory: string;
+ Amount: number;
+ ScalingMultiplier: number;
+ Durability: string;
+ DisplayName: string;
+ Duration: number;
+ CouponSku: number;
+ Syndicate: string;
+ //Milestones: any[];
+ ChooseSetIndex: number;
+ NewSystemReward: boolean;
+ _id: IOid;
+}
diff --git a/src/controllers/api/giveShipDecoAndLoreFragmentController.ts b/src/controllers/api/giveShipDecoAndLoreFragmentController.ts
new file mode 100644
index 00000000..a61d27cf
--- /dev/null
+++ b/src/controllers/api/giveShipDecoAndLoreFragmentController.ts
@@ -0,0 +1,21 @@
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { addLoreFragmentScans, addShipDecorations, getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { ITypeCount } from "../../types/commonTypes.ts";
+import type { ILoreFragmentScan } from "../../types/inventoryTypes/inventoryTypes.ts";
+import type { RequestHandler } from "express";
+
+export const giveShipDecoAndLoreFragmentController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId, "LoreFragmentScans ShipDecorations");
+ const data = getJSONfromString(String(req.body));
+ addLoreFragmentScans(inventory, data.LoreFragmentScans);
+ addShipDecorations(inventory, data.ShipDecorations);
+ await inventory.save();
+ res.end();
+};
+
+interface IGiveShipDecoAndLoreFragmentRequest {
+ LoreFragmentScans: ILoreFragmentScan[];
+ ShipDecorations: ITypeCount[];
+}
diff --git a/src/controllers/api/giveStartingGearController.ts b/src/controllers/api/giveStartingGearController.ts
new file mode 100644
index 00000000..cd964ef1
--- /dev/null
+++ b/src/controllers/api/giveStartingGearController.ts
@@ -0,0 +1,16 @@
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { addStartingGear, getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { TPartialStartingGear } from "../../types/inventoryTypes/inventoryTypes.ts";
+import type { RequestHandler } from "express";
+
+export const giveStartingGearController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const startingGear = getJSONfromString(String(req.body));
+ const inventory = await getInventory(accountId);
+
+ const inventoryChanges = await addStartingGear(inventory, startingGear);
+ await inventory.save();
+
+ res.send(inventoryChanges);
+};
diff --git a/src/controllers/api/guildTechController.ts b/src/controllers/api/guildTechController.ts
new file mode 100644
index 00000000..84bce3b2
--- /dev/null
+++ b/src/controllers/api/guildTechController.ts
@@ -0,0 +1,465 @@
+import type { RequestHandler } from "express";
+import {
+ addGuildMemberMiscItemContribution,
+ getGuildForRequestEx,
+ getGuildVault,
+ hasAccessToDojo,
+ hasGuildPermission,
+ processCompletedGuildTechProject,
+ processFundedGuildTechProject,
+ processGuildTechProjectContributionsUpdate,
+ removePigmentsFromGuildMembers,
+ scaleRequiredCount,
+ setGuildTechLogState
+} from "../../services/guildService.ts";
+import { ExportDojoRecipes, ExportRailjackWeapons } from "warframe-public-export-plus";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import {
+ addCrewShipWeaponSkin,
+ addEquipment,
+ addItem,
+ addMiscItems,
+ addRecipes,
+ combineInventoryChanges,
+ getInventory,
+ occupySlot,
+ updateCurrency
+} from "../../services/inventoryService.ts";
+import type { IMiscItem } from "../../types/inventoryTypes/inventoryTypes.ts";
+import { InventorySlot } from "../../types/inventoryTypes/inventoryTypes.ts";
+import type { IInventoryChanges } from "../../types/purchaseTypes.ts";
+import { config } from "../../services/configService.ts";
+import type { ITechProjectClient } from "../../types/guildTypes.ts";
+import { GuildPermission } from "../../types/guildTypes.ts";
+import { GuildMember } from "../../models/guildModel.ts";
+import { toMongoDate, toOid } from "../../helpers/inventoryHelpers.ts";
+import { logger } from "../../utils/logger.ts";
+import type { TInventoryDatabaseDocument } from "../../models/inventoryModels/inventoryModel.ts";
+
+export const guildTechController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId);
+ const data = JSON.parse(String(req.body)) as TGuildTechRequest;
+ if (data.Action == "Sync") {
+ let needSave = false;
+ const techProjects: ITechProjectClient[] = [];
+ const guild = await getGuildForRequestEx(req, inventory);
+ if (guild.TechProjects) {
+ for (const project of guild.TechProjects) {
+ const techProject: ITechProjectClient = {
+ ItemType: project.ItemType,
+ ReqCredits: project.ReqCredits,
+ ReqItems: project.ReqItems,
+ State: project.State
+ };
+ if (project.CompletionDate) {
+ techProject.CompletionDate = toMongoDate(project.CompletionDate);
+ if (
+ Date.now() >= project.CompletionDate.getTime() &&
+ setGuildTechLogState(guild, project.ItemType, 4, project.CompletionDate)
+ ) {
+ processCompletedGuildTechProject(guild, project.ItemType);
+ needSave = true;
+ }
+ }
+ techProjects.push(techProject);
+ }
+ }
+ if (needSave) {
+ await guild.save();
+ }
+ res.json({ TechProjects: techProjects });
+ } else if (data.Action == "Start") {
+ if (data.Mode == "Guild") {
+ const guild = await getGuildForRequestEx(req, inventory);
+ if (!hasAccessToDojo(inventory) || !(await hasGuildPermission(guild, accountId, GuildPermission.Tech))) {
+ res.status(400).send("-1").end();
+ return;
+ }
+ const recipe = ExportDojoRecipes.research[data.RecipeType];
+ guild.TechProjects ??= [];
+ if (!guild.TechProjects.find(x => x.ItemType == data.RecipeType)) {
+ const techProject =
+ guild.TechProjects[
+ guild.TechProjects.push({
+ ItemType: data.RecipeType,
+ ReqCredits: config.noDojoResearchCosts ? 0 : scaleRequiredCount(guild.Tier, recipe.price),
+ ReqItems: recipe.ingredients.map(x => ({
+ ItemType: x.ItemType,
+ ItemCount: config.noDojoResearchCosts ? 0 : scaleRequiredCount(guild.Tier, x.ItemCount)
+ })),
+ State: 0
+ }) - 1
+ ];
+ setGuildTechLogState(guild, techProject.ItemType, 5);
+ if (config.noDojoResearchCosts) {
+ processFundedGuildTechProject(guild, techProject, recipe);
+ } else {
+ if (data.RecipeType.substring(0, 39) == "/Lotus/Types/Items/Research/DojoColors/") {
+ guild.ActiveDojoColorResearch = data.RecipeType;
+ }
+ }
+ }
+ await guild.save();
+ res.end();
+ } else {
+ const recipe = ExportDojoRecipes.research[data.RecipeType];
+ if (data.TechProductCategory) {
+ if (
+ data.TechProductCategory != "CrewShipWeapons" &&
+ data.TechProductCategory != "CrewShipWeaponSkins"
+ ) {
+ throw new Error(`unexpected TechProductCategory: ${data.TechProductCategory}`);
+ }
+ if (!inventory[getSalvageCategory(data.TechProductCategory)].id(data.CategoryItemId!)) {
+ throw new Error(
+ `no item with id ${data.CategoryItemId} in ${getSalvageCategory(data.TechProductCategory)} array`
+ );
+ }
+ }
+ const techProject =
+ inventory.PersonalTechProjects[
+ inventory.PersonalTechProjects.push({
+ State: 0,
+ ReqCredits: recipe.price,
+ ItemType: data.RecipeType,
+ ProductCategory: data.TechProductCategory,
+ CategoryItemId: data.CategoryItemId,
+ ReqItems: recipe.ingredients
+ }) - 1
+ ];
+ await inventory.save();
+ res.json({
+ isPersonal: true,
+ action: "Start",
+ personalTech: techProject.toJSON()
+ });
+ }
+ } else if (data.Action == "Contribute") {
+ if ((req.query.guildId as string) == "000000000000000000000000") {
+ const techProject = inventory.PersonalTechProjects.id(data.ResearchId)!;
+
+ techProject.ReqCredits -= data.RegularCredits;
+ const inventoryChanges: IInventoryChanges = updateCurrency(inventory, data.RegularCredits, false);
+
+ const miscItemChanges = [];
+ for (const miscItem of data.MiscItems) {
+ const reqItem = techProject.ReqItems.find(x => x.ItemType == miscItem.ItemType);
+ if (reqItem) {
+ if (miscItem.ItemCount > reqItem.ItemCount) {
+ miscItem.ItemCount = reqItem.ItemCount;
+ }
+ reqItem.ItemCount -= miscItem.ItemCount;
+ miscItemChanges.push({
+ ItemType: miscItem.ItemType,
+ ItemCount: miscItem.ItemCount * -1
+ });
+ }
+ }
+ addMiscItems(inventory, miscItemChanges);
+ inventoryChanges.MiscItems = miscItemChanges;
+
+ techProject.HasContributions = true;
+
+ if (techProject.ReqCredits == 0 && !techProject.ReqItems.find(x => x.ItemCount > 0)) {
+ techProject.State = 1;
+ const recipe = ExportDojoRecipes.research[techProject.ItemType];
+ techProject.CompletionDate = new Date(Date.now() + recipe.time * 1000);
+ }
+
+ await inventory.save();
+ res.json({
+ InventoryChanges: inventoryChanges,
+ PersonalResearch: { $oid: data.ResearchId },
+ PersonalResearchDate: techProject.CompletionDate ? toMongoDate(techProject.CompletionDate) : undefined
+ });
+ } else {
+ if (!hasAccessToDojo(inventory)) {
+ res.status(400).send("-1").end();
+ return;
+ }
+
+ const guild = await getGuildForRequestEx(req, inventory);
+ const guildMember = (await GuildMember.findOne(
+ { accountId, guildId: guild._id },
+ "RegularCreditsContributed MiscItemsContributed"
+ ))!;
+
+ const techProject = guild.TechProjects!.find(x => x.ItemType == data.RecipeType)!;
+
+ if (data.VaultCredits) {
+ if (data.VaultCredits > techProject.ReqCredits) {
+ data.VaultCredits = techProject.ReqCredits;
+ }
+ techProject.ReqCredits -= data.VaultCredits;
+ guild.VaultRegularCredits! -= data.VaultCredits;
+ }
+
+ if (data.RegularCredits > techProject.ReqCredits) {
+ data.RegularCredits = techProject.ReqCredits;
+ }
+ techProject.ReqCredits -= data.RegularCredits;
+
+ guildMember.RegularCreditsContributed ??= 0;
+ guildMember.RegularCreditsContributed += data.RegularCredits;
+
+ if (data.VaultMiscItems.length) {
+ for (const miscItem of data.VaultMiscItems) {
+ const reqItem = techProject.ReqItems.find(x => x.ItemType == miscItem.ItemType);
+ if (reqItem) {
+ if (miscItem.ItemCount > reqItem.ItemCount) {
+ miscItem.ItemCount = reqItem.ItemCount;
+ }
+ reqItem.ItemCount -= miscItem.ItemCount;
+
+ const vaultMiscItem = guild.VaultMiscItems!.find(x => x.ItemType == miscItem.ItemType)!;
+ vaultMiscItem.ItemCount -= miscItem.ItemCount;
+ }
+ }
+ }
+
+ const miscItemChanges = [];
+ for (const miscItem of data.MiscItems) {
+ const reqItem = techProject.ReqItems.find(x => x.ItemType == miscItem.ItemType);
+ if (reqItem) {
+ if (miscItem.ItemCount > reqItem.ItemCount) {
+ miscItem.ItemCount = reqItem.ItemCount;
+ }
+ reqItem.ItemCount -= miscItem.ItemCount;
+ miscItemChanges.push({
+ ItemType: miscItem.ItemType,
+ ItemCount: miscItem.ItemCount * -1
+ });
+
+ addGuildMemberMiscItemContribution(guildMember, miscItem);
+ }
+ }
+ addMiscItems(inventory, miscItemChanges);
+ const inventoryChanges: IInventoryChanges = updateCurrency(inventory, data.RegularCredits, false);
+ inventoryChanges.MiscItems = miscItemChanges;
+
+ // Check if research is fully funded now.
+ await processGuildTechProjectContributionsUpdate(guild, techProject);
+
+ await Promise.all([guild.save(), inventory.save(), guildMember.save()]);
+ res.json({
+ InventoryChanges: inventoryChanges,
+ Vault: getGuildVault(guild)
+ });
+ }
+ } else if (data.Action.split(",")[0] == "Buy") {
+ const purchase = data as IGuildTechBuyRequest;
+ if (purchase.Mode == "Guild") {
+ const guild = await getGuildForRequestEx(req, inventory);
+ if (
+ !hasAccessToDojo(inventory) ||
+ !(await hasGuildPermission(guild, accountId, GuildPermission.Fabricator))
+ ) {
+ res.status(400).send("-1").end();
+ return;
+ }
+ const quantity = parseInt(data.Action.split(",")[1]);
+ const recipeChanges = [
+ {
+ ItemType: purchase.RecipeType,
+ ItemCount: quantity
+ }
+ ];
+ addRecipes(inventory, recipeChanges);
+ const currencyChanges = updateCurrency(
+ inventory,
+ ExportDojoRecipes.research[purchase.RecipeType].replicatePrice,
+ false
+ );
+ await inventory.save();
+ // Not a mistake: This response uses `inventoryChanges` instead of `InventoryChanges`.
+ res.json({
+ inventoryChanges: {
+ ...currencyChanges,
+ Recipes: recipeChanges
+ }
+ });
+ } else {
+ const inventoryChanges = claimSalvagedComponent(inventory, purchase.CategoryItemId!);
+ await inventory.save();
+ res.json({
+ inventoryChanges: inventoryChanges
+ });
+ }
+ } else if (data.Action == "Fabricate") {
+ const guild = await getGuildForRequestEx(req, inventory);
+ if (!hasAccessToDojo(inventory) || !(await hasGuildPermission(guild, accountId, GuildPermission.Fabricator))) {
+ res.status(400).send("-1").end();
+ return;
+ }
+ const recipe = ExportDojoRecipes.fabrications[data.RecipeType];
+ const inventoryChanges: IInventoryChanges = updateCurrency(inventory, recipe.price, false);
+ inventoryChanges.MiscItems = recipe.ingredients.map(x => ({
+ ItemType: x.ItemType,
+ ItemCount: x.ItemCount * -1
+ }));
+ addMiscItems(inventory, inventoryChanges.MiscItems);
+ combineInventoryChanges(inventoryChanges, await addItem(inventory, recipe.resultType));
+ await inventory.save();
+ // Not a mistake: This response uses `inventoryChanges` instead of `InventoryChanges`.
+ res.json({ inventoryChanges: inventoryChanges });
+ } else if (data.Action == "Pause") {
+ const guild = await getGuildForRequestEx(req, inventory);
+ if (!hasAccessToDojo(inventory) || !(await hasGuildPermission(guild, accountId, GuildPermission.Tech))) {
+ res.status(400).send("-1").end();
+ return;
+ }
+ const project = guild.TechProjects!.find(x => x.ItemType == data.RecipeType)!;
+ project.State = -2;
+ guild.ActiveDojoColorResearch = "";
+ await guild.save();
+ await removePigmentsFromGuildMembers(guild._id);
+ res.end();
+ } else if (data.Action == "Unpause") {
+ const guild = await getGuildForRequestEx(req, inventory);
+ if (!hasAccessToDojo(inventory) || !(await hasGuildPermission(guild, accountId, GuildPermission.Tech))) {
+ res.status(400).send("-1").end();
+ return;
+ }
+ const project = guild.TechProjects!.find(x => x.ItemType == data.RecipeType)!;
+ project.State = 0;
+ guild.ActiveDojoColorResearch = data.RecipeType;
+ await guild.save();
+ res.end();
+ } else if (data.Action == "Cancel" && data.CategoryItemId) {
+ const personalTechProjectIndex = inventory.PersonalTechProjects.findIndex(x =>
+ x.CategoryItemId?.equals(data.CategoryItemId)
+ );
+ const personalTechProject = inventory.PersonalTechProjects[personalTechProjectIndex];
+ inventory.PersonalTechProjects.splice(personalTechProjectIndex, 1);
+
+ const meta = ExportDojoRecipes.research[personalTechProject.ItemType];
+ const contributedCredits = meta.price - personalTechProject.ReqCredits;
+ const inventoryChanges = updateCurrency(inventory, contributedCredits * -1, false);
+ inventoryChanges.MiscItems = [];
+ for (const ingredient of meta.ingredients) {
+ const reqItem = personalTechProject.ReqItems.find(x => x.ItemType == ingredient.ItemType);
+ if (reqItem) {
+ const contributedItems = ingredient.ItemCount - reqItem.ItemCount;
+ inventoryChanges.MiscItems.push({
+ ItemType: ingredient.ItemType,
+ ItemCount: contributedItems
+ });
+ }
+ }
+ addMiscItems(inventory, inventoryChanges.MiscItems);
+
+ await inventory.save();
+ res.json({
+ action: "Cancel",
+ isPersonal: true,
+ inventoryChanges: inventoryChanges,
+ personalTech: {
+ ItemId: toOid(personalTechProject._id)
+ }
+ });
+ } else if (data.Action == "Rush" && data.CategoryItemId) {
+ const inventoryChanges: IInventoryChanges = {
+ ...updateCurrency(inventory, 20, true),
+ ...claimSalvagedComponent(inventory, data.CategoryItemId)
+ };
+ await inventory.save();
+ res.json({
+ inventoryChanges: inventoryChanges
+ });
+ } else if (data.Action == "InstantFinish") {
+ if (data.TechProductCategory != "CrewShipWeapons" && data.TechProductCategory != "CrewShipWeaponSkins") {
+ throw new Error(`unexpected TechProductCategory: ${data.TechProductCategory}`);
+ }
+ const inventoryChanges = finishComponentRepair(inventory, data.TechProductCategory, data.CategoryItemId!);
+ inventoryChanges.MiscItems = [
+ {
+ ItemType: "/Lotus/Types/Items/MiscItems/InstantSalvageRepairItem",
+ ItemCount: -1
+ }
+ ];
+ addMiscItems(inventory, inventoryChanges.MiscItems);
+ await inventory.save();
+ res.json({
+ inventoryChanges: inventoryChanges
+ });
+ } else {
+ logger.debug(`data provided to ${req.path}: ${String(req.body)}`);
+ throw new Error(`unhandled guildTech request`);
+ }
+};
+
+type TGuildTechRequest =
+ | { Action: "Sync" | "SomethingElseThatWeMightNotKnowAbout" }
+ | IGuildTechBasicRequest
+ | IGuildTechContributeRequest;
+
+interface IGuildTechBasicRequest {
+ Action: "Start" | "Fabricate" | "Pause" | "Unpause" | "Cancel" | "Rush" | "InstantFinish";
+ Mode: "Guild" | "Personal";
+ RecipeType: string;
+ TechProductCategory?: string;
+ CategoryItemId?: string;
+}
+
+interface IGuildTechBuyRequest extends Omit {
+ Action: string;
+}
+
+interface IGuildTechContributeRequest {
+ Action: "Contribute";
+ ResearchId: string;
+ RecipeType: string;
+ RegularCredits: number;
+ MiscItems: IMiscItem[];
+ VaultCredits: number;
+ VaultMiscItems: IMiscItem[];
+}
+
+const getSalvageCategory = (
+ category: "CrewShipWeapons" | "CrewShipWeaponSkins"
+): "CrewShipSalvagedWeapons" | "CrewShipSalvagedWeaponSkins" => {
+ return category == "CrewShipWeapons" ? "CrewShipSalvagedWeapons" : "CrewShipSalvagedWeaponSkins";
+};
+
+const claimSalvagedComponent = (inventory: TInventoryDatabaseDocument, itemId: string): IInventoryChanges => {
+ // delete personal tech project
+ const personalTechProjectIndex = inventory.PersonalTechProjects.findIndex(x => x.CategoryItemId?.equals(itemId));
+ const personalTechProject = inventory.PersonalTechProjects[personalTechProjectIndex];
+ inventory.PersonalTechProjects.splice(personalTechProjectIndex, 1);
+
+ const category = personalTechProject.ProductCategory! as "CrewShipWeapons" | "CrewShipWeaponSkins";
+ return finishComponentRepair(inventory, category, itemId);
+};
+
+const finishComponentRepair = (
+ inventory: TInventoryDatabaseDocument,
+ category: "CrewShipWeapons" | "CrewShipWeaponSkins",
+ itemId: string
+): IInventoryChanges => {
+ const salvageCategory = getSalvageCategory(category);
+
+ // find salved part & delete it
+ const salvageIndex = inventory[salvageCategory].findIndex(x => x._id.equals(itemId));
+ const salvageItem = inventory[salvageCategory][salvageIndex];
+ inventory[salvageCategory].splice(salvageIndex, 1);
+
+ // add final item
+ const inventoryChanges = {
+ ...(category == "CrewShipWeaponSkins"
+ ? addCrewShipWeaponSkin(inventory, salvageItem.ItemType, salvageItem.UpgradeFingerprint)
+ : addEquipment(inventory, category, salvageItem.ItemType, {
+ UpgradeType: ExportRailjackWeapons[salvageItem.ItemType].defaultUpgrades?.[0].ItemType,
+ UpgradeFingerprint: salvageItem.UpgradeFingerprint
+ })),
+ ...occupySlot(inventory, InventorySlot.RJ_COMPONENT_AND_ARMAMENTS, false)
+ };
+
+ inventoryChanges.RemovedIdItems = [
+ {
+ ItemId: { $oid: itemId }
+ }
+ ];
+
+ return inventoryChanges;
+};
diff --git a/src/controllers/api/hostSessionController.ts b/src/controllers/api/hostSessionController.ts
new file mode 100644
index 00000000..c31fa447
--- /dev/null
+++ b/src/controllers/api/hostSessionController.ts
@@ -0,0 +1,24 @@
+import type { RequestHandler } from "express";
+import { getAccountForRequest } from "../../services/loginService.ts";
+import { createNewSession } from "../../managers/sessionManager.ts";
+import { logger } from "../../utils/logger.ts";
+import type { ISession } from "../../types/session.ts";
+import { JSONParse } from "json-with-bigint";
+import { toOid2, version_compare } from "../../helpers/inventoryHelpers.ts";
+
+const hostSessionController: RequestHandler = async (req, res) => {
+ const account = await getAccountForRequest(req);
+ const hostSessionRequest = JSONParse(String(req.body)) as ISession;
+ logger.debug("HostSession Request", { hostSessionRequest });
+ const session = createNewSession(hostSessionRequest, account._id);
+ logger.debug(`New Session Created`, { session });
+
+ if (account.BuildLabel && version_compare(account.BuildLabel, "2015.03.21.08.17") < 0) {
+ // U15 or below
+ res.send(session.sessionId.toString());
+ } else {
+ res.json({ sessionId: toOid2(session.sessionId, account.BuildLabel), rewardSeed: 99999999 });
+ }
+};
+
+export { hostSessionController };
diff --git a/src/controllers/api/hubBlessingController.ts b/src/controllers/api/hubBlessingController.ts
new file mode 100644
index 00000000..467186b5
--- /dev/null
+++ b/src/controllers/api/hubBlessingController.ts
@@ -0,0 +1,45 @@
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { addBooster, getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { getRandomInt } from "../../services/rngService.ts";
+import type { RequestHandler } from "express";
+import { ExportBoosters } from "warframe-public-export-plus";
+
+export const hubBlessingController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const data = getJSONfromString(String(req.body));
+ const boosterType = ExportBoosters[data.booster].typeName;
+ if (req.query.mode == "send") {
+ const inventory = await getInventory(accountId, "BlessingCooldown Boosters");
+ inventory.BlessingCooldown = new Date(Date.now() + 86400000);
+ addBooster(boosterType, 3 * 3600, inventory); // unfaithful, but current HUB server does not handle broadcasting, so this way users can bless themselves.
+ await inventory.save();
+
+ let token = "";
+ for (let i = 0; i != 32; ++i) {
+ token += getRandomInt(0, 15).toString(16);
+ }
+
+ res.json({
+ BlessingCooldown: inventory.BlessingCooldown,
+ SendTime: Math.trunc(Date.now() / 1000).toString(),
+ Token: token
+ });
+ } else {
+ const inventory = await getInventory(accountId, "Boosters");
+ addBooster(boosterType, 3 * 3600, inventory);
+ await inventory.save();
+
+ res.json({
+ BoosterType: data.booster,
+ Sender: data.senderId
+ });
+ }
+};
+
+interface IHubBlessingRequest {
+ booster: string;
+ senderId?: string; // mode=request
+ sendTime?: string; // mode=request
+ token?: string; // mode=request
+}
diff --git a/src/controllers/api/hubController.ts b/src/controllers/api/hubController.ts
new file mode 100644
index 00000000..bc0129e5
--- /dev/null
+++ b/src/controllers/api/hubController.ts
@@ -0,0 +1,7 @@
+import type { RequestHandler } from "express";
+import { getReflexiveAddress } from "../../services/configService.ts";
+
+export const hubController: RequestHandler = (req, res) => {
+ const { myAddress } = getReflexiveAddress(req);
+ res.json(`hub ${myAddress}:6952`);
+};
diff --git a/src/controllers/api/hubInstancesController.ts b/src/controllers/api/hubInstancesController.ts
new file mode 100644
index 00000000..89a5564c
--- /dev/null
+++ b/src/controllers/api/hubInstancesController.ts
@@ -0,0 +1,7 @@
+import type { RequestHandler } from "express";
+
+const hubInstancesController: RequestHandler = (_req, res) => {
+ res.json("list 50 1 0 0 scenarios 0 0 0 0 0 0");
+};
+
+export { hubInstancesController };
diff --git a/src/controllers/api/inboxController.ts b/src/controllers/api/inboxController.ts
new file mode 100644
index 00000000..61911684
--- /dev/null
+++ b/src/controllers/api/inboxController.ts
@@ -0,0 +1,143 @@
+import type { RequestHandler } from "express";
+import { Inbox } from "../../models/inboxModel.ts";
+import {
+ createMessage,
+ createNewEventMessages,
+ deleteAllMessagesRead,
+ deleteMessageRead,
+ getAllMessagesSorted,
+ getMessage
+} from "../../services/inboxService.ts";
+import { getAccountForRequest, getAccountFromSuffixedName, getSuffixedName } from "../../services/loginService.ts";
+import {
+ addItems,
+ combineInventoryChanges,
+ getEffectiveAvatarImageType,
+ getInventory,
+ updateCurrency
+} from "../../services/inventoryService.ts";
+import { logger } from "../../utils/logger.ts";
+import { ExportFlavour } from "warframe-public-export-plus";
+import { handleStoreItemAcquisition } from "../../services/purchaseService.ts";
+import { fromStoreItem, isStoreItem } from "../../services/itemDataService.ts";
+import type { IOid } from "../../types/commonTypes.ts";
+
+export const inboxController: RequestHandler = async (req, res) => {
+ const { deleteId, lastMessage: latestClientMessageId, messageId } = req.query;
+
+ const account = await getAccountForRequest(req);
+ const accountId = account._id.toString();
+
+ if (deleteId) {
+ if (deleteId === "DeleteAllRead") {
+ await deleteAllMessagesRead(accountId);
+ res.status(200).end();
+ return;
+ }
+
+ await deleteMessageRead(parseOid(deleteId as string));
+ res.status(200).end();
+ } else if (messageId) {
+ const message = await getMessage(parseOid(messageId as string));
+ message.r = true;
+ await message.save();
+
+ const attachmentItems = message.attVisualOnly ? undefined : message.att;
+ const attachmentCountedItems = message.attVisualOnly ? undefined : message.countedAtt;
+
+ if (!attachmentItems && !attachmentCountedItems && !message.gifts) {
+ res.status(200).end();
+ return;
+ }
+
+ const inventory = await getInventory(accountId);
+ const inventoryChanges = {};
+ if (attachmentItems) {
+ await addItems(
+ inventory,
+ attachmentItems.map(attItem => ({
+ ItemType: isStoreItem(attItem) ? fromStoreItem(attItem) : attItem,
+ ItemCount: 1
+ })),
+ inventoryChanges
+ );
+ }
+ if (attachmentCountedItems) {
+ await addItems(inventory, attachmentCountedItems, inventoryChanges);
+ }
+ if (message.gifts) {
+ const sender = await getAccountFromSuffixedName(message.sndr);
+ const recipientName = getSuffixedName(account);
+ const giftQuantity = message.arg!.find(x => x.Key == "GIFT_QUANTITY")!.Tag as number;
+ for (const gift of message.gifts) {
+ combineInventoryChanges(
+ inventoryChanges,
+ (await handleStoreItemAcquisition(gift.GiftType, inventory, giftQuantity)).InventoryChanges
+ );
+ if (sender) {
+ await createMessage(sender._id, [
+ {
+ sndr: recipientName,
+ msg: "/Lotus/Language/Menu/GiftReceivedConfirmationBody",
+ arg: [
+ {
+ Key: "RECIPIENT_NAME",
+ Tag: recipientName
+ },
+ {
+ Key: "GIFT_TYPE",
+ Tag: gift.GiftType
+ },
+ {
+ Key: "GIFT_QUANTITY",
+ Tag: giftQuantity
+ }
+ ],
+ sub: "/Lotus/Language/Menu/GiftReceivedConfirmationSubject",
+ icon: ExportFlavour[getEffectiveAvatarImageType(inventory)].icon,
+ highPriority: true
+ }
+ ]);
+ }
+ }
+ }
+ if (message.RegularCredits) {
+ updateCurrency(inventory, -message.RegularCredits, false, inventoryChanges);
+ }
+ await inventory.save();
+ res.json({ InventoryChanges: inventoryChanges });
+ } else if (latestClientMessageId) {
+ await createNewEventMessages(req);
+ const messages = await Inbox.find({ ownerId: accountId }).sort({ date: 1 });
+
+ const latestClientMessage = messages.find(m => m._id.toString() === parseOid(latestClientMessageId as string));
+
+ if (!latestClientMessage) {
+ logger.debug(`this should only happen after DeleteAllRead `);
+ res.json({ Inbox: messages });
+ return;
+ }
+ const newMessages = messages.filter(m => m.date > latestClientMessage.date);
+
+ if (newMessages.length === 0) {
+ res.send("no-new");
+ return;
+ }
+
+ res.json({ Inbox: newMessages });
+ } else {
+ //newly created event messages must be newer than account.LatestEventMessageDate
+ await createNewEventMessages(req);
+ const messages = await getAllMessagesSorted(accountId);
+ const inbox = messages.map(m => m.toJSON());
+ res.json({ Inbox: inbox });
+ }
+};
+
+// 33.6.0 has query arguments like lastMessage={"$oid":"68112baebf192e786d1502bb"} instead of lastMessage=68112baebf192e786d1502bb
+const parseOid = (oid: string): string => {
+ if (oid[0] == "{") {
+ return (JSON.parse(oid) as IOid).$oid;
+ }
+ return oid;
+};
diff --git a/src/controllers/api/infestedFoundryController.ts b/src/controllers/api/infestedFoundryController.ts
new file mode 100644
index 00000000..de0b714e
--- /dev/null
+++ b/src/controllers/api/infestedFoundryController.ts
@@ -0,0 +1,463 @@
+import type { RequestHandler } from "express";
+import { getAccountForRequest } from "../../services/loginService.ts";
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { getInventory, addMiscItems, updateCurrency, addRecipes, freeUpSlot } from "../../services/inventoryService.ts";
+import type { IOid } from "../../types/commonTypes.ts";
+import type {
+ IConsumedSuit,
+ IHelminthFoodRecord,
+ IInventoryClient,
+ IMiscItem
+} from "../../types/inventoryTypes/inventoryTypes.ts";
+import { InventorySlot } from "../../types/inventoryTypes/inventoryTypes.ts";
+import { ExportMisc } from "warframe-public-export-plus";
+import { getRecipe } from "../../services/itemDataService.ts";
+import { toMongoDate, version_compare } from "../../helpers/inventoryHelpers.ts";
+import { logger } from "../../utils/logger.ts";
+import { colorToShard } from "../../helpers/shardHelper.ts";
+import {
+ addInfestedFoundryXP,
+ applyCheatsToInfestedFoundry,
+ handleSubsumeCompletion
+} from "../../services/infestedFoundryService.ts";
+
+export const infestedFoundryController: RequestHandler = async (req, res) => {
+ const account = await getAccountForRequest(req);
+ switch (req.query.mode) {
+ case "s": {
+ // shard installation
+ const request = getJSONfromString(String(req.body));
+ const inventory = await getInventory(account._id.toString());
+ const suit = inventory.Suits.id(request.SuitId.$oid)!;
+ suit.ArchonCrystalUpgrades ??= [];
+ while (suit.ArchonCrystalUpgrades.length < request.Slot) {
+ suit.ArchonCrystalUpgrades.push({});
+ }
+ suit.ArchonCrystalUpgrades[request.Slot] = {
+ UpgradeType: request.UpgradeType,
+ Color: request.Color
+ };
+ const miscItemChanges = [
+ {
+ ItemType: colorToShard[request.Color],
+ ItemCount: -1
+ }
+ ];
+ addMiscItems(inventory, miscItemChanges);
+ await inventory.save();
+ res.json({
+ InventoryChanges: {
+ MiscItems: miscItemChanges
+ }
+ });
+ break;
+ }
+
+ case "x": {
+ // shard removal
+ const request = getJSONfromString(String(req.body));
+ const inventory = await getInventory(account._id.toString());
+ const suit = inventory.Suits.id(request.SuitId.$oid)!;
+
+ const miscItemChanges: IMiscItem[] = [];
+ if (suit.ArchonCrystalUpgrades![request.Slot].Color) {
+ // refund shard
+ const shard = Object.entries(colorToShard).find(
+ ([color]) => color == suit.ArchonCrystalUpgrades![request.Slot].Color
+ )![1];
+ miscItemChanges.push({
+ ItemType: shard,
+ ItemCount: 1
+ });
+ addMiscItems(inventory, miscItemChanges);
+
+ // consume resources
+ if (!inventory.infiniteHelminthMaterials) {
+ let type: string;
+ let count: number;
+ if (account.BuildLabel && version_compare(account.BuildLabel, "2025.05.20.10.18") < 0) {
+ // < 38.6.0
+ type = "/Lotus/Types/Items/InfestedFoundry/HelminthBile";
+ count = 300;
+ } else {
+ // >= 38.6.0
+ type =
+ archonCrystalRemovalResource[
+ suit.ArchonCrystalUpgrades![request.Slot].Color!.replace("_MYTHIC", "")
+ ];
+ count = suit.ArchonCrystalUpgrades![request.Slot].Color!.indexOf("_MYTHIC") != -1 ? 300 : 150;
+ }
+ inventory.InfestedFoundry!.Resources!.find(x => x.ItemType == type)!.Count -= count;
+ }
+ }
+
+ // remove from suit
+ suit.ArchonCrystalUpgrades![request.Slot].UpgradeType = undefined;
+ suit.ArchonCrystalUpgrades![request.Slot].Color = undefined;
+
+ await inventory.save();
+
+ const infestedFoundry = inventory.toJSON().InfestedFoundry!;
+ applyCheatsToInfestedFoundry(inventory, infestedFoundry);
+ res.json({
+ InventoryChanges: {
+ MiscItems: miscItemChanges,
+ InfestedFoundry: infestedFoundry
+ }
+ });
+ break;
+ }
+
+ case "n": {
+ // name the beast
+ const request = getJSONfromString(String(req.body));
+ const inventory = await getInventory(account._id.toString());
+ inventory.InfestedFoundry ??= {};
+ inventory.InfestedFoundry.Name = request.newName;
+ await inventory.save();
+ res.json({
+ InventoryChanges: {
+ InfestedFoundry: {
+ Name: inventory.InfestedFoundry.Name
+ }
+ }
+ });
+ break;
+ }
+
+ case "c": {
+ // consume items
+
+ const inventory = await getInventory(account._id.toString());
+
+ if (inventory.infiniteHelminthMaterials) {
+ res.status(400).end();
+ return;
+ }
+
+ const request = getJSONfromString(String(req.body));
+ inventory.InfestedFoundry ??= {};
+ inventory.InfestedFoundry.Resources ??= [];
+
+ const miscItemChanges: IMiscItem[] = [];
+ let totalPercentagePointsGained = 0;
+
+ const currentUnixSeconds = Math.trunc(Date.now() / 1000);
+
+ for (const contribution of request.ResourceContributions) {
+ const snack = ExportMisc.helminthSnacks[contribution.ItemType];
+
+ // tally items for removal
+ const change = miscItemChanges.find(x => x.ItemType == contribution.ItemType);
+ if (change) {
+ change.ItemCount -= snack.count;
+ } else {
+ miscItemChanges.push({ ItemType: contribution.ItemType, ItemCount: snack.count * -1 });
+ }
+
+ if (snack.type == "/Lotus/Types/Items/InfestedFoundry/HelminthAppetiteCooldownReducer") {
+ // sentinent apetite
+ let mostDislikedSnackRecord: IHelminthFoodRecord = { ItemType: "", Date: 0 };
+ for (const resource of inventory.InfestedFoundry.Resources) {
+ if (resource.RecentlyConvertedResources) {
+ for (const record of resource.RecentlyConvertedResources) {
+ if (record.Date > mostDislikedSnackRecord.Date) {
+ mostDislikedSnackRecord = record;
+ }
+ }
+ }
+ }
+ logger.debug("helminth eats sentient resource; most disliked snack:", {
+ type: mostDislikedSnackRecord.ItemType,
+ date: mostDislikedSnackRecord.Date
+ });
+ mostDislikedSnackRecord.Date = currentUnixSeconds + 24 * 60 * 60; // Possibly unfaithful
+ continue;
+ }
+
+ let resource = inventory.InfestedFoundry.Resources.find(x => x.ItemType == snack.type);
+ if (!resource) {
+ resource =
+ inventory.InfestedFoundry.Resources[
+ inventory.InfestedFoundry.Resources.push({ ItemType: snack.type, Count: 0 }) - 1
+ ];
+ }
+
+ resource.RecentlyConvertedResources ??= [];
+ let record = resource.RecentlyConvertedResources.find(x => x.ItemType == contribution.ItemType);
+ if (!record) {
+ record =
+ resource.RecentlyConvertedResources[
+ resource.RecentlyConvertedResources.push({ ItemType: contribution.ItemType, Date: 0 }) - 1
+ ];
+ }
+
+ const hoursRemaining = (record.Date - currentUnixSeconds) / 3600;
+ const apetiteFactor = apetiteModel(hoursRemaining) / 30;
+ logger.debug(`helminth eating ${contribution.ItemType} (+${(snack.gain * 100).toFixed(0)}%)`, {
+ hoursRemaining,
+ apetiteFactor
+ });
+ if (hoursRemaining >= 18) {
+ record.Date = currentUnixSeconds + 72 * 60 * 60; // Possibly unfaithful
+ } else {
+ record.Date = currentUnixSeconds + 24 * 60 * 60;
+ }
+
+ totalPercentagePointsGained += snack.gain * 100 * apetiteFactor; // 30% would be gain=0.3, so percentage points is equal to gain * 100.
+ resource.Count += Math.trunc(snack.gain * 1000 * apetiteFactor); // 30% would be gain=0.3 or Count=300, so Count=gain*1000.
+ if (resource.Count > 1000) resource.Count = 1000;
+ }
+
+ const recipeChanges = addInfestedFoundryXP(inventory.InfestedFoundry, 666 * totalPercentagePointsGained);
+ addRecipes(inventory, recipeChanges);
+ addMiscItems(inventory, miscItemChanges);
+ await inventory.save();
+
+ res.json({
+ InventoryChanges: {
+ Recipes: recipeChanges,
+ InfestedFoundry: {
+ XP: inventory.InfestedFoundry.XP,
+ Resources: inventory.InfestedFoundry.Resources,
+ Slots: inventory.InfestedFoundry.Slots
+ },
+ MiscItems: miscItemChanges
+ }
+ });
+ break;
+ }
+
+ case "o": {
+ // offerings update
+ const request = getJSONfromString(String(req.body));
+ const inventory = await getInventory(account._id.toString());
+ inventory.InfestedFoundry ??= {};
+ inventory.InfestedFoundry.InvigorationIndex = request.OfferingsIndex;
+ inventory.InfestedFoundry.InvigorationSuitOfferings = request.SuitTypes;
+ if (request.Extra) {
+ inventory.InfestedFoundry.InvigorationsApplied = 0;
+ }
+ await inventory.save();
+ const infestedFoundry = inventory.toJSON().InfestedFoundry!;
+ applyCheatsToInfestedFoundry(inventory, infestedFoundry);
+ res.json({
+ InventoryChanges: {
+ InfestedFoundry: infestedFoundry
+ }
+ });
+ break;
+ }
+
+ case "a": {
+ // subsume warframe
+ const request = getJSONfromString(String(req.body));
+ const inventory = await getInventory(account._id.toString());
+ const recipe = getRecipe(request.Recipe)!;
+ if (!inventory.infiniteHelminthMaterials) {
+ for (const ingredient of recipe.secretIngredients!) {
+ const resource = inventory.InfestedFoundry!.Resources!.find(x => x.ItemType == ingredient.ItemType);
+ if (resource) {
+ resource.Count -= ingredient.ItemCount;
+ }
+ }
+ }
+ const suit = inventory.Suits.id(request.SuitId.$oid)!;
+ inventory.Suits.pull(suit);
+ const consumedSuit: IConsumedSuit = { s: suit.ItemType };
+ if (suit.Configs[0] && suit.Configs[0].pricol) {
+ consumedSuit.c = suit.Configs[0].pricol;
+ }
+ if ((inventory.InfestedFoundry!.XP ?? 0) < 73125_00) {
+ inventory.InfestedFoundry!.Slots!--;
+ }
+ inventory.InfestedFoundry!.ConsumedSuits ??= [];
+ inventory.InfestedFoundry!.ConsumedSuits.push(consumedSuit);
+ inventory.InfestedFoundry!.LastConsumedSuit = suit;
+ inventory.InfestedFoundry!.AbilityOverrideUnlockCooldown = new Date(Date.now() + 24 * 60 * 60 * 1000);
+ const recipeChanges = addInfestedFoundryXP(inventory.InfestedFoundry!, 1600_00);
+ addRecipes(inventory, recipeChanges);
+ freeUpSlot(inventory, InventorySlot.SUITS);
+ await inventory.save();
+ const infestedFoundry = inventory.toJSON().InfestedFoundry!;
+ applyCheatsToInfestedFoundry(inventory, infestedFoundry);
+ res.json({
+ InventoryChanges: {
+ Recipes: recipeChanges,
+ RemovedIdItems: [
+ {
+ ItemId: request.SuitId
+ }
+ ],
+ SuitBin: {
+ count: -1,
+ platinum: 0,
+ Slots: 1
+ },
+ InfestedFoundry: infestedFoundry
+ }
+ });
+ break;
+ }
+
+ case "r": {
+ // rush subsume
+ const inventory = await getInventory(account._id.toString());
+ const currencyChanges = updateCurrency(inventory, 50, true);
+ const recipeChanges = handleSubsumeCompletion(inventory);
+ await inventory.save();
+ const infestedFoundry = inventory.toJSON().InfestedFoundry!;
+ applyCheatsToInfestedFoundry(inventory, infestedFoundry);
+ res.json({
+ InventoryChanges: {
+ ...currencyChanges,
+ Recipes: recipeChanges,
+ InfestedFoundry: infestedFoundry
+ }
+ });
+ break;
+ }
+
+ case "u": {
+ const request = getJSONfromString(String(req.body));
+ const inventory = await getInventory(account._id.toString());
+ const suit = inventory.Suits.id(request.SuitId.$oid)!;
+ const upgradesExpiry = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
+ suit.OffensiveUpgrade = request.OffensiveUpgradeType;
+ suit.DefensiveUpgrade = request.DefensiveUpgradeType;
+ suit.UpgradesExpiry = upgradesExpiry;
+ const recipeChanges = addInfestedFoundryXP(inventory.InfestedFoundry!, 4800_00);
+ addRecipes(inventory, recipeChanges);
+ if (!inventory.infiniteHelminthMaterials) {
+ for (let i = 0; i != request.ResourceTypes.length; ++i) {
+ inventory.InfestedFoundry!.Resources!.find(x => x.ItemType == request.ResourceTypes[i])!.Count -=
+ request.ResourceCosts[i];
+ }
+ }
+ inventory.InfestedFoundry!.InvigorationsApplied ??= 0;
+ inventory.InfestedFoundry!.InvigorationsApplied += 1;
+ await inventory.save();
+ const infestedFoundry = inventory.toJSON().InfestedFoundry!;
+ applyCheatsToInfestedFoundry(inventory, infestedFoundry);
+ res.json({
+ SuitId: request.SuitId,
+ OffensiveUpgrade: request.OffensiveUpgradeType,
+ DefensiveUpgrade: request.DefensiveUpgradeType,
+ UpgradesExpiry: toMongoDate(upgradesExpiry),
+ InventoryChanges: {
+ Recipes: recipeChanges,
+ InfestedFoundry: infestedFoundry
+ }
+ });
+ break;
+ }
+
+ case "custom_unlockall": {
+ const inventory = await getInventory(account._id.toString());
+ inventory.InfestedFoundry ??= {};
+ inventory.InfestedFoundry.XP ??= 0;
+ if (151875_00 > inventory.InfestedFoundry.XP) {
+ const recipeChanges = addInfestedFoundryXP(
+ inventory.InfestedFoundry,
+ 151875_00 - inventory.InfestedFoundry.XP
+ );
+ addRecipes(inventory, recipeChanges);
+ await inventory.save();
+ }
+ res.end();
+ break;
+ }
+
+ default:
+ logger.debug(`data provided to ${req.path}: ${String(req.body)}`);
+ throw new Error(`unhandled infestedFoundry mode: ${String(req.query.mode)}`);
+ }
+};
+
+interface IShardInstallRequest {
+ SuitId: IOid;
+ Slot: number;
+ UpgradeType: string;
+ Color: string;
+}
+
+interface IShardUninstallRequest {
+ SuitId: IOid;
+ Slot: number;
+}
+
+interface IHelminthNameRequest {
+ newName: string;
+}
+
+interface IHelminthFeedRequest {
+ ResourceContributions: {
+ ItemType: string;
+ Date: number; // unix timestamp
+ }[];
+}
+
+interface IHelminthSubsumeRequest {
+ SuitId: IOid;
+ Recipe: string;
+}
+
+interface IHelminthOfferingsUpdate {
+ OfferingsIndex: number;
+ SuitTypes: string[];
+ Extra: boolean;
+}
+
+interface IHelminthInvigorationRequest {
+ SuitId: IOid;
+ OffensiveUpgradeType: string;
+ DefensiveUpgradeType: string;
+ ResourceTypes: string[];
+ ResourceCosts: number[];
+}
+
+// A fitted model for observed apetite values. Likely slightly inaccurate.
+//
+// Hours remaining, percentage points gained (out of 30 total)
+// 0, 30
+// 5, 25.8
+// 10, 21.6
+// 12, 20
+// 16, 16.6
+// 17, 15.8
+// 18, 15
+// 20, 15
+// 24, 15
+// 36, 15
+// 40, 13.6
+// 47, 11.3
+// 48, 11
+// 50, 10.3
+// 60, 7
+// 70, 3.6
+// 71, 3.3
+// 72, 3
+const apetiteModel = (x: number): number => {
+ if (x <= 0) {
+ return 30;
+ }
+ if (x < 18) {
+ return -0.84 * x + 30;
+ }
+ if (x <= 36) {
+ return 15;
+ }
+ if (x < 71.9) {
+ return -0.3327892 * x + 26.94135;
+ }
+ return 3;
+};
+
+const archonCrystalRemovalResource: Record = {
+ ACC_RED: "/Lotus/Types/Items/InfestedFoundry/HelminthOxides",
+ ACC_YELLOW: "/Lotus/Types/Items/InfestedFoundry/HelminthBile",
+ ACC_BLUE: "/Lotus/Types/Items/InfestedFoundry/HelminthSynthetics",
+ ACC_GREEN: "/Lotus/Types/Items/InfestedFoundry/HelminthBiotics",
+ ACC_ORANGE: "/Lotus/Types/Items/InfestedFoundry/HelminthPheromones",
+ ACC_PURPLE: "/Lotus/Types/Items/InfestedFoundry/HelminthCalx"
+};
diff --git a/src/controllers/api/inventoryController.ts b/src/controllers/api/inventoryController.ts
new file mode 100644
index 00000000..13bbd21a
--- /dev/null
+++ b/src/controllers/api/inventoryController.ts
@@ -0,0 +1,515 @@
+import type { RequestHandler } from "express";
+import { getAccountForRequest } from "../../services/loginService.ts";
+import type { TInventoryDatabaseDocument } from "../../models/inventoryModels/inventoryModel.ts";
+import { Inventory } from "../../models/inventoryModels/inventoryModel.ts";
+import { config } from "../../services/configService.ts";
+import allDialogue from "../../../static/fixed_responses/allDialogue.json" with { type: "json" };
+import type { ILoadoutDatabase } from "../../types/saveLoadoutTypes.ts";
+import type { IInventoryClient, IShipInventory } from "../../types/inventoryTypes/inventoryTypes.ts";
+import { equipmentKeys } from "../../types/inventoryTypes/inventoryTypes.ts";
+import type { IPolarity } from "../../types/inventoryTypes/commonInventoryTypes.ts";
+import { ArtifactPolarity } from "../../types/inventoryTypes/commonInventoryTypes.ts";
+import type { ICountedItem } from "warframe-public-export-plus";
+import { eFaction, ExportCustoms, ExportFlavour, ExportResources, ExportVirtuals } from "warframe-public-export-plus";
+import { applyCheatsToInfestedFoundry, handleSubsumeCompletion } from "../../services/infestedFoundryService.ts";
+import {
+ addEmailItem,
+ addItem,
+ addMiscItems,
+ allDailyAffiliationKeys,
+ checkCalendarAutoAdvance,
+ cleanupInventory,
+ createLibraryDailyTask,
+ getCalendarProgress
+} from "../../services/inventoryService.ts";
+import { logger } from "../../utils/logger.ts";
+import { addString, catBreadHash } from "../../helpers/stringHelpers.ts";
+import { Types } from "mongoose";
+import { getNemesisManifest } from "../../helpers/nemesisHelpers.ts";
+import { getPersonalRooms } from "../../services/personalRoomsService.ts";
+import type { IPersonalRoomsClient } from "../../types/personalRoomsTypes.ts";
+import { Ship } from "../../models/shipModel.ts";
+import { toLegacyOid, toOid, version_compare } from "../../helpers/inventoryHelpers.ts";
+import { Inbox } from "../../models/inboxModel.ts";
+import { unixTimesInMs } from "../../constants/timeConstants.ts";
+import { DailyDeal } from "../../models/worldStateModel.ts";
+import { EquipmentFeatures } from "../../types/equipmentTypes.ts";
+import { generateRewardSeed } from "../../services/rngService.ts";
+import { getInvasionByOid, getWorldState } from "../../services/worldStateService.ts";
+import { createMessage } from "../../services/inboxService.ts";
+
+export const inventoryController: RequestHandler = async (request, response) => {
+ const account = await getAccountForRequest(request);
+
+ const inventory = await Inventory.findOne({ accountOwnerId: account._id });
+
+ if (!inventory) {
+ response.status(400).json({ error: "inventory was undefined" });
+ return;
+ }
+
+ // Handle daily reset
+ if (!inventory.NextRefill || Date.now() >= inventory.NextRefill.getTime()) {
+ const today = Math.trunc(Date.now() / 86400000);
+
+ for (const key of allDailyAffiliationKeys) {
+ inventory[key] = 16000 + inventory.PlayerLevel * 500;
+ }
+ inventory.DailyFocus = 250000 + inventory.PlayerLevel * 5000;
+ inventory.GiftsRemaining = Math.max(8, inventory.PlayerLevel);
+ inventory.TradesRemaining = inventory.PlayerLevel;
+
+ inventory.LibraryAvailableDailyTaskInfo = createLibraryDailyTask();
+
+ if (inventory.NextRefill) {
+ const lastLoginDay = Math.trunc(inventory.NextRefill.getTime() / 86400000) - 1;
+ const daysPassed = today - lastLoginDay;
+
+ if (inventory.noArgonCrystalDecay) {
+ inventory.FoundToday = undefined;
+ } else {
+ for (let i = 0; i != daysPassed; ++i) {
+ const numArgonCrystals =
+ inventory.MiscItems.find(x => x.ItemType == "/Lotus/Types/Items/MiscItems/ArgonCrystal")
+ ?.ItemCount ?? 0;
+ if (numArgonCrystals == 0) {
+ break;
+ }
+ const numStableArgonCrystals = Math.min(
+ numArgonCrystals,
+ inventory.FoundToday?.find(x => x.ItemType == "/Lotus/Types/Items/MiscItems/ArgonCrystal")
+ ?.ItemCount ?? 0
+ );
+ const numDecayingArgonCrystals = numArgonCrystals - numStableArgonCrystals;
+ const numDecayingArgonCrystalsToRemove = Math.ceil(numDecayingArgonCrystals / 2);
+ logger.debug(`ticking argon crystals for day ${i + 1} of ${daysPassed}`, {
+ numArgonCrystals,
+ numStableArgonCrystals,
+ numDecayingArgonCrystals,
+ numDecayingArgonCrystalsToRemove
+ });
+ // Remove half of owned decaying argon crystals
+ addMiscItems(inventory, [
+ {
+ ItemType: "/Lotus/Types/Items/MiscItems/ArgonCrystal",
+ ItemCount: numDecayingArgonCrystalsToRemove * -1
+ }
+ ]);
+ // All stable argon crystals are now decaying
+ inventory.FoundToday = undefined;
+ }
+ }
+
+ if (inventory.UsedDailyDeals.length != 0) {
+ if (daysPassed == 1) {
+ const todayAt0Utc = today * 86400000;
+ const darvoIndex = Math.trunc((todayAt0Utc - 25200000) / (26 * unixTimesInMs.hour));
+ const darvoStart = darvoIndex * (26 * unixTimesInMs.hour) + 25200000;
+ const darvoOid =
+ ((darvoStart / 1000) & 0xffffffff).toString(16).padStart(8, "0") + "adc51a72f7324d95";
+ const deal = await DailyDeal.findById(darvoOid);
+ if (deal) {
+ inventory.UsedDailyDeals = inventory.UsedDailyDeals.filter(x => x == deal.StoreItem); // keep only the deal that came into this new day with us
+ } else {
+ inventory.UsedDailyDeals = [];
+ }
+ } else {
+ inventory.UsedDailyDeals = [];
+ }
+ }
+ }
+
+ // TODO: Setup CalendarProgress as part of 1999 mission completion?
+
+ const previousYearIteration = inventory.CalendarProgress?.Iteration;
+
+ // We need to do the following to ensure the in-game calendar does not break:
+ getCalendarProgress(inventory); // Keep the CalendarProgress up-to-date (at least for the current year iteration) (https://onlyg.it/OpenWF/SpaceNinjaServer/issues/2364)
+ checkCalendarAutoAdvance(inventory, getWorldState().KnownCalendarSeasons[0]); // Skip birthday events for characters if we do not have them unlocked yet (https://onlyg.it/OpenWF/SpaceNinjaServer/issues/2424)
+
+ // also handle sending of kiss cinematic at year rollover
+ if (
+ inventory.CalendarProgress!.Iteration != previousYearIteration &&
+ inventory.DialogueHistory &&
+ inventory.DialogueHistory.Dialogues
+ ) {
+ let kalymos = false;
+ for (const { dialogueName, kissEmail } of [
+ {
+ dialogueName: "/Lotus/Types/Gameplay/1999Wf/Dialogue/ArthurDialogue_rom.dialogue",
+ kissEmail: "/Lotus/Types/Items/EmailItems/ArthurKissEmailItem"
+ },
+ {
+ dialogueName: "/Lotus/Types/Gameplay/1999Wf/Dialogue/EleanorDialogue_rom.dialogue",
+ kissEmail: "/Lotus/Types/Items/EmailItems/EleanorKissEmailItem"
+ },
+ {
+ dialogueName: "/Lotus/Types/Gameplay/1999Wf/Dialogue/LettieDialogue_rom.dialogue",
+ kissEmail: "/Lotus/Types/Items/EmailItems/LettieKissEmailItem"
+ },
+ {
+ dialogueName: "/Lotus/Types/Gameplay/1999Wf/Dialogue/JabirDialogue_rom.dialogue",
+ kissEmail: "/Lotus/Types/Items/EmailItems/AmirKissEmailItem"
+ },
+ {
+ dialogueName: "/Lotus/Types/Gameplay/1999Wf/Dialogue/AoiDialogue_rom.dialogue",
+ kissEmail: "/Lotus/Types/Items/EmailItems/AoiKissEmailItem"
+ },
+ {
+ dialogueName: "/Lotus/Types/Gameplay/1999Wf/Dialogue/QuincyDialogue_rom.dialogue",
+ kissEmail: "/Lotus/Types/Items/EmailItems/QuincyKissEmailItem"
+ }
+ ]) {
+ const dialogue = inventory.DialogueHistory.Dialogues.find(x => x.DialogueName == dialogueName);
+ if (dialogue) {
+ if (dialogue.Rank == 7) {
+ await addEmailItem(inventory, kissEmail);
+ kalymos = false;
+ break;
+ }
+ if (dialogue.Rank == 6) {
+ kalymos = true;
+ }
+ }
+ }
+ if (kalymos) {
+ await addEmailItem(inventory, "/Lotus/Types/Items/EmailItems/KalymosKissEmailItem");
+ }
+ }
+
+ cleanupInventory(inventory);
+
+ inventory.NextRefill = new Date((today + 1) * 86400000); // tomorrow at 0 UTC
+ //await inventory.save();
+ }
+
+ if (
+ inventory.InfestedFoundry &&
+ inventory.InfestedFoundry.AbilityOverrideUnlockCooldown &&
+ new Date() >= inventory.InfestedFoundry.AbilityOverrideUnlockCooldown
+ ) {
+ handleSubsumeCompletion(inventory);
+ //await inventory.save();
+ }
+
+ for (let i = 0; i != inventory.QualifyingInvasions.length; ) {
+ const qi = inventory.QualifyingInvasions[i];
+ const invasion = getInvasionByOid(qi.invasionId.toString());
+ if (!invasion) {
+ logger.debug(`removing QualifyingInvasions entry for unknown invasion: ${qi.invasionId.toString()}`);
+ inventory.QualifyingInvasions.splice(i, 1);
+ continue;
+ }
+ if (invasion.Completed) {
+ let factionSidedWith: string | undefined;
+ let battlePay: ICountedItem[] | undefined;
+ if (qi.AttackerScore >= 3) {
+ factionSidedWith = invasion.Faction;
+ battlePay = invasion.AttackerReward.countedItems;
+ logger.debug(`invasion pay from ${factionSidedWith}`, { battlePay });
+ } else if (qi.DefenderScore >= 3) {
+ factionSidedWith = invasion.DefenderFaction;
+ battlePay = invasion.DefenderReward.countedItems;
+ logger.debug(`invasion pay from ${factionSidedWith}`, { battlePay });
+ }
+ if (factionSidedWith) {
+ if (battlePay) {
+ // Decoupling rewards from the inbox message because it may delete itself without being read
+ for (const item of battlePay) {
+ await addItem(inventory, item.ItemType, item.ItemCount);
+ }
+ await createMessage(account._id, [
+ {
+ sndr: eFaction.find(x => x.tag == factionSidedWith)?.name ?? factionSidedWith, // TOVERIFY
+ msg: `/Lotus/Language/G1Quests/${factionSidedWith}_InvasionThankyouMessageBody`,
+ sub: `/Lotus/Language/G1Quests/${factionSidedWith}_InvasionThankyouMessageSubject`,
+ countedAtt: battlePay,
+ attVisualOnly: true,
+ icon:
+ factionSidedWith == "FC_GRINEER"
+ ? "/Lotus/Interface/Icons/Npcs/EliteRifleLancerAvatar.png" // Source: https://www.reddit.com/r/Warframe/comments/1aj4usx/battle_pay_worth_10_plat/, https://www.youtube.com/watch?v=XhNZ6ai6BOY
+ : "/Lotus/Interface/Icons/Npcs/CrewmanNormal.png", // My best source for this is https://www.youtube.com/watch?v=rxrCCFm73XE around 1:37
+ // TOVERIFY: highPriority?
+ endDate: new Date(Date.now() + 86400_000) // TOVERIFY: This type of inbox message seems to automatically delete itself. We'll just delete it after 24 hours, but it's not clear if this is correct.
+ }
+ ]);
+ }
+ if (invasion.Faction != "FC_INFESTATION") {
+ // Sided with grineer -> opposed corpus -> send zanuka (harvester)
+ // Sided with corpus -> opposed grineer -> send g3 (death squad)
+ inventory[factionSidedWith != "FC_GRINEER" ? "DeathSquadable" : "Harvestable"] = true;
+ // TOVERIFY: Should this happen earlier?
+ // TOVERIFY: Should this send an (ephemeral) email?
+ }
+ }
+ logger.debug(`removing QualifyingInvasions entry for completed invasion: ${qi.invasionId.toString()}`);
+ inventory.QualifyingInvasions.splice(i, 1);
+ continue;
+ }
+ ++i;
+ }
+
+ if (inventory.LastInventorySync) {
+ const lastSyncDuviriMood = Math.trunc(inventory.LastInventorySync.getTimestamp().getTime() / 7200000);
+ const currentDuviriMood = Math.trunc(Date.now() / 7200000);
+ if (lastSyncDuviriMood != currentDuviriMood) {
+ logger.debug(`refreshing duviri seed`);
+ if (!inventory.DuviriInfo) {
+ inventory.DuviriInfo = {
+ Seed: generateRewardSeed(),
+ NumCompletions: 0
+ };
+ } else {
+ inventory.DuviriInfo.Seed = generateRewardSeed();
+ }
+ }
+ }
+ inventory.LastInventorySync = new Types.ObjectId();
+ await inventory.save();
+
+ response.json(
+ await getInventoryResponse(inventory, "xpBasedLevelCapDisabled" in request.query, account.BuildLabel)
+ );
+};
+
+export const getInventoryResponse = async (
+ inventory: TInventoryDatabaseDocument,
+ xpBasedLevelCapDisabled: boolean,
+ buildLabel: string | undefined
+): Promise => {
+ const [inventoryWithLoadOutPresets, ships, latestMessage] = await Promise.all([
+ inventory.populate<{ LoadOutPresets: ILoadoutDatabase }>("LoadOutPresets"),
+ Ship.find({ ShipOwnerId: inventory.accountOwnerId }),
+ Inbox.findOne({ ownerId: inventory.accountOwnerId }, "_id").sort({ date: -1 })
+ ]);
+ const inventoryResponse = inventoryWithLoadOutPresets.toJSON();
+ inventoryResponse.Ships = ships.map(x => x.toJSON());
+
+ // In case mission inventory update added an inbox message, we need to send the Mailbox part so the client knows to refresh it.
+ if (latestMessage) {
+ inventoryResponse.Mailbox = {
+ LastInboxId: toOid(latestMessage._id)
+ };
+ }
+
+ if (inventory.infiniteCredits) {
+ inventoryResponse.RegularCredits = 999999999;
+ }
+ if (inventory.infinitePlatinum) {
+ inventoryResponse.PremiumCreditsFree = 0;
+ inventoryResponse.PremiumCredits = 999999999;
+ }
+ if (inventory.infiniteEndo) {
+ inventoryResponse.FusionPoints = 999999999;
+ }
+ if (inventory.infiniteRegalAya) {
+ inventoryResponse.PrimeTokens = 999999999;
+ }
+
+ if (inventory.skipAllDialogue) {
+ inventoryResponse.TauntHistory = [
+ {
+ node: "TreasureTutorial",
+ state: "TS_COMPLETED"
+ }
+ ];
+ for (const str of allDialogue) {
+ addString(inventoryResponse.NodeIntrosCompleted, str);
+ }
+ }
+
+ if (config.unlockAllShipDecorations) {
+ inventoryResponse.ShipDecorations = [];
+ for (const [uniqueName, item] of Object.entries(ExportResources)) {
+ if (item.productCategory == "ShipDecorations") {
+ inventoryResponse.ShipDecorations.push({ ItemType: uniqueName, ItemCount: 999_999 });
+ }
+ }
+ }
+
+ if (config.unlockAllFlavourItems) {
+ inventoryResponse.FlavourItems = [];
+ for (const uniqueName in ExportFlavour) {
+ inventoryResponse.FlavourItems.push({ ItemType: uniqueName });
+ }
+ } else if (config.worldState?.baroTennoConRelay) {
+ [
+ "/Lotus/Types/Items/Events/TennoConRelay2022EarlyAccess",
+ "/Lotus/Types/Items/Events/TennoConRelay2023EarlyAccess",
+ "/Lotus/Types/Items/Events/TennoConRelay2024EarlyAccess",
+ "/Lotus/Types/Items/Events/TennoConRelay2025EarlyAccess"
+ ].forEach(uniqueName => {
+ if (!inventoryResponse.FlavourItems.some(x => x.ItemType == uniqueName)) {
+ inventoryResponse.FlavourItems.push({ ItemType: uniqueName });
+ }
+ });
+ }
+
+ if (config.unlockAllSkins) {
+ const missingWeaponSkins = new Set(Object.keys(ExportCustoms));
+ inventoryResponse.WeaponSkins.forEach(x => missingWeaponSkins.delete(x.ItemType));
+ for (const uniqueName of missingWeaponSkins) {
+ inventoryResponse.WeaponSkins.push({
+ ItemId: {
+ $oid: "ca70ca70ca70ca70" + catBreadHash(uniqueName).toString(16).padStart(8, "0")
+ },
+ ItemType: uniqueName
+ });
+ }
+ }
+
+ if (config.unlockAllCapturaScenes) {
+ for (const uniqueName of Object.keys(ExportResources)) {
+ if (resourceInheritsFrom(uniqueName, "/Lotus/Types/Items/MiscItems/PhotoboothTile")) {
+ inventoryResponse.MiscItems.push({
+ ItemType: uniqueName,
+ ItemCount: 1
+ });
+ }
+ }
+ }
+
+ if (typeof config.spoofMasteryRank === "number" && config.spoofMasteryRank >= 0) {
+ inventoryResponse.PlayerLevel = config.spoofMasteryRank;
+ if (!xpBasedLevelCapDisabled) {
+ // This client has not been patched to accept any mastery rank, need to fake the XP.
+ inventoryResponse.XPInfo = [];
+ let numFrames = getExpRequiredForMr(Math.min(config.spoofMasteryRank, 5030)) / 6000;
+ while (numFrames-- > 0) {
+ inventoryResponse.XPInfo.push({
+ ItemType: "/Lotus/Powersuits/Mag/Mag",
+ XP: 1_600_000
+ });
+ }
+ }
+ }
+
+ if (inventory.universalPolarityEverywhere) {
+ const Polarity: IPolarity[] = [];
+ // 12 is needed for necramechs. 15 is needed for plexus/crewshipharness.
+ for (let i = 0; i != 15; ++i) {
+ Polarity.push({
+ Slot: i,
+ Value: ArtifactPolarity.Any
+ });
+ }
+ for (const key of equipmentKeys) {
+ if (key in inventoryResponse) {
+ for (const equipment of inventoryResponse[key]) {
+ equipment.Polarity = Polarity;
+ }
+ }
+ }
+ }
+
+ if (inventory.unlockDoubleCapacityPotatoesEverywhere) {
+ for (const key of equipmentKeys) {
+ if (key in inventoryResponse) {
+ for (const equipment of inventoryResponse[key]) {
+ equipment.Features ??= 0;
+ equipment.Features |= EquipmentFeatures.DOUBLE_CAPACITY;
+ }
+ }
+ }
+ }
+
+ if (inventory.unlockExilusEverywhere) {
+ for (const key of equipmentKeys) {
+ if (key in inventoryResponse) {
+ for (const equipment of inventoryResponse[key]) {
+ equipment.Features ??= 0;
+ equipment.Features |= EquipmentFeatures.UTILITY_SLOT;
+ }
+ }
+ }
+ }
+
+ if (inventory.unlockArcanesEverywhere) {
+ for (const key of equipmentKeys) {
+ if (key in inventoryResponse) {
+ for (const equipment of inventoryResponse[key]) {
+ equipment.Features ??= 0;
+ equipment.Features |= EquipmentFeatures.ARCANE_SLOT;
+ }
+ }
+ }
+ }
+
+ if (inventory.noDailyStandingLimits) {
+ const spoofedDailyAffiliation = Math.max(999_999, 16000 + inventoryResponse.PlayerLevel * 500);
+ for (const key of allDailyAffiliationKeys) {
+ inventoryResponse[key] = spoofedDailyAffiliation;
+ }
+ }
+
+ if (inventory.noDailyFocusLimit) {
+ inventoryResponse.DailyFocus = Math.max(999_999, 250000 + inventoryResponse.PlayerLevel * 5000);
+ }
+
+ if (inventoryResponse.InfestedFoundry) {
+ applyCheatsToInfestedFoundry(inventory, inventoryResponse.InfestedFoundry);
+ }
+
+ // Set 2FA enabled so trading post can be used
+ inventoryResponse.HWIDProtectEnabled = true;
+
+ if (buildLabel) {
+ // Fix nemesis for older versions
+ if (
+ inventoryResponse.Nemesis &&
+ version_compare(buildLabel, getNemesisManifest(inventoryResponse.Nemesis.manifest).minBuild) < 0
+ ) {
+ inventoryResponse.Nemesis = undefined;
+ }
+
+ if (version_compare(buildLabel, "2018.02.22.14.34") < 0) {
+ const personalRoomsDb = await getPersonalRooms(inventory.accountOwnerId.toString());
+ const personalRooms = personalRoomsDb.toJSON();
+ inventoryResponse.Ship = personalRooms.Ship;
+
+ if (version_compare(buildLabel, "2016.12.21.19.13") <= 0) {
+ // U19.5 and below use $id instead of $oid
+ for (const category of equipmentKeys) {
+ for (const item of inventoryResponse[category]) {
+ toLegacyOid(item.ItemId);
+ }
+ }
+ for (const upgrade of inventoryResponse.Upgrades) {
+ toLegacyOid(upgrade.ItemId);
+ }
+ if (inventoryResponse.BrandedSuits) {
+ for (const id of inventoryResponse.BrandedSuits) {
+ toLegacyOid(id);
+ }
+ }
+ }
+ }
+ }
+
+ return inventoryResponse;
+};
+
+const getExpRequiredForMr = (rank: number): number => {
+ if (rank <= 30) {
+ return 2500 * rank * rank;
+ }
+ return 2_250_000 + 147_500 * (rank - 30);
+};
+
+const resourceInheritsFrom = (resourceName: string, targetName: string): boolean => {
+ let parentName = resourceGetParent(resourceName);
+ for (; parentName != undefined; parentName = resourceGetParent(parentName)) {
+ if (parentName == targetName) {
+ return true;
+ }
+ }
+ return false;
+};
+
+const resourceGetParent = (resourceName: string): string | undefined => {
+ if (resourceName in ExportResources) {
+ return ExportResources[resourceName].parentName;
+ }
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ return ExportVirtuals[resourceName]?.parentName;
+};
diff --git a/src/controllers/api/inventorySlotsController.ts b/src/controllers/api/inventorySlotsController.ts
new file mode 100644
index 00000000..56bad04c
--- /dev/null
+++ b/src/controllers/api/inventorySlotsController.ts
@@ -0,0 +1,70 @@
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { getInventory, updateCurrency, updateSlots } from "../../services/inventoryService.ts";
+import type { RequestHandler } from "express";
+import { InventorySlot } from "../../types/inventoryTypes/inventoryTypes.ts";
+import { exhaustive } from "../../utils/ts-utils.ts";
+
+/*
+ loadout slots are additionally purchased slots only
+ 1 slot per mastery rank is automatically given above mr10, without database needing to save the mastery slots
+ extra = everything above the base + 2 slots (e.g. for warframes)
+ new slot = extra + 1 and slots +1
+ using slot = slots -1, except for when purchased with platinum, then slots are included in price
+
+ e.g. number of frames:
+ 19 slots, 71 extra
+ = 71 - 19 + 2 = 54
+ 19 actually available slots in ingame inventory = 17 extra + 2 Base (base amount depends on slot) (+ 1 for every mastery rank above 10)
+ number of frames = extra - slots + 2
+*/
+
+export const inventorySlotsController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const body = JSON.parse(req.body as string) as IInventorySlotsRequest;
+
+ let price;
+ let amount;
+ switch (body.Bin) {
+ case InventorySlot.SUITS:
+ case InventorySlot.MECHSUITS:
+ case InventorySlot.PVE_LOADOUTS:
+ case InventorySlot.CREWMEMBERS:
+ price = 20;
+ amount = 1;
+ break;
+
+ case InventorySlot.SPACESUITS:
+ price = 12;
+ amount = 1;
+ break;
+
+ case InventorySlot.WEAPONS:
+ case InventorySlot.SPACEWEAPONS:
+ case InventorySlot.SENTINELS:
+ case InventorySlot.RJ_COMPONENT_AND_ARMAMENTS:
+ case InventorySlot.AMPS:
+ price = 12;
+ amount = 2;
+ break;
+
+ case InventorySlot.RIVENS:
+ price = 60;
+ amount = 3;
+ break;
+
+ default:
+ exhaustive(body.Bin);
+ throw new Error(`unexpected slot purchase of type ${body.Bin as string}`);
+ }
+
+ const inventory = await getInventory(accountId);
+ const currencyChanges = updateCurrency(inventory, price, true);
+ updateSlots(inventory, body.Bin, amount, amount);
+ await inventory.save();
+
+ res.json({ InventoryChanges: currencyChanges });
+};
+
+interface IInventorySlotsRequest {
+ Bin: InventorySlot;
+}
diff --git a/src/controllers/api/joinSessionController.ts b/src/controllers/api/joinSessionController.ts
new file mode 100644
index 00000000..95cb874d
--- /dev/null
+++ b/src/controllers/api/joinSessionController.ts
@@ -0,0 +1,14 @@
+import type { RequestHandler } from "express";
+import { getSessionByID } from "../../managers/sessionManager.ts";
+import { logger } from "../../utils/logger.ts";
+
+export const joinSessionController: RequestHandler = (req, res) => {
+ const reqBody = JSON.parse(String(req.body)) as IJoinSessionRequest;
+ logger.debug(`JoinSession Request`, { reqBody });
+ const session = getSessionByID(reqBody.sessionIds[0]);
+ res.json({ rewardSeed: session?.rewardSeed, sessionId: { $oid: session?.sessionId } });
+};
+
+interface IJoinSessionRequest {
+ sessionIds: string[];
+}
diff --git a/src/controllers/api/loginController.ts b/src/controllers/api/loginController.ts
new file mode 100644
index 00000000..bff89eb7
--- /dev/null
+++ b/src/controllers/api/loginController.ts
@@ -0,0 +1,136 @@
+import type { RequestHandler } from "express";
+
+import { config, getReflexiveAddress } from "../../services/configService.ts";
+import { buildConfig } from "../../services/buildConfigService.ts";
+
+import { Account } from "../../models/loginModel.ts";
+import { createAccount, createNonce, getUsernameFromEmail, isCorrectPassword } from "../../services/loginService.ts";
+import type { IDatabaseAccountJson, ILoginRequest, ILoginResponse } from "../../types/loginTypes.ts";
+import { logger } from "../../utils/logger.ts";
+import { version_compare } from "../../helpers/inventoryHelpers.ts";
+import { sendWsBroadcastTo } from "../../services/wsService.ts";
+
+export const loginController: RequestHandler = async (request, response) => {
+ const loginRequest = JSON.parse(String(request.body)) as ILoginRequest; // parse octet stream of json data to json object
+
+ const account = await Account.findOne({ email: loginRequest.email });
+
+ const buildLabel: string =
+ typeof request.query.buildLabel == "string"
+ ? request.query.buildLabel.split(" ").join("+")
+ : buildConfig.buildLabel;
+
+ const { myAddress, myUrlBase } = getReflexiveAddress(request);
+
+ if (
+ !account &&
+ ((config.autoCreateAccount && loginRequest.ClientType != "webui") ||
+ loginRequest.ClientType == "webui-register")
+ ) {
+ try {
+ const name = await getUsernameFromEmail(loginRequest.email);
+ const newAccount = await createAccount({
+ email: loginRequest.email,
+ password: loginRequest.password,
+ DisplayName: name,
+ CountryCode: loginRequest.lang?.toUpperCase() ?? "EN",
+ ClientType: loginRequest.ClientType,
+ Nonce: createNonce(),
+ BuildLabel: buildLabel,
+ LastLogin: new Date()
+ });
+ logger.debug("created new account");
+ response.json(createLoginResponse(myAddress, myUrlBase, newAccount, buildLabel));
+ return;
+ } catch (error: unknown) {
+ if (error instanceof Error) {
+ throw new Error(`error creating account ${error.message}`);
+ }
+ }
+ }
+
+ if (!account) {
+ response.status(400).json({ error: "unknown user" });
+ return;
+ }
+
+ if (!isCorrectPassword(loginRequest.password, account.password)) {
+ response.status(400).json({ error: "incorrect login data" });
+ return;
+ }
+
+ if (account.Nonce && account.ClientType != "webui" && !account.Dropped && !loginRequest.kick) {
+ // U17 seems to handle "nonce still set" like a login failure.
+ if (version_compare(buildLabel, "2015.12.05.18.07") >= 0) {
+ response.status(400).send({ error: "nonce still set" });
+ return;
+ }
+ }
+
+ account.ClientType = loginRequest.ClientType;
+ account.Nonce = createNonce();
+ account.CountryCode = loginRequest.lang?.toUpperCase() ?? "EN";
+ account.BuildLabel = buildLabel;
+ account.LastLogin = new Date();
+ await account.save();
+
+ sendWsBroadcastTo(account._id.toString(), { nonce_updated: true });
+
+ response.json(createLoginResponse(myAddress, myUrlBase, account.toJSON(), buildLabel));
+};
+
+const createLoginResponse = (
+ myAddress: string,
+ myUrlBase: string,
+ account: IDatabaseAccountJson,
+ buildLabel: string
+): ILoginResponse => {
+ const resp: ILoginResponse = {
+ id: account.id,
+ DisplayName: account.DisplayName,
+ CountryCode: account.CountryCode,
+ AmazonAuthToken: account.AmazonAuthToken,
+ AmazonRefreshToken: account.AmazonRefreshToken,
+ Nonce: account.Nonce,
+ BuildLabel: buildLabel
+ };
+ if (version_compare(buildLabel, "2015.02.13.10.41") >= 0) {
+ resp.NRS = [myAddress];
+ }
+ if (version_compare(buildLabel, "2015.05.14.16.29") >= 0) {
+ // U17 and up
+ resp.IRC = config.myIrcAddresses ?? [myAddress];
+ }
+ if (version_compare(buildLabel, "2018.11.08.14.45") >= 0) {
+ // U24 and up
+ resp.ConsentNeeded = account.ConsentNeeded;
+ resp.TrackedSettings = account.TrackedSettings;
+ }
+ if (version_compare(buildLabel, "2019.08.29.20.01") >= 0) {
+ // U25.7 and up
+ resp.ForceLogoutVersion = account.ForceLogoutVersion;
+ }
+ if (version_compare(buildLabel, "2019.10.31.22.42") >= 0) {
+ // U26 and up
+ resp.Groups = [];
+ }
+ if (version_compare(buildLabel, "2021.04.13.19.58") >= 0) {
+ resp.DTLS = 0; // bit 0 enables DTLS. if enabled, additional bits can be set, e.g. bit 2 to enable logging. on live, the value is 99.
+ }
+ if (version_compare(buildLabel, "2022.04.29.12.53") >= 0) {
+ resp.ClientType = account.ClientType;
+ }
+ if (version_compare(buildLabel, "2022.09.06.19.24") >= 0) {
+ resp.CrossPlatformAllowed = account.CrossPlatformAllowed;
+ resp.HUB = `${myUrlBase}/api/`;
+ resp.MatchmakingBuildId = buildConfig.matchmakingBuildId;
+ }
+ if (version_compare(buildLabel, "2023.04.25.23.40") >= 0) {
+ if (version_compare(buildLabel, "2025.08.26.09.49") >= 0) {
+ resp.platformCDNs = [`${myUrlBase}/dynamic/`];
+ } else {
+ resp.platformCDNs = [`${myUrlBase}/`];
+ }
+ }
+ return resp;
+};
diff --git a/src/controllers/api/loginRewardsController.ts b/src/controllers/api/loginRewardsController.ts
new file mode 100644
index 00000000..d412a5ae
--- /dev/null
+++ b/src/controllers/api/loginRewardsController.ts
@@ -0,0 +1,57 @@
+import type { RequestHandler } from "express";
+import { getAccountForRequest } from "../../services/loginService.ts";
+import type { ILoginRewardsReponse } from "../../services/loginRewardService.ts";
+import {
+ claimLoginReward,
+ getRandomLoginRewards,
+ isLoginRewardAChoice,
+ setAccountGotLoginRewardToday
+} from "../../services/loginRewardService.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import { sendWsBroadcastTo } from "../../services/wsService.ts";
+
+export const loginRewardsController: RequestHandler = async (req, res) => {
+ const account = await getAccountForRequest(req);
+ const today = Math.trunc(Date.now() / 86400000) * 86400;
+ const isMilestoneDay = account.LoginDays == 5 || account.LoginDays % 50 == 0;
+ const nextMilestoneDay = account.LoginDays < 5 ? 5 : (Math.trunc(account.LoginDays / 50) + 1) * 50;
+
+ if (today != account.LastLoginRewardDate) {
+ const inventory = await getInventory(account._id.toString());
+ if (!inventory.disableDailyTribute) {
+ const randomRewards = getRandomLoginRewards(account, inventory);
+ const response: ILoginRewardsReponse = {
+ DailyTributeInfo: {
+ Rewards: randomRewards,
+ IsMilestoneDay: isMilestoneDay,
+ IsChooseRewardSet: randomRewards.length != 1,
+ LoginDays: account.LoginDays,
+ NextMilestoneReward: "",
+ NextMilestoneDay: nextMilestoneDay,
+ HasChosenReward: false
+ },
+ LastLoginRewardDate: today
+ };
+ if (!isMilestoneDay && randomRewards.length == 1) {
+ response.DailyTributeInfo.HasChosenReward = true;
+ response.DailyTributeInfo.ChosenReward = randomRewards[0];
+ response.DailyTributeInfo.NewInventory = await claimLoginReward(inventory, randomRewards[0]);
+ setAccountGotLoginRewardToday(account);
+ await Promise.all([inventory.save(), account.save()]);
+
+ sendWsBroadcastTo(account._id.toString(), { update_inventory: true });
+ }
+ res.json(response);
+ return;
+ }
+ }
+ res.json({
+ DailyTributeInfo: {
+ IsMilestoneDay: isMilestoneDay,
+ IsChooseRewardSet: isLoginRewardAChoice(account),
+ LoginDays: account.LoginDays,
+ NextMilestoneReward: "",
+ NextMilestoneDay: nextMilestoneDay
+ }
+ } satisfies ILoginRewardsReponse);
+};
diff --git a/src/controllers/api/loginRewardsSelectionController.ts b/src/controllers/api/loginRewardsSelectionController.ts
new file mode 100644
index 00000000..13e7ec4d
--- /dev/null
+++ b/src/controllers/api/loginRewardsSelectionController.ts
@@ -0,0 +1,65 @@
+import { getInventory } from "../../services/inventoryService.ts";
+import {
+ claimLoginReward,
+ getRandomLoginRewards,
+ setAccountGotLoginRewardToday
+} from "../../services/loginRewardService.ts";
+import { getAccountForRequest } from "../../services/loginService.ts";
+import { handleStoreItemAcquisition } from "../../services/purchaseService.ts";
+import { sendWsBroadcastTo } from "../../services/wsService.ts";
+import type { IInventoryChanges } from "../../types/purchaseTypes.ts";
+import { logger } from "../../utils/logger.ts";
+import type { RequestHandler } from "express";
+
+export const loginRewardsSelectionController: RequestHandler = async (req, res) => {
+ const account = await getAccountForRequest(req);
+ const inventory = await getInventory(account._id.toString());
+ const body = JSON.parse(String(req.body)) as ILoginRewardsSelectionRequest;
+ const isMilestoneDay = account.LoginDays == 5 || account.LoginDays % 50 == 0;
+ if (body.IsMilestoneReward != isMilestoneDay) {
+ logger.warn(`Client disagrees on login milestone (got ${body.IsMilestoneReward}, expected ${isMilestoneDay})`);
+ }
+ let chosenReward;
+ let inventoryChanges: IInventoryChanges;
+ if (body.IsMilestoneReward) {
+ chosenReward = {
+ RewardType: "RT_STORE_ITEM",
+ StoreItemType: body.ChosenReward
+ };
+ inventoryChanges = (await handleStoreItemAcquisition(body.ChosenReward, inventory)).InventoryChanges;
+ if (evergreenRewards.indexOf(body.ChosenReward) == -1) {
+ inventory.LoginMilestoneRewards.push(body.ChosenReward);
+ }
+ } else {
+ const randomRewards = getRandomLoginRewards(account, inventory);
+ chosenReward = randomRewards.find(x => x.StoreItemType == body.ChosenReward)!;
+ inventoryChanges = await claimLoginReward(inventory, chosenReward);
+ }
+ setAccountGotLoginRewardToday(account);
+ await Promise.all([inventory.save(), account.save()]);
+
+ sendWsBroadcastTo(account._id.toString(), { update_inventory: true });
+ res.json({
+ DailyTributeInfo: {
+ NewInventory: inventoryChanges,
+ ChosenReward: chosenReward
+ }
+ });
+};
+
+interface ILoginRewardsSelectionRequest {
+ ChosenReward: string;
+ IsMilestoneReward: boolean;
+}
+
+const evergreenRewards = [
+ "/Lotus/Types/StoreItems/Packages/EvergreenTripleForma",
+ "/Lotus/Types/StoreItems/Packages/EvergreenTripleRifleRiven",
+ "/Lotus/Types/StoreItems/Packages/EvergreenTripleMeleeRiven",
+ "/Lotus/Types/StoreItems/Packages/EvergreenTripleSecondaryRiven",
+ "/Lotus/Types/StoreItems/Packages/EvergreenWeaponSlots",
+ "/Lotus/Types/StoreItems/Packages/EvergreenKuva",
+ "/Lotus/Types/StoreItems/Packages/EvergreenBoosters",
+ "/Lotus/Types/StoreItems/Packages/EvergreenEndo",
+ "/Lotus/Types/StoreItems/Packages/EvergreenExilus"
+];
diff --git a/src/controllers/api/logoutController.ts b/src/controllers/api/logoutController.ts
new file mode 100644
index 00000000..d1c13c2d
--- /dev/null
+++ b/src/controllers/api/logoutController.ts
@@ -0,0 +1,32 @@
+import type { RequestHandler } from "express";
+import { Account } from "../../models/loginModel.ts";
+import { sendWsBroadcastTo } from "../../services/wsService.ts";
+
+export const logoutController: RequestHandler = async (req, res) => {
+ if (!req.query.accountId) {
+ throw new Error("Request is missing accountId parameter");
+ }
+ const nonce: number = parseInt(req.query.nonce as string);
+ if (!nonce) {
+ throw new Error("Request is missing nonce parameter");
+ }
+
+ const stat = await Account.updateOne(
+ {
+ _id: req.query.accountId,
+ Nonce: nonce
+ },
+ {
+ Nonce: 0
+ }
+ );
+ if (stat.modifiedCount) {
+ sendWsBroadcastTo(req.query.accountId as string, { nonce_updated: true });
+ }
+
+ res.writeHead(200, {
+ "Content-Type": "text/html",
+ "Content-Length": 1
+ });
+ res.end("1");
+};
diff --git a/src/controllers/api/marketRecommendationsController.ts b/src/controllers/api/marketRecommendationsController.ts
new file mode 100644
index 00000000..fd105836
--- /dev/null
+++ b/src/controllers/api/marketRecommendationsController.ts
@@ -0,0 +1,16 @@
+import type { RequestHandler } from "express";
+
+const marketRecommendationsController: RequestHandler = (_req, res) => {
+ const data = Buffer.from([
+ 0x7b, 0x22, 0x4d, 0x61, 0x72, 0x6b, 0x65, 0x74, 0x52, 0x65, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x64, 0x61,
+ 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x3a, 0x7b, 0x22, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x64, 0x22,
+ 0x3a, 0x5b, 0x5d, 0x2c, 0x22, 0x50, 0x6f, 0x70, 0x75, 0x6c, 0x61, 0x72, 0x22, 0x3a, 0x5b, 0x5d, 0x7d, 0x7d
+ ]);
+ res.writeHead(200, {
+ "Content-Type": "text/html",
+ "Content-Length": data.length
+ });
+ res.end(data);
+};
+
+export { marketRecommendationsController };
diff --git a/src/controllers/api/marketSearchRecommendationsController.ts b/src/controllers/api/marketSearchRecommendationsController.ts
new file mode 100644
index 00000000..1360a7ab
--- /dev/null
+++ b/src/controllers/api/marketSearchRecommendationsController.ts
@@ -0,0 +1,7 @@
+import type { RequestHandler } from "express";
+
+const marketSearchRecommendationsController: RequestHandler = (_req, res) => {
+ res.sendStatus(200);
+};
+
+export { marketSearchRecommendationsController };
diff --git a/src/controllers/api/maturePetController.ts b/src/controllers/api/maturePetController.ts
new file mode 100644
index 00000000..9ddc2786
--- /dev/null
+++ b/src/controllers/api/maturePetController.ts
@@ -0,0 +1,27 @@
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { RequestHandler } from "express";
+
+export const maturePetController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId, "KubrowPets");
+ const data = getJSONfromString(String(req.body));
+ const details = inventory.KubrowPets.id(data.petId)!.Details!;
+ details.IsPuppy = data.revert;
+ await inventory.save();
+ res.json({
+ petId: data.petId,
+ updateCollar: true,
+ armorSkins: ["", "", ""],
+ furPatterns: data.revert
+ ? ["", "", ""]
+ : [details.DominantTraits.FurPattern, details.DominantTraits.FurPattern, details.DominantTraits.FurPattern],
+ unmature: data.revert
+ });
+};
+
+interface IMaturePetRequest {
+ petId: string;
+ revert: boolean;
+}
diff --git a/src/controllers/api/missionInventoryUpdateController.ts b/src/controllers/api/missionInventoryUpdateController.ts
new file mode 100644
index 00000000..9902f021
--- /dev/null
+++ b/src/controllers/api/missionInventoryUpdateController.ts
@@ -0,0 +1,144 @@
+import type { RequestHandler } from "express";
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { getAccountForRequest } from "../../services/loginService.ts";
+import type { IMissionInventoryUpdateRequest } from "../../types/requestTypes.ts";
+import { addMissionInventoryUpdates, addMissionRewards } from "../../services/missionInventoryUpdateService.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import { getInventoryResponse } from "./inventoryController.ts";
+import { logger } from "../../utils/logger.ts";
+import type {
+ IMissionInventoryUpdateResponse,
+ IMissionInventoryUpdateResponseBackToDryDock,
+ IMissionInventoryUpdateResponseRailjackInterstitial
+} from "../../types/missionTypes.ts";
+import { sendWsBroadcastTo } from "../../services/wsService.ts";
+import { generateRewardSeed } from "../../services/rngService.ts";
+
+/*
+**** INPUT ****
+- [ ] crossPlaySetting
+- [ ] rewardsMultiplier
+- [ ] ActiveBoosters
+- [x] LongGuns
+- [x] Pistols
+- [x] Suits
+- [x] Melee
+- [x] RawUpgrades
+- [x] MiscItems
+- [x] RegularCredits
+- [ ] RandomUpgradesIdentified
+- [ ] MissionFailed
+- [ ] MissionStatus
+- [ ] CurrentLoadOutIds
+- [ ] AliveTime
+- [ ] MissionTime
+- [x] Missions
+- [ ] CompletedAlerts
+- [ ] LastRegionPlayed
+- [ ] GameModeId
+- [ ] hosts
+- [x] ChallengeProgress
+- [ ] SeasonChallengeHistory
+- [ ] PS (anticheat data)
+- [ ] ActiveDojoColorResearch
+- [x] RewardInfo
+- [ ] ReceivedCeremonyMsg
+- [ ] LastCeremonyResetDate
+- [ ] MissionPTS (Used to validate the mission/alive time above.)
+- [ ] RepHash
+- [ ] EndOfMatchUpload
+- [ ] ObjectiveReached
+- [ ] FpsAvg
+- [ ] FpsMin
+- [ ] FpsMax
+- [ ] FpsSamples
+*/
+//move credit calc in here, return MissionRewards: [] if no reward info
+export const missionInventoryUpdateController: RequestHandler = async (req, res): Promise => {
+ const account = await getAccountForRequest(req);
+ const missionReport = getJSONfromString((req.body as string).toString());
+ logger.debug("mission report:", missionReport);
+
+ const inventory = await getInventory(account._id.toString());
+ const firstCompletion = missionReport.SortieId
+ ? inventory.CompletedSorties.indexOf(missionReport.SortieId) == -1
+ : false;
+ const inventoryUpdates = await addMissionInventoryUpdates(account, inventory, missionReport);
+
+ if (
+ missionReport.MissionStatus !== "GS_SUCCESS" &&
+ !(
+ missionReport.RewardInfo?.jobId ||
+ missionReport.RewardInfo?.challengeMissionId ||
+ missionReport.RewardInfo?.T
+ )
+ ) {
+ if (missionReport.EndOfMatchUpload) {
+ inventory.RewardSeed = generateRewardSeed();
+ }
+ await inventory.save();
+ const inventoryResponse = await getInventoryResponse(inventory, true, account.BuildLabel);
+ res.json({
+ InventoryJson: JSON.stringify(inventoryResponse),
+ MissionRewards: []
+ });
+ sendWsBroadcastTo(account._id.toString(), { update_inventory: true });
+ return;
+ }
+
+ const {
+ MissionRewards,
+ inventoryChanges,
+ credits,
+ AffiliationMods,
+ SyndicateXPItemReward,
+ ConquestCompletedMissionsCount
+ } = await addMissionRewards(account, inventory, missionReport, firstCompletion);
+
+ if (missionReport.EndOfMatchUpload) {
+ inventory.RewardSeed = generateRewardSeed();
+ }
+ await inventory.save();
+
+ //TODO: figure out when to send inventory. it is needed for many cases.
+ const deltas: IMissionInventoryUpdateResponseRailjackInterstitial = {
+ InventoryChanges: inventoryChanges,
+ MissionRewards,
+ ...credits,
+ ...inventoryUpdates,
+ //FusionPoints: inventoryChanges?.FusionPoints, // This in combination with InventoryJson or InventoryChanges seems to just double the number of endo shown, so unsure when this is needed.
+ SyndicateXPItemReward,
+ AffiliationMods,
+ ConquestCompletedMissionsCount
+ };
+ if (missionReport.RJ) {
+ logger.debug(`railjack interstitial request, sending only deltas`, deltas);
+ res.json(deltas);
+ } else if (missionReport.RewardInfo) {
+ logger.debug(`classic mission completion, sending everything`);
+ const inventoryResponse = await getInventoryResponse(inventory, true, account.BuildLabel);
+ res.json({
+ InventoryJson: JSON.stringify(inventoryResponse),
+ ...deltas
+ } satisfies IMissionInventoryUpdateResponse);
+ } else {
+ logger.debug(`no reward info, assuming this wasn't a mission completion and we should just sync inventory`);
+ const inventoryResponse = await getInventoryResponse(inventory, true, account.BuildLabel);
+ res.json({
+ InventoryJson: JSON.stringify(inventoryResponse)
+ } satisfies IMissionInventoryUpdateResponseBackToDryDock);
+ }
+
+ sendWsBroadcastTo(account._id.toString(), { update_inventory: true });
+};
+
+/*
+**** OUTPUT ****
+- [x] InventoryJson
+- [x] MissionRewards
+- [x] TotalCredits
+- [x] CreditsBonus
+- [x] MissionCredits
+- [x] InventoryChanges
+- [x] FusionPoints
+*/
diff --git a/src/controllers/api/modularWeaponCraftingController.ts b/src/controllers/api/modularWeaponCraftingController.ts
new file mode 100644
index 00000000..7e235241
--- /dev/null
+++ b/src/controllers/api/modularWeaponCraftingController.ts
@@ -0,0 +1,201 @@
+import type { RequestHandler } from "express";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { sendWsBroadcastTo } from "../../services/wsService.ts";
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import {
+ getInventory,
+ updateCurrency,
+ addEquipment,
+ addMiscItems,
+ applyDefaultUpgrades,
+ occupySlot,
+ productCategoryToInventoryBin,
+ combineInventoryChanges,
+ addSpecialItem
+} from "../../services/inventoryService.ts";
+import type { IInventoryChanges } from "../../types/purchaseTypes.ts";
+import { getDefaultUpgrades } from "../../services/itemDataService.ts";
+import { modularWeaponTypes } from "../../helpers/modularWeaponHelper.ts";
+import { getRandomInt } from "../../services/rngService.ts";
+import type { IDefaultUpgrade } from "warframe-public-export-plus";
+import { ExportSentinels, ExportWeapons } from "warframe-public-export-plus";
+import type { IEquipmentDatabase } from "../../types/equipmentTypes.ts";
+import { Status } from "../../types/equipmentTypes.ts";
+
+interface IModularCraftRequest {
+ WeaponType: string;
+ Parts: string[];
+ isWebUi?: boolean;
+}
+
+export const modularWeaponCraftingController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const data = getJSONfromString(String(req.body));
+ if (!(data.WeaponType in modularWeaponTypes)) {
+ throw new Error(`unknown modular weapon type: ${data.WeaponType}`);
+ }
+ const category = modularWeaponTypes[data.WeaponType];
+ const inventory = await getInventory(accountId);
+
+ let defaultUpgrades: IDefaultUpgrade[] | undefined;
+ const defaultOverwrites: Partial = {
+ ModularParts: data.Parts
+ };
+ const inventoryChanges: IInventoryChanges = {};
+ if (category == "KubrowPets") {
+ const traits = {
+ "/Lotus/Types/Friendly/Pets/CreaturePets/ArmoredInfestedCatbrowPetPowerSuit": {
+ BaseColor: "/Lotus/Types/Game/InfestedKavatPet/Colors/InfestedKavatColorRareBase",
+ SecondaryColor: "/Lotus/Types/Game/InfestedKavatPet/Colors/InfestedKavatColorRareSecondary",
+ TertiaryColor: "/Lotus/Types/Game/InfestedKavatPet/Colors/InfestedKavatColorRareTertiary",
+ AccentColor: "/Lotus/Types/Game/InfestedKavatPet/Colors/InfestedKavatColorRareAccent",
+ EyeColor: "/Lotus/Types/Game/InfestedKavatPet/Colors/InfestedKavatColorRareEyes",
+ FurPattern: "/Lotus/Types/Game/InfestedKavatPet/Patterns/InfestedCritterPatternDefault",
+ Personality: data.WeaponType,
+ BodyType: "/Lotus/Types/Game/CatbrowPet/BodyTypes/InfestedCatbrowPetRegularBodyType",
+ Head: "/Lotus/Types/Game/InfestedKavatPet/Heads/InfestedCritterHeadC"
+ },
+ "/Lotus/Types/Friendly/Pets/CreaturePets/HornedInfestedCatbrowPetPowerSuit": {
+ BaseColor: "/Lotus/Types/Game/InfestedKavatPet/Colors/InfestedKavatColorUncommonBase",
+ SecondaryColor: "/Lotus/Types/Game/InfestedKavatPet/Colors/InfestedKavatColorUncommonSecondary",
+ TertiaryColor: "/Lotus/Types/Game/InfestedKavatPet/Colors/InfestedKavatColorUncommonTertiary",
+ AccentColor: "/Lotus/Types/Game/InfestedKavatPet/Colors/InfestedKavatColorUncommonAccent",
+ EyeColor: "/Lotus/Types/Game/InfestedKavatPet/Colors/InfestedKavatColorUncommonEyes",
+ FurPattern: "/Lotus/Types/Game/InfestedKavatPet/Patterns/InfestedCritterPatternDefault",
+ Personality: data.WeaponType,
+ BodyType: "/Lotus/Types/Game/CatbrowPet/BodyTypes/InfestedCatbrowPetRegularBodyType",
+ Head: "/Lotus/Types/Game/InfestedKavatPet/Heads/InfestedCritterHeadB"
+ },
+ "/Lotus/Types/Friendly/Pets/CreaturePets/MedjayPredatorKubrowPetPowerSuit": {
+ BaseColor: "/Lotus/Types/Game/InfestedPredatorPet/Colors/InfestedPredatorColorRareBase",
+ SecondaryColor: "/Lotus/Types/Game/InfestedPredatorPet/Colors/InfestedPredatorColorRareSecondary",
+ TertiaryColor: "/Lotus/Types/Game/InfestedPredatorPet/Colors/InfestedPredatorColorRareTertiary",
+ AccentColor: "/Lotus/Types/Game/InfestedPredatorPet/Colors/InfestedPredatorColorRareAccent",
+ EyeColor: "/Lotus/Types/Game/InfestedPredatorPet/Colors/InfestedPredatorColorRareEyes",
+ FurPattern: "/Lotus/Types/Game/InfestedPredatorPet/Patterns/InfestedPredatorPatternDefault",
+ Personality: data.WeaponType,
+ BodyType: "/Lotus/Types/Game/KubrowPet/BodyTypes/InfestedKubrowPetBodyType",
+ Head: "/Lotus/Types/Game/InfestedPredatorPet/Heads/InfestedPredatorHeadA"
+ },
+ "/Lotus/Types/Friendly/Pets/CreaturePets/PharaohPredatorKubrowPetPowerSuit": {
+ BaseColor: "/Lotus/Types/Game/InfestedPredatorPet/Colors/InfestedPredatorColorUncommonBase",
+ SecondaryColor: "/Lotus/Types/Game/InfestedPredatorPet/Colors/InfestedPredatorColorUncommonSecondary",
+ TertiaryColor: "/Lotus/Types/Game/InfestedPredatorPet/Colors/InfestedPredatorColorUncommonTertiary",
+ AccentColor: "/Lotus/Types/Game/InfestedPredatorPet/Colors/InfestedPredatorColorUncommonAccent",
+ EyeColor: "/Lotus/Types/Game/InfestedPredatorPet/Colors/InfestedPredatorColorUncommonEyes",
+ FurPattern: "/Lotus/Types/Game/InfestedPredatorPet/Patterns/InfestedPredatorPatternDefault",
+ Personality: data.WeaponType,
+ BodyType: "/Lotus/Types/Game/KubrowPet/BodyTypes/InfestedKubrowPetBodyType",
+ Head: "/Lotus/Types/Game/InfestedPredatorPet/Heads/InfestedPredatorHeadB"
+ },
+ "/Lotus/Types/Friendly/Pets/CreaturePets/VizierPredatorKubrowPetPowerSuit": {
+ BaseColor: "/Lotus/Types/Game/InfestedPredatorPet/Colors/InfestedPredatorColorCommonBase",
+ SecondaryColor: "/Lotus/Types/Game/InfestedPredatorPet/Colors/InfestedPredatorColorCommonSecondary",
+ TertiaryColor: "/Lotus/Types/Game/InfestedPredatorPet/Colors/InfestedPredatorColorCommonTertiary",
+ AccentColor: "/Lotus/Types/Game/InfestedPredatorPet/Colors/InfestedPredatorColorCommonAccent",
+ EyeColor: "/Lotus/Types/Game/InfestedPredatorPet/Colors/InfestedPredatorColorCommonEyes",
+ FurPattern: "/Lotus/Types/Game/InfestedPredatorPet/Patterns/InfestedPredatorPatternDefault",
+ Personality: data.WeaponType,
+ BodyType: "/Lotus/Types/Game/KubrowPet/BodyTypes/InfestedKubrowPetBodyType",
+ Head: "/Lotus/Types/Game/InfestedPredatorPet/Heads/InfestedPredatorHeadC"
+ },
+ "/Lotus/Types/Friendly/Pets/CreaturePets/VulpineInfestedCatbrowPetPowerSuit": {
+ BaseColor: "/Lotus/Types/Game/InfestedKavatPet/Colors/InfestedKavatColorCommonBase",
+ SecondaryColor: "/Lotus/Types/Game/InfestedKavatPet/Colors/InfestedKavatColorCommonSecondary",
+ TertiaryColor: "/Lotus/Types/Game/InfestedKavatPet/Colors/InfestedKavatColorCommonTertiary",
+ AccentColor: "/Lotus/Types/Game/InfestedKavatPet/Colors/InfestedKavatColorCommonAccent",
+ EyeColor: "/Lotus/Types/Game/InfestedKavatPet/Colors/InfestedKavatColorCommonEyes",
+ FurPattern: "/Lotus/Types/Game/InfestedKavatPet/Patterns/InfestedCritterPatternDefault",
+ Personality: data.WeaponType,
+ BodyType: "/Lotus/Types/Game/CatbrowPet/BodyTypes/InfestedCatbrowPetRegularBodyType",
+ Head: "/Lotus/Types/Game/InfestedKavatPet/Heads/InfestedCritterHeadA"
+ }
+ }[data.WeaponType];
+
+ if (!traits) {
+ throw new Error(`unknown KubrowPets type: ${data.WeaponType}`);
+ }
+
+ defaultOverwrites.Details = {
+ Name: "",
+ IsPuppy: false,
+ HasCollar: true,
+ PrintsRemaining: 2,
+ Status: Status.StatusStasis,
+ HatchDate: new Date(Math.trunc(Date.now() / 86400000) * 86400000),
+ IsMale: !!getRandomInt(0, 1),
+ Size: getRandomInt(70, 100) / 100,
+ DominantTraits: traits,
+ RecessiveTraits: traits
+ };
+
+ // Only save mutagen & antigen in the ModularParts.
+ defaultOverwrites.ModularParts = [data.Parts[1], data.Parts[2]];
+
+ const meta = ExportSentinels[data.WeaponType];
+
+ for (const specialItem of meta.exalted!) {
+ addSpecialItem(inventory, specialItem, inventoryChanges);
+ }
+
+ defaultUpgrades = meta.defaultUpgrades;
+ } else {
+ defaultUpgrades = getDefaultUpgrades(data.Parts);
+ }
+
+ if (category == "MoaPets") {
+ const weapon = ExportSentinels[data.WeaponType].defaultWeapon;
+ if (weapon) {
+ const category = ExportWeapons[weapon].productCategory;
+ addEquipment(inventory, category, weapon, undefined, inventoryChanges);
+ combineInventoryChanges(
+ inventoryChanges,
+ occupySlot(inventory, productCategoryToInventoryBin(category)!, !!data.isWebUi)
+ );
+ }
+ }
+ defaultOverwrites.Configs = applyDefaultUpgrades(inventory, defaultUpgrades);
+ addEquipment(inventory, category, data.WeaponType, defaultOverwrites, inventoryChanges);
+ combineInventoryChanges(
+ inventoryChanges,
+ occupySlot(inventory, productCategoryToInventoryBin(category)!, !!data.isWebUi)
+ );
+ if (defaultUpgrades) {
+ inventoryChanges.RawUpgrades = defaultUpgrades.map(x => ({ ItemType: x.ItemType, ItemCount: 1 }));
+ }
+
+ // Remove credits & parts
+ const miscItemChanges = [];
+ let currencyChanges = {};
+ if (!data.isWebUi) {
+ for (const part of data.Parts) {
+ miscItemChanges.push({
+ ItemType: part,
+ ItemCount: -1
+ });
+ }
+ currencyChanges = updateCurrency(
+ inventory,
+ category == "Hoverboards" ||
+ category == "MoaPets" ||
+ category == "LongGuns" ||
+ category == "Pistols" ||
+ category == "KubrowPets"
+ ? 5000
+ : 4000, // Definitely correct for Melee & OperatorAmps
+ false
+ );
+ addMiscItems(inventory, miscItemChanges);
+ }
+
+ await inventory.save();
+ // Tell client what we did
+ res.json({
+ InventoryChanges: {
+ ...inventoryChanges,
+ ...currencyChanges,
+ MiscItems: miscItemChanges
+ }
+ });
+ sendWsBroadcastTo(accountId, { update_inventory: true });
+};
diff --git a/src/controllers/api/modularWeaponSaleController.ts b/src/controllers/api/modularWeaponSaleController.ts
new file mode 100644
index 00000000..71b18f3f
--- /dev/null
+++ b/src/controllers/api/modularWeaponSaleController.ts
@@ -0,0 +1,186 @@
+import type { RequestHandler } from "express";
+import { ExportWeapons } from "warframe-public-export-plus";
+import type { IMongoDate } from "../../types/commonTypes.ts";
+import { toMongoDate } from "../../helpers/inventoryHelpers.ts";
+import { SRng } from "../../services/rngService.ts";
+import type { ArtifactPolarity } from "../../types/inventoryTypes/commonInventoryTypes.ts";
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import {
+ addEquipment,
+ applyDefaultUpgrades,
+ getInventory,
+ occupySlot,
+ productCategoryToInventoryBin,
+ updateCurrency
+} from "../../services/inventoryService.ts";
+import { getDefaultUpgrades } from "../../services/itemDataService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { sendWsBroadcastTo } from "../../services/wsService.ts";
+import { modularWeaponTypes } from "../../helpers/modularWeaponHelper.ts";
+import type { IInventoryChanges } from "../../types/purchaseTypes.ts";
+import { EquipmentFeatures } from "../../types/equipmentTypes.ts";
+
+export const modularWeaponSaleController: RequestHandler = async (req, res) => {
+ const partTypeToParts: Record = {};
+ for (const [uniqueName, data] of Object.entries(ExportWeapons)) {
+ if (
+ data.partType &&
+ data.premiumPrice &&
+ !data.excludeFromCodex // exclude pvp variants
+ ) {
+ partTypeToParts[data.partType] ??= [];
+ partTypeToParts[data.partType].push(uniqueName);
+ }
+ }
+
+ if (req.query.op == "SyncAll") {
+ res.json({
+ SaleInfos: getSaleInfos(partTypeToParts, Math.trunc(Date.now() / 86400000))
+ });
+ } else if (req.query.op == "Purchase") {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId);
+ const payload = getJSONfromString(String(req.body));
+ const weaponInfo = getSaleInfos(partTypeToParts, payload.Revision).find(x => x.Name == payload.SaleName)!
+ .Weapons[payload.ItemIndex];
+ const category = modularWeaponTypes[weaponInfo.ItemType];
+ const defaultUpgrades = getDefaultUpgrades(weaponInfo.ModularParts);
+ const configs = applyDefaultUpgrades(inventory, defaultUpgrades);
+ const inventoryChanges: IInventoryChanges = {
+ ...addEquipment(inventory, category, weaponInfo.ItemType, {
+ Features: EquipmentFeatures.DOUBLE_CAPACITY | EquipmentFeatures.GILDED,
+ ItemName: payload.ItemName,
+ Configs: configs,
+ ModularParts: weaponInfo.ModularParts,
+ Polarity: [
+ {
+ Slot: payload.PolarizeSlot,
+ Value: payload.PolarizeValue
+ }
+ ]
+ }),
+ ...occupySlot(inventory, productCategoryToInventoryBin(category)!, true),
+ ...updateCurrency(inventory, weaponInfo.PremiumPrice, true)
+ };
+ if (defaultUpgrades) {
+ inventoryChanges.RawUpgrades = defaultUpgrades.map(x => ({ ItemType: x.ItemType, ItemCount: 1 }));
+ }
+ await inventory.save();
+ res.json({
+ InventoryChanges: inventoryChanges
+ });
+ sendWsBroadcastTo(accountId, { update_inventory: true });
+ } else {
+ throw new Error(`unknown modularWeaponSale op: ${String(req.query.op)}`);
+ }
+};
+
+const getSaleInfos = (partTypeToParts: Record, day: number): IModularWeaponSaleInfo[] => {
+ const kitgunIsPrimary: boolean = (day & 1) != 0;
+ return [
+ getModularWeaponSale(
+ partTypeToParts,
+ day,
+ "Ostron",
+ ["LWPT_HILT", "LWPT_BLADE", "LWPT_HILT_WEIGHT"],
+ () => "/Lotus/Weapons/Ostron/Melee/LotusModularWeapon"
+ ),
+ getModularWeaponSale(
+ partTypeToParts,
+ day,
+ "SolarisUnitedHoverboard",
+ ["LWPT_HB_DECK", "LWPT_HB_ENGINE", "LWPT_HB_FRONT", "LWPT_HB_JET"],
+ () => "/Lotus/Types/Vehicles/Hoverboard/HoverboardSuit"
+ ),
+ getModularWeaponSale(
+ partTypeToParts,
+ day,
+ "SolarisUnitedMoaPet",
+ ["LWPT_MOA_LEG", "LWPT_MOA_HEAD", "LWPT_MOA_ENGINE", "LWPT_MOA_PAYLOAD"],
+ () => "/Lotus/Types/Friendly/Pets/MoaPets/MoaPetPowerSuit"
+ ),
+ getModularWeaponSale(
+ partTypeToParts,
+ day,
+ "SolarisUnitedKitGun",
+ [
+ kitgunIsPrimary ? "LWPT_GUN_PRIMARY_HANDLE" : "LWPT_GUN_SECONDARY_HANDLE",
+ "LWPT_GUN_BARREL",
+ "LWPT_GUN_CLIP"
+ ],
+ (parts: string[]) => {
+ const barrel = parts[1];
+ const gunType = ExportWeapons[barrel].gunType!;
+ if (kitgunIsPrimary) {
+ return {
+ GT_RIFLE: "/Lotus/Weapons/SolarisUnited/Primary/LotusModularPrimary",
+ GT_SHOTGUN: "/Lotus/Weapons/SolarisUnited/Primary/LotusModularPrimaryShotgun",
+ GT_BEAM: "/Lotus/Weapons/SolarisUnited/Primary/LotusModularPrimaryBeam"
+ }[gunType];
+ } else {
+ return {
+ GT_RIFLE: "/Lotus/Weapons/SolarisUnited/Secondary/LotusModularSecondary",
+ GT_SHOTGUN: "/Lotus/Weapons/SolarisUnited/Secondary/LotusModularSecondaryShotgun",
+ GT_BEAM: "/Lotus/Weapons/SolarisUnited/Secondary/LotusModularSecondaryBeam"
+ }[gunType];
+ }
+ }
+ )
+ ];
+};
+
+const priceFactor: Record = {
+ Ostron: 0.9,
+ SolarisUnitedHoverboard: 0.85,
+ SolarisUnitedMoaPet: 0.95,
+ SolarisUnitedKitGun: 0.9
+};
+
+const getModularWeaponSale = (
+ partTypeToParts: Record,
+ day: number,
+ name: string,
+ partTypes: string[],
+ getItemType: (parts: string[]) => string
+): IModularWeaponSaleInfo => {
+ const rng = new SRng(day);
+ const parts = partTypes.map(partType => rng.randomElement(partTypeToParts[partType])!);
+ let partsCost = 0;
+ for (const part of parts) {
+ partsCost += ExportWeapons[part].premiumPrice!;
+ }
+ return {
+ Name: name,
+ Expiry: toMongoDate(new Date((day + 1) * 86400000)),
+ Revision: day,
+ Weapons: [
+ {
+ ItemType: getItemType(parts),
+ PremiumPrice: Math.trunc(partsCost * priceFactor[name]),
+ ModularParts: parts
+ }
+ ]
+ };
+};
+
+interface IModularWeaponSaleInfo {
+ Name: string;
+ Expiry: IMongoDate;
+ Revision: number;
+ Weapons: IModularWeaponSaleItem[];
+}
+
+interface IModularWeaponSaleItem {
+ ItemType: string;
+ PremiumPrice: number;
+ ModularParts: string[];
+}
+
+interface IModularWeaponPurchaseRequest {
+ SaleName: string;
+ ItemIndex: number;
+ Revision: number;
+ ItemName: string;
+ PolarizeSlot: number;
+ PolarizeValue: ArtifactPolarity;
+}
diff --git a/src/controllers/api/nameWeaponController.ts b/src/controllers/api/nameWeaponController.ts
new file mode 100644
index 00000000..ece87fef
--- /dev/null
+++ b/src/controllers/api/nameWeaponController.ts
@@ -0,0 +1,32 @@
+import type { RequestHandler } from "express";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { getInventory, updateCurrency } from "../../services/inventoryService.ts";
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import type { TEquipmentKey } from "../../types/inventoryTypes/inventoryTypes.ts";
+import { sendWsBroadcastTo } from "../../services/wsService.ts";
+
+interface INameWeaponRequest {
+ ItemName: string;
+}
+
+export const nameWeaponController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId);
+ const body = getJSONfromString(String(req.body));
+ const item = inventory[req.query.Category as string as TEquipmentKey].id(req.query.ItemId as string)!;
+ if (body.ItemName != "") {
+ item.ItemName = body.ItemName;
+ } else {
+ item.ItemName = undefined;
+ }
+ const currencyChanges = updateCurrency(
+ inventory,
+ req.query.Category == "Horses" || "webui" in req.query ? 0 : 15,
+ true
+ );
+ await inventory.save();
+ res.json({
+ InventoryChanges: currencyChanges
+ });
+ sendWsBroadcastTo(accountId, { update_inventory: true });
+};
diff --git a/src/controllers/api/nemesisController.ts b/src/controllers/api/nemesisController.ts
new file mode 100644
index 00000000..71329285
--- /dev/null
+++ b/src/controllers/api/nemesisController.ts
@@ -0,0 +1,446 @@
+import { fromDbOid, version_compare } from "../../helpers/inventoryHelpers.ts";
+import type { IKnifeResponse } from "../../helpers/nemesisHelpers.ts";
+import {
+ antivirusMods,
+ decodeNemesisGuess,
+ encodeNemesisGuess,
+ getInfNodes,
+ getKnifeUpgrade,
+ getNemesisManifest,
+ getNemesisPasscode,
+ GUESS_CORRECT,
+ GUESS_INCORRECT,
+ GUESS_NEUTRAL,
+ GUESS_NONE,
+ GUESS_WILDCARD,
+ parseUpgrade
+} from "../../helpers/nemesisHelpers.ts";
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import type { TInventoryDatabaseDocument } from "../../models/inventoryModels/inventoryModel.ts";
+import { Loadout } from "../../models/inventoryModels/loadoutModel.ts";
+import { addMods, freeUpSlot, getInventory } from "../../services/inventoryService.ts";
+import { getAccountForRequest } from "../../services/loginService.ts";
+import { SRng } from "../../services/rngService.ts";
+import type { IMongoDate, IOid } from "../../types/commonTypes.ts";
+import type { IEquipmentClient } from "../../types/equipmentTypes.ts";
+import type {
+ IInnateDamageFingerprint,
+ IInventoryClient,
+ INemesisClient,
+ IUpgradeClient,
+ IWeaponSkinClient,
+ TEquipmentKey,
+ TNemesisFaction
+} from "../../types/inventoryTypes/inventoryTypes.ts";
+import { InventorySlot, LoadoutIndex } from "../../types/inventoryTypes/inventoryTypes.ts";
+import { logger } from "../../utils/logger.ts";
+import type { RequestHandler } from "express";
+import { Types } from "mongoose";
+
+export const nemesisController: RequestHandler = async (req, res) => {
+ const account = await getAccountForRequest(req);
+ if ((req.query.mode as string) == "f") {
+ const body = getJSONfromString(String(req.body));
+ const inventory = await getInventory(account._id.toString(), body.Category + " WeaponBin");
+ const destWeapon = inventory[body.Category].id(body.DestWeapon.$oid)!;
+ const sourceWeapon = inventory[body.Category].id(body.SourceWeapon.$oid)!;
+ const destFingerprint = JSON.parse(destWeapon.UpgradeFingerprint!) as IInnateDamageFingerprint;
+ const sourceFingerprint = JSON.parse(sourceWeapon.UpgradeFingerprint!) as IInnateDamageFingerprint;
+
+ // Update destination damage type if desired
+ if (body.UseSourceDmgType) {
+ destFingerprint.buffs[0].Tag = sourceFingerprint.buffs[0].Tag;
+ }
+
+ // Upgrade destination damage value
+ const destDamage = 0.25 + (destFingerprint.buffs[0].Value / 0x3fffffff) * (0.6 - 0.25);
+ const sourceDamage = 0.25 + (sourceFingerprint.buffs[0].Value / 0x3fffffff) * (0.6 - 0.25);
+ let newDamage = Math.max(destDamage, sourceDamage) * 1.1;
+ if (newDamage >= 0.5794998) {
+ newDamage = 0.6;
+ }
+ destFingerprint.buffs[0].Value = Math.trunc(((newDamage - 0.25) / (0.6 - 0.25)) * 0x3fffffff);
+
+ // Commit fingerprint
+ destWeapon.UpgradeFingerprint = JSON.stringify(destFingerprint);
+
+ // Remove source weapon
+ inventory[body.Category].pull({ _id: body.SourceWeapon.$oid });
+ freeUpSlot(inventory, InventorySlot.WEAPONS);
+
+ await inventory.save();
+ res.json({
+ InventoryChanges: {
+ [body.Category]: [destWeapon.toJSON()],
+ RemovedIdItems: [{ ItemId: body.SourceWeapon }]
+ }
+ });
+ } else if ((req.query.mode as string) == "p") {
+ const inventory = await getInventory(account._id.toString(), "Nemesis");
+ const body = getJSONfromString(String(req.body));
+ const passcode = getNemesisPasscode(inventory.Nemesis!);
+ let guessResult = 0;
+ if (inventory.Nemesis!.Faction == "FC_INFESTATION") {
+ for (let i = 0; i != 3; ++i) {
+ if (body.guess[i] == passcode[0]) {
+ guessResult = 1 + i;
+ break;
+ }
+ }
+ } else {
+ for (let i = 0; i != 3; ++i) {
+ if (body.guess[i] == passcode[i] || body.guess[i] == GUESS_WILDCARD) {
+ ++guessResult;
+ }
+ }
+ }
+ res.json({ GuessResult: guessResult });
+ } else if (req.query.mode == "r") {
+ const inventory = await getInventory(
+ account._id.toString(),
+ "Nemesis LoadOutPresets CurrentLoadOutIds DataKnives Upgrades RawUpgrades"
+ );
+ const body = getJSONfromString(String(req.body));
+ if (inventory.Nemesis!.Faction == "FC_INFESTATION") {
+ const guess: number[] = [body.guess & 0xf, (body.guess >> 4) & 0xf, (body.guess >> 8) & 0xf];
+ const passcode = getNemesisPasscode(inventory.Nemesis!)[0];
+ const result1 = passcode == guess[0] ? GUESS_CORRECT : GUESS_INCORRECT;
+ const result2 = passcode == guess[1] ? GUESS_CORRECT : GUESS_INCORRECT;
+ const result3 = passcode == guess[2] ? GUESS_CORRECT : GUESS_INCORRECT;
+ inventory.Nemesis!.GuessHistory.push(
+ encodeNemesisGuess([
+ {
+ symbol: guess[0],
+ result: result1
+ },
+ {
+ symbol: guess[1],
+ result: result2
+ },
+ {
+ symbol: guess[2],
+ result: result3
+ }
+ ])
+ );
+
+ // Increase antivirus if correct antivirus mod is installed
+ const response: IKnifeResponse = {};
+ if (result1 == GUESS_CORRECT || result2 == GUESS_CORRECT || result3 == GUESS_CORRECT) {
+ let antivirusGain = 5;
+ const loadout = (await Loadout.findById(inventory.LoadOutPresets, "DATAKNIFE"))!;
+ const dataknifeLoadout = loadout.DATAKNIFE.id(
+ fromDbOid(inventory.CurrentLoadOutIds[LoadoutIndex.DATAKNIFE])
+ );
+ const dataknifeConfigIndex = dataknifeLoadout?.s?.mod ?? 0;
+ const dataknifeUpgrades = inventory.DataKnives[0].Configs[dataknifeConfigIndex].Upgrades!;
+ for (const upgrade of body.knife!.AttachedUpgrades) {
+ switch (upgrade.ItemType) {
+ case "/Lotus/Upgrades/Mods/DataSpike/Potency/GainAntivirusAndSpeedOnUseMod":
+ case "/Lotus/Upgrades/Mods/DataSpike/Potency/GainAntivirusAndWeaponDamageOnUseMod":
+ case "/Lotus/Upgrades/Mods/DataSpike/Potency/GainAntivirusSmallOnSingleUseMod":
+ antivirusGain += 10;
+ consumeModCharge(response, inventory, upgrade, dataknifeUpgrades);
+ break;
+ case "/Lotus/Upgrades/Mods/DataSpike/Potency/GainAntivirusLargeOnSingleUseMod": // Instant Secure
+ case "/Lotus/Upgrades/Mods/DataSpike/Potency/GainAntivirusOnUseMod": // Immuno Shield
+ antivirusGain += 15;
+ consumeModCharge(response, inventory, upgrade, dataknifeUpgrades);
+ break;
+ }
+ }
+ inventory.Nemesis!.HenchmenKilled += antivirusGain;
+ if (inventory.Nemesis!.HenchmenKilled >= 100) {
+ inventory.Nemesis!.HenchmenKilled = 100;
+
+ // Weaken nemesis now.
+ inventory.Nemesis!.InfNodes = [
+ {
+ Node: getNemesisManifest(inventory.Nemesis!.manifest).showdownNode,
+ Influence: 1
+ }
+ ];
+ inventory.Nemesis!.Weakened = true;
+ const upgrade = getKnifeUpgrade(inventory, dataknifeUpgrades, antivirusMods[passcode]);
+ consumeModCharge(response, inventory, upgrade, dataknifeUpgrades);
+ }
+ }
+
+ if (inventory.Nemesis!.HenchmenKilled < 100) {
+ inventory.Nemesis!.InfNodes = getInfNodes(getNemesisManifest(inventory.Nemesis!.manifest), 0);
+ }
+
+ await inventory.save();
+ res.json(response);
+ } else {
+ // For first guess, create a new entry.
+ if (body.position == 0) {
+ inventory.Nemesis!.GuessHistory.push(
+ encodeNemesisGuess([
+ {
+ symbol: GUESS_NONE,
+ result: GUESS_NEUTRAL
+ },
+ {
+ symbol: GUESS_NONE,
+ result: GUESS_NEUTRAL
+ },
+ {
+ symbol: GUESS_NONE,
+ result: GUESS_NEUTRAL
+ }
+ ])
+ );
+ }
+
+ // Evaluate guess
+ const correct =
+ body.guess == GUESS_WILDCARD || getNemesisPasscode(inventory.Nemesis!)[body.position] == body.guess;
+
+ // Update entry
+ const guess = decodeNemesisGuess(
+ inventory.Nemesis!.GuessHistory[inventory.Nemesis!.GuessHistory.length - 1]
+ );
+ guess[body.position].symbol = body.guess;
+ guess[body.position].result = correct ? GUESS_CORRECT : GUESS_INCORRECT;
+ inventory.Nemesis!.GuessHistory[inventory.Nemesis!.GuessHistory.length - 1] = encodeNemesisGuess(guess);
+
+ const response: INemesisRequiemResponse = {};
+ if (correct) {
+ if (body.position == 2) {
+ // That was all 3 guesses correct, nemesis is now weakened.
+ inventory.Nemesis!.InfNodes = [
+ {
+ Node: getNemesisManifest(inventory.Nemesis!.manifest).showdownNode,
+ Influence: 1
+ }
+ ];
+ inventory.Nemesis!.Weakened = true;
+
+ // Subtract a charge from all requiem mods installed on parazon
+ const loadout = (await Loadout.findById(inventory.LoadOutPresets, "DATAKNIFE"))!;
+ const dataknifeLoadout = loadout.DATAKNIFE.id(
+ fromDbOid(inventory.CurrentLoadOutIds[LoadoutIndex.DATAKNIFE])
+ );
+ const dataknifeConfigIndex = dataknifeLoadout?.s?.mod ?? 0;
+ const dataknifeUpgrades = inventory.DataKnives[0].Configs[dataknifeConfigIndex].Upgrades!;
+ for (let i = 3; i != 6; ++i) {
+ //logger.debug(`subtracting a charge from ${dataknifeUpgrades[i]}`);
+ const upgrade = parseUpgrade(inventory, dataknifeUpgrades[i]);
+ consumeModCharge(response, inventory, upgrade, dataknifeUpgrades);
+ }
+ }
+ } else {
+ // Guess was incorrect, increase rank
+ response.RankIncrease = 1;
+ const manifest = getNemesisManifest(inventory.Nemesis!.manifest);
+ inventory.Nemesis!.Rank = Math.min(inventory.Nemesis!.Rank + 1, manifest.systemIndexes.length - 1);
+ inventory.Nemesis!.InfNodes = getInfNodes(manifest, inventory.Nemesis!.Rank);
+ }
+ await inventory.save();
+ res.json(response);
+ }
+ } else if ((req.query.mode as string) == "rs") {
+ // report spawn; POST but no application data in body
+ const inventory = await getInventory(account._id.toString(), "Nemesis");
+ inventory.Nemesis!.LastEnc = inventory.Nemesis!.MissionCount;
+ await inventory.save();
+ res.json({ LastEnc: inventory.Nemesis!.LastEnc });
+ } else if ((req.query.mode as string) == "s") {
+ const inventory = await getInventory(account._id.toString(), "Nemesis");
+ if (inventory.Nemesis) {
+ logger.warn(`overwriting an existing nemesis as a new one is being requested`);
+ }
+ const body = getJSONfromString(String(req.body));
+ body.target.fp = BigInt(body.target.fp);
+
+ const manifest = getNemesisManifest(body.target.manifest);
+ if (account.BuildLabel && version_compare(account.BuildLabel, manifest.minBuild) < 0) {
+ logger.warn(
+ `client on version ${account.BuildLabel} provided nemesis manifest ${body.target.manifest} which was expected to require ${manifest.minBuild} or above. please file a bug report.`
+ );
+ }
+
+ let weaponIdx = -1;
+ if (body.target.Faction != "FC_INFESTATION") {
+ const weapons: readonly string[] = manifest.weapons;
+ const initialWeaponIdx = new SRng(body.target.fp).randomInt(0, weapons.length - 1);
+ weaponIdx = initialWeaponIdx;
+ if (body.target.DisallowedWeapons) {
+ do {
+ const weapon = weapons[weaponIdx];
+ if (body.target.DisallowedWeapons.indexOf(weapon) == -1) {
+ break;
+ }
+ weaponIdx = (weaponIdx + 1) % weapons.length;
+ } while (weaponIdx != initialWeaponIdx);
+ }
+ }
+
+ inventory.Nemesis = {
+ fp: body.target.fp,
+ manifest: body.target.manifest,
+ KillingSuit: body.target.KillingSuit,
+ killingDamageType: body.target.killingDamageType,
+ ShoulderHelmet: body.target.ShoulderHelmet,
+ WeaponIdx: weaponIdx,
+ AgentIdx: body.target.AgentIdx,
+ BirthNode: body.target.BirthNode,
+ Faction: body.target.Faction,
+ Rank: 0,
+ k: false,
+ Traded: false,
+ d: new Date(),
+ InfNodes: getInfNodes(manifest, 0),
+ GuessHistory: [],
+ Hints: [],
+ HintProgress: 0,
+ Weakened: false,
+ PrevOwners: 0,
+ HenchmenKilled: 0,
+ SecondInCommand: false,
+ MissionCount: 0,
+ LastEnc: 0
+ };
+ await inventory.save();
+
+ res.json({
+ target: inventory.toJSON().Nemesis
+ });
+ } else if ((req.query.mode as string) == "w") {
+ const inventory = await getInventory(account._id.toString(), "Nemesis");
+ //const body = getJSONfromString(String(req.body));
+
+ // As of 38.6.0, this request is no longer sent, instead mode=r already weakens the nemesis if appropriate.
+ // We always weaken the nemesis in mode=r so simply giving the client back the nemesis.
+
+ const response: INemesisWeakenResponse = {
+ target: inventory.toJSON().Nemesis!
+ };
+ res.json(response);
+ } else {
+ logger.debug(`data provided to ${req.path}: ${String(req.body)}`);
+ throw new Error(`unknown nemesis mode: ${String(req.query.mode)}`);
+ }
+};
+
+interface IValenceFusionRequest {
+ DestWeapon: IOid;
+ SourceWeapon: IOid;
+ Category: TEquipmentKey;
+ UseSourceDmgType: boolean;
+}
+
+interface INemesisStartRequest {
+ target: {
+ fp: number | bigint;
+ manifest: string;
+ KillingSuit: string;
+ killingDamageType: number;
+ ShoulderHelmet: string;
+ DisallowedWeapons?: string[];
+ WeaponIdx: number;
+ AgentIdx: number;
+ BirthNode: string;
+ Faction: TNemesisFaction;
+ Rank: number;
+ k: boolean;
+ Traded: boolean;
+ d: IMongoDate;
+ InfNodes: [];
+ GuessHistory: [];
+ Hints: [];
+ HintProgress: number;
+ Weakened: boolean;
+ PrevOwners: number;
+ HenchmenKilled: number;
+ MissionCount?: number; // Added in 38.5.0
+ LastEnc?: number; // Added in 38.5.0
+ SecondInCommand: boolean;
+ };
+}
+
+interface INemesisPrespawnCheckRequest {
+ guess: number[]; // .length == 3
+ potency?: number[];
+}
+
+interface INemesisRequiemRequest {
+ guess: number; // grn/crp: 4 bits | coda: 3x 4 bits
+ position: number; // grn/crp: 0-2 | coda: 0
+ // knife field provided for coda only
+ knife?: IKnife;
+}
+
+interface INemesisRequiemResponse extends IKnifeResponse {
+ RankIncrease?: number;
+}
+
+// interface INemesisWeakenRequest {
+// target: INemesisClient;
+// knife: IKnife;
+// }
+
+interface INemesisWeakenResponse extends IKnifeResponse {
+ target: INemesisClient;
+}
+
+interface IKnife {
+ Item: IEquipmentClient;
+ Skins: IWeaponSkinClient[];
+ ModSlot: number;
+ CustSlot: number;
+ AttachedUpgrades: IUpgradeClient[];
+ HiddenWhenHolstered: boolean;
+}
+
+const consumeModCharge = (
+ response: IKnifeResponse,
+ inventory: TInventoryDatabaseDocument,
+ upgrade: { ItemId: IOid; ItemType: string },
+ dataknifeUpgrades: string[]
+): void => {
+ response.UpgradeIds ??= [];
+ response.UpgradeTypes ??= [];
+ response.UpgradeFingerprints ??= [];
+ response.UpgradeNew ??= [];
+ response.HasKnife = true;
+
+ if (upgrade.ItemId.$oid != "000000000000000000000000") {
+ const dbUpgrade = inventory.Upgrades.id(upgrade.ItemId.$oid)!;
+ const fingerprint = JSON.parse(dbUpgrade.UpgradeFingerprint!) as { lvl: number };
+ fingerprint.lvl += 1;
+ dbUpgrade.UpgradeFingerprint = JSON.stringify(fingerprint);
+
+ response.UpgradeIds.push(upgrade.ItemId.$oid);
+ response.UpgradeTypes.push(upgrade.ItemType);
+ response.UpgradeFingerprints.push(fingerprint);
+ response.UpgradeNew.push(false);
+ } else {
+ const id = new Types.ObjectId();
+ inventory.Upgrades.push({
+ _id: id,
+ ItemType: upgrade.ItemType,
+ UpgradeFingerprint: `{"lvl":1}`
+ });
+
+ addMods(inventory, [
+ {
+ ItemType: upgrade.ItemType,
+ ItemCount: -1
+ }
+ ]);
+
+ const dataknifeRawUpgradeIndex = dataknifeUpgrades.indexOf(upgrade.ItemType);
+ if (dataknifeRawUpgradeIndex != -1) {
+ dataknifeUpgrades[dataknifeRawUpgradeIndex] = id.toString();
+ } else {
+ logger.warn(`${upgrade.ItemType} not found in dataknife config`);
+ }
+
+ response.UpgradeIds.push(id.toString());
+ response.UpgradeTypes.push(upgrade.ItemType);
+ response.UpgradeFingerprints.push({ lvl: 1 });
+ response.UpgradeNew.push(true);
+ }
+};
diff --git a/src/controllers/api/placeDecoInComponentController.ts b/src/controllers/api/placeDecoInComponentController.ts
new file mode 100644
index 00000000..a56aae67
--- /dev/null
+++ b/src/controllers/api/placeDecoInComponentController.ts
@@ -0,0 +1,138 @@
+import {
+ getDojoClient,
+ getGuildForRequestEx,
+ getVaultMiscItemCount,
+ hasAccessToDojo,
+ hasGuildPermission,
+ processDojoBuildMaterialsGathered,
+ scaleRequiredCount
+} from "../../services/guildService.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { GuildPermission } from "../../types/guildTypes.ts";
+import type { RequestHandler } from "express";
+import { Types } from "mongoose";
+import { ExportDojoRecipes, ExportResources } from "warframe-public-export-plus";
+import { config } from "../../services/configService.ts";
+
+export const placeDecoInComponentController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId, "GuildId LevelKeys");
+ const guild = await getGuildForRequestEx(req, inventory);
+ if (!hasAccessToDojo(inventory) || !(await hasGuildPermission(guild, accountId, GuildPermission.Decorator))) {
+ res.json({ DojoRequestStatus: -1 });
+ return;
+ }
+ const request = JSON.parse(String(req.body)) as IPlaceDecoInComponentRequest;
+ const component = guild.DojoComponents.id(request.ComponentId)!;
+
+ if (component.DecoCapacity === undefined) {
+ component.DecoCapacity = Object.values(ExportDojoRecipes.rooms).find(
+ x => x.resultType == component.pf
+ )!.decoCapacity;
+ }
+
+ component.Decos ??= [];
+ if (request.MoveId) {
+ const deco = component.Decos.find(x => x._id.equals(request.MoveId))!;
+ deco.Pos = request.Pos;
+ deco.Rot = request.Rot;
+ deco.Scale = request.Scale;
+ } else {
+ const deco =
+ component.Decos[
+ component.Decos.push({
+ _id: new Types.ObjectId(),
+ Type: request.Type,
+ Pos: request.Pos,
+ Rot: request.Rot,
+ Scale: request.Scale,
+ Name: request.Name,
+ Sockets: request.Sockets
+ }) - 1
+ ];
+ const meta = Object.values(ExportDojoRecipes.decos).find(x => x.resultType == request.Type);
+ if (meta) {
+ if (meta.capacityCost) {
+ component.DecoCapacity -= meta.capacityCost;
+ }
+ } else {
+ const entry = Object.entries(ExportResources).find(arr => arr[1].deco == deco.Type);
+ if (!entry) {
+ throw new Error(`unknown deco type: ${deco.Type}`);
+ }
+ const [itemType, meta] = entry;
+ if (meta.dojoCapacityCost === undefined) {
+ throw new Error(`unknown deco type: ${deco.Type}`);
+ }
+ component.DecoCapacity -= meta.dojoCapacityCost;
+ if (deco.Sockets !== undefined) {
+ guild.VaultFusionTreasures!.find(x => x.ItemType == itemType && x.Sockets == deco.Sockets)!.ItemCount -=
+ 1;
+ } else {
+ guild.VaultShipDecorations!.find(x => x.ItemType == itemType)!.ItemCount -= 1;
+ }
+ }
+ if (deco.Type != "/Lotus/Objects/Tenno/Props/TnoPaintBotDojoDeco") {
+ if (!meta || (meta.price == 0 && meta.ingredients.length == 0) || config.noDojoDecoBuildStage) {
+ deco.CompletionTime = new Date();
+ if (meta) {
+ processDojoBuildMaterialsGathered(guild, meta);
+ }
+ } else if (
+ deco.Type.startsWith("/Lotus/Objects/Tenno/Dojo/NpcPlaceables/") ||
+ (guild.AutoContributeFromVault && guild.VaultRegularCredits && guild.VaultMiscItems)
+ ) {
+ if (!guild.VaultRegularCredits || !guild.VaultMiscItems) {
+ throw new Error(`dojo visitor placed without anything in vault?!`);
+ }
+ if (guild.VaultRegularCredits >= scaleRequiredCount(guild.Tier, meta.price)) {
+ let enoughMiscItems = true;
+ for (const ingredient of meta.ingredients) {
+ if (
+ getVaultMiscItemCount(guild, ingredient.ItemType) <
+ scaleRequiredCount(guild.Tier, ingredient.ItemCount)
+ ) {
+ enoughMiscItems = false;
+ break;
+ }
+ }
+ if (enoughMiscItems) {
+ guild.VaultRegularCredits -= scaleRequiredCount(guild.Tier, meta.price);
+ deco.RegularCredits = scaleRequiredCount(guild.Tier, meta.price);
+
+ deco.MiscItems = [];
+ for (const ingredient of meta.ingredients) {
+ guild.VaultMiscItems.find(x => x.ItemType == ingredient.ItemType)!.ItemCount -=
+ scaleRequiredCount(guild.Tier, ingredient.ItemCount);
+ deco.MiscItems.push({
+ ItemType: ingredient.ItemType,
+ ItemCount: scaleRequiredCount(guild.Tier, ingredient.ItemCount)
+ });
+ }
+
+ deco.CompletionTime = new Date(Date.now() + meta.time * 1000);
+ processDojoBuildMaterialsGathered(guild, meta);
+ }
+ }
+ }
+ }
+ }
+
+ await guild.save();
+ res.json(await getDojoClient(guild, 0, component._id));
+};
+
+interface IPlaceDecoInComponentRequest {
+ ComponentId: string;
+ Revision: number;
+ Type: string;
+ Pos: number[];
+ Rot: number[];
+ Scale?: number;
+ Name?: string;
+ Sockets?: number;
+ MoveId?: string;
+ ShipDeco?: boolean;
+ VaultDeco?: boolean;
+}
diff --git a/src/controllers/api/playedParkourTutorialController.ts b/src/controllers/api/playedParkourTutorialController.ts
new file mode 100644
index 00000000..6197c08f
--- /dev/null
+++ b/src/controllers/api/playedParkourTutorialController.ts
@@ -0,0 +1,9 @@
+import { Inventory } from "../../models/inventoryModels/inventoryModel.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { RequestHandler } from "express";
+
+export const playedParkourTutorialController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ await Inventory.updateOne({ accountOwnerId: accountId }, { PlayedParkourTutorial: true });
+ res.end();
+};
diff --git a/src/controllers/api/playerSkillsController.ts b/src/controllers/api/playerSkillsController.ts
new file mode 100644
index 00000000..0a75ff1b
--- /dev/null
+++ b/src/controllers/api/playerSkillsController.ts
@@ -0,0 +1,58 @@
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { addConsumables, getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { IPlayerSkills } from "../../types/inventoryTypes/inventoryTypes.ts";
+import type { IInventoryChanges } from "../../types/purchaseTypes.ts";
+import type { RequestHandler } from "express";
+
+export const playerSkillsController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId, "PlayerSkills Consumables");
+ const request = getJSONfromString(String(req.body));
+
+ const oldRank: number = inventory.PlayerSkills[request.Skill as keyof IPlayerSkills];
+ const cost = (request.Pool == "LPP_DRIFTER" ? drifterCosts[oldRank] : 1 << oldRank) * 1000;
+ inventory.PlayerSkills[request.Pool as keyof IPlayerSkills] -= cost;
+ inventory.PlayerSkills[request.Skill as keyof IPlayerSkills]++;
+
+ const inventoryChanges: IInventoryChanges = {};
+ if (request.Skill == "LPS_COMMAND") {
+ if (inventory.PlayerSkills.LPS_COMMAND == 9) {
+ const consumablesChanges = [
+ {
+ ItemType: "/Lotus/Types/Restoratives/Consumable/CrewmateBall",
+ ItemCount: 1
+ }
+ ];
+ addConsumables(inventory, consumablesChanges);
+ inventoryChanges.Consumables = consumablesChanges;
+ }
+ } else if (request.Skill == "LPS_DRIFT_RIDING") {
+ if (inventory.PlayerSkills.LPS_DRIFT_RIDING == 9) {
+ const consumablesChanges = [
+ {
+ ItemType: "/Lotus/Types/Restoratives/ErsatzSummon",
+ ItemCount: 1
+ }
+ ];
+ addConsumables(inventory, consumablesChanges);
+ inventoryChanges.Consumables = consumablesChanges;
+ }
+ }
+
+ await inventory.save();
+ res.json({
+ Pool: request.Pool,
+ PoolInc: -cost,
+ Skill: request.Skill,
+ Rank: oldRank + 1,
+ InventoryChanges: inventoryChanges
+ });
+};
+
+interface IPlayerSkillsRequest {
+ Pool: string;
+ Skill: string;
+}
+
+const drifterCosts = [20, 25, 30, 45, 65, 90, 125, 160, 205, 255];
diff --git a/src/controllers/api/postGuildAdvertisementController.ts b/src/controllers/api/postGuildAdvertisementController.ts
new file mode 100644
index 00000000..308b1f96
--- /dev/null
+++ b/src/controllers/api/postGuildAdvertisementController.ts
@@ -0,0 +1,75 @@
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { GuildAd, GuildMember } from "../../models/guildModel.ts";
+import {
+ addGuildMemberMiscItemContribution,
+ addVaultMiscItems,
+ getGuildForRequestEx,
+ getVaultMiscItemCount,
+ hasGuildPermissionEx
+} from "../../services/guildService.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { getVendorManifestByTypeName } from "../../services/serversideVendorsService.ts";
+import { GuildPermission } from "../../types/guildTypes.ts";
+import type { IPurchaseParams } from "../../types/purchaseTypes.ts";
+import type { RequestHandler } from "express";
+
+export const postGuildAdvertisementController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId, "GuildId MiscItems");
+ const guild = await getGuildForRequestEx(req, inventory);
+ const guildMember = (await GuildMember.findOne({ accountId, guildId: guild._id }))!;
+ if (!hasGuildPermissionEx(guild, guildMember, GuildPermission.Advertiser)) {
+ res.status(400).end();
+ return;
+ }
+ const payload = getJSONfromString(String(req.body));
+
+ // Handle resource cost
+ const vendor = getVendorManifestByTypeName(
+ "/Lotus/Types/Game/VendorManifests/Hubs/GuildAdvertisementVendorManifest"
+ )!;
+ const offer = vendor.VendorInfo.ItemManifest.find(x => x.StoreItem == payload.PurchaseParams.StoreItem)!;
+ if (getVaultMiscItemCount(guild, offer.ItemPrices![0].ItemType) >= offer.ItemPrices![0].ItemCount) {
+ addVaultMiscItems(guild, [
+ {
+ ItemType: offer.ItemPrices![0].ItemType,
+ ItemCount: offer.ItemPrices![0].ItemCount * -1
+ }
+ ]);
+ } else {
+ const miscItem = inventory.MiscItems.find(x => x.ItemType == offer.ItemPrices![0].ItemType);
+ if (!miscItem || miscItem.ItemCount < offer.ItemPrices![0].ItemCount) {
+ res.status(400).json("Insufficient funds");
+ return;
+ }
+ miscItem.ItemCount -= offer.ItemPrices![0].ItemCount;
+ addGuildMemberMiscItemContribution(guildMember, offer.ItemPrices![0]);
+ await guildMember.save();
+ await inventory.save();
+ }
+
+ // Create or update ad
+ await GuildAd.findOneAndUpdate(
+ { GuildId: guild._id },
+ {
+ Emblem: guild.Emblem,
+ Expiry: new Date(Date.now() + 12 * 3600 * 1000),
+ Features: payload.Features,
+ GuildName: guild.Name,
+ MemberCount: await GuildMember.countDocuments({ guildId: guild._id, status: 0 }),
+ RecruitMsg: payload.RecruitMsg,
+ Tier: guild.Tier
+ },
+ { upsert: true }
+ );
+
+ res.end();
+};
+
+interface IPostGuildAdvertisementRequest {
+ Features: number;
+ RecruitMsg: string;
+ Languages: string[];
+ PurchaseParams: IPurchaseParams;
+}
diff --git a/src/controllers/api/projectionManagerController.ts b/src/controllers/api/projectionManagerController.ts
new file mode 100644
index 00000000..366f07ef
--- /dev/null
+++ b/src/controllers/api/projectionManagerController.ts
@@ -0,0 +1,68 @@
+import type { RequestHandler } from "express";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { addMiscItems, getInventory } from "../../services/inventoryService.ts";
+import type { IRelic } from "warframe-public-export-plus";
+import { ExportRelics } from "warframe-public-export-plus";
+
+export const projectionManagerController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId, "MiscItems dontSubtractVoidTraces");
+ const request = JSON.parse(String(req.body)) as IProjectionUpgradeRequest;
+ const [era, category, currentQuality] = parseProjection(request.projectionType);
+ const upgradeCost = inventory.dontSubtractVoidTraces
+ ? 0
+ : qualityNumberToCost[request.qualityTag] - qualityNumberToCost[qualityKeywordToNumber[currentQuality]];
+ const newProjectionType = findProjection(era, category, qualityNumberToKeyword[request.qualityTag]);
+ addMiscItems(inventory, [
+ {
+ ItemType: request.projectionType,
+ ItemCount: -1
+ },
+ {
+ ItemType: newProjectionType,
+ ItemCount: 1
+ },
+ {
+ ItemType: "/Lotus/Types/Items/MiscItems/VoidTearDrop",
+ ItemCount: -upgradeCost
+ }
+ ]);
+ await inventory.save();
+ res.json({
+ prevProjection: request.projectionType,
+ upgradedProjection: newProjectionType,
+ upgradeCost: upgradeCost
+ });
+};
+
+interface IProjectionUpgradeRequest {
+ projectionType: string;
+ qualityTag: number;
+}
+
+type VoidProjectionQuality = "VPQ_BRONZE" | "VPQ_SILVER" | "VPQ_GOLD" | "VPQ_PLATINUM";
+
+const qualityNumberToKeyword: VoidProjectionQuality[] = ["VPQ_BRONZE", "VPQ_SILVER", "VPQ_GOLD", "VPQ_PLATINUM"];
+const qualityKeywordToNumber: Record = {
+ VPQ_BRONZE: 0,
+ VPQ_SILVER: 1,
+ VPQ_GOLD: 2,
+ VPQ_PLATINUM: 3
+};
+const qualityNumberToCost = [0, 25, 50, 100];
+
+// e.g. "/Lotus/Types/Game/Projections/T2VoidProjectionProteaPrimeDBronze" -> ["Lith", "W5", "VPQ_BRONZE"]
+const parseProjection = (typeName: string): [string, string, VoidProjectionQuality] => {
+ const relic: IRelic | undefined = ExportRelics[typeName];
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ if (!relic) {
+ throw new Error(`Unknown projection ${typeName}`);
+ }
+ return [relic.era, relic.category, relic.quality];
+};
+
+const findProjection = (era: string, category: string, quality: VoidProjectionQuality): string => {
+ return Object.entries(ExportRelics).find(
+ ([_, relic]) => relic.era == era && relic.category == category && relic.quality == quality
+ )![0];
+};
diff --git a/src/controllers/api/purchaseController.ts b/src/controllers/api/purchaseController.ts
new file mode 100644
index 00000000..4658da51
--- /dev/null
+++ b/src/controllers/api/purchaseController.ts
@@ -0,0 +1,71 @@
+import type { RequestHandler } from "express";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { IPurchaseRequest } from "../../types/purchaseTypes.ts";
+import { handlePurchase } from "../../services/purchaseService.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import { sendWsBroadcastTo } from "../../services/wsService.ts";
+import { logger } from "../../utils/logger.ts";
+
+export const purchaseController: RequestHandler = async (req, res) => {
+ const purchaseRequest = JSON.parse(String(req.body)) as IPurchaseRequest;
+ const accountId = await getAccountIdForRequest(req);
+
+ try {
+ const inventory = await getInventory(accountId);
+
+ // 记录购买前的货币状态
+ const beforePurchase = {
+ RegularCredits: inventory.RegularCredits,
+ PremiumCredits: inventory.PremiumCredits,
+ PremiumCreditsFree: inventory.PremiumCreditsFree
+ };
+
+ logger.debug(`Purchase attempt - Account: ${accountId}, Item: ${purchaseRequest.PurchaseParams.StoreItem}, Price: ${purchaseRequest.PurchaseParams.ExpectedPrice}, UsePremium: ${purchaseRequest.PurchaseParams.UsePremium}`);
+ logger.debug(`Currency before purchase:`, beforePurchase);
+
+ const response = await handlePurchase(purchaseRequest, inventory);
+ await inventory.save();
+
+ // 记录购买后的货币状态
+ const afterPurchase = {
+ RegularCredits: inventory.RegularCredits,
+ PremiumCredits: inventory.PremiumCredits,
+ PremiumCreditsFree: inventory.PremiumCreditsFree
+ };
+
+ logger.debug(`Currency after purchase:`, afterPurchase);
+
+ res.json(response);
+
+ // 立即发送货币更新广播,确保客户端实时同步
+ sendWsBroadcastTo(accountId, {
+ update_inventory: true,
+ currency_update: {
+ RegularCredits: inventory.RegularCredits,
+ PremiumCredits: inventory.PremiumCredits,
+ PremiumCreditsFree: inventory.PremiumCreditsFree
+ }
+ });
+
+ } catch (error) {
+ logger.error(`Purchase failed for account ${accountId}:`, error);
+
+ // 返回详细的错误信息给客户端
+ if (error instanceof Error && error.message.includes('Insufficient')) {
+ res.status(400).json({
+ error: 'INSUFFICIENT_CURRENCY',
+ message: error.message,
+ details: {
+ item: purchaseRequest.PurchaseParams.StoreItem,
+ price: purchaseRequest.PurchaseParams.ExpectedPrice,
+ usePremium: purchaseRequest.PurchaseParams.UsePremium
+ }
+ });
+ } else {
+ res.status(500).json({
+ error: 'PURCHASE_FAILED',
+ message: error instanceof Error ? error.message : 'Unknown error occurred'
+ });
+ }
+ }
+};
diff --git a/src/controllers/api/questControlController.ts b/src/controllers/api/questControlController.ts
new file mode 100644
index 00000000..6750fdf6
--- /dev/null
+++ b/src/controllers/api/questControlController.ts
@@ -0,0 +1,25 @@
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { RequestHandler } from "express";
+
+// Basic shim handling action=sync to login on U21
+export const questControlController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId);
+ const quests: IQuestState[] = [];
+ for (const quest of inventory.QuestKeys) {
+ quests.push({
+ quest: quest.ItemType,
+ state: 3 // COMPLETE
+ });
+ }
+ res.json({
+ QuestState: quests
+ });
+};
+
+interface IQuestState {
+ quest: string;
+ state: number;
+ task?: string;
+}
diff --git a/src/controllers/api/queueDojoComponentDestructionController.ts b/src/controllers/api/queueDojoComponentDestructionController.ts
new file mode 100644
index 00000000..98cddd70
--- /dev/null
+++ b/src/controllers/api/queueDojoComponentDestructionController.ts
@@ -0,0 +1,29 @@
+import { config } from "../../services/configService.ts";
+import {
+ getDojoClient,
+ getGuildForRequestEx,
+ hasAccessToDojo,
+ hasGuildPermission
+} from "../../services/guildService.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { GuildPermission } from "../../types/guildTypes.ts";
+import type { RequestHandler } from "express";
+
+export const queueDojoComponentDestructionController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId, "GuildId LevelKeys");
+ const guild = await getGuildForRequestEx(req, inventory);
+ if (!hasAccessToDojo(inventory) || !(await hasGuildPermission(guild, accountId, GuildPermission.Architect))) {
+ res.json({ DojoRequestStatus: -1 });
+ return;
+ }
+ const componentId = req.query.componentId as string;
+
+ guild.DojoComponents.id(componentId)!.DestructionTime = new Date(
+ (Math.trunc(Date.now() / 1000) + (config.fastDojoRoomDestruction ? 5 : 2 * 3600)) * 1000
+ );
+
+ await guild.save();
+ res.json(await getDojoClient(guild, 0, componentId));
+};
diff --git a/src/controllers/api/redeemPromoCodeController.ts b/src/controllers/api/redeemPromoCodeController.ts
new file mode 100644
index 00000000..b81177b8
--- /dev/null
+++ b/src/controllers/api/redeemPromoCodeController.ts
@@ -0,0 +1,34 @@
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import type { RequestHandler } from "express";
+import glyphCodes from "../../../static/fixed_responses/glyphsCodes.json" with { type: "json" };
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { addItem, getInventory } from "../../services/inventoryService.ts";
+
+export const redeemPromoCodeController: RequestHandler = async (req, res) => {
+ const body = getJSONfromString(String(req.body));
+ if (!(body.codeId in glyphCodes)) {
+ res.status(400).send("INVALID_CODE").end();
+ return;
+ }
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId, "FlavourItems");
+ const acquiredGlyphs: string[] = [];
+ for (const glyph of (glyphCodes as Record)[body.codeId]) {
+ if (!inventory.FlavourItems.find(x => x.ItemType == glyph)) {
+ acquiredGlyphs.push(glyph);
+ await addItem(inventory, glyph);
+ }
+ }
+ if (acquiredGlyphs.length == 0) {
+ res.status(400).send("USED_CODE").end();
+ return;
+ }
+ await inventory.save();
+ res.json({
+ FlavourItems: acquiredGlyphs
+ });
+};
+
+interface IRedeemPromoCodeRequest {
+ codeId: string;
+}
diff --git a/src/controllers/api/releasePetController.ts b/src/controllers/api/releasePetController.ts
new file mode 100644
index 00000000..fb707b7a
--- /dev/null
+++ b/src/controllers/api/releasePetController.ts
@@ -0,0 +1,29 @@
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { getInventory, updateCurrency } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { sendWsBroadcastTo } from "../../services/wsService.ts";
+import type { RequestHandler } from "express";
+
+export const releasePetController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId, "RegularCredits KubrowPets");
+ const payload = getJSONfromString(String(req.body));
+
+ const inventoryChanges = updateCurrency(
+ inventory,
+ payload.recipeName == "/Lotus/Types/Game/KubrowPet/ReleasePetRecipe" ? 25000 : 0,
+ false
+ );
+
+ inventoryChanges.RemovedIdItems = [{ ItemId: { $oid: payload.petId } }];
+ inventory.KubrowPets.pull({ _id: payload.petId });
+
+ await inventory.save();
+ res.json({ inventoryChanges }); // Not a mistake; it's "inventoryChanges" here.
+ sendWsBroadcastTo(accountId, { update_inventory: true });
+};
+
+interface IReleasePetRequest {
+ recipeName: "/Lotus/Types/Game/KubrowPet/ReleasePetRecipe" | "webui";
+ petId: string;
+}
diff --git a/src/controllers/api/removeFriendController.ts b/src/controllers/api/removeFriendController.ts
new file mode 100644
index 00000000..0631921a
--- /dev/null
+++ b/src/controllers/api/removeFriendController.ts
@@ -0,0 +1,99 @@
+import { toOid } from "../../helpers/inventoryHelpers.ts";
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { Friendship } from "../../models/friendModel.ts";
+import { Account } from "../../models/loginModel.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { IOid } from "../../types/commonTypes.ts";
+import { parallelForeach } from "../../utils/async-utils.ts";
+import type { RequestHandler } from "express";
+import type { Types } from "mongoose";
+
+export const removeFriendGetController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ if (req.query.all) {
+ const [internalFriendships, externalFriendships] = await Promise.all([
+ Friendship.find({ owner: accountId }, "friend"),
+ Friendship.find({ friend: accountId }, "owner")
+ ]);
+ const promises: Promise[] = [];
+ const friends: IOid[] = [];
+ for (const externalFriendship of externalFriendships) {
+ if (!internalFriendships.find(x => x.friend.equals(externalFriendship.owner))) {
+ promises.push(Friendship.deleteOne({ _id: externalFriendship._id }) as unknown as Promise);
+ friends.push(toOid(externalFriendship.owner));
+ }
+ }
+ await Promise.all(promises);
+ res.json({
+ Friends: friends
+ } satisfies IRemoveFriendsResponse);
+ } else {
+ const friendId = req.query.friendId as string;
+ await Promise.all([
+ Friendship.deleteOne({ owner: accountId, friend: friendId }),
+ Friendship.deleteOne({ owner: friendId, friend: accountId })
+ ]);
+ res.json({
+ Friends: [{ $oid: friendId }]
+ } satisfies IRemoveFriendsResponse);
+ }
+};
+
+export const removeFriendPostController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const data = getJSONfromString(String(req.body));
+ const friends = new Set((await Friendship.find({ owner: accountId }, "friend")).map(x => x.friend));
+ // TOVERIFY: Should pending friendships also be kept?
+
+ // Keep friends that have been online within threshold
+ await parallelForeach([...friends], async friend => {
+ const account = (await Account.findById(friend, "LastLogin"))!;
+ const daysLoggedOut = (Date.now() - account.LastLogin.getTime()) / 86400_000;
+ if (daysLoggedOut < data.DaysLoggedOut) {
+ friends.delete(friend);
+ }
+ });
+
+ if (data.SkipClanmates) {
+ const inventory = await getInventory(accountId, "GuildId");
+ if (inventory.GuildId) {
+ await parallelForeach([...friends], async friend => {
+ const friendInventory = await getInventory(friend.toString(), "GuildId");
+ if (friendInventory.GuildId?.equals(inventory.GuildId)) {
+ friends.delete(friend);
+ }
+ });
+ }
+ }
+
+ // Remove all remaining friends that aren't in SkipFriendIds & give response.
+ const promises = [];
+ const response: IOid[] = [];
+ for (const friend of friends) {
+ if (!data.SkipFriendIds.find(skipFriendId => checkFriendId(skipFriendId, friend))) {
+ promises.push(Friendship.deleteOne({ owner: accountId, friend: friend }));
+ promises.push(Friendship.deleteOne({ owner: friend, friend: accountId }));
+ response.push(toOid(friend));
+ }
+ }
+ await Promise.all(promises);
+ res.json({
+ Friends: response
+ } satisfies IRemoveFriendsResponse);
+};
+
+// The friend ids format is a bit weird, e.g. when 6633b81e9dba0b714f28ff02 (A) is friends with 67cdac105ef1f4b49741c267 (B), A's friend id for B is 808000105ef1f40560ca079e and B's friend id for A is 8000b81e9dba0b06408a8075.
+const checkFriendId = (friendId: string, b: Types.ObjectId): boolean => {
+ return friendId.substring(6, 6 + 8) == b.toString().substring(6, 6 + 8);
+};
+
+interface IBatchRemoveFriendsRequest {
+ DaysLoggedOut: number;
+ SkipClanmates: boolean;
+ SkipFriendIds: string[];
+}
+
+interface IRemoveFriendsResponse {
+ Friends: IOid[];
+}
diff --git a/src/controllers/api/removeFromAllianceController.ts b/src/controllers/api/removeFromAllianceController.ts
new file mode 100644
index 00000000..c425a24d
--- /dev/null
+++ b/src/controllers/api/removeFromAllianceController.ts
@@ -0,0 +1,38 @@
+import { AllianceMember, Guild, GuildMember } from "../../models/guildModel.ts";
+import { deleteAlliance } from "../../services/guildService.ts";
+import { getAccountForRequest } from "../../services/loginService.ts";
+import { GuildPermission } from "../../types/guildTypes.ts";
+import type { RequestHandler } from "express";
+
+export const removeFromAllianceController: RequestHandler = async (req, res) => {
+ // Check requester is a warlord in their guild
+ const account = await getAccountForRequest(req);
+ const guildMember = (await GuildMember.findOne({ accountId: account._id, status: 0 }))!;
+ if (guildMember.rank > 1) {
+ res.status(400).json({ Error: 104 });
+ return;
+ }
+
+ let allianceMember = (await AllianceMember.findOne({ guildId: guildMember.guildId }))!;
+ if (!guildMember.guildId.equals(req.query.guildId as string)) {
+ // Removing a guild that is not our own needs additional permissions
+ if (!(allianceMember.Permissions & GuildPermission.Ruler)) {
+ res.status(400).json({ Error: 104 });
+ return;
+ }
+
+ // Update allianceMember to point to the alliance to kick
+ allianceMember = (await AllianceMember.findOne({ guildId: req.query.guildId }))!;
+ }
+
+ if (allianceMember.Permissions & GuildPermission.Ruler) {
+ await deleteAlliance(allianceMember.allianceId);
+ } else {
+ await Promise.all([
+ await Guild.updateOne({ _id: allianceMember.guildId }, { $unset: { AllianceId: "" } }),
+ await AllianceMember.deleteOne({ _id: allianceMember._id })
+ ]);
+ }
+
+ res.end();
+};
diff --git a/src/controllers/api/removeFromGuildController.ts b/src/controllers/api/removeFromGuildController.ts
new file mode 100644
index 00000000..9535d4d7
--- /dev/null
+++ b/src/controllers/api/removeFromGuildController.ts
@@ -0,0 +1,93 @@
+import { GuildMember } from "../../models/guildModel.ts";
+import { Inbox } from "../../models/inboxModel.ts";
+import { Account } from "../../models/loginModel.ts";
+import {
+ deleteGuild,
+ getGuildForRequest,
+ hasGuildPermission,
+ removeDojoKeyItems
+} from "../../services/guildService.ts";
+import { createMessage } from "../../services/inboxService.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountForRequest, getSuffixedName } from "../../services/loginService.ts";
+import { GuildPermission } from "../../types/guildTypes.ts";
+import type { RequestHandler } from "express";
+
+export const removeFromGuildController: RequestHandler = async (req, res) => {
+ const account = await getAccountForRequest(req);
+ const guild = await getGuildForRequest(req);
+ const payload = JSON.parse(String(req.body)) as IRemoveFromGuildRequest;
+ const isKick = !account._id.equals(payload.userId);
+ if (isKick && !(await hasGuildPermission(guild, account._id, GuildPermission.Regulator))) {
+ res.status(400).json("Invalid permission");
+ return;
+ }
+
+ const guildMember = (await GuildMember.findOne({ accountId: payload.userId, guildId: guild._id }))!;
+ if (guildMember.rank == 0) {
+ await deleteGuild(guild._id);
+ } else {
+ if (guildMember.status == 0) {
+ const inventory = await getInventory(payload.userId, "GuildId LevelKeys Recipes");
+ inventory.GuildId = undefined;
+ removeDojoKeyItems(inventory);
+ await inventory.save();
+ } else if (guildMember.status == 1) {
+ // TOVERIFY: Is this inbox message actually sent on live?
+ await createMessage(guildMember.accountId, [
+ {
+ sndr: "/Lotus/Language/Bosses/Ordis",
+ msg: "/Lotus/Language/Clan/RejectedFromClan",
+ sub: "/Lotus/Language/Clan/RejectedFromClanHeader",
+ arg: [
+ {
+ Key: "PLAYER_NAME",
+ Tag: (await Account.findOne({ _id: guildMember.accountId }, "DisplayName"))!.DisplayName
+ },
+ {
+ Key: "CLAN_NAME",
+ Tag: guild.Name
+ }
+ ]
+ // TOVERIFY: If this message is sent on live, is it highPriority?
+ }
+ ]);
+ } else if (guildMember.status == 2) {
+ // Delete the inbox message for the invite
+ await Inbox.deleteOne({
+ ownerId: guildMember.accountId,
+ contextInfo: guild._id.toString(),
+ acceptAction: "GUILD_INVITE"
+ });
+ }
+ await GuildMember.deleteOne({ _id: guildMember._id });
+
+ guild.RosterActivity ??= [];
+ if (isKick) {
+ const kickee = (await Account.findById(payload.userId))!;
+ guild.RosterActivity.push({
+ dateTime: new Date(),
+ entryType: 12,
+ details: getSuffixedName(kickee) + "," + getSuffixedName(account)
+ });
+ } else {
+ guild.RosterActivity.push({
+ dateTime: new Date(),
+ entryType: 7,
+ details: getSuffixedName(account)
+ });
+ }
+ await guild.save();
+ }
+
+ res.json({
+ _id: payload.userId,
+ ItemToRemove: "/Lotus/Types/Keys/DojoKey",
+ RecipeToRemove: "/Lotus/Types/Keys/DojoKeyBlueprint"
+ });
+};
+
+interface IRemoveFromGuildRequest {
+ userId: string;
+ kicker?: string;
+}
diff --git a/src/controllers/api/removeIgnoredUserController.ts b/src/controllers/api/removeIgnoredUserController.ts
new file mode 100644
index 00000000..21e0722b
--- /dev/null
+++ b/src/controllers/api/removeIgnoredUserController.ts
@@ -0,0 +1,21 @@
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { Account, Ignore } from "../../models/loginModel.ts";
+import { getAccountForRequest } from "../../services/loginService.ts";
+import type { RequestHandler } from "express";
+
+export const removeIgnoredUserController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountForRequest(req);
+ const data = getJSONfromString(String(req.body));
+ const ignoreeAccount = await Account.findOne(
+ { DisplayName: data.playerName.substring(0, data.playerName.length - 1) },
+ "_id"
+ );
+ if (ignoreeAccount) {
+ await Ignore.deleteOne({ ignorer: accountId, ignoree: ignoreeAccount._id });
+ }
+ res.end();
+};
+
+interface IRemoveIgnoredUserRequest {
+ playerName: string;
+}
diff --git a/src/controllers/api/renamePetController.ts b/src/controllers/api/renamePetController.ts
new file mode 100644
index 00000000..d1f0cc3b
--- /dev/null
+++ b/src/controllers/api/renamePetController.ts
@@ -0,0 +1,32 @@
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { getInventory, updateCurrency } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { sendWsBroadcastTo } from "../../services/wsService.ts";
+import type { IInventoryChanges } from "../../types/purchaseTypes.ts";
+import type { RequestHandler } from "express";
+
+export const renamePetController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId, "KubrowPets PremiumCredits PremiumCreditsFree");
+ const data = getJSONfromString(String(req.body));
+ const details = inventory.KubrowPets.id(data.petId)!.Details!;
+
+ details.Name = data.name;
+
+ const inventoryChanges: IInventoryChanges = {};
+ if (!("webui" in req.query)) {
+ updateCurrency(inventory, 15, true, inventoryChanges);
+ }
+
+ await inventory.save();
+ res.json({
+ ...data,
+ inventoryChanges: inventoryChanges
+ });
+ sendWsBroadcastTo(accountId, { update_inventory: true });
+};
+
+interface IRenamePetRequest {
+ petId: string;
+ name: string;
+}
diff --git a/src/controllers/api/rerollRandomModController.ts b/src/controllers/api/rerollRandomModController.ts
new file mode 100644
index 00000000..cbed0276
--- /dev/null
+++ b/src/controllers/api/rerollRandomModController.ts
@@ -0,0 +1,84 @@
+import type { RequestHandler } from "express";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { addMiscItems, getInventory } from "../../services/inventoryService.ts";
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import type { RivenFingerprint } from "../../helpers/rivenHelper.ts";
+import { createUnveiledRivenFingerprint, randomiseRivenStats } from "../../helpers/rivenHelper.ts";
+import { ExportUpgrades } from "warframe-public-export-plus";
+import type { IOid } from "../../types/commonTypes.ts";
+
+export const rerollRandomModController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const request = getJSONfromString(String(req.body));
+ if ("ItemIds" in request) {
+ const inventory = await getInventory(accountId, "Upgrades MiscItems");
+ const changes: IChange[] = [];
+ let totalKuvaCost = 0;
+ request.ItemIds.forEach(itemId => {
+ const upgrade = inventory.Upgrades.id(itemId)!;
+ const fingerprint = JSON.parse(upgrade.UpgradeFingerprint!) as RivenFingerprint;
+ if ("challenge" in fingerprint) {
+ upgrade.UpgradeFingerprint = JSON.stringify(
+ createUnveiledRivenFingerprint(ExportUpgrades[upgrade.ItemType])
+ );
+ } else {
+ fingerprint.rerolls ??= 0;
+ const kuvaCost = fingerprint.rerolls < rerollCosts.length ? rerollCosts[fingerprint.rerolls] : 3500;
+ totalKuvaCost += kuvaCost;
+ addMiscItems(inventory, [
+ {
+ ItemType: "/Lotus/Types/Items/MiscItems/Kuva",
+ ItemCount: kuvaCost * -1
+ }
+ ]);
+
+ fingerprint.rerolls++;
+ upgrade.UpgradeFingerprint = JSON.stringify(fingerprint);
+
+ randomiseRivenStats(ExportUpgrades[upgrade.ItemType], fingerprint);
+ upgrade.PendingRerollFingerprint = JSON.stringify(fingerprint);
+ }
+
+ changes.push({
+ ItemId: { $oid: request.ItemIds[0] },
+ UpgradeFingerprint: upgrade.UpgradeFingerprint,
+ PendingRerollFingerprint: upgrade.PendingRerollFingerprint
+ });
+ });
+
+ await inventory.save();
+
+ res.json({
+ changes: changes,
+ cost: totalKuvaCost
+ });
+ } else {
+ const inventory = await getInventory(accountId, "Upgrades");
+ const upgrade = inventory.Upgrades.id(request.ItemId)!;
+ if (request.CommitReroll && upgrade.PendingRerollFingerprint) {
+ upgrade.UpgradeFingerprint = upgrade.PendingRerollFingerprint;
+ }
+ upgrade.PendingRerollFingerprint = undefined;
+ await inventory.save();
+ res.send(upgrade.UpgradeFingerprint);
+ }
+};
+
+type RerollRandomModRequest = LetsGoGamblingRequest | AwDangitRequest;
+
+interface LetsGoGamblingRequest {
+ ItemIds: string[];
+}
+
+interface AwDangitRequest {
+ ItemId: string;
+ CommitReroll: boolean;
+}
+
+interface IChange {
+ ItemId: IOid;
+ UpgradeFingerprint?: string;
+ PendingRerollFingerprint?: string;
+}
+
+const rerollCosts = [900, 1000, 1200, 1400, 1700, 2000, 2350, 2750, 3150];
diff --git a/src/controllers/api/resetQuestProgressController.ts b/src/controllers/api/resetQuestProgressController.ts
new file mode 100644
index 00000000..ecb893fe
--- /dev/null
+++ b/src/controllers/api/resetQuestProgressController.ts
@@ -0,0 +1,5 @@
+import type { RequestHandler } from "express";
+
+export const resetQuestProgressController: RequestHandler = (_req, res) => {
+ res.send("1").end();
+};
diff --git a/src/controllers/api/retrievePetFromStasisController.ts b/src/controllers/api/retrievePetFromStasisController.ts
new file mode 100644
index 00000000..be4194fc
--- /dev/null
+++ b/src/controllers/api/retrievePetFromStasisController.ts
@@ -0,0 +1,33 @@
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { Status } from "../../types/equipmentTypes.ts";
+import type { RequestHandler } from "express";
+
+export const retrievePetFromStasisController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId, "KubrowPets");
+ const data = getJSONfromString(String(req.body));
+
+ let oldPetId: string | undefined;
+ for (const pet of inventory.KubrowPets) {
+ if (pet.Details!.Status == Status.StatusAvailable) {
+ pet.Details!.Status = Status.StatusStasis;
+ oldPetId = pet._id.toString();
+ break;
+ }
+ }
+
+ inventory.KubrowPets.id(data.petId)!.Details!.Status = Status.StatusAvailable;
+
+ await inventory.save();
+ res.json({
+ petId: data.petId,
+ oldPetId,
+ status: Status.StatusAvailable
+ });
+};
+
+interface IRetrievePetFromStasisRequest {
+ petId: string;
+}
diff --git a/src/controllers/api/saveDialogueController.ts b/src/controllers/api/saveDialogueController.ts
new file mode 100644
index 00000000..0df66a65
--- /dev/null
+++ b/src/controllers/api/saveDialogueController.ts
@@ -0,0 +1,105 @@
+import { addEmailItem, getDialogue, getInventory, updateCurrency } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { ICompletedDialogue } from "../../types/inventoryTypes/inventoryTypes.ts";
+import type { IInventoryChanges } from "../../types/purchaseTypes.ts";
+import type { RequestHandler } from "express";
+
+export const saveDialogueController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const request = JSON.parse(String(req.body)) as SaveDialogueRequest;
+ if ("YearIteration" in request) {
+ const inventory = await getInventory(accountId, "DialogueHistory noKimCooldowns");
+ inventory.DialogueHistory ??= {};
+ inventory.DialogueHistory.YearIteration = request.YearIteration;
+ await inventory.save();
+ res.end();
+ } else {
+ const inventory = await getInventory(accountId);
+ const inventoryChanges: IInventoryChanges = {};
+ const tomorrowAt0Utc = inventory.noKimCooldowns
+ ? Date.now()
+ : (Math.trunc(Date.now() / 86400_000) + 1) * 86400_000;
+ const dialogue = getDialogue(inventory, request.DialogueName);
+ dialogue.Rank = request.Rank;
+ dialogue.Chemistry += request.Chemistry;
+ dialogue.QueuedDialogues = request.QueuedDialogues;
+ for (const bool of request.Booleans) {
+ dialogue.Booleans.push(bool);
+ if (bool == "LizzieShawzin") {
+ await addEmailItem(
+ inventory,
+ "/Lotus/Types/Items/EmailItems/LizzieShawzinSkinEmailItem",
+ inventoryChanges
+ );
+ }
+ }
+ for (const bool of request.ResetBooleans) {
+ const index = dialogue.Booleans.findIndex(x => x == bool);
+ if (index != -1) {
+ dialogue.Booleans.splice(index, 1);
+ }
+ }
+ for (const info of request.OtherDialogueInfos) {
+ const otherDialogue = getDialogue(inventory, info.Dialogue);
+ if (info.Tag != "") {
+ otherDialogue.QueuedDialogues.push(info.Tag);
+ }
+ otherDialogue.Chemistry += info.Value; // unsure
+ }
+ if (request.Data) {
+ dialogue.Completed.push(request.Data);
+ dialogue.AvailableDate = new Date(tomorrowAt0Utc);
+ await inventory.save();
+ res.json({
+ InventoryChanges: inventoryChanges,
+ AvailableDate: { $date: { $numberLong: tomorrowAt0Utc.toString() } }
+ });
+ } else if (request.Gift) {
+ const inventoryChanges = updateCurrency(inventory, request.Gift.Cost, false);
+ const gift = dialogue.Gifts.find(x => x.Item == request.Gift!.Item);
+ if (gift) {
+ gift.GiftedQuantity += 1;
+ } else {
+ dialogue.Gifts.push({ Item: request.Gift.Item, GiftedQuantity: 1 });
+ }
+ dialogue.AvailableGiftDate = new Date(tomorrowAt0Utc);
+ await inventory.save();
+ res.json({
+ InventoryChanges: inventoryChanges,
+ AvailableGiftDate: { $date: { $numberLong: tomorrowAt0Utc.toString() } }
+ });
+ } else {
+ res.end();
+ }
+ }
+};
+
+type SaveDialogueRequest = SaveYearIterationRequest | SaveCompletedDialogueRequest;
+
+interface SaveYearIterationRequest {
+ YearIteration: number;
+}
+
+interface SaveCompletedDialogueRequest {
+ DialogueName: string;
+ Rank: number;
+ Chemistry: number;
+ CompletionType: number;
+ QueuedDialogues: string[];
+ Gift?: {
+ Item: string;
+ GainedChemistry: number;
+ Cost: number;
+ GiftedQuantity: number;
+ };
+ Booleans: string[];
+ ResetBooleans: string[];
+ Data?: ICompletedDialogue;
+ OtherDialogueInfos: IOtherDialogueInfo[];
+}
+
+interface IOtherDialogueInfo {
+ Dialogue: string;
+ Tag: string;
+ Value: number;
+}
diff --git a/src/controllers/api/saveLoadoutController.ts b/src/controllers/api/saveLoadoutController.ts
new file mode 100644
index 00000000..60431610
--- /dev/null
+++ b/src/controllers/api/saveLoadoutController.ts
@@ -0,0 +1,22 @@
+import type { RequestHandler } from "express";
+import type { ISaveLoadoutRequest } from "../../types/saveLoadoutTypes.ts";
+import { handleInventoryItemConfigChange } from "../../services/saveLoadoutService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+
+export const saveLoadoutController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+
+ const body: ISaveLoadoutRequest = getJSONfromString(String(req.body));
+ // console.log(util.inspect(body, { showHidden: false, depth: null, colors: true }));
+
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { UpgradeVer, ...equipmentChanges } = body;
+ const newLoadoutId = await handleInventoryItemConfigChange(equipmentChanges, accountId);
+
+ //send back new loadout id, if new loadout was added
+ if (newLoadoutId) {
+ res.send(newLoadoutId);
+ }
+ res.end();
+};
diff --git a/src/controllers/api/saveSettingsController.ts b/src/controllers/api/saveSettingsController.ts
new file mode 100644
index 00000000..cc99c743
--- /dev/null
+++ b/src/controllers/api/saveSettingsController.ts
@@ -0,0 +1,22 @@
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import type { RequestHandler } from "express";
+import type { ISettings } from "../../types/inventoryTypes/inventoryTypes.ts";
+
+interface ISaveSettingsRequest {
+ Settings: ISettings;
+}
+
+const saveSettingsController: RequestHandler = async (req, res): Promise => {
+ const accountId = await getAccountIdForRequest(req);
+
+ const settingResults = getJSONfromString(String(req.body));
+
+ const inventory = await getInventory(accountId, "Settings");
+ inventory.Settings = Object.assign(inventory.Settings ?? {}, settingResults.Settings);
+ await inventory.save();
+ res.json({ Settings: inventory.Settings });
+};
+
+export { saveSettingsController };
diff --git a/src/controllers/api/saveVaultAutoContributeController.ts b/src/controllers/api/saveVaultAutoContributeController.ts
new file mode 100644
index 00000000..7e91f5e1
--- /dev/null
+++ b/src/controllers/api/saveVaultAutoContributeController.ts
@@ -0,0 +1,25 @@
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { Guild } from "../../models/guildModel.ts";
+import { hasGuildPermission } from "../../services/guildService.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { GuildPermission } from "../../types/guildTypes.ts";
+import type { RequestHandler } from "express";
+
+export const saveVaultAutoContributeController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId, "GuildId");
+ const guild = (await Guild.findById(inventory.GuildId!, "Ranks AutoContributeFromVault"))!;
+ if (!(await hasGuildPermission(guild, accountId, GuildPermission.Treasurer))) {
+ res.status(400).send("Invalid permission").end();
+ return;
+ }
+ const data = getJSONfromString(String(req.body));
+ guild.AutoContributeFromVault = data.autoContributeFromVault;
+ await guild.save();
+ res.end();
+};
+
+interface ISetVaultAutoContributeRequest {
+ autoContributeFromVault: boolean;
+}
diff --git a/src/controllers/api/sellController.ts b/src/controllers/api/sellController.ts
new file mode 100644
index 00000000..dd160c5a
--- /dev/null
+++ b/src/controllers/api/sellController.ts
@@ -0,0 +1,384 @@
+import type { RequestHandler } from "express";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import {
+ getInventory,
+ addMods,
+ addRecipes,
+ addMiscItems,
+ addConsumables,
+ freeUpSlot,
+ combineInventoryChanges,
+ addCrewShipRawSalvage,
+ addFusionPoints,
+ addCrewShipFusionPoints,
+ addFusionTreasures
+} from "../../services/inventoryService.ts";
+import { InventorySlot } from "../../types/inventoryTypes/inventoryTypes.ts";
+import { ExportDojoRecipes } from "warframe-public-export-plus";
+import type { IInventoryChanges } from "../../types/purchaseTypes.ts";
+import type { TInventoryDatabaseDocument } from "../../models/inventoryModels/inventoryModel.ts";
+import { sendWsBroadcastEx } from "../../services/wsService.ts";
+import { parseFusionTreasure } from "../../helpers/inventoryHelpers.ts";
+
+export const sellController: RequestHandler = async (req, res) => {
+ const payload = JSON.parse(String(req.body)) as ISellRequest;
+ //console.log(JSON.stringify(payload, null, 2));
+ const accountId = await getAccountIdForRequest(req);
+ const requiredFields = new Set();
+ if (payload.SellCurrency == "SC_RegularCredits") {
+ requiredFields.add("RegularCredits");
+ } else if (payload.SellCurrency == "SC_FusionPoints") {
+ requiredFields.add("FusionPoints");
+ } else if (payload.SellCurrency == "SC_CrewShipFusionPoints") {
+ requiredFields.add("CrewShipFusionPoints");
+ } else {
+ requiredFields.add("MiscItems");
+ }
+ for (const key of Object.keys(payload.Items)) {
+ requiredFields.add(key as keyof TInventoryDatabaseDocument);
+ }
+ if (requiredFields.has("Upgrades")) {
+ requiredFields.add("RawUpgrades");
+ }
+ if (payload.Items.Suits) {
+ requiredFields.add(InventorySlot.SUITS);
+ }
+ if (payload.Items.LongGuns || payload.Items.Pistols || payload.Items.Melee) {
+ requiredFields.add(InventorySlot.WEAPONS);
+ }
+ if (payload.Items.SpaceSuits) {
+ requiredFields.add(InventorySlot.SPACESUITS);
+ }
+ if (payload.Items.SpaceGuns || payload.Items.SpaceMelee) {
+ requiredFields.add(InventorySlot.SPACEWEAPONS);
+ }
+ if (payload.Items.MechSuits) {
+ requiredFields.add(InventorySlot.MECHSUITS);
+ }
+ if (payload.Items.Sentinels || payload.Items.SentinelWeapons || payload.Items.MoaPets) {
+ requiredFields.add(InventorySlot.SENTINELS);
+ }
+ if (payload.Items.OperatorAmps) {
+ requiredFields.add(InventorySlot.AMPS);
+ }
+ if (payload.Items.Hoverboards) {
+ requiredFields.add(InventorySlot.SPACESUITS);
+ }
+ if (payload.Items.CrewMembers) {
+ requiredFields.add(InventorySlot.CREWMEMBERS);
+ }
+ if (payload.Items.CrewShipWeapons || payload.Items.CrewShipWeaponSkins) {
+ requiredFields.add(InventorySlot.RJ_COMPONENT_AND_ARMAMENTS);
+ requiredFields.add("CrewShipRawSalvage");
+ if (payload.Items.CrewShipWeapons) {
+ requiredFields.add("CrewShipSalvagedWeapons");
+ }
+ if (payload.Items.CrewShipWeaponSkins) {
+ requiredFields.add("CrewShipSalvagedWeaponSkins");
+ }
+ }
+ const inventory = await getInventory(accountId, Array.from(requiredFields).join(" "));
+
+ // Give currency
+ if (payload.SellCurrency == "SC_RegularCredits") {
+ inventory.RegularCredits += payload.SellPrice;
+ } else if (payload.SellCurrency == "SC_FusionPoints") {
+ addFusionPoints(inventory, payload.SellPrice);
+ } else if (payload.SellCurrency == "SC_CrewShipFusionPoints") {
+ addCrewShipFusionPoints(inventory, payload.SellPrice);
+ } else if (payload.SellCurrency == "SC_PrimeBucks") {
+ addMiscItems(inventory, [
+ {
+ ItemType: "/Lotus/Types/Items/MiscItems/PrimeBucks",
+ ItemCount: payload.SellPrice
+ }
+ ]);
+ } else if (payload.SellCurrency == "SC_DistillPoints") {
+ addMiscItems(inventory, [
+ {
+ ItemType: "/Lotus/Types/Items/MiscItems/DistillPoints",
+ ItemCount: payload.SellPrice
+ }
+ ]);
+ } else if (payload.SellCurrency == "SC_Resources") {
+ // Will add appropriate MiscItems from CrewShipWeapons or CrewShipWeaponSkins
+ } else {
+ throw new Error("Unknown SellCurrency: " + payload.SellCurrency);
+ }
+
+ const inventoryChanges: IInventoryChanges = {};
+
+ // Remove item(s)
+ if (payload.Items.Suits) {
+ payload.Items.Suits.forEach(sellItem => {
+ inventory.Suits.pull({ _id: sellItem.String });
+ freeUpSlot(inventory, InventorySlot.SUITS);
+ });
+ }
+ if (payload.Items.LongGuns) {
+ payload.Items.LongGuns.forEach(sellItem => {
+ inventory.LongGuns.pull({ _id: sellItem.String });
+ freeUpSlot(inventory, InventorySlot.WEAPONS);
+ });
+ }
+ if (payload.Items.Pistols) {
+ payload.Items.Pistols.forEach(sellItem => {
+ inventory.Pistols.pull({ _id: sellItem.String });
+ freeUpSlot(inventory, InventorySlot.WEAPONS);
+ });
+ }
+ if (payload.Items.Melee) {
+ payload.Items.Melee.forEach(sellItem => {
+ inventory.Melee.pull({ _id: sellItem.String });
+ freeUpSlot(inventory, InventorySlot.WEAPONS);
+ });
+ }
+ if (payload.Items.SpaceSuits) {
+ payload.Items.SpaceSuits.forEach(sellItem => {
+ inventory.SpaceSuits.pull({ _id: sellItem.String });
+ freeUpSlot(inventory, InventorySlot.SPACESUITS);
+ });
+ }
+ if (payload.Items.SpaceGuns) {
+ payload.Items.SpaceGuns.forEach(sellItem => {
+ inventory.SpaceGuns.pull({ _id: sellItem.String });
+ freeUpSlot(inventory, InventorySlot.SPACEWEAPONS);
+ });
+ }
+ if (payload.Items.SpaceMelee) {
+ payload.Items.SpaceMelee.forEach(sellItem => {
+ inventory.SpaceMelee.pull({ _id: sellItem.String });
+ freeUpSlot(inventory, InventorySlot.SPACEWEAPONS);
+ });
+ }
+ if (payload.Items.MechSuits) {
+ payload.Items.MechSuits.forEach(sellItem => {
+ inventory.MechSuits.pull({ _id: sellItem.String });
+ freeUpSlot(inventory, InventorySlot.MECHSUITS);
+ });
+ }
+ if (payload.Items.Sentinels) {
+ payload.Items.Sentinels.forEach(sellItem => {
+ inventory.Sentinels.pull({ _id: sellItem.String });
+ freeUpSlot(inventory, InventorySlot.SENTINELS);
+ });
+ }
+ if (payload.Items.SentinelWeapons) {
+ payload.Items.SentinelWeapons.forEach(sellItem => {
+ inventory.SentinelWeapons.pull({ _id: sellItem.String });
+ freeUpSlot(inventory, InventorySlot.SENTINELS);
+ });
+ }
+ if (payload.Items.MoaPets) {
+ payload.Items.MoaPets.forEach(sellItem => {
+ inventory.MoaPets.pull({ _id: sellItem.String });
+ freeUpSlot(inventory, InventorySlot.SENTINELS);
+ });
+ }
+ if (payload.Items.OperatorAmps) {
+ payload.Items.OperatorAmps.forEach(sellItem => {
+ inventory.OperatorAmps.pull({ _id: sellItem.String });
+ freeUpSlot(inventory, InventorySlot.AMPS);
+ });
+ }
+ if (payload.Items.Hoverboards) {
+ payload.Items.Hoverboards.forEach(sellItem => {
+ inventory.Hoverboards.pull({ _id: sellItem.String });
+ freeUpSlot(inventory, InventorySlot.SPACESUITS);
+ });
+ }
+ if (payload.Items.Drones) {
+ payload.Items.Drones.forEach(sellItem => {
+ inventory.Drones.pull({ _id: sellItem.String });
+ });
+ }
+ if (payload.Items.KubrowPetPrints) {
+ payload.Items.KubrowPetPrints.forEach(sellItem => {
+ inventory.KubrowPetPrints.pull({ _id: sellItem.String });
+ });
+ }
+ if (payload.Items.CrewMembers) {
+ payload.Items.CrewMembers.forEach(sellItem => {
+ inventory.CrewMembers.pull({ _id: sellItem.String });
+ freeUpSlot(inventory, InventorySlot.CREWMEMBERS);
+ });
+ }
+ if (payload.Items.CrewShipWeapons) {
+ payload.Items.CrewShipWeapons.forEach(sellItem => {
+ if (sellItem.String[0] == "/") {
+ addCrewShipRawSalvage(inventory, [
+ {
+ ItemType: sellItem.String,
+ ItemCount: sellItem.Count * -1
+ }
+ ]);
+ } else {
+ const index = inventory.CrewShipWeapons.findIndex(x => x._id.equals(sellItem.String));
+ if (index != -1) {
+ if (payload.SellCurrency == "SC_Resources") {
+ refundPartialBuildCosts(inventory, inventory.CrewShipWeapons[index].ItemType, inventoryChanges);
+ }
+ inventory.CrewShipWeapons.splice(index, 1);
+ freeUpSlot(inventory, InventorySlot.RJ_COMPONENT_AND_ARMAMENTS);
+ } else {
+ inventory.CrewShipSalvagedWeapons.pull({ _id: sellItem.String });
+ }
+ }
+ });
+ }
+ if (payload.Items.CrewShipWeaponSkins) {
+ payload.Items.CrewShipWeaponSkins.forEach(sellItem => {
+ if (sellItem.String[0] == "/") {
+ addCrewShipRawSalvage(inventory, [
+ {
+ ItemType: sellItem.String,
+ ItemCount: sellItem.Count * -1
+ }
+ ]);
+ } else {
+ const index = inventory.CrewShipWeaponSkins.findIndex(x => x._id.equals(sellItem.String));
+ if (index != -1) {
+ if (payload.SellCurrency == "SC_Resources") {
+ refundPartialBuildCosts(
+ inventory,
+ inventory.CrewShipWeaponSkins[index].ItemType,
+ inventoryChanges
+ );
+ }
+ inventory.CrewShipWeaponSkins.splice(index, 1);
+ freeUpSlot(inventory, InventorySlot.RJ_COMPONENT_AND_ARMAMENTS);
+ } else {
+ inventory.CrewShipSalvagedWeaponSkins.pull({ _id: sellItem.String });
+ }
+ }
+ });
+ }
+ if (payload.Items.Consumables) {
+ const consumablesChanges = [];
+ for (const sellItem of payload.Items.Consumables) {
+ consumablesChanges.push({
+ ItemType: sellItem.String,
+ ItemCount: sellItem.Count * -1
+ });
+ }
+ addConsumables(inventory, consumablesChanges);
+ }
+ if (payload.Items.Recipes) {
+ const recipeChanges = [];
+ for (const sellItem of payload.Items.Recipes) {
+ recipeChanges.push({
+ ItemType: sellItem.String,
+ ItemCount: sellItem.Count * -1
+ });
+ }
+ addRecipes(inventory, recipeChanges);
+ }
+ if (payload.Items.Upgrades) {
+ payload.Items.Upgrades.forEach(sellItem => {
+ if (sellItem.Count == 0) {
+ inventory.Upgrades.pull({ _id: sellItem.String });
+ } else {
+ addMods(inventory, [
+ {
+ ItemType: sellItem.String,
+ ItemCount: sellItem.Count * -1
+ }
+ ]);
+ }
+ });
+ }
+ if (payload.Items.MiscItems) {
+ payload.Items.MiscItems.forEach(sellItem => {
+ addMiscItems(inventory, [
+ {
+ ItemType: sellItem.String,
+ ItemCount: sellItem.Count * -1
+ }
+ ]);
+ });
+ }
+ if (payload.Items.FusionTreasures) {
+ payload.Items.FusionTreasures.forEach(sellItem => {
+ addFusionTreasures(inventory, [parseFusionTreasure(sellItem.String, sellItem.Count * -1)]);
+ });
+ }
+
+ await inventory.save();
+ res.json({
+ inventoryChanges: inventoryChanges // "inventoryChanges" for this response instead of the usual "InventoryChanges"
+ });
+ sendWsBroadcastEx({ update_inventory: true }, accountId, parseInt(String(req.query.wsid)));
+};
+
+interface ISellRequest {
+ Items: {
+ Suits?: ISellItem[];
+ LongGuns?: ISellItem[];
+ Pistols?: ISellItem[];
+ Melee?: ISellItem[];
+ Consumables?: ISellItem[];
+ Recipes?: ISellItem[];
+ Upgrades?: ISellItem[];
+ MiscItems?: ISellItem[];
+ SpaceSuits?: ISellItem[];
+ SpaceGuns?: ISellItem[];
+ SpaceMelee?: ISellItem[];
+ MechSuits?: ISellItem[];
+ Sentinels?: ISellItem[];
+ SentinelWeapons?: ISellItem[];
+ MoaPets?: ISellItem[];
+ OperatorAmps?: ISellItem[];
+ Hoverboards?: ISellItem[];
+ Drones?: ISellItem[];
+ KubrowPetPrints?: ISellItem[];
+ CrewMembers?: ISellItem[];
+ CrewShipWeapons?: ISellItem[];
+ CrewShipWeaponSkins?: ISellItem[];
+ FusionTreasures?: ISellItem[];
+ };
+ SellPrice: number;
+ SellCurrency:
+ | "SC_RegularCredits"
+ | "SC_PrimeBucks"
+ | "SC_FusionPoints"
+ | "SC_DistillPoints"
+ | "SC_CrewShipFusionPoints"
+ | "SC_Resources"
+ | "somethingelsewemightnotknowabout";
+ buildLabel: string;
+}
+
+interface ISellItem {
+ String: string; // oid or uniqueName
+ Count: number;
+}
+
+const refundPartialBuildCosts = (
+ inventory: TInventoryDatabaseDocument,
+ itemType: string,
+ inventoryChanges: IInventoryChanges
+): void => {
+ // House versions
+ const research = Object.values(ExportDojoRecipes.research).find(x => x.resultType == itemType);
+ if (research) {
+ const miscItemChanges = research.ingredients.map(x => ({
+ ItemType: x.ItemType,
+ ItemCount: Math.trunc(x.ItemCount * 0.8)
+ }));
+ addMiscItems(inventory, miscItemChanges);
+ combineInventoryChanges(inventoryChanges, { MiscItems: miscItemChanges });
+ return;
+ }
+
+ // Sigma versions
+ const recipe = Object.values(ExportDojoRecipes.fabrications).find(x => x.resultType == itemType);
+ if (recipe) {
+ const miscItemChanges = recipe.ingredients.map(x => ({
+ ItemType: x.ItemType,
+ ItemCount: Math.trunc(x.ItemCount * 0.8)
+ }));
+ addMiscItems(inventory, miscItemChanges);
+ combineInventoryChanges(inventoryChanges, { MiscItems: miscItemChanges });
+ return;
+ }
+};
diff --git a/src/controllers/api/sendMsgToInBoxController.ts b/src/controllers/api/sendMsgToInBoxController.ts
new file mode 100644
index 00000000..57731d56
--- /dev/null
+++ b/src/controllers/api/sendMsgToInBoxController.ts
@@ -0,0 +1,31 @@
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { createMessage } from "../../services/inboxService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { RequestHandler } from "express";
+
+export const sendMsgToInBoxController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const data = getJSONfromString(String(req.body));
+ await createMessage(accountId, [
+ {
+ sub: data.title,
+ msg: data.message,
+ sndr: data.sender ?? "/Lotus/Language/Bosses/Ordis",
+ icon: data.senderIcon,
+ highPriority: data.highPriority,
+ transmission: data.transmission,
+ att: data.attachments
+ }
+ ]);
+ res.end();
+};
+
+interface ISendMsgToInBoxRequest {
+ title: string;
+ message: string;
+ sender?: string;
+ senderIcon?: string;
+ highPriority?: boolean;
+ transmission?: string;
+ attachments?: string[];
+}
diff --git a/src/controllers/api/setActiveQuestController.ts b/src/controllers/api/setActiveQuestController.ts
new file mode 100644
index 00000000..969398b3
--- /dev/null
+++ b/src/controllers/api/setActiveQuestController.ts
@@ -0,0 +1,18 @@
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { RequestHandler } from "express";
+
+export const setActiveQuestController: RequestHandler<
+ Record,
+ undefined,
+ undefined,
+ { quest: string | undefined }
+> = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const quest = req.query.quest;
+
+ const inventory = await getInventory(accountId, "ActiveQuest");
+ inventory.ActiveQuest = quest ?? "";
+ await inventory.save();
+ res.status(200).end();
+};
diff --git a/src/controllers/api/setActiveShipController.ts b/src/controllers/api/setActiveShipController.ts
new file mode 100644
index 00000000..b21baaba
--- /dev/null
+++ b/src/controllers/api/setActiveShipController.ts
@@ -0,0 +1,15 @@
+import { getPersonalRooms } from "../../services/personalRoomsService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { parseString } from "../../helpers/general.ts";
+import type { RequestHandler } from "express";
+import { Types } from "mongoose";
+
+export const setActiveShipController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const shipId = parseString(req.query.shipId);
+
+ const personalRooms = await getPersonalRooms(accountId);
+ personalRooms.activeShipId = new Types.ObjectId(shipId);
+ await personalRooms.save();
+ res.status(200).end();
+};
diff --git a/src/controllers/api/setAllianceGuildPermissionsController.ts b/src/controllers/api/setAllianceGuildPermissionsController.ts
new file mode 100644
index 00000000..17f1555d
--- /dev/null
+++ b/src/controllers/api/setAllianceGuildPermissionsController.ts
@@ -0,0 +1,38 @@
+import { AllianceMember, GuildMember } from "../../models/guildModel.ts";
+import { getAccountForRequest } from "../../services/loginService.ts";
+import { GuildPermission } from "../../types/guildTypes.ts";
+import type { RequestHandler } from "express";
+
+export const setAllianceGuildPermissionsController: RequestHandler = async (req, res) => {
+ // Check requester is a warlord in their guild
+ const account = await getAccountForRequest(req);
+ const guildMember = (await GuildMember.findOne({ accountId: account._id, status: 0 }))!;
+ if (guildMember.rank > 1) {
+ res.status(400).end();
+ return;
+ }
+
+ // Check guild is the creator of the alliance and don't allow changing of own permissions. (Technically changing permissions requires the Promoter permission, but both are exclusive to the creator guild.)
+ const allianceMember = (await AllianceMember.findOne({
+ guildId: guildMember.guildId,
+ Pending: false
+ }))!;
+ if (
+ !(allianceMember.Permissions & GuildPermission.Ruler) ||
+ allianceMember.guildId.equals(req.query.guildId as string)
+ ) {
+ res.status(400).end();
+ return;
+ }
+
+ const targetAllianceMember = (await AllianceMember.findOne({
+ allianceId: allianceMember.allianceId,
+ guildId: req.query.guildId
+ }))!;
+ targetAllianceMember.Permissions =
+ parseInt(req.query.perms as string) &
+ (GuildPermission.Recruiter | GuildPermission.Treasurer | GuildPermission.ChatModerator);
+ await targetAllianceMember.save();
+
+ res.end();
+};
diff --git a/src/controllers/api/setBootLocationController.ts b/src/controllers/api/setBootLocationController.ts
new file mode 100644
index 00000000..ecb13729
--- /dev/null
+++ b/src/controllers/api/setBootLocationController.ts
@@ -0,0 +1,24 @@
+import type { RequestHandler } from "express";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { getPersonalRooms } from "../../services/personalRoomsService.ts";
+import type { TBootLocation } from "../../types/personalRoomsTypes.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+
+export const setBootLocationController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const personalRooms = await getPersonalRooms(accountId);
+ personalRooms.Ship.BootLocation = req.query.bootLocation as string as TBootLocation;
+ await personalRooms.save();
+
+ if (personalRooms.Ship.BootLocation == "SHOP") {
+ // Temp fix so the motorcycle in the backroom doesn't appear broken.
+ // This code may be removed when quests are fully implemented.
+ const inventory = await getInventory(accountId);
+ if (inventory.Motorcycles.length == 0) {
+ inventory.Motorcycles.push({ ItemType: "/Lotus/Types/Vehicles/Motorcycle/MotorcyclePowerSuit" });
+ await inventory.save();
+ }
+ }
+
+ res.end();
+};
diff --git a/src/controllers/api/setDojoComponentColorsController.ts b/src/controllers/api/setDojoComponentColorsController.ts
new file mode 100644
index 00000000..96203a9b
--- /dev/null
+++ b/src/controllers/api/setDojoComponentColorsController.ts
@@ -0,0 +1,39 @@
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import {
+ getDojoClient,
+ getGuildForRequestEx,
+ hasAccessToDojo,
+ hasGuildPermission
+} from "../../services/guildService.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { GuildPermission } from "../../types/guildTypes.ts";
+import type { RequestHandler } from "express";
+
+export const setDojoComponentColorsController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId, "GuildId LevelKeys");
+ const guild = await getGuildForRequestEx(req, inventory);
+ if (!hasAccessToDojo(inventory) || !(await hasGuildPermission(guild, accountId, GuildPermission.Decorator))) {
+ res.json({ DojoRequestStatus: -1 });
+ return;
+ }
+ const data = getJSONfromString(String(req.body));
+ const component = guild.DojoComponents.id(data.ComponentId)!;
+ //const deco = component.Decos!.find(x => x._id.equals(data.DecoId))!;
+ //deco.Pending = true;
+ //component.PaintBot = new Types.ObjectId(data.DecoId);
+ if ("lights" in req.query) {
+ component.PendingLights = data.Colours;
+ } else {
+ component.PendingColors = data.Colours;
+ }
+ await guild.save();
+ res.json(await getDojoClient(guild, 0, component._id));
+};
+
+interface ISetDojoComponentColorsRequest {
+ ComponentId: string;
+ DecoId: string;
+ Colours: number[];
+}
diff --git a/src/controllers/api/setDojoComponentMessageController.ts b/src/controllers/api/setDojoComponentMessageController.ts
new file mode 100644
index 00000000..04788616
--- /dev/null
+++ b/src/controllers/api/setDojoComponentMessageController.ts
@@ -0,0 +1,18 @@
+import type { RequestHandler } from "express";
+import { getDojoClient, getGuildForRequest } from "../../services/guildService.ts";
+
+export const setDojoComponentMessageController: RequestHandler = async (req, res) => {
+ const guild = await getGuildForRequest(req);
+ // At this point, we know that a member of the guild is making this request. Assuming they are allowed to change the message.
+ const component = guild.DojoComponents.id(req.query.componentId as string)!;
+ const payload = JSON.parse(String(req.body)) as SetDojoComponentMessageRequest;
+ if ("Name" in payload) {
+ component.Name = payload.Name;
+ } else {
+ component.Message = payload.Message;
+ }
+ await guild.save();
+ res.json(await getDojoClient(guild, 0, component._id));
+};
+
+type SetDojoComponentMessageRequest = { Name: string } | { Message: string };
diff --git a/src/controllers/api/setDojoComponentSettingsController.ts b/src/controllers/api/setDojoComponentSettingsController.ts
new file mode 100644
index 00000000..5c8d4fa3
--- /dev/null
+++ b/src/controllers/api/setDojoComponentSettingsController.ts
@@ -0,0 +1,30 @@
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import {
+ getDojoClient,
+ getGuildForRequestEx,
+ hasAccessToDojo,
+ hasGuildPermission
+} from "../../services/guildService.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { GuildPermission } from "../../types/guildTypes.ts";
+import type { RequestHandler } from "express";
+
+export const setDojoComponentSettingsController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId, "GuildId LevelKeys");
+ const guild = await getGuildForRequestEx(req, inventory);
+ if (!hasAccessToDojo(inventory) || !(await hasGuildPermission(guild, accountId, GuildPermission.Decorator))) {
+ res.json({ DojoRequestStatus: -1 });
+ return;
+ }
+ const component = guild.DojoComponents.id(req.query.componentId as string)!;
+ const data = getJSONfromString(String(req.body));
+ component.Settings = data.Settings;
+ await guild.save();
+ res.json(await getDojoClient(guild, 0, component._id));
+};
+
+interface ISetDojoComponentSettingsRequest {
+ Settings: string;
+}
diff --git a/src/controllers/api/setEquippedInstrumentController.ts b/src/controllers/api/setEquippedInstrumentController.ts
new file mode 100644
index 00000000..3d4876e1
--- /dev/null
+++ b/src/controllers/api/setEquippedInstrumentController.ts
@@ -0,0 +1,24 @@
+import type { RequestHandler } from "express";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { Inventory } from "../../models/inventoryModels/inventoryModel.ts";
+
+export const setEquippedInstrumentController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const body = getJSONfromString(String(req.body));
+
+ await Inventory.updateOne(
+ {
+ accountOwnerId: accountId
+ },
+ {
+ EquippedInstrument: body.Instrument
+ }
+ );
+
+ res.end();
+};
+
+interface ISetEquippedInstrumentRequest {
+ Instrument: string;
+}
diff --git a/src/controllers/api/setFriendNoteController.ts b/src/controllers/api/setFriendNoteController.ts
new file mode 100644
index 00000000..835963c3
--- /dev/null
+++ b/src/controllers/api/setFriendNoteController.ts
@@ -0,0 +1,30 @@
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { Friendship } from "../../models/friendModel.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { RequestHandler } from "express";
+
+export const setFriendNoteController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const payload = getJSONfromString(String(req.body));
+ const friendship = await Friendship.findOne({ owner: accountId, friend: payload.FriendId }, "Note Favorite");
+ if (friendship) {
+ if ("Note" in payload) {
+ friendship.Note = payload.Note;
+ } else {
+ friendship.Favorite = payload.Favorite;
+ }
+ await friendship.save();
+ }
+ res.json({
+ Id: payload.FriendId,
+ SetNote: "Note" in payload,
+ Note: friendship?.Note,
+ Favorite: friendship?.Favorite
+ });
+};
+
+interface ISetFriendNoteRequest {
+ FriendId: string;
+ Note?: string;
+ Favorite?: boolean;
+}
diff --git a/src/controllers/api/setGuildMotdController.ts b/src/controllers/api/setGuildMotdController.ts
new file mode 100644
index 00000000..8007bab0
--- /dev/null
+++ b/src/controllers/api/setGuildMotdController.ts
@@ -0,0 +1,65 @@
+import { version_compare } from "../../helpers/inventoryHelpers.ts";
+import { Alliance, Guild, GuildMember } from "../../models/guildModel.ts";
+import { hasGuildPermissionEx } from "../../services/guildService.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountForRequest, getSuffixedName } from "../../services/loginService.ts";
+import type { ILongMOTD } from "../../types/guildTypes.ts";
+import { GuildPermission } from "../../types/guildTypes.ts";
+import type { RequestHandler } from "express";
+
+export const setGuildMotdController: RequestHandler = async (req, res) => {
+ const account = await getAccountForRequest(req);
+ const inventory = await getInventory(account._id.toString(), "GuildId");
+ const guild = (await Guild.findById(inventory.GuildId!))!;
+ const member = (await GuildMember.findOne({ accountId: account._id, guildId: guild._id }))!;
+
+ const IsLongMOTD = "longMOTD" in req.query;
+ const MOTD = req.body ? String(req.body) : undefined;
+
+ if ("alliance" in req.query) {
+ if (member.rank > 1) {
+ res.status(400).json("Invalid permission");
+ return;
+ }
+
+ const alliance = (await Alliance.findById(guild.AllianceId!))!;
+ const motd = MOTD
+ ? ({
+ message: MOTD,
+ authorName: getSuffixedName(account),
+ authorGuildName: guild.Name
+ } satisfies ILongMOTD)
+ : undefined;
+ if (IsLongMOTD) {
+ alliance.LongMOTD = motd;
+ } else {
+ alliance.MOTD = motd;
+ }
+ await alliance.save();
+ } else {
+ if (!hasGuildPermissionEx(guild, member, GuildPermission.Herald)) {
+ res.status(400).json("Invalid permission");
+ return;
+ }
+
+ if (IsLongMOTD) {
+ if (MOTD) {
+ guild.LongMOTD = {
+ message: MOTD,
+ authorName: getSuffixedName(account)
+ };
+ } else {
+ guild.LongMOTD = undefined;
+ }
+ } else {
+ guild.MOTD = MOTD ?? "";
+ }
+ await guild.save();
+ }
+
+ if (!account.BuildLabel || version_compare(account.BuildLabel, "2020.03.24.20.24") > 0) {
+ res.json({ IsLongMOTD, MOTD });
+ } else {
+ res.send(MOTD).end();
+ }
+};
diff --git a/src/controllers/api/setHubNpcCustomizationsController.ts b/src/controllers/api/setHubNpcCustomizationsController.ts
new file mode 100644
index 00000000..1f0326a4
--- /dev/null
+++ b/src/controllers/api/setHubNpcCustomizationsController.ts
@@ -0,0 +1,21 @@
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { IHubNpcCustomization } from "../../types/inventoryTypes/inventoryTypes.ts";
+import type { RequestHandler } from "express";
+
+export const setHubNpcCustomizationsController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId, "HubNpcCustomizations");
+ const upload = getJSONfromString(String(req.body));
+ inventory.HubNpcCustomizations ??= [];
+ const cust = inventory.HubNpcCustomizations.find(x => x.Tag == upload.Tag);
+ if (cust) {
+ cust.Colors = upload.Colors;
+ cust.Pattern = upload.Pattern;
+ } else {
+ inventory.HubNpcCustomizations.push(upload);
+ }
+ await inventory.save();
+ res.end();
+};
diff --git a/src/controllers/api/setPlacedDecoInfoController.ts b/src/controllers/api/setPlacedDecoInfoController.ts
new file mode 100644
index 00000000..0281a0c2
--- /dev/null
+++ b/src/controllers/api/setPlacedDecoInfoController.ts
@@ -0,0 +1,19 @@
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { ISetPlacedDecoInfoRequest } from "../../types/personalRoomsTypes.ts";
+import type { RequestHandler } from "express";
+import { handleSetPlacedDecoInfo } from "../../services/shipCustomizationsService.ts";
+
+export const setPlacedDecoInfoController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const payload = JSON.parse(req.body as string) as ISetPlacedDecoInfoRequest;
+ //console.log(JSON.stringify(payload, null, 2));
+ await handleSetPlacedDecoInfo(accountId, payload);
+ res.json({
+ ...payload,
+ IsPicture: !!payload.PictureFrameInfo
+ } satisfies ISetPlacedDecoInfoResponse);
+};
+
+interface ISetPlacedDecoInfoResponse extends ISetPlacedDecoInfoRequest {
+ IsPicture: boolean;
+}
diff --git a/src/controllers/api/setShipCustomizationsController.ts b/src/controllers/api/setShipCustomizationsController.ts
new file mode 100644
index 00000000..de8cc40b
--- /dev/null
+++ b/src/controllers/api/setShipCustomizationsController.ts
@@ -0,0 +1,20 @@
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { setShipCustomizations } from "../../services/shipCustomizationsService.ts";
+import type { ISetShipCustomizationsRequest } from "../../types/personalRoomsTypes.ts";
+import { logger } from "../../utils/logger.ts";
+import type { RequestHandler } from "express";
+
+export const setShipCustomizationsController: RequestHandler = async (req, res) => {
+ try {
+ const accountId = await getAccountIdForRequest(req);
+ const setShipCustomizationsRequest = JSON.parse(req.body as string) as ISetShipCustomizationsRequest;
+
+ const setShipCustomizationsResponse = await setShipCustomizations(accountId, setShipCustomizationsRequest);
+ res.json(setShipCustomizationsResponse);
+ } catch (error: unknown) {
+ if (error instanceof Error) {
+ logger.error(`error in setShipCustomizationsController: ${error.message}`);
+ res.status(400).json({ error: error.message });
+ }
+ }
+};
diff --git a/src/controllers/api/setShipFavouriteLoadoutController.ts b/src/controllers/api/setShipFavouriteLoadoutController.ts
new file mode 100644
index 00000000..1fe696bc
--- /dev/null
+++ b/src/controllers/api/setShipFavouriteLoadoutController.ts
@@ -0,0 +1,42 @@
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { RequestHandler } from "express";
+import { getPersonalRooms } from "../../services/personalRoomsService.ts";
+import type { IOid } from "../../types/commonTypes.ts";
+import { Types } from "mongoose";
+import type { IFavouriteLoadoutDatabase, TBootLocation } from "../../types/personalRoomsTypes.ts";
+
+export const setShipFavouriteLoadoutController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const personalRooms = await getPersonalRooms(accountId);
+ const body = JSON.parse(String(req.body)) as ISetShipFavouriteLoadoutRequest;
+ if (body.BootLocation == "LISET") {
+ personalRooms.Ship.FavouriteLoadoutId = new Types.ObjectId(body.FavouriteLoadoutId.$oid);
+ } else if (body.BootLocation == "APARTMENT") {
+ updateTaggedDisplay(personalRooms.Apartment.FavouriteLoadouts, body);
+ } else if (body.BootLocation == "SHOP") {
+ updateTaggedDisplay(personalRooms.TailorShop.FavouriteLoadouts, body);
+ } else {
+ console.log(body);
+ throw new Error(`unexpected BootLocation: ${body.BootLocation}`);
+ }
+ await personalRooms.save();
+ res.json(body);
+};
+
+interface ISetShipFavouriteLoadoutRequest {
+ BootLocation: TBootLocation;
+ FavouriteLoadoutId: IOid;
+ TagName?: string;
+}
+
+const updateTaggedDisplay = (arr: IFavouriteLoadoutDatabase[], body: ISetShipFavouriteLoadoutRequest): void => {
+ const display = arr.find(x => x.Tag == body.TagName!);
+ if (display) {
+ display.LoadoutId = new Types.ObjectId(body.FavouriteLoadoutId.$oid);
+ } else {
+ arr.push({
+ Tag: body.TagName!,
+ LoadoutId: new Types.ObjectId(body.FavouriteLoadoutId.$oid)
+ });
+ }
+};
diff --git a/src/controllers/api/setShipVignetteController.ts b/src/controllers/api/setShipVignetteController.ts
new file mode 100644
index 00000000..22e2f2b1
--- /dev/null
+++ b/src/controllers/api/setShipVignetteController.ts
@@ -0,0 +1,48 @@
+import { addMiscItems, combineInventoryChanges, getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { getPersonalRooms } from "../../services/personalRoomsService.ts";
+import type { IInventoryChanges } from "../../types/purchaseTypes.ts";
+import { logger } from "../../utils/logger.ts";
+import type { RequestHandler } from "express";
+
+export const setShipVignetteController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId, "MiscItems");
+ const personalRooms = await getPersonalRooms(accountId);
+ const body = JSON.parse(String(req.body)) as ISetShipVignetteRequest;
+ personalRooms.Ship.Wallpaper = body.Wallpaper;
+ personalRooms.Ship.Vignette = body.Vignette;
+ personalRooms.Ship.VignetteFish ??= [];
+ const inventoryChanges: IInventoryChanges = {};
+ for (let i = 0; i != body.Fish.length; ++i) {
+ if (body.Fish[i] && !personalRooms.Ship.VignetteFish[i]) {
+ logger.debug(`moving ${body.Fish[i]} from inventory to vignette slot ${i}`);
+ const miscItemsDelta = [{ ItemType: body.Fish[i], ItemCount: -1 }];
+ addMiscItems(inventory, miscItemsDelta);
+ combineInventoryChanges(inventoryChanges, { MiscItems: miscItemsDelta });
+ } else if (personalRooms.Ship.VignetteFish[i] && !body.Fish[i]) {
+ logger.debug(`moving ${personalRooms.Ship.VignetteFish[i]} from vignette slot ${i} to inventory`);
+ const miscItemsDelta = [{ ItemType: personalRooms.Ship.VignetteFish[i], ItemCount: +1 }];
+ addMiscItems(inventory, miscItemsDelta);
+ combineInventoryChanges(inventoryChanges, { MiscItems: miscItemsDelta });
+ }
+ }
+ personalRooms.Ship.VignetteFish = body.Fish;
+ if (body.VignetteDecos.length) {
+ logger.error(`setShipVignette request not fully handled:`, body);
+ }
+ await Promise.all([inventory.save(), personalRooms.save()]);
+ res.json({
+ Wallpaper: body.Wallpaper,
+ Vignette: body.Vignette,
+ VignetteFish: body.Fish,
+ InventoryChanges: inventoryChanges
+ });
+};
+
+interface ISetShipVignetteRequest {
+ Wallpaper: string;
+ Vignette: string;
+ Fish: string[];
+ VignetteDecos: unknown[];
+}
diff --git a/src/controllers/api/setSuitInfectionController.ts b/src/controllers/api/setSuitInfectionController.ts
new file mode 100644
index 00000000..6b9952e1
--- /dev/null
+++ b/src/controllers/api/setSuitInfectionController.ts
@@ -0,0 +1,22 @@
+import { fromMongoDate, fromOid } from "../../helpers/inventoryHelpers.ts";
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { IEquipmentClient } from "../../types/equipmentTypes.ts";
+import type { RequestHandler } from "express";
+
+export const setSuitInfectionController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId, "Suits");
+ const payload = getJSONfromString(String(req.body));
+ for (const clientSuit of payload.Suits) {
+ const dbSuit = inventory.Suits.id(fromOid(clientSuit.ItemId))!;
+ dbSuit.InfestationDate = fromMongoDate(clientSuit.InfestationDate!);
+ }
+ await inventory.save();
+ res.end();
+};
+
+interface ISetSuitInfectionRequest {
+ Suits: IEquipmentClient[];
+}
diff --git a/src/controllers/api/setSupportedSyndicateController.ts b/src/controllers/api/setSupportedSyndicateController.ts
new file mode 100644
index 00000000..53db10b9
--- /dev/null
+++ b/src/controllers/api/setSupportedSyndicateController.ts
@@ -0,0 +1,18 @@
+import type { RequestHandler } from "express";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { Inventory } from "../../models/inventoryModels/inventoryModel.ts";
+
+export const setSupportedSyndicateController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+
+ await Inventory.updateOne(
+ {
+ accountOwnerId: accountId
+ },
+ {
+ SupportedSyndicate: req.query.syndicate as string
+ }
+ );
+
+ res.end();
+};
diff --git a/src/controllers/api/setWeaponSkillTreeController.ts b/src/controllers/api/setWeaponSkillTreeController.ts
new file mode 100644
index 00000000..2b7ae409
--- /dev/null
+++ b/src/controllers/api/setWeaponSkillTreeController.ts
@@ -0,0 +1,29 @@
+import type { RequestHandler } from "express";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { Inventory } from "../../models/inventoryModels/inventoryModel.ts";
+import type { TEquipmentKey } from "../../types/inventoryTypes/inventoryTypes.ts";
+import { equipmentKeys } from "../../types/inventoryTypes/inventoryTypes.ts";
+
+export const setWeaponSkillTreeController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const payload = getJSONfromString(String(req.body));
+
+ if (equipmentKeys.indexOf(req.query.Category as TEquipmentKey) != -1) {
+ await Inventory.updateOne(
+ {
+ accountOwnerId: accountId,
+ [`${req.query.Category as string}._id`]: req.query.ItemId as string
+ },
+ {
+ [`${req.query.Category as string}.$.SkillTree`]: payload.SkillTree
+ }
+ );
+ }
+
+ res.end();
+};
+
+interface ISetWeaponSkillTreeRequest {
+ SkillTree: string;
+}
diff --git a/src/controllers/api/shipDecorationsController.ts b/src/controllers/api/shipDecorationsController.ts
new file mode 100644
index 00000000..ec318f82
--- /dev/null
+++ b/src/controllers/api/shipDecorationsController.ts
@@ -0,0 +1,17 @@
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { IShipDecorationsRequest, IResetShipDecorationsRequest } from "../../types/personalRoomsTypes.ts";
+import type { RequestHandler } from "express";
+import { handleResetShipDecorations, handleSetShipDecorations } from "../../services/shipCustomizationsService.ts";
+
+export const shipDecorationsController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ if (req.query.reset == "1") {
+ const request = JSON.parse(req.body as string) as IResetShipDecorationsRequest;
+ const response = await handleResetShipDecorations(accountId, request);
+ res.send(response);
+ } else {
+ const shipDecorationsRequest = JSON.parse(req.body as string) as IShipDecorationsRequest;
+ const placedDecoration = await handleSetShipDecorations(accountId, shipDecorationsRequest);
+ res.send(placedDecoration);
+ }
+};
diff --git a/src/controllers/api/startCollectibleEntryController.ts b/src/controllers/api/startCollectibleEntryController.ts
new file mode 100644
index 00000000..639d239a
--- /dev/null
+++ b/src/controllers/api/startCollectibleEntryController.ts
@@ -0,0 +1,27 @@
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { IIncentiveState } from "../../types/inventoryTypes/inventoryTypes.ts";
+import type { RequestHandler } from "express";
+
+export const startCollectibleEntryController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId);
+ const request = getJSONfromString(String(req.body));
+ inventory.CollectibleSeries ??= [];
+ inventory.CollectibleSeries.push({
+ CollectibleType: request.target,
+ Count: 0,
+ Tracking: "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
+ ReqScans: request.reqScans,
+ IncentiveStates: request.other
+ });
+ await inventory.save();
+ res.status(200).end();
+};
+
+interface IStartCollectibleEntryRequest {
+ target: string;
+ reqScans: number;
+ other: IIncentiveState[];
+}
diff --git a/src/controllers/api/startDojoRecipeController.ts b/src/controllers/api/startDojoRecipeController.ts
new file mode 100644
index 00000000..69b9e58e
--- /dev/null
+++ b/src/controllers/api/startDojoRecipeController.ts
@@ -0,0 +1,69 @@
+import type { RequestHandler } from "express";
+import type { IDojoComponentClient } from "../../types/guildTypes.ts";
+import { GuildPermission } from "../../types/guildTypes.ts";
+import {
+ getDojoClient,
+ getGuildForRequestEx,
+ hasAccessToDojo,
+ hasGuildPermission,
+ processDojoBuildMaterialsGathered,
+ setDojoRoomLogFunded
+} from "../../services/guildService.ts";
+import { Types } from "mongoose";
+import { ExportDojoRecipes } from "warframe-public-export-plus";
+import { config } from "../../services/configService.ts";
+import { getAccountForRequest } from "../../services/loginService.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+
+interface IStartDojoRecipeRequest {
+ PlacedComponent: IDojoComponentClient;
+ Revision: number;
+}
+
+export const startDojoRecipeController: RequestHandler = async (req, res) => {
+ const account = await getAccountForRequest(req);
+ const inventory = await getInventory(account._id.toString(), "GuildId LevelKeys");
+ const guild = await getGuildForRequestEx(req, inventory);
+ if (!hasAccessToDojo(inventory) || !(await hasGuildPermission(guild, account._id, GuildPermission.Architect))) {
+ res.json({ DojoRequestStatus: -1 });
+ return;
+ }
+ const request = JSON.parse(String(req.body)) as IStartDojoRecipeRequest;
+
+ const room = Object.values(ExportDojoRecipes.rooms).find(x => x.resultType == request.PlacedComponent.pf);
+ if (room) {
+ guild.DojoCapacity += room.capacity;
+ guild.DojoEnergy += room.energy;
+ }
+
+ const componentId = new Types.ObjectId();
+
+ guild.RoomChanges ??= [];
+ guild.RoomChanges.push({
+ entryType: 2,
+ details: request.PlacedComponent.pf,
+ componentId: componentId
+ });
+
+ const component =
+ guild.DojoComponents[
+ guild.DojoComponents.push({
+ _id: componentId,
+ pf: request.PlacedComponent.pf,
+ ppf: request.PlacedComponent.ppf,
+ pi: new Types.ObjectId(request.PlacedComponent.pi!.$oid),
+ op: request.PlacedComponent.op,
+ pp: request.PlacedComponent.pp,
+ DecoCapacity: room?.decoCapacity
+ }) - 1
+ ];
+ if (config.noDojoRoomBuildStage) {
+ component.CompletionTime = new Date(Date.now());
+ if (room) {
+ processDojoBuildMaterialsGathered(guild, room);
+ }
+ setDojoRoomLogFunded(guild, component);
+ }
+ await guild.save();
+ res.json(await getDojoClient(guild, 0, undefined, account.BuildLabel));
+};
diff --git a/src/controllers/api/startLibraryDailyTaskController.ts b/src/controllers/api/startLibraryDailyTaskController.ts
new file mode 100644
index 00000000..ff8b56b8
--- /dev/null
+++ b/src/controllers/api/startLibraryDailyTaskController.ts
@@ -0,0 +1,11 @@
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { RequestHandler } from "express";
+
+export const startLibraryDailyTaskController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId);
+ inventory.LibraryActiveDailyTaskInfo = inventory.LibraryAvailableDailyTaskInfo;
+ await inventory.save();
+ res.json(inventory.LibraryAvailableDailyTaskInfo);
+};
diff --git a/src/controllers/api/startLibraryPersonalTargetController.ts b/src/controllers/api/startLibraryPersonalTargetController.ts
new file mode 100644
index 00000000..5eb74d78
--- /dev/null
+++ b/src/controllers/api/startLibraryPersonalTargetController.ts
@@ -0,0 +1,14 @@
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { RequestHandler } from "express";
+
+export const startLibraryPersonalTargetController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId);
+ inventory.LibraryPersonalTarget = req.query.target as string;
+ await inventory.save();
+ res.json({
+ IsQuest: req.query.target == "/Lotus/Types/Game/Library/Targets/DragonframeQuestTarget",
+ Target: req.query.target
+ });
+};
diff --git a/src/controllers/api/startRecipeController.ts b/src/controllers/api/startRecipeController.ts
new file mode 100644
index 00000000..90c0bdda
--- /dev/null
+++ b/src/controllers/api/startRecipeController.ts
@@ -0,0 +1,138 @@
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { logger } from "../../utils/logger.ts";
+import type { RequestHandler } from "express";
+import { getRecipe } from "../../services/itemDataService.ts";
+import { addItem, addKubrowPet, freeUpSlot, getInventory, updateCurrency } from "../../services/inventoryService.ts";
+import { unixTimesInMs } from "../../constants/timeConstants.ts";
+import { Types } from "mongoose";
+import type { ISpectreLoadout } from "../../types/inventoryTypes/inventoryTypes.ts";
+import { InventorySlot } from "../../types/inventoryTypes/inventoryTypes.ts";
+import { fromOid, toOid } from "../../helpers/inventoryHelpers.ts";
+import { ExportWeapons } from "warframe-public-export-plus";
+import { getRandomElement } from "../../services/rngService.ts";
+import type { IInventoryChanges } from "../../types/purchaseTypes.ts";
+
+interface IStartRecipeRequest {
+ RecipeName: string;
+ Ids: string[];
+}
+
+export const startRecipeController: RequestHandler = async (req, res) => {
+ const startRecipeRequest = getJSONfromString(String(req.body));
+ logger.debug("StartRecipe Request", { startRecipeRequest });
+
+ const accountId = await getAccountIdForRequest(req);
+
+ const recipeName = startRecipeRequest.RecipeName;
+ const recipe = getRecipe(recipeName);
+
+ if (!recipe) {
+ throw new Error(`unknown recipe ${recipeName}`);
+ }
+
+ const inventory = await getInventory(accountId);
+ updateCurrency(inventory, recipe.buildPrice, false);
+
+ const pr =
+ inventory.PendingRecipes[
+ inventory.PendingRecipes.push({
+ ItemType: recipeName,
+ CompletionDate: new Date(Date.now() + recipe.buildTime * unixTimesInMs.second),
+ _id: new Types.ObjectId()
+ }) - 1
+ ];
+
+ for (let i = 0; i != recipe.ingredients.length; ++i) {
+ if (startRecipeRequest.Ids[i] && startRecipeRequest.Ids[i][0] != "/") {
+ if (recipe.ingredients[i].ItemType == "/Lotus/Types/Game/KubrowPet/Eggs/KubrowPetEggItem") {
+ const index = inventory.KubrowPetEggs.findIndex(x => x._id.equals(startRecipeRequest.Ids[i]));
+ if (index != -1) {
+ inventory.KubrowPetEggs.splice(index, 1);
+ }
+ } else {
+ const category = ExportWeapons[recipe.ingredients[i].ItemType].productCategory;
+ if (category != "LongGuns" && category != "Pistols" && category != "Melee") {
+ throw new Error(`unexpected equipment ingredient type: ${category}`);
+ }
+ const equipmentIndex = inventory[category].findIndex(x => x._id.equals(startRecipeRequest.Ids[i]));
+ if (equipmentIndex == -1) {
+ throw new Error(`could not find equipment item to use for recipe`);
+ }
+ pr[category] ??= [];
+ pr[category].push(inventory[category][equipmentIndex]);
+ inventory[category].splice(equipmentIndex, 1);
+ freeUpSlot(inventory, InventorySlot.WEAPONS);
+ }
+ } else {
+ await addItem(inventory, recipe.ingredients[i].ItemType, recipe.ingredients[i].ItemCount * -1);
+ }
+ }
+
+ let inventoryChanges: IInventoryChanges | undefined;
+ if (recipe.secretIngredientAction == "SIA_CREATE_KUBROW") {
+ inventoryChanges = addKubrowPet(inventory, getRandomElement(recipe.secretIngredients!)!.ItemType);
+ pr.KubrowPet = new Types.ObjectId(fromOid(inventoryChanges.KubrowPets![0].ItemId));
+ } else if (recipe.secretIngredientAction == "SIA_DISTILL_PRINT") {
+ pr.KubrowPet = new Types.ObjectId(startRecipeRequest.Ids[recipe.ingredients.length]);
+ const pet = inventory.KubrowPets.id(pr.KubrowPet)!;
+ pet.Details!.PrintsRemaining -= 1;
+ } else if (recipe.secretIngredientAction == "SIA_SPECTRE_LOADOUT_COPY") {
+ const spectreLoadout: ISpectreLoadout = {
+ ItemType: recipe.resultType,
+ Suits: "",
+ LongGuns: "",
+ Pistols: "",
+ Melee: ""
+ };
+ for (
+ let secretIngredientsIndex = 0;
+ secretIngredientsIndex != recipe.secretIngredients!.length;
+ ++secretIngredientsIndex
+ ) {
+ const type = recipe.secretIngredients![secretIngredientsIndex].ItemType;
+ const oid = startRecipeRequest.Ids[recipe.ingredients.length + secretIngredientsIndex];
+ if (oid == "ffffffffffffffffffffffff") {
+ // user chose to preserve the active loadout
+ break;
+ }
+ if (type == "/Lotus/Types/Game/PowerSuits/PlayerPowerSuit") {
+ const item = inventory.Suits.id(oid)!;
+ spectreLoadout.Suits = item.ItemType;
+ } else if (type == "/Lotus/Weapons/Tenno/Pistol/LotusPistol") {
+ const item = inventory.Pistols.id(oid)!;
+ spectreLoadout.Pistols = item.ItemType;
+ spectreLoadout.PistolsModularParts = item.ModularParts;
+ } else if (type == "/Lotus/Weapons/Tenno/LotusLongGun") {
+ const item = inventory.LongGuns.id(oid)!;
+ spectreLoadout.LongGuns = item.ItemType;
+ spectreLoadout.LongGunsModularParts = item.ModularParts;
+ } else {
+ console.assert(type == "/Lotus/Types/Game/LotusMeleeWeapon");
+ const item = inventory.Melee.id(oid)!;
+ spectreLoadout.Melee = item.ItemType;
+ spectreLoadout.MeleeModularParts = item.ModularParts;
+ }
+ }
+ if (
+ spectreLoadout.Suits != "" &&
+ spectreLoadout.LongGuns != "" &&
+ spectreLoadout.Pistols != "" &&
+ spectreLoadout.Melee != ""
+ ) {
+ inventory.PendingSpectreLoadouts ??= [];
+ const existingIndex = inventory.PendingSpectreLoadouts.findIndex(x => x.ItemType == recipe.resultType);
+ if (existingIndex != -1) {
+ inventory.PendingSpectreLoadouts.splice(existingIndex, 1);
+ }
+ inventory.PendingSpectreLoadouts.push(spectreLoadout);
+ logger.debug("pending spectre loadout", spectreLoadout);
+ }
+ } else if (recipe.secretIngredientAction == "SIA_UNBRAND") {
+ pr.SuitToUnbrand = new Types.ObjectId(startRecipeRequest.Ids[recipe.ingredients.length + 0]);
+ }
+
+ await inventory.save();
+
+ res.json({ RecipeId: toOid(pr._id), InventoryChanges: inventoryChanges });
+};
diff --git a/src/controllers/api/stepSequencersController.ts b/src/controllers/api/stepSequencersController.ts
new file mode 100644
index 00000000..5ebc9a94
--- /dev/null
+++ b/src/controllers/api/stepSequencersController.ts
@@ -0,0 +1,14 @@
+import type { RequestHandler } from "express";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import type { IStepSequencer } from "../../types/inventoryTypes/inventoryTypes.ts";
+
+export const stepSequencersController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId);
+ const stepSequencer = JSON.parse(String(req.body)) as IStepSequencer;
+ delete stepSequencer.ItemId;
+ const stepSequencerIndex = inventory.StepSequencers.push(stepSequencer);
+ const changedInventory = await inventory.save();
+ res.json(changedInventory.StepSequencers[stepSequencerIndex - 1]); // unsure about the expected response format, but it seems anything works.
+};
diff --git a/src/controllers/api/surveysController.ts b/src/controllers/api/surveysController.ts
new file mode 100644
index 00000000..18e92fe2
--- /dev/null
+++ b/src/controllers/api/surveysController.ts
@@ -0,0 +1,14 @@
+import type { RequestHandler } from "express";
+
+const surveysController: RequestHandler = (_req, res) => {
+ const data = Buffer.from([
+ 0x7b, 0x22, 0x53, 0x75, 0x72, 0x76, 0x65, 0x79, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x22, 0x3a, 0x5b, 0x5d, 0x7d
+ ]);
+ res.writeHead(200, {
+ "Content-Type": "text/html",
+ "Content-Length": data.length
+ });
+ res.end(data);
+};
+
+export { surveysController };
diff --git a/src/controllers/api/syndicateSacrificeController.ts b/src/controllers/api/syndicateSacrificeController.ts
new file mode 100644
index 00000000..5aac5a61
--- /dev/null
+++ b/src/controllers/api/syndicateSacrificeController.ts
@@ -0,0 +1,116 @@
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import type { RequestHandler } from "express";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { ISyndicateSacrifice } from "warframe-public-export-plus";
+import { ExportSyndicates } from "warframe-public-export-plus";
+import { handleStoreItemAcquisition } from "../../services/purchaseService.ts";
+import { addMiscItem, combineInventoryChanges, getInventory, updateCurrency } from "../../services/inventoryService.ts";
+import type { IInventoryChanges } from "../../types/purchaseTypes.ts";
+import { toStoreItem } from "../../services/itemDataService.ts";
+import { logger } from "../../utils/logger.ts";
+
+export const syndicateSacrificeController: RequestHandler = async (request, response) => {
+ const accountId = await getAccountIdForRequest(request);
+ const inventory = await getInventory(accountId);
+ const data = getJSONfromString(String(request.body));
+
+ let syndicate = inventory.Affiliations.find(x => x.Tag == data.AffiliationTag);
+ if (!syndicate) {
+ syndicate = inventory.Affiliations[inventory.Affiliations.push({ Tag: data.AffiliationTag, Standing: 0 }) - 1];
+ }
+
+ const oldLevel = syndicate.Title ?? 0;
+ const levelIncrease = data.SacrificeLevel - oldLevel;
+ if (levelIncrease < 0) {
+ throw new Error(`syndicate sacrifice can not decrease level`);
+ }
+ if (levelIncrease > 1 && !data.AllowMultiple) {
+ throw new Error(`desired syndicate level is an increase of ${levelIncrease}, max. allowed increase is 1`);
+ }
+
+ const res: ISyndicateSacrificeResponse = {
+ AffiliationTag: data.AffiliationTag,
+ InventoryChanges: {},
+ Level: data.SacrificeLevel,
+ LevelIncrease: data.SacrificeLevel < 0 ? 1 : levelIncrease,
+ NewEpisodeReward: false
+ };
+
+ // Process sacrifices and rewards for every level we're reaching
+ const manifest = ExportSyndicates[data.AffiliationTag];
+ for (let level = oldLevel + Math.min(levelIncrease, 1); level <= data.SacrificeLevel; ++level) {
+ let sacrifice: ISyndicateSacrifice | undefined;
+ if (level == 0) {
+ sacrifice = manifest.initiationSacrifice;
+ if (manifest.initiationReward) {
+ combineInventoryChanges(
+ res.InventoryChanges,
+ (await handleStoreItemAcquisition(manifest.initiationReward, inventory)).InventoryChanges
+ );
+ }
+ syndicate.Initiated = true;
+ } else {
+ sacrifice = manifest.titles?.find(x => x.level == level)?.sacrifice;
+ }
+
+ if (sacrifice) {
+ updateCurrency(inventory, sacrifice.credits, false, res.InventoryChanges);
+
+ for (const item of sacrifice.items) {
+ addMiscItem(inventory, item.ItemType, item.ItemCount * -1, res.InventoryChanges);
+ }
+ }
+
+ // Quacks like a nightwave syndicate?
+ if (manifest.dailyChallenges) {
+ const title = manifest.titles!.find(x => x.level == level);
+ if (title) {
+ res.NewEpisodeReward = true;
+ let rewardType: string;
+ let rewardCount: number;
+ if (title.storeItemReward) {
+ rewardType = title.storeItemReward;
+ rewardCount = 1;
+ } else {
+ rewardType = toStoreItem(title.reward!.ItemType);
+ rewardCount = title.reward!.ItemCount;
+ }
+ const rewardInventoryChanges = (await handleStoreItemAcquisition(rewardType, inventory, rewardCount))
+ .InventoryChanges;
+ if (Object.keys(rewardInventoryChanges).length == 0) {
+ logger.debug(`nightwave rank up reward did not seem to get added, giving 50 creds instead`);
+ const nightwaveCredsItemType = manifest.titles![0].reward!.ItemType;
+ addMiscItem(inventory, nightwaveCredsItemType, 50, rewardInventoryChanges);
+ }
+ combineInventoryChanges(res.InventoryChanges, rewardInventoryChanges);
+ }
+ } else {
+ if (level > 0 && manifest.favours.find(x => x.rankUpReward && x.requiredLevel == level)) {
+ syndicate.FreeFavorsEarned ??= [];
+ if (!syndicate.FreeFavorsEarned.includes(level)) {
+ syndicate.FreeFavorsEarned.push(level);
+ }
+ }
+ }
+ }
+
+ // Commit
+ syndicate.Title = data.SacrificeLevel < 0 ? data.SacrificeLevel + 1 : data.SacrificeLevel;
+ await inventory.save();
+
+ response.json(res);
+};
+
+interface ISyndicateSacrificeRequest {
+ AffiliationTag: string;
+ SacrificeLevel: number;
+ AllowMultiple: boolean;
+}
+
+interface ISyndicateSacrificeResponse {
+ AffiliationTag: string;
+ Level: number;
+ LevelIncrease: number;
+ InventoryChanges: IInventoryChanges;
+ NewEpisodeReward: boolean;
+}
diff --git a/src/controllers/api/syndicateStandingBonusController.ts b/src/controllers/api/syndicateStandingBonusController.ts
new file mode 100644
index 00000000..ea04e0bc
--- /dev/null
+++ b/src/controllers/api/syndicateStandingBonusController.ts
@@ -0,0 +1,76 @@
+import type { RequestHandler } from "express";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { addMiscItems, addStanding, freeUpSlot, getInventory } from "../../services/inventoryService.ts";
+import type { IMiscItem } from "../../types/inventoryTypes/inventoryTypes.ts";
+import { InventorySlot } from "../../types/inventoryTypes/inventoryTypes.ts";
+import type { IOid } from "../../types/commonTypes.ts";
+import { ExportSyndicates, ExportWeapons } from "warframe-public-export-plus";
+import { logger } from "../../utils/logger.ts";
+import type { IAffiliationMods, IInventoryChanges } from "../../types/purchaseTypes.ts";
+import { EquipmentFeatures } from "../../types/equipmentTypes.ts";
+
+export const syndicateStandingBonusController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const request = JSON.parse(String(req.body)) as ISyndicateStandingBonusRequest;
+
+ const syndicateMeta = ExportSyndicates[request.Operation.AffiliationTag];
+
+ // Process items
+ let gainedStanding = 0;
+ request.Operation.Items.forEach(item => {
+ const medallion = (syndicateMeta.medallions ?? []).find(medallion => medallion.itemType == item.ItemType);
+ if (medallion) {
+ gainedStanding += medallion.standing * item.ItemCount;
+ }
+
+ item.ItemCount *= -1;
+ });
+ const inventory = await getInventory(accountId);
+ addMiscItems(inventory, request.Operation.Items);
+ const inventoryChanges: IInventoryChanges = {};
+ inventoryChanges.MiscItems = request.Operation.Items;
+
+ // Process modular weapon
+ if (request.Operation.ModularWeaponId.$oid != "000000000000000000000000") {
+ const category = req.query.Category as "LongGuns" | "Pistols" | "Melee" | "OperatorAmps";
+ const weapon = inventory[category].id(request.Operation.ModularWeaponId.$oid)!;
+ if (gainedStanding !== 0) {
+ throw new Error(`modular weapon standing bonus should be mutually exclusive`);
+ }
+ weapon.ModularParts!.forEach(part => {
+ const partStandingBonus = ExportWeapons[part].donationStandingBonus;
+ if (partStandingBonus === undefined) {
+ throw new Error(`no standing bonus for ${part}`);
+ }
+ logger.debug(`modular weapon part ${part} gives ${partStandingBonus} standing`);
+ gainedStanding += partStandingBonus;
+ });
+ if (weapon.Features && (weapon.Features & EquipmentFeatures.GILDED) != 0) {
+ gainedStanding *= 2;
+ }
+ inventoryChanges.RemovedIdItems = [{ ItemId: request.Operation.ModularWeaponId }];
+ inventory[category].pull({ _id: request.Operation.ModularWeaponId.$oid });
+ const slotBin = category == "OperatorAmps" ? InventorySlot.AMPS : InventorySlot.WEAPONS;
+ freeUpSlot(inventory, slotBin);
+ inventoryChanges[slotBin] = { count: -1, platinum: 0, Slots: 1 };
+ }
+
+ const affiliationMods: IAffiliationMods[] = [];
+ addStanding(inventory, request.Operation.AffiliationTag, gainedStanding, affiliationMods, true);
+
+ await inventory.save();
+
+ res.json({
+ InventoryChanges: inventoryChanges,
+ AffiliationMods: affiliationMods
+ });
+};
+
+interface ISyndicateStandingBonusRequest {
+ Operation: {
+ AffiliationTag: string;
+ AlternateBonusReward: ""; // ???
+ Items: IMiscItem[];
+ ModularWeaponId: IOid;
+ };
+}
diff --git a/src/controllers/api/tauntHistoryController.ts b/src/controllers/api/tauntHistoryController.ts
new file mode 100644
index 00000000..51726bfe
--- /dev/null
+++ b/src/controllers/api/tauntHistoryController.ts
@@ -0,0 +1,21 @@
+import type { RequestHandler } from "express";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import type { ITaunt } from "../../types/inventoryTypes/inventoryTypes.ts";
+import { logger } from "../../utils/logger.ts";
+
+export const tauntHistoryController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId);
+ const clientTaunt = JSON.parse(String(req.body)) as ITaunt;
+ logger.debug(`updating taunt ${clientTaunt.node} to state ${clientTaunt.state}`);
+ inventory.TauntHistory ??= [];
+ const taunt = inventory.TauntHistory.find(x => x.node == clientTaunt.node);
+ if (taunt) {
+ taunt.state = clientTaunt.state;
+ } else {
+ inventory.TauntHistory.push(clientTaunt);
+ }
+ await inventory.save();
+ res.end();
+};
diff --git a/src/controllers/api/tradingController.ts b/src/controllers/api/tradingController.ts
new file mode 100644
index 00000000..f159a521
--- /dev/null
+++ b/src/controllers/api/tradingController.ts
@@ -0,0 +1,23 @@
+import { getGuildForRequestEx, hasAccessToDojo, hasGuildPermission } from "../../services/guildService.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { GuildPermission } from "../../types/guildTypes.ts";
+import type { RequestHandler } from "express";
+
+export const tradingController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId, "GuildId LevelKeys");
+ const guild = await getGuildForRequestEx(req, inventory);
+ const op = req.query.op as string;
+ if (op == "5") {
+ if (!hasAccessToDojo(inventory) || !(await hasGuildPermission(guild, accountId, GuildPermission.Treasurer))) {
+ res.status(400).send("-1").end();
+ return;
+ }
+ guild.TradeTax = parseInt(req.query.tax as string);
+ await guild.save();
+ res.send(guild.TradeTax).end();
+ } else {
+ throw new Error(`unknown trading op: ${op}`);
+ }
+};
diff --git a/src/controllers/api/trainingResultController.ts b/src/controllers/api/trainingResultController.ts
new file mode 100644
index 00000000..8c1d55bd
--- /dev/null
+++ b/src/controllers/api/trainingResultController.ts
@@ -0,0 +1,79 @@
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import type { IMongoDate } from "../../types/commonTypes.ts";
+import type { RequestHandler } from "express";
+import { unixTimesInMs } from "../../constants/timeConstants.ts";
+import type { IInventoryChanges } from "../../types/purchaseTypes.ts";
+import { createMessage } from "../../services/inboxService.ts";
+
+interface ITrainingResultsRequest {
+ numLevelsGained: number;
+}
+
+interface ITrainingResultsResponse {
+ NewTrainingDate: IMongoDate;
+ NewLevel: number;
+ InventoryChanges: IInventoryChanges;
+}
+
+const trainingResultController: RequestHandler = async (req, res): Promise => {
+ const accountId = await getAccountIdForRequest(req);
+
+ const trainingResults = getJSONfromString(String(req.body));
+
+ const inventory = await getInventory(accountId, "TrainingDate PlayerLevel TradesRemaining noMasteryRankUpCooldown");
+
+ if (trainingResults.numLevelsGained == 1) {
+ let time = Date.now();
+ if (!inventory.noMasteryRankUpCooldown) {
+ time += unixTimesInMs.hour * 23;
+ }
+ inventory.TrainingDate = new Date(time);
+
+ inventory.PlayerLevel += 1;
+ inventory.TradesRemaining += 1;
+
+ if (inventory.PlayerLevel == 2) {
+ await createMessage(accountId, [
+ {
+ sndr: "/Lotus/Language/Game/Maroo",
+ msg: "/Lotus/Language/Clan/MarooClanSearchDesc",
+ sub: "/Lotus/Language/Clan/MarooClanSearchTitle",
+ icon: "/Lotus/Interface/Icons/Npcs/Maroo.png"
+ }
+ ]);
+ }
+
+ await createMessage(accountId, [
+ {
+ sndr: "/Lotus/Language/Menu/Mailbox_WarframeSender",
+ msg: "/Lotus/Language/Inbox/MasteryRewardMsg",
+ arg: [
+ {
+ Key: "NEW_RANK",
+ Tag: inventory.PlayerLevel
+ }
+ ],
+ att: [
+ `/Lotus/Types/Items/ShipDecos/MasteryTrophies/Rank${inventory.PlayerLevel.toString().padStart(2, "0")}Trophy`
+ ],
+ sub: "/Lotus/Language/Inbox/MasteryRewardTitle",
+ icon: "/Lotus/Interface/Icons/Npcs/Lotus_d.png",
+ highPriority: true
+ }
+ ]);
+ }
+
+ const changedinventory = await inventory.save();
+
+ res.json({
+ NewTrainingDate: {
+ $date: { $numberLong: changedinventory.TrainingDate.getTime().toString() }
+ },
+ NewLevel: trainingResults.numLevelsGained == 1 ? changedinventory.PlayerLevel : inventory.PlayerLevel,
+ InventoryChanges: {}
+ } satisfies ITrainingResultsResponse);
+};
+
+export { trainingResultController };
diff --git a/src/controllers/api/umbraController.ts b/src/controllers/api/umbraController.ts
new file mode 100644
index 00000000..fc7a3ada
--- /dev/null
+++ b/src/controllers/api/umbraController.ts
@@ -0,0 +1,27 @@
+import { fromMongoDate, fromOid } from "../../helpers/inventoryHelpers.ts";
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { addMiscItem, getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { IEquipmentClient } from "../../types/equipmentTypes.ts";
+import type { RequestHandler } from "express";
+
+export const umbraController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId, "Suits MiscItems");
+ const payload = getJSONfromString(String(req.body));
+ for (const clientSuit of payload.Suits) {
+ const dbSuit = inventory.Suits.id(fromOid(clientSuit.ItemId))!;
+ if (clientSuit.UmbraDate) {
+ addMiscItem(inventory, "/Lotus/Types/Items/MiscItems/UmbraEchoes", -1);
+ dbSuit.UmbraDate = fromMongoDate(clientSuit.UmbraDate);
+ } else {
+ dbSuit.UmbraDate = undefined;
+ }
+ }
+ await inventory.save();
+ res.end();
+};
+
+interface IUmbraRequest {
+ Suits: IEquipmentClient[];
+}
diff --git a/src/controllers/api/unlockShipFeatureController.ts b/src/controllers/api/unlockShipFeatureController.ts
new file mode 100644
index 00000000..fb047d8d
--- /dev/null
+++ b/src/controllers/api/unlockShipFeatureController.ts
@@ -0,0 +1,11 @@
+import type { RequestHandler } from "express";
+import { updateShipFeature } from "../../services/personalRoomsService.ts";
+import type { IUnlockShipFeatureRequest } from "../../types/requestTypes.ts";
+import { parseString } from "../../helpers/general.ts";
+
+export const unlockShipFeatureController: RequestHandler = async (req, res) => {
+ const accountId = parseString(req.query.accountId);
+ const shipFeatureRequest = JSON.parse((req.body as string).toString()) as IUnlockShipFeatureRequest;
+ await updateShipFeature(accountId, shipFeatureRequest.Feature);
+ res.send([]);
+};
diff --git a/src/controllers/api/updateAlignmentController.ts b/src/controllers/api/updateAlignmentController.ts
new file mode 100644
index 00000000..79cb5d65
--- /dev/null
+++ b/src/controllers/api/updateAlignmentController.ts
@@ -0,0 +1,25 @@
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { IAlignment } from "../../types/inventoryTypes/inventoryTypes.ts";
+import type { RequestHandler } from "express";
+
+export const updateAlignmentController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId);
+ const body = getJSONfromString(String(req.body));
+ inventory.Alignment = {
+ Alignment: body.Alignment,
+ Wisdom: body.Wisdom
+ };
+ await inventory.save();
+ res.json(inventory.Alignment);
+};
+
+interface IUpdateAlignmentRequest {
+ Wisdom: number;
+ Alignment: number;
+ PreviousAlignment: IAlignment;
+ AlignmentAction: string; // e.g. "/Lotus/Language/Game/MawCinematicDualChoice"
+ KeyChainName: string;
+}
diff --git a/src/controllers/api/updateChallengeProgressController.ts b/src/controllers/api/updateChallengeProgressController.ts
new file mode 100644
index 00000000..7d43e2f6
--- /dev/null
+++ b/src/controllers/api/updateChallengeProgressController.ts
@@ -0,0 +1,77 @@
+import type { RequestHandler } from "express";
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { getAccountForRequest } from "../../services/loginService.ts";
+import { addCalendarProgress, addChallenges, getInventory } from "../../services/inventoryService.ts";
+import type { IChallengeProgress, ISeasonChallenge } from "../../types/inventoryTypes/inventoryTypes.ts";
+import type { IAffiliationMods } from "../../types/purchaseTypes.ts";
+import { getEntriesUnsafe } from "../../utils/ts-utils.ts";
+import { logger } from "../../utils/logger.ts";
+
+export const updateChallengeProgressController: RequestHandler = async (req, res) => {
+ const challenges = getJSONfromString(String(req.body));
+ const account = await getAccountForRequest(req);
+ logger.debug(`challenge report:`, challenges);
+
+ const inventory = await getInventory(
+ account._id.toString(),
+ "ChallengesFixVersion ChallengeProgress SeasonChallengeHistory Affiliations CalendarProgress"
+ );
+ let affiliationMods: IAffiliationMods[] = [];
+ if (challenges.ChallengeProgress) {
+ affiliationMods = await addChallenges(
+ account,
+ inventory,
+ challenges.ChallengeProgress,
+ challenges.SeasonChallengeCompletions
+ );
+ }
+ for (const [key, value] of getEntriesUnsafe(challenges)) {
+ if (value === undefined) {
+ logger.error(`Challenge progress update key ${key} has no value`);
+ continue;
+ }
+ switch (key) {
+ case "ChallengesFixVersion":
+ inventory.ChallengesFixVersion = value;
+ break;
+
+ case "SeasonChallengeHistory":
+ value.forEach(({ challenge, id }) => {
+ const itemIndex = inventory.SeasonChallengeHistory.findIndex(i => i.challenge === challenge);
+ if (itemIndex !== -1) {
+ inventory.SeasonChallengeHistory[itemIndex].id = id;
+ } else {
+ inventory.SeasonChallengeHistory.push({ challenge, id });
+ }
+ });
+ break;
+
+ case "CalendarProgress":
+ addCalendarProgress(inventory, value);
+ break;
+
+ case "ChallengeProgress":
+ case "SeasonChallengeCompletions":
+ case "ChallengePTS":
+ case "crossPlaySetting":
+ break;
+ default:
+ logger.warn(`unknown challenge progress entry`, { key, value });
+ }
+ }
+ await inventory.save();
+
+ res.json({
+ AffiliationMods: affiliationMods
+ });
+};
+
+interface IUpdateChallengeProgressRequest {
+ ChallengePTS?: number;
+ ChallengesFixVersion?: number;
+ ChallengeProgress?: IChallengeProgress[];
+ SeasonChallengeHistory?: ISeasonChallenge[];
+ SeasonChallengeCompletions?: ISeasonChallenge[];
+ CalendarProgress?: { challenge: string }[];
+ crossPlaySetting?: string;
+}
diff --git a/src/controllers/api/updateQuestController.ts b/src/controllers/api/updateQuestController.ts
new file mode 100644
index 00000000..0ca4d857
--- /dev/null
+++ b/src/controllers/api/updateQuestController.ts
@@ -0,0 +1,32 @@
+import type { RequestHandler } from "express";
+import { parseString } from "../../helpers/general.ts";
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import type { IUpdateQuestRequest } from "../../services/questService.ts";
+import { updateQuestKey } from "../../services/questService.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import type { IInventoryChanges } from "../../types/purchaseTypes.ts";
+
+export const updateQuestController: RequestHandler = async (req, res) => {
+ const accountId = parseString(req.query.accountId);
+ const updateQuestRequest = getJSONfromString((req.body as string).toString());
+
+ // updates should be made only to one quest key per request
+ if (updateQuestRequest.QuestKeys.length > 1) {
+ throw new Error(`quest keys array should only have 1 item, but has ${updateQuestRequest.QuestKeys.length}`);
+ }
+
+ const inventory = await getInventory(accountId);
+
+ const updateQuestResponse: { CustomData?: string; InventoryChanges?: IInventoryChanges; MissionRewards: [] } = {
+ MissionRewards: []
+ };
+ updateQuestResponse.InventoryChanges = await updateQuestKey(inventory, updateQuestRequest.QuestKeys);
+
+ //TODO: might need to parse the custom data and add the associated items to inventory
+ if (updateQuestRequest.QuestKeys[0].CustomData) {
+ updateQuestResponse.CustomData = updateQuestRequest.QuestKeys[0].CustomData;
+ }
+
+ await inventory.save();
+ res.send(updateQuestResponse);
+};
diff --git a/src/controllers/api/updateSessionController.ts b/src/controllers/api/updateSessionController.ts
new file mode 100644
index 00000000..787f2d8c
--- /dev/null
+++ b/src/controllers/api/updateSessionController.ts
@@ -0,0 +1,13 @@
+import type { RequestHandler } from "express";
+import { updateSession } from "../../managers/sessionManager.ts";
+
+const updateSessionGetController: RequestHandler = (_req, res) => {
+ res.json({});
+};
+const updateSessionPostController: RequestHandler = (_req, res) => {
+ //console.log("UpdateSessions POST Request:", JSON.parse(String(_req.body)));
+ //console.log("ReqID:", _req.query.sessionId as string);
+ updateSession(_req.query.sessionId as string, String(_req.body));
+ res.json({});
+};
+export { updateSessionGetController, updateSessionPostController };
diff --git a/src/controllers/api/updateSongChallengeController.ts b/src/controllers/api/updateSongChallengeController.ts
new file mode 100644
index 00000000..0fc485fd
--- /dev/null
+++ b/src/controllers/api/updateSongChallengeController.ts
@@ -0,0 +1,50 @@
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { addShipDecorations, getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { IInventoryChanges } from "../../types/purchaseTypes.ts";
+import type { RequestHandler } from "express";
+
+export const updateSongChallengeController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId);
+ const request = getJSONfromString(String(req.body));
+ inventory.SongChallenges ??= [];
+ let songChallenge = inventory.SongChallenges.find(x => x.Song == request.Song);
+ if (!songChallenge) {
+ songChallenge =
+ inventory.SongChallenges[inventory.SongChallenges.push({ Song: request.Song, Difficulties: [] }) - 1];
+ }
+ songChallenge.Difficulties.push(request.Difficulty);
+
+ const response: IUpdateSongChallengeResponse = {
+ Song: request.Song,
+ Difficulty: request.Difficulty
+ };
+
+ // Handle all songs being completed on all difficulties
+ if (inventory.SongChallenges.length == 12 && !inventory.SongChallenges.find(x => x.Difficulties.length != 2)) {
+ response.Reward = "/Lotus/StoreItems/Types/Items/ShipDecos/LisetPropShawzinDuviri";
+ const shipDecorationChanges = [
+ { ItemType: "/Lotus/Types/Items/ShipDecos/LisetPropShawzinDuviri", ItemCount: 1 }
+ ];
+ response.InventoryChanges = {
+ ShipDecorations: shipDecorationChanges
+ };
+ addShipDecorations(inventory, shipDecorationChanges);
+ }
+
+ await inventory.save();
+ res.json(response);
+};
+
+interface IUpdateSongChallengeRequest {
+ Song: string;
+ Difficulty: number;
+}
+
+interface IUpdateSongChallengeResponse {
+ Song: string;
+ Difficulty: number;
+ Reward?: string;
+ InventoryChanges?: IInventoryChanges;
+}
diff --git a/src/controllers/api/updateThemeController.ts b/src/controllers/api/updateThemeController.ts
new file mode 100644
index 00000000..0998964b
--- /dev/null
+++ b/src/controllers/api/updateThemeController.ts
@@ -0,0 +1,23 @@
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import type { RequestHandler } from "express";
+import { getInventory } from "../../services/inventoryService.ts";
+
+export const updateThemeController: RequestHandler = async (request, response) => {
+ const accountId = await getAccountIdForRequest(request);
+ const data = getJSONfromString(String(request.body));
+
+ const inventory = await getInventory(accountId, "ThemeStyle ThemeBackground ThemeSounds");
+ if (data.Style) inventory.ThemeStyle = data.Style;
+ if (data.Background) inventory.ThemeBackground = data.Background;
+ if (data.Sounds) inventory.ThemeSounds = data.Sounds;
+ await inventory.save();
+
+ response.json({});
+};
+
+interface IThemeUpdateRequest {
+ Style?: string;
+ Background?: string;
+ Sounds?: string;
+}
diff --git a/src/controllers/api/upgradeOperatorController.ts b/src/controllers/api/upgradeOperatorController.ts
new file mode 100644
index 00000000..4aedef6f
--- /dev/null
+++ b/src/controllers/api/upgradeOperatorController.ts
@@ -0,0 +1,16 @@
+import { getInventory, updateCurrency } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { RequestHandler } from "express";
+
+export const upgradeOperatorController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(
+ accountId,
+ "OperatorCustomizationSlotPurchases PremiumCredits PremiumCreditsFree"
+ );
+ inventory.OperatorCustomizationSlotPurchases ??= 0;
+ inventory.OperatorCustomizationSlotPurchases += 1;
+ const inventoryChanges = updateCurrency(inventory, 10, true);
+ await inventory.save();
+ res.json({ InventoryChanges: inventoryChanges });
+};
diff --git a/src/controllers/api/upgradesController.ts b/src/controllers/api/upgradesController.ts
new file mode 100644
index 00000000..dbb9f1db
--- /dev/null
+++ b/src/controllers/api/upgradesController.ts
@@ -0,0 +1,161 @@
+import type { RequestHandler } from "express";
+import type { IUpgradesRequest } from "../../types/requestTypes.ts";
+import type { ArtifactPolarity, IAbilityOverride } from "../../types/inventoryTypes/commonInventoryTypes.ts";
+import type { IInventoryClient, IMiscItem } from "../../types/inventoryTypes/inventoryTypes.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { addMiscItems, addRecipes, getInventory, updateCurrency } from "../../services/inventoryService.ts";
+import { getRecipeByResult } from "../../services/itemDataService.ts";
+import type { IInventoryChanges } from "../../types/purchaseTypes.ts";
+import { addInfestedFoundryXP, applyCheatsToInfestedFoundry } from "../../services/infestedFoundryService.ts";
+import { sendWsBroadcastTo } from "../../services/wsService.ts";
+import type { IEquipmentDatabase } from "../../types/equipmentTypes.ts";
+import { EquipmentFeatures } from "../../types/equipmentTypes.ts";
+
+export const upgradesController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const payload = JSON.parse(String(req.body)) as IUpgradesRequest;
+ const inventory = await getInventory(accountId);
+ const inventoryChanges: IInventoryChanges = {};
+ for (const operation of payload.Operations) {
+ if (
+ operation.UpgradeRequirement == "/Lotus/Types/Items/MiscItems/ModSlotUnlocker" ||
+ operation.UpgradeRequirement == "/Lotus/Types/Items/MiscItems/CustomizationSlotUnlocker"
+ ) {
+ updateCurrency(inventory, 10, true);
+ } else if (
+ operation.OperationType != "UOT_SWAP_POLARITY" &&
+ operation.OperationType != "UOT_ABILITY_OVERRIDE"
+ ) {
+ if (!operation.UpgradeRequirement) {
+ throw new Error(`${operation.OperationType} operation should be free?`);
+ }
+ addMiscItems(inventory, [
+ {
+ ItemType: operation.UpgradeRequirement,
+ ItemCount: -1
+ } satisfies IMiscItem
+ ]);
+ }
+
+ if (operation.OperationType == "UOT_ABILITY_OVERRIDE") {
+ console.assert(payload.ItemCategory == "Suits");
+ const suit = inventory.Suits.id(payload.ItemId.$oid)!;
+
+ let newAbilityOverride: IAbilityOverride | undefined;
+ let totalPercentagePointsConsumed = 0;
+ if (operation.UpgradeRequirement != "") {
+ newAbilityOverride = {
+ Ability: operation.UpgradeRequirement,
+ Index: operation.PolarizeSlot
+ };
+
+ const recipe = getRecipeByResult(operation.UpgradeRequirement)!;
+ for (const ingredient of recipe.ingredients) {
+ totalPercentagePointsConsumed += ingredient.ItemCount / 10;
+ if (!inventory.infiniteHelminthMaterials) {
+ inventory.InfestedFoundry!.Resources!.find(x => x.ItemType == ingredient.ItemType)!.Count -=
+ ingredient.ItemCount;
+ }
+ }
+ }
+
+ for (const entry of operation.PolarityRemap) {
+ suit.Configs[entry.Slot] ??= {};
+ suit.Configs[entry.Slot].AbilityOverride = newAbilityOverride;
+ }
+
+ const recipeChanges = addInfestedFoundryXP(inventory.InfestedFoundry!, totalPercentagePointsConsumed * 8);
+ addRecipes(inventory, recipeChanges);
+
+ inventoryChanges.Recipes = recipeChanges;
+ inventoryChanges.InfestedFoundry = inventory.toJSON().InfestedFoundry;
+ applyCheatsToInfestedFoundry(inventory, inventoryChanges.InfestedFoundry!);
+ } else
+ switch (operation.UpgradeRequirement) {
+ case "/Lotus/Types/Items/MiscItems/OrokinReactor":
+ case "/Lotus/Types/Items/MiscItems/OrokinCatalyst": {
+ const item = inventory[payload.ItemCategory].id(payload.ItemId.$oid)!;
+ item.Features ??= 0;
+ item.Features |= EquipmentFeatures.DOUBLE_CAPACITY;
+ break;
+ }
+ case "/Lotus/Types/Items/MiscItems/UtilityUnlocker":
+ case "/Lotus/Types/Items/MiscItems/WeaponUtilityUnlocker": {
+ const item = inventory[payload.ItemCategory].id(payload.ItemId.$oid)!;
+ item.Features ??= 0;
+ item.Features |= EquipmentFeatures.UTILITY_SLOT;
+ break;
+ }
+ case "/Lotus/Types/Items/MiscItems/HeavyWeaponCatalyst": {
+ console.assert(payload.ItemCategory == "SpaceGuns");
+ const item = inventory[payload.ItemCategory].id(payload.ItemId.$oid)!;
+ item.Features ??= 0;
+ item.Features |= EquipmentFeatures.GRAVIMAG_INSTALLED;
+ break;
+ }
+ case "/Lotus/Types/Items/MiscItems/WeaponPrimaryArcaneUnlocker":
+ case "/Lotus/Types/Items/MiscItems/WeaponSecondaryArcaneUnlocker":
+ case "/Lotus/Types/Items/MiscItems/WeaponMeleeArcaneUnlocker":
+ case "/Lotus/Types/Items/MiscItems/WeaponAmpArcaneUnlocker": {
+ const item = inventory[payload.ItemCategory].id(payload.ItemId.$oid)!;
+ item.Features ??= 0;
+ item.Features |= EquipmentFeatures.ARCANE_SLOT;
+ break;
+ }
+ case "/Lotus/Types/Items/MiscItems/ValenceAdapter": {
+ const item = inventory[payload.ItemCategory].id(payload.ItemId.$oid)!;
+ item.Features ??= 0;
+ item.Features |= EquipmentFeatures.VALENCE_SWAP;
+ break;
+ }
+ case "/Lotus/Types/Items/MiscItems/Forma":
+ case "/Lotus/Types/Items/MiscItems/FormaUmbra":
+ case "/Lotus/Types/Items/MiscItems/FormaAura":
+ case "/Lotus/Types/Items/MiscItems/FormaStance": {
+ const item = inventory[payload.ItemCategory].id(payload.ItemId.$oid)!;
+ item.XP = 0;
+ setSlotPolarity(item, operation.PolarizeSlot, operation.PolarizeValue);
+ item.Polarized ??= 0;
+ item.Polarized += 1;
+ sendWsBroadcastTo(accountId, { update_inventory: true }); // webui may need to to re-add "max rank" button
+ break;
+ }
+ case "/Lotus/Types/Items/MiscItems/ModSlotUnlocker": {
+ const item = inventory[payload.ItemCategory].id(payload.ItemId.$oid)!;
+ item.ModSlotPurchases ??= 0;
+ item.ModSlotPurchases += 1;
+ break;
+ }
+ case "/Lotus/Types/Items/MiscItems/CustomizationSlotUnlocker": {
+ const item = inventory[payload.ItemCategory].id(payload.ItemId.$oid)!;
+ item.CustomizationSlotPurchases ??= 0;
+ item.CustomizationSlotPurchases += 1;
+ break;
+ }
+ case "": {
+ console.assert(operation.OperationType == "UOT_SWAP_POLARITY");
+ const item = inventory[payload.ItemCategory].id(payload.ItemId.$oid)!;
+ for (let i = 0; i != operation.PolarityRemap.length; ++i) {
+ if (operation.PolarityRemap[i].Slot != i) {
+ setSlotPolarity(item, i, operation.PolarityRemap[i].Value);
+ }
+ }
+ break;
+ }
+ default:
+ throw new Error("Unsupported upgrade: " + operation.UpgradeRequirement);
+ }
+ }
+ await inventory.save();
+ res.json({ InventoryChanges: inventoryChanges });
+};
+
+const setSlotPolarity = (item: IEquipmentDatabase, slot: number, polarity: ArtifactPolarity): void => {
+ item.Polarity ??= [];
+ const entry = item.Polarity.find(entry => entry.Slot == slot);
+ if (entry) {
+ entry.Value = polarity;
+ } else {
+ item.Polarity.push({ Slot: slot, Value: polarity });
+ }
+};
diff --git a/src/controllers/api/valenceSwapController.ts b/src/controllers/api/valenceSwapController.ts
new file mode 100644
index 00000000..198415a0
--- /dev/null
+++ b/src/controllers/api/valenceSwapController.ts
@@ -0,0 +1,29 @@
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { IOid } from "../../types/commonTypes.ts";
+import type { IInnateDamageFingerprint, TEquipmentKey } from "../../types/inventoryTypes/inventoryTypes.ts";
+import type { RequestHandler } from "express";
+
+export const valenceSwapController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const body = JSON.parse(String(req.body)) as IValenceSwapRequest;
+ const inventory = await getInventory(accountId, body.WeaponCategory);
+ const weapon = inventory[body.WeaponCategory].id(body.WeaponId.$oid)!;
+
+ const upgradeFingerprint = JSON.parse(weapon.UpgradeFingerprint!) as IInnateDamageFingerprint;
+ upgradeFingerprint.buffs[0].Tag = body.NewValenceUpgradeTag;
+ weapon.UpgradeFingerprint = JSON.stringify(upgradeFingerprint);
+
+ await inventory.save();
+ res.json({
+ InventoryChanges: {
+ [body.WeaponCategory]: [weapon.toJSON()]
+ }
+ });
+};
+
+interface IValenceSwapRequest {
+ WeaponId: IOid;
+ WeaponCategory: TEquipmentKey;
+ NewValenceUpgradeTag: string;
+}
diff --git a/src/controllers/api/wishlistController.ts b/src/controllers/api/wishlistController.ts
new file mode 100644
index 00000000..2ff6ee52
--- /dev/null
+++ b/src/controllers/api/wishlistController.ts
@@ -0,0 +1,24 @@
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { RequestHandler } from "express";
+
+export const wishlistController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId, "Wishlist");
+ const body = getJSONfromString(String(req.body));
+ for (const item of body.WishlistItems) {
+ const i = inventory.Wishlist.findIndex(x => x == item);
+ if (i == -1) {
+ inventory.Wishlist.push(item);
+ } else {
+ inventory.Wishlist.splice(i, 1);
+ }
+ }
+ await inventory.save();
+ res.end();
+};
+
+interface IWishlistRequest {
+ WishlistItems: string[];
+}
diff --git a/src/controllers/custom/abilityOverrideController.ts b/src/controllers/custom/abilityOverrideController.ts
new file mode 100644
index 00000000..47b7e13a
--- /dev/null
+++ b/src/controllers/custom/abilityOverrideController.ts
@@ -0,0 +1,33 @@
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { TEquipmentKey } from "../../types/inventoryTypes/inventoryTypes.ts";
+import type { RequestHandler } from "express";
+
+export const abilityOverrideController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const request = req.body as IAbilityOverrideRequest;
+ if (request.category === "Suits") {
+ const inventory = await getInventory(accountId, request.category);
+ const item = inventory[request.category].id(request.oid);
+ if (item) {
+ if (request.action == "set") {
+ item.Configs[request.configIndex].AbilityOverride = request.AbilityOverride;
+ } else {
+ item.Configs[request.configIndex].AbilityOverride = undefined;
+ }
+ await inventory.save();
+ }
+ }
+ res.end();
+};
+
+interface IAbilityOverrideRequest {
+ category: TEquipmentKey;
+ oid: string;
+ action: "set" | "remove";
+ configIndex: number;
+ AbilityOverride: {
+ Ability: string;
+ Index: number;
+ };
+}
diff --git a/src/controllers/custom/addCurrencyController.ts b/src/controllers/custom/addCurrencyController.ts
new file mode 100644
index 00000000..dd7f472b
--- /dev/null
+++ b/src/controllers/custom/addCurrencyController.ts
@@ -0,0 +1,21 @@
+import type { RequestHandler } from "express";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { addFusionPoints, getInventory } from "../../services/inventoryService.ts";
+
+export const addCurrencyController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const request = req.body as IAddCurrencyRequest;
+ const inventory = await getInventory(accountId, request.currency);
+ if (request.currency == "FusionPoints") {
+ addFusionPoints(inventory, request.delta);
+ } else {
+ inventory[request.currency] += request.delta;
+ }
+ await inventory.save();
+ res.end();
+};
+
+interface IAddCurrencyRequest {
+ currency: "RegularCredits" | "PremiumCredits" | "FusionPoints" | "PrimeTokens";
+ delta: number;
+}
diff --git a/src/controllers/custom/addItemsController.ts b/src/controllers/custom/addItemsController.ts
new file mode 100644
index 00000000..1dc76718
--- /dev/null
+++ b/src/controllers/custom/addItemsController.ts
@@ -0,0 +1,20 @@
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { getInventory, addItem } from "../../services/inventoryService.ts";
+import type { RequestHandler } from "express";
+
+export const addItemsController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const requests = req.body as IAddItemRequest[];
+ const inventory = await getInventory(accountId);
+ for (const request of requests) {
+ await addItem(inventory, request.ItemType, request.ItemCount, true, undefined, request.Fingerprint, true);
+ }
+ await inventory.save();
+ res.end();
+};
+
+interface IAddItemRequest {
+ ItemType: string;
+ ItemCount: number;
+ Fingerprint?: string;
+}
diff --git a/src/controllers/custom/addMissingHelminthBlueprintsController.ts b/src/controllers/custom/addMissingHelminthBlueprintsController.ts
new file mode 100644
index 00000000..27f6dec8
--- /dev/null
+++ b/src/controllers/custom/addMissingHelminthBlueprintsController.ts
@@ -0,0 +1,24 @@
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { getInventory, addRecipes } from "../../services/inventoryService.ts";
+import type { RequestHandler } from "express";
+import { ExportRecipes } from "warframe-public-export-plus";
+
+export const addMissingHelminthBlueprintsController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId, "Recipes");
+ const allHelminthRecipes = Object.keys(ExportRecipes).filter(
+ key => ExportRecipes[key].secretIngredientAction === "SIA_WARFRAME_ABILITY"
+ );
+ const inventoryHelminthRecipes = inventory.Recipes.filter(recipe =>
+ recipe.ItemType.startsWith("/Lotus/Types/Recipes/AbilityOverrides/")
+ ).map(recipe => recipe.ItemType);
+
+ const missingHelminthRecipes = allHelminthRecipes
+ .filter(key => !inventoryHelminthRecipes.includes(key))
+ .map(ItemType => ({ ItemType, ItemCount: 1 }));
+
+ addRecipes(inventory, missingHelminthRecipes);
+
+ await inventory.save();
+ res.end();
+};
diff --git a/src/controllers/custom/addMissingMaxRankModsController.ts b/src/controllers/custom/addMissingMaxRankModsController.ts
new file mode 100644
index 00000000..46a299a3
--- /dev/null
+++ b/src/controllers/custom/addMissingMaxRankModsController.ts
@@ -0,0 +1,44 @@
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { RequestHandler } from "express";
+import { ExportArcanes, ExportUpgrades } from "warframe-public-export-plus";
+
+export const addMissingMaxRankModsController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId, "Upgrades");
+
+ const maxOwnedRanks: Record = {};
+ for (const upgrade of inventory.Upgrades) {
+ const fingerprint = JSON.parse(upgrade.UpgradeFingerprint ?? "{}") as { lvl?: number };
+ if (fingerprint.lvl) {
+ maxOwnedRanks[upgrade.ItemType] ??= 0;
+ if (fingerprint.lvl > maxOwnedRanks[upgrade.ItemType]) {
+ maxOwnedRanks[upgrade.ItemType] = fingerprint.lvl;
+ }
+ }
+ }
+
+ for (const [uniqueName, data] of Object.entries(ExportUpgrades)) {
+ if (data.fusionLimit != 0 && data.type != "PARAZON" && maxOwnedRanks[uniqueName] != data.fusionLimit) {
+ inventory.Upgrades.push({
+ ItemType: uniqueName,
+ UpgradeFingerprint: JSON.stringify({ lvl: data.fusionLimit })
+ });
+ }
+ }
+
+ for (const [uniqueName, data] of Object.entries(ExportArcanes)) {
+ if (
+ data.name != "/Lotus/Language/Items/GenericCosmeticEnhancerName" &&
+ maxOwnedRanks[uniqueName] != data.fusionLimit
+ ) {
+ inventory.Upgrades.push({
+ ItemType: uniqueName,
+ UpgradeFingerprint: JSON.stringify({ lvl: data.fusionLimit })
+ });
+ }
+ }
+
+ await inventory.save();
+ res.end();
+};
diff --git a/src/controllers/custom/addXpController.ts b/src/controllers/custom/addXpController.ts
new file mode 100644
index 00000000..0b469168
--- /dev/null
+++ b/src/controllers/custom/addXpController.ts
@@ -0,0 +1,32 @@
+import { applyClientEquipmentUpdates, getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { IOid } from "../../types/commonTypes.ts";
+import type { IEquipmentClient } from "../../types/equipmentTypes.ts";
+import type { TEquipmentKey } from "../../types/inventoryTypes/inventoryTypes.ts";
+import type { RequestHandler } from "express";
+import { ExportMisc } from "warframe-public-export-plus";
+
+export const addXpController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId);
+ const request = req.body as IAddXpRequest;
+ for (const [category, gear] of Object.entries(request)) {
+ for (const clientItem of gear) {
+ const dbItem = inventory[category as TEquipmentKey].id((clientItem.ItemId as IOid).$oid);
+ if (dbItem) {
+ if (dbItem.ItemType in ExportMisc.uniqueLevelCaps) {
+ if ((dbItem.Polarized ?? 0) < 5) {
+ dbItem.Polarized = 5;
+ }
+ }
+ }
+ }
+ applyClientEquipmentUpdates(inventory, gear, category as TEquipmentKey);
+ }
+ await inventory.save();
+ res.end();
+};
+
+type IAddXpRequest = {
+ [_ in TEquipmentKey]: IEquipmentClient[];
+};
diff --git a/src/controllers/custom/changeModularPartsController.ts b/src/controllers/custom/changeModularPartsController.ts
new file mode 100644
index 00000000..df63da13
--- /dev/null
+++ b/src/controllers/custom/changeModularPartsController.ts
@@ -0,0 +1,65 @@
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { TEquipmentKey } from "../../types/inventoryTypes/inventoryTypes.ts";
+import type { RequestHandler } from "express";
+
+export const changeModularPartsController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const request = req.body as IUpdateFingerPrintRequest;
+ const inventory = await getInventory(accountId, request.category);
+ const item = inventory[request.category].id(request.oid);
+ if (item) {
+ item.ModularParts = request.modularParts;
+
+ request.modularParts.forEach(part => {
+ const categoryMap = mapping[part];
+ if (categoryMap && categoryMap[request.category]) {
+ item.ItemType = categoryMap[request.category]!;
+ }
+ });
+ await inventory.save();
+ }
+ res.end();
+};
+
+interface IUpdateFingerPrintRequest {
+ category: TEquipmentKey;
+ oid: string;
+ modularParts: string[];
+}
+
+const mapping: Partial>>> = {
+ "/Lotus/Weapons/SolarisUnited/Secondary/SUModularSecondarySet1/Barrel/SUModularSecondaryBarrelAPart": {
+ LongGuns: "/Lotus/Weapons/SolarisUnited/Primary/LotusModularPrimaryShotgun",
+ Pistols: "/Lotus/Weapons/SolarisUnited/Secondary/LotusModularSecondaryShotgun"
+ },
+ "/Lotus/Weapons/Infested/Pistols/InfKitGun/Barrels/InfBarrelEgg/InfModularBarrelEggPart": {
+ LongGuns: "/Lotus/Weapons/SolarisUnited/Primary/LotusModularPrimaryShotgun",
+ Pistols: "/Lotus/Weapons/SolarisUnited/Secondary/LotusModularSecondaryShotgun"
+ },
+ "/Lotus/Weapons/SolarisUnited/Secondary/SUModularSecondarySet1/Barrel/SUModularSecondaryBarrelBPart": {
+ LongGuns: "/Lotus/Weapons/SolarisUnited/Primary/LotusModularPrimary",
+ Pistols: "/Lotus/Weapons/SolarisUnited/Secondary/LotusModularSecondary"
+ },
+ "/Lotus/Weapons/SolarisUnited/Secondary/SUModularSecondarySet1/Barrel/SUModularSecondaryBarrelCPart": {
+ LongGuns: "/Lotus/Weapons/SolarisUnited/Primary/LotusModularPrimary",
+ Pistols: "/Lotus/Weapons/SolarisUnited/Secondary/LotusModularSecondary"
+ },
+ "/Lotus/Weapons/SolarisUnited/Secondary/SUModularSecondarySet1/Barrel/SUModularSecondaryBarrelDPart": {
+ LongGuns: "/Lotus/Weapons/SolarisUnited/Primary/LotusModularPrimaryBeam",
+ Pistols: "/Lotus/Weapons/SolarisUnited/Secondary/LotusModularSecondaryBeam"
+ },
+ "/Lotus/Weapons/Infested/Pistols/InfKitGun/Barrels/InfBarrelBeam/InfModularBarrelBeamPart": {
+ LongGuns: "/Lotus/Weapons/SolarisUnited/Primary/LotusModularPrimaryBeam",
+ Pistols: "/Lotus/Weapons/SolarisUnited/Secondary/LotusModularSecondaryBeam"
+ },
+ "/Lotus/Types/Friendly/Pets/ZanukaPets/ZanukaPetParts/ZanukaPetPartHeadA": {
+ MoaPets: "/Lotus/Types/Friendly/Pets/ZanukaPets/ZanukaPetAPowerSuit"
+ },
+ "/Lotus/Types/Friendly/Pets/ZanukaPets/ZanukaPetParts/ZanukaPetPartHeadB": {
+ MoaPets: "/Lotus/Types/Friendly/Pets/ZanukaPets/ZanukaPetBPowerSuit"
+ },
+ "/Lotus/Types/Friendly/Pets/ZanukaPets/ZanukaPetParts/ZanukaPetPartHeadC": {
+ MoaPets: "/Lotus/Types/Friendly/Pets/ZanukaPets/ZanukaPetCPowerSuit"
+ }
+};
diff --git a/src/controllers/custom/completeAllMissionsController.ts b/src/controllers/custom/completeAllMissionsController.ts
new file mode 100644
index 00000000..e56a6780
--- /dev/null
+++ b/src/controllers/custom/completeAllMissionsController.ts
@@ -0,0 +1,41 @@
+import { addString } from "../../helpers/stringHelpers.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { addFixedLevelRewards } from "../../services/missionInventoryUpdateService.ts";
+import { handleStoreItemAcquisition } from "../../services/purchaseService.ts";
+import type { IMissionReward } from "../../types/missionTypes.ts";
+import type { RequestHandler } from "express";
+import { ExportRegions } from "warframe-public-export-plus";
+
+export const completeAllMissionsController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId);
+ const MissionRewards: IMissionReward[] = [];
+ for (const [tag, node] of Object.entries(ExportRegions)) {
+ let mission = inventory.Missions.find(x => x.Tag == tag);
+ if (!mission) {
+ mission =
+ inventory.Missions[
+ inventory.Missions.push({
+ Completes: 0,
+ Tier: 0,
+ Tag: tag
+ }) - 1
+ ];
+ }
+ if (mission.Completes == 0) {
+ mission.Completes++;
+ if (node.missionReward) {
+ addFixedLevelRewards(node.missionReward, MissionRewards);
+ }
+ }
+ mission.Tier = 1;
+ }
+ for (const reward of MissionRewards) {
+ await handleStoreItemAcquisition(reward.StoreItem, inventory, reward.ItemCount, undefined, true);
+ }
+ addString(inventory.NodeIntrosCompleted, "TeshinHardModeUnlocked");
+ addString(inventory.NodeIntrosCompleted, "CetusSyndicate_IntroJob");
+ await inventory.save();
+ res.end();
+};
diff --git a/src/controllers/custom/configController.ts b/src/controllers/custom/configController.ts
new file mode 100644
index 00000000..70b61174
--- /dev/null
+++ b/src/controllers/custom/configController.ts
@@ -0,0 +1,47 @@
+import type { RequestHandler } from "express";
+import { config, syncConfigWithDatabase } from "../../services/configService.ts";
+import { getAccountForRequest, isAdministrator } from "../../services/loginService.ts";
+import { saveConfig } from "../../services/configWriterService.ts";
+import { sendWsBroadcastEx } from "../../services/wsService.ts";
+
+export const getConfigController: RequestHandler = async (req, res) => {
+ const account = await getAccountForRequest(req);
+ if (isAdministrator(account)) {
+ const responseData: Record = {};
+ for (const id of req.body as string[]) {
+ const [obj, idx] = configIdToIndexable(id);
+ responseData[id] = obj[idx] ?? null;
+ }
+ res.json(responseData);
+ } else {
+ res.status(401).end();
+ }
+};
+
+export const setConfigController: RequestHandler = async (req, res) => {
+ const account = await getAccountForRequest(req);
+ if (isAdministrator(account)) {
+ for (const [id, value] of Object.entries(req.body as Record)) {
+ const [obj, idx] = configIdToIndexable(id);
+ obj[idx] = value;
+ }
+ sendWsBroadcastEx({ config_reloaded: true }, undefined, parseInt(String(req.query.wsid)));
+ syncConfigWithDatabase();
+ await saveConfig();
+ res.end();
+ } else {
+ res.status(401).end();
+ }
+};
+
+const configIdToIndexable = (id: string): [Record, string] => {
+ let obj = config as unknown as Record;
+ const arr = id.split(".");
+ while (arr.length > 1) {
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ obj[arr[0]] ??= {} as never;
+ obj = obj[arr[0]];
+ arr.splice(0, 1);
+ }
+ return [obj, arr[0]];
+};
diff --git a/src/controllers/custom/createAccountController.ts b/src/controllers/custom/createAccountController.ts
new file mode 100644
index 00000000..3cae26d6
--- /dev/null
+++ b/src/controllers/custom/createAccountController.ts
@@ -0,0 +1,16 @@
+import { toCreateAccount, toDatabaseAccount } from "../../helpers/customHelpers/customHelpers.ts";
+import { createAccount, isNameTaken } from "../../services/loginService.ts";
+import type { RequestHandler } from "express";
+
+const createAccountController: RequestHandler = async (req, res) => {
+ const createAccountData = toCreateAccount(req.body);
+ if (await isNameTaken(createAccountData.DisplayName)) {
+ res.status(409).json("Name already in use");
+ } else {
+ const databaseAccount = toDatabaseAccount(createAccountData);
+ const account = await createAccount(databaseAccount);
+ res.json(account);
+ }
+};
+
+export { createAccountController };
diff --git a/src/controllers/custom/createMessageController.ts b/src/controllers/custom/createMessageController.ts
new file mode 100644
index 00000000..b8d744a9
--- /dev/null
+++ b/src/controllers/custom/createMessageController.ts
@@ -0,0 +1,15 @@
+import type { IMessageCreationTemplate } from "../../services/inboxService.ts";
+import { createMessage } from "../../services/inboxService.ts";
+import type { RequestHandler } from "express";
+
+export const createMessageController: RequestHandler = async (req, res) => {
+ const message = req.body as (IMessageCreationTemplate & { ownerId: string })[] | undefined;
+
+ if (!message) {
+ res.status(400).send("No message provided");
+ return;
+ }
+ const savedMessages = await createMessage(message[0].ownerId, message);
+
+ res.json(savedMessages);
+};
diff --git a/src/controllers/custom/deleteAccountController.ts b/src/controllers/custom/deleteAccountController.ts
new file mode 100644
index 00000000..5b52f46f
--- /dev/null
+++ b/src/controllers/custom/deleteAccountController.ts
@@ -0,0 +1,44 @@
+import type { RequestHandler } from "express";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { Account, Ignore } from "../../models/loginModel.ts";
+import { Inbox } from "../../models/inboxModel.ts";
+import { Inventory } from "../../models/inventoryModels/inventoryModel.ts";
+import { Loadout } from "../../models/inventoryModels/loadoutModel.ts";
+import { PersonalRooms } from "../../models/personalRoomsModel.ts";
+import { Ship } from "../../models/shipModel.ts";
+import { Stats } from "../../models/statsModel.ts";
+import { GuildMember } from "../../models/guildModel.ts";
+import { Leaderboard } from "../../models/leaderboardModel.ts";
+import { deleteGuild } from "../../services/guildService.ts";
+import { Friendship } from "../../models/friendModel.ts";
+import { sendWsBroadcastTo } from "../../services/wsService.ts";
+
+export const deleteAccountController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+
+ // If account is the founding warlord of a guild, delete that guild as well.
+ const guildMember = await GuildMember.findOne({ accountId, rank: 0, status: 0 });
+ if (guildMember) {
+ await deleteGuild(guildMember.guildId);
+ }
+
+ await Promise.all([
+ Account.deleteOne({ _id: accountId }),
+ Friendship.deleteMany({ owner: accountId }),
+ Friendship.deleteMany({ friend: accountId }),
+ GuildMember.deleteMany({ accountId: accountId }),
+ Ignore.deleteMany({ ignorer: accountId }),
+ Ignore.deleteMany({ ignoree: accountId }),
+ Inbox.deleteMany({ ownerId: accountId }),
+ Inventory.deleteOne({ accountOwnerId: accountId }),
+ Leaderboard.deleteMany({ ownerId: accountId }),
+ Loadout.deleteOne({ loadoutOwnerId: accountId }),
+ PersonalRooms.deleteOne({ personalRoomsOwnerId: accountId }),
+ Ship.deleteMany({ ShipOwnerId: accountId }),
+ Stats.deleteOne({ accountOwnerId: accountId })
+ ]);
+
+ sendWsBroadcastTo(accountId, { logged_out: true });
+
+ res.end();
+};
diff --git a/src/controllers/custom/editSuitInvigorationUpgradeController.ts b/src/controllers/custom/editSuitInvigorationUpgradeController.ts
new file mode 100644
index 00000000..200b403a
--- /dev/null
+++ b/src/controllers/custom/editSuitInvigorationUpgradeController.ts
@@ -0,0 +1,34 @@
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import type { RequestHandler } from "express";
+
+const DEFAULT_UPGRADE_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
+
+export const editSuitInvigorationUpgradeController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const { oid, data } = req.body as {
+ oid: string;
+ data?: {
+ DefensiveUpgrade: string;
+ OffensiveUpgrade: string;
+ UpgradesExpiry?: number;
+ };
+ };
+ const inventory = await getInventory(accountId);
+ const suit = inventory.Suits.id(oid)!;
+ if (data) {
+ suit.DefensiveUpgrade = data.DefensiveUpgrade;
+ suit.OffensiveUpgrade = data.OffensiveUpgrade;
+ if (data.UpgradesExpiry) {
+ suit.UpgradesExpiry = new Date(data.UpgradesExpiry);
+ } else {
+ suit.UpgradesExpiry = new Date(Date.now() + DEFAULT_UPGRADE_EXPIRY_MS);
+ }
+ } else {
+ suit.DefensiveUpgrade = undefined;
+ suit.OffensiveUpgrade = undefined;
+ suit.UpgradesExpiry = undefined;
+ }
+ await inventory.save();
+ res.end();
+};
diff --git a/src/controllers/custom/getAccountInfoController.ts b/src/controllers/custom/getAccountInfoController.ts
new file mode 100644
index 00000000..12848317
--- /dev/null
+++ b/src/controllers/custom/getAccountInfoController.ts
@@ -0,0 +1,44 @@
+import { AllianceMember, Guild, GuildMember } from "../../models/guildModel.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountForRequest, isAdministrator } from "../../services/loginService.ts";
+import type { RequestHandler } from "express";
+
+export const getAccountInfoController: RequestHandler = async (req, res) => {
+ const account = await getAccountForRequest(req);
+ const inventory = await getInventory(account._id.toString(), "QuestKeys");
+ const info: IAccountInfo = {
+ DisplayName: account.DisplayName,
+ IsAdministrator: isAdministrator(account),
+ CompletedVorsPrize: !!inventory.QuestKeys.find(
+ x => x.ItemType == "/Lotus/Types/Keys/VorsPrize/VorsPrizeQuestKeyChain"
+ )?.Completed
+ };
+ const guildMember = await GuildMember.findOne({ accountId: account._id, status: 0 }, "guildId rank");
+ if (guildMember) {
+ const guild = (await Guild.findById(guildMember.guildId, "Ranks AllianceId"))!;
+ info.GuildId = guildMember.guildId.toString();
+ info.GuildPermissions = guild.Ranks[guildMember.rank].Permissions;
+ info.GuildRank = guildMember.rank;
+ if (guild.AllianceId) {
+ //const alliance = (await Alliance.findById(guild.AllianceId))!;
+ const allianceMember = (await AllianceMember.findOne({
+ allianceId: guild.AllianceId,
+ guildId: guild._id
+ }))!;
+ info.AllianceId = guild.AllianceId.toString();
+ info.AlliancePermissions = allianceMember.Permissions;
+ }
+ }
+ res.json(info);
+};
+
+interface IAccountInfo {
+ DisplayName: string;
+ IsAdministrator: boolean;
+ CompletedVorsPrize: boolean;
+ GuildId?: string;
+ GuildPermissions?: number;
+ GuildRank?: number;
+ AllianceId?: string;
+ AlliancePermissions?: number;
+}
diff --git a/src/controllers/custom/getItemListsController.ts b/src/controllers/custom/getItemListsController.ts
new file mode 100644
index 00000000..dc2d6c4f
--- /dev/null
+++ b/src/controllers/custom/getItemListsController.ts
@@ -0,0 +1,373 @@
+import type { RequestHandler } from "express";
+import { getDict, getItemName, getString } from "../../services/itemDataService.ts";
+import type { TRelicQuality } from "warframe-public-export-plus";
+import {
+ ExportAbilities,
+ ExportArcanes,
+ ExportAvionics,
+ ExportBoosters,
+ ExportCustoms,
+ ExportDrones,
+ ExportGear,
+ ExportKeys,
+ ExportMisc,
+ ExportRailjackWeapons,
+ ExportRecipes,
+ ExportRelics,
+ ExportResources,
+ ExportSentinels,
+ ExportSyndicates,
+ ExportUpgrades,
+ ExportWarframes,
+ ExportWeapons
+} from "warframe-public-export-plus";
+import allIncarnons from "../../../static/fixed_responses/allIncarnonList.json" with { type: "json" };
+import varzia from "../../../static/fixed_responses/worldState/varzia.json" with { type: "json" };
+
+interface ListedItem {
+ uniqueName: string;
+ name: string;
+ subtype?: string;
+ fusionLimit?: number;
+ exalted?: string[];
+ badReason?: "starter" | "frivolous" | "notraw";
+ partType?: string;
+ chainLength?: number;
+ parazon?: boolean;
+}
+
+interface ItemLists {
+ uniqueLevelCaps: Record;
+ Suits: ListedItem[];
+ LongGuns: ListedItem[];
+ Melee: ListedItem[];
+ ModularParts: ListedItem[];
+ Pistols: ListedItem[];
+ Sentinels: ListedItem[];
+ SentinelWeapons: ListedItem[];
+ SpaceGuns: ListedItem[];
+ SpaceMelee: ListedItem[];
+ SpaceSuits: ListedItem[];
+ MechSuits: ListedItem[];
+ miscitems: ListedItem[];
+ Syndicates: ListedItem[];
+ OperatorAmps: ListedItem[];
+ QuestKeys: ListedItem[];
+ KubrowPets: ListedItem[];
+ EvolutionProgress: ListedItem[];
+ mods: ListedItem[];
+ Boosters: ListedItem[];
+ VarziaOffers: ListedItem[];
+ Abilities: ListedItem[];
+ //circuitGameModes: ListedItem[];
+}
+
+const relicQualitySuffixes: Record = {
+ VPQ_BRONZE: "",
+ VPQ_SILVER: " [Exceptional]",
+ VPQ_GOLD: " [Flawless]",
+ VPQ_PLATINUM: " [Radiant]"
+};
+
+/*const toTitleCase = (str: string): string => {
+ return str.replace(/[^\s-]+/g, word => word.charAt(0).toUpperCase() + word.substr(1).toLowerCase());
+};*/
+
+const getItemListsController: RequestHandler = (req, response) => {
+ const lang = getDict(typeof req.query.lang == "string" ? req.query.lang : "en");
+ const res: ItemLists = {
+ uniqueLevelCaps: ExportMisc.uniqueLevelCaps,
+ Suits: [],
+ LongGuns: [],
+ Melee: [],
+ ModularParts: [],
+ Pistols: [],
+ Sentinels: [],
+ SentinelWeapons: [],
+ SpaceGuns: [],
+ SpaceMelee: [],
+ SpaceSuits: [],
+ MechSuits: [],
+ miscitems: [],
+ Syndicates: [],
+ OperatorAmps: [],
+ QuestKeys: [],
+ KubrowPets: [],
+ EvolutionProgress: [],
+ mods: [],
+ Boosters: [],
+ VarziaOffers: [],
+ Abilities: []
+ /*circuitGameModes: [
+ {
+ uniqueName: "Survival",
+ name: toTitleCase(getString("/Lotus/Language/Missions/MissionName_Survival", lang))
+ },
+ {
+ uniqueName: "VoidFlood",
+ name: toTitleCase(getString("/Lotus/Language/Missions/MissionName_Corruption", lang))
+ },
+ {
+ uniqueName: "Excavation",
+ name: toTitleCase(getString("/Lotus/Language/Missions/MissionName_Excavation", lang))
+ },
+ {
+ uniqueName: "Defense",
+ name: toTitleCase(getString("/Lotus/Language/Missions/MissionName_Defense", lang))
+ },
+ {
+ uniqueName: "Exterminate",
+ name: toTitleCase(getString("/Lotus/Language/Missions/MissionName_Exterminate", lang))
+ },
+ {
+ uniqueName: "Assassination",
+ name: toTitleCase(getString("/Lotus/Language/Missions/MissionName_Assassination", lang))
+ },
+ {
+ uniqueName: "Alchemy",
+ name: toTitleCase(getString("/Lotus/Language/Missions/MissionName_Alchemy", lang))
+ }
+ ]*/
+ };
+ for (const [uniqueName, item] of Object.entries(ExportWarframes)) {
+ res[item.productCategory].push({
+ uniqueName,
+ name: getString(item.name, lang),
+ exalted: item.exalted
+ });
+ item.abilities.forEach(ability => {
+ res.Abilities.push({
+ uniqueName: ability.uniqueName,
+ name: getString(ability.name || uniqueName, lang)
+ });
+ });
+ }
+ for (const [uniqueName, item] of Object.entries(ExportSentinels)) {
+ if (item.productCategory == "Sentinels" || item.productCategory == "KubrowPets") {
+ res[item.productCategory].push({
+ uniqueName,
+ name: getString(item.name, lang),
+ exalted: item.exalted
+ });
+ }
+ }
+ for (const [uniqueName, item] of Object.entries(ExportWeapons)) {
+ if (item.partType) {
+ if (!uniqueName.split("/")[7]?.startsWith("PvPVariant")) {
+ // not a pvp variant
+ if (!uniqueName.startsWith("/Lotus/Types/Items/Deimos/")) {
+ res.ModularParts.push({
+ uniqueName,
+ name: getString(item.name, lang),
+ partType: item.partType
+ });
+ }
+ if (uniqueName.split("/")[5] != "SentTrainingAmplifier") {
+ res.miscitems.push({
+ uniqueName: uniqueName,
+ name: getString(item.name, lang)
+ });
+ }
+ }
+ } else if (item.totalDamage !== 0) {
+ if (
+ item.productCategory == "LongGuns" ||
+ item.productCategory == "Pistols" ||
+ item.productCategory == "Melee" ||
+ item.productCategory == "SpaceGuns" ||
+ item.productCategory == "SpaceMelee" ||
+ item.productCategory == "SentinelWeapons" ||
+ item.productCategory == "OperatorAmps"
+ ) {
+ res[item.productCategory].push({
+ uniqueName,
+ name: getString(item.name, lang)
+ });
+ }
+ } else if (!item.excludeFromCodex) {
+ res.miscitems.push({
+ uniqueName: uniqueName,
+ name: getString(item.name, lang)
+ });
+ }
+ }
+ for (const [uniqueName, item] of Object.entries(ExportResources)) {
+ let name = getString(item.name, lang);
+ if ("dissectionParts" in item) {
+ name = getString("/Lotus/Language/Fish/FishDisplayName", lang).split("|FISH_NAME|").join(name);
+ if (item.syndicateTag == "CetusSyndicate") {
+ if (uniqueName.indexOf("Large") != -1) {
+ name = name.split("|FISH_SIZE|").join(getString("/Lotus/Language/Fish/FishSizeLargeAbbrev", lang));
+ } else if (uniqueName.indexOf("Medium") != -1) {
+ name = name.split("|FISH_SIZE|").join(getString("/Lotus/Language/Fish/FishSizeMediumAbbrev", lang));
+ } else {
+ name = name.split("|FISH_SIZE|").join(getString("/Lotus/Language/Fish/FishSizeSmallAbbrev", lang));
+ }
+ } else {
+ if (uniqueName.indexOf("Large") != -1) {
+ name = name
+ .split("|FISH_SIZE|")
+ .join(getString("/Lotus/Language/SolarisVenus/RobofishAgeCategoryElderAbbrev", lang));
+ } else if (uniqueName.indexOf("Medium") != -1) {
+ name = name
+ .split("|FISH_SIZE|")
+ .join(getString("/Lotus/Language/SolarisVenus/RobofishAgeCategoryMatureAbbrev", lang));
+ } else {
+ name = name
+ .split("|FISH_SIZE|")
+ .join(getString("/Lotus/Language/SolarisVenus/RobofishAgeCategoryYoungAbbrev", lang));
+ }
+ }
+ }
+ if (
+ name &&
+ uniqueName.substr(0, 30) != "/Lotus/Types/Game/Projections/" &&
+ uniqueName != "/Lotus/Types/Gameplay/EntratiLab/Resources/EntratiLanthornBundle"
+ ) {
+ res.miscitems.push({
+ uniqueName: uniqueName,
+ name: name,
+ subtype: "Resource"
+ });
+ }
+ }
+ for (const [uniqueName, item] of Object.entries(ExportRelics)) {
+ res.miscitems.push({
+ uniqueName: uniqueName,
+ name:
+ getString("/Lotus/Language/Relics/VoidProjectionName", lang)
+ .split("|ERA|")
+ .join(item.era)
+ .split("|CATEGORY|")
+ .join(item.category) + relicQualitySuffixes[item.quality]
+ });
+ }
+ for (const [uniqueName, item] of Object.entries(ExportGear)) {
+ res.miscitems.push({
+ uniqueName: uniqueName,
+ name: getString(item.name, lang),
+ subtype: "Gear"
+ });
+ }
+ const recipeNameTemplate = getString("/Lotus/Language/Items/BlueprintAndItem", lang);
+ for (const [uniqueName, item] of Object.entries(ExportRecipes)) {
+ if (!item.hidden) {
+ const resultName = getItemName(item.resultType);
+ if (resultName) {
+ let itemName = getString(resultName, lang);
+ if (item.num > 1) itemName = `${itemName} X ${item.num}`;
+ res.miscitems.push({
+ uniqueName: uniqueName,
+ name: recipeNameTemplate.replace("|ITEM|", itemName)
+ });
+ }
+ }
+ }
+ for (const [uniqueName, item] of Object.entries(ExportDrones)) {
+ res.miscitems.push({
+ uniqueName: uniqueName,
+ name: getString(item.name, lang)
+ });
+ }
+ for (const [uniqueName, item] of Object.entries(ExportRailjackWeapons)) {
+ res.miscitems.push({
+ uniqueName: uniqueName,
+ name: getString(item.name, lang)
+ });
+ }
+ for (const [uniqueName, item] of Object.entries(ExportCustoms)) {
+ res.miscitems.push({
+ uniqueName: uniqueName,
+ name: getString(item.name, lang)
+ });
+ }
+
+ for (const [uniqueName, upgrade] of Object.entries(ExportUpgrades)) {
+ const mod: ListedItem = {
+ uniqueName,
+ name: getString(upgrade.name, lang),
+ fusionLimit: upgrade.fusionLimit
+ };
+ if (upgrade.isStarter) {
+ mod.badReason = "starter";
+ } else if (upgrade.isFrivolous) {
+ mod.badReason = "frivolous";
+ } else if (upgrade.upgradeEntries) {
+ mod.badReason = "notraw";
+ }
+ if (upgrade.type == "PARAZON") {
+ mod.parazon = true;
+ }
+ res.mods.push(mod);
+ }
+ for (const [uniqueName, upgrade] of Object.entries(ExportAvionics)) {
+ res.mods.push({
+ uniqueName,
+ name: getString(upgrade.name, lang),
+ fusionLimit: upgrade.fusionLimit
+ });
+ }
+ for (const [uniqueName, arcane] of Object.entries(ExportArcanes)) {
+ if (uniqueName.substring(0, 18) != "/Lotus/Types/Game/") {
+ const mod: ListedItem = {
+ uniqueName,
+ name: getString(arcane.name, lang)
+ };
+ if (arcane.isFrivolous) {
+ mod.badReason = "frivolous";
+ }
+ res.mods.push(mod);
+ }
+ }
+ for (const [uniqueName, syndicate] of Object.entries(ExportSyndicates)) {
+ res.Syndicates.push({
+ uniqueName,
+ name: getString(syndicate.name, lang)
+ });
+ }
+ for (const [uniqueName, key] of Object.entries(ExportKeys)) {
+ if (key.chainStages) {
+ res.QuestKeys.push({
+ uniqueName,
+ name: getString(key.name || "", lang),
+ chainLength: key.chainStages.length
+ });
+ } else if (key.name) {
+ res.miscitems.push({
+ uniqueName,
+ name: getString(key.name, lang)
+ });
+ }
+ }
+ for (const uniqueName of allIncarnons) {
+ res.EvolutionProgress.push({
+ uniqueName,
+ name: getString(getItemName(uniqueName) || "", lang)
+ });
+ }
+
+ for (const item of Object.values(ExportBoosters)) {
+ res.Boosters.push({
+ uniqueName: item.typeName,
+ name: getString(item.name, lang)
+ });
+ }
+
+ for (const item of Object.values(varzia.primeDualPacks)) {
+ res.VarziaOffers.push({
+ uniqueName: item.ItemType,
+ name: getString(getItemName(item.ItemType) || "", lang)
+ });
+ }
+
+ for (const [uniqueName, ability] of Object.entries(ExportAbilities)) {
+ res.Abilities.push({
+ uniqueName,
+ name: getString(ability.name || uniqueName, lang)
+ });
+ }
+
+ response.json(res);
+};
+
+export { getItemListsController };
diff --git a/src/controllers/custom/getNameController.ts b/src/controllers/custom/getNameController.ts
new file mode 100644
index 00000000..2480e637
--- /dev/null
+++ b/src/controllers/custom/getNameController.ts
@@ -0,0 +1,7 @@
+import type { RequestHandler } from "express";
+import { getAccountForRequest } from "../../services/loginService.ts";
+
+export const getNameController: RequestHandler = async (req, res) => {
+ const account = await getAccountForRequest(req);
+ res.json(account.DisplayName);
+};
diff --git a/src/controllers/custom/importController.ts b/src/controllers/custom/importController.ts
new file mode 100644
index 00000000..02528e64
--- /dev/null
+++ b/src/controllers/custom/importController.ts
@@ -0,0 +1,39 @@
+import { importInventory, importLoadOutPresets, importPersonalRooms } from "../../services/importService.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import { getLoadout } from "../../services/loadoutService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { getPersonalRooms } from "../../services/personalRoomsService.ts";
+import type { IInventoryClient } from "../../types/inventoryTypes/inventoryTypes.ts";
+import type { IGetShipResponse } from "../../types/personalRoomsTypes.ts";
+import type { RequestHandler } from "express";
+
+export const importController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const request = req.body as IImportRequest;
+
+ const inventory = await getInventory(accountId);
+ importInventory(inventory, request.inventory);
+ await inventory.save();
+
+ if ("LoadOutPresets" in request.inventory && request.inventory.LoadOutPresets) {
+ const loadout = await getLoadout(accountId);
+ importLoadOutPresets(loadout, request.inventory.LoadOutPresets);
+ await loadout.save();
+ }
+
+ if (
+ request.inventory.Ship?.Rooms || // very old accounts may have Ship with { Features: [ ... ] }
+ "Apartment" in request.inventory ||
+ "TailorShop" in request.inventory
+ ) {
+ const personalRooms = await getPersonalRooms(accountId);
+ importPersonalRooms(personalRooms, request.inventory);
+ await personalRooms.save();
+ }
+
+ res.end();
+};
+
+interface IImportRequest {
+ inventory: Partial | Partial;
+}
diff --git a/src/controllers/custom/ircDroppedController.ts b/src/controllers/custom/ircDroppedController.ts
new file mode 100644
index 00000000..86be32d3
--- /dev/null
+++ b/src/controllers/custom/ircDroppedController.ts
@@ -0,0 +1,24 @@
+import { Account } from "../../models/loginModel.ts";
+import type { RequestHandler } from "express";
+
+export const ircDroppedController: RequestHandler = async (req, res) => {
+ if (!req.query.accountId) {
+ throw new Error("Request is missing accountId parameter");
+ }
+ const nonce: number = parseInt(req.query.nonce as string);
+ if (!nonce) {
+ throw new Error("Request is missing nonce parameter");
+ }
+
+ await Account.updateOne(
+ {
+ _id: req.query.accountId,
+ Nonce: nonce
+ },
+ {
+ Dropped: true
+ }
+ );
+
+ res.end();
+};
diff --git a/src/controllers/custom/manageQuestsController.ts b/src/controllers/custom/manageQuestsController.ts
new file mode 100644
index 00000000..4c7d614d
--- /dev/null
+++ b/src/controllers/custom/manageQuestsController.ts
@@ -0,0 +1,160 @@
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import {
+ addQuestKey,
+ completeQuest,
+ giveKeyChainMissionReward,
+ giveKeyChainStageTriggered
+} from "../../services/questService.ts";
+import { logger } from "../../utils/logger.ts";
+import type { RequestHandler } from "express";
+import { ExportKeys } from "warframe-public-export-plus";
+
+export const manageQuestsController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const operation = req.query.operation as
+ | "completeAll"
+ | "resetAll"
+ | "giveAll"
+ | "completeKey"
+ | "deleteKey"
+ | "resetKey"
+ | "prevStage"
+ | "nextStage"
+ | "setInactive";
+
+ const questItemType = req.query.itemType as string;
+
+ const allQuestKeys: string[] = [];
+ for (const [k, v] of Object.entries(ExportKeys)) {
+ if ("chainStages" in v) {
+ allQuestKeys.push(k);
+ }
+ }
+ const inventory = await getInventory(accountId);
+
+ switch (operation) {
+ case "completeAll": {
+ for (const questKey of inventory.QuestKeys) {
+ await completeQuest(inventory, questKey.ItemType);
+ }
+ break;
+ }
+ case "resetAll": {
+ for (const questKey of inventory.QuestKeys) {
+ questKey.Completed = false;
+ questKey.Progress = [];
+ questKey.CompletionDate = undefined;
+ }
+ inventory.ActiveQuest = "";
+ break;
+ }
+ case "giveAll": {
+ allQuestKeys.forEach(questKey => addQuestKey(inventory, { ItemType: questKey }));
+ break;
+ }
+ case "deleteKey": {
+ const questKey = inventory.QuestKeys.find(key => key.ItemType === questItemType);
+ if (!questKey) {
+ logger.error(`Quest key not found in inventory: ${questItemType}`);
+ break;
+ }
+ inventory.QuestKeys.pull({ ItemType: questItemType });
+ break;
+ }
+ case "completeKey": {
+ if (allQuestKeys.includes(questItemType)) {
+ const questKey = inventory.QuestKeys.find(key => key.ItemType === questItemType);
+ if (!questKey) {
+ logger.error(`Quest key not found in inventory: ${questItemType}`);
+ break;
+ }
+
+ await completeQuest(inventory, questItemType);
+ }
+ break;
+ }
+ case "resetKey": {
+ if (allQuestKeys.includes(questItemType)) {
+ const questKey = inventory.QuestKeys.find(key => key.ItemType === questItemType);
+ if (!questKey) {
+ logger.error(`Quest key not found in inventory: ${questItemType}`);
+ break;
+ }
+
+ questKey.Completed = false;
+ questKey.Progress = [];
+ questKey.CompletionDate = undefined;
+ }
+ break;
+ }
+ case "prevStage": {
+ if (allQuestKeys.includes(questItemType)) {
+ const questKey = inventory.QuestKeys.find(key => key.ItemType === questItemType);
+ if (!questKey) {
+ logger.error(`Quest key not found in inventory: ${questItemType}`);
+ break;
+ }
+ if (!questKey.Progress) break;
+
+ if (questKey.Completed) {
+ questKey.Completed = false;
+ questKey.CompletionDate = undefined;
+ }
+ questKey.Progress.pop();
+ const stage = questKey.Progress.length - 1;
+ if (stage > 0) {
+ await giveKeyChainStageTriggered(inventory, {
+ KeyChain: questKey.ItemType,
+ ChainStage: stage
+ });
+ }
+ }
+ break;
+ }
+ case "nextStage": {
+ if (allQuestKeys.includes(questItemType)) {
+ const questKey = inventory.QuestKeys.find(key => key.ItemType === questItemType);
+ const questManifest = ExportKeys[questItemType];
+ if (!questKey) {
+ logger.error(`Quest key not found in inventory: ${questItemType}`);
+ break;
+ }
+ if (!questKey.Progress) break;
+
+ const currentStage = questKey.Progress.length;
+ if (currentStage + 1 == questManifest.chainStages?.length) {
+ logger.debug(`Trying to complete last stage with nextStage, calling completeQuest instead`);
+ await completeQuest(inventory, questKey.ItemType);
+ } else {
+ const progress = {
+ c: 0,
+ i: false,
+ m: false,
+ b: []
+ };
+ questKey.Progress.push(progress);
+
+ await giveKeyChainStageTriggered(inventory, {
+ KeyChain: questKey.ItemType,
+ ChainStage: currentStage
+ });
+
+ if (currentStage > 0) {
+ await giveKeyChainMissionReward(inventory, {
+ KeyChain: questKey.ItemType,
+ ChainStage: currentStage - 1
+ });
+ }
+ }
+ }
+ break;
+ }
+ case "setInactive":
+ inventory.ActiveQuest = "";
+ break;
+ }
+
+ await inventory.save();
+ res.status(200).end();
+};
diff --git a/src/controllers/custom/popArchonCrystalUpgradeController.ts b/src/controllers/custom/popArchonCrystalUpgradeController.ts
new file mode 100644
index 00000000..2d5e40b9
--- /dev/null
+++ b/src/controllers/custom/popArchonCrystalUpgradeController.ts
@@ -0,0 +1,18 @@
+import type { RequestHandler } from "express";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+
+export const popArchonCrystalUpgradeController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId);
+ const suit = inventory.Suits.id(req.query.oid as string);
+ if (suit && suit.ArchonCrystalUpgrades) {
+ suit.ArchonCrystalUpgrades = suit.ArchonCrystalUpgrades.filter(
+ x => x.UpgradeType != (req.query.type as string)
+ );
+ await inventory.save();
+ res.end();
+ return;
+ }
+ res.status(400).end();
+};
diff --git a/src/controllers/custom/pushArchonCrystalUpgradeController.ts b/src/controllers/custom/pushArchonCrystalUpgradeController.ts
new file mode 100644
index 00000000..6eb7276e
--- /dev/null
+++ b/src/controllers/custom/pushArchonCrystalUpgradeController.ts
@@ -0,0 +1,22 @@
+import type { RequestHandler } from "express";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+
+export const pushArchonCrystalUpgradeController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId);
+ const suit = inventory.Suits.id(req.query.oid as string);
+ if (suit) {
+ suit.ArchonCrystalUpgrades ??= [];
+ const count = (req.query.count as number | undefined) ?? 1;
+ if (count >= 1 && count <= 10000) {
+ for (let i = 0; i != count; ++i) {
+ suit.ArchonCrystalUpgrades.push({ UpgradeType: req.query.type as string });
+ }
+ await inventory.save();
+ res.end();
+ return;
+ }
+ }
+ res.status(400).end();
+};
diff --git a/src/controllers/custom/renameAccountController.ts b/src/controllers/custom/renameAccountController.ts
new file mode 100644
index 00000000..a0665fd1
--- /dev/null
+++ b/src/controllers/custom/renameAccountController.ts
@@ -0,0 +1,29 @@
+import type { RequestHandler } from "express";
+import { getAccountForRequest, isAdministrator, isNameTaken } from "../../services/loginService.ts";
+import { config } from "../../services/configService.ts";
+import { saveConfig } from "../../services/configWriterService.ts";
+
+export const renameAccountController: RequestHandler = async (req, res) => {
+ const account = await getAccountForRequest(req);
+ if (typeof req.query.newname == "string") {
+ if (await isNameTaken(req.query.newname)) {
+ res.status(409).json("Name already in use");
+ } else {
+ if (isAdministrator(account)) {
+ for (let i = 0; i != config.administratorNames!.length; ++i) {
+ if (config.administratorNames![i] == account.DisplayName) {
+ config.administratorNames![i] = req.query.newname;
+ }
+ }
+ await saveConfig();
+ }
+
+ account.DisplayName = req.query.newname;
+ await account.save();
+
+ res.end();
+ }
+ } else {
+ res.status(400).end();
+ }
+};
diff --git a/src/controllers/custom/setAccountCheatController.ts b/src/controllers/custom/setAccountCheatController.ts
new file mode 100644
index 00000000..5a188777
--- /dev/null
+++ b/src/controllers/custom/setAccountCheatController.ts
@@ -0,0 +1,18 @@
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { IAccountCheats } from "../../types/inventoryTypes/inventoryTypes.ts";
+import type { RequestHandler } from "express";
+
+export const setAccountCheatController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const payload = req.body as ISetAccountCheatRequest;
+ const inventory = await getInventory(accountId, payload.key);
+ inventory[payload.key] = payload.value;
+ await inventory.save();
+ res.end();
+};
+
+interface ISetAccountCheatRequest {
+ key: keyof IAccountCheats;
+ value: boolean;
+}
diff --git a/src/controllers/custom/setBoosterController.ts b/src/controllers/custom/setBoosterController.ts
new file mode 100644
index 00000000..756aad98
--- /dev/null
+++ b/src/controllers/custom/setBoosterController.ts
@@ -0,0 +1,45 @@
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+import type { RequestHandler } from "express";
+import { ExportBoosters } from "warframe-public-export-plus";
+
+const I32_MAX = 0x7fffffff;
+
+export const setBoosterController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const requests = req.body as { ItemType: string; ExpiryDate: number }[];
+ const inventory = await getInventory(accountId, "Boosters");
+ const boosters = inventory.Boosters;
+ if (
+ requests.some(request => {
+ if (typeof request.ItemType !== "string") return true;
+ if (Object.entries(ExportBoosters).find(([_, item]) => item.typeName === request.ItemType) === undefined)
+ return true;
+ if (typeof request.ExpiryDate !== "number") return true;
+ if (request.ExpiryDate < 0 || request.ExpiryDate > I32_MAX) return true;
+ return false;
+ })
+ ) {
+ res.status(400).send("Invalid ItemType provided.");
+ return;
+ }
+ const now = Math.trunc(Date.now() / 1000);
+ for (const { ItemType, ExpiryDate } of requests) {
+ if (ExpiryDate <= now) {
+ // remove expired boosters
+ const index = boosters.findIndex(item => item.ItemType === ItemType);
+ if (index !== -1) {
+ boosters.splice(index, 1);
+ }
+ } else {
+ const boosterItem = boosters.find(item => item.ItemType === ItemType);
+ if (boosterItem) {
+ boosterItem.ExpiryDate = ExpiryDate;
+ } else {
+ boosters.push({ ItemType, ExpiryDate });
+ }
+ }
+ }
+ await inventory.save();
+ res.end();
+};
diff --git a/src/controllers/custom/setEvolutionProgressController.ts b/src/controllers/custom/setEvolutionProgressController.ts
new file mode 100644
index 00000000..c68ccaff
--- /dev/null
+++ b/src/controllers/custom/setEvolutionProgressController.ts
@@ -0,0 +1,33 @@
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { RequestHandler } from "express";
+
+export const setEvolutionProgressController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId);
+ const payload = req.body as ISetEvolutionProgressRequest;
+
+ inventory.EvolutionProgress ??= [];
+ payload.forEach(element => {
+ const entry = inventory.EvolutionProgress!.find(entry => entry.ItemType === element.ItemType);
+
+ if (entry) {
+ entry.Progress = 0;
+ entry.Rank = element.Rank;
+ } else {
+ inventory.EvolutionProgress!.push({
+ Progress: 0,
+ Rank: element.Rank,
+ ItemType: element.ItemType
+ });
+ }
+ });
+
+ await inventory.save();
+ res.end();
+};
+
+type ISetEvolutionProgressRequest = {
+ ItemType: string;
+ Rank: number;
+}[];
diff --git a/src/controllers/custom/tunablesController.ts b/src/controllers/custom/tunablesController.ts
new file mode 100644
index 00000000..87419fcc
--- /dev/null
+++ b/src/controllers/custom/tunablesController.ts
@@ -0,0 +1,23 @@
+import type { RequestHandler } from "express";
+
+// This endpoint is specific to the OpenWF Bootstrapper: https://openwf.io/bootstrapper-manual
+
+interface ITunables {
+ prohibit_skip_mission_start_timer?: boolean;
+ prohibit_fov_override?: boolean;
+ prohibit_freecam?: boolean;
+ prohibit_teleport?: boolean;
+ prohibit_scripts?: boolean;
+}
+
+const tunablesController: RequestHandler = (_req, res) => {
+ const tunables: ITunables = {};
+ //tunables.prohibit_skip_mission_start_timer = true;
+ //tunables.prohibit_fov_override = true;
+ //tunables.prohibit_freecam = true;
+ //tunables.prohibit_teleport = true;
+ //tunables.prohibit_scripts = true;
+ res.json(tunables);
+};
+
+export { tunablesController };
diff --git a/src/controllers/custom/unlockAllIntrinsicsController.ts b/src/controllers/custom/unlockAllIntrinsicsController.ts
new file mode 100644
index 00000000..8dd484ce
--- /dev/null
+++ b/src/controllers/custom/unlockAllIntrinsicsController.ts
@@ -0,0 +1,19 @@
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { RequestHandler } from "express";
+
+export const unlockAllIntrinsicsController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId, "PlayerSkills");
+ inventory.PlayerSkills.LPS_PILOTING = 10;
+ inventory.PlayerSkills.LPS_GUNNERY = 10;
+ inventory.PlayerSkills.LPS_TACTICAL = 10;
+ inventory.PlayerSkills.LPS_ENGINEERING = 10;
+ inventory.PlayerSkills.LPS_COMMAND = 10;
+ inventory.PlayerSkills.LPS_DRIFT_COMBAT = 10;
+ inventory.PlayerSkills.LPS_DRIFT_RIDING = 10;
+ inventory.PlayerSkills.LPS_DRIFT_OPPORTUNITY = 10;
+ inventory.PlayerSkills.LPS_DRIFT_ENDURANCE = 10;
+ await inventory.save();
+ res.end();
+};
diff --git a/src/controllers/custom/unlockAllProfitTakerStagesController.ts b/src/controllers/custom/unlockAllProfitTakerStagesController.ts
new file mode 100644
index 00000000..79fe3b87
--- /dev/null
+++ b/src/controllers/custom/unlockAllProfitTakerStagesController.ts
@@ -0,0 +1,24 @@
+import { getInventory } from "../../services/inventoryService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { RequestHandler } from "express";
+
+const allEudicoHeistJobs = [
+ "/Lotus/Types/Gameplay/Venus/Jobs/Heists/HeistProfitTakerBountyOne",
+ "/Lotus/Types/Gameplay/Venus/Jobs/Heists/HeistProfitTakerBountyTwo",
+ "/Lotus/Types/Gameplay/Venus/Jobs/Heists/HeistProfitTakerBountyThree",
+ "/Lotus/Types/Gameplay/Venus/Jobs/Heists/HeistProfitTakerBountyFour"
+];
+
+export const unlockAllProfitTakerStagesController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId, "CompletedJobChains");
+ inventory.CompletedJobChains ??= [];
+ const chain = inventory.CompletedJobChains.find(x => x.LocationTag == "EudicoHeists");
+ if (chain) {
+ chain.Jobs = allEudicoHeistJobs;
+ } else {
+ inventory.CompletedJobChains.push({ LocationTag: "EudicoHeists", Jobs: allEudicoHeistJobs });
+ }
+ await inventory.save();
+ res.end();
+};
diff --git a/src/controllers/custom/unlockAllSimarisResearchEntriesController.ts b/src/controllers/custom/unlockAllSimarisResearchEntriesController.ts
new file mode 100644
index 00000000..fae3c55d
--- /dev/null
+++ b/src/controllers/custom/unlockAllSimarisResearchEntriesController.ts
@@ -0,0 +1,20 @@
+import type { RequestHandler } from "express";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { getInventory } from "../../services/inventoryService.ts";
+
+export const unlockAllSimarisResearchEntriesController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId, "LibraryPersonalTarget LibraryPersonalProgress");
+ inventory.LibraryPersonalTarget = undefined;
+ inventory.LibraryPersonalProgress = [
+ "/Lotus/Types/Game/Library/Targets/Research1Target",
+ "/Lotus/Types/Game/Library/Targets/Research2Target",
+ "/Lotus/Types/Game/Library/Targets/Research3Target",
+ "/Lotus/Types/Game/Library/Targets/Research4Target",
+ "/Lotus/Types/Game/Library/Targets/Research5Target",
+ "/Lotus/Types/Game/Library/Targets/Research6Target",
+ "/Lotus/Types/Game/Library/Targets/Research7Target"
+ ].map(type => ({ TargetType: type, Scans: 10, Completed: true }));
+ await inventory.save();
+ res.end();
+};
diff --git a/src/controllers/custom/updateFingerprintController.ts b/src/controllers/custom/updateFingerprintController.ts
new file mode 100644
index 00000000..f600b578
--- /dev/null
+++ b/src/controllers/custom/updateFingerprintController.ts
@@ -0,0 +1,39 @@
+import { getInventory } from "../../services/inventoryService.ts";
+import type { WeaponTypeInternal } from "../../services/itemDataService.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import type { RequestHandler } from "express";
+
+export const updateFingerprintController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const request = req.body as IUpdateFingerPrintRequest;
+ const inventory = await getInventory(accountId, request.category);
+ const item = inventory[request.category].id(request.oid);
+ if (item) {
+ if (request.action == "set" && request.upgradeFingerprint.buffs[0].Tag) {
+ const newUpgradeFingerprint = request.upgradeFingerprint;
+ if (!newUpgradeFingerprint.compact) newUpgradeFingerprint.compact = item.ItemType;
+
+ item.UpgradeType = request.upgradeType;
+ item.UpgradeFingerprint = JSON.stringify(newUpgradeFingerprint);
+ } else if (request.action == "remove") {
+ item.UpgradeFingerprint = undefined;
+ item.UpgradeType = undefined;
+ }
+ await inventory.save();
+ }
+ res.end();
+};
+
+interface IUpdateFingerPrintRequest {
+ category: WeaponTypeInternal;
+ oid: string;
+ action: "set" | "remove";
+ upgradeType: string;
+ upgradeFingerprint: {
+ compact?: string;
+ buffs: {
+ Tag: string;
+ Value: number;
+ }[];
+ };
+}
diff --git a/src/controllers/custom/webuiFileChangeDetectedController.ts b/src/controllers/custom/webuiFileChangeDetectedController.ts
new file mode 100644
index 00000000..d8a3e508
--- /dev/null
+++ b/src/controllers/custom/webuiFileChangeDetectedController.ts
@@ -0,0 +1,10 @@
+import { args } from "../../helpers/commandLineArguments.ts";
+import { sendWsBroadcast } from "../../services/wsService.ts";
+import type { RequestHandler } from "express";
+
+export const webuiFileChangeDetectedController: RequestHandler = (req, res) => {
+ if (args.dev && args.secret && req.query.secret == args.secret) {
+ sendWsBroadcast({ reload: true });
+ }
+ res.end();
+};
diff --git a/src/controllers/dynamic/aggregateSessionsController.ts b/src/controllers/dynamic/aggregateSessionsController.ts
new file mode 100644
index 00000000..2162c731
--- /dev/null
+++ b/src/controllers/dynamic/aggregateSessionsController.ts
@@ -0,0 +1,8 @@
+import type { RequestHandler } from "express";
+import aggregateSessions from "../../../static/fixed_responses/aggregateSessions.json" with { type: "json" };
+
+const aggregateSessionsController: RequestHandler = (_req, res) => {
+ res.json(aggregateSessions);
+};
+
+export { aggregateSessionsController };
diff --git a/src/controllers/dynamic/getGuildAdsController.ts b/src/controllers/dynamic/getGuildAdsController.ts
new file mode 100644
index 00000000..1f4cf6e2
--- /dev/null
+++ b/src/controllers/dynamic/getGuildAdsController.ts
@@ -0,0 +1,26 @@
+import { toMongoDate, toOid } from "../../helpers/inventoryHelpers.ts";
+import { GuildAd } from "../../models/guildModel.ts";
+import type { IGuildAdInfoClient } from "../../types/guildTypes.ts";
+import type { RequestHandler } from "express";
+
+export const getGuildAdsController: RequestHandler = async (req, res) => {
+ const ads = await GuildAd.find(req.query.tier ? { Tier: req.query.tier } : {});
+ const guildAdInfos: IGuildAdInfoClient[] = [];
+ for (const ad of ads) {
+ guildAdInfos.push({
+ _id: toOid(ad.GuildId),
+ CrossPlatformEnabled: true,
+ Emblem: ad.Emblem,
+ Expiry: toMongoDate(ad.Expiry),
+ Features: ad.Features,
+ GuildName: ad.GuildName,
+ MemberCount: ad.MemberCount,
+ OriginalPlatform: 0,
+ RecruitMsg: ad.RecruitMsg,
+ Tier: ad.Tier
+ });
+ }
+ res.json({
+ GuildAdInfos: guildAdInfos
+ });
+};
diff --git a/src/controllers/dynamic/getProfileViewingDataController.ts b/src/controllers/dynamic/getProfileViewingDataController.ts
new file mode 100644
index 00000000..808c3242
--- /dev/null
+++ b/src/controllers/dynamic/getProfileViewingDataController.ts
@@ -0,0 +1,347 @@
+import { fromDbOid, toMongoDate, toOid } from "../../helpers/inventoryHelpers.ts";
+import type { TGuildDatabaseDocument } from "../../models/guildModel.ts";
+import { Guild, GuildMember } from "../../models/guildModel.ts";
+import type { TInventoryDatabaseDocument } from "../../models/inventoryModels/inventoryModel.ts";
+import { Inventory } from "../../models/inventoryModels/inventoryModel.ts";
+import { Loadout } from "../../models/inventoryModels/loadoutModel.ts";
+import { Account } from "../../models/loginModel.ts";
+import type { TStatsDatabaseDocument } from "../../models/statsModel.ts";
+import { Stats } from "../../models/statsModel.ts";
+import { allDailyAffiliationKeys } from "../../services/inventoryService.ts";
+import type { IMongoDate, IOid } from "../../types/commonTypes.ts";
+import type {
+ IAffiliation,
+ IAlignment,
+ IChallengeProgress,
+ IDailyAffiliations,
+ IMission,
+ IPlayerSkills,
+ ITypeXPItem
+} from "../../types/inventoryTypes/inventoryTypes.ts";
+import { LoadoutIndex } from "../../types/inventoryTypes/inventoryTypes.ts";
+import type { RequestHandler } from "express";
+import { catBreadHash, getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { ExportCustoms, ExportDojoRecipes } from "warframe-public-export-plus";
+import type { IStatsClient } from "../../types/statTypes.ts";
+import { toStoreItem } from "../../services/itemDataService.ts";
+import type { FlattenMaps } from "mongoose";
+import type { IEquipmentClient } from "../../types/equipmentTypes.ts";
+import type { ILoadoutConfigClient } from "../../types/saveLoadoutTypes.ts";
+
+const getProfileViewingDataByPlayerIdImpl = async (playerId: string): Promise => {
+ const account = await Account.findById(playerId, "DisplayName");
+ if (!account) {
+ return;
+ }
+ const inventory = (await Inventory.findOne({ accountOwnerId: account._id }))!;
+
+ const result: IPlayerProfileViewingDataResult = {
+ AccountId: toOid(account._id),
+ DisplayName: account.DisplayName,
+ PlayerLevel: inventory.PlayerLevel,
+ LoadOutInventory: {
+ WeaponSkins: [],
+ XPInfo: inventory.XPInfo
+ },
+ PlayerSkills: inventory.PlayerSkills,
+ ChallengeProgress: inventory.ChallengeProgress,
+ DeathMarks: inventory.DeathMarks,
+ Harvestable: inventory.Harvestable,
+ DeathSquadable: inventory.DeathSquadable,
+ Created: toMongoDate(inventory.Created),
+ MigratedToConsole: false,
+ Missions: inventory.Missions,
+ Affiliations: inventory.Affiliations,
+ DailyFocus: inventory.DailyFocus,
+ Wishlist: inventory.Wishlist,
+ Alignment: inventory.Alignment
+ };
+ await populateLoadout(inventory, result);
+ if (inventory.GuildId) {
+ const guild = (await Guild.findById(inventory.GuildId, "Name Tier XP Class Emblem"))!;
+ populateGuild(guild, result);
+ }
+ for (const key of allDailyAffiliationKeys) {
+ result[key] = inventory[key];
+ }
+
+ const stats = (await Stats.findOne({ accountOwnerId: account._id }))!.toJSON>();
+ delete stats._id;
+ delete stats.__v;
+ delete stats.accountOwnerId;
+
+ return {
+ Results: [result],
+ TechProjects: [],
+ XpComponents: [],
+ //XpCacheExpiryDate, some IMongoDate in the future, no clue what it's for
+ Stats: stats
+ };
+};
+
+export const getProfileViewingDataGetController: RequestHandler = async (req, res) => {
+ if (req.query.playerId) {
+ const data = await getProfileViewingDataByPlayerIdImpl(req.query.playerId as string);
+ if (data) {
+ res.json(data);
+ } else {
+ res.status(409).send("Could not find requested account");
+ }
+ } else if (req.query.guildId) {
+ const guild = await Guild.findById(
+ req.query.guildId as string,
+ "Name Tier XP Class Emblem TechProjects ClaimedXP"
+ );
+ if (!guild) {
+ res.status(409).send("Could not find guild");
+ return;
+ }
+ const members = await GuildMember.find({ guildId: guild._id, status: 0 });
+ const results: IPlayerProfileViewingDataResult[] = [];
+ for (let i = 0; i != Math.min(4, members.length); ++i) {
+ const member = members[i];
+ const [account, inventory] = await Promise.all([
+ Account.findById(member.accountId, "DisplayName"),
+ Inventory.findOne(
+ { accountOwnerId: member.accountId },
+ "DisplayName PlayerLevel XPInfo LoadOutPresets CurrentLoadOutIds WeaponSkins Suits Pistols LongGuns Melee"
+ )
+ ]);
+ const result: IPlayerProfileViewingDataResult = {
+ AccountId: toOid(account!._id),
+ DisplayName: account!.DisplayName,
+ PlayerLevel: inventory!.PlayerLevel,
+ LoadOutInventory: {
+ WeaponSkins: [],
+ XPInfo: inventory!.XPInfo
+ }
+ };
+ await populateLoadout(inventory!, result);
+ results.push(result);
+ }
+ populateGuild(guild, results[0]);
+
+ const combinedStats: IStatsClient = {};
+ const statsArr = await Stats.find({ accountOwnerId: { $in: members.map(x => x.accountId) } }).lean(); // need this as POJO so Object.entries works as expected
+ for (const stats of statsArr) {
+ for (const [key, value] of Object.entries(stats)) {
+ if (typeof value == "number" && key != "__v") {
+ (combinedStats[key as keyof IStatsClient] as number | undefined) ??= 0;
+ (combinedStats[key as keyof IStatsClient] as number) += value;
+ }
+ }
+ for (const arrayName of ["Weapons", "Enemies", "Scans", "Missions", "PVP"] as const) {
+ if (stats[arrayName]) {
+ combinedStats[arrayName] ??= [];
+ for (const entry of stats[arrayName]) {
+ const combinedEntry = combinedStats[arrayName].find(x => x.type == entry.type);
+ if (combinedEntry) {
+ for (const [key, value] of Object.entries(entry)) {
+ if (typeof value == "number") {
+ (combinedEntry[key as keyof typeof combinedEntry] as unknown as
+ | number
+ | undefined) ??= 0;
+ (combinedEntry[key as keyof typeof combinedEntry] as unknown as number) += value;
+ }
+ }
+ } else {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
+ combinedStats[arrayName].push(entry as any);
+ }
+ }
+ }
+ }
+ }
+
+ const xpComponents: IXPComponentClient[] = [];
+ if (guild.ClaimedXP) {
+ for (const componentName of guild.ClaimedXP) {
+ if (componentName.endsWith(".level")) {
+ const [key] = Object.entries(ExportDojoRecipes.rooms).find(
+ ([_key, value]) => value.resultType == componentName
+ )!;
+ xpComponents.push({
+ StoreTypeName: toStoreItem(key)
+ });
+ } else {
+ const [key] = Object.entries(ExportDojoRecipes.decos).find(
+ ([_key, value]) => value.resultType == componentName
+ )!;
+ xpComponents.push({
+ StoreTypeName: toStoreItem(key)
+ });
+ }
+ }
+ }
+
+ res.json({
+ Results: results,
+ TechProjects: guild.TechProjects,
+ XpComponents: xpComponents,
+ //XpCacheExpiryDate, some IMongoDate in the future, no clue what it's for
+ Stats: combinedStats
+ });
+ } else {
+ res.sendStatus(400);
+ }
+};
+
+// For old versions, this was an authenticated POST request.
+interface IGetProfileViewingDataRequest {
+ AccountId: string;
+}
+export const getProfileViewingDataPostController: RequestHandler = async (req, res) => {
+ const payload = getJSONfromString(String(req.body));
+ const data = await getProfileViewingDataByPlayerIdImpl(payload.AccountId);
+ if (data) {
+ res.json(data);
+ } else {
+ res.status(409).send("Could not find requested account");
+ }
+};
+
+interface IProfileViewingData {
+ Results: IPlayerProfileViewingDataResult[];
+ TechProjects: [];
+ XpComponents: [];
+ //XpCacheExpiryDate, some IMongoDate in the future, no clue what it's for
+ Stats: FlattenMaps>;
+}
+
+interface IPlayerProfileViewingDataResult extends Partial {
+ AccountId: IOid;
+ DisplayName: string;
+ PlayerLevel: number;
+ LoadOutPreset?: Omit & { ItemId?: IOid };
+ LoadOutInventory: {
+ WeaponSkins: { ItemType: string }[];
+ Suits?: IEquipmentClient[];
+ Pistols?: IEquipmentClient[];
+ LongGuns?: IEquipmentClient[];
+ Melee?: IEquipmentClient[];
+ XPInfo: ITypeXPItem[];
+ };
+ GuildId?: IOid;
+ GuildName?: string;
+ GuildTier?: number;
+ GuildXp?: number;
+ GuildClass?: number;
+ GuildEmblem?: boolean;
+ PlayerSkills?: IPlayerSkills;
+ ChallengeProgress?: IChallengeProgress[];
+ DeathMarks?: string[];
+ Harvestable?: boolean;
+ DeathSquadable?: boolean;
+ Created?: IMongoDate;
+ MigratedToConsole?: boolean;
+ Missions?: IMission[];
+ Affiliations?: IAffiliation[];
+ DailyFocus?: number;
+ Wishlist?: string[];
+ Alignment?: IAlignment;
+}
+
+interface IXPComponentClient {
+ _id?: IOid;
+ StoreTypeName: string;
+ TypeName?: string;
+ PurchaseQuantity?: number;
+ ProductCategory?: "Recipes";
+ Rarity?: "COMMON";
+ RegularPrice?: number;
+ PremiumPrice?: number;
+ SellingPrice?: number;
+ DateAddedToManifest?: number;
+ PrimeSellingPrice?: number;
+ GuildXp?: number;
+ ResultPrefab?: string;
+ ResultDecoration?: string;
+ ShowInMarket?: boolean;
+ ShowInInventory?: boolean;
+ locTags?: Record;
+}
+
+let skinLookupTable: Record | undefined;
+
+const resolveAndCollectSkins = (
+ inventory: TInventoryDatabaseDocument,
+ skins: Set,
+ item: IEquipmentClient
+): void => {
+ for (const config of item.Configs) {
+ if (config.Skins) {
+ for (let i = 0; i != config.Skins.length; ++i) {
+ // Resolve oids to type names
+ if (config.Skins[i].length == 24) {
+ if (config.Skins[i].substring(0, 16) == "ca70ca70ca70ca70") {
+ if (!skinLookupTable) {
+ skinLookupTable = {};
+ for (const key of Object.keys(ExportCustoms)) {
+ skinLookupTable[catBreadHash(key)] = key;
+ }
+ }
+ config.Skins[i] = skinLookupTable[parseInt(config.Skins[i].substring(16), 16)];
+ } else {
+ const skinItem = inventory.WeaponSkins.id(config.Skins[i]);
+ config.Skins[i] = skinItem ? skinItem.ItemType : "";
+ }
+ }
+
+ // Collect type names
+ if (config.Skins[i]) {
+ skins.add(config.Skins[i]);
+ }
+ }
+ }
+ }
+};
+
+const populateLoadout = async (
+ inventory: TInventoryDatabaseDocument,
+ result: IPlayerProfileViewingDataResult
+): Promise => {
+ if (inventory.CurrentLoadOutIds.length) {
+ const loadout = (await Loadout.findById(inventory.LoadOutPresets, "NORMAL"))!;
+ result.LoadOutPreset = loadout.NORMAL.id(
+ fromDbOid(inventory.CurrentLoadOutIds[LoadoutIndex.NORMAL])
+ )!.toJSON();
+ result.LoadOutPreset.ItemId = undefined;
+ const skins = new Set();
+ if (result.LoadOutPreset.s?.ItemId) {
+ result.LoadOutInventory.Suits = [
+ inventory.Suits.id(fromDbOid(result.LoadOutPreset.s.ItemId))!.toJSON()
+ ];
+ resolveAndCollectSkins(inventory, skins, result.LoadOutInventory.Suits[0]);
+ }
+ if (result.LoadOutPreset.p?.ItemId) {
+ result.LoadOutInventory.Pistols = [
+ inventory.Pistols.id(fromDbOid(result.LoadOutPreset.p.ItemId))!.toJSON()
+ ];
+ resolveAndCollectSkins(inventory, skins, result.LoadOutInventory.Pistols[0]);
+ }
+ if (result.LoadOutPreset.l?.ItemId) {
+ result.LoadOutInventory.LongGuns = [
+ inventory.LongGuns.id(fromDbOid(result.LoadOutPreset.l.ItemId))!.toJSON()
+ ];
+ resolveAndCollectSkins(inventory, skins, result.LoadOutInventory.LongGuns[0]);
+ }
+ if (result.LoadOutPreset.m?.ItemId) {
+ result.LoadOutInventory.Melee = [
+ inventory.Melee.id(fromDbOid(result.LoadOutPreset.m.ItemId))!.toJSON()
+ ];
+ resolveAndCollectSkins(inventory, skins, result.LoadOutInventory.Melee[0]);
+ }
+ for (const skin of skins) {
+ result.LoadOutInventory.WeaponSkins.push({ ItemType: skin });
+ }
+ }
+};
+
+const populateGuild = (guild: TGuildDatabaseDocument, result: IPlayerProfileViewingDataResult): void => {
+ result.GuildId = toOid(guild._id);
+ result.GuildName = guild.Name;
+ result.GuildTier = guild.Tier;
+ result.GuildXp = guild.XP;
+ result.GuildClass = guild.Class;
+ result.GuildEmblem = guild.Emblem;
+};
diff --git a/src/controllers/dynamic/worldStateController.ts b/src/controllers/dynamic/worldStateController.ts
new file mode 100644
index 00000000..c027a52a
--- /dev/null
+++ b/src/controllers/dynamic/worldStateController.ts
@@ -0,0 +1,19 @@
+import type { RequestHandler } from "express";
+import { getWorldState, populateDailyDeal, populateFissures } from "../../services/worldStateService.ts";
+import { version_compare } from "../../helpers/inventoryHelpers.ts";
+
+export const worldStateController: RequestHandler = async (req, res) => {
+ const buildLabel = req.query.buildLabel as string | undefined;
+ const worldState = getWorldState(buildLabel);
+
+ const populatePromises = [populateDailyDeal(worldState)];
+
+ // Omitting void fissures for versions prior to Dante Unbound to avoid script errors.
+ if (!buildLabel || version_compare(buildLabel, "2024.03.24.20.00") >= 0) {
+ populatePromises.push(populateFissures(worldState));
+ }
+
+ await Promise.all(populatePromises);
+
+ res.json(worldState);
+};
diff --git a/src/controllers/pay/getSkuCatalogController.ts b/src/controllers/pay/getSkuCatalogController.ts
new file mode 100644
index 00000000..8495c316
--- /dev/null
+++ b/src/controllers/pay/getSkuCatalogController.ts
@@ -0,0 +1,5 @@
+import type { RequestHandler } from "express";
+
+export const getSkuCatalogController: RequestHandler = (_req, res) => {
+ res.sendFile("static/fixed_responses/getSkuCatalog.json", { root: "./" });
+};
diff --git a/src/controllers/pay/steamPacksController.ts b/src/controllers/pay/steamPacksController.ts
new file mode 100644
index 00000000..ea4c5b89
--- /dev/null
+++ b/src/controllers/pay/steamPacksController.ts
@@ -0,0 +1,6 @@
+import type { RequestHandler } from "express";
+
+const steamPacksController: RequestHandler = (_req, res) => {
+ res.sendStatus(200);
+};
+export { steamPacksController };
diff --git a/src/controllers/stats/leaderboardController.ts b/src/controllers/stats/leaderboardController.ts
new file mode 100644
index 00000000..bd269a27
--- /dev/null
+++ b/src/controllers/stats/leaderboardController.ts
@@ -0,0 +1,25 @@
+import { getLeaderboard } from "../../services/leaderboardService.ts";
+import type { RequestHandler } from "express";
+
+export const leaderboardController: RequestHandler = async (req, res) => {
+ const payload = JSON.parse(String(req.body)) as ILeaderboardRequest;
+ res.json({
+ results: await getLeaderboard(
+ payload.field,
+ payload.before,
+ payload.after,
+ payload.pivotId,
+ payload.guildId,
+ payload.guildTier
+ )
+ });
+};
+
+interface ILeaderboardRequest {
+ field: string;
+ before: number;
+ after: number;
+ pivotId?: string;
+ guildId?: string;
+ guildTier?: number;
+}
diff --git a/src/controllers/stats/uploadController.ts b/src/controllers/stats/uploadController.ts
new file mode 100644
index 00000000..dd7e3d77
--- /dev/null
+++ b/src/controllers/stats/uploadController.ts
@@ -0,0 +1,15 @@
+import { getJSONfromString } from "../../helpers/stringHelpers.ts";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { updateStats } from "../../services/statsService.ts";
+import type { IStatsUpdate } from "../../types/statTypes.ts";
+import type { RequestHandler } from "express";
+
+const uploadController: RequestHandler = async (req, res) => {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { PS, ...payload } = getJSONfromString(String(req.body));
+ const accountId = await getAccountIdForRequest(req);
+ await updateStats(accountId, payload);
+ res.status(200).end();
+};
+
+export { uploadController };
diff --git a/src/controllers/stats/viewController.ts b/src/controllers/stats/viewController.ts
new file mode 100644
index 00000000..52d5e97f
--- /dev/null
+++ b/src/controllers/stats/viewController.ts
@@ -0,0 +1,46 @@
+import type { RequestHandler } from "express";
+import { getAccountIdForRequest } from "../../services/loginService.ts";
+import { config } from "../../services/configService.ts";
+import allScans from "../../../static/fixed_responses/allScans.json" with { type: "json" };
+import { ExportEnemies } from "warframe-public-export-plus";
+import { getInventory } from "../../services/inventoryService.ts";
+import { getStats } from "../../services/statsService.ts";
+import type { IStatsClient } from "../../types/statTypes.ts";
+
+const viewController: RequestHandler = async (req, res) => {
+ const accountId = await getAccountIdForRequest(req);
+ const inventory = await getInventory(accountId, "XPInfo");
+ const playerStats = await getStats(accountId);
+
+ const responseJson = playerStats.toJSON() as IStatsClient;
+ responseJson.Weapons ??= [];
+ for (const item of inventory.XPInfo) {
+ const weaponIndex = responseJson.Weapons.findIndex(element => element.type == item.ItemType);
+ if (weaponIndex !== -1) {
+ responseJson.Weapons[weaponIndex].xp = item.XP;
+ } else {
+ responseJson.Weapons.push({ type: item.ItemType, xp: item.XP });
+ }
+ }
+ if (config.unlockAllScans) {
+ const scans = new Set(allScans);
+ for (const type of Object.keys(ExportEnemies.avatars)) {
+ if (!scans.has(type)) scans.add(type);
+ }
+
+ // Take any existing scans and also set them to 9999
+ if (responseJson.Scans) {
+ for (const scan of responseJson.Scans) {
+ scans.add(scan.type);
+ }
+ }
+ responseJson.Scans = [];
+
+ for (const type of scans) {
+ responseJson.Scans.push({ type: type, scans: 9999 });
+ }
+ }
+ res.json(responseJson);
+};
+
+export { viewController };
diff --git a/src/helpers/commandLineArguments.ts b/src/helpers/commandLineArguments.ts
new file mode 100644
index 00000000..09af105d
--- /dev/null
+++ b/src/helpers/commandLineArguments.ts
@@ -0,0 +1,23 @@
+interface IArguments {
+ configPath?: string;
+ dev?: boolean;
+ secret?: string;
+}
+
+export const args: IArguments = {};
+
+for (let i = 2; i < process.argv.length; ) {
+ switch (process.argv[i++]) {
+ case "--configPath":
+ args.configPath = process.argv[i++];
+ break;
+
+ case "--dev":
+ args.dev = true;
+ break;
+
+ case "--secret":
+ args.secret = process.argv[i++];
+ break;
+ }
+}
diff --git a/src/helpers/customHelpers/customHelpers.ts b/src/helpers/customHelpers/customHelpers.ts
new file mode 100644
index 00000000..e0d5a483
--- /dev/null
+++ b/src/helpers/customHelpers/customHelpers.ts
@@ -0,0 +1,56 @@
+import type { IAccountCreation } from "../../types/customTypes.ts";
+import type { IDatabaseAccountRequiredFields } from "../../types/loginTypes.ts";
+import crypto from "crypto";
+import { isString, parseEmail, parseString } from "../general.ts";
+
+const getWhirlpoolHash = (rawPassword: string): string => {
+ const whirlpool = crypto.createHash("whirlpool");
+ const data = whirlpool.update(rawPassword, "utf8");
+ const hash = data.digest("hex");
+ return hash;
+};
+
+const parsePassword = (passwordCandidate: unknown): string => {
+ // a different function could be called that checks whether the password has a certain shape
+ if (!isString(passwordCandidate)) {
+ throw new Error("incorrect password format");
+ }
+ return passwordCandidate;
+};
+
+const toAccountCreation = (accountCreation: unknown): IAccountCreation => {
+ if (!accountCreation || typeof accountCreation !== "object") {
+ throw new Error("incorrect or missing account creation data");
+ }
+
+ if (
+ "email" in accountCreation &&
+ "password" in accountCreation &&
+ "DisplayName" in accountCreation &&
+ "CountryCode" in accountCreation
+ ) {
+ const rawPassword = parsePassword(accountCreation.password);
+ return {
+ email: parseEmail(accountCreation.email),
+ password: getWhirlpoolHash(rawPassword),
+ CountryCode: parseString(accountCreation.CountryCode),
+ DisplayName: parseString(accountCreation.DisplayName)
+ };
+ }
+ throw new Error("incorrect account creation data: incorrect properties");
+};
+
+const toDatabaseAccount = (createAccount: IAccountCreation): IDatabaseAccountRequiredFields => {
+ return {
+ ...createAccount,
+ ClientType: "",
+ ConsentNeeded: false,
+ CrossPlatformAllowed: true,
+ ForceLogoutVersion: 0,
+ TrackedSettings: [],
+ Nonce: 0,
+ LastLogin: new Date()
+ } satisfies IDatabaseAccountRequiredFields;
+};
+
+export { toDatabaseAccount, toAccountCreation as toCreateAccount };
diff --git a/src/helpers/general.ts b/src/helpers/general.ts
new file mode 100644
index 00000000..ed448d84
--- /dev/null
+++ b/src/helpers/general.ts
@@ -0,0 +1,72 @@
+export const isEmptyObject = (obj: unknown): boolean => {
+ return Boolean(obj && Object.keys(obj).length === 0 && obj.constructor === Object);
+};
+
+/*
+alternative to isEmptyObject
+export const isEmptyObject = (obj: object): boolean => {
+ return Object.keys(obj).length === 0;
+};
+*/
+
+export const isString = (text: unknown): text is string => {
+ return typeof text === "string" || text instanceof String;
+};
+
+export const parseString = (data: unknown): string => {
+ if (!isString(data)) {
+ throw new Error("data is not a string");
+ }
+
+ return data;
+};
+
+export const isNumber = (number: unknown): number is number => {
+ return typeof number === "number" && !isNaN(number);
+};
+
+export const parseNumber = (data: unknown): number => {
+ if (!isNumber(data)) {
+ throw new Error("data is not a number");
+ }
+
+ return Number(data);
+};
+
+export const isDate = (date: string): boolean => {
+ return Date.parse(date) != 0;
+};
+
+export const parseDateNumber = (date: unknown): string => {
+ if (!isString(date) || !isDate(date)) {
+ throw new Error("date could not be parsed");
+ }
+
+ return date;
+};
+
+export const parseEmail = (email: unknown): string => {
+ if (!isString(email)) {
+ throw new Error("incorrect email");
+ }
+ return email;
+};
+
+export const isBoolean = (booleanCandidate: unknown): booleanCandidate is boolean => {
+ return typeof booleanCandidate === "boolean";
+};
+
+export const parseBoolean = (booleanCandidate: unknown): boolean => {
+ if (!isBoolean(booleanCandidate)) {
+ throw new Error("argument was not a boolean");
+ }
+ return booleanCandidate;
+};
+
+export const isObject = (objectCandidate: unknown): objectCandidate is Record => {
+ return (
+ (typeof objectCandidate === "object" || objectCandidate instanceof Object) &&
+ objectCandidate !== null &&
+ !Array.isArray(objectCandidate)
+ );
+};
diff --git a/src/helpers/inventoryHelpers.ts b/src/helpers/inventoryHelpers.ts
new file mode 100644
index 00000000..a5edcaf8
--- /dev/null
+++ b/src/helpers/inventoryHelpers.ts
@@ -0,0 +1,213 @@
+import type { IMongoDate, IOid, IOidWithLegacySupport } from "../types/commonTypes.ts";
+import { Types } from "mongoose";
+import type { TRarity } from "warframe-public-export-plus";
+import type { IFusionTreasure } from "../types/inventoryTypes/inventoryTypes.ts";
+
+export const version_compare = (a: string, b: string): number => {
+ const a_digits = a
+ .split("/")[0]
+ .split(".")
+ .map(x => parseInt(x));
+ const b_digits = b
+ .split("/")[0]
+ .split(".")
+ .map(x => parseInt(x));
+ for (let i = 0; i != a_digits.length; ++i) {
+ if (a_digits[i] != b_digits[i]) {
+ return a_digits[i] > b_digits[i] ? 1 : -1;
+ }
+ }
+ return 0;
+};
+
+export const toOid = (objectId: Types.ObjectId): IOid => {
+ return { $oid: objectId.toString() };
+};
+
+export function toOid2(objectId: Types.ObjectId, buildLabel: undefined): IOid;
+export function toOid2(objectId: Types.ObjectId, buildLabel: string | undefined): IOidWithLegacySupport;
+export function toOid2(objectId: Types.ObjectId, buildLabel: string | undefined): IOidWithLegacySupport {
+ if (buildLabel && version_compare(buildLabel, "2016.12.21.19.13") <= 0) {
+ return { $id: objectId.toString() };
+ }
+ return { $oid: objectId.toString() };
+}
+
+export const toLegacyOid = (oid: IOidWithLegacySupport): void => {
+ if (!("$id" in oid)) {
+ oid.$id = oid.$oid;
+ delete oid.$oid;
+ }
+};
+
+export const fromOid = (oid: IOidWithLegacySupport): string => {
+ return (oid.$oid ?? oid.$id)!;
+};
+
+// For oids that may have been stored incorrectly
+export const fromDbOid = (x: Types.ObjectId | IOid): Types.ObjectId => {
+ return "$oid" in x ? new Types.ObjectId(x.$oid) : x;
+};
+
+export const toMongoDate = (date: Date): IMongoDate => {
+ return { $date: { $numberLong: date.getTime().toString() } };
+};
+
+export const fromMongoDate = (date: IMongoDate): Date => {
+ return new Date(parseInt(date.$date.$numberLong));
+};
+
+export const parseFusionTreasure = (name: string, count: number): IFusionTreasure => {
+ const arr = name.split("_");
+ return {
+ ItemType: arr[0],
+ Sockets: parseInt(arr[1], 16),
+ ItemCount: count
+ };
+};
+
+export type TTraitsPool = Record<
+ "Colors" | "EyeColors" | "FurPatterns" | "BodyTypes" | "Heads" | "Tails",
+ { type: string; rarity: TRarity }[]
+>;
+
+export const kubrowWeights: Record = {
+ COMMON: 6,
+ UNCOMMON: 4,
+ RARE: 2,
+ LEGENDARY: 1
+};
+
+export const kubrowFurPatternsWeights: Record = {
+ COMMON: 6,
+ UNCOMMON: 5,
+ RARE: 2,
+ LEGENDARY: 1
+};
+
+export const catbrowDetails: TTraitsPool = {
+ Colors: [
+ { type: "/Lotus/Types/Game/CatbrowPet/Colors/CatbrowPetColorBaseA", rarity: "COMMON" },
+ { type: "/Lotus/Types/Game/CatbrowPet/Colors/CatbrowPetColorBaseB", rarity: "COMMON" },
+ { type: "/Lotus/Types/Game/CatbrowPet/Colors/CatbrowPetColorBaseC", rarity: "COMMON" },
+ { type: "/Lotus/Types/Game/CatbrowPet/Colors/CatbrowPetColorBaseD", rarity: "COMMON" },
+
+ { type: "/Lotus/Types/Game/CatbrowPet/Colors/CatbrowPetColorSecondaryA", rarity: "UNCOMMON" },
+ { type: "/Lotus/Types/Game/CatbrowPet/Colors/CatbrowPetColorSecondaryB", rarity: "UNCOMMON" },
+ { type: "/Lotus/Types/Game/CatbrowPet/Colors/CatbrowPetColorSecondaryC", rarity: "UNCOMMON" },
+ { type: "/Lotus/Types/Game/CatbrowPet/Colors/CatbrowPetColorSecondaryD", rarity: "UNCOMMON" },
+
+ { type: "/Lotus/Types/Game/CatbrowPet/Colors/CatbrowPetColorTertiaryA", rarity: "RARE" },
+ { type: "/Lotus/Types/Game/CatbrowPet/Colors/CatbrowPetColorTertiaryB", rarity: "RARE" },
+ { type: "/Lotus/Types/Game/CatbrowPet/Colors/CatbrowPetColorTertiaryC", rarity: "RARE" },
+ { type: "/Lotus/Types/Game/CatbrowPet/Colors/CatbrowPetColorTertiaryD", rarity: "RARE" },
+
+ { type: "/Lotus/Types/Game/CatbrowPet/Colors/CatbrowPetColorAccentsA", rarity: "LEGENDARY" },
+ { type: "/Lotus/Types/Game/CatbrowPet/Colors/CatbrowPetColorAccentsB", rarity: "LEGENDARY" },
+ { type: "/Lotus/Types/Game/CatbrowPet/Colors/CatbrowPetColorAccentsC", rarity: "LEGENDARY" },
+ { type: "/Lotus/Types/Game/CatbrowPet/Colors/CatbrowPetColorAccentsD", rarity: "LEGENDARY" }
+ ],
+
+ EyeColors: [
+ { type: "/Lotus/Types/Game/CatbrowPet/Colors/CatbrowPetColorEyesA", rarity: "LEGENDARY" },
+ { type: "/Lotus/Types/Game/CatbrowPet/Colors/CatbrowPetColorEyesB", rarity: "LEGENDARY" },
+ { type: "/Lotus/Types/Game/CatbrowPet/Colors/CatbrowPetColorEyesC", rarity: "LEGENDARY" },
+ { type: "/Lotus/Types/Game/CatbrowPet/Colors/CatbrowPetColorEyesD", rarity: "LEGENDARY" },
+ { type: "/Lotus/Types/Game/CatbrowPet/Colors/CatbrowPetColorEyesE", rarity: "LEGENDARY" },
+ { type: "/Lotus/Types/Game/CatbrowPet/Colors/CatbrowPetColorEyesF", rarity: "LEGENDARY" },
+ { type: "/Lotus/Types/Game/CatbrowPet/Colors/CatbrowPetColorEyesG", rarity: "LEGENDARY" },
+ { type: "/Lotus/Types/Game/CatbrowPet/Colors/CatbrowPetColorEyesH", rarity: "LEGENDARY" },
+ { type: "/Lotus/Types/Game/CatbrowPet/Colors/CatbrowPetColorEyesI", rarity: "LEGENDARY" },
+ { type: "/Lotus/Types/Game/CatbrowPet/Colors/CatbrowPetColorEyesJ", rarity: "LEGENDARY" },
+ { type: "/Lotus/Types/Game/CatbrowPet/Colors/CatbrowPetColorEyesK", rarity: "LEGENDARY" },
+ { type: "/Lotus/Types/Game/CatbrowPet/Colors/CatbrowPetColorEyesL", rarity: "LEGENDARY" },
+ { type: "/Lotus/Types/Game/CatbrowPet/Colors/CatbrowPetColorEyesM", rarity: "LEGENDARY" },
+ { type: "/Lotus/Types/Game/CatbrowPet/Colors/CatbrowPetColorEyesN", rarity: "LEGENDARY" }
+ ],
+
+ FurPatterns: [{ type: "/Lotus/Types/Game/CatbrowPet/Patterns/CatbrowPetPatternA", rarity: "COMMON" }],
+
+ BodyTypes: [
+ { type: "/Lotus/Types/Game/CatbrowPet/BodyTypes/CatbrowPetRegularBodyType", rarity: "UNCOMMON" },
+ { type: "/Lotus/Types/Game/CatbrowPet/BodyTypes/CatbrowPetRegularBodyType", rarity: "LEGENDARY" }
+ ],
+
+ Heads: [
+ { type: "/Lotus/Types/Game/CatbrowPet/Heads/CatbrowHeadA", rarity: "LEGENDARY" },
+ { type: "/Lotus/Types/Game/CatbrowPet/Heads/CatbrowHeadB", rarity: "LEGENDARY" },
+ { type: "/Lotus/Types/Game/CatbrowPet/Heads/CatbrowHeadC", rarity: "LEGENDARY" },
+ { type: "/Lotus/Types/Game/CatbrowPet/Heads/CatbrowHeadD", rarity: "LEGENDARY" }
+ ],
+
+ Tails: [
+ { type: "/Lotus/Types/Game/CatbrowPet/Tails/CatbrowTailA", rarity: "LEGENDARY" },
+ { type: "/Lotus/Types/Game/CatbrowPet/Tails/CatbrowTailB", rarity: "LEGENDARY" },
+ { type: "/Lotus/Types/Game/CatbrowPet/Tails/CatbrowTailC", rarity: "LEGENDARY" },
+ { type: "/Lotus/Types/Game/CatbrowPet/Tails/CatbrowTailD", rarity: "LEGENDARY" },
+ { type: "/Lotus/Types/Game/CatbrowPet/Tails/CatbrowTailE", rarity: "LEGENDARY" }
+ ]
+};
+
+export const kubrowDetails: TTraitsPool = {
+ Colors: [
+ { type: "/Lotus/Types/Game/KubrowPet/Colors/KubrowPetColorMundaneA", rarity: "UNCOMMON" },
+ { type: "/Lotus/Types/Game/KubrowPet/Colors/KubrowPetColorMundaneB", rarity: "UNCOMMON" },
+ { type: "/Lotus/Types/Game/KubrowPet/Colors/KubrowPetColorMundaneC", rarity: "UNCOMMON" },
+ { type: "/Lotus/Types/Game/KubrowPet/Colors/KubrowPetColorMundaneD", rarity: "UNCOMMON" },
+ { type: "/Lotus/Types/Game/KubrowPet/Colors/KubrowPetColorMundaneE", rarity: "UNCOMMON" },
+ { type: "/Lotus/Types/Game/KubrowPet/Colors/KubrowPetColorMundaneF", rarity: "UNCOMMON" },
+ { type: "/Lotus/Types/Game/KubrowPet/Colors/KubrowPetColorMundaneG", rarity: "UNCOMMON" },
+ { type: "/Lotus/Types/Game/KubrowPet/Colors/KubrowPetColorMundaneH", rarity: "UNCOMMON" },
+
+ { type: "/Lotus/Types/Game/KubrowPet/Colors/KubrowPetColorMidA", rarity: "RARE" },
+ { type: "/Lotus/Types/Game/KubrowPet/Colors/KubrowPetColorMidB", rarity: "RARE" },
+ { type: "/Lotus/Types/Game/KubrowPet/Colors/KubrowPetColorMidC", rarity: "RARE" },
+ { type: "/Lotus/Types/Game/KubrowPet/Colors/KubrowPetColorMidD", rarity: "RARE" },
+ { type: "/Lotus/Types/Game/KubrowPet/Colors/KubrowPetColorMidE", rarity: "RARE" },
+ { type: "/Lotus/Types/Game/KubrowPet/Colors/KubrowPetColorMidF", rarity: "RARE" },
+ { type: "/Lotus/Types/Game/KubrowPet/Colors/KubrowPetColorMidG", rarity: "RARE" },
+ { type: "/Lotus/Types/Game/KubrowPet/Colors/KubrowPetColorMidH", rarity: "RARE" },
+
+ { type: "/Lotus/Types/Game/KubrowPet/Colors/KubrowPetColorVibrantA", rarity: "LEGENDARY" },
+ { type: "/Lotus/Types/Game/KubrowPet/Colors/KubrowPetColorVibrantB", rarity: "LEGENDARY" },
+ { type: "/Lotus/Types/Game/KubrowPet/Colors/KubrowPetColorVibrantC", rarity: "LEGENDARY" },
+ { type: "/Lotus/Types/Game/KubrowPet/Colors/KubrowPetColorVibrantD", rarity: "LEGENDARY" },
+ { type: "/Lotus/Types/Game/KubrowPet/Colors/KubrowPetColorVibrantE", rarity: "LEGENDARY" },
+ { type: "/Lotus/Types/Game/KubrowPet/Colors/KubrowPetColorVibrantF", rarity: "LEGENDARY" },
+ { type: "/Lotus/Types/Game/KubrowPet/Colors/KubrowPetColorVibrantG", rarity: "LEGENDARY" },
+ { type: "/Lotus/Types/Game/KubrowPet/Colors/KubrowPetColorVibrantH", rarity: "LEGENDARY" }
+ ],
+
+ EyeColors: [
+ { type: "/Lotus/Types/Game/KubrowPet/Colors/KubrowPetColorEyesA", rarity: "LEGENDARY" },
+ { type: "/Lotus/Types/Game/KubrowPet/Colors/KubrowPetColorEyesB", rarity: "LEGENDARY" },
+ { type: "/Lotus/Types/Game/KubrowPet/Colors/KubrowPetColorEyesC", rarity: "LEGENDARY" },
+ { type: "/Lotus/Types/Game/KubrowPet/Colors/KubrowPetColorEyesD", rarity: "LEGENDARY" },
+ { type: "/Lotus/Types/Game/KubrowPet/Colors/KubrowPetColorEyesE", rarity: "LEGENDARY" },
+ { type: "/Lotus/Types/Game/KubrowPet/Colors/KubrowPetColorEyesF", rarity: "LEGENDARY" },
+ { type: "/Lotus/Types/Game/KubrowPet/Colors/KubrowPetColorEyesG", rarity: "LEGENDARY" },
+ { type: "/Lotus/Types/Game/KubrowPet/Colors/KubrowPetColorEyesH", rarity: "LEGENDARY" },
+ { type: "/Lotus/Types/Game/KubrowPet/Colors/KubrowPetColorEyesI", rarity: "LEGENDARY" }
+ ],
+
+ FurPatterns: [
+ { type: "/Lotus/Types/Game/KubrowPet/Patterns/KubrowPetPatternB", rarity: "UNCOMMON" },
+ { type: "/Lotus/Types/Game/KubrowPet/Patterns/KubrowPetPatternA", rarity: "UNCOMMON" },
+
+ { type: "/Lotus/Types/Game/KubrowPet/Patterns/KubrowPetPatternC", rarity: "RARE" },
+ { type: "/Lotus/Types/Game/KubrowPet/Patterns/KubrowPetPatternD", rarity: "RARE" },
+
+ { type: "/Lotus/Types/Game/KubrowPet/Patterns/KubrowPetPatternE", rarity: "LEGENDARY" },
+ { type: "/Lotus/Types/Game/KubrowPet/Patterns/KubrowPetPatternF", rarity: "LEGENDARY" }
+ ],
+
+ BodyTypes: [
+ { type: "/Lotus/Types/Game/KubrowPet/BodyTypes/KubrowPetRegularBodyType", rarity: "UNCOMMON" },
+ { type: "/Lotus/Types/Game/KubrowPet/BodyTypes/KubrowPetHeavyBodyType", rarity: "LEGENDARY" },
+ { type: "/Lotus/Types/Game/KubrowPet/BodyTypes/KubrowPetThinBodyType", rarity: "LEGENDARY" }
+ ],
+
+ Heads: [],
+
+ Tails: []
+};
diff --git a/src/helpers/modularWeaponHelper.ts b/src/helpers/modularWeaponHelper.ts
new file mode 100644
index 00000000..111f85c9
--- /dev/null
+++ b/src/helpers/modularWeaponHelper.ts
@@ -0,0 +1,26 @@
+import type { TEquipmentKey } from "../types/inventoryTypes/inventoryTypes.ts";
+
+export const modularWeaponTypes: Record = {
+ "/Lotus/Weapons/SolarisUnited/Primary/LotusModularPrimary": "LongGuns",
+ "/Lotus/Weapons/SolarisUnited/Primary/LotusModularPrimaryBeam": "LongGuns",
+ "/Lotus/Weapons/SolarisUnited/Primary/LotusModularPrimaryLauncher": "LongGuns",
+ "/Lotus/Weapons/SolarisUnited/Primary/LotusModularPrimaryShotgun": "LongGuns",
+ "/Lotus/Weapons/SolarisUnited/Primary/LotusModularPrimarySniper": "LongGuns",
+ "/Lotus/Weapons/SolarisUnited/Secondary/LotusModularSecondary": "Pistols",
+ "/Lotus/Weapons/SolarisUnited/Secondary/LotusModularSecondaryBeam": "Pistols",
+ "/Lotus/Weapons/SolarisUnited/Secondary/LotusModularSecondaryShotgun": "Pistols",
+ "/Lotus/Weapons/Ostron/Melee/LotusModularWeapon": "Melee",
+ "/Lotus/Weapons/Sentients/OperatorAmplifiers/OperatorAmpWeapon": "OperatorAmps",
+ "/Lotus/Types/Vehicles/Hoverboard/HoverboardSuit": "Hoverboards",
+ "/Lotus/Types/Friendly/Pets/MoaPets/MoaPetPowerSuit": "MoaPets",
+ "/Lotus/Types/Friendly/Pets/ZanukaPets/ZanukaPetPowerSuit": "MoaPets",
+ "/Lotus/Types/Friendly/Pets/ZanukaPets/ZanukaPetAPowerSuit": "MoaPets",
+ "/Lotus/Types/Friendly/Pets/ZanukaPets/ZanukaPetBPowerSuit": "MoaPets",
+ "/Lotus/Types/Friendly/Pets/ZanukaPets/ZanukaPetCPowerSuit": "MoaPets",
+ "/Lotus/Types/Friendly/Pets/CreaturePets/ArmoredInfestedCatbrowPetPowerSuit": "KubrowPets",
+ "/Lotus/Types/Friendly/Pets/CreaturePets/HornedInfestedCatbrowPetPowerSuit": "KubrowPets",
+ "/Lotus/Types/Friendly/Pets/CreaturePets/VulpineInfestedCatbrowPetPowerSuit": "KubrowPets",
+ "/Lotus/Types/Friendly/Pets/CreaturePets/MedjayPredatorKubrowPetPowerSuit": "KubrowPets",
+ "/Lotus/Types/Friendly/Pets/CreaturePets/PharaohPredatorKubrowPetPowerSuit": "KubrowPets",
+ "/Lotus/Types/Friendly/Pets/CreaturePets/VizierPredatorKubrowPetPowerSuit": "KubrowPets"
+};
diff --git a/src/helpers/nemesisHelpers.ts b/src/helpers/nemesisHelpers.ts
new file mode 100644
index 00000000..14cb6ba8
--- /dev/null
+++ b/src/helpers/nemesisHelpers.ts
@@ -0,0 +1,492 @@
+import { ExportRegions, ExportWarframes } from "warframe-public-export-plus";
+import type { IInfNode, TNemesisFaction } from "../types/inventoryTypes/inventoryTypes.ts";
+import { generateRewardSeed, getRewardAtPercentage, SRng } from "../services/rngService.ts";
+import type { TInventoryDatabaseDocument } from "../models/inventoryModels/inventoryModel.ts";
+import type { IOid } from "../types/commonTypes.ts";
+import { isArchwingMission } from "../services/worldStateService.ts";
+
+type TInnateDamageTag =
+ | "InnateElectricityDamage"
+ | "InnateHeatDamage"
+ | "InnateFreezeDamage"
+ | "InnateToxinDamage"
+ | "InnateMagDamage"
+ | "InnateRadDamage"
+ | "InnateImpactDamage";
+
+export interface INemesisManifest {
+ weapons: readonly string[];
+ systemIndexes: readonly number[];
+ showdownNode: string;
+ ephemeraChance: number;
+ ephemeraTypes?: Record;
+ firstKillReward: string;
+ firstConvertReward: string;
+ killMessageSubject: string;
+ killMessageBody: string;
+ convertMessageSubject: string;
+ convertMessageBody: string;
+ convertMessageIcon: string;
+ minBuild: string;
+}
+
+class KuvaLichManifest implements INemesisManifest {
+ weapons = [
+ "/Lotus/Weapons/Grineer/KuvaLich/LongGuns/Drakgoon/KuvaDrakgoon",
+ "/Lotus/Weapons/Grineer/KuvaLich/LongGuns/Karak/KuvaKarak",
+ "/Lotus/Weapons/Grineer/Melee/GrnKuvaLichScythe/GrnKuvaLichScytheWeapon",
+ "/Lotus/Weapons/Grineer/KuvaLich/LongGuns/Kohm/KuvaKohm",
+ "/Lotus/Weapons/Grineer/KuvaLich/LongGuns/Ogris/KuvaOgris",
+ "/Lotus/Weapons/Grineer/KuvaLich/LongGuns/Quartakk/KuvaQuartakk",
+ "/Lotus/Weapons/Grineer/KuvaLich/LongGuns/Tonkor/KuvaTonkor",
+ "/Lotus/Weapons/Grineer/KuvaLich/Secondaries/Brakk/KuvaBrakk",
+ "/Lotus/Weapons/Grineer/KuvaLich/Secondaries/Kraken/KuvaKraken",
+ "/Lotus/Weapons/Grineer/KuvaLich/Secondaries/Seer/KuvaSeer",
+ "/Lotus/Weapons/Grineer/KuvaLich/Secondaries/Stubba/KuvaStubba",
+ "/Lotus/Weapons/Grineer/HeavyWeapons/GrnHeavyGrenadeLauncher",
+ "/Lotus/Weapons/Grineer/LongGuns/GrnKuvaLichRifle/GrnKuvaLichRifleWeapon"
+ ];
+ systemIndexes = [2, 3, 9, 11, 18];
+ showdownNode = "CrewBattleNode557";
+ ephemeraChance = 0.05;
+ ephemeraTypes = {
+ InnateElectricityDamage: "/Lotus/Upgrades/Skins/Effects/Kuva/KuvaLightningEphemera",
+ InnateHeatDamage: "/Lotus/Upgrades/Skins/Effects/Kuva/KuvaFireEphemera",
+ InnateFreezeDamage: "/Lotus/Upgrades/Skins/Effects/Kuva/KuvaIceEphemera",
+ InnateToxinDamage: "/Lotus/Upgrades/Skins/Effects/Kuva/KuvaToxinEphemera",
+ InnateMagDamage: "/Lotus/Upgrades/Skins/Effects/Kuva/KuvaMagneticEphemera",
+ InnateRadDamage: "/Lotus/Upgrades/Skins/Effects/Kuva/KuvaTricksterEphemera",
+ InnateImpactDamage: "/Lotus/Upgrades/Skins/Effects/Kuva/KuvaImpactEphemera"
+ };
+ firstKillReward = "/Lotus/StoreItems/Upgrades/Skins/Clan/LichKillerBadgeItem";
+ firstConvertReward = "/Lotus/StoreItems/Upgrades/Skins/Sigils/KuvaLichSigil";
+ killMessageSubject = "/Lotus/Language/Inbox/VanquishKuvaMsgTitle";
+ killMessageBody = "/Lotus/Language/Inbox/VanquishLichMsgBody";
+ convertMessageSubject = "/Lotus/Language/Kingpins/InboxKuvaConvertedSubject";
+ convertMessageBody = "/Lotus/Language/Kingpins/InboxKuvaConvertedBody";
+ convertMessageIcon = "/Lotus/Interface/Graphics/WorldStatePanel/Grineer.png";
+ minBuild = "2019.10.31.22.42"; // 26.0.0
+}
+
+class KuvaLichManifestVersionTwo extends KuvaLichManifest {
+ constructor() {
+ super();
+ this.ephemeraChance = 0.1;
+ this.minBuild = "2020.03.05.16.06"; // Unsure about this one, so using the same value as in version three.
+ }
+}
+
+class KuvaLichManifestVersionThree extends KuvaLichManifestVersionTwo {
+ constructor() {
+ super();
+ this.weapons.push("/Lotus/Weapons/Grineer/Bows/GrnBow/GrnBowWeapon");
+ this.weapons.push("/Lotus/Weapons/Grineer/KuvaLich/LongGuns/Hind/KuvaHind");
+ this.weapons.push("/Lotus/Weapons/Grineer/KuvaLich/Secondaries/Nukor/KuvaNukor");
+ this.ephemeraChance = 0.2;
+ this.minBuild = "2020.03.05.16.06"; // This is 27.2.0, tho 27.1.0 should also recognise this.
+ }
+}
+
+class KuvaLichManifestVersionFour extends KuvaLichManifestVersionThree {
+ constructor() {
+ super();
+ this.minBuild = "2021.07.05.17.03"; // Unsure about this one, so using the same value as in version five.
+ }
+}
+
+class KuvaLichManifestVersionFive extends KuvaLichManifestVersionFour {
+ constructor() {
+ super();
+ this.weapons.push("/Lotus/Weapons/Grineer/KuvaLich/LongGuns/Hek/KuvaHekWeapon");
+ this.weapons.push("/Lotus/Weapons/Grineer/KuvaLich/LongGuns/Zarr/KuvaZarr");
+ this.weapons.push("/Lotus/Weapons/Grineer/KuvaLich/HeavyWeapons/Grattler/KuvaGrattler");
+ this.minBuild = "2021.07.05.17.03"; // 30.5.0
+ }
+}
+
+class KuvaLichManifestVersionSix extends KuvaLichManifestVersionFive {
+ constructor() {
+ super();
+ this.weapons.push("/Lotus/Weapons/Grineer/KuvaLich/LongGuns/Sobek/KuvaSobek");
+ this.minBuild = "2024.05.15.11.07"; // 35.6.0
+ }
+}
+
+class LawyerManifest implements INemesisManifest {
+ weapons = [
+ "/Lotus/Weapons/Corpus/LongGuns/CrpBriefcaseLauncher/CrpBriefcaseLauncher",
+ "/Lotus/Weapons/Corpus/BoardExec/Primary/CrpBEArcaPlasmor/CrpBEArcaPlasmor",
+ "/Lotus/Weapons/Corpus/BoardExec/Primary/CrpBEFluxRifle/CrpBEFluxRifle",
+ "/Lotus/Weapons/Corpus/BoardExec/Primary/CrpBETetra/CrpBETetra",
+ "/Lotus/Weapons/Corpus/BoardExec/Secondary/CrpBECycron/CrpBECycron",
+ "/Lotus/Weapons/Corpus/BoardExec/Secondary/CrpBEDetron/CrpBEDetron",
+ "/Lotus/Weapons/Corpus/Pistols/CrpIgniterPistol/CrpIgniterPistol",
+ "/Lotus/Weapons/Corpus/Pistols/CrpBriefcaseAkimbo/CrpBriefcaseAkimboPistol"
+ ];
+ systemIndexes = [1, 15, 4, 7, 8];
+ showdownNode = "CrewBattleNode558";
+ ephemeraChance = 0.2;
+ ephemeraTypes = {
+ InnateElectricityDamage: "/Lotus/Upgrades/Skins/Effects/CorpusLichEphemeraA",
+ InnateHeatDamage: "/Lotus/Upgrades/Skins/Effects/CorpusLichEphemeraB",
+ InnateFreezeDamage: "/Lotus/Upgrades/Skins/Effects/CorpusLichEphemeraC",
+ InnateToxinDamage: "/Lotus/Upgrades/Skins/Effects/CorpusLichEphemeraD",
+ InnateMagDamage: "/Lotus/Upgrades/Skins/Effects/CorpusLichEphemeraE",
+ InnateRadDamage: "/Lotus/Upgrades/Skins/Effects/CorpusLichEphemeraF",
+ InnateImpactDamage: "/Lotus/Upgrades/Skins/Effects/CorpusLichEphemeraG"
+ };
+ firstKillReward = "/Lotus/StoreItems/Upgrades/Skins/Clan/CorpusLichBadgeItem";
+ firstConvertReward = "/Lotus/StoreItems/Upgrades/Skins/Sigils/CorpusLichSigil";
+ killMessageSubject = "/Lotus/Language/Inbox/VanquishLawyerMsgTitle";
+ killMessageBody = "/Lotus/Language/Inbox/VanquishLichMsgBody";
+ convertMessageSubject = "/Lotus/Language/Kingpins/InboxSisterConvertedSubject";
+ convertMessageBody = "/Lotus/Language/Kingpins/InboxSisterConvertedBody";
+ convertMessageIcon = "/Lotus/Interface/Graphics/WorldStatePanel/Corpus.png";
+ minBuild = "2021.07.05.17.03"; // 30.5.0
+}
+
+class LawyerManifestVersionTwo extends LawyerManifest {
+ constructor() {
+ super();
+ this.weapons.push("/Lotus/Weapons/Corpus/BoardExec/Secondary/CrpBEPlinx/CrpBEPlinxWeapon");
+ this.minBuild = "2022.11.30.08.13"; // 32.2.0
+ }
+}
+
+class LawyerManifestVersionThree extends LawyerManifestVersionTwo {
+ constructor() {
+ super();
+ this.weapons.push("/Lotus/Weapons/Corpus/BoardExec/Primary/CrpBEGlaxion/CrpBEGlaxion");
+ this.minBuild = "2024.05.15.11.07"; // 35.6.0
+ }
+}
+
+class LawyerManifestVersionFour extends LawyerManifestVersionThree {
+ constructor() {
+ super();
+ this.minBuild = "2024.10.01.11.03"; // 37.0.0
+ }
+}
+
+class InfestedLichManfest implements INemesisManifest {
+ weapons = [];
+ systemIndexes = [23];
+ showdownNode = "CrewBattleNode559";
+ ephemeraChance = 0;
+ firstKillReward = "/Lotus/StoreItems/Upgrades/Skins/Sigils/InfLichVanquishedSigil";
+ firstConvertReward = "/Lotus/StoreItems/Upgrades/Skins/Sigils/InfLichConvertedSigil";
+ killMessageSubject = "/Lotus/Language/Inbox/VanquishBandMsgTitle";
+ killMessageBody = "/Lotus/Language/Inbox/VanquishBandMsgBody";
+ convertMessageSubject = "/Lotus/Language/Kingpins/InboxBandConvertedSubject";
+ convertMessageBody = "/Lotus/Language/Kingpins/InboxBandConvertedBody";
+ convertMessageIcon = "/Lotus/Interface/Graphics/WorldStatePanel/Infested.png";
+ minBuild = "2025.03.18.09.51"; // 38.5.0
+}
+
+const nemesisManifests: Record = {
+ "/Lotus/Types/Game/Nemesis/KuvaLich/KuvaLichManifest": new KuvaLichManifest(),
+ "/Lotus/Types/Game/Nemesis/KuvaLich/KuvaLichManifestVersionTwo": new KuvaLichManifestVersionTwo(),
+ "/Lotus/Types/Game/Nemesis/KuvaLich/KuvaLichManifestVersionThree": new KuvaLichManifestVersionThree(),
+ "/Lotus/Types/Game/Nemesis/KuvaLich/KuvaLichManifestVersionFour": new KuvaLichManifestVersionFour(),
+ "/Lotus/Types/Game/Nemesis/KuvaLich/KuvaLichManifestVersionFive": new KuvaLichManifestVersionFive(),
+ "/Lotus/Types/Game/Nemesis/KuvaLich/KuvaLichManifestVersionSix": new KuvaLichManifestVersionSix(),
+ "/Lotus/Types/Enemies/Corpus/Lawyers/LawyerManifest": new LawyerManifest(),
+ "/Lotus/Types/Enemies/Corpus/Lawyers/LawyerManifestVersionTwo": new LawyerManifestVersionTwo(),
+ "/Lotus/Types/Enemies/Corpus/Lawyers/LawyerManifestVersionThree": new LawyerManifestVersionThree(),
+ "/Lotus/Types/Enemies/Corpus/Lawyers/LawyerManifestVersionFour": new LawyerManifestVersionFour(),
+ "/Lotus/Types/Enemies/InfestedLich/InfestedLichManifest": new InfestedLichManfest()
+};
+
+export const getNemesisManifest = (manifest: string): INemesisManifest => {
+ if (manifest in nemesisManifests) {
+ return nemesisManifests[manifest];
+ }
+ throw new Error(`unknown nemesis manifest: ${manifest}`);
+};
+
+export const getInfNodes = (manifest: INemesisManifest, rank: number): IInfNode[] => {
+ const infNodes = [];
+ const systemIndex = manifest.systemIndexes[rank];
+ for (const [key, value] of Object.entries(ExportRegions)) {
+ if (
+ value.systemIndex === systemIndex &&
+ value.nodeType != 3 && // not hub
+ value.nodeType != 7 && // not junction
+ value.missionIndex && // must have a mission type and not assassination
+ value.missionIndex != 28 && // not open world
+ value.missionIndex != 32 && // not railjack
+ value.missionIndex != 41 && // not saya's visions
+ value.missionIndex != 42 && // not face off
+ value.name.indexOf("1999NodeI") == -1 && // not stage defence
+ value.name.indexOf("1999NodeJ") == -1 && // not lich bounty
+ !isArchwingMission(value)
+ ) {
+ //console.log(dict_en[value.name]);
+ infNodes.push({ Node: key, Influence: 1 });
+ }
+ }
+ return infNodes;
+};
+
+// Get a parazon 'passcode' based on the nemesis fingerprint so it's always the same for the same nemesis.
+export const getNemesisPasscode = (nemesis: { fp: bigint; Faction: TNemesisFaction }): number[] => {
+ const rng = new SRng(nemesis.fp);
+ const choices = [0, 1, 2, 3, 5, 6, 7];
+ let choiceIndex = rng.randomInt(0, choices.length - 1);
+ const passcode = [choices[choiceIndex]];
+ if (nemesis.Faction != "FC_INFESTATION") {
+ choices.splice(choiceIndex, 1);
+ choiceIndex = rng.randomInt(0, choices.length - 1);
+ passcode.push(choices[choiceIndex]);
+
+ choices.splice(choiceIndex, 1);
+ choiceIndex = rng.randomInt(0, choices.length - 1);
+ passcode.push(choices[choiceIndex]);
+ }
+ return passcode;
+};
+
+/*const requiemMods: readonly string[] = [
+ "/Lotus/Upgrades/Mods/Immortal/ImmortalOneMod",
+ "/Lotus/Upgrades/Mods/Immortal/ImmortalTwoMod",
+ "/Lotus/Upgrades/Mods/Immortal/ImmortalThreeMod",
+ "/Lotus/Upgrades/Mods/Immortal/ImmortalFourMod",
+ "/Lotus/Upgrades/Mods/Immortal/ImmortalFiveMod",
+ "/Lotus/Upgrades/Mods/Immortal/ImmortalSixMod",
+ "/Lotus/Upgrades/Mods/Immortal/ImmortalSevenMod",
+ "/Lotus/Upgrades/Mods/Immortal/ImmortalEightMod"
+];*/
+
+export const antivirusMods: readonly string[] = [
+ "/Lotus/Upgrades/Mods/Immortal/AntivirusOneMod",
+ "/Lotus/Upgrades/Mods/Immortal/AntivirusTwoMod",
+ "/Lotus/Upgrades/Mods/Immortal/AntivirusThreeMod",
+ "/Lotus/Upgrades/Mods/Immortal/AntivirusFourMod",
+ "/Lotus/Upgrades/Mods/Immortal/AntivirusFiveMod",
+ "/Lotus/Upgrades/Mods/Immortal/AntivirusSixMod",
+ "/Lotus/Upgrades/Mods/Immortal/AntivirusSevenMod",
+ "/Lotus/Upgrades/Mods/Immortal/AntivirusEightMod"
+];
+
+/*export const getNemesisPasscodeModTypes = (nemesis: { fp: bigint; Faction: TNemesisFaction }): string[] => {
+ const passcode = getNemesisPasscode(nemesis);
+ return nemesis.Faction == "FC_INFESTATION"
+ ? passcode.map(i => antivirusMods[i])
+ : passcode.map(i => requiemMods[i]);
+};*/
+
+// Symbols; 0-7 are the normal requiem mods.
+export const GUESS_NONE = 8;
+export const GUESS_WILDCARD = 9;
+
+// Results; there are 3, 4, 5 as well which are more muted versions but unused afaik.
+export const GUESS_NEUTRAL = 0;
+export const GUESS_INCORRECT = 1;
+export const GUESS_CORRECT = 2;
+
+interface NemesisPositionGuess {
+ symbol: number;
+ result: number;
+}
+
+export type NemesisGuess = [NemesisPositionGuess, NemesisPositionGuess, NemesisPositionGuess];
+
+export const encodeNemesisGuess = (guess: NemesisGuess): number => {
+ return (
+ (guess[0].symbol & 0xf) |
+ ((guess[0].result & 3) << 12) |
+ ((guess[1].symbol << 4) & 0xff) |
+ ((guess[1].result << 14) & 0xffff) |
+ ((guess[2].symbol & 0xf) << 8) |
+ ((guess[2].result & 3) << 16)
+ );
+};
+
+export const decodeNemesisGuess = (val: number): NemesisGuess => {
+ return [
+ {
+ symbol: val & 0xf,
+ result: (val >> 12) & 3
+ },
+ {
+ symbol: (val & 0xff) >> 4,
+ result: (val & 0xffff) >> 14
+ },
+ {
+ symbol: (val >> 8) & 0xf,
+ result: (val >> 16) & 3
+ }
+ ];
+};
+
+export interface IKnifeResponse {
+ UpgradeIds?: string[];
+ UpgradeTypes?: string[];
+ UpgradeFingerprints?: { lvl: number }[];
+ UpgradeNew?: boolean[];
+ HasKnife?: boolean;
+}
+
+export const getKnifeUpgrade = (
+ inventory: TInventoryDatabaseDocument,
+ dataknifeUpgrades: string[],
+ type: string
+): { ItemId: IOid; ItemType: string } => {
+ if (dataknifeUpgrades.indexOf(type) != -1) {
+ return {
+ ItemId: { $oid: "000000000000000000000000" },
+ ItemType: type
+ };
+ }
+ for (const upgradeId of dataknifeUpgrades) {
+ if (upgradeId.length == 24) {
+ const upgrade = inventory.Upgrades.id(upgradeId);
+ if (upgrade && upgrade.ItemType == type) {
+ return {
+ ItemId: { $oid: upgradeId },
+ ItemType: type
+ };
+ }
+ }
+ }
+ throw new Error(`${type} does not seem to be installed on parazon?!`);
+};
+
+export const parseUpgrade = (
+ inventory: TInventoryDatabaseDocument,
+ str: string
+): { ItemId: IOid; ItemType: string } => {
+ if (str.length == 24) {
+ const upgrade = inventory.Upgrades.id(str);
+ if (upgrade) {
+ return {
+ ItemId: { $oid: str },
+ ItemType: upgrade.ItemType
+ };
+ }
+ throw new Error(`Could not resolve oid ${str}`);
+ } else {
+ return {
+ ItemId: { $oid: "000000000000000000000000" },
+ ItemType: str
+ };
+ }
+};
+
+export const getInnateDamageTag = (KillingSuit: string): TInnateDamageTag => {
+ return ExportWarframes[KillingSuit].nemesisUpgradeTag!;
+};
+
+const petHeads = [
+ "/Lotus/Types/Friendly/Pets/ZanukaPets/ZanukaPetParts/ZanukaPetPartHeadA",
+ "/Lotus/Types/Friendly/Pets/ZanukaPets/ZanukaPetParts/ZanukaPetPartHeadB",
+ "/Lotus/Types/Friendly/Pets/ZanukaPets/ZanukaPetParts/ZanukaPetPartHeadC"
+] as const;
+
+export interface INemesisProfile {
+ innateDamageTag: TInnateDamageTag;
+ innateDamageValue: number;
+ ephemera?: string;
+ petHead?: (typeof petHeads)[number];
+ petBody?: string;
+ petLegs?: string;
+ petTail?: string;
+}
+
+export const generateNemesisProfile = (
+ fp: bigint = generateRewardSeed(),
+ manifest: INemesisManifest = new LawyerManifest(),
+ killingSuit: string = "/Lotus/Powersuits/Ember/Ember"
+): INemesisProfile => {
+ const rng = new SRng(fp);
+ rng.randomFloat(); // used for the weapon index
+ const WeaponUpgradeValueAttenuationExponent = 2.25;
+ let value = Math.pow(rng.randomFloat(), WeaponUpgradeValueAttenuationExponent);
+ if (value >= 0.941428) {
+ value = 1;
+ }
+ const profile: INemesisProfile = {
+ innateDamageTag: getInnateDamageTag(killingSuit),
+ innateDamageValue: Math.trunc(value * 0x40000000) // TODO: For -1399275245665749231n, the value should be 75306944, but we're off by 59 with 75307003.
+ };
+ if (rng.randomFloat() <= manifest.ephemeraChance && manifest.ephemeraTypes) {
+ profile.ephemera = manifest.ephemeraTypes[profile.innateDamageTag];
+ }
+ rng.randomFloat(); // something related to sentinel agent maybe
+ if (manifest instanceof LawyerManifest) {
+ profile.petHead = rng.randomElement(petHeads)!;
+ profile.petBody = rng.randomElement([
+ "/Lotus/Types/Friendly/Pets/ZanukaPets/ZanukaPetParts/ZanukaPetPartBodyA",
+ "/Lotus/Types/Friendly/Pets/ZanukaPets/ZanukaPetParts/ZanukaPetPartBodyB",
+ "/Lotus/Types/Friendly/Pets/ZanukaPets/ZanukaPetParts/ZanukaPetPartBodyC"
+ ])!;
+ profile.petLegs = rng.randomElement([
+ "/Lotus/Types/Friendly/Pets/ZanukaPets/ZanukaPetParts/ZanukaPetPartLegsA",
+ "/Lotus/Types/Friendly/Pets/ZanukaPets/ZanukaPetParts/ZanukaPetPartLegsB",
+ "/Lotus/Types/Friendly/Pets/ZanukaPets/ZanukaPetParts/ZanukaPetPartLegsC"
+ ])!;
+ profile.petTail = rng.randomElement([
+ "/Lotus/Types/Friendly/Pets/ZanukaPets/ZanukaPetParts/ZanukaPetPartTailA",
+ "/Lotus/Types/Friendly/Pets/ZanukaPets/ZanukaPetParts/ZanukaPetPartTailB",
+ "/Lotus/Types/Friendly/Pets/ZanukaPets/ZanukaPetParts/ZanukaPetPartTailC"
+ ])!;
+ }
+ return profile;
+};
+
+export const getKillTokenRewardCount = (fp: bigint): number => {
+ const rng = new SRng(fp);
+ return rng.randomInt(10, 15);
+};
+
+// /Lotus/Types/Enemies/InfestedLich/InfestedLichRewardManifest
+const infestedLichRotA = [
+ { type: "/Lotus/StoreItems/Types/Items/ShipDecos/Plushies/PlushyDJRomHuman", probability: 0.046 },
+ { type: "/Lotus/StoreItems/Types/Items/ShipDecos/Plushies/PlushyDJRomInfested", probability: 0.045 },
+ { type: "/Lotus/StoreItems/Types/Items/ShipDecos/Plushies/PlushyDrillbitHuman", probability: 0.046 },
+ { type: "/Lotus/StoreItems/Types/Items/ShipDecos/Plushies/PlushyDrillbitInfested", probability: 0.045 },
+ { type: "/Lotus/StoreItems/Types/Items/ShipDecos/Plushies/PlushyHarddriveHuman", probability: 0.046 },
+ { type: "/Lotus/StoreItems/Types/Items/ShipDecos/Plushies/PlushyHarddriveInfested", probability: 0.045 },
+ { type: "/Lotus/StoreItems/Types/Items/ShipDecos/Plushies/PlushyPacketHuman", probability: 0.046 },
+ { type: "/Lotus/StoreItems/Types/Items/ShipDecos/Plushies/PlushyPacketInfested", probability: 0.045 },
+ { type: "/Lotus/StoreItems/Types/Items/ShipDecos/Plushies/PlushyZekeHuman", probability: 0.046 },
+ { type: "/Lotus/StoreItems/Types/Items/ShipDecos/Plushies/PlushyZekeInfested", probability: 0.045 },
+ { type: "/Lotus/StoreItems/Types/Items/ShipDecos/BoybandPosters/BoybandBillboardPosterA", probability: 0.045 },
+ { type: "/Lotus/StoreItems/Types/Items/ShipDecos/BoybandPosters/BoybandBillboardPosterB", probability: 0.046 },
+ { type: "/Lotus/StoreItems/Types/Items/ShipDecos/BoybandPosters/BoybandDespairPoster", probability: 0.045 },
+ { type: "/Lotus/StoreItems/Types/Items/ShipDecos/BoybandPosters/BoybandGridPoster", probability: 0.046 },
+ { type: "/Lotus/StoreItems/Types/Items/ShipDecos/BoybandPosters/BoybandHuddlePoster", probability: 0.045 },
+ { type: "/Lotus/StoreItems/Types/Items/ShipDecos/BoybandPosters/BoybandJumpPoster", probability: 0.046 },
+ { type: "/Lotus/StoreItems/Types/Items/ShipDecos/BoybandPosters/BoybandLimoPoster", probability: 0.045 },
+ { type: "/Lotus/StoreItems/Types/Items/ShipDecos/BoybandPosters/BoybandLookingDownPosterDay", probability: 0.046 },
+ {
+ type: "/Lotus/StoreItems/Types/Items/ShipDecos/BoybandPosters/BoybandLookingDownPosterNight",
+ probability: 0.045
+ },
+ { type: "/Lotus/StoreItems/Types/Items/ShipDecos/BoybandPosters/BoybandSillyPoster", probability: 0.046 },
+ { type: "/Lotus/StoreItems/Types/Items/ShipDecos/BoybandPosters/BoybandWhiteBluePoster", probability: 0.045 },
+ { type: "/Lotus/StoreItems/Types/Items/ShipDecos/BoybandPosters/BoybandWhitePinkPoster", probability: 0.045 }
+];
+const infestedLichRotB = [
+ { type: "/Lotus/StoreItems/Upgrades/Skins/Effects/InfestedLichEphemeraA", probability: 0.072 },
+ { type: "/Lotus/StoreItems/Upgrades/Skins/Effects/InfestedLichEphemeraB", probability: 0.071 },
+ { type: "/Lotus/StoreItems/Upgrades/Skins/Effects/InfestedLichEphemeraC", probability: 0.072 },
+ { type: "/Lotus/StoreItems/Upgrades/Skins/Effects/InfestedLichEphemeraD", probability: 0.071 },
+ { type: "/Lotus/StoreItems/Upgrades/Skins/Effects/InfestedLichEphemeraE", probability: 0.072 },
+ { type: "/Lotus/StoreItems/Upgrades/Skins/Effects/InfestedLichEphemeraF", probability: 0.071 },
+ { type: "/Lotus/StoreItems/Upgrades/Skins/Effects/InfestedLichEphemeraG", probability: 0.071 },
+ { type: "/Lotus/StoreItems/Upgrades/Skins/Effects/InfestedLichEphemeraH", probability: 0.072 },
+ { type: "/Lotus/StoreItems/Types/Items/Emotes/DanceDJRomHype", probability: 0.071 },
+ { type: "/Lotus/StoreItems/Types/Items/Emotes/DancePacketWindmillShuffle", probability: 0.072 },
+ { type: "/Lotus/StoreItems/Types/Items/Emotes/DanceHarddrivePony", probability: 0.071 },
+ { type: "/Lotus/StoreItems/Types/Items/Emotes/DanceDrillbitCrisscross", probability: 0.072 },
+ { type: "/Lotus/StoreItems/Types/Items/Emotes/DanceZekeCanthavethis", probability: 0.071 },
+ { type: "/Lotus/StoreItems/Types/Items/PhotoBooth/PhotoboothTileRJLasXStadiumBossArena", probability: 0.071 }
+];
+export const getInfestedLichItemRewards = (fp: bigint): string[] => {
+ const rng = new SRng(fp);
+ const rotAReward = getRewardAtPercentage(infestedLichRotA, rng.randomFloat())!.type;
+ rng.randomFloat(); // unused afaict
+ const rotBReward = getRewardAtPercentage(infestedLichRotB, rng.randomFloat())!.type;
+ return [rotAReward, rotBReward];
+};
diff --git a/src/helpers/pathHelper.ts b/src/helpers/pathHelper.ts
new file mode 100644
index 00000000..69b0c373
--- /dev/null
+++ b/src/helpers/pathHelper.ts
@@ -0,0 +1,4 @@
+import path from "path";
+
+export const rootDir = path.join(import.meta.dirname, "../..");
+export const repoDir = path.basename(rootDir) != "build" ? rootDir : path.join(rootDir, "..");
diff --git a/src/helpers/purchaseHelpers.ts b/src/helpers/purchaseHelpers.ts
new file mode 100644
index 00000000..3e5c0783
--- /dev/null
+++ b/src/helpers/purchaseHelpers.ts
@@ -0,0 +1,23 @@
+import type { SlotPurchase, SlotPurchaseName } from "../types/purchaseTypes.ts";
+
+export const slotPurchaseNameToSlotName: SlotPurchase = {
+ SuitSlotItem: { name: "SuitBin", purchaseQuantity: 1 },
+ TwoSentinelSlotItem: { name: "SentinelBin", purchaseQuantity: 2 },
+ TwoWeaponSlotItem: { name: "WeaponBin", purchaseQuantity: 2 },
+ SpaceSuitSlotItem: { name: "SpaceSuitBin", purchaseQuantity: 1 },
+ TwoSpaceWeaponSlotItem: { name: "SpaceWeaponBin", purchaseQuantity: 2 },
+ MechSlotItem: { name: "MechBin", purchaseQuantity: 1 },
+ TwoOperatorWeaponSlotItem: { name: "OperatorAmpBin", purchaseQuantity: 2 },
+ RandomModSlotItem: { name: "RandomModBin", purchaseQuantity: 3 },
+ TwoCrewShipSalvageSlotItem: { name: "CrewShipSalvageBin", purchaseQuantity: 2 },
+ CrewMemberSlotItem: { name: "CrewMemberBin", purchaseQuantity: 1 }
+};
+
+export const isSlotPurchaseName = (slotPurchaseName: string): slotPurchaseName is SlotPurchaseName => {
+ return slotPurchaseName in slotPurchaseNameToSlotName;
+};
+
+export const parseSlotPurchaseName = (slotPurchaseName: string): SlotPurchaseName => {
+ if (!isSlotPurchaseName(slotPurchaseName)) throw new Error(`invalid slot name ${slotPurchaseName}`);
+ return slotPurchaseName;
+};
diff --git a/src/helpers/relicHelper.ts b/src/helpers/relicHelper.ts
new file mode 100644
index 00000000..964e43e2
--- /dev/null
+++ b/src/helpers/relicHelper.ts
@@ -0,0 +1,85 @@
+import type { TInventoryDatabaseDocument } from "../models/inventoryModels/inventoryModel.ts";
+import type { IVoidTearParticipantInfo } from "../types/requestTypes.ts";
+import type { TRarity } from "warframe-public-export-plus";
+import { ExportRelics, ExportRewards } from "warframe-public-export-plus";
+import type { IRngResult } from "../services/rngService.ts";
+import { getRandomWeightedReward } from "../services/rngService.ts";
+import { logger } from "../utils/logger.ts";
+import { addMiscItems, combineInventoryChanges } from "../services/inventoryService.ts";
+import { handleStoreItemAcquisition } from "../services/purchaseService.ts";
+import type { IInventoryChanges } from "../types/purchaseTypes.ts";
+import { config } from "../services/configService.ts";
+
+export const crackRelic = async (
+ inventory: TInventoryDatabaseDocument,
+ participant: IVoidTearParticipantInfo,
+ inventoryChanges: IInventoryChanges = {}
+): Promise => {
+ const relic = ExportRelics[participant.VoidProjection];
+ let weights = refinementToWeights[relic.quality];
+ if (relic.quality == "VPQ_SILVER" && inventory.exceptionalRelicsAlwaysGiveBronzeReward) {
+ weights = { COMMON: 1, UNCOMMON: 0, RARE: 0, LEGENDARY: 0 };
+ } else if (relic.quality == "VPQ_GOLD" && inventory.flawlessRelicsAlwaysGiveSilverReward) {
+ weights = { COMMON: 0, UNCOMMON: 1, RARE: 0, LEGENDARY: 0 };
+ } else if (relic.quality == "VPQ_PLATINUM" && inventory.radiantRelicsAlwaysGiveGoldReward) {
+ weights = { COMMON: 0, UNCOMMON: 0, RARE: 1, LEGENDARY: 0 };
+ }
+ logger.debug(`opening a relic of quality ${relic.quality}; rarity weights are`, weights);
+ let reward = getRandomWeightedReward(
+ ExportRewards[relic.rewardManifest][0] as { type: string; itemCount: number; rarity: TRarity }[], // rarity is nullable in PE+ typings, but always present for relics
+ weights
+ )!;
+ if (config.relicRewardItemCountMultiplier !== undefined && (config.relicRewardItemCountMultiplier ?? 1) != 1) {
+ reward = {
+ ...reward,
+ itemCount: reward.itemCount * config.relicRewardItemCountMultiplier
+ };
+ }
+ logger.debug(`relic rolled`, reward);
+ participant.Reward = reward.type;
+
+ // Remove relic
+ const miscItemChanges = [
+ {
+ ItemType: participant.VoidProjection,
+ ItemCount: -1
+ }
+ ];
+ addMiscItems(inventory, miscItemChanges);
+ combineInventoryChanges(inventoryChanges, { MiscItems: miscItemChanges });
+
+ // Give reward
+ combineInventoryChanges(
+ inventoryChanges,
+ (await handleStoreItemAcquisition(reward.type, inventory, reward.itemCount)).InventoryChanges
+ );
+
+ return reward;
+};
+
+const refinementToWeights = {
+ VPQ_BRONZE: {
+ COMMON: 0.76,
+ UNCOMMON: 0.22,
+ RARE: 0.02,
+ LEGENDARY: 0
+ },
+ VPQ_SILVER: {
+ COMMON: 0.7,
+ UNCOMMON: 0.26,
+ RARE: 0.04,
+ LEGENDARY: 0
+ },
+ VPQ_GOLD: {
+ COMMON: 0.6,
+ UNCOMMON: 0.34,
+ RARE: 0.06,
+ LEGENDARY: 0
+ },
+ VPQ_PLATINUM: {
+ COMMON: 0.5,
+ UNCOMMON: 0.4,
+ RARE: 0.1,
+ LEGENDARY: 0
+ }
+};
diff --git a/src/helpers/rivenHelper.ts b/src/helpers/rivenHelper.ts
new file mode 100644
index 00000000..61f10ccd
--- /dev/null
+++ b/src/helpers/rivenHelper.ts
@@ -0,0 +1,121 @@
+import type { IUpgrade } from "warframe-public-export-plus";
+import { getRandomElement, getRandomInt, getRandomReward } from "../services/rngService.ts";
+
+export type RivenFingerprint = IVeiledRivenFingerprint | IUnveiledRivenFingerprint;
+
+export interface IVeiledRivenFingerprint {
+ challenge: IRivenChallenge;
+}
+
+export interface IRivenChallenge {
+ Type: string;
+ Progress: number;
+ Required: number;
+ Complication?: string;
+}
+
+export interface IUnveiledRivenFingerprint {
+ compat: string;
+ lim: 0;
+ lvl: number;
+ lvlReq: number;
+ rerolls?: number;
+ pol: string;
+ buffs: IFingerprintStat[];
+ curses: IFingerprintStat[];
+}
+
+export interface IFingerprintStat {
+ Tag: string;
+ Value: number;
+}
+
+export const createVeiledRivenFingerprint = (meta: IUpgrade): IVeiledRivenFingerprint => {
+ const challenge = getRandomElement(meta.availableChallenges!)!;
+ const fingerprintChallenge: IRivenChallenge = {
+ Type: challenge.fullName,
+ Progress: 0,
+ Required: getRandomInt(challenge.countRange[0], challenge.countRange[1])
+ };
+ if (Math.random() < challenge.complicationChance) {
+ const complications: { type: string; probability: number }[] = [];
+ for (const complication of challenge.complications) {
+ complications.push({
+ type: complication.fullName,
+ probability: complication.weight
+ });
+ }
+ fingerprintChallenge.Complication = getRandomReward(complications)!.type;
+ const complication = challenge.complications.find(x => x.fullName == fingerprintChallenge.Complication)!;
+ fingerprintChallenge.Required *= complication.countMultiplier;
+ }
+ return { challenge: fingerprintChallenge };
+};
+
+export const createUnveiledRivenFingerprint = (meta: IUpgrade): IUnveiledRivenFingerprint => {
+ const fingerprint: IUnveiledRivenFingerprint = {
+ compat: getRandomElement(meta.compatibleItems!)!,
+ lim: 0,
+ lvl: 0,
+ lvlReq: getRandomInt(8, 16),
+ pol: getRandomElement(["AP_ATTACK", "AP_DEFENSE", "AP_TACTIC"])!,
+ buffs: [],
+ curses: []
+ };
+ randomiseRivenStats(meta, fingerprint);
+ return fingerprint;
+};
+
+export const randomiseRivenStats = (meta: IUpgrade, fingerprint: IUnveiledRivenFingerprint): void => {
+ fingerprint.buffs = [];
+ const numBuffs = 2 + Math.trunc(Math.random() * 2); // 2 or 3
+ const buffEntries = meta.upgradeEntries!.filter(x => x.canBeBuff);
+ for (let i = 0; i != numBuffs; ++i) {
+ const buffIndex = Math.trunc(Math.random() * buffEntries.length);
+ const entry = buffEntries[buffIndex];
+ fingerprint.buffs.push({ Tag: entry.tag, Value: Math.trunc(Math.random() * 0x40000000) });
+ buffEntries.splice(buffIndex, 1);
+ }
+
+ fingerprint.curses = [];
+ if (Math.random() < 0.5) {
+ const entry = getRandomElement(
+ meta.upgradeEntries!.filter(x => x.canBeCurse && !fingerprint.buffs.find(y => y.Tag == x.tag))
+ )!;
+ fingerprint.curses.push({ Tag: entry.tag, Value: Math.trunc(Math.random() * 0x40000000) });
+ }
+};
+
+export const rivenRawToRealWeighted: Record = {
+ "/Lotus/Upgrades/Mods/Randomized/RawArchgunRandomMod": [
+ "/Lotus/Upgrades/Mods/Randomized/LotusArchgunRandomModRare"
+ ],
+ "/Lotus/Upgrades/Mods/Randomized/RawMeleeRandomMod": [
+ "/Lotus/Upgrades/Mods/Randomized/PlayerMeleeWeaponRandomModRare"
+ ],
+ "/Lotus/Upgrades/Mods/Randomized/RawModularMeleeRandomMod": [
+ "/Lotus/Upgrades/Mods/Randomized/LotusModularMeleeRandomModRare"
+ ],
+ "/Lotus/Upgrades/Mods/Randomized/RawModularPistolRandomMod": [
+ "/Lotus/Upgrades/Mods/Randomized/LotusModularPistolRandomModRare"
+ ],
+ "/Lotus/Upgrades/Mods/Randomized/RawPistolRandomMod": ["/Lotus/Upgrades/Mods/Randomized/LotusPistolRandomModRare"],
+ "/Lotus/Upgrades/Mods/Randomized/RawRifleRandomMod": ["/Lotus/Upgrades/Mods/Randomized/LotusRifleRandomModRare"],
+ "/Lotus/Upgrades/Mods/Randomized/RawShotgunRandomMod": [
+ "/Lotus/Upgrades/Mods/Randomized/LotusShotgunRandomModRare"
+ ],
+ "/Lotus/Upgrades/Mods/Randomized/RawSentinelWeaponRandomMod": [
+ "/Lotus/Upgrades/Mods/Randomized/LotusRifleRandomModRare",
+ "/Lotus/Upgrades/Mods/Randomized/LotusRifleRandomModRare",
+ "/Lotus/Upgrades/Mods/Randomized/LotusRifleRandomModRare",
+ "/Lotus/Upgrades/Mods/Randomized/LotusRifleRandomModRare",
+ "/Lotus/Upgrades/Mods/Randomized/LotusRifleRandomModRare",
+ "/Lotus/Upgrades/Mods/Randomized/LotusRifleRandomModRare",
+ "/Lotus/Upgrades/Mods/Randomized/LotusRifleRandomModRare",
+ "/Lotus/Upgrades/Mods/Randomized/LotusRifleRandomModRare",
+ "/Lotus/Upgrades/Mods/Randomized/LotusRifleRandomModRare",
+ "/Lotus/Upgrades/Mods/Randomized/LotusShotgunRandomModRare",
+ "/Lotus/Upgrades/Mods/Randomized/LotusPistolRandomModRare",
+ "/Lotus/Upgrades/Mods/Randomized/PlayerMeleeWeaponRandomModRare"
+ ]
+};
diff --git a/src/helpers/shardHelper.ts b/src/helpers/shardHelper.ts
new file mode 100644
index 00000000..0971f7b7
--- /dev/null
+++ b/src/helpers/shardHelper.ts
@@ -0,0 +1,66 @@
+export const colorToShard: Record = {
+ ACC_RED: "/Lotus/Types/Gameplay/NarmerSorties/ArchonCrystalAmar",
+ ACC_RED_MYTHIC: "/Lotus/Types/Gameplay/NarmerSorties/ArchonCrystalAmarMythic",
+ ACC_YELLOW: "/Lotus/Types/Gameplay/NarmerSorties/ArchonCrystalNira",
+ ACC_YELLOW_MYTHIC: "/Lotus/Types/Gameplay/NarmerSorties/ArchonCrystalNiraMythic",
+ ACC_BLUE: "/Lotus/Types/Gameplay/NarmerSorties/ArchonCrystalBoreal",
+ ACC_BLUE_MYTHIC: "/Lotus/Types/Gameplay/NarmerSorties/ArchonCrystalBorealMythic",
+ ACC_GREEN: "/Lotus/Types/Gameplay/NarmerSorties/ArchonCrystalGreen",
+ ACC_GREEN_MYTHIC: "/Lotus/Types/Gameplay/NarmerSorties/ArchonCrystalGreenMythic",
+ ACC_ORANGE: "/Lotus/Types/Gameplay/NarmerSorties/ArchonCrystalOrange",
+ ACC_ORANGE_MYTHIC: "/Lotus/Types/Gameplay/NarmerSorties/ArchonCrystalOrangeMythic",
+ ACC_PURPLE: "/Lotus/Types/Gameplay/NarmerSorties/ArchonCrystalViolet",
+ ACC_PURPLE_MYTHIC: "/Lotus/Types/Gameplay/NarmerSorties/ArchonCrystalVioletMythic"
+};
+
+export const shardToColor: Record = {
+ "/Lotus/Types/Gameplay/NarmerSorties/ArchonCrystalAmar": "ACC_RED",
+ "/Lotus/Types/Gameplay/NarmerSorties/ArchonCrystalAmarMythic": "ACC_RED_MYTHIC",
+ "/Lotus/Types/Gameplay/NarmerSorties/ArchonCrystalNira": "ACC_YELLOW",
+ "/Lotus/Types/Gameplay/NarmerSorties/ArchonCrystalNiraMythic": "ACC_YELLOW_MYTHIC",
+ "/Lotus/Types/Gameplay/NarmerSorties/ArchonCrystalBoreal": "ACC_BLUE",
+ "/Lotus/Types/Gameplay/NarmerSorties/ArchonCrystalBorealMythic": "ACC_BLUE_MYTHIC",
+ "/Lotus/Types/Gameplay/NarmerSorties/ArchonCrystalGreen": "ACC_GREEN",
+ "/Lotus/Types/Gameplay/NarmerSorties/ArchonCrystalGreenMythic": "ACC_GREEN_MYTHIC",
+ "/Lotus/Types/Gameplay/NarmerSorties/ArchonCrystalOrange": "ACC_ORANGE",
+ "/Lotus/Types/Gameplay/NarmerSorties/ArchonCrystalOrangeMythic": "ACC_ORANGE_MYTHIC",
+ "/Lotus/Types/Gameplay/NarmerSorties/ArchonCrystalViolet": "ACC_PURPLE",
+ "/Lotus/Types/Gameplay/NarmerSorties/ArchonCrystalVioletMythic": "ACC_PURPLE_MYTHIC"
+};
+
+export const combineColors = (a: string, b: string): string => {
+ return (
+ combinePlainColors(a.replace("_MYTHIC", ""), b.replace("_MYTHIC", "")) +
+ (a.indexOf("_MYTHIC") != -1 ? "_MYTHIC" : "")
+ );
+};
+
+const combinePlainColors = (a: string, b: string): string => {
+ switch (a) {
+ case "ACC_RED":
+ switch (b) {
+ case "ACC_YELLOW":
+ return "ACC_ORANGE";
+ case "ACC_BLUE":
+ return "ACC_PURPLE";
+ }
+ break;
+ case "ACC_YELLOW":
+ switch (b) {
+ case "ACC_RED":
+ return "ACC_ORANGE";
+ case "ACC_BLUE":
+ return "ACC_GREEN";
+ }
+ break;
+ case "ACC_BLUE":
+ switch (b) {
+ case "ACC_RED":
+ return "ACC_PURPLE";
+ case "ACC_YELLOW":
+ return "ACC_GREEN";
+ }
+ break;
+ }
+ throw new Error(`invalid color combination request: ${a} and ${b}`);
+};
diff --git a/src/helpers/stringHelpers.ts b/src/helpers/stringHelpers.ts
new file mode 100644
index 00000000..3512c1ea
--- /dev/null
+++ b/src/helpers/stringHelpers.ts
@@ -0,0 +1,62 @@
+import { JSONParse } from "json-with-bigint";
+
+export const getJSONfromString = (str: string): T => {
+ const jsonSubstring = str.substring(0, str.lastIndexOf("}") + 1);
+ return JSONParse(jsonSubstring) as T;
+};
+
+export const getSubstringFromKeyword = (str: string, keyword: string): string => {
+ const index = str.indexOf(keyword);
+ if (index == -1) {
+ throw new Error(`keyword ${keyword} not found in string ${str}`);
+ }
+ return str.substring(index);
+};
+
+export const getSubstringFromKeywordToKeyword = (str: string, keywordBegin: string, keywordEnd: string): string => {
+ const beginIndex = str.lastIndexOf(keywordBegin) + 1;
+ const endIndex = str.indexOf(keywordEnd);
+ return str.substring(beginIndex, endIndex + 1);
+};
+
+export const getIndexAfter = (str: string, searchWord: string): number => {
+ const index = str.indexOf(searchWord);
+ if (index === -1) {
+ return -1;
+ }
+ return index + searchWord.length;
+};
+
+// This is FNV1a-32 except operating under modulus 2^31 because JavaScript is stinky and likes producing negative integers out of nowhere.
+export const catBreadHash = (name: string): number => {
+ let hash = 2166136261;
+ for (let i = 0; i != name.length; ++i) {
+ hash = (hash ^ name.charCodeAt(i)) & 0x7fffffff;
+ hash = (hash * 16777619) & 0x7fffffff;
+ }
+ return hash;
+};
+
+export const regexEscape = (str: string): string => {
+ str = str.split(".").join("\\.");
+ str = str.split("\\").join("\\\\");
+ str = str.split("[").join("\\[");
+ str = str.split("]").join("\\]");
+ str = str.split("+").join("\\+");
+ str = str.split("*").join("\\*");
+ str = str.split("$").join("\\$");
+ str = str.split("^").join("\\^");
+ str = str.split("?").join("\\?");
+ str = str.split("|").join("\\|");
+ str = str.split("(").join("\\(");
+ str = str.split(")").join("\\)");
+ str = str.split("{").join("\\{");
+ str = str.split("}").join("\\}");
+ return str;
+};
+
+export const addString = (arr: string[], str: string): void => {
+ if (arr.indexOf(str) == -1) {
+ arr.push(str);
+ }
+};
diff --git a/src/helpers/syndicateStandingHelper.ts b/src/helpers/syndicateStandingHelper.ts
new file mode 100644
index 00000000..ec9c939e
--- /dev/null
+++ b/src/helpers/syndicateStandingHelper.ts
@@ -0,0 +1,23 @@
+import type { ISyndicate } from "warframe-public-export-plus";
+
+export const getMaxStanding = (syndicate: ISyndicate, title: number): number => {
+ if (!syndicate.titles) {
+ // LibrarySyndicate
+ return 125000;
+ }
+ if (title == 0) {
+ return syndicate.titles.find(x => x.level == 1)!.minStanding;
+ }
+ return syndicate.titles.find(x => x.level == title)!.maxStanding;
+};
+
+export const getMinStanding = (syndicate: ISyndicate, title: number): number => {
+ if (!syndicate.titles) {
+ // LibrarySyndicate
+ return 0;
+ }
+ if (title == 0) {
+ return syndicate.titles.find(x => x.level == -1)!.maxStanding;
+ }
+ return syndicate.titles.find(x => x.level == title)!.minStanding;
+};
diff --git a/src/index.ts b/src/index.ts
new file mode 100644
index 00000000..3021b14b
--- /dev/null
+++ b/src/index.ts
@@ -0,0 +1,56 @@
+// First, init config.
+import { config, configPath, loadConfig, syncConfigWithDatabase } from "./services/configService.ts";
+import fs from "fs";
+try {
+ loadConfig();
+} catch (e) {
+ if (fs.existsSync("config.json")) {
+ console.log("Failed to load " + configPath + ": " + (e as Error).message);
+ } else {
+ console.log("Failed to load " + configPath + ". You can copy config-vanilla.json to create your config file.");
+ }
+ process.exit(1);
+}
+
+// Now we can init the logger with the settings provided in the config.
+import { logger } from "./utils/logger.ts";
+logger.info("Starting up...");
+
+// Proceed with normal startup: bring up config watcher service, validate config, connect to MongoDB, and finally start listening for HTTP.
+import mongoose from "mongoose";
+import path from "path";
+import { JSONStringify } from "json-with-bigint";
+import { startWebServer } from "./services/webService.ts";
+import { validateConfig } from "./services/configWatcherService.ts";
+import { updateWorldStateCollections } from "./services/worldStateService.ts";
+import { repoDir } from "./helpers/pathHelper.ts";
+
+JSON.stringify = JSONStringify; // Patch JSON.stringify to work flawlessly with Bigints.
+
+validateConfig();
+
+fs.readFile(path.join(repoDir, "BUILD_DATE"), "utf-8", (err, data) => {
+ if (!err) {
+ logger.info(`Docker image was built on ${data.trim()}`);
+ }
+});
+
+mongoose
+ .connect(config.mongodbUrl)
+ .then(() => {
+ logger.info("Connected to MongoDB");
+ syncConfigWithDatabase();
+
+ startWebServer();
+
+ void updateWorldStateCollections();
+ setInterval(() => {
+ void updateWorldStateCollections();
+ }, 60_000);
+ })
+ .catch(error => {
+ if (error instanceof Error) {
+ logger.error(`Error connecting to MongoDB server: ${error.message}`);
+ }
+ process.exit(1);
+ });
diff --git a/src/managers/sessionManager.ts b/src/managers/sessionManager.ts
new file mode 100644
index 00000000..a6bbee08
--- /dev/null
+++ b/src/managers/sessionManager.ts
@@ -0,0 +1,119 @@
+import type { ISession, IFindSessionRequest } from "../types/session.ts";
+import { logger } from "../utils/logger.ts";
+import { JSONParse } from "json-with-bigint";
+import { Types } from "mongoose";
+
+const sessions: ISession[] = [];
+
+function createNewSession(sessionData: ISession, Creator: Types.ObjectId): ISession {
+ const sessionId = new Types.ObjectId();
+ const newSession: ISession = {
+ sessionId,
+ creatorId: Creator,
+ maxPlayers: sessionData.maxPlayers || 4,
+ minPlayers: sessionData.minPlayers || 1,
+ privateSlots: sessionData.privateSlots || 0,
+ scoreLimit: sessionData.scoreLimit || 15,
+ timeLimit: sessionData.timeLimit || 900,
+ gameModeId: sessionData.gameModeId || 0,
+ eloRating: sessionData.eloRating || 3,
+ regionId: sessionData.regionId || 3,
+ difficulty: sessionData.difficulty || 0,
+ hasStarted: sessionData.hasStarted || false,
+ enableVoice: sessionData.enableVoice || true,
+ matchType: sessionData.matchType || "NORMAL",
+ maps: sessionData.maps || [],
+ originalSessionId: sessionData.originalSessionId || "",
+ customSettings: sessionData.customSettings || "",
+ rewardSeed: sessionData.rewardSeed || -1,
+ guildId: sessionData.guildId || "",
+ buildId: sessionData.buildId || 4920386201513015989n,
+ platform: sessionData.platform || 0,
+ xplatform: sessionData.xplatform || true,
+ freePublic: sessionData.freePublic || 3,
+ freePrivate: sessionData.freePrivate || 0,
+ fullReset: 0
+ };
+ sessions.push(newSession);
+ return newSession;
+}
+
+function getAllSessions(): ISession[] {
+ return sessions;
+}
+
+function getSessionByID(sessionId: string | Types.ObjectId): ISession | undefined {
+ return sessions.find(session => session.sessionId.equals(sessionId));
+}
+
+function getSession(
+ sessionIdOrRequest: string | Types.ObjectId | IFindSessionRequest
+): { createdBy: Types.ObjectId; id: Types.ObjectId }[] {
+ if (typeof sessionIdOrRequest === "string" || sessionIdOrRequest instanceof Types.ObjectId) {
+ const session = sessions.find(session => session.sessionId.equals(sessionIdOrRequest));
+ if (session) {
+ logger.debug("Found Sessions:", { session });
+ return [
+ {
+ createdBy: session.creatorId,
+ id: session.sessionId
+ }
+ ];
+ }
+ return [];
+ }
+
+ const request = sessionIdOrRequest;
+ const matchingSessions = sessions.filter(session => {
+ for (const key in request) {
+ if (
+ key !== "eloRating" &&
+ key !== "queryId" &&
+ request[key as keyof IFindSessionRequest] !== session[key as keyof ISession]
+ ) {
+ return false;
+ }
+ }
+ logger.debug("Found Matching Sessions:", { matchingSessions });
+ return true;
+ });
+ return matchingSessions.map(session => ({
+ createdBy: session.creatorId,
+ id: session.sessionId
+ }));
+}
+
+function getSessionByCreatorID(creatorId: string | Types.ObjectId): ISession | undefined {
+ return sessions.find(session => session.creatorId.equals(creatorId));
+}
+
+function updateSession(sessionId: string | Types.ObjectId, sessionData: string): boolean {
+ const session = sessions.find(session => session.sessionId.equals(sessionId));
+ if (!session) return false;
+ try {
+ Object.assign(session, JSONParse(sessionData));
+ return true;
+ } catch (error) {
+ console.error("Invalid JSON string for session update.");
+ return false;
+ }
+}
+
+function deleteSession(sessionId: string | Types.ObjectId): boolean {
+ const index = sessions.findIndex(session => session.sessionId.equals(sessionId));
+ if (index !== -1) {
+ sessions.splice(index, 1);
+ return true;
+ }
+ return false;
+}
+
+export {
+ createNewSession,
+ getAllSessions,
+ getSessionByID,
+ getSessionByCreatorID,
+ updateSession,
+ deleteSession,
+ getSession
+};
diff --git a/src/middleware/errorHandler.ts b/src/middleware/errorHandler.ts
new file mode 100644
index 00000000..8556e903
--- /dev/null
+++ b/src/middleware/errorHandler.ts
@@ -0,0 +1,11 @@
+import type { NextFunction, Request, Response } from "express";
+import { logError } from "../utils/logger.ts";
+
+export const errorHandler = (err: Error, req: Request, res: Response, _next: NextFunction): void => {
+ if (err.message == "Invalid accountId-nonce pair") {
+ res.status(400).send("Log-in expired");
+ } else {
+ logError(err, `processing ${req.path} request`);
+ res.status(500).end();
+ }
+};
diff --git a/src/middleware/middleware.ts b/src/middleware/middleware.ts
new file mode 100644
index 00000000..2b67c3bb
--- /dev/null
+++ b/src/middleware/middleware.ts
@@ -0,0 +1,20 @@
+import { logger } from "../utils/logger.ts";
+import type { /*NextFunction,*/ Request, Response } from "express";
+
+const unknownEndpointHandler = (request: Request, response: Response): void => {
+ logger.error(`unknown endpoint ${request.method} ${request.path}`);
+ if (request.body) {
+ logger.debug(`data provided to ${request.path}: ${String(request.body)}`);
+ }
+ response.status(404).json({ error: "endpoint was not found" });
+};
+
+// const requestLogger = (request: Request, _response: Response, next: NextFunction) => {
+// console.log("Method:", request.method);
+// console.log("Path: ", request.path);
+// console.log("Body: ", request.body);
+// console.log("---");
+// next();
+// };
+
+export { unknownEndpointHandler };
diff --git a/src/middleware/morgenMiddleware.ts b/src/middleware/morgenMiddleware.ts
new file mode 100644
index 00000000..2888d77c
--- /dev/null
+++ b/src/middleware/morgenMiddleware.ts
@@ -0,0 +1,6 @@
+import morgan from "morgan";
+import { logger } from "../utils/logger.ts";
+
+export const requestLogger = morgan("dev", {
+ stream: { write: message => logger.http(message.trim()) }
+});
diff --git a/src/models/commonModel.ts b/src/models/commonModel.ts
new file mode 100644
index 00000000..94dd8016
--- /dev/null
+++ b/src/models/commonModel.ts
@@ -0,0 +1,25 @@
+import { Schema } from "mongoose";
+import type { IColor, IShipCustomization } from "../types/inventoryTypes/commonInventoryTypes.ts";
+
+export const colorSchema = new Schema(
+ {
+ t0: Number,
+ t1: Number,
+ t2: Number,
+ t3: Number,
+ en: Number,
+ e1: Number,
+ m0: Number,
+ m1: Number
+ },
+ { _id: false }
+);
+
+export const shipCustomizationSchema = new Schema(
+ {
+ SkinFlavourItem: String,
+ Colors: colorSchema,
+ ShipAttachments: { HOOD_ORNAMENT: String }
+ },
+ { _id: false }
+);
diff --git a/src/models/friendModel.ts b/src/models/friendModel.ts
new file mode 100644
index 00000000..a40affd1
--- /dev/null
+++ b/src/models/friendModel.ts
@@ -0,0 +1,15 @@
+import type { IFriendship } from "../types/friendTypes.ts";
+import { model, Schema } from "mongoose";
+
+const friendshipSchema = new Schema({
+ owner: { type: Schema.Types.ObjectId, required: true },
+ friend: { type: Schema.Types.ObjectId, required: true },
+ Note: String,
+ Favorite: Boolean
+});
+
+friendshipSchema.index({ owner: 1 });
+friendshipSchema.index({ friend: 1 });
+friendshipSchema.index({ owner: 1, friend: 1 }, { unique: true });
+
+export const Friendship = model("Friendship", friendshipSchema);
diff --git a/src/models/guildModel.ts b/src/models/guildModel.ts
new file mode 100644
index 00000000..e7d53297
--- /dev/null
+++ b/src/models/guildModel.ts
@@ -0,0 +1,323 @@
+import type {
+ IGuildDatabase,
+ IDojoComponentDatabase,
+ ITechProjectDatabase,
+ IDojoDecoDatabase,
+ ILongMOTD,
+ IGuildMemberDatabase,
+ IGuildLogEntryNumber,
+ IGuildRank,
+ IGuildLogRoomChange,
+ IGuildLogEntryRoster,
+ IGuildLogEntryContributable,
+ IDojoLeaderboardEntry,
+ IGuildAdDatabase,
+ IAllianceDatabase,
+ IAllianceMemberDatabase
+} from "../types/guildTypes.ts";
+import { GuildPermission } from "../types/guildTypes.ts";
+import type { Document, Model } from "mongoose";
+import { model, Schema, Types } from "mongoose";
+import { fusionTreasuresSchema, typeCountSchema } from "./inventoryModels/inventoryModel.ts";
+import { pictureFrameInfoSchema } from "./personalRoomsModel.ts";
+import type { IGoalProgressClient, IGoalProgressDatabase } from "../types/inventoryTypes/inventoryTypes.ts";
+import { toOid } from "../helpers/inventoryHelpers.ts";
+
+const dojoDecoSchema = new Schema({
+ Type: String,
+ Pos: [Number],
+ Rot: [Number],
+ Scale: Number,
+ Name: String,
+ Sockets: Number,
+ RegularCredits: Number,
+ MiscItems: { type: [typeCountSchema], default: undefined },
+ CompletionTime: Date,
+ RushPlatinum: Number,
+ PictureFrameInfo: pictureFrameInfoSchema,
+ Pending: Boolean
+});
+
+const dojoLeaderboardEntrySchema = new Schema(
+ {
+ s: Number,
+ r: Number,
+ n: String
+ },
+ { _id: false }
+);
+
+const dojoComponentSchema = new Schema({
+ SortId: Schema.Types.ObjectId,
+ pf: { type: String, required: true },
+ ppf: String,
+ pi: Schema.Types.ObjectId,
+ op: String,
+ pp: String,
+ Name: String,
+ Message: String,
+ RegularCredits: Number,
+ MiscItems: { type: [typeCountSchema], default: undefined },
+ CompletionTime: Date,
+ CompletionLogPending: Boolean,
+ RushPlatinum: Number,
+ DestructionTime: Date,
+ Decos: [dojoDecoSchema],
+ DecoCapacity: Number,
+ PaintBot: Schema.Types.ObjectId,
+ PendingColors: { type: [Number], default: undefined },
+ Colors: { type: [Number], default: undefined },
+ PendingLights: { type: [Number], default: undefined },
+ Lights: { type: [Number], default: undefined },
+ Settings: String,
+ Leaderboard: { type: [dojoLeaderboardEntrySchema], default: undefined }
+});
+
+const techProjectSchema = new Schema(
+ {
+ ItemType: String,
+ ReqCredits: Number,
+ ReqItems: [typeCountSchema],
+ State: Number,
+ CompletionDate: Date
+ },
+ { _id: false }
+);
+
+const longMOTDSchema = new Schema(
+ {
+ message: String,
+ authorName: String,
+ authorGuildName: String
+ },
+ { _id: false }
+);
+
+const guildRankSchema = new Schema(
+ {
+ Name: String,
+ Permissions: Number
+ },
+ { _id: false }
+);
+
+const defaultRanks: IGuildRank[] = [
+ {
+ Name: "/Lotus/Language/Game/Rank_Creator",
+ Permissions: 16351
+ },
+ {
+ Name: "/Lotus/Language/Game/Rank_Warlord",
+ Permissions: 16351
+ },
+ {
+ Name: "/Lotus/Language/Game/Rank_General",
+ Permissions: GuildPermission.Host | 4318
+ },
+ {
+ Name: "/Lotus/Language/Game/Rank_Officer",
+ Permissions: GuildPermission.Host | 4314
+ },
+ {
+ Name: "/Lotus/Language/Game/Rank_Leader",
+ Permissions: GuildPermission.Host | 4106
+ },
+ {
+ Name: "/Lotus/Language/Game/Rank_Sage",
+ Permissions: GuildPermission.Host | 4304
+ },
+ {
+ Name: "/Lotus/Language/Game/Rank_Soldier",
+ Permissions: GuildPermission.Host | 4098
+ },
+ {
+ Name: "/Lotus/Language/Game/Rank_Initiate",
+ Permissions: GuildPermission.Host | GuildPermission.Fabricator
+ },
+ {
+ Name: "/Lotus/Language/Game/Rank_Utility",
+ Permissions: GuildPermission.Host | GuildPermission.Fabricator
+ }
+];
+
+const guildLogRoomChangeSchema = new Schema(
+ {
+ dateTime: Date,
+ entryType: Number,
+ details: String,
+ componentId: Types.ObjectId
+ },
+ { _id: false }
+);
+
+const guildLogEntryContributableSchema = new Schema(
+ {
+ dateTime: Date,
+ entryType: Number,
+ details: String
+ },
+ { _id: false }
+);
+
+const guildLogEntryRosterSchema = new Schema(
+ {
+ dateTime: Date,
+ entryType: Number,
+ details: String
+ },
+ { _id: false }
+);
+
+const guildLogEntryNumberSchema = new Schema(
+ {
+ dateTime: Date,
+ entryType: Number,
+ details: Number
+ },
+ { _id: false }
+);
+
+const goalProgressSchema = new Schema(
+ {
+ Count: Number,
+ Tag: String,
+ goalId: Types.ObjectId
+ },
+ { _id: false }
+);
+
+goalProgressSchema.set("toJSON", {
+ virtuals: true,
+ transform(_doc, obj: Record) {
+ const db = obj as IGoalProgressDatabase;
+ const client = obj as IGoalProgressClient;
+
+ client._id = toOid(db.goalId);
+
+ delete obj.goalId;
+ delete obj.__v;
+ }
+});
+
+const guildSchema = new Schema(
+ {
+ Name: { type: String, required: true, unique: true },
+ MOTD: { type: String, default: "" },
+ LongMOTD: { type: longMOTDSchema, default: undefined },
+ Ranks: { type: [guildRankSchema], default: defaultRanks },
+ TradeTax: { type: Number, default: 0 },
+ Tier: { type: Number, default: 1 },
+ Emblem: { type: Boolean },
+ AutoContributeFromVault: { type: Boolean },
+ AllianceId: { type: Types.ObjectId },
+ DojoComponents: { type: [dojoComponentSchema], default: [] },
+ DojoCapacity: { type: Number, default: 100 },
+ DojoEnergy: { type: Number, default: 5 },
+ VaultRegularCredits: Number,
+ VaultPremiumCredits: Number,
+ VaultMiscItems: { type: [typeCountSchema], default: undefined },
+ VaultShipDecorations: { type: [typeCountSchema], default: undefined },
+ VaultFusionTreasures: { type: [fusionTreasuresSchema], default: undefined },
+ VaultDecoRecipes: { type: [typeCountSchema], default: undefined },
+ TechProjects: { type: [techProjectSchema], default: undefined },
+ ActiveDojoColorResearch: { type: String, default: "" },
+ Class: { type: Number, default: 0 },
+ XP: { type: Number, default: 0 },
+ ClaimedXP: { type: [String], default: undefined },
+ CeremonyClass: Number,
+ CeremonyContributors: { type: [Types.ObjectId], default: undefined },
+ CeremonyResetDate: Date,
+ CeremonyEndo: Number,
+ RoomChanges: { type: [guildLogRoomChangeSchema], default: undefined },
+ TechChanges: { type: [guildLogEntryContributableSchema], default: undefined },
+ RosterActivity: { type: [guildLogEntryRosterSchema], default: undefined },
+ ClassChanges: { type: [guildLogEntryNumberSchema], default: undefined },
+ GoalProgress: { type: [goalProgressSchema], default: undefined }
+ },
+ { id: false }
+);
+
+type GuildDocumentProps = {
+ DojoComponents: Types.DocumentArray;
+};
+
+// eslint-disable-next-line @typescript-eslint/no-empty-object-type
+type GuildModel = Model;
+
+export const Guild = model("Guild", guildSchema);
+
+// eslint-disable-next-line @typescript-eslint/no-empty-object-type
+export type TGuildDatabaseDocument = Document &
+ Omit<
+ IGuildDatabase & {
+ _id: Types.ObjectId;
+ } & {
+ __v: number;
+ },
+ keyof GuildDocumentProps
+ > &
+ GuildDocumentProps;
+
+const guildMemberSchema = new Schema({
+ accountId: Types.ObjectId,
+ guildId: Types.ObjectId,
+ status: { type: Number, required: true },
+ rank: { type: Number, default: 7 },
+ RequestMsg: String,
+ RequestExpiry: Date,
+ RegularCreditsContributed: Number,
+ PremiumCreditsContributed: Number,
+ MiscItemsContributed: { type: [typeCountSchema], default: undefined },
+ ShipDecorationsContributed: { type: [typeCountSchema], default: undefined }
+});
+
+guildMemberSchema.index({ accountId: 1, guildId: 1 }, { unique: true });
+guildMemberSchema.index({ RequestExpiry: 1 }, { expireAfterSeconds: 0 });
+
+export const GuildMember = model("GuildMember", guildMemberSchema);
+
+// eslint-disable-next-line @typescript-eslint/no-empty-object-type
+export type TGuildMemberDatabaseDocument = Document &
+ IGuildMemberDatabase & {
+ _id: Types.ObjectId;
+ __v: number;
+ };
+
+const guildAdSchema = new Schema({
+ GuildId: { type: Schema.Types.ObjectId, required: true },
+ Emblem: Boolean,
+ Expiry: { type: Date, required: true },
+ Features: { type: Number, required: true },
+ GuildName: { type: String, required: true },
+ MemberCount: { type: Number, required: true },
+ RecruitMsg: { type: String, required: true },
+ Tier: { type: Number, required: true }
+});
+
+guildAdSchema.index({ GuildId: 1 }, { unique: true });
+guildAdSchema.index({ Expiry: 1 }, { expireAfterSeconds: 0 });
+
+export const GuildAd = model("GuildAd", guildAdSchema);
+
+const allianceSchema = new Schema({
+ Name: String,
+ MOTD: longMOTDSchema,
+ LongMOTD: longMOTDSchema,
+ Emblem: Boolean,
+ VaultRegularCredits: Number
+});
+
+allianceSchema.index({ Name: 1 }, { unique: true });
+
+export const Alliance = model("Alliance", allianceSchema);
+
+const allianceMemberSchema = new Schema({
+ allianceId: { type: Schema.Types.ObjectId, required: true },
+ guildId: { type: Schema.Types.ObjectId, required: true },
+ Pending: { type: Boolean, required: true },
+ Permissions: { type: Number, required: true }
+});
+
+allianceMemberSchema.index({ allianceId: 1, guildId: 1 }, { unique: true });
+
+export const AllianceMember = model("AllianceMember", allianceMemberSchema);
diff --git a/src/models/inboxModel.ts b/src/models/inboxModel.ts
new file mode 100644
index 00000000..9adfb8ef
--- /dev/null
+++ b/src/models/inboxModel.ts
@@ -0,0 +1,185 @@
+import type { Types } from "mongoose";
+import { model, Schema } from "mongoose";
+import { toMongoDate, toOid } from "../helpers/inventoryHelpers.ts";
+import { typeCountSchema } from "./inventoryModels/inventoryModel.ts";
+import type { IMongoDate, IOid, ITypeCount } from "../types/commonTypes.ts";
+
+export interface IMessageClient
+ extends Omit<
+ IMessageDatabase,
+ "_id" | "globaUpgradeId" | "date" | "startDate" | "endDate" | "ownerId" | "attVisualOnly" | "expiry"
+ > {
+ _id?: IOid;
+ globaUpgradeId?: IOid; // [sic]
+ date: IMongoDate;
+ startDate?: IMongoDate;
+ endDate?: IMongoDate;
+ messageId: IOid;
+}
+
+export interface IMessageDatabase extends IMessage {
+ ownerId: Types.ObjectId;
+ globaUpgradeId?: Types.ObjectId; // [sic]
+ date: Date; //created at
+ attVisualOnly?: boolean;
+ _id: Types.ObjectId;
+}
+
+export interface IMessage {
+ sndr: string;
+ msg: string;
+ cinematic?: string;
+ sub: string;
+ customData?: string;
+ icon?: string;
+ highPriority?: boolean;
+ lowPrioNewPlayers?: boolean;
+ transmission?: string;
+ att?: string[];
+ countedAtt?: ITypeCount[];
+ startDate?: Date;
+ endDate?: Date;
+ goalTag?: string;
+ CrossPlatform?: boolean;
+ arg?: Arg[];
+ gifts?: IGift[];
+ r?: boolean;
+ contextInfo?: string;
+ acceptAction?: string;
+ declineAction?: string;
+ hasAccountAction?: boolean;
+ RegularCredits?: number;
+}
+
+export interface Arg {
+ Key: string;
+ Tag: string | number;
+}
+
+export interface IGift {
+ GiftType: string;
+}
+
+//types are wrong
+// export interface IMessageDatabase {
+// _id: Types.ObjectId;
+// messageId: string;
+// sub: string;
+// sndr: string;
+// msg: string;
+// startDate: Date;
+// endDate: Date;
+// date: Date;
+// contextInfo: string;
+// icon: string;
+// att: string[];
+// modPacks: string[];
+// countedAtt: string[];
+// attSpecial: string[];
+// transmission: string;
+// ordisReactionTransmission: string;
+// arg: string[];
+// r: string;
+// acceptAction: string;
+// declineAction: string;
+// highPriority: boolean;
+// lowPrioNewPlayers: boolean
+// gifts: string[];
+// teleportLoc: string;
+// RegularCredits: string;
+// PremiumCredits: string;
+// PrimeTokens: string;
+// Coupons: string[];
+// syndicateAttachment: string[];
+// tutorialTag: string;
+// url: string;
+// urlButtonText: string;
+// cinematic: string;
+// requiredLevel: string;
+// }
+
+const giftSchema = new Schema(
+ {
+ GiftType: String
+ },
+ { _id: false }
+);
+
+const messageSchema = new Schema(
+ {
+ ownerId: Schema.Types.ObjectId,
+ globaUpgradeId: Schema.Types.ObjectId,
+ sndr: String,
+ msg: String,
+ cinematic: String,
+ sub: String,
+ customData: String,
+ icon: String,
+ highPriority: Boolean,
+ lowPrioNewPlayers: Boolean,
+ startDate: Date,
+ endDate: Date,
+ goalTag: String,
+ date: { type: Date, required: true },
+ r: Boolean,
+ CrossPlatform: Boolean,
+ att: { type: [String], default: undefined },
+ gifts: { type: [giftSchema], default: undefined },
+ countedAtt: { type: [typeCountSchema], default: undefined },
+ attVisualOnly: Boolean,
+ transmission: String,
+ arg: {
+ type: [
+ {
+ Key: String,
+ Tag: Schema.Types.Mixed,
+ _id: false
+ }
+ ],
+ default: undefined
+ },
+ contextInfo: String,
+ acceptAction: String,
+ declineAction: String,
+ hasAccountAction: Boolean,
+ RegularCredits: Number
+ },
+ { id: false }
+);
+
+messageSchema.virtual("messageId").get(function (this: IMessageDatabase) {
+ return toOid(this._id);
+});
+
+messageSchema.set("toJSON", {
+ virtuals: true,
+ transform(_document, returnedObject: Record) {
+ const messageDatabase = returnedObject as IMessageDatabase;
+ const messageClient = returnedObject as IMessageClient;
+
+ delete returnedObject._id;
+ delete returnedObject.__v;
+ delete returnedObject.ownerId;
+ delete returnedObject.attVisualOnly;
+ delete returnedObject.expiry;
+
+ if (messageDatabase.globaUpgradeId) {
+ messageClient.globaUpgradeId = toOid(messageDatabase.globaUpgradeId);
+ }
+
+ messageClient.date = toMongoDate(messageDatabase.date);
+
+ if (messageDatabase.startDate && messageDatabase.endDate) {
+ messageClient.startDate = toMongoDate(messageDatabase.startDate);
+ messageClient.endDate = toMongoDate(messageDatabase.endDate);
+ } else {
+ delete messageClient.startDate;
+ delete messageClient.endDate;
+ }
+ }
+});
+
+messageSchema.index({ ownerId: 1 });
+messageSchema.index({ endDate: 1 }, { expireAfterSeconds: 0 });
+
+export const Inbox = model("Inbox", messageSchema, "inbox");
diff --git a/src/models/inventoryModels/inventoryModel.ts b/src/models/inventoryModels/inventoryModel.ts
new file mode 100644
index 00000000..ff017f5d
--- /dev/null
+++ b/src/models/inventoryModels/inventoryModel.ts
@@ -0,0 +1,1919 @@
+import type { Document, Model } from "mongoose";
+import { Schema, Types, model } from "mongoose";
+import type {
+ IRawUpgrade,
+ IMiscItem,
+ IInventoryDatabase,
+ IBooster,
+ IInventoryClient,
+ ISlots,
+ IDuviriInfo,
+ IPendingRecipeDatabase,
+ IPendingRecipeClient,
+ IFocusXP,
+ IFocusUpgrade,
+ ITypeXPItem,
+ IChallengeProgress,
+ IStepSequencer,
+ IAffiliation,
+ INotePacks,
+ ICompletedJobChain,
+ ISeasonChallenge,
+ IPlayerSkills,
+ ISettings,
+ IInfestedFoundryDatabase,
+ IHelminthResource,
+ IMissionDatabase,
+ IConsumedSuit,
+ IQuestStage,
+ IQuestKeyDatabase,
+ IQuestKeyClient,
+ IFusionTreasure,
+ ISpectreLoadout,
+ IWeaponSkinDatabase,
+ ITaunt,
+ IPeriodicMissionCompletionDatabase,
+ IPeriodicMissionCompletionResponse,
+ ILoreFragmentScan,
+ IEvolutionProgress,
+ IEndlessXpProgressDatabase,
+ IEndlessXpProgressClient,
+ IHelminthFoodRecord,
+ IDialogueHistoryDatabase,
+ IDialogueDatabase,
+ IDialogueGift,
+ ICompletedDialogue,
+ IDialogueClient,
+ IUpgradeDatabase,
+ TEquipmentKey,
+ IKubrowPetEggDatabase,
+ IKubrowPetEggClient,
+ ICustomMarkers,
+ IMarkerInfo,
+ IMarker,
+ ICalendarProgress,
+ IPendingCouponDatabase,
+ IPendingCouponClient,
+ ILibraryDailyTaskInfo,
+ IDroneDatabase,
+ IDroneClient,
+ IAlignment,
+ ICollectibleEntry,
+ IIncentiveState,
+ ISongChallenge,
+ ILibraryPersonalProgress,
+ IRecentVendorPurchaseDatabase,
+ IVendorPurchaseHistoryEntryDatabase,
+ IVendorPurchaseHistoryEntryClient,
+ INemesisDatabase,
+ INemesisClient,
+ IInfNode,
+ IDiscoveredMarker,
+ IWeeklyMission,
+ ILockedWeaponGroupDatabase,
+ IPersonalTechProjectDatabase,
+ IPersonalTechProjectClient,
+ ILastSortieRewardDatabase,
+ ILastSortieRewardClient,
+ ICrewMemberSkill,
+ ICrewMemberSkillEfficiency,
+ ICrewMemberDatabase,
+ ICrewMemberClient,
+ IRewardAttenuation,
+ IInvasionProgressDatabase,
+ IInvasionProgressClient,
+ IAccolades,
+ IHubNpcCustomization,
+ IEndlessXpReward,
+ IGoalProgressDatabase,
+ IGoalProgressClient,
+ IKubrowPetPrintClient,
+ IKubrowPetPrintDatabase
+} from "../../types/inventoryTypes/inventoryTypes.ts";
+import { equipmentKeys } from "../../types/inventoryTypes/inventoryTypes.ts";
+import type { IOid, ITypeCount } from "../../types/commonTypes.ts";
+import type {
+ IAbilityOverride,
+ ICrewShipCustomization,
+ IFlavourItem,
+ IItemConfig,
+ ILotusCustomization,
+ IOperatorConfigDatabase,
+ IPolarity
+} from "../../types/inventoryTypes/commonInventoryTypes.ts";
+import { fromDbOid, toMongoDate, toOid } from "../../helpers/inventoryHelpers.ts";
+import { EquipmentSelectionSchema } from "./loadoutModel.ts";
+import type { ICountedStoreItem } from "warframe-public-export-plus";
+import { colorSchema, shipCustomizationSchema } from "../commonModel.ts";
+import type {
+ IArchonCrystalUpgrade,
+ ICrewShipMemberClient,
+ ICrewShipMemberDatabase,
+ ICrewShipMembersDatabase,
+ ICrewShipWeaponDatabase,
+ ICrewShipWeaponEmplacementsDatabase,
+ IEquipmentClient,
+ IEquipmentDatabase,
+ IKubrowPetDetailsClient,
+ IKubrowPetDetailsDatabase,
+ ITraits
+} from "../../types/equipmentTypes.ts";
+
+export const typeCountSchema = new Schema({ ItemType: String, ItemCount: Number }, { _id: false });
+
+typeCountSchema.set("toJSON", {
+ transform(_doc, obj: Record) {
+ if (obj.ItemCount > 2147483647) {
+ obj.ItemCount = 2147483647;
+ } else if (obj.ItemCount < -2147483648) {
+ obj.ItemCount = -2147483648;
+ }
+ }
+});
+
+const focusXPSchema = new Schema(
+ {
+ AP_POWER: Number,
+ AP_TACTIC: Number,
+ AP_DEFENSE: Number,
+ AP_ATTACK: Number,
+ AP_WARD: Number
+ },
+ { _id: false }
+);
+
+const focusUpgradeSchema = new Schema(
+ {
+ ItemType: String,
+ Level: Number,
+ IsUniversal: Boolean
+ },
+ { _id: false }
+);
+
+const polaritySchema = new Schema(
+ {
+ Slot: Number,
+ Value: String
+ },
+ { _id: false }
+);
+
+const abilityOverrideSchema = new Schema(
+ {
+ Ability: String,
+ Index: Number
+ },
+ { _id: false }
+);
+
+const operatorConfigSchema = new Schema(
+ {
+ Skins: [String],
+ pricol: colorSchema,
+ attcol: colorSchema,
+ sigcol: colorSchema,
+ eyecol: colorSchema,
+ facial: colorSchema,
+ syancol: colorSchema,
+ cloth: colorSchema,
+ Upgrades: [String],
+ Name: String, // not sure if possible in operator
+ ugly: Boolean // not sure if possible in operator
+ },
+ { id: false }
+);
+
+operatorConfigSchema.virtual("ItemId").get(function () {
+ return { $oid: this._id.toString() } satisfies IOid;
+});
+
+operatorConfigSchema.set("toJSON", {
+ virtuals: true,
+ transform(_document, returnedObject: Record) {
+ delete returnedObject._id;
+ delete returnedObject.__v;
+ }
+});
+
+///TODO: clearly seperate the different config schemas. (suit and weapon and so on)
+const ItemConfigSchema = new Schema(
+ {
+ Skins: [String],
+ pricol: colorSchema,
+ attcol: colorSchema,
+ sigcol: colorSchema,
+ eyecol: colorSchema,
+ facial: colorSchema,
+ syancol: colorSchema,
+ Upgrades: [String],
+ Songs: {
+ type: [
+ {
+ m: String,
+ b: String,
+ p: String,
+ s: String
+ }
+ ],
+ default: undefined
+ },
+ Name: String,
+ AbilityOverride: abilityOverrideSchema,
+ PvpUpgrades: [String],
+ ugly: Boolean
+ },
+ { _id: false }
+);
+
+ItemConfigSchema.set("toJSON", {
+ transform(_document, returnedObject: Record) {
+ delete returnedObject.__v;
+ }
+});
+
+const ArchonCrystalUpgradeSchema = new Schema(
+ {
+ UpgradeType: String,
+ Color: String
+ },
+ { _id: false }
+);
+
+const boosterSchema = new Schema(
+ {
+ ExpiryDate: Number,
+ ItemType: String
+ },
+ { _id: false }
+);
+
+const RawUpgrades = new Schema(
+ {
+ ItemType: String,
+ ItemCount: Number
+ },
+ { id: false }
+);
+
+RawUpgrades.virtual("LastAdded").get(function () {
+ return { $oid: this._id.toString() } satisfies IOid;
+});
+
+RawUpgrades.set("toJSON", {
+ virtuals: true,
+ transform(_document, returnedObject: Record) {
+ delete returnedObject._id;
+ delete returnedObject.__v;
+ }
+});
+
+const upgradeSchema = new Schema(
+ {
+ UpgradeFingerprint: String,
+ PendingRerollFingerprint: { type: String, required: false },
+ ItemType: String
+ },
+ { id: false }
+);
+
+upgradeSchema.virtual("ItemId").get(function () {
+ return toOid(this._id);
+});
+
+upgradeSchema.set("toJSON", {
+ virtuals: true,
+ transform(_document, returnedObject: Record) {
+ delete returnedObject._id;
+ delete returnedObject.__v;
+ }
+});
+
+const crewMemberSkillSchema = new Schema(
+ {
+ Assigned: Number
+ },
+ { _id: false }
+);
+
+const crewMemberSkillEfficiencySchema = new Schema(
+ {
+ PILOTING: crewMemberSkillSchema,
+ GUNNERY: crewMemberSkillSchema,
+ ENGINEERING: crewMemberSkillSchema,
+ COMBAT: crewMemberSkillSchema,
+ SURVIVABILITY: crewMemberSkillSchema
+ },
+ { _id: false }
+);
+
+const crewMemberSchema = new Schema(
+ {
+ ItemType: { type: String, required: true },
+ NemesisFingerprint: { type: BigInt, default: 0n },
+ Seed: { type: BigInt, default: 0n },
+ AssignedRole: Number,
+ SkillEfficiency: crewMemberSkillEfficiencySchema,
+ WeaponConfigIdx: Number,
+ WeaponId: { type: Schema.Types.ObjectId, default: "000000000000000000000000" },
+ XP: { type: Number, default: 0 },
+ PowersuitType: { type: String, required: true },
+ Configs: [ItemConfigSchema],
+ SecondInCommand: { type: Boolean, default: false }
+ },
+ { id: false }
+);
+
+crewMemberSchema.set("toJSON", {
+ virtuals: true,
+ transform(_doc, obj: Record) {
+ const db = obj as ICrewMemberDatabase;
+ const client = obj as ICrewMemberClient;
+
+ client.WeaponId = toOid(db.WeaponId);
+ client.ItemId = toOid(db._id);
+
+ delete obj._id;
+ delete obj.__v;
+ }
+});
+
+const slotsBinSchema = new Schema(
+ {
+ Slots: Number,
+ Extra: Number
+ },
+ { _id: false }
+);
+
+const FlavourItemSchema = new Schema(
+ {
+ ItemType: String
+ },
+ { _id: false }
+);
+
+FlavourItemSchema.set("toJSON", {
+ transform(_document, returnedObject: Record) {
+ delete returnedObject._id;
+ delete returnedObject.__v;
+ }
+});
+
+/*const MailboxSchema = new Schema(
+ {
+ LastInboxId: Schema.Types.ObjectId
+ },
+ { id: false, _id: false }
+);
+
+MailboxSchema.set("toJSON", {
+ transform(_document, returnedObject: Record) {
+ const mailboxDatabase = returnedObject as HydratedDocument;
+ delete mailboxDatabase.__v;
+ (returnedObject as IMailboxClient).LastInboxId = toOid(mailboxDatabase.LastInboxId);
+ }
+});*/
+
+const DuviriInfoSchema = new Schema(
+ {
+ Seed: { type: BigInt, required: true },
+ NumCompletions: { type: Number, required: true }
+ },
+ {
+ _id: false,
+ id: false
+ }
+);
+
+DuviriInfoSchema.set("toJSON", {
+ transform(_document, returnedObject: Record) {
+ delete returnedObject.__v;
+ }
+});
+
+const TypeXPItemSchema = new Schema(
+ {
+ ItemType: String,
+ XP: Number
+ },
+ { _id: false }
+);
+
+const droneSchema = new Schema(
+ {
+ ItemType: String,
+ CurrentHP: Number,
+ RepairStart: { type: Date, default: undefined },
+
+ DeployTime: { type: Date, default: undefined },
+ System: Number,
+ DamageTime: { type: Date, default: undefined },
+ PendingDamage: Number,
+ ResourceType: String,
+ ResourceCount: Number
+ },
+ { id: false }
+);
+droneSchema.set("toJSON", {
+ virtuals: true,
+ transform(_document, obj: Record) {
+ const client = obj as IDroneClient;
+ const db = obj as IDroneDatabase;
+
+ client.ItemId = toOid(db._id);
+ if (db.RepairStart) {
+ client.RepairStart = toMongoDate(db.RepairStart);
+ }
+
+ delete db.DeployTime;
+ delete db.System;
+ delete db.DamageTime;
+ delete db.PendingDamage;
+ delete db.ResourceType;
+ delete db.ResourceCount;
+
+ delete obj._id;
+ delete obj.__v;
+ }
+});
+
+const discoveredMarkerSchema = new Schema(
+ {
+ tag: String,
+ discoveryState: [Number]
+ },
+ { _id: false }
+);
+
+const personalGoalProgressSchema = new Schema(
+ {
+ Best: Number,
+ Count: Number,
+ Tag: String,
+ goalId: Types.ObjectId
+ },
+ { _id: false }
+);
+
+personalGoalProgressSchema.set("toJSON", {
+ virtuals: true,
+ transform(_doc, obj: Record) {
+ const db = obj as IGoalProgressDatabase;
+ const client = obj as IGoalProgressClient;
+
+ client._id = toOid(db.goalId);
+
+ delete obj.goalId;
+ delete obj.__v;
+ }
+});
+
+const challengeProgressSchema = new Schema(
+ {
+ Progress: Number,
+ Completed: { type: [String], default: undefined },
+ ReceivedJunctionReward: Boolean,
+ Name: { type: String, required: true }
+ },
+ { _id: false }
+);
+
+const notePacksSchema = new Schema(
+ {
+ MELODY: String,
+ BASS: String,
+ PERCUSSION: String
+ },
+ { _id: false }
+);
+
+const StepSequencersSchema = new Schema(
+ {
+ NotePacks: notePacksSchema,
+ FingerPrint: String,
+ Name: String
+ },
+ { id: false }
+);
+
+StepSequencersSchema.virtual("ItemId").get(function () {
+ return { $oid: this._id.toString() } satisfies IOid;
+});
+
+StepSequencersSchema.set("toJSON", {
+ virtuals: true,
+ transform(_document, returnedObject: Record) {
+ delete returnedObject._id;
+ delete returnedObject.__v;
+ }
+});
+
+const kubrowPetEggSchema = new Schema(
+ {
+ ItemType: String
+ },
+ { id: false }
+);
+kubrowPetEggSchema.set("toJSON", {
+ virtuals: true,
+ transform(_document, obj: Record) {
+ const client = obj as IKubrowPetEggClient;
+ const db = obj as IKubrowPetEggDatabase;
+
+ client.ExpirationDate = { $date: { $numberLong: "2000000000000" } };
+ client.ItemId = toOid(db._id);
+
+ delete obj._id;
+ delete obj.__v;
+ }
+});
+
+const weeklyMissionSchema = new Schema(
+ {
+ MissionIndex: Number,
+ CompletedMission: Boolean,
+ JobManifest: String,
+ Challenges: [String],
+ ChallengesReset: Boolean,
+ WeekCount: Number
+ },
+ { _id: false }
+);
+
+const affiliationsSchema = new Schema