chore(webui): improve string #2405
							
								
								
									
										18
									
								
								.eslintrc
									
									
									
									
									
								
							
							
						
						
									
										18
									
								
								.eslintrc
									
									
									
									
									
								
							@ -11,17 +11,17 @@
 | 
			
		||||
        "node": true
 | 
			
		||||
    },
 | 
			
		||||
    "rules": {
 | 
			
		||||
        "@typescript-eslint/explicit-function-return-type": "warn",
 | 
			
		||||
        "@typescript-eslint/restrict-template-expressions": "warn",
 | 
			
		||||
        "@typescript-eslint/restrict-plus-operands": "warn",
 | 
			
		||||
        "@typescript-eslint/no-unsafe-member-access": "warn",
 | 
			
		||||
        "@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": "warn",
 | 
			
		||||
        "@typescript-eslint/no-unsafe-assignment": "warn",
 | 
			
		||||
        "@typescript-eslint/no-explicit-any": "warn",
 | 
			
		||||
        "no-loss-of-precision": "warn",
 | 
			
		||||
        "@typescript-eslint/no-unnecessary-condition": "warn",
 | 
			
		||||
        "@typescript-eslint/no-unsafe-call": "error",
 | 
			
		||||
        "@typescript-eslint/no-unsafe-assignment": "error",
 | 
			
		||||
        "@typescript-eslint/no-explicit-any": "error",
 | 
			
		||||
        "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",
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										3
									
								
								.github/workflows/build.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.github/workflows/build.yml
									
									
									
									
										vendored
									
									
								
							@ -1,6 +1,7 @@
 | 
			
		||||
name: Build
 | 
			
		||||
on:
 | 
			
		||||
    push: {}
 | 
			
		||||
    push:
 | 
			
		||||
        branches: ["main"]
 | 
			
		||||
    pull_request: {}
 | 
			
		||||
jobs:
 | 
			
		||||
    build:
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										25
									
								
								.github/workflows/docker.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										25
									
								
								.github/workflows/docker.yml
									
									
									
									
										vendored
									
									
								
							@ -4,9 +4,9 @@ on:
 | 
			
		||||
        branches:
 | 
			
		||||
            - main
 | 
			
		||||
jobs:
 | 
			
		||||
    docker:
 | 
			
		||||
    docker-amd64:
 | 
			
		||||
        if: github.repository == 'OpenWF/SpaceNinjaServer'
 | 
			
		||||
        runs-on: ubuntu-latest
 | 
			
		||||
        runs-on: amd64
 | 
			
		||||
        steps:
 | 
			
		||||
            - name: Set up Docker buildx
 | 
			
		||||
              uses: docker/setup-buildx-action@v3
 | 
			
		||||
@ -18,8 +18,27 @@ jobs:
 | 
			
		||||
            - name: Build and push
 | 
			
		||||
              uses: docker/build-push-action@v6
 | 
			
		||||
              with:
 | 
			
		||||
                  platforms: linux/amd64,linux/arm64
 | 
			
		||||
                  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
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										3
									
								
								.vscode/launch.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.vscode/launch.json
									
									
									
									
										vendored
									
									
								
							@ -8,8 +8,7 @@
 | 
			
		||||
      "type": "node",
 | 
			
		||||
      "request": "launch",
 | 
			
		||||
      "name": "Debug and Watch",
 | 
			
		||||
      "runtimeArgs": ["-r", "tsconfig-paths/register", "-r", "ts-node/register", "--watch-path", "src"],
 | 
			
		||||
      "args": ["${workspaceFolder}/src/index.ts"],
 | 
			
		||||
      "args": ["${workspaceFolder}/scripts/dev.js"],
 | 
			
		||||
      "console": "integratedTerminal"
 | 
			
		||||
    }
 | 
			
		||||
  ]
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										17
									
								
								AGENTS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								AGENTS.md
									
									
									
									
									
										Normal file
									
								
							@ -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.
 | 
			
		||||
							
								
								
									
										52
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										52
									
								
								Dockerfile
									
									
									
									
									
								
							@ -1,53 +1,11 @@
 | 
			
		||||
FROM node:18-alpine3.19
 | 
			
		||||
FROM node:24-alpine3.21
 | 
			
		||||
 | 
			
		||||
ENV APP_MONGODB_URL=mongodb://mongodb:27017/openWF
 | 
			
		||||
ENV APP_MY_ADDRESS=localhost
 | 
			
		||||
ENV APP_HTTP_PORT=80
 | 
			
		||||
ENV APP_HTTPS_PORT=443
 | 
			
		||||
ENV APP_AUTO_CREATE_ACCOUNT=true
 | 
			
		||||
ENV APP_SKIP_TUTORIAL=false
 | 
			
		||||
ENV APP_SKIP_ALL_DIALOGUE=false
 | 
			
		||||
ENV APP_UNLOCK_ALL_SCANS=false
 | 
			
		||||
ENV APP_UNLOCK_ALL_MISSIONS=false
 | 
			
		||||
ENV APP_INFINITE_CREDITS=false
 | 
			
		||||
ENV APP_INFINITE_PLATINUM=false
 | 
			
		||||
ENV APP_INFINITE_ENDO=false
 | 
			
		||||
ENV APP_INFINITE_REGAL_AYA=false
 | 
			
		||||
ENV APP_INFINITE_HELMINTH_MATERIALS=false
 | 
			
		||||
ENV APP_CLAIMING_BLUEPRINT_REFUNDS_INGREDIENTS=false
 | 
			
		||||
ENV APP_DONT_SUBTRACT_VOIDTRACES=false
 | 
			
		||||
ENV APP_DONT_SUBTRACT_CONSUMABLES=false
 | 
			
		||||
ENV APP_UNLOCK_ALL_SHIP_FEATURES=false
 | 
			
		||||
ENV APP_UNLOCK_ALL_SHIP_DECORATIONS=false
 | 
			
		||||
ENV APP_UNLOCK_ALL_FLAVOUR_ITEMS=false
 | 
			
		||||
ENV APP_UNLOCK_ALL_SKINS=false
 | 
			
		||||
ENV APP_UNLOCK_ALL_CAPTURA_SCENES=false
 | 
			
		||||
ENV APP_UNIVERSAL_POLARITY_EVERYWHERE=false
 | 
			
		||||
ENV APP_UNLOCK_DOUBLE_CAPACITY_POTATOES_EVERYWHERE=false
 | 
			
		||||
ENV APP_UNLOCK_EXILUS_EVERYWHERE=false
 | 
			
		||||
ENV APP_UNLOCK_ARCANES_EVERYWHERE=false
 | 
			
		||||
ENV APP_NO_DAILY_FOCUS_LIMIT=false
 | 
			
		||||
ENV APP_NO_ARGON_CRYSTAL_DECAY=false
 | 
			
		||||
ENV APP_NO_MASTERY_RANK_UP_COOLDOWN=false
 | 
			
		||||
ENV APP_NO_VENDOR_PURCHASE_LIMITS=true
 | 
			
		||||
ENV APP_NO_DEATH_MARKS=false
 | 
			
		||||
ENV APP_NO_KIM_COOLDOWNS=false
 | 
			
		||||
ENV APP_SYNDICATE_MISSIONS_REPEATABLE=false
 | 
			
		||||
ENV APP_INSTANT_FINISH_RIVEN_CHALLENGE=false
 | 
			
		||||
ENV APP_INSTANT_RESOURCE_EXTRACTOR_DRONES=false
 | 
			
		||||
ENV APP_NO_RESOURCE_EXTRACTOR_DRONES_DAMAGE=false
 | 
			
		||||
ENV APP_SKIP_CLAN_KEY_CRAFTING=false
 | 
			
		||||
ENV APP_NO_DOJO_ROOM_BUILD_STAGE=false
 | 
			
		||||
ENV APP_NO_DECO_BUILD_STAGE=false
 | 
			
		||||
ENV APP_FAST_DOJO_ROOM_DESTRUCTION=false
 | 
			
		||||
ENV APP_NO_DOJO_RESEARCH_COSTS=false
 | 
			
		||||
ENV APP_NO_DOJO_RESEARCH_TIME=false
 | 
			
		||||
ENV APP_FAST_CLAN_ASCENSION=false
 | 
			
		||||
ENV APP_SPOOF_MASTERY_RANK=-1
 | 
			
		||||
 | 
			
		||||
RUN apk add --no-cache bash sed wget jq
 | 
			
		||||
RUN apk add --no-cache bash jq
 | 
			
		||||
 | 
			
		||||
COPY . /app
 | 
			
		||||
WORKDIR /app
 | 
			
		||||
 | 
			
		||||
RUN npm i --omit=dev
 | 
			
		||||
RUN npm run build
 | 
			
		||||
 | 
			
		||||
ENTRYPOINT ["/app/docker-entrypoint.sh"]
 | 
			
		||||
 | 
			
		||||
@ -16,6 +16,7 @@ SpaceNinjaServer requires a `config.json`. To set it up, you can copy the [confi
 | 
			
		||||
- `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
 | 
			
		||||
@ -33,3 +34,5 @@ SpaceNinjaServer requires a `config.json`. To set it up, you can copy the [confi
 | 
			
		||||
  - `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`.
 | 
			
		||||
 | 
			
		||||
@ -13,13 +13,16 @@
 | 
			
		||||
  "skipTutorial": false,
 | 
			
		||||
  "skipAllDialogue": false,
 | 
			
		||||
  "unlockAllScans": false,
 | 
			
		||||
  "unlockAllMissions": false,
 | 
			
		||||
  "infiniteCredits": false,
 | 
			
		||||
  "infinitePlatinum": false,
 | 
			
		||||
  "infiniteEndo": false,
 | 
			
		||||
  "infiniteRegalAya": false,
 | 
			
		||||
  "infiniteHelminthMaterials": false,
 | 
			
		||||
  "claimingBlueprintRefundsIngredients": false,
 | 
			
		||||
  "dontSubtractPurchaseCreditCost": false,
 | 
			
		||||
  "dontSubtractPurchasePlatinumCost": false,
 | 
			
		||||
  "dontSubtractPurchaseItemCost": false,
 | 
			
		||||
  "dontSubtractPurchaseStandingCost": false,
 | 
			
		||||
  "dontSubtractVoidTraces": false,
 | 
			
		||||
  "dontSubtractConsumables": false,
 | 
			
		||||
  "unlockAllShipFeatures": false,
 | 
			
		||||
@ -35,10 +38,14 @@
 | 
			
		||||
  "noDailyFocusLimit": false,
 | 
			
		||||
  "noArgonCrystalDecay": false,
 | 
			
		||||
  "noMasteryRankUpCooldown": false,
 | 
			
		||||
  "noVendorPurchaseLimits": true,
 | 
			
		||||
  "noVendorPurchaseLimits": false,
 | 
			
		||||
  "noDeathMarks": false,
 | 
			
		||||
  "noKimCooldowns": false,
 | 
			
		||||
  "fullyStockedVendors": false,
 | 
			
		||||
  "baroAlwaysAvailable": false,
 | 
			
		||||
  "baroFullyStocked": false,
 | 
			
		||||
  "syndicateMissionsRepeatable": false,
 | 
			
		||||
  "unlockAllProfitTakerStages": false,
 | 
			
		||||
  "instantFinishRivenChallenge": false,
 | 
			
		||||
  "instantResourceExtractorDrones": false,
 | 
			
		||||
  "noResourceExtractorDronesDamage": false,
 | 
			
		||||
@ -49,14 +56,31 @@
 | 
			
		||||
  "noDojoResearchCosts": false,
 | 
			
		||||
  "noDojoResearchTime": false,
 | 
			
		||||
  "fastClanAscension": false,
 | 
			
		||||
  "missionsCanGiveAllRelics": false,
 | 
			
		||||
  "unlockAllSimarisResearchEntries": false,
 | 
			
		||||
  "disableDailyTribute": false,
 | 
			
		||||
  "spoofMasteryRank": -1,
 | 
			
		||||
  "relicRewardItemCountMultiplier": 1,
 | 
			
		||||
  "nightwaveStandingMultiplier": 1,
 | 
			
		||||
  "unfaithfulBugFixes": {
 | 
			
		||||
    "ignore1999LastRegionPlayed": false,
 | 
			
		||||
    "fixXtraCheeseTimer": false
 | 
			
		||||
  },
 | 
			
		||||
  "worldState": {
 | 
			
		||||
    "creditBoost": false,
 | 
			
		||||
    "affinityBoost": false,
 | 
			
		||||
    "resourceBoost": false,
 | 
			
		||||
    "starDays": true,
 | 
			
		||||
    "galleonOfGhouls": 0,
 | 
			
		||||
    "eidolonOverride": "",
 | 
			
		||||
    "vallisOverride": "",
 | 
			
		||||
    "nightwaveOverride": ""
 | 
			
		||||
    "duviriOverride": "",
 | 
			
		||||
    "nightwaveOverride": "",
 | 
			
		||||
    "allTheFissures": "",
 | 
			
		||||
    "circuitGameModes": null,
 | 
			
		||||
    "darvoStockMultiplier": 1
 | 
			
		||||
  },
 | 
			
		||||
  "dev": {
 | 
			
		||||
    "keepVendorsExpired": false
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,62 +1,20 @@
 | 
			
		||||
services:
 | 
			
		||||
    spaceninjaserver:
 | 
			
		||||
        # build: .
 | 
			
		||||
        # The image to use. If you have an ARM CPU, replace 'latest' with 'latest-arm64'.
 | 
			
		||||
        image: openwf/spaceninjaserver:latest
 | 
			
		||||
        environment:
 | 
			
		||||
            APP_MONGODB_URL: mongodb://openwfagent:spaceninjaserver@mongodb:27017/
 | 
			
		||||
 | 
			
		||||
            # Following environment variables are set to default image values.
 | 
			
		||||
            # Uncomment to edit.
 | 
			
		||||
 | 
			
		||||
            # APP_MY_ADDRESS: localhost
 | 
			
		||||
            # APP_HTTP_PORT: 80
 | 
			
		||||
            # APP_HTTPS_PORT: 443
 | 
			
		||||
            # APP_AUTO_CREATE_ACCOUNT: true
 | 
			
		||||
            # APP_SKIP_TUTORIAL: false
 | 
			
		||||
            # APP_SKIP_ALL_DIALOGUE: false
 | 
			
		||||
            # APP_UNLOCK_ALL_SCANS: false
 | 
			
		||||
            # APP_UNLOCK_ALL_MISSIONS: false
 | 
			
		||||
            # APP_INFINITE_CREDITS: false
 | 
			
		||||
            # APP_INFINITE_PLATINUM: false
 | 
			
		||||
            # APP_INFINITE_ENDO: false
 | 
			
		||||
            # APP_INFINITE_REGAL_AYA: false
 | 
			
		||||
            # APP_INFINITE_HELMINTH_MATERIALS: false
 | 
			
		||||
            # APP_CLAIMING_BLUEPRINT_REFUNDS_INGREDIENTS: false
 | 
			
		||||
            # APP_DONT_SUBTRACT_VOIDTRACES: false
 | 
			
		||||
            # APP_DONT_SUBTRACT_CONSUMABLES: false
 | 
			
		||||
            # APP_UNLOCK_ALL_SHIP_FEATURES: false
 | 
			
		||||
            # APP_UNLOCK_ALL_SHIP_DECORATIONS: false
 | 
			
		||||
            # APP_UNLOCK_ALL_FLAVOUR_ITEMS: false
 | 
			
		||||
            # APP_UNLOCK_ALL_SKINS: false
 | 
			
		||||
            # APP_UNLOCK_ALL_CAPTURA_SCENES: false
 | 
			
		||||
            # APP_UNIVERSAL_POLARITY_EVERYWHERE: false
 | 
			
		||||
            # APP_UNLOCK_DOUBLE_CAPACITY_POTATOES_EVERYWHERE: false
 | 
			
		||||
            # APP_UNLOCK_EXILUS_EVERYWHERE: false
 | 
			
		||||
            # APP_UNLOCK_ARCANES_EVERYWHERE: false
 | 
			
		||||
            # APP_NO_DAILY_FOCUS_LIMIT: false
 | 
			
		||||
            # APP_NO_ARGON_CRYSTAL_DECAY: false
 | 
			
		||||
            # APP_NO_MASTERY_RANK_UP_COOLDOWN: false
 | 
			
		||||
            # APP_NO_VENDOR_PURCHASE_LIMITS: true
 | 
			
		||||
            # APP_NO_DEATH_MARKS: false
 | 
			
		||||
            # APP_NO_KIM_COOLDOWNS: false
 | 
			
		||||
            # APP_SYNDICATE_MISSIONS_REPEATABLE: false
 | 
			
		||||
            # APP_INSTANT_FINISH_RIVEN_CHALLENGE: false
 | 
			
		||||
            # APP_INSTANT_RESOURCE_EXTRACTOR_DRONES: false
 | 
			
		||||
            # APP_NO_RESOURCE_EXTRACTOR_DRONES_DAMAGE: false
 | 
			
		||||
            # APP_SKIP_CLAN_KEY_CRAFTING: false
 | 
			
		||||
            # APP_NO_DOJO_ROOM_BUILD_STAGE: false
 | 
			
		||||
            # APP_NO_DECO_BUILD_STAGE: false
 | 
			
		||||
            # APP_FAST_DOJO_ROOM_DESTRUCTION: false
 | 
			
		||||
            # APP_NO_DOJO_RESEARCH_COSTS: false
 | 
			
		||||
            # APP_NO_DOJO_RESEARCH_TIME: false
 | 
			
		||||
            # APP_FAST_CLAN_ASCENSION: false
 | 
			
		||||
            # APP_SPOOF_MASTERY_RANK: -1
 | 
			
		||||
        volumes:
 | 
			
		||||
            - ./docker-data/static:/app/static/data
 | 
			
		||||
            - ./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:
 | 
			
		||||
@ -66,3 +24,4 @@ services:
 | 
			
		||||
            MONGO_INITDB_ROOT_PASSWORD: spaceninjaserver
 | 
			
		||||
        volumes:
 | 
			
		||||
            - ./docker-data/database:/data/db
 | 
			
		||||
        command: mongod --quiet --logpath /dev/null
 | 
			
		||||
 | 
			
		||||
@ -1,24 +1,8 @@
 | 
			
		||||
#!/bin/bash
 | 
			
		||||
set -e
 | 
			
		||||
 | 
			
		||||
# Set up the configuration file using environment variables.
 | 
			
		||||
echo '{
 | 
			
		||||
	"logger": {
 | 
			
		||||
	  "files": true,
 | 
			
		||||
	  "level": "trace",
 | 
			
		||||
	  "__valid_levels": "fatal, error, warn, info, http, debug, trace"
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
' > config.json
 | 
			
		||||
if [ ! -f conf/config.json ]; then
 | 
			
		||||
	jq --arg value "mongodb://openwfagent:spaceninjaserver@mongodb:27017/" '.mongodbUrl = $value' /app/config.json.example > /app/conf/config.json
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
for config in $(env | grep "APP_")
 | 
			
		||||
do
 | 
			
		||||
  var=$(echo "${config}" | tr '[:upper:]' '[:lower:]' | sed 's/app_//g' | sed -E 's/_([a-z])/\U\1/g' | sed 's/=.*//g')
 | 
			
		||||
  val=$(echo "${config}" | sed 's/.*=//g')
 | 
			
		||||
  jq --arg variable "$var" --arg value "$val" '.[$variable] += try [$value|fromjson][] catch $value' config.json > config.tmp
 | 
			
		||||
  mv config.tmp config.json
 | 
			
		||||
done
 | 
			
		||||
 | 
			
		||||
npm i --omit=dev
 | 
			
		||||
npm run build
 | 
			
		||||
exec npm run start
 | 
			
		||||
exec npm run start -- --configPath conf/config.json
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										731
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										731
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										25
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										25
									
								
								package.json
									
									
									
									
									
								
							@ -5,9 +5,16 @@
 | 
			
		||||
  "main": "index.ts",
 | 
			
		||||
  "scripts": {
 | 
			
		||||
    "start": "node --enable-source-maps --import ./build/src/pathman.js build/src/index.js",
 | 
			
		||||
    "dev": "ts-node-dev --openssl-legacy-provider -r tsconfig-paths/register src/index.ts ",
 | 
			
		||||
    "build": "tsc --incremental --sourceMap && ncp static/webui build/static/webui",
 | 
			
		||||
    "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.js",
 | 
			
		||||
    "dev:bun": "bun scripts/dev.js",
 | 
			
		||||
    "verify": "tsgo --noEmit",
 | 
			
		||||
    "bun-run": "bun src/index.ts",
 | 
			
		||||
    "lint": "eslint --ext .ts .",
 | 
			
		||||
    "lint:ci": "eslint --ext .ts --rule \"prettier/prettier: off\" .",
 | 
			
		||||
    "lint:fix": "eslint --fix --ext .ts .",
 | 
			
		||||
@ -18,6 +25,10 @@
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@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",
 | 
			
		||||
    "chokidar": "^4.0.3",
 | 
			
		||||
    "crc-32": "^1.2.2",
 | 
			
		||||
    "express": "^5",
 | 
			
		||||
    "json-with-bigint": "^3.4.4",
 | 
			
		||||
@ -25,19 +36,19 @@
 | 
			
		||||
    "morgan": "^1.10.0",
 | 
			
		||||
    "ncp": "^2.0.0",
 | 
			
		||||
    "typescript": "^5.5",
 | 
			
		||||
    "warframe-public-export-plus": "^0.5.66",
 | 
			
		||||
    "undici": "^7.10.0",
 | 
			
		||||
    "warframe-public-export-plus": "^0.5.77",
 | 
			
		||||
    "warframe-riven-info": "^0.1.2",
 | 
			
		||||
    "winston": "^3.17.0",
 | 
			
		||||
    "winston-daily-rotate-file": "^5.0.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",
 | 
			
		||||
    "@typescript/native-preview": "^7.0.0-dev.20250523.1",
 | 
			
		||||
    "eslint": "^8",
 | 
			
		||||
    "eslint-plugin-prettier": "^5.2.5",
 | 
			
		||||
    "prettier": "^3.5.3",
 | 
			
		||||
    "ts-node-dev": "^2.0.0",
 | 
			
		||||
    "tsconfig-paths": "^4.2.0"
 | 
			
		||||
    "tree-kill": "^1.2.2"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										58
									
								
								scripts/dev.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								scripts/dev.js
									
									
									
									
									
										Normal file
									
								
							@ -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 ? "bun-run" : "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) {}
 | 
			
		||||
});
 | 
			
		||||
@ -1,6 +1,7 @@
 | 
			
		||||
// 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) {
 | 
			
		||||
@ -30,7 +31,7 @@ fs.readdirSync("../static/webui/translations").forEach(file => {
 | 
			
		||||
            const strings = extractStrings(line);
 | 
			
		||||
            if (Object.keys(strings).length > 0) {
 | 
			
		||||
                Object.entries(strings).forEach(([key, value]) => {
 | 
			
		||||
                    if (targetStrings.hasOwnProperty(key)) {
 | 
			
		||||
                    if (targetStrings.hasOwnProperty(key) && !targetStrings[key].startsWith("[UNTRANSLATED] ")) {
 | 
			
		||||
                        fs.writeSync(fileHandle, `    ${key}: \`${targetStrings[key]}\`,\n`);
 | 
			
		||||
                    } else {
 | 
			
		||||
                        fs.writeSync(fileHandle, `    ${key}: \`[UNTRANSLATED] ${value}\`,\n`);
 | 
			
		||||
 | 
			
		||||
@ -24,7 +24,6 @@ export const artifactsController: RequestHandler = async (req, res) => {
 | 
			
		||||
 | 
			
		||||
    if (itemIndex !== -1) {
 | 
			
		||||
        Upgrades[itemIndex].UpgradeFingerprint = stringifiedUpgradeFingerprint;
 | 
			
		||||
        inventory.markModified(`Upgrades.${itemIndex}.UpgradeFingerprint`);
 | 
			
		||||
    } else {
 | 
			
		||||
        itemIndex =
 | 
			
		||||
            Upgrades.push({
 | 
			
		||||
 | 
			
		||||
@ -1,16 +1,12 @@
 | 
			
		||||
import { getAccountForRequest } from "@/src/services/loginService";
 | 
			
		||||
import { RequestHandler } from "express";
 | 
			
		||||
 | 
			
		||||
const checkDailyMissionBonusController: RequestHandler = (_req, res) => {
 | 
			
		||||
    const data = Buffer.from([
 | 
			
		||||
        0x44, 0x61, 0x69, 0x6c, 0x79, 0x4d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x42, 0x6f, 0x6e, 0x75, 0x73, 0x3a,
 | 
			
		||||
        0x31, 0x2d, 0x44, 0x61, 0x69, 0x6c, 0x79, 0x50, 0x56, 0x50, 0x57, 0x69, 0x6e, 0x42, 0x6f, 0x6e, 0x75, 0x73,
 | 
			
		||||
        0x3a, 0x31, 0x0a
 | 
			
		||||
    ]);
 | 
			
		||||
    res.writeHead(200, {
 | 
			
		||||
        "Content-Type": "text/html",
 | 
			
		||||
        "Content-Length": data.length
 | 
			
		||||
    });
 | 
			
		||||
    res.end(data);
 | 
			
		||||
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");
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export { checkDailyMissionBonusController };
 | 
			
		||||
 | 
			
		||||
@ -13,7 +13,8 @@ import {
 | 
			
		||||
    addItem,
 | 
			
		||||
    addRecipes,
 | 
			
		||||
    occupySlot,
 | 
			
		||||
    combineInventoryChanges
 | 
			
		||||
    combineInventoryChanges,
 | 
			
		||||
    addKubrowPetPrint
 | 
			
		||||
} from "@/src/services/inventoryService";
 | 
			
		||||
import { IInventoryChanges } from "@/src/types/purchaseTypes";
 | 
			
		||||
import { IEquipmentClient } from "@/src/types/inventoryTypes/commonInventoryTypes";
 | 
			
		||||
@ -119,6 +120,9 @@ export const claimCompletedRecipeController: RequestHandler = async (req, res) =
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            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, InventoryChanges);
 | 
			
		||||
        } else if (recipe.secretIngredientAction != "SIA_UNBRAND") {
 | 
			
		||||
            InventoryChanges = {
 | 
			
		||||
                ...InventoryChanges,
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,35 @@
 | 
			
		||||
import { getJSONfromString } from "@/src/helpers/stringHelpers";
 | 
			
		||||
import { combineInventoryChanges, getInventory } from "@/src/services/inventoryService";
 | 
			
		||||
import { getAccountIdForRequest } from "@/src/services/loginService";
 | 
			
		||||
import { handleStoreItemAcquisition } from "@/src/services/purchaseService";
 | 
			
		||||
import { 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<IClaimJunctionChallengeRewardRequest>(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;
 | 
			
		||||
}
 | 
			
		||||
@ -1,4 +1,4 @@
 | 
			
		||||
import { getCalendarProgress, getInventory } from "@/src/services/inventoryService";
 | 
			
		||||
import { checkCalendarChallengeCompletion, getCalendarProgress, getInventory } from "@/src/services/inventoryService";
 | 
			
		||||
import { getAccountIdForRequest } from "@/src/services/loginService";
 | 
			
		||||
import { handleStoreItemAcquisition } from "@/src/services/purchaseService";
 | 
			
		||||
import { getWorldState } from "@/src/services/worldStateService";
 | 
			
		||||
@ -12,27 +12,23 @@ export const completeCalendarEventController: RequestHandler = async (req, res)
 | 
			
		||||
    const calendarProgress = getCalendarProgress(inventory);
 | 
			
		||||
    const currentSeason = getWorldState().KnownCalendarSeasons[0];
 | 
			
		||||
    let inventoryChanges: IInventoryChanges = {};
 | 
			
		||||
    let dayIndex = 0;
 | 
			
		||||
    for (const day of currentSeason.Days) {
 | 
			
		||||
        if (day.events.length == 0 || day.events[0].type != "CET_CHALLENGE") {
 | 
			
		||||
            if (dayIndex == calendarProgress.SeasonProgress.LastCompletedDayIdx) {
 | 
			
		||||
                if (day.events.length != 0) {
 | 
			
		||||
                    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}`);
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                break;
 | 
			
		||||
            }
 | 
			
		||||
            ++dayIndex;
 | 
			
		||||
    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++;
 | 
			
		||||
    calendarProgress.SeasonProgress.LastCompletedDayIdx = dayIndex;
 | 
			
		||||
    checkCalendarChallengeCompletion(calendarProgress, currentSeason);
 | 
			
		||||
    await inventory.save();
 | 
			
		||||
    res.json({
 | 
			
		||||
        InventoryChanges: inventoryChanges,
 | 
			
		||||
 | 
			
		||||
@ -4,9 +4,15 @@ import { getAccountIdForRequest } from "@/src/services/loginService";
 | 
			
		||||
import { getInventory } from "@/src/services/inventoryService";
 | 
			
		||||
 | 
			
		||||
export const creditsController: RequestHandler = async (req, res) => {
 | 
			
		||||
    const accountId = await getAccountIdForRequest(req);
 | 
			
		||||
 | 
			
		||||
    const inventory = await getInventory(accountId, "RegularCredits TradesRemaining PremiumCreditsFree PremiumCredits");
 | 
			
		||||
    const inventory = (
 | 
			
		||||
        await Promise.all([
 | 
			
		||||
            getAccountIdForRequest(req),
 | 
			
		||||
            getInventory(
 | 
			
		||||
                req.query.accountId as string,
 | 
			
		||||
                "RegularCredits TradesRemaining PremiumCreditsFree PremiumCredits"
 | 
			
		||||
            )
 | 
			
		||||
        ])
 | 
			
		||||
    )[1];
 | 
			
		||||
 | 
			
		||||
    const response = {
 | 
			
		||||
        RegularCredits: inventory.RegularCredits,
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										107
									
								
								src/controllers/api/crewShipFusionController.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										107
									
								
								src/controllers/api/crewShipFusionController.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,107 @@
 | 
			
		||||
import { getJSONfromString } from "@/src/helpers/stringHelpers";
 | 
			
		||||
import { addMiscItems, freeUpSlot, getInventory, updateCurrency } from "@/src/services/inventoryService";
 | 
			
		||||
import { getAccountIdForRequest } from "@/src/services/loginService";
 | 
			
		||||
import { IOid } from "@/src/types/commonTypes";
 | 
			
		||||
import { ICrewShipComponentFingerprint, InventorySlot } from "@/src/types/inventoryTypes/inventoryTypes";
 | 
			
		||||
import { IInventoryChanges } from "@/src/types/purchaseTypes";
 | 
			
		||||
import { 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<ICrewShipFusionRequest>(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) {
 | 
			
		||||
            const useSuperiorSubroutine = tierA < tierB ? !payload.UseSubroutineA : payload.UseSubroutineA;
 | 
			
		||||
            if (!useSuperiorSubroutine) {
 | 
			
		||||
                fingerprint.SubroutineIndex = inferiorFingerprint.SubroutineIndex;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    superiorItem.UpgradeFingerprint = JSON.stringify(fingerprint);
 | 
			
		||||
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | 
			
		||||
    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];
 | 
			
		||||
@ -30,15 +30,14 @@ export const fishmongerController: RequestHandler = async (req, res) => {
 | 
			
		||||
        miscItemChanges.push({ ItemType: fish.ItemType, ItemCount: fish.ItemCount * -1 });
 | 
			
		||||
    }
 | 
			
		||||
    addMiscItems(inventory, miscItemChanges);
 | 
			
		||||
    let affiliationMod;
 | 
			
		||||
    if (gainedStanding && syndicateTag) affiliationMod = addStanding(inventory, syndicateTag, gainedStanding);
 | 
			
		||||
    if (gainedStanding && syndicateTag) addStanding(inventory, syndicateTag, gainedStanding);
 | 
			
		||||
    await inventory.save();
 | 
			
		||||
    res.json({
 | 
			
		||||
        InventoryChanges: {
 | 
			
		||||
            MiscItems: miscItemChanges
 | 
			
		||||
        },
 | 
			
		||||
        SyndicateTag: syndicateTag,
 | 
			
		||||
        StandingChange: affiliationMod?.Standing || 0
 | 
			
		||||
        StandingChange: gainedStanding
 | 
			
		||||
    });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -43,7 +43,7 @@ export const focusController: RequestHandler = async (req, res) => {
 | 
			
		||||
            inventory.FocusAbility ??= focusType;
 | 
			
		||||
            inventory.FocusUpgrades.push({ ItemType: focusType });
 | 
			
		||||
            if (inventory.FocusXP) {
 | 
			
		||||
                inventory.FocusXP[focusPolarity] -= cost;
 | 
			
		||||
                inventory.FocusXP[focusPolarity]! -= cost;
 | 
			
		||||
            }
 | 
			
		||||
            await inventory.save();
 | 
			
		||||
            res.json({
 | 
			
		||||
@ -78,7 +78,7 @@ export const focusController: RequestHandler = async (req, res) => {
 | 
			
		||||
                cost += ExportFocusUpgrades[focusType].baseFocusPointCost;
 | 
			
		||||
                inventory.FocusUpgrades.push({ ItemType: focusType, Level: 0 });
 | 
			
		||||
            }
 | 
			
		||||
            inventory.FocusXP![focusPolarity] -= cost;
 | 
			
		||||
            inventory.FocusXP![focusPolarity]! -= cost;
 | 
			
		||||
            await inventory.save();
 | 
			
		||||
            res.json({
 | 
			
		||||
                FocusTypes: request.FocusTypes,
 | 
			
		||||
@ -96,7 +96,7 @@ export const focusController: RequestHandler = async (req, res) => {
 | 
			
		||||
                const focusUpgradeDb = inventory.FocusUpgrades.find(entry => entry.ItemType == focusUpgrade.ItemType)!;
 | 
			
		||||
                focusUpgradeDb.Level = focusUpgrade.Level;
 | 
			
		||||
            }
 | 
			
		||||
            inventory.FocusXP![focusPolarity] -= cost;
 | 
			
		||||
            inventory.FocusXP![focusPolarity]! -= cost;
 | 
			
		||||
            await inventory.save();
 | 
			
		||||
            res.json({
 | 
			
		||||
                FocusInfos: request.FocusInfos,
 | 
			
		||||
@ -123,7 +123,7 @@ export const focusController: RequestHandler = async (req, res) => {
 | 
			
		||||
            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;
 | 
			
		||||
            inventory.FocusXP![focusPolarity]! -= 750_000 * request.FocusTypes.length;
 | 
			
		||||
            addMiscItems(inventory, [
 | 
			
		||||
                {
 | 
			
		||||
                    ItemType: "/Lotus/Types/Gameplay/Eidolon/Resources/SentientShards/SentientShardBrilliantItem",
 | 
			
		||||
@ -168,8 +168,10 @@ export const focusController: RequestHandler = async (req, res) => {
 | 
			
		||||
                shard.ItemCount *= -1;
 | 
			
		||||
            }
 | 
			
		||||
            const inventory = await getInventory(accountId);
 | 
			
		||||
            inventory.FocusXP ??= { AP_POWER: 0, AP_TACTIC: 0, AP_DEFENSE: 0, AP_ATTACK: 0, AP_WARD: 0 };
 | 
			
		||||
            inventory.FocusXP[request.Polarity] += xp;
 | 
			
		||||
            const polarity = request.Polarity;
 | 
			
		||||
            inventory.FocusXP ??= {};
 | 
			
		||||
            inventory.FocusXP[polarity] ??= 0;
 | 
			
		||||
            inventory.FocusXP[polarity] += xp;
 | 
			
		||||
            addMiscItems(inventory, request.Shards);
 | 
			
		||||
            await inventory.save();
 | 
			
		||||
            break;
 | 
			
		||||
 | 
			
		||||
@ -1,8 +1,10 @@
 | 
			
		||||
import { DailyDeal } from "@/src/models/worldStateModel";
 | 
			
		||||
import { RequestHandler } from "express";
 | 
			
		||||
 | 
			
		||||
export const getDailyDealStockLevelsController: RequestHandler = (req, res) => {
 | 
			
		||||
export const getDailyDealStockLevelsController: RequestHandler = async (req, res) => {
 | 
			
		||||
    const dailyDeal = (await DailyDeal.findOne({ StoreItem: req.query.productName }, "AmountSold"))!;
 | 
			
		||||
    res.json({
 | 
			
		||||
        StoreItem: req.query.productName,
 | 
			
		||||
        AmountSold: 0
 | 
			
		||||
        AmountSold: dailyDeal.AmountSold
 | 
			
		||||
    });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -2,6 +2,7 @@ import { RequestHandler } from "express";
 | 
			
		||||
import { applyStandingToVendorManifest, getVendorManifestByTypeName } from "@/src/services/serversideVendorsService";
 | 
			
		||||
import { getInventory } from "@/src/services/inventoryService";
 | 
			
		||||
import { getAccountIdForRequest } from "@/src/services/loginService";
 | 
			
		||||
import { config } from "@/src/services/configService";
 | 
			
		||||
 | 
			
		||||
export const getVendorInfoController: RequestHandler = async (req, res) => {
 | 
			
		||||
    let manifest = getVendorManifestByTypeName(req.query.vendor as string);
 | 
			
		||||
@ -14,6 +15,14 @@ export const getVendorInfoController: RequestHandler = async (req, res) => {
 | 
			
		||||
        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);
 | 
			
		||||
 | 
			
		||||
@ -9,15 +9,26 @@ import {
 | 
			
		||||
    updateCurrency
 | 
			
		||||
} from "@/src/services/inventoryService";
 | 
			
		||||
import { getAccountForRequest, getSuffixedName } from "@/src/services/loginService";
 | 
			
		||||
import { handleStoreItemAcquisition } from "@/src/services/purchaseService";
 | 
			
		||||
import { handleDailyDealPurchase, handleStoreItemAcquisition } from "@/src/services/purchaseService";
 | 
			
		||||
import { IOid } from "@/src/types/commonTypes";
 | 
			
		||||
import { IInventoryChanges, IPurchaseParams } from "@/src/types/purchaseTypes";
 | 
			
		||||
import { IPurchaseParams, IPurchaseResponse, PurchaseSource } from "@/src/types/purchaseTypes";
 | 
			
		||||
import { 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<IGiftingRequest>(String(req.body));
 | 
			
		||||
    if (data.PurchaseParams.Source != 0 || !data.PurchaseParams.UsePremium) {
 | 
			
		||||
    if (!checkPurchaseParams(data.PurchaseParams)) {
 | 
			
		||||
        throw new Error(`unexpected purchase params in gifting request: ${String(req.body)}`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -58,16 +69,19 @@ export const giftingController: RequestHandler = async (req, res) => {
 | 
			
		||||
    }
 | 
			
		||||
    senderInventory.GiftsRemaining -= 1;
 | 
			
		||||
 | 
			
		||||
    const inventoryChanges: IInventoryChanges = updateCurrency(
 | 
			
		||||
        senderInventory,
 | 
			
		||||
        data.PurchaseParams.ExpectedPrice,
 | 
			
		||||
        true
 | 
			
		||||
    );
 | 
			
		||||
    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(
 | 
			
		||||
                inventoryChanges,
 | 
			
		||||
                response.InventoryChanges,
 | 
			
		||||
                (await handleStoreItemAcquisition(bundle.giftingBonus, senderInventory)).InventoryChanges
 | 
			
		||||
            );
 | 
			
		||||
        }
 | 
			
		||||
@ -99,9 +113,7 @@ export const giftingController: RequestHandler = async (req, res) => {
 | 
			
		||||
        }
 | 
			
		||||
    ]);
 | 
			
		||||
 | 
			
		||||
    res.json({
 | 
			
		||||
        InventoryChanges: inventoryChanges
 | 
			
		||||
    });
 | 
			
		||||
    res.json(response);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
interface IGiftingRequest {
 | 
			
		||||
 | 
			
		||||
@ -5,13 +5,14 @@ import {
 | 
			
		||||
    getGuildVault,
 | 
			
		||||
    hasAccessToDojo,
 | 
			
		||||
    hasGuildPermission,
 | 
			
		||||
    processCompletedGuildTechProject,
 | 
			
		||||
    processFundedGuildTechProject,
 | 
			
		||||
    processGuildTechProjectContributionsUpdate,
 | 
			
		||||
    removePigmentsFromGuildMembers,
 | 
			
		||||
    scaleRequiredCount,
 | 
			
		||||
    setGuildTechLogState
 | 
			
		||||
} from "@/src/services/guildService";
 | 
			
		||||
import { ExportDojoRecipes } from "warframe-public-export-plus";
 | 
			
		||||
import { ExportDojoRecipes, ExportRailjackWeapons } from "warframe-public-export-plus";
 | 
			
		||||
import { getAccountIdForRequest } from "@/src/services/loginService";
 | 
			
		||||
import {
 | 
			
		||||
    addCrewShipWeaponSkin,
 | 
			
		||||
@ -51,8 +52,12 @@ export const guildTechController: RequestHandler = async (req, res) => {
 | 
			
		||||
                };
 | 
			
		||||
                if (project.CompletionDate) {
 | 
			
		||||
                    techProject.CompletionDate = toMongoDate(project.CompletionDate);
 | 
			
		||||
                    if (Date.now() >= project.CompletionDate.getTime()) {
 | 
			
		||||
                        needSave ||= setGuildTechLogState(guild, project.ItemType, 4, project.CompletionDate);
 | 
			
		||||
                    if (
 | 
			
		||||
                        Date.now() >= project.CompletionDate.getTime() &&
 | 
			
		||||
                        setGuildTechLogState(guild, project.ItemType, 4, project.CompletionDate)
 | 
			
		||||
                    ) {
 | 
			
		||||
                        processCompletedGuildTechProject(guild, project.ItemType);
 | 
			
		||||
                        needSave = true;
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                techProjects.push(techProject);
 | 
			
		||||
@ -442,6 +447,7 @@ const finishComponentRepair = (
 | 
			
		||||
        ...(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)
 | 
			
		||||
 | 
			
		||||
@ -30,8 +30,9 @@ export const infestedFoundryController: RequestHandler = async (req, res) => {
 | 
			
		||||
            const request = getJSONfromString<IShardInstallRequest>(String(req.body));
 | 
			
		||||
            const inventory = await getInventory(account._id.toString());
 | 
			
		||||
            const suit = inventory.Suits.id(request.SuitId.$oid)!;
 | 
			
		||||
            if (!suit.ArchonCrystalUpgrades || suit.ArchonCrystalUpgrades.length != 5) {
 | 
			
		||||
                suit.ArchonCrystalUpgrades = [{}, {}, {}, {}, {}];
 | 
			
		||||
            suit.ArchonCrystalUpgrades ??= [];
 | 
			
		||||
            while (suit.ArchonCrystalUpgrades.length < request.Slot) {
 | 
			
		||||
                suit.ArchonCrystalUpgrades.push({});
 | 
			
		||||
            }
 | 
			
		||||
            suit.ArchonCrystalUpgrades[request.Slot] = {
 | 
			
		||||
                UpgradeType: request.UpgradeType,
 | 
			
		||||
@ -92,7 +93,8 @@ export const infestedFoundryController: RequestHandler = async (req, res) => {
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // remove from suit
 | 
			
		||||
            suit.ArchonCrystalUpgrades![request.Slot] = {};
 | 
			
		||||
            suit.ArchonCrystalUpgrades![request.Slot].UpgradeType = undefined;
 | 
			
		||||
            suit.ArchonCrystalUpgrades![request.Slot].Color = undefined;
 | 
			
		||||
 | 
			
		||||
            await inventory.save();
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -6,29 +6,28 @@ import allDialogue from "@/static/fixed_responses/allDialogue.json";
 | 
			
		||||
import { ILoadoutDatabase } from "@/src/types/saveLoadoutTypes";
 | 
			
		||||
import { IInventoryClient, IShipInventory, equipmentKeys } from "@/src/types/inventoryTypes/inventoryTypes";
 | 
			
		||||
import { IPolarity, ArtifactPolarity, EquipmentFeatures } from "@/src/types/inventoryTypes/commonInventoryTypes";
 | 
			
		||||
import {
 | 
			
		||||
    ExportCustoms,
 | 
			
		||||
    ExportFlavour,
 | 
			
		||||
    ExportRegions,
 | 
			
		||||
    ExportResources,
 | 
			
		||||
    ExportVirtuals
 | 
			
		||||
} from "warframe-public-export-plus";
 | 
			
		||||
import { ExportCustoms, ExportFlavour, ExportResources, ExportVirtuals } from "warframe-public-export-plus";
 | 
			
		||||
import { applyCheatsToInfestedFoundry, handleSubsumeCompletion } from "@/src/services/infestedFoundryService";
 | 
			
		||||
import {
 | 
			
		||||
    addEmailItem,
 | 
			
		||||
    addMiscItems,
 | 
			
		||||
    allDailyAffiliationKeys,
 | 
			
		||||
    cleanupInventory,
 | 
			
		||||
    createLibraryDailyTask,
 | 
			
		||||
    generateRewardSeed
 | 
			
		||||
    generateRewardSeed,
 | 
			
		||||
    getCalendarProgress
 | 
			
		||||
} from "@/src/services/inventoryService";
 | 
			
		||||
import { logger } from "@/src/utils/logger";
 | 
			
		||||
import { catBreadHash } from "@/src/helpers/stringHelpers";
 | 
			
		||||
import { addString, catBreadHash } from "@/src/helpers/stringHelpers";
 | 
			
		||||
import { Types } from "mongoose";
 | 
			
		||||
import { getNemesisManifest } from "@/src/helpers/nemesisHelpers";
 | 
			
		||||
import { getPersonalRooms } from "@/src/services/personalRoomsService";
 | 
			
		||||
import { IPersonalRoomsClient } from "@/src/types/personalRoomsTypes";
 | 
			
		||||
import { Ship } from "@/src/models/shipModel";
 | 
			
		||||
import { toLegacyOid, version_compare } from "@/src/helpers/inventoryHelpers";
 | 
			
		||||
import { toLegacyOid, toOid, version_compare } from "@/src/helpers/inventoryHelpers";
 | 
			
		||||
import { Inbox } from "@/src/models/inboxModel";
 | 
			
		||||
import { unixTimesInMs } from "@/src/constants/timeConstants";
 | 
			
		||||
import { DailyDeal } from "@/src/models/worldStateModel";
 | 
			
		||||
 | 
			
		||||
export const inventoryController: RequestHandler = async (request, response) => {
 | 
			
		||||
    const account = await getAccountForRequest(request);
 | 
			
		||||
@ -42,6 +41,8 @@ export const inventoryController: RequestHandler = async (request, response) =>
 | 
			
		||||
 | 
			
		||||
    // 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;
 | 
			
		||||
        }
 | 
			
		||||
@ -52,12 +53,12 @@ export const inventoryController: RequestHandler = async (request, response) =>
 | 
			
		||||
        inventory.LibraryAvailableDailyTaskInfo = createLibraryDailyTask();
 | 
			
		||||
 | 
			
		||||
        if (inventory.NextRefill) {
 | 
			
		||||
            const lastLoginDay = Math.trunc(inventory.NextRefill.getTime() / 86400000) - 1;
 | 
			
		||||
            const daysPassed = today - lastLoginDay;
 | 
			
		||||
 | 
			
		||||
            if (config.noArgonCrystalDecay) {
 | 
			
		||||
                inventory.FoundToday = undefined;
 | 
			
		||||
            } else {
 | 
			
		||||
                const lastLoginDay = Math.trunc(inventory.NextRefill.getTime() / 86400000) - 1;
 | 
			
		||||
                const today = Math.trunc(Date.now() / 86400000);
 | 
			
		||||
                const daysPassed = today - lastLoginDay;
 | 
			
		||||
                for (let i = 0; i != daysPassed; ++i) {
 | 
			
		||||
                    const numArgonCrystals =
 | 
			
		||||
                        inventory.MiscItems.find(x => x.ItemType == "/Lotus/Types/Items/MiscItems/ArgonCrystal")
 | 
			
		||||
@ -89,11 +90,84 @@ export const inventoryController: RequestHandler = async (request, response) =>
 | 
			
		||||
                    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 = [];
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (inventory.CalendarProgress) {
 | 
			
		||||
            const previousYearIteration = inventory.CalendarProgress.Iteration;
 | 
			
		||||
            getCalendarProgress(inventory); // handle year rollover; the client expects to receive an inventory with an up-to-date CalendarProgress
 | 
			
		||||
 | 
			
		||||
            // 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((Math.trunc(Date.now() / 86400000) + 1) * 86400000);
 | 
			
		||||
        inventory.NextRefill = new Date((today + 1) * 86400000); // tomorrow at 0 UTC
 | 
			
		||||
        //await inventory.save();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -134,13 +208,21 @@ export const getInventoryResponse = async (
 | 
			
		||||
    xpBasedLevelCapDisabled: boolean,
 | 
			
		||||
    buildLabel: string | undefined
 | 
			
		||||
): Promise<IInventoryClient> => {
 | 
			
		||||
    const [inventoryWithLoadOutPresets, ships] = await Promise.all([
 | 
			
		||||
    const [inventoryWithLoadOutPresets, ships, latestMessage] = await Promise.all([
 | 
			
		||||
        inventory.populate<{ LoadOutPresets: ILoadoutDatabase }>("LoadOutPresets"),
 | 
			
		||||
        Ship.find({ ShipOwnerId: inventory.accountOwnerId })
 | 
			
		||||
        Ship.find({ ShipOwnerId: inventory.accountOwnerId }),
 | 
			
		||||
        Inbox.findOne({ ownerId: inventory.accountOwnerId }, "_id").sort({ date: -1 })
 | 
			
		||||
    ]);
 | 
			
		||||
    const inventoryResponse = inventoryWithLoadOutPresets.toJSON<IInventoryClient>();
 | 
			
		||||
    inventoryResponse.Ships = ships.map(x => x.toJSON<IShipInventory>());
 | 
			
		||||
 | 
			
		||||
    // 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 (config.infiniteCredits) {
 | 
			
		||||
        inventoryResponse.RegularCredits = 999999999;
 | 
			
		||||
    }
 | 
			
		||||
@ -167,18 +249,6 @@ export const getInventoryResponse = async (
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (config.unlockAllMissions) {
 | 
			
		||||
        inventoryResponse.Missions = [];
 | 
			
		||||
        for (const tag of Object.keys(ExportRegions)) {
 | 
			
		||||
            inventoryResponse.Missions.push({
 | 
			
		||||
                Completes: 1,
 | 
			
		||||
                Tier: 1,
 | 
			
		||||
                Tag: tag
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
        addString(inventoryResponse.NodeIntrosCompleted, "TeshinHardModeUnlocked");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (config.unlockAllShipDecorations) {
 | 
			
		||||
        inventoryResponse.ShipDecorations = [];
 | 
			
		||||
        for (const [uniqueName, item] of Object.entries(ExportResources)) {
 | 
			
		||||
@ -300,9 +370,6 @@ export const getInventoryResponse = async (
 | 
			
		||||
        applyCheatsToInfestedFoundry(inventoryResponse.InfestedFoundry);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Omitting this field so opening the navigation resyncs the inventory which is more desirable for typical usage.
 | 
			
		||||
    inventoryResponse.LastInventorySync = undefined;
 | 
			
		||||
 | 
			
		||||
    // Set 2FA enabled so trading post can be used
 | 
			
		||||
    inventoryResponse.HWIDProtectEnabled = true;
 | 
			
		||||
 | 
			
		||||
@ -339,14 +406,41 @@ export const getInventoryResponse = async (
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (config.unlockAllProfitTakerStages) {
 | 
			
		||||
        inventoryResponse.CompletedJobChains ??= [];
 | 
			
		||||
        const EudicoHeists = inventoryResponse.CompletedJobChains.find(x => x.LocationTag == "EudicoHeists");
 | 
			
		||||
        if (EudicoHeists) {
 | 
			
		||||
            EudicoHeists.Jobs = allEudicoHeistJobs;
 | 
			
		||||
        } else {
 | 
			
		||||
            inventoryResponse.CompletedJobChains.push({
 | 
			
		||||
                LocationTag: "EudicoHeists",
 | 
			
		||||
                Jobs: allEudicoHeistJobs
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (config.unlockAllSimarisResearchEntries) {
 | 
			
		||||
        inventoryResponse.LibraryPersonalTarget = undefined;
 | 
			
		||||
        inventoryResponse.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 }));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return inventoryResponse;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const addString = (arr: string[], str: string): void => {
 | 
			
		||||
    if (arr.indexOf(str) == -1) {
 | 
			
		||||
        arr.push(str);
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
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"
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
const getExpRequiredForMr = (rank: number): number => {
 | 
			
		||||
    if (rank <= 30) {
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,6 @@
 | 
			
		||||
import { getAccountIdForRequest } from "@/src/services/loginService";
 | 
			
		||||
import { getInventory, updateCurrency } from "@/src/services/inventoryService";
 | 
			
		||||
import { getInventory, updateCurrency, updateSlots } from "@/src/services/inventoryService";
 | 
			
		||||
import { RequestHandler } from "express";
 | 
			
		||||
import { updateSlots } from "@/src/services/inventoryService";
 | 
			
		||||
import { InventorySlot } from "@/src/types/inventoryTypes/inventoryTypes";
 | 
			
		||||
import { logger } from "@/src/utils/logger";
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -4,16 +4,16 @@ import { config } from "@/src/services/configService";
 | 
			
		||||
import { buildConfig } from "@/src/services/buildConfigService";
 | 
			
		||||
 | 
			
		||||
import { Account } from "@/src/models/loginModel";
 | 
			
		||||
import { createAccount, isCorrectPassword, isNameTaken } from "@/src/services/loginService";
 | 
			
		||||
import { createAccount, createNonce, getUsernameFromEmail, isCorrectPassword } from "@/src/services/loginService";
 | 
			
		||||
import { IDatabaseAccountJson, ILoginRequest, ILoginResponse } from "@/src/types/loginTypes";
 | 
			
		||||
import { logger } from "@/src/utils/logger";
 | 
			
		||||
import { version_compare } from "@/src/helpers/inventoryHelpers";
 | 
			
		||||
import { sendWsBroadcastTo } from "@/src/services/webService";
 | 
			
		||||
 | 
			
		||||
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 nonce = Math.round(Math.random() * Number.MAX_SAFE_INTEGER);
 | 
			
		||||
 | 
			
		||||
    const buildLabel: string =
 | 
			
		||||
        typeof request.query.buildLabel == "string"
 | 
			
		||||
@ -42,26 +42,14 @@ export const loginController: RequestHandler = async (request, response) => {
 | 
			
		||||
            loginRequest.ClientType == "webui-register")
 | 
			
		||||
    ) {
 | 
			
		||||
        try {
 | 
			
		||||
            const nameFromEmail = loginRequest.email.substring(0, loginRequest.email.indexOf("@"));
 | 
			
		||||
            let name = nameFromEmail || loginRequest.email.substring(1) || "SpaceNinja";
 | 
			
		||||
            if (await isNameTaken(name)) {
 | 
			
		||||
                let suffix = 0;
 | 
			
		||||
                do {
 | 
			
		||||
                    ++suffix;
 | 
			
		||||
                    name = nameFromEmail + suffix;
 | 
			
		||||
                } while (await isNameTaken(name));
 | 
			
		||||
            }
 | 
			
		||||
            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 == "webui-register" ? "webui" : loginRequest.ClientType,
 | 
			
		||||
                CrossPlatformAllowed: true,
 | 
			
		||||
                ForceLogoutVersion: 0,
 | 
			
		||||
                ConsentNeeded: false,
 | 
			
		||||
                TrackedSettings: [],
 | 
			
		||||
                Nonce: nonce,
 | 
			
		||||
                ClientType: loginRequest.ClientType,
 | 
			
		||||
                Nonce: createNonce(),
 | 
			
		||||
                BuildLabel: buildLabel,
 | 
			
		||||
                LastLogin: new Date()
 | 
			
		||||
            });
 | 
			
		||||
@ -80,38 +68,29 @@ export const loginController: RequestHandler = async (request, response) => {
 | 
			
		||||
        return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (loginRequest.ClientType == "webui-register") {
 | 
			
		||||
        response.status(400).json({ error: "account already exists" });
 | 
			
		||||
        return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!isCorrectPassword(loginRequest.password, account.password)) {
 | 
			
		||||
        response.status(400).json({ error: "incorrect login data" });
 | 
			
		||||
        return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (loginRequest.ClientType == "webui") {
 | 
			
		||||
        if (!account.Nonce) {
 | 
			
		||||
            account.ClientType = "webui";
 | 
			
		||||
            account.Nonce = nonce;
 | 
			
		||||
    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;
 | 
			
		||||
        }
 | 
			
		||||
    } else {
 | 
			
		||||
        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 = nonce;
 | 
			
		||||
        account.CountryCode = loginRequest.lang?.toUpperCase() ?? "EN";
 | 
			
		||||
        account.BuildLabel = buildLabel;
 | 
			
		||||
        account.LastLogin = new Date();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    account.ClientType = loginRequest.ClientType;
 | 
			
		||||
    account.Nonce = createNonce();
 | 
			
		||||
    account.CountryCode = loginRequest.lang?.toUpperCase() ?? "EN";
 | 
			
		||||
    account.BuildLabel = buildLabel;
 | 
			
		||||
    account.LastLogin = new Date();
 | 
			
		||||
    await account.save();
 | 
			
		||||
 | 
			
		||||
    // Tell WebUI its nonce has been invalidated
 | 
			
		||||
    sendWsBroadcastTo(account._id.toString(), { logged_out: true });
 | 
			
		||||
 | 
			
		||||
    response.json(createLoginResponse(myAddress, myUrlBase, account.toJSON(), buildLabel));
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -8,6 +8,8 @@ import {
 | 
			
		||||
    setAccountGotLoginRewardToday
 | 
			
		||||
} from "@/src/services/loginRewardService";
 | 
			
		||||
import { getInventory } from "@/src/services/inventoryService";
 | 
			
		||||
import { config } from "@/src/services/configService";
 | 
			
		||||
import { sendWsBroadcastTo } from "@/src/services/webService";
 | 
			
		||||
 | 
			
		||||
export const loginRewardsController: RequestHandler = async (req, res) => {
 | 
			
		||||
    const account = await getAccountForRequest(req);
 | 
			
		||||
@ -15,7 +17,7 @@ export const loginRewardsController: RequestHandler = async (req, res) => {
 | 
			
		||||
    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) {
 | 
			
		||||
    if (today == account.LastLoginRewardDate || config.disableDailyTribute) {
 | 
			
		||||
        res.json({
 | 
			
		||||
            DailyTributeInfo: {
 | 
			
		||||
                IsMilestoneDay: isMilestoneDay,
 | 
			
		||||
@ -46,10 +48,10 @@ export const loginRewardsController: RequestHandler = async (req, res) => {
 | 
			
		||||
        response.DailyTributeInfo.HasChosenReward = true;
 | 
			
		||||
        response.DailyTributeInfo.ChosenReward = randomRewards[0];
 | 
			
		||||
        response.DailyTributeInfo.NewInventory = await claimLoginReward(inventory, randomRewards[0]);
 | 
			
		||||
        await inventory.save();
 | 
			
		||||
 | 
			
		||||
        setAccountGotLoginRewardToday(account);
 | 
			
		||||
        await account.save();
 | 
			
		||||
        await Promise.all([inventory.save(), account.save()]);
 | 
			
		||||
 | 
			
		||||
        sendWsBroadcastTo(account._id.toString(), { update_inventory: true });
 | 
			
		||||
    }
 | 
			
		||||
    res.json(response);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -6,6 +6,7 @@ import {
 | 
			
		||||
} from "@/src/services/loginRewardService";
 | 
			
		||||
import { getAccountForRequest } from "@/src/services/loginService";
 | 
			
		||||
import { handleStoreItemAcquisition } from "@/src/services/purchaseService";
 | 
			
		||||
import { sendWsBroadcastTo } from "@/src/services/webService";
 | 
			
		||||
import { IInventoryChanges } from "@/src/types/purchaseTypes";
 | 
			
		||||
import { logger } from "@/src/utils/logger";
 | 
			
		||||
import { RequestHandler } from "express";
 | 
			
		||||
@ -34,11 +35,10 @@ export const loginRewardsSelectionController: RequestHandler = async (req, res)
 | 
			
		||||
        chosenReward = randomRewards.find(x => x.StoreItemType == body.ChosenReward)!;
 | 
			
		||||
        inventoryChanges = await claimLoginReward(inventory, chosenReward);
 | 
			
		||||
    }
 | 
			
		||||
    await inventory.save();
 | 
			
		||||
 | 
			
		||||
    setAccountGotLoginRewardToday(account);
 | 
			
		||||
    await account.save();
 | 
			
		||||
    await Promise.all([inventory.save(), account.save()]);
 | 
			
		||||
 | 
			
		||||
    sendWsBroadcastTo(account._id.toString(), { update_inventory: true });
 | 
			
		||||
    res.json({
 | 
			
		||||
        DailyTributeInfo: {
 | 
			
		||||
            NewInventory: inventoryChanges,
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,6 @@
 | 
			
		||||
import { RequestHandler } from "express";
 | 
			
		||||
import { Account } from "@/src/models/loginModel";
 | 
			
		||||
import { sendWsBroadcastTo } from "@/src/services/webService";
 | 
			
		||||
 | 
			
		||||
export const logoutController: RequestHandler = async (req, res) => {
 | 
			
		||||
    if (!req.query.accountId) {
 | 
			
		||||
@ -10,7 +11,7 @@ export const logoutController: RequestHandler = async (req, res) => {
 | 
			
		||||
        throw new Error("Request is missing nonce parameter");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    await Account.updateOne(
 | 
			
		||||
    const stat = await Account.updateOne(
 | 
			
		||||
        {
 | 
			
		||||
            _id: req.query.accountId,
 | 
			
		||||
            Nonce: nonce
 | 
			
		||||
@ -19,6 +20,10 @@ export const logoutController: RequestHandler = async (req, res) => {
 | 
			
		||||
            Nonce: 0
 | 
			
		||||
        }
 | 
			
		||||
    );
 | 
			
		||||
    if (stat.modifiedCount) {
 | 
			
		||||
        // Tell WebUI its nonce has been invalidated
 | 
			
		||||
        sendWsBroadcastTo(req.query.accountId as string, { logged_out: true });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    res.writeHead(200, {
 | 
			
		||||
        "Content-Type": "text/html",
 | 
			
		||||
 | 
			
		||||
@ -7,6 +7,7 @@ import { generateRewardSeed, getInventory } from "@/src/services/inventoryServic
 | 
			
		||||
import { getInventoryResponse } from "./inventoryController";
 | 
			
		||||
import { logger } from "@/src/utils/logger";
 | 
			
		||||
import { IMissionInventoryUpdateResponse } from "@/src/types/missionTypes";
 | 
			
		||||
import { sendWsBroadcastTo } from "@/src/services/webService";
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
**** INPUT ****
 | 
			
		||||
@ -76,6 +77,7 @@ export const missionInventoryUpdateController: RequestHandler = async (req, res)
 | 
			
		||||
            InventoryJson: JSON.stringify(inventoryResponse),
 | 
			
		||||
            MissionRewards: []
 | 
			
		||||
        });
 | 
			
		||||
        sendWsBroadcastTo(account._id.toString(), { update_inventory: true });
 | 
			
		||||
        return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -86,7 +88,7 @@ export const missionInventoryUpdateController: RequestHandler = async (req, res)
 | 
			
		||||
        AffiliationMods,
 | 
			
		||||
        SyndicateXPItemReward,
 | 
			
		||||
        ConquestCompletedMissionsCount
 | 
			
		||||
    } = await addMissionRewards(inventory, missionReport, firstCompletion);
 | 
			
		||||
    } = await addMissionRewards(account, inventory, missionReport, firstCompletion);
 | 
			
		||||
 | 
			
		||||
    if (missionReport.EndOfMatchUpload) {
 | 
			
		||||
        inventory.RewardSeed = generateRewardSeed();
 | 
			
		||||
@ -106,6 +108,7 @@ export const missionInventoryUpdateController: RequestHandler = async (req, res)
 | 
			
		||||
        AffiliationMods,
 | 
			
		||||
        ConquestCompletedMissionsCount
 | 
			
		||||
    } satisfies IMissionInventoryUpdateResponse);
 | 
			
		||||
    sendWsBroadcastTo(account._id.toString(), { update_inventory: true });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
 | 
			
		||||
@ -3,6 +3,7 @@ import { getAccountIdForRequest } from "@/src/services/loginService";
 | 
			
		||||
import { getInventory, updateCurrency } from "@/src/services/inventoryService";
 | 
			
		||||
import { getJSONfromString } from "@/src/helpers/stringHelpers";
 | 
			
		||||
import { TEquipmentKey } from "@/src/types/inventoryTypes/inventoryTypes";
 | 
			
		||||
import { sendWsBroadcastTo } from "@/src/services/webService";
 | 
			
		||||
 | 
			
		||||
interface INameWeaponRequest {
 | 
			
		||||
    ItemName: string;
 | 
			
		||||
@ -27,4 +28,5 @@ export const nameWeaponController: RequestHandler = async (req, res) => {
 | 
			
		||||
    res.json({
 | 
			
		||||
        InventoryChanges: currencyChanges
 | 
			
		||||
    });
 | 
			
		||||
    sendWsBroadcastTo(accountId, { update_inventory: true });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,6 @@
 | 
			
		||||
import { version_compare } from "@/src/helpers/inventoryHelpers";
 | 
			
		||||
import {
 | 
			
		||||
    antivirusMods,
 | 
			
		||||
    consumeModCharge,
 | 
			
		||||
    decodeNemesisGuess,
 | 
			
		||||
    encodeNemesisGuess,
 | 
			
		||||
@ -7,13 +8,13 @@ import {
 | 
			
		||||
    getKnifeUpgrade,
 | 
			
		||||
    getNemesisManifest,
 | 
			
		||||
    getNemesisPasscode,
 | 
			
		||||
    getNemesisPasscodeModTypes,
 | 
			
		||||
    GUESS_CORRECT,
 | 
			
		||||
    GUESS_INCORRECT,
 | 
			
		||||
    GUESS_NEUTRAL,
 | 
			
		||||
    GUESS_NONE,
 | 
			
		||||
    GUESS_WILDCARD,
 | 
			
		||||
    IKnifeResponse
 | 
			
		||||
    IKnifeResponse,
 | 
			
		||||
    parseUpgrade
 | 
			
		||||
} from "@/src/helpers/nemesisHelpers";
 | 
			
		||||
import { getJSONfromString } from "@/src/helpers/stringHelpers";
 | 
			
		||||
import { Loadout } from "@/src/models/inventoryModels/loadoutModel";
 | 
			
		||||
@ -134,34 +135,38 @@ export const nemesisController: RequestHandler = async (req, res) => {
 | 
			
		||||
                for (const upgrade of body.knife!.AttachedUpgrades) {
 | 
			
		||||
                    switch (upgrade.ItemType) {
 | 
			
		||||
                        case "/Lotus/Upgrades/Mods/DataSpike/Potency/GainAntivirusAndSpeedOnUseMod":
 | 
			
		||||
                            antivirusGain += 10;
 | 
			
		||||
                            consumeModCharge(response, inventory, upgrade, dataknifeUpgrades);
 | 
			
		||||
                            break;
 | 
			
		||||
                        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
 | 
			
		||||
                            antivirusGain += 15;
 | 
			
		||||
                            consumeModCharge(response, inventory, upgrade, dataknifeUpgrades);
 | 
			
		||||
                            break;
 | 
			
		||||
                        case "/Lotus/Upgrades/Mods/DataSpike/Potency/GainAntivirusOnUseMod": // Immuno Shield
 | 
			
		||||
                            antivirusGain += 15;
 | 
			
		||||
                            consumeModCharge(response, inventory, upgrade, dataknifeUpgrades);
 | 
			
		||||
                            break;
 | 
			
		||||
                        case "/Lotus/Upgrades/Mods/DataSpike/Potency/GainAntivirusSmallOnSingleUseMod":
 | 
			
		||||
                            antivirusGain += 10;
 | 
			
		||||
                            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!.HenchmenKilled = 100;
 | 
			
		||||
            if (inventory.Nemesis!.HenchmenKilled < 100) {
 | 
			
		||||
                inventory.Nemesis!.InfNodes = getInfNodes(getNemesisManifest(inventory.Nemesis!.manifest), 0);
 | 
			
		||||
            }
 | 
			
		||||
            inventory.Nemesis!.InfNodes = getInfNodes(getNemesisManifest(inventory.Nemesis!.manifest), 0);
 | 
			
		||||
 | 
			
		||||
            await inventory.save();
 | 
			
		||||
            res.json(response);
 | 
			
		||||
@ -198,16 +203,40 @@ export const nemesisController: RequestHandler = async (req, res) => {
 | 
			
		||||
            guess[body.position].result = correct ? GUESS_CORRECT : GUESS_INCORRECT;
 | 
			
		||||
            inventory.Nemesis!.GuessHistory[inventory.Nemesis!.GuessHistory.length - 1] = encodeNemesisGuess(guess);
 | 
			
		||||
 | 
			
		||||
            // Increase rank if incorrect
 | 
			
		||||
            let RankIncrease: number | undefined;
 | 
			
		||||
            if (!correct) {
 | 
			
		||||
                RankIncrease = 1;
 | 
			
		||||
            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(
 | 
			
		||||
                        inventory.CurrentLoadOutIds[LoadoutIndex.DATAKNIFE].$oid
 | 
			
		||||
                    );
 | 
			
		||||
                    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({ RankIncrease });
 | 
			
		||||
            res.json(response);
 | 
			
		||||
        }
 | 
			
		||||
    } else if ((req.query.mode as string) == "rs") {
 | 
			
		||||
        // report spawn; POST but no application data in body
 | 
			
		||||
@ -277,36 +306,15 @@ export const nemesisController: RequestHandler = async (req, res) => {
 | 
			
		||||
            target: inventory.toJSON().Nemesis
 | 
			
		||||
        });
 | 
			
		||||
    } else if ((req.query.mode as string) == "w") {
 | 
			
		||||
        const inventory = await getInventory(
 | 
			
		||||
            account._id.toString(),
 | 
			
		||||
            "Nemesis LoadOutPresets CurrentLoadOutIds DataKnives Upgrades RawUpgrades"
 | 
			
		||||
        );
 | 
			
		||||
        const inventory = await getInventory(account._id.toString(), "Nemesis");
 | 
			
		||||
        //const body = getJSONfromString<INemesisWeakenRequest>(String(req.body));
 | 
			
		||||
 | 
			
		||||
        inventory.Nemesis!.InfNodes = [
 | 
			
		||||
            {
 | 
			
		||||
                Node: getNemesisManifest(inventory.Nemesis!.manifest).showdownNode,
 | 
			
		||||
                Influence: 1
 | 
			
		||||
            }
 | 
			
		||||
        ];
 | 
			
		||||
        inventory.Nemesis!.Weakened = true;
 | 
			
		||||
        // 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: IKnifeResponse & { target: INemesisClient } = {
 | 
			
		||||
        const response: INemesisWeakenResponse = {
 | 
			
		||||
            target: inventory.toJSON<IInventoryClient>().Nemesis!
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        // Consume charge of the correct requiem mod(s)
 | 
			
		||||
        const loadout = (await Loadout.findById(inventory.LoadOutPresets, "DATAKNIFE"))!;
 | 
			
		||||
        const dataknifeLoadout = loadout.DATAKNIFE.id(inventory.CurrentLoadOutIds[LoadoutIndex.DATAKNIFE].$oid);
 | 
			
		||||
        const dataknifeConfigIndex = dataknifeLoadout?.s?.mod ?? 0;
 | 
			
		||||
        const dataknifeUpgrades = inventory.DataKnives[0].Configs[dataknifeConfigIndex].Upgrades!;
 | 
			
		||||
        const modTypes = getNemesisPasscodeModTypes(inventory.Nemesis!);
 | 
			
		||||
        for (const modType of modTypes) {
 | 
			
		||||
            const upgrade = getKnifeUpgrade(inventory, dataknifeUpgrades, modType);
 | 
			
		||||
            consumeModCharge(response, inventory, upgrade, dataknifeUpgrades);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await inventory.save();
 | 
			
		||||
        res.json(response);
 | 
			
		||||
    } else {
 | 
			
		||||
        logger.debug(`data provided to ${req.path}: ${String(req.body)}`);
 | 
			
		||||
@ -362,11 +370,19 @@ interface INemesisRequiemRequest {
 | 
			
		||||
    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[];
 | 
			
		||||
 | 
			
		||||
@ -57,7 +57,15 @@ export const placeDecoInComponentController: RequestHandler = async (req, res) =
 | 
			
		||||
                component.DecoCapacity -= meta.capacityCost;
 | 
			
		||||
            }
 | 
			
		||||
        } else {
 | 
			
		||||
            const itemType = Object.entries(ExportResources).find(arr => arr[1].deco == deco.Type)![0];
 | 
			
		||||
            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;
 | 
			
		||||
@ -71,7 +79,13 @@ export const placeDecoInComponentController: RequestHandler = async (req, res) =
 | 
			
		||||
                if (meta) {
 | 
			
		||||
                    processDojoBuildMaterialsGathered(guild, meta);
 | 
			
		||||
                }
 | 
			
		||||
            } else if (guild.AutoContributeFromVault && guild.VaultRegularCredits && guild.VaultMiscItems) {
 | 
			
		||||
            } 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) {
 | 
			
		||||
 | 
			
		||||
@ -3,6 +3,7 @@ import { getAccountIdForRequest } from "@/src/services/loginService";
 | 
			
		||||
import { IPurchaseRequest } from "@/src/types/purchaseTypes";
 | 
			
		||||
import { handlePurchase } from "@/src/services/purchaseService";
 | 
			
		||||
import { getInventory } from "@/src/services/inventoryService";
 | 
			
		||||
import { sendWsBroadcastTo } from "@/src/services/webService";
 | 
			
		||||
 | 
			
		||||
export const purchaseController: RequestHandler = async (req, res) => {
 | 
			
		||||
    const purchaseRequest = JSON.parse(String(req.body)) as IPurchaseRequest;
 | 
			
		||||
@ -10,5 +11,7 @@ export const purchaseController: RequestHandler = async (req, res) => {
 | 
			
		||||
    const inventory = await getInventory(accountId);
 | 
			
		||||
    const response = await handlePurchase(purchaseRequest, inventory);
 | 
			
		||||
    await inventory.save();
 | 
			
		||||
    //console.log(JSON.stringify(response, null, 2));
 | 
			
		||||
    res.json(response);
 | 
			
		||||
    sendWsBroadcastTo(accountId, { update_inventory: true });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,8 @@
 | 
			
		||||
import { getJSONfromString } from "@/src/helpers/stringHelpers";
 | 
			
		||||
import { getInventory, updateCurrency } from "@/src/services/inventoryService";
 | 
			
		||||
import { getAccountIdForRequest } from "@/src/services/loginService";
 | 
			
		||||
import { sendWsBroadcastTo } from "@/src/services/webService";
 | 
			
		||||
import { IInventoryChanges } from "@/src/types/purchaseTypes";
 | 
			
		||||
import { RequestHandler } from "express";
 | 
			
		||||
 | 
			
		||||
export const renamePetController: RequestHandler = async (req, res) => {
 | 
			
		||||
@ -8,13 +10,20 @@ export const renamePetController: RequestHandler = async (req, res) => {
 | 
			
		||||
    const inventory = await getInventory(accountId, "KubrowPets PremiumCredits PremiumCreditsFree");
 | 
			
		||||
    const data = getJSONfromString<IRenamePetRequest>(String(req.body));
 | 
			
		||||
    const details = inventory.KubrowPets.id(data.petId)!.Details!;
 | 
			
		||||
 | 
			
		||||
    details.Name = data.name;
 | 
			
		||||
    const currencyChanges = updateCurrency(inventory, 15, true);
 | 
			
		||||
 | 
			
		||||
    const inventoryChanges: IInventoryChanges = {};
 | 
			
		||||
    if (!("webui" in req.query)) {
 | 
			
		||||
        updateCurrency(inventory, 15, true, inventoryChanges);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    await inventory.save();
 | 
			
		||||
    res.json({
 | 
			
		||||
        ...data,
 | 
			
		||||
        inventoryChanges: currencyChanges
 | 
			
		||||
        inventoryChanges: inventoryChanges
 | 
			
		||||
    });
 | 
			
		||||
    sendWsBroadcastTo(accountId, { update_inventory: true });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
interface IRenamePetRequest {
 | 
			
		||||
 | 
			
		||||
@ -24,7 +24,7 @@ export const saveDialogueController: RequestHandler = async (req, res) => {
 | 
			
		||||
        inventory.DialogueHistory.Dialogues ??= [];
 | 
			
		||||
        const dialogue = getDialogue(inventory, request.DialogueName);
 | 
			
		||||
        dialogue.Rank = request.Rank;
 | 
			
		||||
        dialogue.Chemistry = request.Chemistry;
 | 
			
		||||
        dialogue.Chemistry += request.Chemistry;
 | 
			
		||||
        dialogue.QueuedDialogues = request.QueuedDialogues;
 | 
			
		||||
        for (const bool of request.Booleans) {
 | 
			
		||||
            dialogue.Booleans.push(bool);
 | 
			
		||||
 | 
			
		||||
@ -15,9 +15,11 @@ import { InventorySlot } from "@/src/types/inventoryTypes/inventoryTypes";
 | 
			
		||||
import { ExportDojoRecipes } from "warframe-public-export-plus";
 | 
			
		||||
import { IInventoryChanges } from "@/src/types/purchaseTypes";
 | 
			
		||||
import { TInventoryDatabaseDocument } from "@/src/models/inventoryModels/inventoryModel";
 | 
			
		||||
import { sendWsBroadcastTo } from "@/src/services/webService";
 | 
			
		||||
 | 
			
		||||
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<keyof TInventoryDatabaseDocument>();
 | 
			
		||||
    if (payload.SellCurrency == "SC_RegularCredits") {
 | 
			
		||||
@ -57,6 +59,9 @@ export const sellController: RequestHandler = async (req, res) => {
 | 
			
		||||
    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");
 | 
			
		||||
@ -180,6 +185,17 @@ export const sellController: RequestHandler = async (req, res) => {
 | 
			
		||||
            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] == "/") {
 | 
			
		||||
@ -279,6 +295,7 @@ export const sellController: RequestHandler = async (req, res) => {
 | 
			
		||||
    res.json({
 | 
			
		||||
        inventoryChanges: inventoryChanges // "inventoryChanges" for this response instead of the usual "InventoryChanges"
 | 
			
		||||
    });
 | 
			
		||||
    sendWsBroadcastTo(accountId, { update_inventory: true });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
interface ISellRequest {
 | 
			
		||||
@ -301,6 +318,8 @@ interface ISellRequest {
 | 
			
		||||
        OperatorAmps?: ISellItem[];
 | 
			
		||||
        Hoverboards?: ISellItem[];
 | 
			
		||||
        Drones?: ISellItem[];
 | 
			
		||||
        KubrowPetPrints?: ISellItem[];
 | 
			
		||||
        CrewMembers?: ISellItem[];
 | 
			
		||||
        CrewShipWeapons?: ISellItem[];
 | 
			
		||||
        CrewShipWeaponSkins?: ISellItem[];
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										22
									
								
								src/controllers/api/setSuitInfectionController.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								src/controllers/api/setSuitInfectionController.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,22 @@
 | 
			
		||||
import { fromMongoDate, fromOid } from "@/src/helpers/inventoryHelpers";
 | 
			
		||||
import { getJSONfromString } from "@/src/helpers/stringHelpers";
 | 
			
		||||
import { getInventory } from "@/src/services/inventoryService";
 | 
			
		||||
import { getAccountIdForRequest } from "@/src/services/loginService";
 | 
			
		||||
import { IEquipmentClient } from "@/src/types/inventoryTypes/commonInventoryTypes";
 | 
			
		||||
import { RequestHandler } from "express";
 | 
			
		||||
 | 
			
		||||
export const setSuitInfectionController: RequestHandler = async (req, res) => {
 | 
			
		||||
    const accountId = await getAccountIdForRequest(req);
 | 
			
		||||
    const inventory = await getInventory(accountId, "Suits");
 | 
			
		||||
    const payload = getJSONfromString<ISetSuitInfectionRequest>(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[];
 | 
			
		||||
}
 | 
			
		||||
@ -45,9 +45,9 @@ export const startRecipeController: RequestHandler = async (req, res) => {
 | 
			
		||||
    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]));
 | 
			
		||||
                const index = inventory.KubrowPetEggs.findIndex(x => x._id.equals(startRecipeRequest.Ids[i]));
 | 
			
		||||
                if (index != -1) {
 | 
			
		||||
                    inventory.KubrowPetEggs!.splice(index, 1);
 | 
			
		||||
                    inventory.KubrowPetEggs.splice(index, 1);
 | 
			
		||||
                }
 | 
			
		||||
            } else {
 | 
			
		||||
                const category = ExportWeapons[recipe.ingredients[i].ItemType].productCategory;
 | 
			
		||||
@ -72,6 +72,10 @@ export const startRecipeController: RequestHandler = async (req, res) => {
 | 
			
		||||
    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,
 | 
			
		||||
 | 
			
		||||
@ -31,13 +31,13 @@ export const syndicateSacrificeController: RequestHandler = async (request, resp
 | 
			
		||||
        AffiliationTag: data.AffiliationTag,
 | 
			
		||||
        InventoryChanges: {},
 | 
			
		||||
        Level: data.SacrificeLevel,
 | 
			
		||||
        LevelIncrease: levelIncrease,
 | 
			
		||||
        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 + levelIncrease; level <= data.SacrificeLevel; ++level) {
 | 
			
		||||
    for (let level = oldLevel + Math.min(levelIncrease, 1); level <= data.SacrificeLevel; ++level) {
 | 
			
		||||
        let sacrifice: ISyndicateSacrifice | undefined;
 | 
			
		||||
        if (level == 0) {
 | 
			
		||||
            sacrifice = manifest.initiationSacrifice;
 | 
			
		||||
@ -94,7 +94,7 @@ export const syndicateSacrificeController: RequestHandler = async (request, resp
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Commit
 | 
			
		||||
    syndicate.Title = data.SacrificeLevel;
 | 
			
		||||
    syndicate.Title = data.SacrificeLevel < 0 ? data.SacrificeLevel + 1 : data.SacrificeLevel;
 | 
			
		||||
    await inventory.save();
 | 
			
		||||
 | 
			
		||||
    response.json(res);
 | 
			
		||||
 | 
			
		||||
@ -5,7 +5,7 @@ import { IMiscItem, InventorySlot } from "@/src/types/inventoryTypes/inventoryTy
 | 
			
		||||
import { IOid } from "@/src/types/commonTypes";
 | 
			
		||||
import { ExportSyndicates, ExportWeapons } from "warframe-public-export-plus";
 | 
			
		||||
import { logger } from "@/src/utils/logger";
 | 
			
		||||
import { IInventoryChanges } from "@/src/types/purchaseTypes";
 | 
			
		||||
import { IAffiliationMods, IInventoryChanges } from "@/src/types/purchaseTypes";
 | 
			
		||||
import { EquipmentFeatures } from "@/src/types/inventoryTypes/commonInventoryTypes";
 | 
			
		||||
 | 
			
		||||
export const syndicateStandingBonusController: RequestHandler = async (req, res) => {
 | 
			
		||||
@ -54,13 +54,14 @@ export const syndicateStandingBonusController: RequestHandler = async (req, res)
 | 
			
		||||
        inventoryChanges[slotBin] = { count: -1, platinum: 0, Slots: 1 };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const affiliationMod = addStanding(inventory, request.Operation.AffiliationTag, gainedStanding, true);
 | 
			
		||||
    const affiliationMods: IAffiliationMods[] = [];
 | 
			
		||||
    addStanding(inventory, request.Operation.AffiliationTag, gainedStanding, affiliationMods, true);
 | 
			
		||||
 | 
			
		||||
    await inventory.save();
 | 
			
		||||
 | 
			
		||||
    res.json({
 | 
			
		||||
        InventoryChanges: inventoryChanges,
 | 
			
		||||
        AffiliationMods: [affiliationMod]
 | 
			
		||||
        AffiliationMods: affiliationMods
 | 
			
		||||
    });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										27
									
								
								src/controllers/api/umbraController.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								src/controllers/api/umbraController.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,27 @@
 | 
			
		||||
import { fromMongoDate, fromOid } from "@/src/helpers/inventoryHelpers";
 | 
			
		||||
import { getJSONfromString } from "@/src/helpers/stringHelpers";
 | 
			
		||||
import { addMiscItem, getInventory } from "@/src/services/inventoryService";
 | 
			
		||||
import { getAccountIdForRequest } from "@/src/services/loginService";
 | 
			
		||||
import { IEquipmentClient } from "@/src/types/inventoryTypes/commonInventoryTypes";
 | 
			
		||||
import { 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<IUmbraRequest>(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[];
 | 
			
		||||
}
 | 
			
		||||
@ -1,9 +1,11 @@
 | 
			
		||||
import { RequestHandler } from "express";
 | 
			
		||||
import { getJSONfromString } from "@/src/helpers/stringHelpers";
 | 
			
		||||
import { getAccountForRequest } from "@/src/services/loginService";
 | 
			
		||||
import { addChallenges, getInventory } from "@/src/services/inventoryService";
 | 
			
		||||
import { addCalendarProgress, addChallenges, getInventory } from "@/src/services/inventoryService";
 | 
			
		||||
import { IChallengeProgress, ISeasonChallenge } from "@/src/types/inventoryTypes/inventoryTypes";
 | 
			
		||||
import { IAffiliationMods } from "@/src/types/purchaseTypes";
 | 
			
		||||
import { getEntriesUnsafe } from "@/src/utils/ts-utils";
 | 
			
		||||
import { logger } from "@/src/utils/logger";
 | 
			
		||||
 | 
			
		||||
export const updateChallengeProgressController: RequestHandler = async (req, res) => {
 | 
			
		||||
    const challenges = getJSONfromString<IUpdateChallengeProgressRequest>(String(req.body));
 | 
			
		||||
@ -11,7 +13,7 @@ export const updateChallengeProgressController: RequestHandler = async (req, res
 | 
			
		||||
 | 
			
		||||
    const inventory = await getInventory(
 | 
			
		||||
        account._id.toString(),
 | 
			
		||||
        "ChallengeProgress SeasonChallengeHistory Affiliations"
 | 
			
		||||
        "ChallengesFixVersion ChallengeProgress SeasonChallengeHistory Affiliations CalendarProgress"
 | 
			
		||||
    );
 | 
			
		||||
    let affiliationMods: IAffiliationMods[] = [];
 | 
			
		||||
    if (challenges.ChallengeProgress) {
 | 
			
		||||
@ -22,15 +24,39 @@ export const updateChallengeProgressController: RequestHandler = async (req, res
 | 
			
		||||
            challenges.SeasonChallengeCompletions
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
    if (challenges.SeasonChallengeHistory) {
 | 
			
		||||
        challenges.SeasonChallengeHistory.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 });
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    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();
 | 
			
		||||
 | 
			
		||||
@ -40,7 +66,11 @@ export const updateChallengeProgressController: RequestHandler = async (req, res
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
interface IUpdateChallengeProgressRequest {
 | 
			
		||||
    ChallengePTS?: number;
 | 
			
		||||
    ChallengesFixVersion?: number;
 | 
			
		||||
    ChallengeProgress?: IChallengeProgress[];
 | 
			
		||||
    SeasonChallengeHistory?: ISeasonChallenge[];
 | 
			
		||||
    SeasonChallengeCompletions?: ISeasonChallenge[];
 | 
			
		||||
    CalendarProgress?: { challenge: string }[];
 | 
			
		||||
    crossPlaySetting?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -13,6 +13,7 @@ import { getRecipeByResult } from "@/src/services/itemDataService";
 | 
			
		||||
import { IInventoryChanges } from "@/src/types/purchaseTypes";
 | 
			
		||||
import { addInfestedFoundryXP, applyCheatsToInfestedFoundry } from "@/src/services/infestedFoundryService";
 | 
			
		||||
import { config } from "@/src/services/configService";
 | 
			
		||||
import { sendWsBroadcastTo } from "@/src/services/webService";
 | 
			
		||||
 | 
			
		||||
export const upgradesController: RequestHandler = async (req, res) => {
 | 
			
		||||
    const accountId = await getAccountIdForRequest(req);
 | 
			
		||||
@ -120,6 +121,7 @@ export const upgradesController: RequestHandler = async (req, res) => {
 | 
			
		||||
                    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": {
 | 
			
		||||
 | 
			
		||||
@ -7,7 +7,7 @@ export const addItemsController: RequestHandler = async (req, res) => {
 | 
			
		||||
    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, undefined, true);
 | 
			
		||||
        await addItem(inventory, request.ItemType, request.ItemCount, true, undefined, request.Fingerprint, true);
 | 
			
		||||
    }
 | 
			
		||||
    await inventory.save();
 | 
			
		||||
    res.end();
 | 
			
		||||
@ -16,4 +16,5 @@ export const addItemsController: RequestHandler = async (req, res) => {
 | 
			
		||||
interface IAddItemRequest {
 | 
			
		||||
    ItemType: string;
 | 
			
		||||
    ItemCount: number;
 | 
			
		||||
    Fingerprint?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,24 @@
 | 
			
		||||
import { getAccountIdForRequest } from "@/src/services/loginService";
 | 
			
		||||
import { getInventory, addRecipes } from "@/src/services/inventoryService";
 | 
			
		||||
import { 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();
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										40
									
								
								src/controllers/custom/completeAllMissionsController.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								src/controllers/custom/completeAllMissionsController.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,40 @@
 | 
			
		||||
import { addString } from "@/src/helpers/stringHelpers";
 | 
			
		||||
import { getInventory } from "@/src/services/inventoryService";
 | 
			
		||||
import { getAccountIdForRequest } from "@/src/services/loginService";
 | 
			
		||||
import { addFixedLevelRewards } from "@/src/services/missionInventoryUpdateService";
 | 
			
		||||
import { handleStoreItemAcquisition } from "@/src/services/purchaseService";
 | 
			
		||||
import { IMissionReward } from "@/src/types/missionTypes";
 | 
			
		||||
import { 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");
 | 
			
		||||
    await inventory.save();
 | 
			
		||||
    res.end();
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										44
									
								
								src/controllers/custom/configController.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								src/controllers/custom/configController.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,44 @@
 | 
			
		||||
import { RequestHandler } from "express";
 | 
			
		||||
import { config } from "@/src/services/configService";
 | 
			
		||||
import { getAccountForRequest, isAdministrator } from "@/src/services/loginService";
 | 
			
		||||
import { saveConfig } from "@/src/services/configWatcherService";
 | 
			
		||||
import { sendWsBroadcastExcept } from "@/src/services/webService";
 | 
			
		||||
 | 
			
		||||
export const getConfigController: RequestHandler = async (req, res) => {
 | 
			
		||||
    const account = await getAccountForRequest(req);
 | 
			
		||||
    if (isAdministrator(account)) {
 | 
			
		||||
        const responseData: Record<string, boolean | string | number | null> = {};
 | 
			
		||||
        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<string, boolean | string | number>)) {
 | 
			
		||||
            const [obj, idx] = configIdToIndexable(id);
 | 
			
		||||
            obj[idx] = value;
 | 
			
		||||
        }
 | 
			
		||||
        sendWsBroadcastExcept(parseInt(String(req.query.wsid)), { config_reloaded: true });
 | 
			
		||||
        await saveConfig();
 | 
			
		||||
        res.end();
 | 
			
		||||
    } else {
 | 
			
		||||
        res.status(401).end();
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const configIdToIndexable = (id: string): [Record<string, boolean | string | number | undefined>, string] => {
 | 
			
		||||
    let obj = config as unknown as Record<string, never>;
 | 
			
		||||
    const arr = id.split(".");
 | 
			
		||||
    while (arr.length > 1) {
 | 
			
		||||
        obj = obj[arr[0]];
 | 
			
		||||
        arr.splice(0, 1);
 | 
			
		||||
    }
 | 
			
		||||
    return [obj, arr[0]];
 | 
			
		||||
};
 | 
			
		||||
@ -1,14 +0,0 @@
 | 
			
		||||
import { RequestHandler } from "express";
 | 
			
		||||
import { config } from "@/src/services/configService";
 | 
			
		||||
import { getAccountForRequest, isAdministrator } from "@/src/services/loginService";
 | 
			
		||||
 | 
			
		||||
const getConfigDataController: RequestHandler = async (req, res) => {
 | 
			
		||||
    const account = await getAccountForRequest(req);
 | 
			
		||||
    if (isAdministrator(account)) {
 | 
			
		||||
        res.json(config);
 | 
			
		||||
    } else {
 | 
			
		||||
        res.status(401).end();
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export { getConfigDataController };
 | 
			
		||||
@ -20,7 +20,6 @@ import {
 | 
			
		||||
    ExportWeapons,
 | 
			
		||||
    TRelicQuality
 | 
			
		||||
} from "warframe-public-export-plus";
 | 
			
		||||
import archonCrystalUpgrades from "@/static/fixed_responses/webuiArchonCrystalUpgrades.json";
 | 
			
		||||
import allIncarnons from "@/static/fixed_responses/allIncarnonList.json";
 | 
			
		||||
 | 
			
		||||
interface ListedItem {
 | 
			
		||||
@ -36,7 +35,6 @@ interface ListedItem {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface ItemLists {
 | 
			
		||||
    archonCrystalUpgrades: Record<string, string>;
 | 
			
		||||
    uniqueLevelCaps: Record<string, number>;
 | 
			
		||||
    Suits: ListedItem[];
 | 
			
		||||
    LongGuns: ListedItem[];
 | 
			
		||||
@ -57,6 +55,7 @@ interface ItemLists {
 | 
			
		||||
    EvolutionProgress: ListedItem[];
 | 
			
		||||
    mods: ListedItem[];
 | 
			
		||||
    Boosters: ListedItem[];
 | 
			
		||||
    //circuitGameModes: ListedItem[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const relicQualitySuffixes: Record<TRelicQuality, string> = {
 | 
			
		||||
@ -66,10 +65,13 @@ const relicQualitySuffixes: Record<TRelicQuality, string> = {
 | 
			
		||||
    VPQ_PLATINUM: " [Exceptional]"
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/*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 = {
 | 
			
		||||
        archonCrystalUpgrades,
 | 
			
		||||
        uniqueLevelCaps: ExportMisc.uniqueLevelCaps,
 | 
			
		||||
        Suits: [],
 | 
			
		||||
        LongGuns: [],
 | 
			
		||||
@ -90,6 +92,36 @@ const getItemListsController: RequestHandler = (req, response) => {
 | 
			
		||||
        EvolutionProgress: [],
 | 
			
		||||
        mods: [],
 | 
			
		||||
        Boosters: []
 | 
			
		||||
        /*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({
 | 
			
		||||
 | 
			
		||||
@ -128,7 +128,7 @@ export const manageQuestsController: RequestHandler = async (req, res) => {
 | 
			
		||||
                    await completeQuest(inventory, questKey.ItemType);
 | 
			
		||||
                } else {
 | 
			
		||||
                    const progress = {
 | 
			
		||||
                        c: questManifest.chainStages![currentStage].key ? -1 : 0,
 | 
			
		||||
                        c: 0,
 | 
			
		||||
                        i: false,
 | 
			
		||||
                        m: false,
 | 
			
		||||
                        b: []
 | 
			
		||||
 | 
			
		||||
@ -12,6 +12,7 @@ export const popArchonCrystalUpgradeController: RequestHandler = async (req, res
 | 
			
		||||
        );
 | 
			
		||||
        await inventory.save();
 | 
			
		||||
        res.end();
 | 
			
		||||
        return;
 | 
			
		||||
    }
 | 
			
		||||
    res.status(400).end();
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -15,6 +15,7 @@ export const pushArchonCrystalUpgradeController: RequestHandler = async (req, re
 | 
			
		||||
            }
 | 
			
		||||
            await inventory.save();
 | 
			
		||||
            res.end();
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    res.status(400).end();
 | 
			
		||||
 | 
			
		||||
@ -23,9 +23,9 @@ export const setBoosterController: RequestHandler = async (req, res) => {
 | 
			
		||||
        res.status(400).send("Invalid ItemType provided.");
 | 
			
		||||
        return;
 | 
			
		||||
    }
 | 
			
		||||
    const now = Math.floor(Date.now() / 1000);
 | 
			
		||||
    const now = Math.trunc(Date.now() / 1000);
 | 
			
		||||
    for (const { ItemType, ExpiryDate } of requests) {
 | 
			
		||||
        if (ExpiryDate < now) {
 | 
			
		||||
        if (ExpiryDate <= now) {
 | 
			
		||||
            // remove expired boosters
 | 
			
		||||
            const index = boosters.findIndex(item => item.ItemType === ItemType);
 | 
			
		||||
            if (index !== -1) {
 | 
			
		||||
 | 
			
		||||
@ -1,15 +0,0 @@
 | 
			
		||||
import { RequestHandler } from "express";
 | 
			
		||||
import { updateConfig } from "@/src/services/configWatcherService";
 | 
			
		||||
import { getAccountForRequest, isAdministrator } from "@/src/services/loginService";
 | 
			
		||||
 | 
			
		||||
const updateConfigDataController: RequestHandler = async (req, res) => {
 | 
			
		||||
    const account = await getAccountForRequest(req);
 | 
			
		||||
    if (isAdministrator(account)) {
 | 
			
		||||
        await updateConfig(String(req.body));
 | 
			
		||||
        res.end();
 | 
			
		||||
    } else {
 | 
			
		||||
        res.status(401).end();
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export { updateConfigDataController };
 | 
			
		||||
							
								
								
									
										39
									
								
								src/controllers/custom/updateFingerprintController.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								src/controllers/custom/updateFingerprintController.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,39 @@
 | 
			
		||||
import { getInventory } from "@/src/services/inventoryService";
 | 
			
		||||
import { WeaponTypeInternal } from "@/src/services/itemDataService";
 | 
			
		||||
import { getAccountIdForRequest } from "@/src/services/loginService";
 | 
			
		||||
import { 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;
 | 
			
		||||
        }[];
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										10
									
								
								src/controllers/custom/webuiFileChangeDetectedController.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/controllers/custom/webuiFileChangeDetectedController.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,10 @@
 | 
			
		||||
import { args } from "@/src/helpers/commandLineArguments";
 | 
			
		||||
import { sendWsBroadcast } from "@/src/services/webService";
 | 
			
		||||
import { RequestHandler } from "express";
 | 
			
		||||
 | 
			
		||||
export const webuiFileChangeDetectedController: RequestHandler = (req, res) => {
 | 
			
		||||
    if (args.dev && args.secret && req.query.secret == args.secret) {
 | 
			
		||||
        sendWsBroadcast({ reload: true });
 | 
			
		||||
    }
 | 
			
		||||
    res.end();
 | 
			
		||||
};
 | 
			
		||||
@ -1,6 +1,19 @@
 | 
			
		||||
import { RequestHandler } from "express";
 | 
			
		||||
import { getWorldState } from "@/src/services/worldStateService";
 | 
			
		||||
import { getWorldState, populateDailyDeal, populateFissures } from "@/src/services/worldStateService";
 | 
			
		||||
import { version_compare } from "@/src/helpers/inventoryHelpers";
 | 
			
		||||
 | 
			
		||||
export const worldStateController: RequestHandler = (req, res) => {
 | 
			
		||||
    res.json(getWorldState(req.query.buildLabel as string | undefined));
 | 
			
		||||
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);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										23
									
								
								src/helpers/commandLineArguments.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src/helpers/commandLineArguments.ts
									
									
									
									
									
										Normal file
									
								
							@ -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;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@ -237,7 +237,7 @@ export const getNemesisPasscode = (nemesis: { fp: bigint; Faction: TNemesisFacti
 | 
			
		||||
    return passcode;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const requiemMods: readonly string[] = [
 | 
			
		||||
/*const requiemMods: readonly string[] = [
 | 
			
		||||
    "/Lotus/Upgrades/Mods/Immortal/ImmortalOneMod",
 | 
			
		||||
    "/Lotus/Upgrades/Mods/Immortal/ImmortalTwoMod",
 | 
			
		||||
    "/Lotus/Upgrades/Mods/Immortal/ImmortalThreeMod",
 | 
			
		||||
@ -246,9 +246,9 @@ const requiemMods: readonly string[] = [
 | 
			
		||||
    "/Lotus/Upgrades/Mods/Immortal/ImmortalSixMod",
 | 
			
		||||
    "/Lotus/Upgrades/Mods/Immortal/ImmortalSevenMod",
 | 
			
		||||
    "/Lotus/Upgrades/Mods/Immortal/ImmortalEightMod"
 | 
			
		||||
];
 | 
			
		||||
];*/
 | 
			
		||||
 | 
			
		||||
const antivirusMods: readonly string[] = [
 | 
			
		||||
export const antivirusMods: readonly string[] = [
 | 
			
		||||
    "/Lotus/Upgrades/Mods/Immortal/AntivirusOneMod",
 | 
			
		||||
    "/Lotus/Upgrades/Mods/Immortal/AntivirusTwoMod",
 | 
			
		||||
    "/Lotus/Upgrades/Mods/Immortal/AntivirusThreeMod",
 | 
			
		||||
@ -259,12 +259,12 @@ const antivirusMods: readonly string[] = [
 | 
			
		||||
    "/Lotus/Upgrades/Mods/Immortal/AntivirusEightMod"
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
export const getNemesisPasscodeModTypes = (nemesis: { fp: bigint; Faction: TNemesisFaction }): string[] => {
 | 
			
		||||
/*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;
 | 
			
		||||
@ -343,6 +343,27 @@ export const getKnifeUpgrade = (
 | 
			
		||||
    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 consumeModCharge = (
 | 
			
		||||
    response: IKnifeResponse,
 | 
			
		||||
    inventory: TInventoryDatabaseDocument,
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,4 @@
 | 
			
		||||
import path from "path";
 | 
			
		||||
 | 
			
		||||
export const rootDir = path.join(__dirname, "../..");
 | 
			
		||||
export const isDev = path.basename(rootDir) != "build";
 | 
			
		||||
export const repoDir = isDev ? rootDir : path.join(rootDir, "..");
 | 
			
		||||
export const repoDir = path.basename(rootDir) != "build" ? rootDir : path.join(rootDir, "..");
 | 
			
		||||
 | 
			
		||||
@ -6,6 +6,7 @@ import { logger } from "@/src/utils/logger";
 | 
			
		||||
import { addMiscItems, combineInventoryChanges } from "@/src/services/inventoryService";
 | 
			
		||||
import { handleStoreItemAcquisition } from "@/src/services/purchaseService";
 | 
			
		||||
import { IInventoryChanges } from "../types/purchaseTypes";
 | 
			
		||||
import { config } from "../services/configService";
 | 
			
		||||
 | 
			
		||||
export const crackRelic = async (
 | 
			
		||||
    inventory: TInventoryDatabaseDocument,
 | 
			
		||||
@ -13,7 +14,14 @@ export const crackRelic = async (
 | 
			
		||||
    inventoryChanges: IInventoryChanges = {}
 | 
			
		||||
): Promise<IRngResult> => {
 | 
			
		||||
    const relic = ExportRelics[participant.VoidProjection];
 | 
			
		||||
    const weights = refinementToWeights[relic.quality];
 | 
			
		||||
    let weights = refinementToWeights[relic.quality];
 | 
			
		||||
    if (relic.quality == "VPQ_SILVER" && config.exceptionalRelicsAlwaysGiveBronzeReward) {
 | 
			
		||||
        weights = { COMMON: 1, UNCOMMON: 0, RARE: 0, LEGENDARY: 0 };
 | 
			
		||||
    } else if (relic.quality == "VPQ_GOLD" && config.flawlessRelicsAlwaysGiveSilverReward) {
 | 
			
		||||
        weights = { COMMON: 0, UNCOMMON: 1, RARE: 0, LEGENDARY: 0 };
 | 
			
		||||
    } else if (relic.quality == "VPQ_PLATINUM" && config.radiantRelicsAlwaysGiveGoldReward) {
 | 
			
		||||
        weights = { COMMON: 0, UNCOMMON: 0, RARE: 1, LEGENDARY: 0 };
 | 
			
		||||
    }
 | 
			
		||||
    logger.debug(`opening a relic of quality ${relic.quality}; rarity weights are`, weights);
 | 
			
		||||
    const reward = getRandomWeightedReward(
 | 
			
		||||
        ExportRewards[relic.rewardManifest][0] as { type: string; itemCount: number; rarity: TRarity }[], // rarity is nullable in PE+ typings, but always present for relics
 | 
			
		||||
@ -35,7 +43,13 @@ export const crackRelic = async (
 | 
			
		||||
    // Give reward
 | 
			
		||||
    combineInventoryChanges(
 | 
			
		||||
        inventoryChanges,
 | 
			
		||||
        (await handleStoreItemAcquisition(reward.type, inventory, reward.itemCount)).InventoryChanges
 | 
			
		||||
        (
 | 
			
		||||
            await handleStoreItemAcquisition(
 | 
			
		||||
                reward.type,
 | 
			
		||||
                inventory,
 | 
			
		||||
                reward.itemCount * (config.relicRewardItemCountMultiplier ?? 1)
 | 
			
		||||
            )
 | 
			
		||||
        ).InventoryChanges
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return reward;
 | 
			
		||||
 | 
			
		||||
@ -54,3 +54,9 @@ export const regexEscape = (str: string): string => {
 | 
			
		||||
    str = str.split("}").join("\\}");
 | 
			
		||||
    return str;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const addString = (arr: string[], str: string): void => {
 | 
			
		||||
    if (arr.indexOf(str) == -1) {
 | 
			
		||||
        arr.push(str);
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -10,3 +10,14 @@ export const getMaxStanding = (syndicate: ISyndicate, title: number): number =>
 | 
			
		||||
    }
 | 
			
		||||
    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;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										19
									
								
								src/index.ts
									
									
									
									
									
								
							
							
						
						
									
										19
									
								
								src/index.ts
									
									
									
									
									
								
							@ -1,9 +1,14 @@
 | 
			
		||||
// First, init config.
 | 
			
		||||
import { config, loadConfig } from "@/src/services/configService";
 | 
			
		||||
import { config, configPath, loadConfig } from "@/src/services/configService";
 | 
			
		||||
import fs from "fs";
 | 
			
		||||
try {
 | 
			
		||||
    loadConfig();
 | 
			
		||||
} catch (e) {
 | 
			
		||||
    console.log("ERROR: Failed to load config.json. You can copy config.json.example to create your config.json.");
 | 
			
		||||
    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.json.example to create your config file.");
 | 
			
		||||
    }
 | 
			
		||||
    process.exit(1);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -16,7 +21,8 @@ import mongoose from "mongoose";
 | 
			
		||||
import { JSONStringify } from "json-with-bigint";
 | 
			
		||||
import { startWebServer } from "./services/webService";
 | 
			
		||||
 | 
			
		||||
import { validateConfig } from "@/src/services/configWatcherService";
 | 
			
		||||
import { syncConfigWithDatabase, validateConfig } from "@/src/services/configWatcherService";
 | 
			
		||||
import { updateWorldStateCollections } from "./services/worldStateService";
 | 
			
		||||
 | 
			
		||||
// Patch JSON.stringify to work flawlessly with Bigints.
 | 
			
		||||
JSON.stringify = JSONStringify;
 | 
			
		||||
@ -27,7 +33,14 @@ 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) {
 | 
			
		||||
 | 
			
		||||
@ -17,22 +17,25 @@ export interface IMessageDatabase extends IMessage {
 | 
			
		||||
    ownerId: Types.ObjectId;
 | 
			
		||||
    date: Date; //created at
 | 
			
		||||
    attVisualOnly?: boolean;
 | 
			
		||||
    expiry?: Date;
 | 
			
		||||
    _id: Types.ObjectId;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IMessage {
 | 
			
		||||
    sndr: string;
 | 
			
		||||
    msg: string;
 | 
			
		||||
    cinematic?: string;
 | 
			
		||||
    sub: string;
 | 
			
		||||
    customData?: string;
 | 
			
		||||
    icon?: string;
 | 
			
		||||
    highPriority?: boolean;
 | 
			
		||||
    lowPrioNewPlayers?: boolean;
 | 
			
		||||
    startDate?: Date;
 | 
			
		||||
    endDate?: Date;
 | 
			
		||||
    transmission?: string;
 | 
			
		||||
    att?: string[];
 | 
			
		||||
    countedAtt?: ITypeCount[];
 | 
			
		||||
    transmission?: string;
 | 
			
		||||
    startDate?: Date;
 | 
			
		||||
    endDate?: Date;
 | 
			
		||||
    goalTag?: string;
 | 
			
		||||
    CrossPlatform?: boolean;
 | 
			
		||||
    arg?: Arg[];
 | 
			
		||||
    gifts?: IGift[];
 | 
			
		||||
    r?: boolean;
 | 
			
		||||
@ -101,13 +104,18 @@ const messageSchema = new Schema<IMessageDatabase>(
 | 
			
		||||
        ownerId: 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 },
 | 
			
		||||
@ -128,7 +136,7 @@ const messageSchema = new Schema<IMessageDatabase>(
 | 
			
		||||
        declineAction: String,
 | 
			
		||||
        hasAccountAction: Boolean
 | 
			
		||||
    },
 | 
			
		||||
    { timestamps: { createdAt: "date", updatedAt: false }, id: false }
 | 
			
		||||
    { id: false }
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
messageSchema.virtual("messageId").get(function (this: IMessageDatabase) {
 | 
			
		||||
@ -151,13 +159,15 @@ messageSchema.set("toJSON", {
 | 
			
		||||
 | 
			
		||||
        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({ expiry: 1 }, { expireAfterSeconds: 0 });
 | 
			
		||||
messageSchema.index({ endDate: 1 }, { expireAfterSeconds: 0 });
 | 
			
		||||
 | 
			
		||||
export const Inbox = model<IMessageDatabase>("Inbox", messageSchema, "inbox");
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,4 @@
 | 
			
		||||
import { Document, HydratedDocument, Model, Schema, Types, model } from "mongoose";
 | 
			
		||||
import { Document, Model, Schema, Types, model } from "mongoose";
 | 
			
		||||
import {
 | 
			
		||||
    IFlavourItem,
 | 
			
		||||
    IRawUpgrade,
 | 
			
		||||
@ -7,7 +7,6 @@ import {
 | 
			
		||||
    IBooster,
 | 
			
		||||
    IInventoryClient,
 | 
			
		||||
    ISlots,
 | 
			
		||||
    IMailboxDatabase,
 | 
			
		||||
    IDuviriInfo,
 | 
			
		||||
    IPendingRecipeDatabase,
 | 
			
		||||
    IPendingRecipeClient,
 | 
			
		||||
@ -54,7 +53,6 @@ import {
 | 
			
		||||
    IUpgradeDatabase,
 | 
			
		||||
    ICrewShipMemberDatabase,
 | 
			
		||||
    ICrewShipMemberClient,
 | 
			
		||||
    IMailboxClient,
 | 
			
		||||
    TEquipmentKey,
 | 
			
		||||
    equipmentKeys,
 | 
			
		||||
    IKubrowPetDetailsDatabase,
 | 
			
		||||
@ -93,13 +91,17 @@ import {
 | 
			
		||||
    ICrewMemberSkillEfficiency,
 | 
			
		||||
    ICrewMemberDatabase,
 | 
			
		||||
    ICrewMemberClient,
 | 
			
		||||
    ISortieRewardAttenuation,
 | 
			
		||||
    IRewardAttenuation,
 | 
			
		||||
    IInvasionProgressDatabase,
 | 
			
		||||
    IInvasionProgressClient,
 | 
			
		||||
    IAccolades,
 | 
			
		||||
    IHubNpcCustomization,
 | 
			
		||||
    ILotusCustomization,
 | 
			
		||||
    IEndlessXpReward
 | 
			
		||||
    IEndlessXpReward,
 | 
			
		||||
    IPersonalGoalProgressDatabase,
 | 
			
		||||
    IPersonalGoalProgressClient,
 | 
			
		||||
    IKubrowPetPrintClient,
 | 
			
		||||
    IKubrowPetPrintDatabase
 | 
			
		||||
} from "../../types/inventoryTypes/inventoryTypes";
 | 
			
		||||
import { IOid } from "../../types/commonTypes";
 | 
			
		||||
import {
 | 
			
		||||
@ -251,12 +253,6 @@ const ArchonCrystalUpgradeSchema = new Schema<IArchonCrystalUpgrade>(
 | 
			
		||||
    { _id: false }
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
ArchonCrystalUpgradeSchema.set("toJSON", {
 | 
			
		||||
    transform(_document, returnedObject) {
 | 
			
		||||
        delete returnedObject.__v;
 | 
			
		||||
    }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const boosterSchema = new Schema<IBooster>(
 | 
			
		||||
    {
 | 
			
		||||
        ExpiryDate: Number,
 | 
			
		||||
@ -377,7 +373,7 @@ FlavourItemSchema.set("toJSON", {
 | 
			
		||||
    }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const MailboxSchema = new Schema<IMailboxDatabase>(
 | 
			
		||||
/*const MailboxSchema = new Schema<IMailboxDatabase>(
 | 
			
		||||
    {
 | 
			
		||||
        LastInboxId: Schema.Types.ObjectId
 | 
			
		||||
    },
 | 
			
		||||
@ -390,7 +386,7 @@ MailboxSchema.set("toJSON", {
 | 
			
		||||
        delete mailboxDatabase.__v;
 | 
			
		||||
        (returnedObject as IMailboxClient).LastInboxId = toOid(mailboxDatabase.LastInboxId);
 | 
			
		||||
    }
 | 
			
		||||
});
 | 
			
		||||
});*/
 | 
			
		||||
 | 
			
		||||
const DuviriInfoSchema = new Schema<IDuviriInfo>(
 | 
			
		||||
    {
 | 
			
		||||
@ -463,11 +459,35 @@ const discoveredMarkerSchema = new Schema<IDiscoveredMarker>(
 | 
			
		||||
    { _id: false }
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const personalGoalProgressSchema = new Schema<IPersonalGoalProgressDatabase>(
 | 
			
		||||
    {
 | 
			
		||||
        Best: Number,
 | 
			
		||||
        Count: Number,
 | 
			
		||||
        Tag: String,
 | 
			
		||||
        goalId: Types.ObjectId
 | 
			
		||||
    },
 | 
			
		||||
    { _id: false }
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
personalGoalProgressSchema.set("toJSON", {
 | 
			
		||||
    virtuals: true,
 | 
			
		||||
    transform(_doc, obj) {
 | 
			
		||||
        const db = obj as IPersonalGoalProgressDatabase;
 | 
			
		||||
        const client = obj as IPersonalGoalProgressClient;
 | 
			
		||||
 | 
			
		||||
        client._id = toOid(db.goalId);
 | 
			
		||||
 | 
			
		||||
        delete obj.goalId;
 | 
			
		||||
        delete obj.__v;
 | 
			
		||||
    }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const challengeProgressSchema = new Schema<IChallengeProgress>(
 | 
			
		||||
    {
 | 
			
		||||
        Progress: Number,
 | 
			
		||||
        Name: String,
 | 
			
		||||
        Completed: [String]
 | 
			
		||||
        Completed: { type: [String], default: undefined },
 | 
			
		||||
        ReceivedJunctionReward: Boolean,
 | 
			
		||||
        Name: { type: String, required: true }
 | 
			
		||||
    },
 | 
			
		||||
    { _id: false }
 | 
			
		||||
);
 | 
			
		||||
@ -990,6 +1010,27 @@ const traitsSchema = new Schema<ITraits>(
 | 
			
		||||
    { _id: false }
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const kubrowPetPrintSchema = new Schema<IKubrowPetPrintDatabase>({
 | 
			
		||||
    ItemType: String,
 | 
			
		||||
    Name: String,
 | 
			
		||||
    IsMale: Boolean,
 | 
			
		||||
    Size: Number,
 | 
			
		||||
    DominantTraits: traitsSchema,
 | 
			
		||||
    RecessiveTraits: traitsSchema
 | 
			
		||||
});
 | 
			
		||||
kubrowPetPrintSchema.set("toJSON", {
 | 
			
		||||
    virtuals: true,
 | 
			
		||||
    transform(_doc, obj) {
 | 
			
		||||
        const db = obj as IKubrowPetPrintDatabase;
 | 
			
		||||
        const client = obj as IKubrowPetPrintClient;
 | 
			
		||||
 | 
			
		||||
        client.ItemId = toOid(db._id);
 | 
			
		||||
 | 
			
		||||
        delete obj._id;
 | 
			
		||||
        delete obj.__v;
 | 
			
		||||
    }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const detailsSchema = new Schema<IKubrowPetDetailsDatabase>(
 | 
			
		||||
    {
 | 
			
		||||
        Name: String,
 | 
			
		||||
@ -1079,6 +1120,11 @@ EquipmentSchema.set("toJSON", {
 | 
			
		||||
        if (db.UmbraDate) {
 | 
			
		||||
            client.UmbraDate = toMongoDate(db.UmbraDate);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (client.ArchonCrystalUpgrades) {
 | 
			
		||||
            // For some reason, mongoose turns empty objects here into nulls, so we have to fix it.
 | 
			
		||||
            client.ArchonCrystalUpgrades = client.ArchonCrystalUpgrades.map(x => (x as unknown) ?? {});
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@ -1371,10 +1417,10 @@ lastSortieRewardSchema.set("toJSON", {
 | 
			
		||||
    }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const sortieRewardAttenutationSchema = new Schema<ISortieRewardAttenuation>(
 | 
			
		||||
const rewardAttenutationSchema = new Schema<IRewardAttenuation>(
 | 
			
		||||
    {
 | 
			
		||||
        Tag: String,
 | 
			
		||||
        Atten: Number
 | 
			
		||||
        Tag: { type: String, required: true },
 | 
			
		||||
        Atten: { type: Number, required: true }
 | 
			
		||||
    },
 | 
			
		||||
    { _id: false }
 | 
			
		||||
);
 | 
			
		||||
@ -1488,7 +1534,7 @@ const inventorySchema = new Schema<IInventoryDatabase, InventoryDocumentProps>(
 | 
			
		||||
 | 
			
		||||
        KubrowPetEggs: [kubrowPetEggSchema],
 | 
			
		||||
        //Prints   Cat(3 Prints)\Kubrow(2 Prints) Pets
 | 
			
		||||
        //KubrowPetPrints: [Schema.Types.Mixed],
 | 
			
		||||
        KubrowPetPrints: [kubrowPetPrintSchema],
 | 
			
		||||
 | 
			
		||||
        //Item for EquippedGear example:Scaner,LoadoutTechSummon etc
 | 
			
		||||
        Consumables: [typeCountSchema],
 | 
			
		||||
@ -1602,6 +1648,9 @@ const inventorySchema = new Schema<IInventoryDatabase, InventoryDocumentProps>(
 | 
			
		||||
        PendingSpectreLoadouts: { type: [spectreLoadoutsSchema], default: undefined },
 | 
			
		||||
        SpectreLoadouts: { type: [spectreLoadoutsSchema], default: undefined },
 | 
			
		||||
 | 
			
		||||
        //Darvo Deal
 | 
			
		||||
        UsedDailyDeals: [String],
 | 
			
		||||
 | 
			
		||||
        //New Quest Email
 | 
			
		||||
        EmailItems: [typeCountSchema],
 | 
			
		||||
 | 
			
		||||
@ -1617,7 +1666,7 @@ const inventorySchema = new Schema<IInventoryDatabase, InventoryDocumentProps>(
 | 
			
		||||
        CompletedSorties: [String],
 | 
			
		||||
        LastSortieReward: { type: [lastSortieRewardSchema], default: undefined },
 | 
			
		||||
        LastLiteSortieReward: { type: [lastSortieRewardSchema], default: undefined },
 | 
			
		||||
        SortieRewardAttenuation: { type: [sortieRewardAttenutationSchema], default: undefined },
 | 
			
		||||
        SortieRewardAttenuation: { type: [rewardAttenutationSchema], default: undefined },
 | 
			
		||||
 | 
			
		||||
        // Resource Extractor Drones
 | 
			
		||||
        Drones: [droneSchema],
 | 
			
		||||
@ -1631,7 +1680,7 @@ const inventorySchema = new Schema<IInventoryDatabase, InventoryDocumentProps>(
 | 
			
		||||
        //CompletedJobs: [Schema.Types.Mixed],
 | 
			
		||||
 | 
			
		||||
        //Game mission\ivent score example  "Tag": "WaterFight", "Best": 170, "Count": 1258,
 | 
			
		||||
        //PersonalGoalProgress: [Schema.Types.Mixed],
 | 
			
		||||
        PersonalGoalProgress: { type: [personalGoalProgressSchema], default: undefined },
 | 
			
		||||
 | 
			
		||||
        //Setting interface Style
 | 
			
		||||
        ThemeStyle: String,
 | 
			
		||||
@ -1702,9 +1751,9 @@ const inventorySchema = new Schema<IInventoryDatabase, InventoryDocumentProps>(
 | 
			
		||||
        //Unknown and system
 | 
			
		||||
        DuviriInfo: DuviriInfoSchema,
 | 
			
		||||
        LastInventorySync: Schema.Types.ObjectId,
 | 
			
		||||
        Mailbox: MailboxSchema,
 | 
			
		||||
        //Mailbox: MailboxSchema,
 | 
			
		||||
        HandlerPoints: Number,
 | 
			
		||||
        ChallengesFixVersion: { type: Number, default: 6 },
 | 
			
		||||
        ChallengesFixVersion: Number,
 | 
			
		||||
        PlayedParkourTutorial: Boolean,
 | 
			
		||||
        //ActiveLandscapeTraps: [Schema.Types.Mixed],
 | 
			
		||||
        //RepVotes: [Schema.Types.Mixed],
 | 
			
		||||
@ -1718,7 +1767,6 @@ const inventorySchema = new Schema<IInventoryDatabase, InventoryDocumentProps>(
 | 
			
		||||
        //ChallengeInstanceStates: [Schema.Types.Mixed],
 | 
			
		||||
        RecentVendorPurchases: { type: [recentVendorPurchaseSchema], default: undefined },
 | 
			
		||||
        //Robotics: [Schema.Types.Mixed],
 | 
			
		||||
        //UsedDailyDeals: [Schema.Types.Mixed],
 | 
			
		||||
        CollectibleSeries: { type: [collectibleEntrySchema], default: undefined },
 | 
			
		||||
        HasResetAccount: { type: Boolean, default: false },
 | 
			
		||||
 | 
			
		||||
@ -1755,7 +1803,11 @@ const inventorySchema = new Schema<IInventoryDatabase, InventoryDocumentProps>(
 | 
			
		||||
        BrandedSuits: { type: [Schema.Types.ObjectId], default: undefined },
 | 
			
		||||
        LockedWeaponGroup: { type: lockedWeaponGroupSchema, default: undefined },
 | 
			
		||||
 | 
			
		||||
        HubNpcCustomizations: { type: [hubNpcCustomizationSchema], default: undefined }
 | 
			
		||||
        HubNpcCustomizations: { type: [hubNpcCustomizationSchema], default: undefined },
 | 
			
		||||
 | 
			
		||||
        ClaimedJunctionChallengeRewards: { type: [String], default: undefined },
 | 
			
		||||
 | 
			
		||||
        SpecialItemRewardAttenuation: { type: [rewardAttenutationSchema], default: undefined }
 | 
			
		||||
    },
 | 
			
		||||
    { timestamps: { createdAt: "Created", updatedAt: false } }
 | 
			
		||||
);
 | 
			
		||||
@ -1825,6 +1877,7 @@ export type InventoryDocumentProps = {
 | 
			
		||||
    CrewShipSalvagedWeaponSkins: Types.DocumentArray<IUpgradeDatabase>;
 | 
			
		||||
    PersonalTechProjects: Types.DocumentArray<IPersonalTechProjectDatabase>;
 | 
			
		||||
    CrewMembers: Types.DocumentArray<ICrewMemberDatabase>;
 | 
			
		||||
    KubrowPetPrints: Types.DocumentArray<IKubrowPetPrintDatabase>;
 | 
			
		||||
} & { [K in TEquipmentKey]: Types.DocumentArray<IEquipmentDatabase> };
 | 
			
		||||
 | 
			
		||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
 | 
			
		||||
 | 
			
		||||
@ -11,13 +11,13 @@ const databaseAccountSchema = new Schema<IDatabaseAccountJson>(
 | 
			
		||||
        email: { type: String, required: true, unique: true },
 | 
			
		||||
        password: { type: String, required: true },
 | 
			
		||||
        DisplayName: { type: String, required: true, unique: true },
 | 
			
		||||
        CountryCode: { type: String, required: true },
 | 
			
		||||
        CountryCode: { type: String, default: "" },
 | 
			
		||||
        ClientType: { type: String },
 | 
			
		||||
        CrossPlatformAllowed: { type: Boolean, required: true },
 | 
			
		||||
        ForceLogoutVersion: { type: Number, required: true },
 | 
			
		||||
        CrossPlatformAllowed: { type: Boolean, default: true },
 | 
			
		||||
        ForceLogoutVersion: { type: Number, default: 0 },
 | 
			
		||||
        AmazonAuthToken: { type: String },
 | 
			
		||||
        AmazonRefreshToken: { type: String },
 | 
			
		||||
        ConsentNeeded: { type: Boolean, required: true },
 | 
			
		||||
        ConsentNeeded: { type: Boolean, default: false },
 | 
			
		||||
        TrackedSettings: { type: [String], default: [] },
 | 
			
		||||
        Nonce: { type: Number, default: 0 },
 | 
			
		||||
        BuildLabel: String,
 | 
			
		||||
@ -25,7 +25,8 @@ const databaseAccountSchema = new Schema<IDatabaseAccountJson>(
 | 
			
		||||
        LastLogin: { type: Date, default: 0 },
 | 
			
		||||
        LatestEventMessageDate: { type: Date, default: 0 },
 | 
			
		||||
        LastLoginRewardDate: { type: Number, default: 0 },
 | 
			
		||||
        LoginDays: { type: Number, default: 1 }
 | 
			
		||||
        LoginDays: { type: Number, default: 1 },
 | 
			
		||||
        DailyFirstWinDate: { type: Number, default: 0 }
 | 
			
		||||
    },
 | 
			
		||||
    opts
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
@ -40,6 +40,7 @@ const placedDecosSchema = new Schema<IPlacedDecosDatabase>(
 | 
			
		||||
        Pos: [Number],
 | 
			
		||||
        Rot: [Number],
 | 
			
		||||
        Scale: Number,
 | 
			
		||||
        Sockets: Number,
 | 
			
		||||
        PictureFrameInfo: { type: pictureFrameInfoSchema, default: undefined }
 | 
			
		||||
    },
 | 
			
		||||
    { id: false }
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										30
									
								
								src/models/worldStateModel.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								src/models/worldStateModel.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,30 @@
 | 
			
		||||
import { IDailyDealDatabase, IFissureDatabase } from "@/src/types/worldStateTypes";
 | 
			
		||||
import { model, Schema } from "mongoose";
 | 
			
		||||
 | 
			
		||||
const fissureSchema = new Schema<IFissureDatabase>({
 | 
			
		||||
    Activation: Date,
 | 
			
		||||
    Expiry: Date,
 | 
			
		||||
    Node: String, // must be unique
 | 
			
		||||
    Modifier: String,
 | 
			
		||||
    Hard: Boolean
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
fissureSchema.index({ Expiry: 1 }, { expireAfterSeconds: 0 }); // With this, MongoDB will automatically delete expired entries.
 | 
			
		||||
 | 
			
		||||
export const Fissure = model<IFissureDatabase>("Fissure", fissureSchema);
 | 
			
		||||
 | 
			
		||||
const dailyDealSchema = new Schema<IDailyDealDatabase>({
 | 
			
		||||
    StoreItem: { type: String, required: true },
 | 
			
		||||
    Activation: { type: Date, required: true },
 | 
			
		||||
    Expiry: { type: Date, required: true },
 | 
			
		||||
    Discount: { type: Number, required: true },
 | 
			
		||||
    OriginalPrice: { type: Number, required: true },
 | 
			
		||||
    SalePrice: { type: Number, required: true },
 | 
			
		||||
    AmountTotal: { type: Number, required: true },
 | 
			
		||||
    AmountSold: { type: Number, required: true }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
dailyDealSchema.index({ StoreItem: 1 }, { unique: true });
 | 
			
		||||
dailyDealSchema.index({ Expiry: 1 }, { expireAfterSeconds: 86400 });
 | 
			
		||||
 | 
			
		||||
export const DailyDeal = model<IDailyDealDatabase>("DailyDeal", dailyDealSchema);
 | 
			
		||||
@ -19,6 +19,7 @@ import { changeDojoRootController } from "@/src/controllers/api/changeDojoRootCo
 | 
			
		||||
import { changeGuildRankController } from "@/src/controllers/api/changeGuildRankController";
 | 
			
		||||
import { checkDailyMissionBonusController } from "@/src/controllers/api/checkDailyMissionBonusController";
 | 
			
		||||
import { claimCompletedRecipeController } from "@/src/controllers/api/claimCompletedRecipeController";
 | 
			
		||||
import { claimJunctionChallengeRewardController } from "@/src/controllers/api/claimJunctionChallengeRewardController";
 | 
			
		||||
import { claimLibraryDailyTaskRewardController } from "@/src/controllers/api/claimLibraryDailyTaskRewardController";
 | 
			
		||||
import { clearDialogueHistoryController } from "@/src/controllers/api/clearDialogueHistoryController";
 | 
			
		||||
import { clearNewEpisodeRewardController } from "@/src/controllers/api/clearNewEpisodeRewardController";
 | 
			
		||||
@ -33,6 +34,7 @@ import { createAllianceController } from "@/src/controllers/api/createAllianceCo
 | 
			
		||||
import { createGuildController } from "@/src/controllers/api/createGuildController";
 | 
			
		||||
import { creditsController } from "@/src/controllers/api/creditsController";
 | 
			
		||||
import { crewMembersController } from "@/src/controllers/api/crewMembersController";
 | 
			
		||||
import { crewShipFusionController } from "@/src/controllers/api/crewShipFusionController";
 | 
			
		||||
import { crewShipIdentifySalvageController } from "@/src/controllers/api/crewShipIdentifySalvageController";
 | 
			
		||||
import { customizeGuildRanksController } from "@/src/controllers/api/customizeGuildRanksController";
 | 
			
		||||
import { customObstacleCourseLeaderboardController } from "@/src/controllers/api/customObstacleCourseLeaderboardController";
 | 
			
		||||
@ -132,6 +134,7 @@ import { setPlacedDecoInfoController } from "@/src/controllers/api/setPlacedDeco
 | 
			
		||||
import { setShipCustomizationsController } from "@/src/controllers/api/setShipCustomizationsController";
 | 
			
		||||
import { setShipFavouriteLoadoutController } from "@/src/controllers/api/setShipFavouriteLoadoutController";
 | 
			
		||||
import { setShipVignetteController } from "@/src/controllers/api/setShipVignetteController";
 | 
			
		||||
import { setSuitInfectionController } from "@/src/controllers/api/setSuitInfectionController";
 | 
			
		||||
import { setSupportedSyndicateController } from "@/src/controllers/api/setSupportedSyndicateController";
 | 
			
		||||
import { setWeaponSkillTreeController } from "@/src/controllers/api/setWeaponSkillTreeController";
 | 
			
		||||
import { shipDecorationsController } from "@/src/controllers/api/shipDecorationsController";
 | 
			
		||||
@ -147,6 +150,7 @@ import { syndicateStandingBonusController } from "@/src/controllers/api/syndicat
 | 
			
		||||
import { tauntHistoryController } from "@/src/controllers/api/tauntHistoryController";
 | 
			
		||||
import { tradingController } from "@/src/controllers/api/tradingController";
 | 
			
		||||
import { trainingResultController } from "@/src/controllers/api/trainingResultController";
 | 
			
		||||
import { umbraController } from "@/src/controllers/api/umbraController";
 | 
			
		||||
import { unlockShipFeatureController } from "@/src/controllers/api/unlockShipFeatureController";
 | 
			
		||||
import { updateAlignmentController } from "@/src/controllers/api/updateAlignmentController";
 | 
			
		||||
import { updateChallengeProgressController } from "@/src/controllers/api/updateChallengeProgressController";
 | 
			
		||||
@ -234,6 +238,7 @@ apiRouter.post("/artifacts.php", artifactsController);
 | 
			
		||||
apiRouter.post("/artifactTransmutation.php", artifactTransmutationController);
 | 
			
		||||
apiRouter.post("/changeDojoRoot.php", changeDojoRootController);
 | 
			
		||||
apiRouter.post("/claimCompletedRecipe.php", claimCompletedRecipeController);
 | 
			
		||||
apiRouter.post("/claimJunctionChallengeReward.php", claimJunctionChallengeRewardController);
 | 
			
		||||
apiRouter.post("/clearDialogueHistory.php", clearDialogueHistoryController);
 | 
			
		||||
apiRouter.post("/clearNewEpisodeReward.php", clearNewEpisodeRewardController);
 | 
			
		||||
apiRouter.post("/commitStoryModeDecision.php", (_req, res) => { res.end(); }); // U14 (maybe wanna actually unlock the ship features?)
 | 
			
		||||
@ -245,6 +250,7 @@ apiRouter.post("/contributeToVault.php", contributeToVaultController);
 | 
			
		||||
apiRouter.post("/createAlliance.php", createAllianceController);
 | 
			
		||||
apiRouter.post("/createGuild.php", createGuildController);
 | 
			
		||||
apiRouter.post("/crewMembers.php", crewMembersController);
 | 
			
		||||
apiRouter.post("/crewShipFusion.php", crewShipFusionController);
 | 
			
		||||
apiRouter.post("/crewShipIdentifySalvage.php", crewShipIdentifySalvageController);
 | 
			
		||||
apiRouter.post("/customizeGuildRanks.php", customizeGuildRanksController);
 | 
			
		||||
apiRouter.post("/customObstacleCourseLeaderboard.php", customObstacleCourseLeaderboardController);
 | 
			
		||||
@ -280,6 +286,7 @@ apiRouter.post("/inventorySlots.php", inventorySlotsController);
 | 
			
		||||
apiRouter.post("/joinSession.php", joinSessionController);
 | 
			
		||||
apiRouter.post("/login.php", loginController);
 | 
			
		||||
apiRouter.post("/loginRewardsSelection.php", loginRewardsSelectionController);
 | 
			
		||||
apiRouter.post("/logout.php", logoutController); // from ~U16, don't know when they changed it to GET
 | 
			
		||||
apiRouter.post("/maturePet.php", maturePetController);
 | 
			
		||||
apiRouter.post("/missionInventoryUpdate.php", missionInventoryUpdateController);
 | 
			
		||||
apiRouter.post("/modularWeaponCrafting.php", modularWeaponCraftingController);
 | 
			
		||||
@ -317,6 +324,7 @@ apiRouter.post("/setPlacedDecoInfo.php", setPlacedDecoInfoController);
 | 
			
		||||
apiRouter.post("/setShipCustomizations.php", setShipCustomizationsController);
 | 
			
		||||
apiRouter.post("/setShipFavouriteLoadout.php", setShipFavouriteLoadoutController);
 | 
			
		||||
apiRouter.post("/setShipVignette.php", setShipVignetteController);
 | 
			
		||||
apiRouter.post("/setSuitInfection.php", setSuitInfectionController);
 | 
			
		||||
apiRouter.post("/setWeaponSkillTree.php", setWeaponSkillTreeController);
 | 
			
		||||
apiRouter.post("/shipDecorations.php", shipDecorationsController);
 | 
			
		||||
apiRouter.post("/startCollectibleEntry.php", startCollectibleEntryController);
 | 
			
		||||
@ -327,6 +335,7 @@ apiRouter.post("/syndicateSacrifice.php", syndicateSacrificeController);
 | 
			
		||||
apiRouter.post("/syndicateStandingBonus.php", syndicateStandingBonusController);
 | 
			
		||||
apiRouter.post("/tauntHistory.php", tauntHistoryController);
 | 
			
		||||
apiRouter.post("/trainingResult.php", trainingResultController);
 | 
			
		||||
apiRouter.post("/umbra.php", umbraController);
 | 
			
		||||
apiRouter.post("/unlockShipFeature.php", unlockShipFeatureController);
 | 
			
		||||
apiRouter.post("/updateAlignment.php", updateAlignmentController);
 | 
			
		||||
apiRouter.post("/updateChallengeProgress.php", updateChallengeProgressController);
 | 
			
		||||
 | 
			
		||||
@ -11,6 +11,9 @@ import { renameAccountController } from "@/src/controllers/custom/renameAccountC
 | 
			
		||||
import { ircDroppedController } from "@/src/controllers/custom/ircDroppedController";
 | 
			
		||||
import { unlockAllIntrinsicsController } from "@/src/controllers/custom/unlockAllIntrinsicsController";
 | 
			
		||||
import { addMissingMaxRankModsController } from "@/src/controllers/custom/addMissingMaxRankModsController";
 | 
			
		||||
import { webuiFileChangeDetectedController } from "@/src/controllers/custom/webuiFileChangeDetectedController";
 | 
			
		||||
import { completeAllMissionsController } from "@/src/controllers/custom/completeAllMissionsController";
 | 
			
		||||
import { addMissingHelminthBlueprintsController } from "@/src/controllers/custom/addMissingHelminthBlueprintsController";
 | 
			
		||||
 | 
			
		||||
import { createAccountController } from "@/src/controllers/custom/createAccountController";
 | 
			
		||||
import { createMessageController } from "@/src/controllers/custom/createMessageController";
 | 
			
		||||
@ -20,10 +23,10 @@ import { addXpController } from "@/src/controllers/custom/addXpController";
 | 
			
		||||
import { importController } from "@/src/controllers/custom/importController";
 | 
			
		||||
import { manageQuestsController } from "@/src/controllers/custom/manageQuestsController";
 | 
			
		||||
import { setEvolutionProgressController } from "@/src/controllers/custom/setEvolutionProgressController";
 | 
			
		||||
import { setBoosterController } from "@/src/controllers/custom/setBoosterController";
 | 
			
		||||
import { updateFingerprintController } from "@/src/controllers/custom/updateFingerprintController";
 | 
			
		||||
 | 
			
		||||
import { getConfigDataController } from "@/src/controllers/custom/getConfigDataController";
 | 
			
		||||
import { updateConfigDataController } from "@/src/controllers/custom/updateConfigDataController";
 | 
			
		||||
import { setBoosterController } from "../controllers/custom/setBoosterController";
 | 
			
		||||
import { getConfigController, setConfigController } from "@/src/controllers/custom/configController";
 | 
			
		||||
 | 
			
		||||
const customRouter = express.Router();
 | 
			
		||||
 | 
			
		||||
@ -38,6 +41,9 @@ customRouter.get("/renameAccount", renameAccountController);
 | 
			
		||||
customRouter.get("/ircDropped", ircDroppedController);
 | 
			
		||||
customRouter.get("/unlockAllIntrinsics", unlockAllIntrinsicsController);
 | 
			
		||||
customRouter.get("/addMissingMaxRankMods", addMissingMaxRankModsController);
 | 
			
		||||
customRouter.get("/webuiFileChangeDetected", webuiFileChangeDetectedController);
 | 
			
		||||
customRouter.get("/completeAllMissions", completeAllMissionsController);
 | 
			
		||||
customRouter.get("/addMissingHelminthBlueprints", addMissingHelminthBlueprintsController);
 | 
			
		||||
 | 
			
		||||
customRouter.post("/createAccount", createAccountController);
 | 
			
		||||
customRouter.post("/createMessage", createMessageController);
 | 
			
		||||
@ -48,8 +54,9 @@ customRouter.post("/import", importController);
 | 
			
		||||
customRouter.post("/manageQuests", manageQuestsController);
 | 
			
		||||
customRouter.post("/setEvolutionProgress", setEvolutionProgressController);
 | 
			
		||||
customRouter.post("/setBooster", setBoosterController);
 | 
			
		||||
customRouter.post("/updateFingerprint", updateFingerprintController);
 | 
			
		||||
 | 
			
		||||
customRouter.get("/config", getConfigDataController);
 | 
			
		||||
customRouter.post("/config", updateConfigDataController);
 | 
			
		||||
customRouter.post("/getConfig", getConfigController);
 | 
			
		||||
customRouter.post("/setConfig", setConfigController);
 | 
			
		||||
 | 
			
		||||
export { customRouter };
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,9 @@
 | 
			
		||||
import express from "express";
 | 
			
		||||
import path from "path";
 | 
			
		||||
import { repoDir, rootDir } from "@/src/helpers/pathHelper";
 | 
			
		||||
import { args } from "@/src/helpers/commandLineArguments";
 | 
			
		||||
 | 
			
		||||
const baseDir = args.dev ? repoDir : rootDir;
 | 
			
		||||
 | 
			
		||||
const webuiRouter = express.Router();
 | 
			
		||||
 | 
			
		||||
@ -19,29 +22,29 @@ webuiRouter.use("/webui", (req, res, next) => {
 | 
			
		||||
 | 
			
		||||
// Serve virtual routes
 | 
			
		||||
webuiRouter.get("/webui/inventory", (_req, res) => {
 | 
			
		||||
    res.sendFile(path.join(rootDir, "static/webui/index.html"));
 | 
			
		||||
    res.sendFile(path.join(baseDir, "static/webui/index.html"));
 | 
			
		||||
});
 | 
			
		||||
webuiRouter.get(/webui\/powersuit\/(.+)/, (_req, res) => {
 | 
			
		||||
    res.sendFile(path.join(rootDir, "static/webui/index.html"));
 | 
			
		||||
webuiRouter.get("/webui/detailedView", (_req, res) => {
 | 
			
		||||
    res.sendFile(path.join(baseDir, "static/webui/index.html"));
 | 
			
		||||
});
 | 
			
		||||
webuiRouter.get("/webui/mods", (_req, res) => {
 | 
			
		||||
    res.sendFile(path.join(rootDir, "static/webui/index.html"));
 | 
			
		||||
    res.sendFile(path.join(baseDir, "static/webui/index.html"));
 | 
			
		||||
});
 | 
			
		||||
webuiRouter.get("/webui/settings", (_req, res) => {
 | 
			
		||||
    res.sendFile(path.join(rootDir, "static/webui/index.html"));
 | 
			
		||||
    res.sendFile(path.join(baseDir, "static/webui/index.html"));
 | 
			
		||||
});
 | 
			
		||||
webuiRouter.get("/webui/quests", (_req, res) => {
 | 
			
		||||
    res.sendFile(path.join(rootDir, "static/webui/index.html"));
 | 
			
		||||
    res.sendFile(path.join(baseDir, "static/webui/index.html"));
 | 
			
		||||
});
 | 
			
		||||
webuiRouter.get("/webui/cheats", (_req, res) => {
 | 
			
		||||
    res.sendFile(path.join(rootDir, "static/webui/index.html"));
 | 
			
		||||
    res.sendFile(path.join(baseDir, "static/webui/index.html"));
 | 
			
		||||
});
 | 
			
		||||
webuiRouter.get("/webui/import", (_req, res) => {
 | 
			
		||||
    res.sendFile(path.join(rootDir, "static/webui/index.html"));
 | 
			
		||||
    res.sendFile(path.join(baseDir, "static/webui/index.html"));
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Serve static files
 | 
			
		||||
webuiRouter.use("/webui", express.static(path.join(rootDir, "static/webui")));
 | 
			
		||||
webuiRouter.use("/webui", express.static(path.join(baseDir, "static/webui")));
 | 
			
		||||
 | 
			
		||||
// Serve favicon
 | 
			
		||||
webuiRouter.get("/favicon.ico", (_req, res) => {
 | 
			
		||||
@ -58,7 +61,7 @@ webuiRouter.get("/webui/riven-tool/RivenParser.js", (_req, res) => {
 | 
			
		||||
 | 
			
		||||
// Serve translations
 | 
			
		||||
webuiRouter.get("/translations/:file", (req, res) => {
 | 
			
		||||
    res.sendFile(path.join(rootDir, `static/webui/translations/${req.params.file}`));
 | 
			
		||||
    res.sendFile(path.join(baseDir, `static/webui/translations/${req.params.file}`));
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export { webuiRouter };
 | 
			
		||||
 | 
			
		||||
@ -1,8 +1,9 @@
 | 
			
		||||
import fs from "fs";
 | 
			
		||||
import path from "path";
 | 
			
		||||
import { repoDir } from "@/src/helpers/pathHelper";
 | 
			
		||||
import { args } from "@/src/helpers/commandLineArguments";
 | 
			
		||||
 | 
			
		||||
interface IConfig {
 | 
			
		||||
export interface IConfig {
 | 
			
		||||
    mongodbUrl: string;
 | 
			
		||||
    logger: {
 | 
			
		||||
        files: boolean;
 | 
			
		||||
@ -18,13 +19,16 @@ interface IConfig {
 | 
			
		||||
    skipTutorial?: boolean;
 | 
			
		||||
    skipAllDialogue?: boolean;
 | 
			
		||||
    unlockAllScans?: boolean;
 | 
			
		||||
    unlockAllMissions?: boolean;
 | 
			
		||||
    infiniteCredits?: boolean;
 | 
			
		||||
    infinitePlatinum?: boolean;
 | 
			
		||||
    infiniteEndo?: boolean;
 | 
			
		||||
    infiniteRegalAya?: boolean;
 | 
			
		||||
    infiniteHelminthMaterials?: boolean;
 | 
			
		||||
    claimingBlueprintRefundsIngredients?: boolean;
 | 
			
		||||
    dontSubtractPurchaseCreditCost?: boolean;
 | 
			
		||||
    dontSubtractPurchasePlatinumCost?: boolean;
 | 
			
		||||
    dontSubtractPurchaseItemCost?: boolean;
 | 
			
		||||
    dontSubtractPurchaseStandingCost?: boolean;
 | 
			
		||||
    dontSubtractVoidTraces?: boolean;
 | 
			
		||||
    dontSubtractConsumables?: boolean;
 | 
			
		||||
    unlockAllShipFeatures?: boolean;
 | 
			
		||||
@ -44,7 +48,11 @@ interface IConfig {
 | 
			
		||||
    noVendorPurchaseLimits?: boolean;
 | 
			
		||||
    noDeathMarks?: boolean;
 | 
			
		||||
    noKimCooldowns?: boolean;
 | 
			
		||||
    fullyStockedVendors?: boolean;
 | 
			
		||||
    baroAlwaysAvailable?: boolean;
 | 
			
		||||
    baroFullyStocked?: boolean;
 | 
			
		||||
    syndicateMissionsRepeatable?: boolean;
 | 
			
		||||
    unlockAllProfitTakerStages?: boolean;
 | 
			
		||||
    instantFinishRivenChallenge?: boolean;
 | 
			
		||||
    instantResourceExtractorDrones?: boolean;
 | 
			
		||||
    noResourceExtractorDronesDamage?: boolean;
 | 
			
		||||
@ -55,20 +63,39 @@ interface IConfig {
 | 
			
		||||
    noDojoResearchCosts?: boolean;
 | 
			
		||||
    noDojoResearchTime?: boolean;
 | 
			
		||||
    fastClanAscension?: boolean;
 | 
			
		||||
    missionsCanGiveAllRelics?: boolean;
 | 
			
		||||
    exceptionalRelicsAlwaysGiveBronzeReward?: boolean;
 | 
			
		||||
    flawlessRelicsAlwaysGiveSilverReward?: boolean;
 | 
			
		||||
    radiantRelicsAlwaysGiveGoldReward?: boolean;
 | 
			
		||||
    unlockAllSimarisResearchEntries?: boolean;
 | 
			
		||||
    disableDailyTribute?: boolean;
 | 
			
		||||
    spoofMasteryRank?: number;
 | 
			
		||||
    relicRewardItemCountMultiplier?: number;
 | 
			
		||||
    nightwaveStandingMultiplier?: number;
 | 
			
		||||
    unfaithfulBugFixes?: {
 | 
			
		||||
        ignore1999LastRegionPlayed?: boolean;
 | 
			
		||||
        fixXtraCheeseTimer?: boolean;
 | 
			
		||||
    };
 | 
			
		||||
    worldState?: {
 | 
			
		||||
        creditBoost?: boolean;
 | 
			
		||||
        affinityBoost?: boolean;
 | 
			
		||||
        resourceBoost?: boolean;
 | 
			
		||||
        starDays?: boolean;
 | 
			
		||||
        galleonOfGhouls?: number;
 | 
			
		||||
        eidolonOverride?: string;
 | 
			
		||||
        vallisOverride?: string;
 | 
			
		||||
        duviriOverride?: string;
 | 
			
		||||
        nightwaveOverride?: string;
 | 
			
		||||
        allTheFissures?: string;
 | 
			
		||||
        circuitGameModes?: string[];
 | 
			
		||||
        darvoStockMultiplier?: number;
 | 
			
		||||
    };
 | 
			
		||||
    dev?: {
 | 
			
		||||
        keepVendorsExpired?: boolean;
 | 
			
		||||
    };
 | 
			
		||||
    nightwaveStandingMultiplier?: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const configPath = path.join(repoDir, "config.json");
 | 
			
		||||
export const configPath = path.join(repoDir, args.configPath ?? "config.json");
 | 
			
		||||
 | 
			
		||||
export const config: IConfig = {
 | 
			
		||||
    mongodbUrl: "mongodb://127.0.0.1:27017/openWF",
 | 
			
		||||
@ -80,11 +107,13 @@ export const config: IConfig = {
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const loadConfig = (): void => {
 | 
			
		||||
    const newConfig = JSON.parse(fs.readFileSync(configPath, "utf-8")) as IConfig;
 | 
			
		||||
 | 
			
		||||
    // Set all values to undefined now so if the new config.json omits some fields that were previously present, it's correct in-memory.
 | 
			
		||||
    for (const key of Object.keys(config)) {
 | 
			
		||||
        // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
 | 
			
		||||
        (config as any)[key] = undefined;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Object.assign(config, JSON.parse(fs.readFileSync(configPath, "utf-8")));
 | 
			
		||||
    Object.assign(config, newConfig);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -1,27 +1,35 @@
 | 
			
		||||
import fs from "fs";
 | 
			
		||||
import chokidar from "chokidar";
 | 
			
		||||
import fsPromises from "fs/promises";
 | 
			
		||||
import { logger } from "../utils/logger";
 | 
			
		||||
import { config, configPath, loadConfig } from "./configService";
 | 
			
		||||
import { getWebPorts, startWebServer, stopWebServer } from "./webService";
 | 
			
		||||
import { getWebPorts, sendWsBroadcast, startWebServer, stopWebServer } from "./webService";
 | 
			
		||||
import { Inbox } from "../models/inboxModel";
 | 
			
		||||
 | 
			
		||||
let amnesia = false;
 | 
			
		||||
fs.watchFile(configPath, () => {
 | 
			
		||||
chokidar.watch(configPath).on("change", () => {
 | 
			
		||||
    if (amnesia) {
 | 
			
		||||
        amnesia = false;
 | 
			
		||||
    } else {
 | 
			
		||||
        logger.info("Detected a change to config.json, reloading its contents.");
 | 
			
		||||
        logger.info("Detected a change to config file, reloading its contents.");
 | 
			
		||||
        try {
 | 
			
		||||
            loadConfig();
 | 
			
		||||
        } catch (e) {
 | 
			
		||||
            logger.error("Failed to reload config.json. Did you delete it?! Execution cannot continue.");
 | 
			
		||||
            process.exit(1);
 | 
			
		||||
            logger.error("Config changes were not applied: " + (e as Error).message);
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
        validateConfig();
 | 
			
		||||
        syncConfigWithDatabase();
 | 
			
		||||
 | 
			
		||||
        const webPorts = getWebPorts();
 | 
			
		||||
        if (config.httpPort != webPorts.http || config.httpsPort != webPorts.https) {
 | 
			
		||||
            logger.info(`Restarting web server to apply port changes.`);
 | 
			
		||||
 | 
			
		||||
            // Tell webui clients to reload with new port
 | 
			
		||||
            sendWsBroadcast({ ports: { http: config.httpPort, https: config.httpsPort } });
 | 
			
		||||
 | 
			
		||||
            void stopWebServer().then(startWebServer);
 | 
			
		||||
        } else {
 | 
			
		||||
            sendWsBroadcast({ config_reloaded: true });
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
});
 | 
			
		||||
@ -40,19 +48,29 @@ export const validateConfig = (): void => {
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    if (
 | 
			
		||||
        config.worldState?.galleonOfGhouls &&
 | 
			
		||||
        config.worldState.galleonOfGhouls != 1 &&
 | 
			
		||||
        config.worldState.galleonOfGhouls != 2 &&
 | 
			
		||||
        config.worldState.galleonOfGhouls != 3
 | 
			
		||||
    ) {
 | 
			
		||||
        config.worldState.galleonOfGhouls = 0;
 | 
			
		||||
        modified = true;
 | 
			
		||||
    }
 | 
			
		||||
    if (modified) {
 | 
			
		||||
        logger.info(`Updating config.json to fix some issues with it.`);
 | 
			
		||||
        logger.info(`Updating config file to fix some issues with it.`);
 | 
			
		||||
        void saveConfig();
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const updateConfig = async (data: string): Promise<void> => {
 | 
			
		||||
    amnesia = true;
 | 
			
		||||
    await fsPromises.writeFile(configPath, data);
 | 
			
		||||
    Object.assign(config, JSON.parse(data));
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const saveConfig = async (): Promise<void> => {
 | 
			
		||||
    amnesia = true;
 | 
			
		||||
    await fsPromises.writeFile(configPath, JSON.stringify(config, null, 2));
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const syncConfigWithDatabase = (): void => {
 | 
			
		||||
    // Event messages are deleted after endDate. Since we don't use beginDate/endDate and instead have config toggles, we need to delete the messages once those bools are false.
 | 
			
		||||
    if (!config.worldState?.galleonOfGhouls) {
 | 
			
		||||
        void Inbox.deleteMany({ goalTag: "GalleonRobbery" }).then(() => {}); // For some reason, I can't just do `Inbox.deleteMany(...)`; it needs this whole circus.
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -349,7 +349,8 @@ export const removeDojoDeco = (
 | 
			
		||||
            component.DecoCapacity! += meta.capacityCost;
 | 
			
		||||
        }
 | 
			
		||||
    } else {
 | 
			
		||||
        const itemType = Object.entries(ExportResources).find(arr => arr[1].deco == deco.Type)![0];
 | 
			
		||||
        const [itemType, meta] = Object.entries(ExportResources).find(arr => arr[1].deco == deco.Type)!;
 | 
			
		||||
        component.DecoCapacity! += meta.dojoCapacityCost!;
 | 
			
		||||
        if (deco.Sockets !== undefined) {
 | 
			
		||||
            addVaultFusionTreasures(guild, [
 | 
			
		||||
                {
 | 
			
		||||
@ -549,6 +550,19 @@ export const processFundedGuildTechProject = (
 | 
			
		||||
        guild.XP += recipe.guildXpValue;
 | 
			
		||||
    }
 | 
			
		||||
    setGuildTechLogState(guild, techProject.ItemType, config.noDojoResearchTime ? 4 : 3, techProject.CompletionDate);
 | 
			
		||||
    if (config.noDojoResearchTime) {
 | 
			
		||||
        processCompletedGuildTechProject(guild, techProject.ItemType);
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const processCompletedGuildTechProject = (guild: TGuildDatabaseDocument, type: string): void => {
 | 
			
		||||
    if (type.startsWith("/Lotus/Levels/ClanDojo/ComponentPropRecipes/NpcPlaceables/")) {
 | 
			
		||||
        guild.VaultDecoRecipes ??= [];
 | 
			
		||||
        guild.VaultDecoRecipes.push({
 | 
			
		||||
            ItemType: type,
 | 
			
		||||
            ItemCount: 1
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const setGuildTechLogState = (
 | 
			
		||||
 | 
			
		||||
@ -296,6 +296,12 @@ export const importInventory = (db: TInventoryDatabaseDocument, client: Partial<
 | 
			
		||||
            db[key] = client[key];
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    // IRewardAtten[]
 | 
			
		||||
    for (const key of ["SortieRewardAttenuation", "SpecialItemRewardAttenuation"] as const) {
 | 
			
		||||
        if (client[key] !== undefined) {
 | 
			
		||||
            db[key] = client[key];
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    if (client.XPInfo !== undefined) {
 | 
			
		||||
        db.XPInfo = client.XPInfo;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -2,8 +2,8 @@ import { IMessageDatabase, Inbox } from "@/src/models/inboxModel";
 | 
			
		||||
import { getAccountForRequest } from "@/src/services/loginService";
 | 
			
		||||
import { HydratedDocument, Types } from "mongoose";
 | 
			
		||||
import { Request } from "express";
 | 
			
		||||
import eventMessages from "@/static/fixed_responses/eventMessages.json";
 | 
			
		||||
import { logger } from "@/src/utils/logger";
 | 
			
		||||
import { unixTimesInMs } from "../constants/timeConstants";
 | 
			
		||||
import { config } from "./configService";
 | 
			
		||||
 | 
			
		||||
export const getAllMessagesSorted = async (accountId: string): Promise<HydratedDocument<IMessageDatabase>[]> => {
 | 
			
		||||
    const inbox = await Inbox.find({ ownerId: accountId }).sort({ date: -1 });
 | 
			
		||||
@ -29,37 +29,72 @@ export const deleteAllMessagesRead = async (accountId: string): Promise<void> =>
 | 
			
		||||
 | 
			
		||||
export const createNewEventMessages = async (req: Request): Promise<void> => {
 | 
			
		||||
    const account = await getAccountForRequest(req);
 | 
			
		||||
    const latestEventMessageDate = account.LatestEventMessageDate;
 | 
			
		||||
    const newEventMessages: IMessageCreationTemplate[] = [];
 | 
			
		||||
 | 
			
		||||
    //TODO: is baroo there? create these kind of messages too (periodical messages)
 | 
			
		||||
    const newEventMessages = eventMessages.Messages.filter(m => new Date(m.eventMessageDate) > latestEventMessageDate);
 | 
			
		||||
    // Baro
 | 
			
		||||
    const baroIndex = Math.trunc((Date.now() - 910800000) / (unixTimesInMs.day * 14));
 | 
			
		||||
    const baroStart = baroIndex * (unixTimesInMs.day * 14) + 910800000;
 | 
			
		||||
    const baroActualStart = baroStart + unixTimesInMs.day * (config.baroAlwaysAvailable ? 0 : 12);
 | 
			
		||||
    if (account.LatestEventMessageDate.getTime() < baroActualStart) {
 | 
			
		||||
        newEventMessages.push({
 | 
			
		||||
            sndr: "/Lotus/Language/G1Quests/VoidTraderName",
 | 
			
		||||
            sub: "/Lotus/Language/CommunityMessages/VoidTraderAppearanceTitle",
 | 
			
		||||
            msg: "/Lotus/Language/CommunityMessages/VoidTraderAppearanceMessage",
 | 
			
		||||
            icon: "/Lotus/Interface/Icons/Npcs/BaroKiTeerPortrait.png",
 | 
			
		||||
            startDate: new Date(baroActualStart),
 | 
			
		||||
            endDate: new Date(baroStart + unixTimesInMs.day * 14),
 | 
			
		||||
            CrossPlatform: true,
 | 
			
		||||
            arg: [
 | 
			
		||||
                {
 | 
			
		||||
                    Key: "NODE_NAME",
 | 
			
		||||
                    Tag: ["EarthHUB", "MercuryHUB", "SaturnHUB", "PlutoHUB"][baroIndex % 4]
 | 
			
		||||
                }
 | 
			
		||||
            ],
 | 
			
		||||
            date: new Date(baroActualStart)
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // BUG: Deleting the inbox message manually means it'll just be automatically re-created. This is because we don't use startDate/endDate for these config-toggled events.
 | 
			
		||||
    if (config.worldState?.galleonOfGhouls) {
 | 
			
		||||
        if (!(await Inbox.exists({ ownerId: account._id, goalTag: "GalleonRobbery" }))) {
 | 
			
		||||
            newEventMessages.push({
 | 
			
		||||
                sndr: "/Lotus/Language/Bosses/BossCouncilorVayHek",
 | 
			
		||||
                sub: "/Lotus/Language/Events/GalleonRobberyIntroMsgTitle",
 | 
			
		||||
                msg: "/Lotus/Language/Events/GalleonRobberyIntroMsgDesc",
 | 
			
		||||
                icon: "/Lotus/Interface/Icons/Npcs/VayHekPortrait.png",
 | 
			
		||||
                transmission: "/Lotus/Sounds/Dialog/GalleonOfGhouls/DGhoulsWeekOneInbox0010VayHek",
 | 
			
		||||
                att: ["/Lotus/Upgrades/Skins/Events/OgrisOldSchool"],
 | 
			
		||||
                startDate: new Date(),
 | 
			
		||||
                goalTag: "GalleonRobbery"
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (newEventMessages.length === 0) {
 | 
			
		||||
        logger.debug(`No new event messages. Latest event message date: ${latestEventMessageDate.toISOString()}`);
 | 
			
		||||
        return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const savedEventMessages = await createMessage(account._id, newEventMessages);
 | 
			
		||||
    logger.debug("created event messages", savedEventMessages);
 | 
			
		||||
    await createMessage(account._id, newEventMessages);
 | 
			
		||||
 | 
			
		||||
    const latestEventMessage = newEventMessages.reduce((prev, current) =>
 | 
			
		||||
        prev.eventMessageDate > current.eventMessageDate ? prev : current
 | 
			
		||||
        prev.startDate! > current.startDate! ? prev : current
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    account.LatestEventMessageDate = new Date(latestEventMessage.eventMessageDate);
 | 
			
		||||
    account.LatestEventMessageDate = new Date(latestEventMessage.startDate!);
 | 
			
		||||
    await account.save();
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const createMessage = async (accountId: string | Types.ObjectId, messages: IMessageCreationTemplate[]) => {
 | 
			
		||||
export const createMessage = async (
 | 
			
		||||
    accountId: string | Types.ObjectId,
 | 
			
		||||
    messages: IMessageCreationTemplate[]
 | 
			
		||||
): Promise<void> => {
 | 
			
		||||
    const ownerIdMessages = messages.map(m => ({
 | 
			
		||||
        ...m,
 | 
			
		||||
        date: m.date ?? new Date(),
 | 
			
		||||
        ownerId: accountId
 | 
			
		||||
    }));
 | 
			
		||||
 | 
			
		||||
    const savedMessages = await Inbox.insertMany(ownerIdMessages);
 | 
			
		||||
    return savedMessages;
 | 
			
		||||
    await Inbox.insertMany(ownerIdMessages);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export interface IMessageCreationTemplate extends Omit<IMessageDatabase, "_id" | "date" | "ownerId"> {
 | 
			
		||||
    ownerId?: string;
 | 
			
		||||
    date?: Date;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -29,7 +29,8 @@ import {
 | 
			
		||||
    ICalendarProgress,
 | 
			
		||||
    INemesisWeaponTargetFingerprint,
 | 
			
		||||
    INemesisPetTargetFingerprint,
 | 
			
		||||
    IDialogueDatabase
 | 
			
		||||
    IDialogueDatabase,
 | 
			
		||||
    IKubrowPetPrintClient
 | 
			
		||||
} from "@/src/types/inventoryTypes/inventoryTypes";
 | 
			
		||||
import { IGenericUpdate, IUpdateNodeIntrosResponse } from "../types/genericUpdate";
 | 
			
		||||
import { IKeyChainRequest, IMissionInventoryUpdateRequest } from "../types/requestTypes";
 | 
			
		||||
@ -43,6 +44,7 @@ import {
 | 
			
		||||
} from "../types/inventoryTypes/commonInventoryTypes";
 | 
			
		||||
import {
 | 
			
		||||
    ExportArcanes,
 | 
			
		||||
    ExportBoosters,
 | 
			
		||||
    ExportBundles,
 | 
			
		||||
    ExportChallenges,
 | 
			
		||||
    ExportCustoms,
 | 
			
		||||
@ -81,12 +83,14 @@ import { addQuestKey, completeQuest } from "@/src/services/questService";
 | 
			
		||||
import { handleBundleAcqusition } from "./purchaseService";
 | 
			
		||||
import libraryDailyTasks from "@/static/fixed_responses/libraryDailyTasks.json";
 | 
			
		||||
import { getRandomElement, getRandomInt, getRandomWeightedReward, SRng } from "./rngService";
 | 
			
		||||
import { createMessage } from "./inboxService";
 | 
			
		||||
import { getMaxStanding } from "@/src/helpers/syndicateStandingHelper";
 | 
			
		||||
import { createMessage, IMessageCreationTemplate } from "./inboxService";
 | 
			
		||||
import { getMaxStanding, getMinStanding } from "@/src/helpers/syndicateStandingHelper";
 | 
			
		||||
import { getNightwaveSyndicateTag, getWorldState } from "./worldStateService";
 | 
			
		||||
import { ICalendarSeason } from "@/src/types/worldStateTypes";
 | 
			
		||||
import { generateNemesisProfile, INemesisProfile } from "../helpers/nemesisHelpers";
 | 
			
		||||
import { TAccountDocument } from "./loginService";
 | 
			
		||||
import { unixTimesInMs } from "../constants/timeConstants";
 | 
			
		||||
import { addString } from "../helpers/stringHelpers";
 | 
			
		||||
 | 
			
		||||
export const createInventory = async (
 | 
			
		||||
    accountOwnerId: Types.ObjectId,
 | 
			
		||||
@ -422,7 +426,6 @@ export const addItem = async (
 | 
			
		||||
                    ItemType: "/Lotus/Types/Game/KubrowPet/Eggs/KubrowEgg",
 | 
			
		||||
                    _id: new Types.ObjectId()
 | 
			
		||||
                };
 | 
			
		||||
                inventory.KubrowPetEggs ??= [];
 | 
			
		||||
                inventory.KubrowPetEggs.push(egg);
 | 
			
		||||
                changes.push({
 | 
			
		||||
                    ItemType: egg.ItemType,
 | 
			
		||||
@ -480,6 +483,16 @@ export const addItem = async (
 | 
			
		||||
        return addCustomization(inventory, typeName);
 | 
			
		||||
    }
 | 
			
		||||
    if (typeName in ExportUpgrades || typeName in ExportArcanes) {
 | 
			
		||||
        if (targetFingerprint) {
 | 
			
		||||
            if (quantity != 1) {
 | 
			
		||||
                logger.warn(`adding 1 of ${typeName} ${targetFingerprint} even tho quantity ${quantity} was requested`);
 | 
			
		||||
            }
 | 
			
		||||
            inventory.Upgrades.push({
 | 
			
		||||
                ItemType: typeName,
 | 
			
		||||
                UpgradeFingerprint: targetFingerprint
 | 
			
		||||
            });
 | 
			
		||||
            return {}; // there's not exactly a common "InventoryChanges" format for these
 | 
			
		||||
        }
 | 
			
		||||
        const changes = [
 | 
			
		||||
            {
 | 
			
		||||
                ItemType: typeName,
 | 
			
		||||
@ -497,6 +510,7 @@ export const addItem = async (
 | 
			
		||||
        // - Blueprints for Ancient Protector Specter, Shield Osprey Specter, etc. have num=1 despite giving their purchaseQuantity.
 | 
			
		||||
        if (!exactQuantity) {
 | 
			
		||||
            quantity *= ExportGear[typeName].purchaseQuantity ?? 1;
 | 
			
		||||
            logger.debug(`non-exact acquisition of ${typeName}; factored quantity is ${quantity}`);
 | 
			
		||||
        }
 | 
			
		||||
        const consumablesChanges = [
 | 
			
		||||
            {
 | 
			
		||||
@ -668,6 +682,17 @@ export const addItem = async (
 | 
			
		||||
        return await addEmailItem(inventory, typeName);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Boosters are an odd case. They're only added like this via Baro's Void Surplus afaik.
 | 
			
		||||
    {
 | 
			
		||||
        const boosterEntry = Object.entries(ExportBoosters).find(arr => arr[1].typeName == typeName);
 | 
			
		||||
        if (boosterEntry) {
 | 
			
		||||
            addBooster(typeName, quantity, inventory);
 | 
			
		||||
            return {
 | 
			
		||||
                Boosters: [{ ItemType: typeName, ExpiryDate: quantity }]
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Path-based duck typing
 | 
			
		||||
    switch (typeName.substr(1).split("/")[1]) {
 | 
			
		||||
        case "Powersuits":
 | 
			
		||||
@ -781,7 +806,11 @@ export const addItem = async (
 | 
			
		||||
                        typeName.substr(1).split("/")[3] == "CatbrowPet" ||
 | 
			
		||||
                        typeName.substr(1).split("/")[3] == "KubrowPet"
 | 
			
		||||
                    ) {
 | 
			
		||||
                        if (typeName != "/Lotus/Types/Game/KubrowPet/Eggs/KubrowPetEggItem") {
 | 
			
		||||
                        if (
 | 
			
		||||
                            typeName != "/Lotus/Types/Game/KubrowPet/Eggs/KubrowPetEggItem" &&
 | 
			
		||||
                            typeName != "/Lotus/Types/Game/KubrowPet/BlankTraitPrint" &&
 | 
			
		||||
                            typeName != "/Lotus/Types/Game/KubrowPet/ImprintedTraitPrint"
 | 
			
		||||
                        ) {
 | 
			
		||||
                            return addKubrowPet(inventory, typeName, undefined, premiumPurchase);
 | 
			
		||||
                        }
 | 
			
		||||
                    } else if (typeName.startsWith("/Lotus/Types/Game/CrewShip/CrewMember/")) {
 | 
			
		||||
@ -1045,8 +1074,13 @@ export const addKubrowPet = (
 | 
			
		||||
    const configs: IItemConfig[] = applyDefaultUpgrades(inventory, kubrowPet?.defaultUpgrades);
 | 
			
		||||
 | 
			
		||||
    if (!details) {
 | 
			
		||||
        let traits: ITraits;
 | 
			
		||||
        const isCatbrow = [
 | 
			
		||||
            "/Lotus/Types/Game/CatbrowPet/CheshireCatbrowPetPowerSuit",
 | 
			
		||||
            "/Lotus/Types/Game/CatbrowPet/MirrorCatbrowPetPowerSuit",
 | 
			
		||||
            "/Lotus/Types/Game/CatbrowPet/VampireCatbrowPetPowerSuit"
 | 
			
		||||
        ].includes(kubrowPetName);
 | 
			
		||||
 | 
			
		||||
        let traits: ITraits;
 | 
			
		||||
        if (kubrowPetName == "/Lotus/Types/Game/CatbrowPet/VampireCatbrowPetPowerSuit") {
 | 
			
		||||
            traits = {
 | 
			
		||||
                BaseColor: "/Lotus/Types/Game/CatbrowPet/Colors/CatbrowPetColorBaseVampire",
 | 
			
		||||
@ -1061,12 +1095,7 @@ export const addKubrowPet = (
 | 
			
		||||
                Tail: "/Lotus/Types/Game/CatbrowPet/Tails/CatbrowTailVampire"
 | 
			
		||||
            };
 | 
			
		||||
        } else {
 | 
			
		||||
            const isCatbrow = [
 | 
			
		||||
                "/Lotus/Types/Game/CatbrowPet/MirrorCatbrowPetPowerSuit",
 | 
			
		||||
                "/Lotus/Types/Game/CatbrowPet/CheshireCatbrowPetPowerSuit"
 | 
			
		||||
            ].includes(kubrowPetName);
 | 
			
		||||
            const traitsPool = isCatbrow ? catbrowDetails : kubrowDetails;
 | 
			
		||||
 | 
			
		||||
            traits = {
 | 
			
		||||
                BaseColor: getRandomWeightedReward(traitsPool.Colors, kubrowWeights)!.type,
 | 
			
		||||
                SecondaryColor: getRandomWeightedReward(traitsPool.Colors, kubrowWeights)!.type,
 | 
			
		||||
@ -1085,7 +1114,7 @@ export const addKubrowPet = (
 | 
			
		||||
            Name: "",
 | 
			
		||||
            IsPuppy: !premiumPurchase,
 | 
			
		||||
            HasCollar: true,
 | 
			
		||||
            PrintsRemaining: 3,
 | 
			
		||||
            PrintsRemaining: isCatbrow ? 3 : 2,
 | 
			
		||||
            Status: premiumPurchase ? Status.StatusStasis : Status.StatusIncubating,
 | 
			
		||||
            HatchDate: premiumPurchase ? new Date() : new Date(Date.now() + 10 * unixTimesInMs.hour), // On live, this seems to be somewhat randomised so that the pet hatches 9~11 hours after start.
 | 
			
		||||
            IsMale: !!getRandomInt(0, 1),
 | 
			
		||||
@ -1109,6 +1138,26 @@ export const addKubrowPet = (
 | 
			
		||||
    return inventoryChanges;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const addKubrowPetPrint = (
 | 
			
		||||
    inventory: TInventoryDatabaseDocument,
 | 
			
		||||
    pet: IEquipmentDatabase,
 | 
			
		||||
    inventoryChanges: IInventoryChanges
 | 
			
		||||
): void => {
 | 
			
		||||
    inventoryChanges.KubrowPetPrints ??= [];
 | 
			
		||||
    inventoryChanges.KubrowPetPrints.push(
 | 
			
		||||
        inventory.KubrowPetPrints[
 | 
			
		||||
            inventory.KubrowPetPrints.push({
 | 
			
		||||
                ItemType: "/Lotus/Types/Game/KubrowPet/ImprintedTraitPrint",
 | 
			
		||||
                Name: pet.Details!.Name,
 | 
			
		||||
                IsMale: pet.Details!.IsMale,
 | 
			
		||||
                Size: pet.Details!.Size,
 | 
			
		||||
                DominantTraits: pet.Details!.DominantTraits,
 | 
			
		||||
                RecessiveTraits: pet.Details!.RecessiveTraits
 | 
			
		||||
            }) - 1
 | 
			
		||||
        ].toJSON<IKubrowPetPrintClient>()
 | 
			
		||||
    );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const updateSlots = (
 | 
			
		||||
    inventory: TInventoryDatabaseDocument,
 | 
			
		||||
    slotName: SlotNames,
 | 
			
		||||
@ -1202,8 +1251,10 @@ export const addStanding = (
 | 
			
		||||
    inventory: TInventoryDatabaseDocument,
 | 
			
		||||
    syndicateTag: string,
 | 
			
		||||
    gainedStanding: number,
 | 
			
		||||
    isMedallion: boolean = false
 | 
			
		||||
): IAffiliationMods => {
 | 
			
		||||
    affiliationMods: IAffiliationMods[] = [],
 | 
			
		||||
    isMedallion: boolean = false,
 | 
			
		||||
    propagateAlignments: boolean = true
 | 
			
		||||
): void => {
 | 
			
		||||
    let syndicate = inventory.Affiliations.find(x => x.Tag == syndicateTag);
 | 
			
		||||
    const syndicateMeta = ExportSyndicates[syndicateTag];
 | 
			
		||||
 | 
			
		||||
@ -1215,6 +1266,10 @@ export const addStanding = (
 | 
			
		||||
    const max = getMaxStanding(syndicateMeta, syndicate.Title ?? 0);
 | 
			
		||||
    if (syndicate.Standing + gainedStanding > max) gainedStanding = max - syndicate.Standing;
 | 
			
		||||
 | 
			
		||||
    if (syndicate.Standing + gainedStanding < -71000) {
 | 
			
		||||
        gainedStanding = -71000 - syndicate.Standing;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!isMedallion || syndicateMeta.medallionsCappedByDailyLimit) {
 | 
			
		||||
        if (gainedStanding > getStandingLimit(inventory, syndicateMeta.dailyLimitBin)) {
 | 
			
		||||
            gainedStanding = getStandingLimit(inventory, syndicateMeta.dailyLimitBin);
 | 
			
		||||
@ -1223,10 +1278,27 @@ export const addStanding = (
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    syndicate.Standing += gainedStanding;
 | 
			
		||||
    return {
 | 
			
		||||
    const affiliationMod: IAffiliationMods = {
 | 
			
		||||
        Tag: syndicateTag,
 | 
			
		||||
        Standing: gainedStanding
 | 
			
		||||
    };
 | 
			
		||||
    affiliationMods.push(affiliationMod);
 | 
			
		||||
 | 
			
		||||
    if (syndicateMeta.alignments) {
 | 
			
		||||
        if (propagateAlignments) {
 | 
			
		||||
            for (const [tag, factor] of Object.entries(syndicateMeta.alignments)) {
 | 
			
		||||
                addStanding(inventory, tag, gainedStanding * factor, affiliationMods, isMedallion, false);
 | 
			
		||||
            }
 | 
			
		||||
        } else {
 | 
			
		||||
            while (syndicate.Standing < getMinStanding(syndicateMeta, syndicate.Title ?? 0)) {
 | 
			
		||||
                syndicate.Title ??= 0;
 | 
			
		||||
                syndicate.Title -= 1;
 | 
			
		||||
                affiliationMod.Title ??= 0;
 | 
			
		||||
                affiliationMod.Title -= 1;
 | 
			
		||||
                logger.debug(`${syndicateTag} is decreasing to title ${syndicate.Title} after applying alignment`);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// TODO: AffiliationMods support (Nightwave).
 | 
			
		||||
@ -1304,7 +1376,7 @@ export const addCustomization = (
 | 
			
		||||
    customizationName: string,
 | 
			
		||||
    inventoryChanges: IInventoryChanges = {}
 | 
			
		||||
): IInventoryChanges => {
 | 
			
		||||
    if (!inventory.FlavourItems.find(x => x.ItemType == customizationName)) {
 | 
			
		||||
    if (!inventory.FlavourItems.some(x => x.ItemType == customizationName)) {
 | 
			
		||||
        const flavourItemIndex = inventory.FlavourItems.push({ ItemType: customizationName }) - 1;
 | 
			
		||||
        // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
 | 
			
		||||
        inventoryChanges.FlavourItems ??= [];
 | 
			
		||||
@ -1320,7 +1392,7 @@ export const addSkin = (
 | 
			
		||||
    typeName: string,
 | 
			
		||||
    inventoryChanges: IInventoryChanges = {}
 | 
			
		||||
): IInventoryChanges => {
 | 
			
		||||
    if (inventory.WeaponSkins.find(x => x.ItemType == typeName)) {
 | 
			
		||||
    if (inventory.WeaponSkins.some(x => x.ItemType == typeName)) {
 | 
			
		||||
        logger.debug(`refusing to add WeaponSkin ${typeName} because account already owns it`);
 | 
			
		||||
    } else {
 | 
			
		||||
        const index = inventory.WeaponSkins.push({ ItemType: typeName, IsNew: true }) - 1;
 | 
			
		||||
@ -1501,7 +1573,22 @@ export const addEmailItem = async (
 | 
			
		||||
    const meta = ExportEmailItems[typeName];
 | 
			
		||||
    const emailItem = inventory.EmailItems.find(x => x.ItemType == typeName);
 | 
			
		||||
    if (!emailItem || !meta.sendOnlyOnce) {
 | 
			
		||||
        await createMessage(inventory.accountOwnerId, [convertInboxMessage(meta.message)]);
 | 
			
		||||
        const msg: IMessageCreationTemplate = convertInboxMessage(meta.message);
 | 
			
		||||
        if (msg.cinematic == "/Lotus/Levels/1999/PlayerHomeBalconyCinematics.level") {
 | 
			
		||||
            msg.customData = JSON.stringify({
 | 
			
		||||
                Tag: msg.customData + "KissCin",
 | 
			
		||||
                CinLoadout: {
 | 
			
		||||
                    Skins: inventory.AdultOperatorLoadOuts[0].Skins,
 | 
			
		||||
                    Upgrades: inventory.AdultOperatorLoadOuts[0].Upgrades,
 | 
			
		||||
                    attcol: inventory.AdultOperatorLoadOuts[0].attcol,
 | 
			
		||||
                    cloth: inventory.AdultOperatorLoadOuts[0].cloth,
 | 
			
		||||
                    eyecol: inventory.AdultOperatorLoadOuts[0].eyecol,
 | 
			
		||||
                    pricol: inventory.AdultOperatorLoadOuts[0].pricol,
 | 
			
		||||
                    syancol: inventory.AdultOperatorLoadOuts[0].syancol
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
        await createMessage(inventory.accountOwnerId, [msg]);
 | 
			
		||||
 | 
			
		||||
        if (emailItem) {
 | 
			
		||||
            emailItem.ItemCount += 1;
 | 
			
		||||
@ -1557,7 +1644,7 @@ export const addMiscItem = (
 | 
			
		||||
    inventory: TInventoryDatabaseDocument,
 | 
			
		||||
    type: string,
 | 
			
		||||
    count: number,
 | 
			
		||||
    inventoryChanges: IInventoryChanges
 | 
			
		||||
    inventoryChanges: IInventoryChanges = {}
 | 
			
		||||
): void => {
 | 
			
		||||
    const miscItemChanges: IMiscItem[] = [
 | 
			
		||||
        {
 | 
			
		||||
@ -1708,12 +1795,27 @@ export const addFocusXpIncreases = (inventory: TInventoryDatabaseDocument, focus
 | 
			
		||||
        AP_ANY
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    inventory.FocusXP ??= { AP_ATTACK: 0, AP_DEFENSE: 0, AP_TACTIC: 0, AP_POWER: 0, AP_WARD: 0 };
 | 
			
		||||
    inventory.FocusXP.AP_ATTACK += focusXpPlus[FocusType.AP_ATTACK];
 | 
			
		||||
    inventory.FocusXP.AP_DEFENSE += focusXpPlus[FocusType.AP_DEFENSE];
 | 
			
		||||
    inventory.FocusXP.AP_TACTIC += focusXpPlus[FocusType.AP_TACTIC];
 | 
			
		||||
    inventory.FocusXP.AP_POWER += focusXpPlus[FocusType.AP_POWER];
 | 
			
		||||
    inventory.FocusXP.AP_WARD += focusXpPlus[FocusType.AP_WARD];
 | 
			
		||||
    inventory.FocusXP ??= {};
 | 
			
		||||
    if (focusXpPlus[FocusType.AP_ATTACK]) {
 | 
			
		||||
        inventory.FocusXP.AP_ATTACK ??= 0;
 | 
			
		||||
        inventory.FocusXP.AP_ATTACK += focusXpPlus[FocusType.AP_ATTACK];
 | 
			
		||||
    }
 | 
			
		||||
    if (focusXpPlus[FocusType.AP_DEFENSE]) {
 | 
			
		||||
        inventory.FocusXP.AP_DEFENSE ??= 0;
 | 
			
		||||
        inventory.FocusXP.AP_DEFENSE += focusXpPlus[FocusType.AP_DEFENSE];
 | 
			
		||||
    }
 | 
			
		||||
    if (focusXpPlus[FocusType.AP_TACTIC]) {
 | 
			
		||||
        inventory.FocusXP.AP_TACTIC ??= 0;
 | 
			
		||||
        inventory.FocusXP.AP_TACTIC += focusXpPlus[FocusType.AP_TACTIC];
 | 
			
		||||
    }
 | 
			
		||||
    if (focusXpPlus[FocusType.AP_POWER]) {
 | 
			
		||||
        inventory.FocusXP.AP_POWER ??= 0;
 | 
			
		||||
        inventory.FocusXP.AP_POWER += focusXpPlus[FocusType.AP_POWER];
 | 
			
		||||
    }
 | 
			
		||||
    if (focusXpPlus[FocusType.AP_WARD]) {
 | 
			
		||||
        inventory.FocusXP.AP_WARD ??= 0;
 | 
			
		||||
        inventory.FocusXP.AP_WARD += focusXpPlus[FocusType.AP_WARD];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!config.noDailyFocusLimit) {
 | 
			
		||||
        inventory.DailyFocus -= focusXpPlus.reduce((a, b) => a + b, 0);
 | 
			
		||||
@ -1745,6 +1847,10 @@ export const addChallenges = (
 | 
			
		||||
        } else {
 | 
			
		||||
            inventory.ChallengeProgress.push({ Name, Progress });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (Name.startsWith("Calendar")) {
 | 
			
		||||
            addString(getCalendarProgress(inventory).SeasonProgress.ActivatedChallenges, Name);
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const affiliationMods: IAffiliationMods[] = [];
 | 
			
		||||
@ -1787,12 +1893,24 @@ export const addChallenges = (
 | 
			
		||||
    return affiliationMods;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const addMissionComplete = (inventory: TInventoryDatabaseDocument, { Tag, Completes }: IMission): void => {
 | 
			
		||||
export const addCalendarProgress = (inventory: TInventoryDatabaseDocument, value: { challenge: string }[]): void => {
 | 
			
		||||
    const calendarProgress = getCalendarProgress(inventory);
 | 
			
		||||
    const currentSeason = getWorldState().KnownCalendarSeasons[0];
 | 
			
		||||
    calendarProgress.SeasonProgress.LastCompletedChallengeDayIdx = currentSeason.Days.findIndex(
 | 
			
		||||
        day => day.events.length != 0 && day.events[0].challenge == value[value.length - 1].challenge
 | 
			
		||||
    );
 | 
			
		||||
    checkCalendarChallengeCompletion(calendarProgress, currentSeason);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const addMissionComplete = (inventory: TInventoryDatabaseDocument, { Tag, Completes, Tier }: IMission): void => {
 | 
			
		||||
    const { Missions } = inventory;
 | 
			
		||||
    const itemIndex = Missions.findIndex(item => item.Tag === Tag);
 | 
			
		||||
 | 
			
		||||
    if (itemIndex !== -1) {
 | 
			
		||||
        Missions[itemIndex].Completes += Completes;
 | 
			
		||||
        if (Tier) {
 | 
			
		||||
            Missions[itemIndex].Tier = Tier;
 | 
			
		||||
        }
 | 
			
		||||
    } else {
 | 
			
		||||
        Missions.push({ Tag, Completes });
 | 
			
		||||
    }
 | 
			
		||||
@ -1988,6 +2106,20 @@ export const getCalendarProgress = (inventory: TInventoryDatabaseDocument): ICal
 | 
			
		||||
    return inventory.CalendarProgress;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const checkCalendarChallengeCompletion = (
 | 
			
		||||
    calendarProgress: ICalendarProgress,
 | 
			
		||||
    currentSeason: ICalendarSeason
 | 
			
		||||
): void => {
 | 
			
		||||
    const dayIndex = calendarProgress.SeasonProgress.LastCompletedDayIdx + 1;
 | 
			
		||||
    if (calendarProgress.SeasonProgress.LastCompletedChallengeDayIdx >= dayIndex) {
 | 
			
		||||
        const day = currentSeason.Days[dayIndex];
 | 
			
		||||
        if (day.events.length != 0 && day.events[0].type == "CET_CHALLENGE") {
 | 
			
		||||
            //logger.debug(`already completed the challenge, skipping ahead`);
 | 
			
		||||
            calendarProgress.SeasonProgress.LastCompletedDayIdx++;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const giveNemesisWeaponRecipe = (
 | 
			
		||||
    inventory: TInventoryDatabaseDocument,
 | 
			
		||||
    weaponType: string,
 | 
			
		||||
 | 
			
		||||
@ -45,6 +45,39 @@ export type WeaponTypeInternal =
 | 
			
		||||
    | "SpecialItems";
 | 
			
		||||
 | 
			
		||||
export const getRecipe = (uniqueName: string): IRecipe | undefined => {
 | 
			
		||||
    // Handle crafting of archwing summon for versions prior to 39.0.0 as this blueprint was removed then.
 | 
			
		||||
    if (uniqueName == "/Lotus/Types/Recipes/EidolonRecipes/OpenArchwingSummonBlueprint") {
 | 
			
		||||
        return {
 | 
			
		||||
            resultType: "/Lotus/Types/Restoratives/OpenArchwingSummon",
 | 
			
		||||
            buildPrice: 7500,
 | 
			
		||||
            buildTime: 1800,
 | 
			
		||||
            skipBuildTimePrice: 10,
 | 
			
		||||
            consumeOnUse: false,
 | 
			
		||||
            num: 1,
 | 
			
		||||
            codexSecret: false,
 | 
			
		||||
            alwaysAvailable: true,
 | 
			
		||||
            ingredients: [
 | 
			
		||||
                {
 | 
			
		||||
                    ItemType: "/Lotus/Types/Gameplay/Eidolon/Resources/IraditeItem",
 | 
			
		||||
                    ItemCount: 50
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                    ItemType: "/Lotus/Types/Gameplay/Eidolon/Resources/GrokdrulItem",
 | 
			
		||||
                    ItemCount: 50
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                    ItemType: "/Lotus/Types/Items/Fish/Eidolon/FishParts/EidolonFishOilItem",
 | 
			
		||||
                    ItemCount: 30
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                    ItemType: "/Lotus/Types/Items/MiscItems/Circuits",
 | 
			
		||||
                    ItemCount: 600
 | 
			
		||||
                }
 | 
			
		||||
            ],
 | 
			
		||||
            excludeFromMarket: true
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return ExportRecipes[uniqueName];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -218,7 +251,9 @@ export const convertInboxMessage = (message: IInboxMessage): IMessage => {
 | 
			
		||||
    return {
 | 
			
		||||
        sndr: message.sender,
 | 
			
		||||
        msg: message.body,
 | 
			
		||||
        cinematic: message.cinematic,
 | 
			
		||||
        sub: message.title,
 | 
			
		||||
        customData: message.customData,
 | 
			
		||||
        att: message.attachments.length > 0 ? message.attachments : undefined,
 | 
			
		||||
        countedAtt: message.countedAttachments.length > 0 ? message.countedAttachments : undefined,
 | 
			
		||||
        icon: message.icon ?? "",
 | 
			
		||||
 | 
			
		||||
@ -144,7 +144,8 @@ export const claimLoginReward = async (
 | 
			
		||||
        case "RT_STORE_ITEM":
 | 
			
		||||
        case "RT_RECIPE":
 | 
			
		||||
        case "RT_RANDOM_RECIPE":
 | 
			
		||||
            return (await handleStoreItemAcquisition(reward.StoreItemType, inventory, reward.Amount)).InventoryChanges;
 | 
			
		||||
            return (await handleStoreItemAcquisition(reward.StoreItemType, inventory, reward.Amount, undefined, true))
 | 
			
		||||
                .InventoryChanges;
 | 
			
		||||
 | 
			
		||||
        case "RT_CREDITS":
 | 
			
		||||
            return updateCurrency(inventory, -reward.Amount, false);
 | 
			
		||||
 | 
			
		||||
@ -18,6 +18,23 @@ export const isNameTaken = async (name: string): Promise<boolean> => {
 | 
			
		||||
    return !!(await Account.findOne({ DisplayName: name }));
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const createNonce = (): number => {
 | 
			
		||||
    return Math.round(Math.random() * Number.MAX_SAFE_INTEGER);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getUsernameFromEmail = async (email: string): Promise<string> => {
 | 
			
		||||
    const nameFromEmail = email.substring(0, email.indexOf("@"));
 | 
			
		||||
    let name = nameFromEmail || email.substring(1) || "SpaceNinja";
 | 
			
		||||
    if (await isNameTaken(name)) {
 | 
			
		||||
        let suffix = 0;
 | 
			
		||||
        do {
 | 
			
		||||
            ++suffix;
 | 
			
		||||
            name = nameFromEmail + suffix;
 | 
			
		||||
        } while (await isNameTaken(name));
 | 
			
		||||
    }
 | 
			
		||||
    return nameFromEmail;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const createAccount = async (accountData: IDatabaseAccountRequiredFields): Promise<IDatabaseAccountJson> => {
 | 
			
		||||
    const account = new Account(accountData);
 | 
			
		||||
    try {
 | 
			
		||||
 | 
			
		||||
@ -2,6 +2,7 @@ import {
 | 
			
		||||
    ExportEnemies,
 | 
			
		||||
    ExportFusionBundles,
 | 
			
		||||
    ExportRegions,
 | 
			
		||||
    ExportRelics,
 | 
			
		||||
    ExportRewards,
 | 
			
		||||
    IMissionReward as IMissionRewardExternal,
 | 
			
		||||
    IRegion,
 | 
			
		||||
@ -13,6 +14,7 @@ import { IRngResult, SRng, getRandomElement, getRandomReward } from "@/src/servi
 | 
			
		||||
import { equipmentKeys, IMission, ITypeCount, TEquipmentKey } from "@/src/types/inventoryTypes/inventoryTypes";
 | 
			
		||||
import {
 | 
			
		||||
    addBooster,
 | 
			
		||||
    addCalendarProgress,
 | 
			
		||||
    addChallenges,
 | 
			
		||||
    addConsumables,
 | 
			
		||||
    addCrewShipAmmo,
 | 
			
		||||
@ -34,7 +36,6 @@ import {
 | 
			
		||||
    applyClientEquipmentUpdates,
 | 
			
		||||
    combineInventoryChanges,
 | 
			
		||||
    generateRewardSeed,
 | 
			
		||||
    getCalendarProgress,
 | 
			
		||||
    getDialogue,
 | 
			
		||||
    giveNemesisPetRecipe,
 | 
			
		||||
    giveNemesisWeaponRecipe,
 | 
			
		||||
@ -44,7 +45,7 @@ import {
 | 
			
		||||
import { updateQuestKey } from "@/src/services/questService";
 | 
			
		||||
import { Types } from "mongoose";
 | 
			
		||||
import { IAffiliationMods, IInventoryChanges } from "@/src/types/purchaseTypes";
 | 
			
		||||
import { fromStoreItem, getLevelKeyRewards, toStoreItem } from "@/src/services/itemDataService";
 | 
			
		||||
import { fromStoreItem, getLevelKeyRewards, isStoreItem, toStoreItem } from "@/src/services/itemDataService";
 | 
			
		||||
import { TInventoryDatabaseDocument } from "@/src/models/inventoryModels/inventoryModel";
 | 
			
		||||
import { getEntriesUnsafe } from "@/src/utils/ts-utils";
 | 
			
		||||
import { IEquipmentClient } from "@/src/types/inventoryTypes/commonInventoryTypes";
 | 
			
		||||
@ -86,7 +87,7 @@ const getRotations = (rewardInfo: IRewardInfo, tierOverride?: number): number[]
 | 
			
		||||
    if (rewardInfo.VaultsCracked) {
 | 
			
		||||
        const rotations: number[] = [];
 | 
			
		||||
        for (let i = 0; i != rewardInfo.VaultsCracked; ++i) {
 | 
			
		||||
            rotations.push(i);
 | 
			
		||||
            rotations.push(Math.min(i, 2));
 | 
			
		||||
        }
 | 
			
		||||
        return rotations;
 | 
			
		||||
    }
 | 
			
		||||
@ -233,7 +234,7 @@ export const addMissionInventoryUpdates = async (
 | 
			
		||||
    }
 | 
			
		||||
    for (const [key, value] of getEntriesUnsafe(inventoryUpdates)) {
 | 
			
		||||
        if (value === undefined) {
 | 
			
		||||
            logger.error(`Inventory update key ${key} has no value `);
 | 
			
		||||
            logger.error(`Inventory update key ${key} has no value`);
 | 
			
		||||
            continue;
 | 
			
		||||
        }
 | 
			
		||||
        switch (key) {
 | 
			
		||||
@ -266,7 +267,9 @@ export const addMissionInventoryUpdates = async (
 | 
			
		||||
                addMissionComplete(inventory, value);
 | 
			
		||||
                break;
 | 
			
		||||
            case "LastRegionPlayed":
 | 
			
		||||
                inventory.LastRegionPlayed = value;
 | 
			
		||||
                if (!(config.unfaithfulBugFixes?.ignore1999LastRegionPlayed && value === "1999MapName")) {
 | 
			
		||||
                    inventory.LastRegionPlayed = value;
 | 
			
		||||
                }
 | 
			
		||||
                break;
 | 
			
		||||
            case "RawUpgrades":
 | 
			
		||||
                addMods(inventory, value);
 | 
			
		||||
@ -478,7 +481,7 @@ export const addMissionInventoryUpdates = async (
 | 
			
		||||
                                    msg: "/Lotus/Language/G1Quests/DeathMarkMessage",
 | 
			
		||||
                                    icon: "/Lotus/Interface/Icons/Npcs/Stalker_d.png",
 | 
			
		||||
                                    highPriority: true,
 | 
			
		||||
                                    expiry: 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 clear if this is correct.
 | 
			
		||||
                                    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.
 | 
			
		||||
                                }
 | 
			
		||||
                            ]);
 | 
			
		||||
                        }
 | 
			
		||||
@ -522,7 +525,6 @@ export const addMissionInventoryUpdates = async (
 | 
			
		||||
            }
 | 
			
		||||
            case "KubrowPetEggs": {
 | 
			
		||||
                for (const egg of value) {
 | 
			
		||||
                    inventory.KubrowPetEggs ??= [];
 | 
			
		||||
                    inventory.KubrowPetEggs.push({
 | 
			
		||||
                        ItemType: egg.ItemType,
 | 
			
		||||
                        _id: new Types.ObjectId()
 | 
			
		||||
@ -605,6 +607,47 @@ export const addMissionInventoryUpdates = async (
 | 
			
		||||
                inventoryChanges.RegularCredits -= value;
 | 
			
		||||
                break;
 | 
			
		||||
            }
 | 
			
		||||
            case "GoalProgress": {
 | 
			
		||||
                for (const uploadProgress of value) {
 | 
			
		||||
                    const goal = getWorldState().Goals.find(x => x._id.$oid == uploadProgress._id.$oid);
 | 
			
		||||
                    if (goal && goal.Personal) {
 | 
			
		||||
                        inventory.PersonalGoalProgress ??= [];
 | 
			
		||||
                        const goalProgress = inventory.PersonalGoalProgress.find(x => x.goalId.equals(goal._id.$oid));
 | 
			
		||||
                        if (goalProgress) {
 | 
			
		||||
                            goalProgress.Best = Math.max(goalProgress.Best, uploadProgress.Best);
 | 
			
		||||
                            goalProgress.Count += uploadProgress.Count;
 | 
			
		||||
                        } else {
 | 
			
		||||
                            inventory.PersonalGoalProgress.push({
 | 
			
		||||
                                Best: uploadProgress.Best,
 | 
			
		||||
                                Count: uploadProgress.Count,
 | 
			
		||||
                                Tag: goal.Tag,
 | 
			
		||||
                                goalId: new Types.ObjectId(goal._id.$oid)
 | 
			
		||||
                            });
 | 
			
		||||
 | 
			
		||||
                            if (
 | 
			
		||||
                                goal.Reward &&
 | 
			
		||||
                                goal.Reward.items &&
 | 
			
		||||
                                goal.MissionKeyName &&
 | 
			
		||||
                                goal.MissionKeyName in goalMessagesByKey
 | 
			
		||||
                            ) {
 | 
			
		||||
                                // Send reward via inbox
 | 
			
		||||
                                const info = goalMessagesByKey[goal.MissionKeyName];
 | 
			
		||||
                                await createMessage(inventory.accountOwnerId, [
 | 
			
		||||
                                    {
 | 
			
		||||
                                        sndr: info.sndr,
 | 
			
		||||
                                        msg: info.msg,
 | 
			
		||||
                                        att: goal.Reward.items.map(x => (isStoreItem(x) ? fromStoreItem(x) : x)),
 | 
			
		||||
                                        sub: info.sub,
 | 
			
		||||
                                        icon: info.icon,
 | 
			
		||||
                                        highPriority: true
 | 
			
		||||
                                    }
 | 
			
		||||
                                ]);
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                break;
 | 
			
		||||
            }
 | 
			
		||||
            case "InvasionProgress": {
 | 
			
		||||
                for (const clientProgress of value) {
 | 
			
		||||
                    const dbProgress = inventory.QualifyingInvasions.find(x =>
 | 
			
		||||
@ -626,12 +669,7 @@ export const addMissionInventoryUpdates = async (
 | 
			
		||||
                break;
 | 
			
		||||
            }
 | 
			
		||||
            case "CalendarProgress": {
 | 
			
		||||
                const calendarProgress = getCalendarProgress(inventory);
 | 
			
		||||
                for (const progress of value) {
 | 
			
		||||
                    const challengeName = progress.challenge.substring(progress.challenge.lastIndexOf("/") + 1);
 | 
			
		||||
                    calendarProgress.SeasonProgress.LastCompletedChallengeDayIdx++;
 | 
			
		||||
                    calendarProgress.SeasonProgress.ActivatedChallenges.push(challengeName);
 | 
			
		||||
                }
 | 
			
		||||
                addCalendarProgress(inventory, value);
 | 
			
		||||
                break;
 | 
			
		||||
            }
 | 
			
		||||
            case "duviriCaveOffers": {
 | 
			
		||||
@ -923,6 +961,7 @@ const droptableAliases: Record<string, string> = {
 | 
			
		||||
 | 
			
		||||
//TODO: return type of partial missioninventoryupdate response
 | 
			
		||||
export const addMissionRewards = async (
 | 
			
		||||
    account: TAccountDocument,
 | 
			
		||||
    inventory: TInventoryDatabaseDocument,
 | 
			
		||||
    {
 | 
			
		||||
        wagerTier: wagerTier,
 | 
			
		||||
@ -958,17 +997,29 @@ export const addMissionRewards = async (
 | 
			
		||||
 | 
			
		||||
    let missionCompletionCredits = 0;
 | 
			
		||||
    //inventory change is what the client has not rewarded itself, also the client needs to know the credit changes for display
 | 
			
		||||
 | 
			
		||||
    if (rewardInfo.goalId) {
 | 
			
		||||
        const goal = getWorldState().Goals.find(x => x._id.$oid == rewardInfo.goalId);
 | 
			
		||||
        if (goal?.MissionKeyName) {
 | 
			
		||||
            levelKeyName = goal.MissionKeyName;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (levelKeyName) {
 | 
			
		||||
        const fixedLevelRewards = getLevelKeyRewards(levelKeyName);
 | 
			
		||||
        //logger.debug(`fixedLevelRewards ${fixedLevelRewards}`);
 | 
			
		||||
        if (fixedLevelRewards.levelKeyRewards) {
 | 
			
		||||
            addFixedLevelRewards(fixedLevelRewards.levelKeyRewards, inventory, MissionRewards, rewardInfo);
 | 
			
		||||
            missionCompletionCredits += addFixedLevelRewards(
 | 
			
		||||
                fixedLevelRewards.levelKeyRewards,
 | 
			
		||||
                MissionRewards,
 | 
			
		||||
                rewardInfo
 | 
			
		||||
            );
 | 
			
		||||
        }
 | 
			
		||||
        if (fixedLevelRewards.levelKeyRewards2) {
 | 
			
		||||
            for (const reward of fixedLevelRewards.levelKeyRewards2) {
 | 
			
		||||
                //quest stage completion credit rewards
 | 
			
		||||
                if (reward.rewardType == "RT_CREDITS") {
 | 
			
		||||
                    missionCompletionCredits += reward.amount; // will be added to inventory in addCredits
 | 
			
		||||
                    missionCompletionCredits += reward.amount;
 | 
			
		||||
                    continue;
 | 
			
		||||
                }
 | 
			
		||||
                MissionRewards.push({
 | 
			
		||||
@ -997,12 +1048,11 @@ export const addMissionRewards = async (
 | 
			
		||||
        ) {
 | 
			
		||||
            const levelCreditReward = getLevelCreditRewards(node);
 | 
			
		||||
            missionCompletionCredits += levelCreditReward;
 | 
			
		||||
            inventory.RegularCredits += levelCreditReward;
 | 
			
		||||
            logger.debug(`levelCreditReward ${levelCreditReward}`);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (node.missionReward) {
 | 
			
		||||
            missionCompletionCredits += addFixedLevelRewards(node.missionReward, inventory, MissionRewards, rewardInfo);
 | 
			
		||||
            missionCompletionCredits += addFixedLevelRewards(node.missionReward, MissionRewards, rewardInfo);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (rewardInfo.sortieTag == "Mission1") {
 | 
			
		||||
@ -1112,7 +1162,9 @@ export const addMissionRewards = async (
 | 
			
		||||
        combineInventoryChanges(inventoryChanges, inventoryChange.InventoryChanges);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const credits = addCredits(inventory, {
 | 
			
		||||
    inventory.RegularCredits += missionCompletionCredits;
 | 
			
		||||
 | 
			
		||||
    const credits = await addCredits(account, inventory, {
 | 
			
		||||
        missionCompletionCredits,
 | 
			
		||||
        missionDropCredits: creditDrops ?? 0,
 | 
			
		||||
        rngRewardCredits: inventoryChanges.RegularCredits ?? 0
 | 
			
		||||
@ -1203,7 +1255,7 @@ export const addMissionRewards = async (
 | 
			
		||||
 | 
			
		||||
    if (rewardInfo.JobStage != undefined && rewardInfo.jobId) {
 | 
			
		||||
        // eslint-disable-next-line @typescript-eslint/no-unused-vars
 | 
			
		||||
        const [jobType, unkIndex, hubNode, syndicateMissionId, locationTag] = rewardInfo.jobId.split("_");
 | 
			
		||||
        const [jobType, unkIndex, hubNode, syndicateMissionId] = rewardInfo.jobId.split("_");
 | 
			
		||||
        const syndicateMissions: ISyndicateMissionInfo[] = [];
 | 
			
		||||
        if (syndicateMissionId) {
 | 
			
		||||
            pushClassicBounties(syndicateMissions, idToBountyCycle(syndicateMissionId));
 | 
			
		||||
@ -1212,10 +1264,27 @@ export const addMissionRewards = async (
 | 
			
		||||
        if (syndicateEntry && syndicateEntry.Jobs) {
 | 
			
		||||
            let currentJob = syndicateEntry.Jobs[rewardInfo.JobTier!];
 | 
			
		||||
            if (syndicateEntry.Tag === "EntratiSyndicate") {
 | 
			
		||||
                const vault = syndicateEntry.Jobs.find(j => j.locationTag === locationTag);
 | 
			
		||||
                if (vault) currentJob = vault;
 | 
			
		||||
                let medallionAmount = Math.floor(currentJob.xpAmounts[rewardInfo.JobStage] / (rewardInfo.Q ? 0.8 : 1));
 | 
			
		||||
 | 
			
		||||
                if (
 | 
			
		||||
                    [
 | 
			
		||||
                        "DeimosRuinsExterminateBounty",
 | 
			
		||||
                        "DeimosRuinsEscortBounty",
 | 
			
		||||
                        "DeimosRuinsMistBounty",
 | 
			
		||||
                        "DeimosRuinsPurifyBounty",
 | 
			
		||||
                        "DeimosRuinsSacBounty",
 | 
			
		||||
                        "VaultBounty"
 | 
			
		||||
                    ].some(ending => jobType.endsWith(ending))
 | 
			
		||||
                ) {
 | 
			
		||||
                    const vault = syndicateEntry.Jobs.find(j => j.locationTag == rewardInfo.jobId!.split("_").at(-1));
 | 
			
		||||
                    if (vault) {
 | 
			
		||||
                        currentJob = vault;
 | 
			
		||||
                        if (jobType.endsWith("VaultBounty")) {
 | 
			
		||||
                            currentJob.xpAmounts = [currentJob.xpAmounts.reduce((partialSum, a) => partialSum + a, 0)];
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                let medallionAmount = Math.floor(
 | 
			
		||||
                    Math.min(rewardInfo.JobStage, currentJob.xpAmounts.length - 1) / (rewardInfo.Q ? 0.8 : 1)
 | 
			
		||||
                );
 | 
			
		||||
                if (
 | 
			
		||||
                    ["DeimosEndlessAreaDefenseBounty", "DeimosEndlessExcavateBounty", "DeimosEndlessPurifyBounty"].some(
 | 
			
		||||
                        ending => jobType.endsWith(ending)
 | 
			
		||||
@ -1236,19 +1305,18 @@ export const addMissionRewards = async (
 | 
			
		||||
                SyndicateXPItemReward = medallionAmount;
 | 
			
		||||
            } else {
 | 
			
		||||
                if (rewardInfo.JobTier! >= 0) {
 | 
			
		||||
                    AffiliationMods.push(
 | 
			
		||||
                        addStanding(
 | 
			
		||||
                            inventory,
 | 
			
		||||
                            syndicateEntry.Tag,
 | 
			
		||||
                            Math.floor(currentJob.xpAmounts[rewardInfo.JobStage] / (rewardInfo.Q ? 0.8 : 1))
 | 
			
		||||
                        )
 | 
			
		||||
                    addStanding(
 | 
			
		||||
                        inventory,
 | 
			
		||||
                        syndicateEntry.Tag,
 | 
			
		||||
                        Math.floor(currentJob.xpAmounts[rewardInfo.JobStage] / (rewardInfo.Q ? 0.8 : 1)),
 | 
			
		||||
                        AffiliationMods
 | 
			
		||||
                    );
 | 
			
		||||
                } else {
 | 
			
		||||
                    if (jobType.endsWith("Heists/HeistProfitTakerBountyOne") && rewardInfo.JobStage === 2) {
 | 
			
		||||
                        AffiliationMods.push(addStanding(inventory, syndicateEntry.Tag, 1000));
 | 
			
		||||
                        addStanding(inventory, syndicateEntry.Tag, 1000, AffiliationMods);
 | 
			
		||||
                    }
 | 
			
		||||
                    if (jobType.endsWith("Hunts/AllTeralystsHunt") && rewardInfo.JobStage === 2) {
 | 
			
		||||
                        AffiliationMods.push(addStanding(inventory, syndicateEntry.Tag, 5000));
 | 
			
		||||
                        addStanding(inventory, syndicateEntry.Tag, 5000, AffiliationMods);
 | 
			
		||||
                    }
 | 
			
		||||
                    if (
 | 
			
		||||
                        [
 | 
			
		||||
@ -1259,7 +1327,7 @@ export const addMissionRewards = async (
 | 
			
		||||
                            "Heists/HeistExploiterBountyOne"
 | 
			
		||||
                        ].some(ending => jobType.endsWith(ending))
 | 
			
		||||
                    ) {
 | 
			
		||||
                        AffiliationMods.push(addStanding(inventory, syndicateEntry.Tag, 1000));
 | 
			
		||||
                        addStanding(inventory, syndicateEntry.Tag, 1000, AffiliationMods);
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
@ -1267,9 +1335,9 @@ export const addMissionRewards = async (
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (rewardInfo.challengeMissionId) {
 | 
			
		||||
        const [syndicateTag, tierStr, chemistryStr] = rewardInfo.challengeMissionId.split("_");
 | 
			
		||||
        const [syndicateTag, tierStr, chemistryBuddyStr] = rewardInfo.challengeMissionId.split("_");
 | 
			
		||||
        const tier = Number(tierStr);
 | 
			
		||||
        const chemistry = Number(chemistryStr);
 | 
			
		||||
        const chemistryBuddy = Number(chemistryBuddyStr);
 | 
			
		||||
        const isSteelPath = missions?.Tier;
 | 
			
		||||
        if (syndicateTag === "ZarimanSyndicate") {
 | 
			
		||||
            let medallionAmount = tier + 1;
 | 
			
		||||
@ -1284,24 +1352,21 @@ export const addMissionRewards = async (
 | 
			
		||||
            let standingAmount = (tier + 1) * 1000;
 | 
			
		||||
            if (tier > 5) standingAmount = 7500; // InfestedLichBounty
 | 
			
		||||
            if (isSteelPath) standingAmount *= 1.5;
 | 
			
		||||
            AffiliationMods.push(addStanding(inventory, syndicateTag, standingAmount));
 | 
			
		||||
            addStanding(inventory, syndicateTag, standingAmount, AffiliationMods);
 | 
			
		||||
        }
 | 
			
		||||
        if (syndicateTag == "HexSyndicate" && chemistry && tier < 6) {
 | 
			
		||||
            const seed = getWorldState().SyndicateMissions.find(x => x.Tag == "HexSyndicate")!.Seed;
 | 
			
		||||
            const { nodes, buddies } = getHexBounties(seed);
 | 
			
		||||
            const buddy = buddies[tier];
 | 
			
		||||
            logger.debug(`Hex seed is ${seed}, giving chemistry for ${buddy}`);
 | 
			
		||||
            if (missions?.Tag != nodes[tier]) {
 | 
			
		||||
                logger.warn(
 | 
			
		||||
                    `Uh-oh, tier ${tier} bounty should've been on ${nodes[tier]} but you were just on ${missions?.Tag}`
 | 
			
		||||
                );
 | 
			
		||||
            }
 | 
			
		||||
            const tomorrowAt0Utc = config.noKimCooldowns
 | 
			
		||||
                ? Date.now()
 | 
			
		||||
                : (Math.trunc(Date.now() / 86400_000) + 1) * 86400_000;
 | 
			
		||||
        if (syndicateTag == "HexSyndicate" && tier < 6) {
 | 
			
		||||
            const buddy = chemistryBuddies[chemistryBuddy];
 | 
			
		||||
            const dialogue = getDialogue(inventory, buddy);
 | 
			
		||||
            dialogue.Chemistry += chemistry;
 | 
			
		||||
            dialogue.BountyChemExpiry = new Date(tomorrowAt0Utc);
 | 
			
		||||
            if (Date.now() >= dialogue.BountyChemExpiry.getTime()) {
 | 
			
		||||
                logger.debug(`Giving 20 chemistry for ${buddy}`);
 | 
			
		||||
                const tomorrowAt0Utc = config.noKimCooldowns
 | 
			
		||||
                    ? Date.now()
 | 
			
		||||
                    : (Math.trunc(Date.now() / 86400_000) + 1) * 86400_000;
 | 
			
		||||
                dialogue.Chemistry += 20;
 | 
			
		||||
                dialogue.BountyChemExpiry = new Date(tomorrowAt0Utc);
 | 
			
		||||
            } else {
 | 
			
		||||
                logger.debug(`Already got today's chemistry for ${buddy}`);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        if (isSteelPath) {
 | 
			
		||||
            await addItem(inventory, "/Lotus/Types/Items/MiscItems/SteelEssence", 1);
 | 
			
		||||
@ -1322,48 +1387,61 @@ export const addMissionRewards = async (
 | 
			
		||||
    };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
//creditBonus is not entirely accurate.
 | 
			
		||||
//TODO: consider ActiveBoosters
 | 
			
		||||
export const addCredits = (
 | 
			
		||||
export const addCredits = async (
 | 
			
		||||
    account: TAccountDocument,
 | 
			
		||||
    inventory: TInventoryDatabaseDocument,
 | 
			
		||||
    {
 | 
			
		||||
        missionDropCredits,
 | 
			
		||||
        missionCompletionCredits,
 | 
			
		||||
        rngRewardCredits
 | 
			
		||||
    }: { missionDropCredits: number; missionCompletionCredits: number; rngRewardCredits: number }
 | 
			
		||||
): IMissionCredits => {
 | 
			
		||||
    const hasDailyCreditBonus = true;
 | 
			
		||||
    const totalCredits = missionDropCredits + missionCompletionCredits + rngRewardCredits;
 | 
			
		||||
 | 
			
		||||
): Promise<IMissionCredits> => {
 | 
			
		||||
    const finalCredits: IMissionCredits = {
 | 
			
		||||
        MissionCredits: [missionDropCredits, missionDropCredits],
 | 
			
		||||
        CreditBonus: [missionCompletionCredits, missionCompletionCredits],
 | 
			
		||||
        TotalCredits: [totalCredits, totalCredits]
 | 
			
		||||
        CreditsBonus: [missionCompletionCredits, missionCompletionCredits],
 | 
			
		||||
        TotalCredits: [0, 0]
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    if (hasDailyCreditBonus) {
 | 
			
		||||
    const today = Math.trunc(Date.now() / 86400000) * 86400;
 | 
			
		||||
    if (account.DailyFirstWinDate != today) {
 | 
			
		||||
        account.DailyFirstWinDate = today;
 | 
			
		||||
        await account.save();
 | 
			
		||||
 | 
			
		||||
        logger.debug(`daily first win, doubling missionCompletionCredits (${missionCompletionCredits})`);
 | 
			
		||||
 | 
			
		||||
        finalCredits.DailyMissionBonus = true;
 | 
			
		||||
        inventory.RegularCredits += missionCompletionCredits;
 | 
			
		||||
        finalCredits.CreditBonus[1] *= 2;
 | 
			
		||||
        finalCredits.MissionCredits[1] *= 2;
 | 
			
		||||
        finalCredits.TotalCredits[1] *= 2;
 | 
			
		||||
        finalCredits.CreditsBonus[1] *= 2;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!hasDailyCreditBonus) {
 | 
			
		||||
        return finalCredits;
 | 
			
		||||
    const totalCredits = finalCredits.MissionCredits[1] + finalCredits.CreditsBonus[1] + rngRewardCredits;
 | 
			
		||||
    finalCredits.TotalCredits = [totalCredits, totalCredits];
 | 
			
		||||
 | 
			
		||||
    if (config.worldState?.creditBoost) {
 | 
			
		||||
        inventory.RegularCredits += finalCredits.TotalCredits[1];
 | 
			
		||||
        finalCredits.TotalCredits[1] += finalCredits.TotalCredits[1];
 | 
			
		||||
    }
 | 
			
		||||
    return { ...finalCredits, DailyMissionBonus: true };
 | 
			
		||||
    const now = Math.trunc(Date.now() / 1000); // TOVERIFY: Should we maybe subtract mission time as to apply credit boosters that expired during mission?
 | 
			
		||||
    if ((inventory.Boosters.find(x => x.ItemType == "/Lotus/Types/Boosters/CreditBooster")?.ExpiryDate ?? 0) > now) {
 | 
			
		||||
        inventory.RegularCredits += finalCredits.TotalCredits[1];
 | 
			
		||||
        finalCredits.TotalCredits[1] += finalCredits.TotalCredits[1];
 | 
			
		||||
    }
 | 
			
		||||
    if ((inventory.Boosters.find(x => x.ItemType == "/Lotus/Types/Boosters/CreditBlessing")?.ExpiryDate ?? 0) > now) {
 | 
			
		||||
        inventory.RegularCredits += finalCredits.TotalCredits[1];
 | 
			
		||||
        finalCredits.TotalCredits[1] += finalCredits.TotalCredits[1];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return finalCredits;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const addFixedLevelRewards = (
 | 
			
		||||
    rewards: IMissionRewardExternal,
 | 
			
		||||
    inventory: TInventoryDatabaseDocument,
 | 
			
		||||
    MissionRewards: IMissionReward[],
 | 
			
		||||
    rewardInfo?: IRewardInfo
 | 
			
		||||
): number => {
 | 
			
		||||
    let missionBonusCredits = 0;
 | 
			
		||||
    if (rewards.credits) {
 | 
			
		||||
        missionBonusCredits += rewards.credits;
 | 
			
		||||
        inventory.RegularCredits += rewards.credits;
 | 
			
		||||
    }
 | 
			
		||||
    if (rewards.items) {
 | 
			
		||||
        for (const item of rewards.items) {
 | 
			
		||||
@ -1376,7 +1454,7 @@ export const addFixedLevelRewards = (
 | 
			
		||||
    if (rewards.countedItems) {
 | 
			
		||||
        for (const item of rewards.countedItems) {
 | 
			
		||||
            MissionRewards.push({
 | 
			
		||||
                StoreItem: `/Lotus/StoreItems${item.ItemType.substring("Lotus/".length)}`,
 | 
			
		||||
                StoreItem: toStoreItem(item.ItemType),
 | 
			
		||||
                ItemCount: item.ItemCount
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
@ -1537,6 +1615,27 @@ function getRandomMissionDrops(
 | 
			
		||||
                    ? "/Lotus/Types/Game/MissionDecks/DuviriEncounterRewards/DuviriKullervoSteelPathRNGRewards"
 | 
			
		||||
                    : "/Lotus/Types/Game/MissionDecks/DuviriEncounterRewards/DuviriKullervoNormalRNGRewards"
 | 
			
		||||
            ];
 | 
			
		||||
        } else if (RewardInfo.T == 17) {
 | 
			
		||||
            if (mission?.Tier == 1) {
 | 
			
		||||
                logger.warn(`non-steel path duviri murmur tier used on steel path?!`);
 | 
			
		||||
            }
 | 
			
		||||
            /*if (operation eight claw is active) {
 | 
			
		||||
                drops.push({
 | 
			
		||||
                    StoreItem: "/Lotus/StoreItems/Types/Gameplay/DuviriMITW/Resources/DuviriMurmurItemEvent",
 | 
			
		||||
                    ItemCount: 10
 | 
			
		||||
                });
 | 
			
		||||
            }*/
 | 
			
		||||
            rewardManifests = ["/Lotus/Types/Game/MissionDecks/DuviriEncounterRewards/DuviriMurmurFinalChestRewards"];
 | 
			
		||||
        } else if (RewardInfo.T == 19) {
 | 
			
		||||
            /*if (operation eight claw is active) {
 | 
			
		||||
                drops.push({
 | 
			
		||||
                    StoreItem: "/Lotus/StoreItems/Types/Gameplay/DuviriMITW/Resources/DuviriMurmurItemEvent",
 | 
			
		||||
                    ItemCount: 15
 | 
			
		||||
                });
 | 
			
		||||
            }*/
 | 
			
		||||
            rewardManifests = [
 | 
			
		||||
                "/Lotus/Types/Game/MissionDecks/DuviriEncounterRewards/DuviriMurmurFinalSteelChestRewards"
 | 
			
		||||
            ];
 | 
			
		||||
        } else if (RewardInfo.T == 70) {
 | 
			
		||||
            // Orowyrm chest, gives 10 Pathos Clamps, or 15 on Steel Path.
 | 
			
		||||
            drops.push({
 | 
			
		||||
@ -1552,7 +1651,7 @@ function getRandomMissionDrops(
 | 
			
		||||
        if (RewardInfo.jobId) {
 | 
			
		||||
            if (RewardInfo.JobStage! >= 0) {
 | 
			
		||||
                // eslint-disable-next-line @typescript-eslint/no-unused-vars
 | 
			
		||||
                const [jobType, unkIndex, hubNode, syndicateMissionId, locationTag] = RewardInfo.jobId.split("_");
 | 
			
		||||
                const [jobType, unkIndex, hubNode, syndicateMissionId] = RewardInfo.jobId.split("_");
 | 
			
		||||
                let isEndlessJob = false;
 | 
			
		||||
                if (syndicateMissionId) {
 | 
			
		||||
                    const syndicateMissions: ISyndicateMissionInfo[] = [];
 | 
			
		||||
@ -1564,19 +1663,30 @@ function getRandomMissionDrops(
 | 
			
		||||
                        let job = syndicateEntry.Jobs[RewardInfo.JobTier!];
 | 
			
		||||
 | 
			
		||||
                        if (syndicateEntry.Tag === "EntratiSyndicate") {
 | 
			
		||||
                            const vault = syndicateEntry.Jobs.find(j => j.locationTag === locationTag);
 | 
			
		||||
                            if (vault && locationTag) job = vault;
 | 
			
		||||
                            // if (
 | 
			
		||||
                            //     [
 | 
			
		||||
                            //         "DeimosRuinsExterminateBounty",
 | 
			
		||||
                            //         "DeimosRuinsEscortBounty",
 | 
			
		||||
                            //         "DeimosRuinsMistBounty",
 | 
			
		||||
                            //         "DeimosRuinsPurifyBounty",
 | 
			
		||||
                            //         "DeimosRuinsSacBounty"
 | 
			
		||||
                            //     ].some(ending => jobType.endsWith(ending))
 | 
			
		||||
                            // ) {
 | 
			
		||||
                            //     job.rewards = "TODO"; // Droptable for Arcana Isolation Vault
 | 
			
		||||
                            // }
 | 
			
		||||
                            if (
 | 
			
		||||
                                [
 | 
			
		||||
                                    "DeimosRuinsExterminateBounty",
 | 
			
		||||
                                    "DeimosRuinsEscortBounty",
 | 
			
		||||
                                    "DeimosRuinsMistBounty",
 | 
			
		||||
                                    "DeimosRuinsPurifyBounty",
 | 
			
		||||
                                    "DeimosRuinsSacBounty",
 | 
			
		||||
                                    "VaultBounty"
 | 
			
		||||
                                ].some(ending => jobType.endsWith(ending))
 | 
			
		||||
                            ) {
 | 
			
		||||
                                const vault = syndicateEntry.Jobs.find(
 | 
			
		||||
                                    j => j.locationTag === RewardInfo.jobId!.split("_").at(-1)
 | 
			
		||||
                                );
 | 
			
		||||
                                if (vault) {
 | 
			
		||||
                                    job = vault;
 | 
			
		||||
                                    if (jobType.endsWith("VaultBounty")) {
 | 
			
		||||
                                        job.rewards = job.rewards.replace(
 | 
			
		||||
                                            "/Lotus/Types/Game/MissionDecks/",
 | 
			
		||||
                                            "/Supplementals/"
 | 
			
		||||
                                        );
 | 
			
		||||
                                        job.xpAmounts = [job.xpAmounts.reduce((partialSum, a) => partialSum + a, 0)];
 | 
			
		||||
                                    }
 | 
			
		||||
                                }
 | 
			
		||||
                            }
 | 
			
		||||
                            if (
 | 
			
		||||
                                [
 | 
			
		||||
                                    "DeimosEndlessAreaDefenseBounty",
 | 
			
		||||
@ -1635,20 +1745,28 @@ function getRandomMissionDrops(
 | 
			
		||||
                        }
 | 
			
		||||
                        rewardManifests = [job.rewards];
 | 
			
		||||
                        if (job.xpAmounts.length > 1) {
 | 
			
		||||
                            rotations = [RewardInfo.JobStage! % (job.xpAmounts.length - 1)];
 | 
			
		||||
                            const curentStage = RewardInfo.JobStage! + 1;
 | 
			
		||||
                            const totalStage = job.xpAmounts.length;
 | 
			
		||||
                            let tableIndex = 1; // Stage 2, Stage 3 of 4, and Stage 3 of 5
 | 
			
		||||
 | 
			
		||||
                            if (curentStage == 1) {
 | 
			
		||||
                                tableIndex = 0;
 | 
			
		||||
                            } else if (curentStage == totalStage) {
 | 
			
		||||
                                tableIndex = 3;
 | 
			
		||||
                            } else if (totalStage == 5 && curentStage == 4) {
 | 
			
		||||
                                tableIndex = 2;
 | 
			
		||||
                            }
 | 
			
		||||
 | 
			
		||||
                            rotations = [tableIndex];
 | 
			
		||||
                        } else {
 | 
			
		||||
                            rotations = [0];
 | 
			
		||||
                        }
 | 
			
		||||
                        if (
 | 
			
		||||
                            RewardInfo.Q &&
 | 
			
		||||
                            (RewardInfo.JobStage === job.xpAmounts.length - 1 || job.isVault) &&
 | 
			
		||||
                            (RewardInfo.JobStage === job.xpAmounts.length - 1 || jobType.endsWith("VaultBounty")) &&
 | 
			
		||||
                            !isEndlessJob
 | 
			
		||||
                        ) {
 | 
			
		||||
                            // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
 | 
			
		||||
                            if (ExportRewards[job.rewards]) {
 | 
			
		||||
                                rewardManifests.push(job.rewards);
 | 
			
		||||
                                rotations.push(ExportRewards[job.rewards].length - 1);
 | 
			
		||||
                            }
 | 
			
		||||
                            rotations.push(ExportRewards[job.rewards].length - 1);
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
@ -1813,6 +1931,23 @@ function getRandomMissionDrops(
 | 
			
		||||
            drops.push({ StoreItem: drop.type, ItemCount: drop.itemCount });
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (config.missionsCanGiveAllRelics) {
 | 
			
		||||
        for (const drop of drops) {
 | 
			
		||||
            const itemType = fromStoreItem(drop.StoreItem);
 | 
			
		||||
            if (itemType in ExportRelics) {
 | 
			
		||||
                const relic = ExportRelics[itemType];
 | 
			
		||||
                const replacement = getRandomElement(
 | 
			
		||||
                    Object.entries(ExportRelics).filter(
 | 
			
		||||
                        arr => arr[1].era == relic.era && arr[1].quality == relic.quality
 | 
			
		||||
                    )
 | 
			
		||||
                )!;
 | 
			
		||||
                logger.debug(`replacing ${relic.era} ${relic.category} with ${replacement[1].category}`);
 | 
			
		||||
                drop.StoreItem = toStoreItem(replacement[0]);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return drops;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1865,7 +2000,16 @@ const libraryPersonalTargetToAvatar: Record<string, string> = {
 | 
			
		||||
        "/Lotus/Types/Enemies/Corpus/Spaceman/AIWeek/NullifySpacemanAvatar"
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const node_excluded_buddies: Record<string, string> = {
 | 
			
		||||
const chemistryBuddies: readonly string[] = [
 | 
			
		||||
    "/Lotus/Types/Gameplay/1999Wf/Dialogue/JabirDialogue_rom.dialogue",
 | 
			
		||||
    "/Lotus/Types/Gameplay/1999Wf/Dialogue/AoiDialogue_rom.dialogue",
 | 
			
		||||
    "/Lotus/Types/Gameplay/1999Wf/Dialogue/ArthurDialogue_rom.dialogue",
 | 
			
		||||
    "/Lotus/Types/Gameplay/1999Wf/Dialogue/EleanorDialogue_rom.dialogue",
 | 
			
		||||
    "/Lotus/Types/Gameplay/1999Wf/Dialogue/LettieDialogue_rom.dialogue",
 | 
			
		||||
    "/Lotus/Types/Gameplay/1999Wf/Dialogue/QuincyDialogue_rom.dialogue"
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
/*const node_excluded_buddies: Record<string, string> = {
 | 
			
		||||
    SolNode856: "/Lotus/Types/Gameplay/1999Wf/Dialogue/ArthurDialogue_rom.dialogue",
 | 
			
		||||
    SolNode852: "/Lotus/Types/Gameplay/1999Wf/Dialogue/LettieDialogue_rom.dialogue",
 | 
			
		||||
    SolNode851: "/Lotus/Types/Gameplay/1999Wf/Dialogue/JabirDialogue_rom.dialogue",
 | 
			
		||||
@ -1915,4 +2059,25 @@ const getHexBounties = (seed: number): { nodes: string[]; buddies: string[] } =>
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    return { nodes, buddies };
 | 
			
		||||
};*/
 | 
			
		||||
 | 
			
		||||
const goalMessagesByKey: Record<string, { sndr: string; msg: string; sub: string; icon: string }> = {
 | 
			
		||||
    "/Lotus/Types/Keys/GalleonRobberyAlert": {
 | 
			
		||||
        sndr: "/Lotus/Language/Bosses/BossCouncilorVayHek",
 | 
			
		||||
        msg: "/Lotus/Language/Messages/GalleonRobbery2025RewardMsgA",
 | 
			
		||||
        sub: "/Lotus/Language/Messages/GalleonRobbery2025MissionTitleA",
 | 
			
		||||
        icon: "/Lotus/Interface/Icons/Npcs/VayHekPortrait.png"
 | 
			
		||||
    },
 | 
			
		||||
    "/Lotus/Types/Keys/GalleonRobberyAlertB": {
 | 
			
		||||
        sndr: "/Lotus/Language/Bosses/BossCouncilorVayHek",
 | 
			
		||||
        msg: "/Lotus/Language/Messages/GalleonRobbery2025RewardMsgB",
 | 
			
		||||
        sub: "/Lotus/Language/Messages/GalleonRobbery2025MissionTitleB",
 | 
			
		||||
        icon: "/Lotus/Interface/Icons/Npcs/VayHekPortrait.png"
 | 
			
		||||
    },
 | 
			
		||||
    "/Lotus/Types/Keys/GalleonRobberyAlertC": {
 | 
			
		||||
        sndr: "/Lotus/Language/Bosses/BossCouncilorVayHek",
 | 
			
		||||
        msg: "/Lotus/Language/Messages/GalleonRobbery2025RewardMsgC",
 | 
			
		||||
        sub: "/Lotus/Language/Messages/GalleonRobbery2025MissionTitleC",
 | 
			
		||||
        icon: "/Lotus/Interface/Icons/Npcs/VayHekPortrait.png"
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -8,12 +8,20 @@ import {
 | 
			
		||||
    updateCurrency,
 | 
			
		||||
    updateSlots
 | 
			
		||||
} from "@/src/services/inventoryService";
 | 
			
		||||
import { getRandomWeightedRewardUc } from "@/src/services/rngService";
 | 
			
		||||
import { getRandomReward, getRandomWeightedRewardUc } from "@/src/services/rngService";
 | 
			
		||||
import { applyStandingToVendorManifest, getVendorManifestByOid } from "@/src/services/serversideVendorsService";
 | 
			
		||||
import { IMiscItem } from "@/src/types/inventoryTypes/inventoryTypes";
 | 
			
		||||
import { IPurchaseRequest, IPurchaseResponse, SlotPurchase, IInventoryChanges } from "@/src/types/purchaseTypes";
 | 
			
		||||
import {
 | 
			
		||||
    IPurchaseRequest,
 | 
			
		||||
    IPurchaseResponse,
 | 
			
		||||
    SlotPurchase,
 | 
			
		||||
    IInventoryChanges,
 | 
			
		||||
    PurchaseSource,
 | 
			
		||||
    IPurchaseParams
 | 
			
		||||
} from "@/src/types/purchaseTypes";
 | 
			
		||||
import { logger } from "@/src/utils/logger";
 | 
			
		||||
import worldState from "@/static/fixed_responses/worldState/worldState.json";
 | 
			
		||||
import { getWorldState } from "./worldStateService";
 | 
			
		||||
import staticWorldState from "@/static/fixed_responses/worldState/worldState.json";
 | 
			
		||||
import {
 | 
			
		||||
    ExportBoosterPacks,
 | 
			
		||||
    ExportBoosters,
 | 
			
		||||
@ -28,6 +36,8 @@ import {
 | 
			
		||||
import { config } from "./configService";
 | 
			
		||||
import { TInventoryDatabaseDocument } from "../models/inventoryModels/inventoryModel";
 | 
			
		||||
import { fromStoreItem, toStoreItem } from "./itemDataService";
 | 
			
		||||
import { DailyDeal } from "../models/worldStateModel";
 | 
			
		||||
import { fromMongoDate, toMongoDate } from "../helpers/inventoryHelpers";
 | 
			
		||||
 | 
			
		||||
export const getStoreItemCategory = (storeItem: string): string => {
 | 
			
		||||
    const storeItemString = getSubstringFromKeyword(storeItem, "StoreItems/");
 | 
			
		||||
@ -44,6 +54,58 @@ export const getStoreItemTypesCategory = (typesItem: string): string => {
 | 
			
		||||
    return typeElements[1];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const tallyVendorPurchase = (
 | 
			
		||||
    inventory: TInventoryDatabaseDocument,
 | 
			
		||||
    inventoryChanges: IInventoryChanges,
 | 
			
		||||
    VendorType: string,
 | 
			
		||||
    ItemId: string,
 | 
			
		||||
    numPurchased: number,
 | 
			
		||||
    Expiry: Date
 | 
			
		||||
): void => {
 | 
			
		||||
    if (!config.noVendorPurchaseLimits) {
 | 
			
		||||
        inventory.RecentVendorPurchases ??= [];
 | 
			
		||||
        let vendorPurchases = inventory.RecentVendorPurchases.find(x => x.VendorType == VendorType);
 | 
			
		||||
        if (!vendorPurchases) {
 | 
			
		||||
            vendorPurchases =
 | 
			
		||||
                inventory.RecentVendorPurchases[
 | 
			
		||||
                    inventory.RecentVendorPurchases.push({
 | 
			
		||||
                        VendorType: VendorType,
 | 
			
		||||
                        PurchaseHistory: []
 | 
			
		||||
                    }) - 1
 | 
			
		||||
                ];
 | 
			
		||||
        }
 | 
			
		||||
        let historyEntry = vendorPurchases.PurchaseHistory.find(x => x.ItemId == ItemId);
 | 
			
		||||
        if (historyEntry) {
 | 
			
		||||
            if (Date.now() >= historyEntry.Expiry.getTime()) {
 | 
			
		||||
                historyEntry.NumPurchased = numPurchased;
 | 
			
		||||
                historyEntry.Expiry = Expiry;
 | 
			
		||||
            } else {
 | 
			
		||||
                historyEntry.NumPurchased += numPurchased;
 | 
			
		||||
            }
 | 
			
		||||
        } else {
 | 
			
		||||
            historyEntry =
 | 
			
		||||
                vendorPurchases.PurchaseHistory[
 | 
			
		||||
                    vendorPurchases.PurchaseHistory.push({
 | 
			
		||||
                        ItemId: ItemId,
 | 
			
		||||
                        NumPurchased: numPurchased,
 | 
			
		||||
                        Expiry: Expiry
 | 
			
		||||
                    }) - 1
 | 
			
		||||
                ];
 | 
			
		||||
        }
 | 
			
		||||
        inventoryChanges.NewVendorPurchase = {
 | 
			
		||||
            VendorType: VendorType,
 | 
			
		||||
            PurchaseHistory: [
 | 
			
		||||
                {
 | 
			
		||||
                    ItemId: ItemId,
 | 
			
		||||
                    NumPurchased: historyEntry.NumPurchased,
 | 
			
		||||
                    Expiry: toMongoDate(Expiry)
 | 
			
		||||
                }
 | 
			
		||||
            ]
 | 
			
		||||
        };
 | 
			
		||||
        inventoryChanges.RecentVendorPurchases = inventoryChanges.NewVendorPurchase;
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const handlePurchase = async (
 | 
			
		||||
    purchaseRequest: IPurchaseRequest,
 | 
			
		||||
    inventory: TInventoryDatabaseDocument
 | 
			
		||||
@ -52,7 +114,7 @@ export const handlePurchase = async (
 | 
			
		||||
 | 
			
		||||
    const prePurchaseInventoryChanges: IInventoryChanges = {};
 | 
			
		||||
    let seed: bigint | undefined;
 | 
			
		||||
    if (purchaseRequest.PurchaseParams.Source == 7) {
 | 
			
		||||
    if (purchaseRequest.PurchaseParams.Source == PurchaseSource.Vendor) {
 | 
			
		||||
        let manifest = getVendorManifestByOid(purchaseRequest.PurchaseParams.SourceId!);
 | 
			
		||||
        if (manifest) {
 | 
			
		||||
            manifest = applyStandingToVendorManifest(inventory, manifest);
 | 
			
		||||
@ -67,43 +129,30 @@ export const handlePurchase = async (
 | 
			
		||||
            if (!offer) {
 | 
			
		||||
                throw new Error(`unknown vendor offer: ${ItemId ? ItemId : purchaseRequest.PurchaseParams.StoreItem}`);
 | 
			
		||||
            }
 | 
			
		||||
            if (offer.RegularPrice) {
 | 
			
		||||
                combineInventoryChanges(
 | 
			
		||||
                    prePurchaseInventoryChanges,
 | 
			
		||||
                    updateCurrency(inventory, offer.RegularPrice[0], false)
 | 
			
		||||
                );
 | 
			
		||||
            if (!config.dontSubtractPurchaseCreditCost) {
 | 
			
		||||
                if (offer.RegularPrice) {
 | 
			
		||||
                    updateCurrency(inventory, offer.RegularPrice[0], false, prePurchaseInventoryChanges);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            if (offer.PremiumPrice) {
 | 
			
		||||
                combineInventoryChanges(
 | 
			
		||||
                    prePurchaseInventoryChanges,
 | 
			
		||||
                    updateCurrency(inventory, offer.PremiumPrice[0], true)
 | 
			
		||||
                );
 | 
			
		||||
            if (!config.dontSubtractPurchasePlatinumCost) {
 | 
			
		||||
                if (offer.PremiumPrice) {
 | 
			
		||||
                    updateCurrency(inventory, offer.PremiumPrice[0], true, prePurchaseInventoryChanges);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            if (offer.ItemPrices) {
 | 
			
		||||
                handleItemPrices(
 | 
			
		||||
                    inventory,
 | 
			
		||||
                    offer.ItemPrices,
 | 
			
		||||
                    purchaseRequest.PurchaseParams.Quantity,
 | 
			
		||||
                    prePurchaseInventoryChanges
 | 
			
		||||
                );
 | 
			
		||||
            if (!config.dontSubtractPurchaseItemCost) {
 | 
			
		||||
                if (offer.ItemPrices) {
 | 
			
		||||
                    handleItemPrices(
 | 
			
		||||
                        inventory,
 | 
			
		||||
                        offer.ItemPrices,
 | 
			
		||||
                        purchaseRequest.PurchaseParams.Quantity,
 | 
			
		||||
                        prePurchaseInventoryChanges
 | 
			
		||||
                    );
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            if (offer.LocTagRandSeed !== undefined) {
 | 
			
		||||
                seed = BigInt(offer.LocTagRandSeed);
 | 
			
		||||
            }
 | 
			
		||||
            if (!config.noVendorPurchaseLimits && ItemId) {
 | 
			
		||||
                inventory.RecentVendorPurchases ??= [];
 | 
			
		||||
                let vendorPurchases = inventory.RecentVendorPurchases.find(
 | 
			
		||||
                    x => x.VendorType == manifest!.VendorInfo.TypeName
 | 
			
		||||
                );
 | 
			
		||||
                if (!vendorPurchases) {
 | 
			
		||||
                    vendorPurchases =
 | 
			
		||||
                        inventory.RecentVendorPurchases[
 | 
			
		||||
                            inventory.RecentVendorPurchases.push({
 | 
			
		||||
                                VendorType: manifest.VendorInfo.TypeName,
 | 
			
		||||
                                PurchaseHistory: []
 | 
			
		||||
                            }) - 1
 | 
			
		||||
                        ];
 | 
			
		||||
                }
 | 
			
		||||
            if (ItemId) {
 | 
			
		||||
                let expiry = parseInt(offer.Expiry.$date.$numberLong);
 | 
			
		||||
                if (purchaseRequest.PurchaseParams.IsWeekly) {
 | 
			
		||||
                    const EPOCH = 1734307200 * 1000; // Monday
 | 
			
		||||
@ -111,34 +160,14 @@ export const handlePurchase = async (
 | 
			
		||||
                    const weekStart = EPOCH + week * 604800000;
 | 
			
		||||
                    expiry = weekStart + 604800000;
 | 
			
		||||
                }
 | 
			
		||||
                const historyEntry = vendorPurchases.PurchaseHistory.find(x => x.ItemId == ItemId);
 | 
			
		||||
                let numPurchased = purchaseRequest.PurchaseParams.Quantity;
 | 
			
		||||
                if (historyEntry) {
 | 
			
		||||
                    if (Date.now() >= historyEntry.Expiry.getTime()) {
 | 
			
		||||
                        historyEntry.NumPurchased = numPurchased;
 | 
			
		||||
                        historyEntry.Expiry = new Date(expiry);
 | 
			
		||||
                    } else {
 | 
			
		||||
                        numPurchased += historyEntry.NumPurchased;
 | 
			
		||||
                        historyEntry.NumPurchased += purchaseRequest.PurchaseParams.Quantity;
 | 
			
		||||
                    }
 | 
			
		||||
                } else {
 | 
			
		||||
                    vendorPurchases.PurchaseHistory.push({
 | 
			
		||||
                        ItemId: ItemId,
 | 
			
		||||
                        NumPurchased: purchaseRequest.PurchaseParams.Quantity,
 | 
			
		||||
                        Expiry: new Date(expiry)
 | 
			
		||||
                    });
 | 
			
		||||
                }
 | 
			
		||||
                prePurchaseInventoryChanges.NewVendorPurchase = {
 | 
			
		||||
                    VendorType: manifest.VendorInfo.TypeName,
 | 
			
		||||
                    PurchaseHistory: [
 | 
			
		||||
                        {
 | 
			
		||||
                            ItemId: ItemId,
 | 
			
		||||
                            NumPurchased: numPurchased,
 | 
			
		||||
                            Expiry: { $date: { $numberLong: expiry.toString() } }
 | 
			
		||||
                        }
 | 
			
		||||
                    ]
 | 
			
		||||
                };
 | 
			
		||||
                prePurchaseInventoryChanges.RecentVendorPurchases = prePurchaseInventoryChanges.NewVendorPurchase;
 | 
			
		||||
                tallyVendorPurchase(
 | 
			
		||||
                    inventory,
 | 
			
		||||
                    prePurchaseInventoryChanges,
 | 
			
		||||
                    manifest.VendorInfo.TypeName,
 | 
			
		||||
                    ItemId,
 | 
			
		||||
                    purchaseRequest.PurchaseParams.Quantity,
 | 
			
		||||
                    new Date(expiry)
 | 
			
		||||
                );
 | 
			
		||||
            }
 | 
			
		||||
            purchaseRequest.PurchaseParams.Quantity *= offer.QuantityMultiplier;
 | 
			
		||||
        } else {
 | 
			
		||||
@ -160,18 +189,16 @@ export const handlePurchase = async (
 | 
			
		||||
    );
 | 
			
		||||
    combineInventoryChanges(purchaseResponse.InventoryChanges, prePurchaseInventoryChanges);
 | 
			
		||||
 | 
			
		||||
    const currencyChanges = updateCurrency(
 | 
			
		||||
    updateCurrency(
 | 
			
		||||
        inventory,
 | 
			
		||||
        purchaseRequest.PurchaseParams.ExpectedPrice,
 | 
			
		||||
        purchaseRequest.PurchaseParams.UsePremium
 | 
			
		||||
        purchaseRequest.PurchaseParams.UsePremium,
 | 
			
		||||
        prePurchaseInventoryChanges
 | 
			
		||||
    );
 | 
			
		||||
    purchaseResponse.InventoryChanges = {
 | 
			
		||||
        ...currencyChanges,
 | 
			
		||||
        ...purchaseResponse.InventoryChanges
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    switch (purchaseRequest.PurchaseParams.Source) {
 | 
			
		||||
        case 1: {
 | 
			
		||||
        case PurchaseSource.VoidTrader: {
 | 
			
		||||
            const worldState = getWorldState();
 | 
			
		||||
            if (purchaseRequest.PurchaseParams.SourceId! != worldState.VoidTraders[0]._id.$oid) {
 | 
			
		||||
                throw new Error("invalid request source");
 | 
			
		||||
            }
 | 
			
		||||
@ -179,25 +206,37 @@ export const handlePurchase = async (
 | 
			
		||||
                x => x.ItemType == purchaseRequest.PurchaseParams.StoreItem
 | 
			
		||||
            );
 | 
			
		||||
            if (offer) {
 | 
			
		||||
                combineInventoryChanges(
 | 
			
		||||
                    purchaseResponse.InventoryChanges,
 | 
			
		||||
                    updateCurrency(inventory, offer.RegularPrice, false)
 | 
			
		||||
                );
 | 
			
		||||
                if (!config.dontSubtractPurchaseCreditCost) {
 | 
			
		||||
                    updateCurrency(inventory, offer.RegularPrice, false, purchaseResponse.InventoryChanges);
 | 
			
		||||
                }
 | 
			
		||||
                if (purchaseRequest.PurchaseParams.ExpectedPrice) {
 | 
			
		||||
                    throw new Error(`vendor purchase should not have an expected price`);
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                const invItem: IMiscItem = {
 | 
			
		||||
                    ItemType: "/Lotus/Types/Items/MiscItems/PrimeBucks",
 | 
			
		||||
                    ItemCount: offer.PrimePrice * purchaseRequest.PurchaseParams.Quantity * -1
 | 
			
		||||
                };
 | 
			
		||||
                addMiscItems(inventory, [invItem]);
 | 
			
		||||
                purchaseResponse.InventoryChanges.MiscItems ??= [];
 | 
			
		||||
                purchaseResponse.InventoryChanges.MiscItems.push(invItem);
 | 
			
		||||
                if (offer.PrimePrice && !config.dontSubtractPurchaseItemCost) {
 | 
			
		||||
                    const invItem: IMiscItem = {
 | 
			
		||||
                        ItemType: "/Lotus/Types/Items/MiscItems/PrimeBucks",
 | 
			
		||||
                        ItemCount: offer.PrimePrice * purchaseRequest.PurchaseParams.Quantity * -1
 | 
			
		||||
                    };
 | 
			
		||||
                    addMiscItems(inventory, [invItem]);
 | 
			
		||||
                    purchaseResponse.InventoryChanges.MiscItems ??= [];
 | 
			
		||||
                    purchaseResponse.InventoryChanges.MiscItems.push(invItem);
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                if (offer.Limit) {
 | 
			
		||||
                    tallyVendorPurchase(
 | 
			
		||||
                        inventory,
 | 
			
		||||
                        purchaseResponse.InventoryChanges,
 | 
			
		||||
                        "VoidTrader",
 | 
			
		||||
                        offer.ItemType,
 | 
			
		||||
                        purchaseRequest.PurchaseParams.Quantity,
 | 
			
		||||
                        fromMongoDate(worldState.VoidTraders[0].Expiry)
 | 
			
		||||
                    );
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            break;
 | 
			
		||||
        }
 | 
			
		||||
        case 2:
 | 
			
		||||
        case PurchaseSource.SyndicateFavor:
 | 
			
		||||
            {
 | 
			
		||||
                const syndicateTag = purchaseRequest.PurchaseParams.SyndicateTag!;
 | 
			
		||||
                if (purchaseRequest.PurchaseParams.UseFreeFavor!) {
 | 
			
		||||
@ -211,7 +250,7 @@ export const handlePurchase = async (
 | 
			
		||||
                            Title: lastTitle
 | 
			
		||||
                        }
 | 
			
		||||
                    ];
 | 
			
		||||
                } else {
 | 
			
		||||
                } else if (!config.dontSubtractPurchaseStandingCost) {
 | 
			
		||||
                    const syndicate = ExportSyndicates[syndicateTag];
 | 
			
		||||
                    // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
 | 
			
		||||
                    if (syndicate) {
 | 
			
		||||
@ -234,24 +273,24 @@ export const handlePurchase = async (
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            break;
 | 
			
		||||
        case 7:
 | 
			
		||||
        case PurchaseSource.DailyDeal:
 | 
			
		||||
            if (purchaseRequest.PurchaseParams.ExpectedPrice) {
 | 
			
		||||
                throw new Error(`daily deal purchase should not have an expected price`);
 | 
			
		||||
            }
 | 
			
		||||
            await handleDailyDealPurchase(inventory, purchaseRequest.PurchaseParams, purchaseResponse);
 | 
			
		||||
            break;
 | 
			
		||||
        case PurchaseSource.Vendor:
 | 
			
		||||
            if (purchaseRequest.PurchaseParams.SourceId! in ExportVendors) {
 | 
			
		||||
                const vendor = ExportVendors[purchaseRequest.PurchaseParams.SourceId!];
 | 
			
		||||
                const offer = vendor.items.find(x => x.storeItem == purchaseRequest.PurchaseParams.StoreItem);
 | 
			
		||||
                if (offer) {
 | 
			
		||||
                    if (typeof offer.credits == "number") {
 | 
			
		||||
                        combineInventoryChanges(
 | 
			
		||||
                            purchaseResponse.InventoryChanges,
 | 
			
		||||
                            updateCurrency(inventory, offer.credits, false)
 | 
			
		||||
                        );
 | 
			
		||||
                    if (typeof offer.credits == "number" && !config.dontSubtractPurchaseCreditCost) {
 | 
			
		||||
                        updateCurrency(inventory, offer.credits, false, purchaseResponse.InventoryChanges);
 | 
			
		||||
                    }
 | 
			
		||||
                    if (typeof offer.platinum == "number") {
 | 
			
		||||
                        combineInventoryChanges(
 | 
			
		||||
                            purchaseResponse.InventoryChanges,
 | 
			
		||||
                            updateCurrency(inventory, offer.platinum, true)
 | 
			
		||||
                        );
 | 
			
		||||
                    if (typeof offer.platinum == "number" && !config.dontSubtractPurchasePlatinumCost) {
 | 
			
		||||
                        updateCurrency(inventory, offer.platinum, true, purchaseResponse.InventoryChanges);
 | 
			
		||||
                    }
 | 
			
		||||
                    if (offer.itemPrices) {
 | 
			
		||||
                    if (offer.itemPrices && !config.dontSubtractPurchaseItemCost) {
 | 
			
		||||
                        handleItemPrices(
 | 
			
		||||
                            inventory,
 | 
			
		||||
                            offer.itemPrices,
 | 
			
		||||
@ -265,28 +304,30 @@ export const handlePurchase = async (
 | 
			
		||||
                throw new Error(`vendor purchase should not have an expected price`);
 | 
			
		||||
            }
 | 
			
		||||
            break;
 | 
			
		||||
        case 18: {
 | 
			
		||||
            if (purchaseRequest.PurchaseParams.SourceId! != worldState.PrimeVaultTraders[0]._id.$oid) {
 | 
			
		||||
        case PurchaseSource.PrimeVaultTrader: {
 | 
			
		||||
            if (purchaseRequest.PurchaseParams.SourceId! != staticWorldState.PrimeVaultTraders[0]._id.$oid) {
 | 
			
		||||
                throw new Error("invalid request source");
 | 
			
		||||
            }
 | 
			
		||||
            const offer =
 | 
			
		||||
                worldState.PrimeVaultTraders[0].Manifest.find(
 | 
			
		||||
                staticWorldState.PrimeVaultTraders[0].Manifest.find(
 | 
			
		||||
                    x => x.ItemType == purchaseRequest.PurchaseParams.StoreItem
 | 
			
		||||
                ) ??
 | 
			
		||||
                worldState.PrimeVaultTraders[0].EvergreenManifest.find(
 | 
			
		||||
                staticWorldState.PrimeVaultTraders[0].EvergreenManifest.find(
 | 
			
		||||
                    x => x.ItemType == purchaseRequest.PurchaseParams.StoreItem
 | 
			
		||||
                );
 | 
			
		||||
            if (offer) {
 | 
			
		||||
                if (offer.RegularPrice) {
 | 
			
		||||
                    const invItem: IMiscItem = {
 | 
			
		||||
                        ItemType: "/Lotus/Types/Items/MiscItems/SchismKey",
 | 
			
		||||
                        ItemCount: offer.RegularPrice * purchaseRequest.PurchaseParams.Quantity * -1
 | 
			
		||||
                    };
 | 
			
		||||
                    if (!config.dontSubtractPurchaseItemCost) {
 | 
			
		||||
                        const invItem: IMiscItem = {
 | 
			
		||||
                            ItemType: "/Lotus/Types/Items/MiscItems/SchismKey",
 | 
			
		||||
                            ItemCount: offer.RegularPrice * purchaseRequest.PurchaseParams.Quantity * -1
 | 
			
		||||
                        };
 | 
			
		||||
 | 
			
		||||
                    addMiscItems(inventory, [invItem]);
 | 
			
		||||
                        addMiscItems(inventory, [invItem]);
 | 
			
		||||
 | 
			
		||||
                    purchaseResponse.InventoryChanges.MiscItems ??= [];
 | 
			
		||||
                    purchaseResponse.InventoryChanges.MiscItems.push(invItem);
 | 
			
		||||
                        purchaseResponse.InventoryChanges.MiscItems ??= [];
 | 
			
		||||
                        purchaseResponse.InventoryChanges.MiscItems.push(invItem);
 | 
			
		||||
                    }
 | 
			
		||||
                } else if (!config.infiniteRegalAya) {
 | 
			
		||||
                    inventory.PrimeTokens -= offer.PrimePrice! * purchaseRequest.PurchaseParams.Quantity;
 | 
			
		||||
 | 
			
		||||
@ -326,6 +367,25 @@ const handleItemPrices = (
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const handleDailyDealPurchase = async (
 | 
			
		||||
    inventory: TInventoryDatabaseDocument,
 | 
			
		||||
    purchaseParams: IPurchaseParams,
 | 
			
		||||
    purchaseResponse: IPurchaseResponse
 | 
			
		||||
): Promise<void> => {
 | 
			
		||||
    const dailyDeal = (await DailyDeal.findOne({ StoreItem: purchaseParams.StoreItem }))!;
 | 
			
		||||
    dailyDeal.AmountSold += 1;
 | 
			
		||||
    await dailyDeal.save();
 | 
			
		||||
 | 
			
		||||
    if (!config.dontSubtractPurchasePlatinumCost) {
 | 
			
		||||
        updateCurrency(inventory, dailyDeal.SalePrice, true, purchaseResponse.InventoryChanges);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!config.noVendorPurchaseLimits) {
 | 
			
		||||
        inventory.UsedDailyDeals.push(purchaseParams.StoreItem);
 | 
			
		||||
        purchaseResponse.DailyDealUsed = purchaseParams.StoreItem;
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const handleBundleAcqusition = async (
 | 
			
		||||
    storeItemName: string,
 | 
			
		||||
    inventory: TInventoryDatabaseDocument,
 | 
			
		||||
@ -369,18 +429,28 @@ export const handleStoreItemAcquisition = async (
 | 
			
		||||
    } else {
 | 
			
		||||
        const storeCategory = getStoreItemCategory(storeItemName);
 | 
			
		||||
        const internalName = fromStoreItem(storeItemName);
 | 
			
		||||
        logger.debug(`store category ${storeCategory}`);
 | 
			
		||||
        if (!ignorePurchaseQuantity) {
 | 
			
		||||
            if (internalName in ExportGear) {
 | 
			
		||||
                quantity *= ExportGear[internalName].purchaseQuantity || 1;
 | 
			
		||||
                logger.debug(`factored quantity is ${quantity}`);
 | 
			
		||||
            } else if (internalName in ExportResources) {
 | 
			
		||||
                quantity *= ExportResources[internalName].purchaseQuantity || 1;
 | 
			
		||||
                logger.debug(`factored quantity is ${quantity}`);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        logger.debug(`store category ${storeCategory}`);
 | 
			
		||||
        switch (storeCategory) {
 | 
			
		||||
            default: {
 | 
			
		||||
                purchaseResponse = {
 | 
			
		||||
                    InventoryChanges: await addItem(inventory, internalName, quantity, premiumPurchase, seed)
 | 
			
		||||
                    InventoryChanges: await addItem(
 | 
			
		||||
                        inventory,
 | 
			
		||||
                        internalName,
 | 
			
		||||
                        quantity,
 | 
			
		||||
                        premiumPurchase,
 | 
			
		||||
                        seed,
 | 
			
		||||
                        undefined,
 | 
			
		||||
                        true
 | 
			
		||||
                    )
 | 
			
		||||
                };
 | 
			
		||||
                break;
 | 
			
		||||
            }
 | 
			
		||||
@ -470,12 +540,57 @@ const handleBoosterPackPurchase = async (
 | 
			
		||||
            "attempt to roll over 100 booster packs in a single go. possible but unlikely to be desirable for the user or the server."
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
    const specialItemReward = pack.components.find(x => x.PityIncreaseRate);
 | 
			
		||||
    for (let i = 0; i != quantity; ++i) {
 | 
			
		||||
        const disallowedItems = new Set();
 | 
			
		||||
        for (let roll = 0; roll != pack.rarityWeightsPerRoll.length; ) {
 | 
			
		||||
            const weights = pack.rarityWeightsPerRoll[roll];
 | 
			
		||||
            const result = getRandomWeightedRewardUc(pack.components, weights);
 | 
			
		||||
            if (result) {
 | 
			
		||||
        if (specialItemReward) {
 | 
			
		||||
            {
 | 
			
		||||
                const normalComponents = [];
 | 
			
		||||
                for (const comp of pack.components) {
 | 
			
		||||
                    if (!comp.PityIncreaseRate) {
 | 
			
		||||
                        const { Probability, ...rest } = comp;
 | 
			
		||||
                        normalComponents.push({
 | 
			
		||||
                            ...rest,
 | 
			
		||||
                            probability: Probability!
 | 
			
		||||
                        });
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                const result = getRandomReward(normalComponents)!;
 | 
			
		||||
                logger.debug(`booster pack rolled`, result);
 | 
			
		||||
                purchaseResponse.BoosterPackItems += toStoreItem(result.Item) + ',{"lvl":0};';
 | 
			
		||||
                combineInventoryChanges(
 | 
			
		||||
                    purchaseResponse.InventoryChanges,
 | 
			
		||||
                    await addItem(inventory, result.Item, result.Amount)
 | 
			
		||||
                );
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (!inventory.WeaponSkins.some(x => x.ItemType == specialItemReward.Item)) {
 | 
			
		||||
                inventory.SpecialItemRewardAttenuation ??= [];
 | 
			
		||||
                let atten = inventory.SpecialItemRewardAttenuation.find(x => x.Tag == specialItemReward.Item);
 | 
			
		||||
                if (!atten) {
 | 
			
		||||
                    atten =
 | 
			
		||||
                        inventory.SpecialItemRewardAttenuation[
 | 
			
		||||
                            inventory.SpecialItemRewardAttenuation.push({
 | 
			
		||||
                                Tag: specialItemReward.Item,
 | 
			
		||||
                                Atten: specialItemReward.Probability!
 | 
			
		||||
                            }) - 1
 | 
			
		||||
                        ];
 | 
			
		||||
                }
 | 
			
		||||
                if (Math.random() < atten.Atten) {
 | 
			
		||||
                    purchaseResponse.BoosterPackItems += toStoreItem(specialItemReward.Item) + ',{"lvl":0};';
 | 
			
		||||
                    combineInventoryChanges(
 | 
			
		||||
                        purchaseResponse.InventoryChanges,
 | 
			
		||||
                        await addItem(inventory, specialItemReward.Item)
 | 
			
		||||
                    );
 | 
			
		||||
                    // TOVERIFY: Is the SpecialItemRewardAttenuation entry removed now?
 | 
			
		||||
                } else {
 | 
			
		||||
                    atten.Atten += specialItemReward.PityIncreaseRate!;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        } else {
 | 
			
		||||
            const disallowedItems = new Set();
 | 
			
		||||
            for (let roll = 0; roll != pack.rarityWeightsPerRoll.length; ) {
 | 
			
		||||
                const weights = pack.rarityWeightsPerRoll[roll];
 | 
			
		||||
                const result = getRandomWeightedRewardUc(pack.components, weights)!;
 | 
			
		||||
                logger.debug(`booster pack rolled`, result);
 | 
			
		||||
                if (disallowedItems.has(result.Item)) {
 | 
			
		||||
                    logger.debug(`oops, can't use that one; trying again`);
 | 
			
		||||
@ -485,9 +600,12 @@ const handleBoosterPackPurchase = async (
 | 
			
		||||
                    disallowedItems.add(result.Item);
 | 
			
		||||
                }
 | 
			
		||||
                purchaseResponse.BoosterPackItems += toStoreItem(result.Item) + ',{"lvl":0};';
 | 
			
		||||
                combineInventoryChanges(purchaseResponse.InventoryChanges, await addItem(inventory, result.Item, 1));
 | 
			
		||||
                combineInventoryChanges(
 | 
			
		||||
                    purchaseResponse.InventoryChanges,
 | 
			
		||||
                    await addItem(inventory, result.Item, result.Amount)
 | 
			
		||||
                );
 | 
			
		||||
                ++roll;
 | 
			
		||||
            }
 | 
			
		||||
            ++roll;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    return purchaseResponse;
 | 
			
		||||
@ -522,7 +640,9 @@ const handleTypesPurchase = async (
 | 
			
		||||
    logger.debug(`type category ${typeCategory}`);
 | 
			
		||||
    switch (typeCategory) {
 | 
			
		||||
        default:
 | 
			
		||||
            return { InventoryChanges: await addItem(inventory, typesName, quantity, premiumPurchase, seed) };
 | 
			
		||||
            return {
 | 
			
		||||
                InventoryChanges: await addItem(inventory, typesName, quantity, premiumPurchase, seed, undefined, true)
 | 
			
		||||
            };
 | 
			
		||||
        case "BoosterPacks":
 | 
			
		||||
            return handleBoosterPackPurchase(typesName, inventory, quantity);
 | 
			
		||||
        case "SlotItems":
 | 
			
		||||
 | 
			
		||||
@ -331,7 +331,7 @@ export const giveKeyChainMissionReward = async (
 | 
			
		||||
            const fixedLevelRewards = getLevelKeyRewards(missionName);
 | 
			
		||||
            if (fixedLevelRewards.levelKeyRewards) {
 | 
			
		||||
                const missionRewards: { StoreItem: string; ItemCount: number }[] = [];
 | 
			
		||||
                addFixedLevelRewards(fixedLevelRewards.levelKeyRewards, inventory, missionRewards);
 | 
			
		||||
                inventory.RegularCredits += addFixedLevelRewards(fixedLevelRewards.levelKeyRewards, missionRewards);
 | 
			
		||||
 | 
			
		||||
                for (const reward of missionRewards) {
 | 
			
		||||
                    await addItem(inventory, fromStoreItem(reward.StoreItem), reward.ItemCount);
 | 
			
		||||
 | 
			
		||||
@ -107,6 +107,16 @@ export class SRng {
 | 
			
		||||
        return arr[this.randomInt(0, arr.length - 1)];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    randomElementPop<T>(arr: T[]): T | undefined {
 | 
			
		||||
        if (arr.length != 0) {
 | 
			
		||||
            const index = this.randomInt(0, arr.length - 1);
 | 
			
		||||
            const elm = arr[index];
 | 
			
		||||
            arr.splice(index, 1);
 | 
			
		||||
            return elm;
 | 
			
		||||
        }
 | 
			
		||||
        return undefined;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    randomFloat(): number {
 | 
			
		||||
        this.state = (0x5851f42d4c957f2dn * this.state + 0x14057b7ef767814fn) & 0xffffffffffffffffn;
 | 
			
		||||
        return (Number(this.state >> 38n) & 0xffffff) * 0.000000059604645;
 | 
			
		||||
 | 
			
		||||
@ -149,7 +149,8 @@ export const handleInventoryItemConfigChange = async (
 | 
			
		||||
                    } else {
 | 
			
		||||
                        const inventoryItem = inventory.WeaponSkins.id(itemId);
 | 
			
		||||
                        if (!inventoryItem) {
 | 
			
		||||
                            throw new Error(`inventory item WeaponSkins not found with id ${itemId}`);
 | 
			
		||||
                            logger.warn(`inventory item WeaponSkins not found with id ${itemId}`);
 | 
			
		||||
                            continue;
 | 
			
		||||
                        }
 | 
			
		||||
                        if ("Favorite" in itemConfigEntries) {
 | 
			
		||||
                            inventoryItem.Favorite = itemConfigEntries.Favorite;
 | 
			
		||||
@ -166,8 +167,13 @@ export const handleInventoryItemConfigChange = async (
 | 
			
		||||
                inventory.LotusCustomization = equipmentChanges.LotusCustomization;
 | 
			
		||||
                break;
 | 
			
		||||
            }
 | 
			
		||||
            case "ValidNewLoadoutId": {
 | 
			
		||||
                logger.debug(`ignoring ValidNewLoadoutId (${equipmentChanges.ValidNewLoadoutId})`);
 | 
			
		||||
                // seems always equal to the id of loadout config NORMAL[0], likely has no purpose and we're free to ignore it
 | 
			
		||||
                break;
 | 
			
		||||
            }
 | 
			
		||||
            default: {
 | 
			
		||||
                if (equipmentKeys.includes(equipmentName as TEquipmentKey) && equipmentName != "ValidNewLoadoutId") {
 | 
			
		||||
                if (equipmentKeys.includes(equipmentName as TEquipmentKey)) {
 | 
			
		||||
                    logger.debug(`general Item config saved of type ${equipmentName}`, {
 | 
			
		||||
                        config: equipment
 | 
			
		||||
                    });
 | 
			
		||||
@ -177,7 +183,8 @@ export const handleInventoryItemConfigChange = async (
 | 
			
		||||
                        const inventoryItem = inventory[equipmentName].id(itemId);
 | 
			
		||||
 | 
			
		||||
                        if (!inventoryItem) {
 | 
			
		||||
                            throw new Error(`inventory item ${equipmentName} not found with id ${itemId}`);
 | 
			
		||||
                            logger.warn(`inventory item ${equipmentName} not found with id ${itemId}`);
 | 
			
		||||
                            continue;
 | 
			
		||||
                        }
 | 
			
		||||
 | 
			
		||||
                        for (const [configId, config] of Object.entries(itemConfigEntries)) {
 | 
			
		||||
@ -214,7 +221,7 @@ export const handleInventoryItemConfigChange = async (
 | 
			
		||||
                    }
 | 
			
		||||
                    break;
 | 
			
		||||
                } else {
 | 
			
		||||
                    logger.warn(`loadout category not implemented, changes may be lost: ${equipmentName}`, {
 | 
			
		||||
                    logger.error(`loadout category not implemented, changes will be lost: ${equipmentName}`, {
 | 
			
		||||
                        config: equipment
 | 
			
		||||
                    });
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
@ -1,64 +1,12 @@
 | 
			
		||||
import { unixTimesInMs } from "@/src/constants/timeConstants";
 | 
			
		||||
import { isDev } from "@/src/helpers/pathHelper";
 | 
			
		||||
import { args } from "@/src/helpers/commandLineArguments";
 | 
			
		||||
import { catBreadHash } from "@/src/helpers/stringHelpers";
 | 
			
		||||
import { TInventoryDatabaseDocument } from "@/src/models/inventoryModels/inventoryModel";
 | 
			
		||||
import { mixSeeds, SRng } from "@/src/services/rngService";
 | 
			
		||||
import { IMongoDate } from "@/src/types/commonTypes";
 | 
			
		||||
import { IItemManifest, IVendorInfo, IVendorManifest } from "@/src/types/vendorTypes";
 | 
			
		||||
import { logger } from "@/src/utils/logger";
 | 
			
		||||
import { ExportVendors, IRange, IVendor } from "warframe-public-export-plus";
 | 
			
		||||
 | 
			
		||||
import ArchimedeanVendorManifest from "@/static/fixed_responses/getVendorInfo/ArchimedeanVendorManifest.json";
 | 
			
		||||
import DeimosEntratiFragmentVendorProductsManifest from "@/static/fixed_responses/getVendorInfo/DeimosEntratiFragmentVendorProductsManifest.json";
 | 
			
		||||
import DeimosHivemindCommisionsManifestFishmonger from "@/static/fixed_responses/getVendorInfo/DeimosHivemindCommisionsManifestFishmonger.json";
 | 
			
		||||
import DeimosHivemindCommisionsManifestPetVendor from "@/static/fixed_responses/getVendorInfo/DeimosHivemindCommisionsManifestPetVendor.json";
 | 
			
		||||
import DeimosHivemindCommisionsManifestProspector from "@/static/fixed_responses/getVendorInfo/DeimosHivemindCommisionsManifestProspector.json";
 | 
			
		||||
import DeimosHivemindCommisionsManifestTokenVendor from "@/static/fixed_responses/getVendorInfo/DeimosHivemindCommisionsManifestTokenVendor.json";
 | 
			
		||||
import DeimosHivemindCommisionsManifestWeaponsmith from "@/static/fixed_responses/getVendorInfo/DeimosHivemindCommisionsManifestWeaponsmith.json";
 | 
			
		||||
import DeimosHivemindTokenVendorManifest from "@/static/fixed_responses/getVendorInfo/DeimosHivemindTokenVendorManifest.json";
 | 
			
		||||
import DeimosPetVendorManifest from "@/static/fixed_responses/getVendorInfo/DeimosPetVendorManifest.json";
 | 
			
		||||
import DeimosProspectorVendorManifest from "@/static/fixed_responses/getVendorInfo/DeimosProspectorVendorManifest.json";
 | 
			
		||||
import DuviriAcrithisVendorManifest from "@/static/fixed_responses/getVendorInfo/DuviriAcrithisVendorManifest.json";
 | 
			
		||||
import EntratiLabsEntratiLabsCommisionsManifest from "@/static/fixed_responses/getVendorInfo/EntratiLabsEntratiLabsCommisionsManifest.json";
 | 
			
		||||
import EntratiLabsEntratiLabVendorManifest from "@/static/fixed_responses/getVendorInfo/EntratiLabsEntratiLabVendorManifest.json";
 | 
			
		||||
import HubsIronwakeDondaVendorManifest from "@/static/fixed_responses/getVendorInfo/HubsIronwakeDondaVendorManifest.json";
 | 
			
		||||
import HubsRailjackCrewMemberVendorManifest from "@/static/fixed_responses/getVendorInfo/HubsRailjackCrewMemberVendorManifest.json";
 | 
			
		||||
import MaskSalesmanManifest from "@/static/fixed_responses/getVendorInfo/MaskSalesmanManifest.json";
 | 
			
		||||
import Nova1999ConquestShopManifest from "@/static/fixed_responses/getVendorInfo/Nova1999ConquestShopManifest.json";
 | 
			
		||||
import OstronPetVendorManifest from "@/static/fixed_responses/getVendorInfo/OstronPetVendorManifest.json";
 | 
			
		||||
import OstronProspectorVendorManifest from "@/static/fixed_responses/getVendorInfo/OstronProspectorVendorManifest.json";
 | 
			
		||||
import SolarisDebtTokenVendorRepossessionsManifest from "@/static/fixed_responses/getVendorInfo/SolarisDebtTokenVendorRepossessionsManifest.json";
 | 
			
		||||
import SolarisProspectorVendorManifest from "@/static/fixed_responses/getVendorInfo/SolarisProspectorVendorManifest.json";
 | 
			
		||||
import Temple1999VendorManifest from "@/static/fixed_responses/getVendorInfo/Temple1999VendorManifest.json";
 | 
			
		||||
import TeshinHardModeVendorManifest from "@/static/fixed_responses/getVendorInfo/TeshinHardModeVendorManifest.json";
 | 
			
		||||
import ZarimanCommisionsManifestArchimedean from "@/static/fixed_responses/getVendorInfo/ZarimanCommisionsManifestArchimedean.json";
 | 
			
		||||
 | 
			
		||||
const rawVendorManifests: IVendorManifest[] = [
 | 
			
		||||
    ArchimedeanVendorManifest,
 | 
			
		||||
    DeimosEntratiFragmentVendorProductsManifest,
 | 
			
		||||
    DeimosHivemindCommisionsManifestFishmonger,
 | 
			
		||||
    DeimosHivemindCommisionsManifestPetVendor,
 | 
			
		||||
    DeimosHivemindCommisionsManifestProspector,
 | 
			
		||||
    DeimosHivemindCommisionsManifestTokenVendor,
 | 
			
		||||
    DeimosHivemindCommisionsManifestWeaponsmith,
 | 
			
		||||
    DeimosHivemindTokenVendorManifest,
 | 
			
		||||
    DeimosPetVendorManifest,
 | 
			
		||||
    DeimosProspectorVendorManifest,
 | 
			
		||||
    DuviriAcrithisVendorManifest,
 | 
			
		||||
    EntratiLabsEntratiLabsCommisionsManifest,
 | 
			
		||||
    EntratiLabsEntratiLabVendorManifest,
 | 
			
		||||
    HubsIronwakeDondaVendorManifest, // uses preprocessing
 | 
			
		||||
    HubsRailjackCrewMemberVendorManifest,
 | 
			
		||||
    MaskSalesmanManifest,
 | 
			
		||||
    Nova1999ConquestShopManifest,
 | 
			
		||||
    OstronPetVendorManifest,
 | 
			
		||||
    OstronProspectorVendorManifest,
 | 
			
		||||
    SolarisDebtTokenVendorRepossessionsManifest,
 | 
			
		||||
    SolarisProspectorVendorManifest,
 | 
			
		||||
    Temple1999VendorManifest,
 | 
			
		||||
    TeshinHardModeVendorManifest, // uses preprocessing
 | 
			
		||||
    ZarimanCommisionsManifestArchimedean
 | 
			
		||||
];
 | 
			
		||||
import { ExportVendors, IRange, IVendor, IVendorOffer } from "warframe-public-export-plus";
 | 
			
		||||
import { config } from "./configService";
 | 
			
		||||
 | 
			
		||||
interface IGeneratableVendorInfo extends Omit<IVendorInfo, "ItemManifest" | "Expiry"> {
 | 
			
		||||
    cycleOffset?: number;
 | 
			
		||||
@ -83,10 +31,6 @@ const generatableVendors: IGeneratableVendorInfo[] = [
 | 
			
		||||
        cycleOffset: 1744934400_000,
 | 
			
		||||
        cycleDuration: 4 * unixTimesInMs.day
 | 
			
		||||
    }
 | 
			
		||||
    // {
 | 
			
		||||
    //     _id: { $oid: "5dbb4c41e966f7886c3ce939" },
 | 
			
		||||
    //     TypeName: "/Lotus/Types/Game/VendorManifests/Hubs/IronwakeDondaVendorManifest"
 | 
			
		||||
    // }
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
const getVendorOid = (typeName: string): string => {
 | 
			
		||||
@ -101,60 +45,60 @@ const gcd = (a: number, b: number): number => {
 | 
			
		||||
const getCycleDuration = (manifest: IVendor): number => {
 | 
			
		||||
    let dur = 0;
 | 
			
		||||
    for (const item of manifest.items) {
 | 
			
		||||
        if (typeof item.durationHours != "number") {
 | 
			
		||||
        if (item.alwaysOffered) {
 | 
			
		||||
            continue;
 | 
			
		||||
        }
 | 
			
		||||
        const durationHours = item.rotatedWeekly ? 168 : item.durationHours;
 | 
			
		||||
        if (typeof durationHours != "number") {
 | 
			
		||||
            dur = 1;
 | 
			
		||||
            break;
 | 
			
		||||
        }
 | 
			
		||||
        if (dur != item.durationHours) {
 | 
			
		||||
            dur = gcd(dur, item.durationHours);
 | 
			
		||||
        if (dur != durationHours) {
 | 
			
		||||
            dur = gcd(dur, durationHours);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    return dur * unixTimesInMs.hour;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getVendorManifestByTypeName = (typeName: string): IVendorManifest | undefined => {
 | 
			
		||||
    for (const vendorManifest of rawVendorManifests) {
 | 
			
		||||
        if (vendorManifest.VendorInfo.TypeName == typeName) {
 | 
			
		||||
            return preprocessVendorManifest(vendorManifest);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
export const getVendorManifestByTypeName = (typeName: string, fullStock?: boolean): IVendorManifest | undefined => {
 | 
			
		||||
    for (const vendorInfo of generatableVendors) {
 | 
			
		||||
        if (vendorInfo.TypeName == typeName) {
 | 
			
		||||
            return generateVendorManifest(vendorInfo);
 | 
			
		||||
            return generateVendorManifest(vendorInfo, fullStock ?? config.fullyStockedVendors);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    if (typeName in ExportVendors) {
 | 
			
		||||
        const manifest = ExportVendors[typeName];
 | 
			
		||||
        return generateVendorManifest({
 | 
			
		||||
            _id: { $oid: getVendorOid(typeName) },
 | 
			
		||||
            TypeName: typeName,
 | 
			
		||||
            RandomSeedType: manifest.randomSeedType,
 | 
			
		||||
            cycleDuration: getCycleDuration(manifest)
 | 
			
		||||
        });
 | 
			
		||||
        return generateVendorManifest(
 | 
			
		||||
            {
 | 
			
		||||
                _id: { $oid: getVendorOid(typeName) },
 | 
			
		||||
                TypeName: typeName,
 | 
			
		||||
                RandomSeedType: manifest.randomSeedType,
 | 
			
		||||
                cycleDuration: getCycleDuration(manifest)
 | 
			
		||||
            },
 | 
			
		||||
            fullStock ?? config.fullyStockedVendors
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
    return undefined;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getVendorManifestByOid = (oid: string): IVendorManifest | undefined => {
 | 
			
		||||
    for (const vendorManifest of rawVendorManifests) {
 | 
			
		||||
        if (vendorManifest.VendorInfo._id.$oid == oid) {
 | 
			
		||||
            return preprocessVendorManifest(vendorManifest);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    for (const vendorInfo of generatableVendors) {
 | 
			
		||||
        if (vendorInfo._id.$oid == oid) {
 | 
			
		||||
            return generateVendorManifest(vendorInfo);
 | 
			
		||||
            return generateVendorManifest(vendorInfo, config.fullyStockedVendors);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    for (const [typeName, manifest] of Object.entries(ExportVendors)) {
 | 
			
		||||
        const typeNameOid = getVendorOid(typeName);
 | 
			
		||||
        if (typeNameOid == oid) {
 | 
			
		||||
            return generateVendorManifest({
 | 
			
		||||
                _id: { $oid: typeNameOid },
 | 
			
		||||
                TypeName: typeName,
 | 
			
		||||
                RandomSeedType: manifest.randomSeedType,
 | 
			
		||||
                cycleDuration: getCycleDuration(manifest)
 | 
			
		||||
            });
 | 
			
		||||
            return generateVendorManifest(
 | 
			
		||||
                {
 | 
			
		||||
                    _id: { $oid: typeNameOid },
 | 
			
		||||
                    TypeName: typeName,
 | 
			
		||||
                    RandomSeedType: manifest.randomSeedType,
 | 
			
		||||
                    cycleDuration: getCycleDuration(manifest)
 | 
			
		||||
                },
 | 
			
		||||
                config.fullyStockedVendors
 | 
			
		||||
            );
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    return undefined;
 | 
			
		||||
@ -197,30 +141,6 @@ export const applyStandingToVendorManifest = (
 | 
			
		||||
    };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const preprocessVendorManifest = (originalManifest: IVendorManifest): IVendorManifest => {
 | 
			
		||||
    if (Date.now() >= parseInt(originalManifest.VendorInfo.Expiry.$date.$numberLong)) {
 | 
			
		||||
        const manifest = structuredClone(originalManifest);
 | 
			
		||||
        const info = manifest.VendorInfo;
 | 
			
		||||
        refreshExpiry(info.Expiry);
 | 
			
		||||
        for (const offer of info.ItemManifest) {
 | 
			
		||||
            refreshExpiry(offer.Expiry);
 | 
			
		||||
        }
 | 
			
		||||
        return manifest;
 | 
			
		||||
    }
 | 
			
		||||
    return originalManifest;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const refreshExpiry = (expiry: IMongoDate): void => {
 | 
			
		||||
    const period = parseInt(expiry.$date.$numberLong);
 | 
			
		||||
    if (Date.now() >= period) {
 | 
			
		||||
        const epoch = 1734307200_000; // Monday (for weekly schedules)
 | 
			
		||||
        const iteration = Math.trunc((Date.now() - epoch) / period);
 | 
			
		||||
        const start = epoch + iteration * period;
 | 
			
		||||
        const end = start + period;
 | 
			
		||||
        expiry.$date.$numberLong = end.toString();
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const toRange = (value: IRange | number): IRange => {
 | 
			
		||||
    if (typeof value == "number") {
 | 
			
		||||
        return { minValue: value, maxValue: value };
 | 
			
		||||
@ -228,9 +148,54 @@ const toRange = (value: IRange | number): IRange => {
 | 
			
		||||
    return value;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const getCycleDurationRange = (manifest: IVendor): IRange | undefined => {
 | 
			
		||||
    const res: IRange = { minValue: Number.MAX_SAFE_INTEGER, maxValue: 0 };
 | 
			
		||||
    for (const offer of manifest.items) {
 | 
			
		||||
        if (offer.durationHours) {
 | 
			
		||||
            const range = toRange(offer.durationHours);
 | 
			
		||||
            if (res.minValue > range.minValue) {
 | 
			
		||||
                res.minValue = range.minValue;
 | 
			
		||||
            }
 | 
			
		||||
            if (res.maxValue < range.maxValue) {
 | 
			
		||||
                res.maxValue = range.maxValue;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    return res.maxValue != 0 ? res : undefined;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type TOfferId = string;
 | 
			
		||||
 | 
			
		||||
const getOfferId = (offer: IVendorOffer | IItemManifest): TOfferId => {
 | 
			
		||||
    if ("storeItem" in offer) {
 | 
			
		||||
        // IVendorOffer
 | 
			
		||||
        return offer.storeItem + "x" + offer.quantity;
 | 
			
		||||
    } else {
 | 
			
		||||
        // IItemManifest
 | 
			
		||||
        return offer.StoreItem + "x" + offer.QuantityMultiplier;
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
let vendorManifestsUsingFullStock = false;
 | 
			
		||||
const vendorManifestCache: Record<string, IVendorManifest> = {};
 | 
			
		||||
 | 
			
		||||
const generateVendorManifest = (vendorInfo: IGeneratableVendorInfo): IVendorManifest => {
 | 
			
		||||
const clearVendorCache = (): void => {
 | 
			
		||||
    for (const k of Object.keys(vendorManifestCache)) {
 | 
			
		||||
        delete vendorManifestCache[k];
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const generateVendorManifest = (
 | 
			
		||||
    vendorInfo: IGeneratableVendorInfo,
 | 
			
		||||
    fullStock: boolean | undefined
 | 
			
		||||
): IVendorManifest => {
 | 
			
		||||
    fullStock ??= config.fullyStockedVendors;
 | 
			
		||||
    fullStock ??= false;
 | 
			
		||||
    if (vendorManifestsUsingFullStock != fullStock) {
 | 
			
		||||
        vendorManifestsUsingFullStock = fullStock;
 | 
			
		||||
        clearVendorCache();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!(vendorInfo.TypeName in vendorManifestCache)) {
 | 
			
		||||
        // eslint-disable-next-line @typescript-eslint/no-unused-vars
 | 
			
		||||
        const { cycleOffset, cycleDuration, ...clientVendorInfo } = vendorInfo;
 | 
			
		||||
@ -244,10 +209,16 @@ const generateVendorManifest = (vendorInfo: IGeneratableVendorInfo): IVendorMani
 | 
			
		||||
    }
 | 
			
		||||
    const cacheEntry = vendorManifestCache[vendorInfo.TypeName];
 | 
			
		||||
    const info = cacheEntry.VendorInfo;
 | 
			
		||||
    if (Date.now() >= parseInt(info.Expiry.$date.$numberLong)) {
 | 
			
		||||
    const manifest = ExportVendors[vendorInfo.TypeName];
 | 
			
		||||
    const cycleDurationRange = getCycleDurationRange(manifest);
 | 
			
		||||
    let now = Date.now();
 | 
			
		||||
    if (cycleDurationRange && cycleDurationRange.minValue != cycleDurationRange.maxValue) {
 | 
			
		||||
        now -= (cycleDurationRange.maxValue - 1) * unixTimesInMs.hour;
 | 
			
		||||
    }
 | 
			
		||||
    while (Date.now() >= parseInt(info.Expiry.$date.$numberLong)) {
 | 
			
		||||
        // Remove expired offers
 | 
			
		||||
        for (let i = 0; i != info.ItemManifest.length; ) {
 | 
			
		||||
            if (Date.now() >= parseInt(info.ItemManifest[i].Expiry.$date.$numberLong)) {
 | 
			
		||||
            if (now >= parseInt(info.ItemManifest[i].Expiry.$date.$numberLong)) {
 | 
			
		||||
                info.ItemManifest.splice(i, 1);
 | 
			
		||||
            } else {
 | 
			
		||||
                ++i;
 | 
			
		||||
@ -258,62 +229,124 @@ const generateVendorManifest = (vendorInfo: IGeneratableVendorInfo): IVendorMani
 | 
			
		||||
        const vendorSeed = parseInt(vendorInfo._id.$oid.substring(16), 16);
 | 
			
		||||
        const cycleOffset = vendorInfo.cycleOffset ?? 1734307200_000;
 | 
			
		||||
        const cycleDuration = vendorInfo.cycleDuration;
 | 
			
		||||
        const cycleIndex = Math.trunc((Date.now() - cycleOffset) / cycleDuration);
 | 
			
		||||
        const cycleIndex = Math.trunc((now - cycleOffset) / cycleDuration);
 | 
			
		||||
        const rng = new SRng(mixSeeds(vendorSeed, cycleIndex));
 | 
			
		||||
        const manifest = ExportVendors[vendorInfo.TypeName];
 | 
			
		||||
        const offersToAdd = [];
 | 
			
		||||
        if (
 | 
			
		||||
            manifest.numItems &&
 | 
			
		||||
            (manifest.numItems.minValue != manifest.numItems.maxValue ||
 | 
			
		||||
                manifest.items.length != manifest.numItems.minValue) &&
 | 
			
		||||
            !manifest.isOneBinPerCycle
 | 
			
		||||
        ) {
 | 
			
		||||
            const remainingItemCapacity: Record<string, number> = {};
 | 
			
		||||
            for (const item of manifest.items) {
 | 
			
		||||
                remainingItemCapacity[item.storeItem] = 1 + item.duplicates;
 | 
			
		||||
            }
 | 
			
		||||
            for (const offer of info.ItemManifest) {
 | 
			
		||||
                remainingItemCapacity[offer.StoreItem] -= 1;
 | 
			
		||||
            }
 | 
			
		||||
            const numItemsTarget = rng.randomInt(manifest.numItems.minValue, manifest.numItems.maxValue);
 | 
			
		||||
            while (info.ItemManifest.length + offersToAdd.length < numItemsTarget) {
 | 
			
		||||
                // TODO: Consider per-bin item limits
 | 
			
		||||
                // TODO: Consider item probability weightings
 | 
			
		||||
                const item = rng.randomElement(manifest.items)!;
 | 
			
		||||
                if (remainingItemCapacity[item.storeItem] != 0) {
 | 
			
		||||
                    remainingItemCapacity[item.storeItem] -= 1;
 | 
			
		||||
                    offersToAdd.push(item);
 | 
			
		||||
        const offersToAdd: IVendorOffer[] = [];
 | 
			
		||||
        if (manifest.isOneBinPerCycle) {
 | 
			
		||||
            if (fullStock) {
 | 
			
		||||
                for (const rawItem of manifest.items) {
 | 
			
		||||
                    offersToAdd.push(rawItem);
 | 
			
		||||
                }
 | 
			
		||||
            } else {
 | 
			
		||||
                const binThisCycle = cycleIndex % 2; // Note: May want to check the actual number of bins, but this is only used for coda weapons right now.
 | 
			
		||||
                for (const rawItem of manifest.items) {
 | 
			
		||||
                    if (rawItem.bin == binThisCycle) {
 | 
			
		||||
                        offersToAdd.push(rawItem);
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        } else {
 | 
			
		||||
            let binThisCycle;
 | 
			
		||||
            if (manifest.isOneBinPerCycle) {
 | 
			
		||||
                binThisCycle = cycleIndex % 2; // Note: May want to auto-compute the bin size, but this is only used for coda weapons right now.
 | 
			
		||||
            // Compute vendor requirements, subtracting existing offers
 | 
			
		||||
            const remainingItemCapacity: Record<TOfferId, number> = {};
 | 
			
		||||
            const missingItemsPerBin: Record<number, number> = {};
 | 
			
		||||
            let numOffersThatNeedToMatchABin = 0;
 | 
			
		||||
            if (manifest.numItemsPerBin) {
 | 
			
		||||
                for (let bin = 0; bin != manifest.numItemsPerBin.length; ++bin) {
 | 
			
		||||
                    missingItemsPerBin[bin] = manifest.numItemsPerBin[bin];
 | 
			
		||||
                    numOffersThatNeedToMatchABin += manifest.numItemsPerBin[bin];
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            for (const rawItem of manifest.items) {
 | 
			
		||||
                if (!manifest.isOneBinPerCycle || rawItem.bin == binThisCycle) {
 | 
			
		||||
                    offersToAdd.push(rawItem);
 | 
			
		||||
            for (const item of manifest.items) {
 | 
			
		||||
                remainingItemCapacity[getOfferId(item)] = 1 + item.duplicates;
 | 
			
		||||
            }
 | 
			
		||||
            for (const offer of info.ItemManifest) {
 | 
			
		||||
                remainingItemCapacity[getOfferId(offer)] -= 1;
 | 
			
		||||
                const bin = parseInt(offer.Bin.substring(4));
 | 
			
		||||
                if (missingItemsPerBin[bin]) {
 | 
			
		||||
                    missingItemsPerBin[bin] -= 1;
 | 
			
		||||
                    numOffersThatNeedToMatchABin -= 1;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // For most vendors, the offers seem to roughly be in reverse order from the manifest. Coda weapons are an odd exception.
 | 
			
		||||
            if (!manifest.isOneBinPerCycle) {
 | 
			
		||||
                offersToAdd.reverse();
 | 
			
		||||
            // Add permanent offers
 | 
			
		||||
            let numUncountedOffers = 0;
 | 
			
		||||
            let numCountedOffers = 0;
 | 
			
		||||
            let offset = 0;
 | 
			
		||||
            for (const item of manifest.items) {
 | 
			
		||||
                if (item.alwaysOffered || item.rotatedWeekly) {
 | 
			
		||||
                    ++numUncountedOffers;
 | 
			
		||||
                    const id = getOfferId(item);
 | 
			
		||||
                    if (remainingItemCapacity[id] != 0) {
 | 
			
		||||
                        remainingItemCapacity[id] -= 1;
 | 
			
		||||
                        offersToAdd.push(item);
 | 
			
		||||
                        ++offset;
 | 
			
		||||
                    }
 | 
			
		||||
                    if (missingItemsPerBin[item.bin]) {
 | 
			
		||||
                        missingItemsPerBin[item.bin] -= 1;
 | 
			
		||||
                        numOffersThatNeedToMatchABin -= 1;
 | 
			
		||||
                    }
 | 
			
		||||
                } else {
 | 
			
		||||
                    numCountedOffers += 1 + item.duplicates;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Add counted offers
 | 
			
		||||
            const useRng =
 | 
			
		||||
                manifest.numItems &&
 | 
			
		||||
                (manifest.numItems.minValue != manifest.numItems.maxValue ||
 | 
			
		||||
                    manifest.numItems.minValue != numCountedOffers);
 | 
			
		||||
            const numItemsTarget = fullStock
 | 
			
		||||
                ? numUncountedOffers + numCountedOffers
 | 
			
		||||
                : manifest.numItems
 | 
			
		||||
                  ? numUncountedOffers +
 | 
			
		||||
                    Math.min(
 | 
			
		||||
                        Object.values(remainingItemCapacity).reduce((a, b) => a + b, 0),
 | 
			
		||||
                        useRng
 | 
			
		||||
                            ? rng.randomInt(manifest.numItems.minValue, manifest.numItems.maxValue)
 | 
			
		||||
                            : manifest.numItems.minValue
 | 
			
		||||
                    )
 | 
			
		||||
                  : manifest.items.length;
 | 
			
		||||
            let i = 0;
 | 
			
		||||
            const rollableOffers = manifest.items.filter(x => x.probability !== undefined) as (Omit<
 | 
			
		||||
                IVendorOffer,
 | 
			
		||||
                "probability"
 | 
			
		||||
            > & { probability: number })[];
 | 
			
		||||
            while (info.ItemManifest.length + offersToAdd.length < numItemsTarget) {
 | 
			
		||||
                const item = useRng ? rng.randomReward(rollableOffers)! : rollableOffers[i++];
 | 
			
		||||
                if (
 | 
			
		||||
                    remainingItemCapacity[getOfferId(item)] != 0 &&
 | 
			
		||||
                    (numOffersThatNeedToMatchABin == 0 || missingItemsPerBin[item.bin])
 | 
			
		||||
                ) {
 | 
			
		||||
                    remainingItemCapacity[getOfferId(item)] -= 1;
 | 
			
		||||
                    if (missingItemsPerBin[item.bin]) {
 | 
			
		||||
                        missingItemsPerBin[item.bin] -= 1;
 | 
			
		||||
                        numOffersThatNeedToMatchABin -= 1;
 | 
			
		||||
                    }
 | 
			
		||||
                    offersToAdd.splice(offset, 0, item);
 | 
			
		||||
                }
 | 
			
		||||
                if (i == rollableOffers.length) {
 | 
			
		||||
                    i = 0;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        const cycleStart = cycleOffset + cycleIndex * cycleDuration;
 | 
			
		||||
        for (const rawItem of offersToAdd) {
 | 
			
		||||
            const durationHoursRange = toRange(rawItem.durationHours ?? cycleDuration);
 | 
			
		||||
            const expiry =
 | 
			
		||||
                cycleStart +
 | 
			
		||||
                rng.randomInt(durationHoursRange.minValue, durationHoursRange.maxValue) * unixTimesInMs.hour;
 | 
			
		||||
            const expiry = rawItem.alwaysOffered
 | 
			
		||||
                ? 2051240400_000
 | 
			
		||||
                : cycleStart +
 | 
			
		||||
                  (rawItem.rotatedWeekly
 | 
			
		||||
                      ? unixTimesInMs.week
 | 
			
		||||
                      : rng.randomInt(durationHoursRange.minValue, durationHoursRange.maxValue) * unixTimesInMs.hour);
 | 
			
		||||
            const item: IItemManifest = {
 | 
			
		||||
                StoreItem: rawItem.storeItem,
 | 
			
		||||
                ItemPrices: rawItem.itemPrices?.map(itemPrice => ({ ...itemPrice, ProductCategory: "MiscItems" })),
 | 
			
		||||
                Bin: "BIN_" + rawItem.bin,
 | 
			
		||||
                QuantityMultiplier: 1,
 | 
			
		||||
                QuantityMultiplier: rawItem.quantity,
 | 
			
		||||
                Expiry: { $date: { $numberLong: expiry.toString() } },
 | 
			
		||||
                AllowMultipurchase: false,
 | 
			
		||||
                PurchaseQuantityLimit: rawItem.purchaseLimit,
 | 
			
		||||
                RotatedWeekly: rawItem.rotatedWeekly,
 | 
			
		||||
                AllowMultipurchase: rawItem.purchaseLimit !== 1,
 | 
			
		||||
                Id: {
 | 
			
		||||
                    $oid:
 | 
			
		||||
                        ((cycleStart / 1000) & 0xffffffff).toString(16).padStart(8, "0") +
 | 
			
		||||
@ -322,7 +355,7 @@ const generateVendorManifest = (vendorInfo: IGeneratableVendorInfo): IVendorMani
 | 
			
		||||
                }
 | 
			
		||||
            };
 | 
			
		||||
            if (rawItem.numRandomItemPrices) {
 | 
			
		||||
                item.ItemPrices = [];
 | 
			
		||||
                item.ItemPrices ??= [];
 | 
			
		||||
                for (let i = 0; i != rawItem.numRandomItemPrices; ++i) {
 | 
			
		||||
                    let itemPrice: { type: string; count: IRange };
 | 
			
		||||
                    do {
 | 
			
		||||
@ -362,6 +395,14 @@ const generateVendorManifest = (vendorInfo: IGeneratableVendorInfo): IVendorMani
 | 
			
		||||
            info.ItemManifest.push(item);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (manifest.numItemsPerBin) {
 | 
			
		||||
            info.ItemManifest.sort((a, b) => {
 | 
			
		||||
                const aBin = parseInt(a.Bin.substring(4));
 | 
			
		||||
                const bBin = parseInt(b.Bin.substring(4));
 | 
			
		||||
                return aBin == bBin ? 0 : aBin < bBin ? +1 : -1;
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Update vendor expiry
 | 
			
		||||
        let soonestOfferExpiry: number = Number.MAX_SAFE_INTEGER;
 | 
			
		||||
        for (const offer of info.ItemManifest) {
 | 
			
		||||
@ -371,21 +412,99 @@ const generateVendorManifest = (vendorInfo: IGeneratableVendorInfo): IVendorMani
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        info.Expiry.$date.$numberLong = soonestOfferExpiry.toString();
 | 
			
		||||
 | 
			
		||||
        now += unixTimesInMs.hour;
 | 
			
		||||
    }
 | 
			
		||||
    return cacheEntry;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
if (isDev) {
 | 
			
		||||
    const ads = getVendorManifestByTypeName("/Lotus/Types/Game/VendorManifests/Hubs/GuildAdvertisementVendorManifest")!
 | 
			
		||||
if (args.dev) {
 | 
			
		||||
    if (
 | 
			
		||||
        getCycleDuration(ExportVendors["/Lotus/Types/Game/VendorManifests/Hubs/TeshinHardModeVendorManifest"]) !=
 | 
			
		||||
        unixTimesInMs.week
 | 
			
		||||
    ) {
 | 
			
		||||
        logger.warn(`getCycleDuration self test failed`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    for (let i = 0; i != 2; ++i) {
 | 
			
		||||
        const fullStock = !!i;
 | 
			
		||||
 | 
			
		||||
        const ads = getVendorManifestByTypeName(
 | 
			
		||||
            "/Lotus/Types/Game/VendorManifests/Hubs/GuildAdvertisementVendorManifest",
 | 
			
		||||
            fullStock
 | 
			
		||||
        )!.VendorInfo.ItemManifest;
 | 
			
		||||
        if (
 | 
			
		||||
            ads.length != 5 ||
 | 
			
		||||
            ads[0].Bin != "BIN_4" ||
 | 
			
		||||
            ads[1].Bin != "BIN_3" ||
 | 
			
		||||
            ads[2].Bin != "BIN_2" ||
 | 
			
		||||
            ads[3].Bin != "BIN_1" ||
 | 
			
		||||
            ads[4].Bin != "BIN_0"
 | 
			
		||||
        ) {
 | 
			
		||||
            logger.warn(`self test failed for /Lotus/Types/Game/VendorManifests/Hubs/GuildAdvertisementVendorManifest`);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const pall = getVendorManifestByTypeName(
 | 
			
		||||
            "/Lotus/Types/Game/VendorManifests/Hubs/IronwakeDondaVendorManifest",
 | 
			
		||||
            fullStock
 | 
			
		||||
        )!.VendorInfo.ItemManifest;
 | 
			
		||||
        if (
 | 
			
		||||
            pall.length != 5 ||
 | 
			
		||||
            pall[0].StoreItem != "/Lotus/StoreItems/Types/Items/ShipDecos/HarrowQuestKeyOrnament" ||
 | 
			
		||||
            pall[1].StoreItem != "/Lotus/StoreItems/Types/BoosterPacks/RivenModPack" ||
 | 
			
		||||
            pall[2].StoreItem != "/Lotus/StoreItems/Types/StoreItems/CreditBundles/150000Credits" ||
 | 
			
		||||
            pall[3].StoreItem != "/Lotus/StoreItems/Types/Items/MiscItems/Kuva" ||
 | 
			
		||||
            pall[4].StoreItem != "/Lotus/StoreItems/Types/BoosterPacks/RivenModPack"
 | 
			
		||||
        ) {
 | 
			
		||||
            logger.warn(`self test failed for /Lotus/Types/Game/VendorManifests/Hubs/IronwakeDondaVendorManifest`);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const cms = getVendorManifestByTypeName(
 | 
			
		||||
        "/Lotus/Types/Game/VendorManifests/Hubs/RailjackCrewMemberVendorManifest",
 | 
			
		||||
        false
 | 
			
		||||
    )!.VendorInfo.ItemManifest;
 | 
			
		||||
    if (
 | 
			
		||||
        cms.length != 9 ||
 | 
			
		||||
        cms[0].Bin != "BIN_2" ||
 | 
			
		||||
        cms[8].Bin != "BIN_0" ||
 | 
			
		||||
        cms.reduce((a, x) => a + (x.Bin == "BIN_2" ? 1 : 0), 0) < 2 ||
 | 
			
		||||
        cms.reduce((a, x) => a + (x.Bin == "BIN_1" ? 1 : 0), 0) < 2 ||
 | 
			
		||||
        cms.reduce((a, x) => a + (x.Bin == "BIN_0" ? 1 : 0), 0) < 4
 | 
			
		||||
    ) {
 | 
			
		||||
        logger.warn(`self test failed for /Lotus/Types/Game/VendorManifests/Hubs/RailjackCrewMemberVendorManifest`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const temple = getVendorManifestByTypeName(
 | 
			
		||||
        "/Lotus/Types/Game/VendorManifests/TheHex/Temple1999VendorManifest",
 | 
			
		||||
        false
 | 
			
		||||
    )!.VendorInfo.ItemManifest;
 | 
			
		||||
    if (!temple.find(x => x.StoreItem == "/Lotus/StoreItems/Types/Items/MiscItems/Kuva")) {
 | 
			
		||||
        logger.warn(`self test failed for /Lotus/Types/Game/VendorManifests/TheHex/Temple1999VendorManifest`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const nakak = getVendorManifestByTypeName("/Lotus/Types/Game/VendorManifests/Ostron/MaskSalesmanManifest", false)!
 | 
			
		||||
        .VendorInfo.ItemManifest;
 | 
			
		||||
    if (
 | 
			
		||||
        ads.length != 5 ||
 | 
			
		||||
        ads[0].Bin != "BIN_4" ||
 | 
			
		||||
        ads[1].Bin != "BIN_3" ||
 | 
			
		||||
        ads[2].Bin != "BIN_2" ||
 | 
			
		||||
        ads[3].Bin != "BIN_1" ||
 | 
			
		||||
        ads[4].Bin != "BIN_0"
 | 
			
		||||
        nakak.length != 10 ||
 | 
			
		||||
        nakak[0].StoreItem != "/Lotus/StoreItems/Upgrades/Skins/Ostron/RevenantMask" ||
 | 
			
		||||
        nakak[1].StoreItem != "/Lotus/StoreItems/Types/Items/ShipDecos/Plushies/PlushyThumper" ||
 | 
			
		||||
        nakak[1].ItemPrices?.length != 4 ||
 | 
			
		||||
        nakak[2].StoreItem != "/Lotus/StoreItems/Types/Items/ShipDecos/Plushies/PlushyThumperMedium" ||
 | 
			
		||||
        nakak[2].ItemPrices?.length != 4 ||
 | 
			
		||||
        nakak[3].StoreItem != "/Lotus/StoreItems/Types/Items/ShipDecos/Plushies/PlushyThumperLarge" ||
 | 
			
		||||
        nakak[3].ItemPrices?.length != 4
 | 
			
		||||
        // The remaining offers should be computed by weighted RNG.
 | 
			
		||||
    ) {
 | 
			
		||||
        logger.warn(`self test failed for /Lotus/Types/Game/VendorManifests/Hubs/GuildAdvertisementVendorManifest`);
 | 
			
		||||
        logger.warn(`self test failed for /Lotus/Types/Game/VendorManifests/Ostron/MaskSalesmanManifest`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // strange case where numItems is 5 even tho only 3 offers can possibly be generated
 | 
			
		||||
    const loid = getVendorManifestByTypeName(
 | 
			
		||||
        "/Lotus/Types/Game/VendorManifests/EntratiLabs/EntratiLabsCommisionsManifest",
 | 
			
		||||
        false
 | 
			
		||||
    )!.VendorInfo.ItemManifest;
 | 
			
		||||
    if (loid.length != 3) {
 | 
			
		||||
        logger.warn(`self test failed for /Lotus/Types/Game/VendorManifests/EntratiLabs/EntratiLabsCommisionsManifest`);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -4,15 +4,18 @@ import {
 | 
			
		||||
    ISetShipCustomizationsRequest,
 | 
			
		||||
    IShipDecorationsRequest,
 | 
			
		||||
    IShipDecorationsResponse,
 | 
			
		||||
    ISetPlacedDecoInfoRequest
 | 
			
		||||
    ISetPlacedDecoInfoRequest,
 | 
			
		||||
    TBootLocation
 | 
			
		||||
} from "@/src/types/shipTypes";
 | 
			
		||||
import { logger } from "@/src/utils/logger";
 | 
			
		||||
import { Types } from "mongoose";
 | 
			
		||||
import { addShipDecorations, getInventory } from "./inventoryService";
 | 
			
		||||
import { addFusionTreasures, addShipDecorations, getInventory } from "./inventoryService";
 | 
			
		||||
import { config } from "./configService";
 | 
			
		||||
import { Guild } from "../models/guildModel";
 | 
			
		||||
import { hasGuildPermission } from "./guildService";
 | 
			
		||||
import { GuildPermission } from "../types/guildTypes";
 | 
			
		||||
import { ExportResources } from "warframe-public-export-plus";
 | 
			
		||||
import { RoomsType, TPersonalRoomsDatabaseDocument } from "../types/personalRoomsTypes";
 | 
			
		||||
 | 
			
		||||
export const setShipCustomizations = async (
 | 
			
		||||
    accountId: string,
 | 
			
		||||
@ -58,7 +61,16 @@ export const handleSetShipDecorations = async (
 | 
			
		||||
    const roomToPlaceIn = rooms.find(room => room.Name === placedDecoration.Room);
 | 
			
		||||
 | 
			
		||||
    if (!roomToPlaceIn) {
 | 
			
		||||
        throw new Error("room not found");
 | 
			
		||||
        throw new Error(`unknown room: ${placedDecoration.Room}`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const entry = Object.entries(ExportResources).find(arr => arr[1].deco == placedDecoration.Type);
 | 
			
		||||
    if (!entry) {
 | 
			
		||||
        throw new Error(`unknown deco type: ${placedDecoration.Type}`);
 | 
			
		||||
    }
 | 
			
		||||
    const [itemType, meta] = entry;
 | 
			
		||||
    if (meta.capacityCost === undefined) {
 | 
			
		||||
        throw new Error(`unknown deco type: ${placedDecoration.Type}`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (placedDecoration.MoveId) {
 | 
			
		||||
@ -82,7 +94,7 @@ export const handleSetShipDecorations = async (
 | 
			
		||||
                OldRoom: placedDecoration.OldRoom,
 | 
			
		||||
                NewRoom: placedDecoration.Room,
 | 
			
		||||
                IsApartment: placedDecoration.IsApartment,
 | 
			
		||||
                MaxCapacityIncrease: 0 // TODO: calculate capacity change upon removal
 | 
			
		||||
                MaxCapacityIncrease: 0
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
@ -95,33 +107,44 @@ export const handleSetShipDecorations = async (
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        oldRoom.PlacedDecos.pull({ _id: placedDecoration.MoveId });
 | 
			
		||||
        oldRoom.MaxCapacity += meta.capacityCost;
 | 
			
		||||
 | 
			
		||||
        const newDecoration = {
 | 
			
		||||
            Type: placedDecoration.Type,
 | 
			
		||||
            Pos: placedDecoration.Pos,
 | 
			
		||||
            Rot: placedDecoration.Rot,
 | 
			
		||||
            Scale: placedDecoration.Scale,
 | 
			
		||||
            Sockets: placedDecoration.Sockets,
 | 
			
		||||
            _id: placedDecoration.MoveId
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        //the new room is still roomToPlaceIn
 | 
			
		||||
        roomToPlaceIn.PlacedDecos.push(newDecoration);
 | 
			
		||||
        roomToPlaceIn.MaxCapacity -= meta.capacityCost;
 | 
			
		||||
 | 
			
		||||
        await personalRooms.save();
 | 
			
		||||
        return {
 | 
			
		||||
            OldRoom: placedDecoration.OldRoom,
 | 
			
		||||
            NewRoom: placedDecoration.Room,
 | 
			
		||||
            IsApartment: placedDecoration.IsApartment,
 | 
			
		||||
            MaxCapacityIncrease: 0 // TODO: calculate capacity change upon removal
 | 
			
		||||
            MaxCapacityIncrease: -meta.capacityCost
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (placedDecoration.RemoveId) {
 | 
			
		||||
        roomToPlaceIn.PlacedDecos.pull({ _id: placedDecoration.RemoveId });
 | 
			
		||||
        const decoIndex = roomToPlaceIn.PlacedDecos.findIndex(x => x._id.equals(placedDecoration.RemoveId));
 | 
			
		||||
        const deco = roomToPlaceIn.PlacedDecos[decoIndex];
 | 
			
		||||
        roomToPlaceIn.PlacedDecos.splice(decoIndex, 1);
 | 
			
		||||
        roomToPlaceIn.MaxCapacity += meta.capacityCost;
 | 
			
		||||
        await personalRooms.save();
 | 
			
		||||
 | 
			
		||||
        if (!config.unlockAllShipDecorations) {
 | 
			
		||||
            const inventory = await getInventory(accountId);
 | 
			
		||||
            addShipDecorations(inventory, [{ ItemType: placedDecoration.Type, ItemCount: 1 }]);
 | 
			
		||||
            if (deco.Sockets !== undefined) {
 | 
			
		||||
                addFusionTreasures(inventory, [{ ItemType: itemType, Sockets: deco.Sockets, ItemCount: 1 }]);
 | 
			
		||||
            } else {
 | 
			
		||||
                addShipDecorations(inventory, [{ ItemType: itemType, ItemCount: 1 }]);
 | 
			
		||||
            }
 | 
			
		||||
            await inventory.save();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
@ -129,17 +152,20 @@ export const handleSetShipDecorations = async (
 | 
			
		||||
            DecoId: placedDecoration.RemoveId,
 | 
			
		||||
            Room: placedDecoration.Room,
 | 
			
		||||
            IsApartment: placedDecoration.IsApartment,
 | 
			
		||||
            MaxCapacityIncrease: 0
 | 
			
		||||
            MaxCapacityIncrease: 0 // Client already implies the capacity being refunded.
 | 
			
		||||
        };
 | 
			
		||||
    } else {
 | 
			
		||||
        if (!config.unlockAllShipDecorations) {
 | 
			
		||||
            const inventory = await getInventory(accountId);
 | 
			
		||||
            addShipDecorations(inventory, [{ ItemType: placedDecoration.Type, ItemCount: -1 }]);
 | 
			
		||||
            await inventory.save();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // TODO: handle capacity
 | 
			
		||||
    if (!config.unlockAllShipDecorations) {
 | 
			
		||||
        const inventory = await getInventory(accountId);
 | 
			
		||||
        const itemType = Object.entries(ExportResources).find(arr => arr[1].deco == placedDecoration.Type)![0];
 | 
			
		||||
        if (placedDecoration.Sockets !== undefined) {
 | 
			
		||||
            addFusionTreasures(inventory, [{ ItemType: itemType, Sockets: placedDecoration.Sockets, ItemCount: -1 }]);
 | 
			
		||||
        } else {
 | 
			
		||||
            addShipDecorations(inventory, [{ ItemType: itemType, ItemCount: -1 }]);
 | 
			
		||||
        }
 | 
			
		||||
        await inventory.save();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    //place decoration
 | 
			
		||||
    const decoId = new Types.ObjectId();
 | 
			
		||||
@ -148,12 +174,32 @@ export const handleSetShipDecorations = async (
 | 
			
		||||
        Pos: placedDecoration.Pos,
 | 
			
		||||
        Rot: placedDecoration.Rot,
 | 
			
		||||
        Scale: placedDecoration.Scale,
 | 
			
		||||
        Sockets: placedDecoration.Sockets,
 | 
			
		||||
        _id: decoId
 | 
			
		||||
    });
 | 
			
		||||
    roomToPlaceIn.MaxCapacity -= meta.capacityCost;
 | 
			
		||||
 | 
			
		||||
    await personalRooms.save();
 | 
			
		||||
 | 
			
		||||
    return { DecoId: decoId.toString(), Room: placedDecoration.Room, IsApartment: placedDecoration.IsApartment };
 | 
			
		||||
    return {
 | 
			
		||||
        DecoId: decoId.toString(),
 | 
			
		||||
        Room: placedDecoration.Room,
 | 
			
		||||
        IsApartment: placedDecoration.IsApartment,
 | 
			
		||||
        MaxCapacityIncrease: -meta.capacityCost
 | 
			
		||||
    };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const getRoomsForBootLocation = (
 | 
			
		||||
    personalRooms: TPersonalRoomsDatabaseDocument,
 | 
			
		||||
    bootLocation: TBootLocation | undefined
 | 
			
		||||
): RoomsType[] => {
 | 
			
		||||
    if (bootLocation == "SHOP") {
 | 
			
		||||
        return personalRooms.TailorShop.Rooms;
 | 
			
		||||
    }
 | 
			
		||||
    if (bootLocation == "APARTMENT") {
 | 
			
		||||
        return personalRooms.Apartment.Rooms;
 | 
			
		||||
    }
 | 
			
		||||
    return personalRooms.Ship.Rooms;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const handleSetPlacedDecoInfo = async (accountId: string, req: ISetPlacedDecoInfoRequest): Promise<void> => {
 | 
			
		||||
@ -170,14 +216,14 @@ export const handleSetPlacedDecoInfo = async (accountId: string, req: ISetPlaced
 | 
			
		||||
 | 
			
		||||
    const personalRooms = await getPersonalRooms(accountId);
 | 
			
		||||
 | 
			
		||||
    const room = personalRooms.Ship.Rooms.find(room => room.Name === req.Room);
 | 
			
		||||
    const room = getRoomsForBootLocation(personalRooms, req.BootLocation).find(room => room.Name === req.Room);
 | 
			
		||||
    if (!room) {
 | 
			
		||||
        throw new Error("room not found");
 | 
			
		||||
        throw new Error(`unknown room: ${req.Room}`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const placedDeco = room.PlacedDecos.id(req.DecoId);
 | 
			
		||||
    if (!placedDeco) {
 | 
			
		||||
        throw new Error("deco not found");
 | 
			
		||||
        throw new Error(`unknown deco id: ${req.DecoId}`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    placedDeco.PictureFrameInfo = req.PictureFrameInfo;
 | 
			
		||||
 | 
			
		||||
@ -5,9 +5,17 @@ import { config } from "./configService";
 | 
			
		||||
import { logger } from "../utils/logger";
 | 
			
		||||
import { app } from "../app";
 | 
			
		||||
import { AddressInfo } from "node:net";
 | 
			
		||||
import ws from "ws";
 | 
			
		||||
import { Account } from "../models/loginModel";
 | 
			
		||||
import { createAccount, createNonce, getUsernameFromEmail, isCorrectPassword } from "./loginService";
 | 
			
		||||
import { IDatabaseAccountJson } from "../types/loginTypes";
 | 
			
		||||
import { HydratedDocument } from "mongoose";
 | 
			
		||||
import { Agent, WebSocket as UnidiciWebSocket } from "undici";
 | 
			
		||||
 | 
			
		||||
let httpServer: http.Server | undefined;
 | 
			
		||||
let httpsServer: https.Server | undefined;
 | 
			
		||||
let wsServer: ws.Server | undefined;
 | 
			
		||||
let wssServer: ws.Server | undefined;
 | 
			
		||||
 | 
			
		||||
const tlsOptions = {
 | 
			
		||||
    key: fs.readFileSync("static/certs/key.pem"),
 | 
			
		||||
@ -21,19 +29,65 @@ export const startWebServer = (): void => {
 | 
			
		||||
    // eslint-disable-next-line @typescript-eslint/no-misused-promises
 | 
			
		||||
    httpServer = http.createServer(app);
 | 
			
		||||
    httpServer.listen(httpPort, () => {
 | 
			
		||||
        wsServer = new ws.Server({ server: httpServer });
 | 
			
		||||
        wsServer.on("connection", wsOnConnect);
 | 
			
		||||
 | 
			
		||||
        logger.info("HTTP server started on port " + httpPort);
 | 
			
		||||
 | 
			
		||||
        // eslint-disable-next-line @typescript-eslint/no-misused-promises
 | 
			
		||||
        httpsServer = https.createServer(tlsOptions, app);
 | 
			
		||||
        httpsServer.listen(httpsPort, () => {
 | 
			
		||||
            wssServer = new ws.Server({ server: httpsServer });
 | 
			
		||||
            wssServer.on("connection", wsOnConnect);
 | 
			
		||||
 | 
			
		||||
            logger.info("HTTPS server started on port " + httpsPort);
 | 
			
		||||
 | 
			
		||||
            logger.info(
 | 
			
		||||
                "Access the WebUI in your browser at http://localhost" + (httpPort == 80 ? "" : ":" + httpPort)
 | 
			
		||||
            );
 | 
			
		||||
 | 
			
		||||
            void runWsSelfTest("wss", httpsPort).then(ok => {
 | 
			
		||||
                if (!ok) {
 | 
			
		||||
                    logger.warn(`WSS self-test failed. The server may not actually be reachable at port ${httpsPort}.`);
 | 
			
		||||
                    if (process.platform == "win32") {
 | 
			
		||||
                        logger.warn(
 | 
			
		||||
                            `You can check who actually has that port via powershell: Get-Process -Id (Get-NetTCPConnection -LocalPort ${httpsPort}).OwningProcess`
 | 
			
		||||
                        );
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        });
 | 
			
		||||
    });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const runWsSelfTest = (protocol: "ws" | "wss", port: number): Promise<boolean> => {
 | 
			
		||||
    return new Promise(resolve => {
 | 
			
		||||
        // https://github.com/oven-sh/bun/issues/20547
 | 
			
		||||
        if (process.versions.bun) {
 | 
			
		||||
            const client = new WebSocket(`${protocol}://localhost:${port}/custom/selftest`, {
 | 
			
		||||
                tls: { rejectUnauthorized: false }
 | 
			
		||||
            } as unknown as string);
 | 
			
		||||
            client.onmessage = (e): void => {
 | 
			
		||||
                resolve(e.data == "SpaceNinjaServer");
 | 
			
		||||
            };
 | 
			
		||||
            client.onerror = client.onclose = (): void => {
 | 
			
		||||
                resolve(false);
 | 
			
		||||
            };
 | 
			
		||||
        } else {
 | 
			
		||||
            const agent = new Agent({ connect: { rejectUnauthorized: false } });
 | 
			
		||||
            const client = new UnidiciWebSocket(`${protocol}://localhost:${port}/custom/selftest`, {
 | 
			
		||||
                dispatcher: agent
 | 
			
		||||
            });
 | 
			
		||||
            client.onmessage = (e): void => {
 | 
			
		||||
                resolve(e.data == "SpaceNinjaServer");
 | 
			
		||||
            };
 | 
			
		||||
            client.onerror = client.onclose = (): void => {
 | 
			
		||||
                resolve(false);
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getWebPorts = (): Record<"http" | "https", number | undefined> => {
 | 
			
		||||
    return {
 | 
			
		||||
        http: (httpServer?.address() as AddressInfo | undefined)?.port,
 | 
			
		||||
@ -61,5 +115,182 @@ export const stopWebServer = async (): Promise<void> => {
 | 
			
		||||
            })
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
    if (wsServer) {
 | 
			
		||||
        promises.push(
 | 
			
		||||
            new Promise(resolve => {
 | 
			
		||||
                wsServer!.close(() => {
 | 
			
		||||
                    resolve();
 | 
			
		||||
                });
 | 
			
		||||
            })
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
    if (wssServer) {
 | 
			
		||||
        promises.push(
 | 
			
		||||
            new Promise(resolve => {
 | 
			
		||||
                wssServer!.close(() => {
 | 
			
		||||
                    resolve();
 | 
			
		||||
                });
 | 
			
		||||
            })
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
    await Promise.all(promises);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
let lastWsid: number = 0;
 | 
			
		||||
 | 
			
		||||
interface IWsCustomData extends ws {
 | 
			
		||||
    id?: number;
 | 
			
		||||
    accountId?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface IWsMsgFromClient {
 | 
			
		||||
    auth?: {
 | 
			
		||||
        email: string;
 | 
			
		||||
        password: string;
 | 
			
		||||
        isRegister: boolean;
 | 
			
		||||
    };
 | 
			
		||||
    logout?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface IWsMsgToClient {
 | 
			
		||||
    //wsid?: number;
 | 
			
		||||
    reload?: boolean;
 | 
			
		||||
    ports?: {
 | 
			
		||||
        http: number | undefined;
 | 
			
		||||
        https: number | undefined;
 | 
			
		||||
    };
 | 
			
		||||
    config_reloaded?: boolean;
 | 
			
		||||
    auth_succ?: {
 | 
			
		||||
        id: string;
 | 
			
		||||
        DisplayName: string;
 | 
			
		||||
        Nonce: number;
 | 
			
		||||
    };
 | 
			
		||||
    auth_fail?: {
 | 
			
		||||
        isRegister: boolean;
 | 
			
		||||
    };
 | 
			
		||||
    logged_out?: boolean;
 | 
			
		||||
    update_inventory?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const wsOnConnect = (ws: ws, req: http.IncomingMessage): void => {
 | 
			
		||||
    if (req.url == "/custom/selftest") {
 | 
			
		||||
        ws.send("SpaceNinjaServer");
 | 
			
		||||
        ws.close();
 | 
			
		||||
        return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    (ws as IWsCustomData).id = ++lastWsid;
 | 
			
		||||
    ws.send(JSON.stringify({ wsid: lastWsid }));
 | 
			
		||||
 | 
			
		||||
    // eslint-disable-next-line @typescript-eslint/no-misused-promises
 | 
			
		||||
    ws.on("message", async msg => {
 | 
			
		||||
        const data = JSON.parse(String(msg)) as IWsMsgFromClient;
 | 
			
		||||
        if (data.auth) {
 | 
			
		||||
            let account: IDatabaseAccountJson | null = await Account.findOne({ email: data.auth.email });
 | 
			
		||||
            if (account) {
 | 
			
		||||
                if (isCorrectPassword(data.auth.password, account.password)) {
 | 
			
		||||
                    if (!account.Nonce) {
 | 
			
		||||
                        account.ClientType = "webui";
 | 
			
		||||
                        account.Nonce = createNonce();
 | 
			
		||||
                        await (account as HydratedDocument<IDatabaseAccountJson>).save();
 | 
			
		||||
                    }
 | 
			
		||||
                } else {
 | 
			
		||||
                    account = null;
 | 
			
		||||
                }
 | 
			
		||||
            } else if (data.auth.isRegister) {
 | 
			
		||||
                const name = await getUsernameFromEmail(data.auth.email);
 | 
			
		||||
                account = await createAccount({
 | 
			
		||||
                    email: data.auth.email,
 | 
			
		||||
                    password: data.auth.password,
 | 
			
		||||
                    ClientType: "webui",
 | 
			
		||||
                    LastLogin: new Date(),
 | 
			
		||||
                    DisplayName: name,
 | 
			
		||||
                    Nonce: createNonce()
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
            if (account) {
 | 
			
		||||
                (ws as IWsCustomData).accountId = account.id;
 | 
			
		||||
                ws.send(
 | 
			
		||||
                    JSON.stringify({
 | 
			
		||||
                        auth_succ: {
 | 
			
		||||
                            id: account.id,
 | 
			
		||||
                            DisplayName: account.DisplayName,
 | 
			
		||||
                            Nonce: account.Nonce
 | 
			
		||||
                        }
 | 
			
		||||
                    } satisfies IWsMsgToClient)
 | 
			
		||||
                );
 | 
			
		||||
            } else {
 | 
			
		||||
                ws.send(
 | 
			
		||||
                    JSON.stringify({
 | 
			
		||||
                        auth_fail: {
 | 
			
		||||
                            isRegister: data.auth.isRegister
 | 
			
		||||
                        }
 | 
			
		||||
                    } satisfies IWsMsgToClient)
 | 
			
		||||
                );
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        if (data.logout) {
 | 
			
		||||
            const accountId = (ws as IWsCustomData).accountId;
 | 
			
		||||
            (ws as IWsCustomData).accountId = undefined;
 | 
			
		||||
            await Account.updateOne(
 | 
			
		||||
                {
 | 
			
		||||
                    _id: accountId,
 | 
			
		||||
                    ClientType: "webui"
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                    Nonce: 0
 | 
			
		||||
                }
 | 
			
		||||
            );
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const sendWsBroadcast = (data: IWsMsgToClient): void => {
 | 
			
		||||
    const msg = JSON.stringify(data);
 | 
			
		||||
    if (wsServer) {
 | 
			
		||||
        for (const client of wsServer.clients) {
 | 
			
		||||
            client.send(msg);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    if (wssServer) {
 | 
			
		||||
        for (const client of wssServer.clients) {
 | 
			
		||||
            client.send(msg);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const sendWsBroadcastTo = (accountId: string, data: IWsMsgToClient): void => {
 | 
			
		||||
    const msg = JSON.stringify(data);
 | 
			
		||||
    if (wsServer) {
 | 
			
		||||
        for (const client of wsServer.clients) {
 | 
			
		||||
            if ((client as IWsCustomData).accountId == accountId) {
 | 
			
		||||
                client.send(msg);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    if (wssServer) {
 | 
			
		||||
        for (const client of wssServer.clients) {
 | 
			
		||||
            if ((client as IWsCustomData).accountId == accountId) {
 | 
			
		||||
                client.send(msg);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const sendWsBroadcastExcept = (wsid: number | undefined, data: IWsMsgToClient): void => {
 | 
			
		||||
    const msg = JSON.stringify(data);
 | 
			
		||||
    if (wsServer) {
 | 
			
		||||
        for (const client of wsServer.clients) {
 | 
			
		||||
            if ((client as IWsCustomData).id != wsid) {
 | 
			
		||||
                client.send(msg);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    if (wssServer) {
 | 
			
		||||
        for (const client of wssServer.clients) {
 | 
			
		||||
            if ((client as IWsCustomData).id != wsid) {
 | 
			
		||||
                client.send(msg);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -1,12 +1,15 @@
 | 
			
		||||
import staticWorldState from "@/static/fixed_responses/worldState/worldState.json";
 | 
			
		||||
import baro from "@/static/fixed_responses/worldState/baro.json";
 | 
			
		||||
import fissureMissions from "@/static/fixed_responses/worldState/fissureMissions.json";
 | 
			
		||||
import sortieTilesets from "@/static/fixed_responses/worldState/sortieTilesets.json";
 | 
			
		||||
import sortieTilesetMissions from "@/static/fixed_responses/worldState/sortieTilesetMissions.json";
 | 
			
		||||
import syndicateMissions from "@/static/fixed_responses/worldState/syndicateMissions.json";
 | 
			
		||||
import darvoDeals from "@/static/fixed_responses/worldState/darvoDeals.json";
 | 
			
		||||
import { buildConfig } from "@/src/services/buildConfigService";
 | 
			
		||||
import { unixTimesInMs } from "@/src/constants/timeConstants";
 | 
			
		||||
import { config } from "@/src/services/configService";
 | 
			
		||||
import { SRng } from "@/src/services/rngService";
 | 
			
		||||
import { ExportRegions, ExportSyndicates, IRegion } from "warframe-public-export-plus";
 | 
			
		||||
import { getRandomElement, getRandomInt, SRng } from "@/src/services/rngService";
 | 
			
		||||
import { eMissionType, ExportRegions, ExportSyndicates, IRegion } from "warframe-public-export-plus";
 | 
			
		||||
import {
 | 
			
		||||
    ICalendarDay,
 | 
			
		||||
    ICalendarEvent,
 | 
			
		||||
@ -16,10 +19,16 @@ import {
 | 
			
		||||
    ISortie,
 | 
			
		||||
    ISortieMission,
 | 
			
		||||
    ISyndicateMissionInfo,
 | 
			
		||||
    IWorldState
 | 
			
		||||
    ITmp,
 | 
			
		||||
    IVoidStorm,
 | 
			
		||||
    IVoidTrader,
 | 
			
		||||
    IVoidTraderOffer,
 | 
			
		||||
    IWorldState,
 | 
			
		||||
    TCircuitGameMode
 | 
			
		||||
} from "../types/worldStateTypes";
 | 
			
		||||
import { version_compare } from "../helpers/inventoryHelpers";
 | 
			
		||||
import { toMongoDate, toOid, version_compare } from "../helpers/inventoryHelpers";
 | 
			
		||||
import { logger } from "../utils/logger";
 | 
			
		||||
import { DailyDeal, Fissure } from "../models/worldStateModel";
 | 
			
		||||
 | 
			
		||||
const sortieBosses = [
 | 
			
		||||
    "SORTIE_BOSS_HYENA",
 | 
			
		||||
@ -93,7 +102,7 @@ const sortieBossNode: Record<Exclude<TSortieBoss, "SORTIE_BOSS_CORRUPTED_VOR">,
 | 
			
		||||
    SORTIE_BOSS_VOR: "SolNode108"
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const eidolonJobs = [
 | 
			
		||||
const eidolonJobs: readonly string[] = [
 | 
			
		||||
    "/Lotus/Types/Gameplay/Eidolon/Jobs/AssassinateBountyAss",
 | 
			
		||||
    "/Lotus/Types/Gameplay/Eidolon/Jobs/AssassinateBountyCap",
 | 
			
		||||
    "/Lotus/Types/Gameplay/Eidolon/Jobs/AttritionBountySab",
 | 
			
		||||
@ -109,14 +118,14 @@ const eidolonJobs = [
 | 
			
		||||
    "/Lotus/Types/Gameplay/Eidolon/Jobs/RescueBountyResc"
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
const eidolonNarmerJobs = [
 | 
			
		||||
const eidolonNarmerJobs: readonly string[] = [
 | 
			
		||||
    "/Lotus/Types/Gameplay/Eidolon/Jobs/Narmer/AssassinateBountyAss",
 | 
			
		||||
    "/Lotus/Types/Gameplay/Eidolon/Jobs/Narmer/AttritionBountyExt",
 | 
			
		||||
    "/Lotus/Types/Gameplay/Eidolon/Jobs/Narmer/ReclamationBountyTheft",
 | 
			
		||||
    "/Lotus/Types/Gameplay/Eidolon/Jobs/Narmer/AttritionBountyLib"
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
const venusJobs = [
 | 
			
		||||
const venusJobs: readonly string[] = [
 | 
			
		||||
    "/Lotus/Types/Gameplay/Venus/Jobs/VenusArtifactJobAmbush",
 | 
			
		||||
    "/Lotus/Types/Gameplay/Venus/Jobs/VenusArtifactJobExcavation",
 | 
			
		||||
    "/Lotus/Types/Gameplay/Venus/Jobs/VenusArtifactJobRecovery",
 | 
			
		||||
@ -142,14 +151,14 @@ const venusJobs = [
 | 
			
		||||
    "/Lotus/Types/Gameplay/Venus/Jobs/VenusWetworkJobSpy"
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
const venusNarmerJobs = [
 | 
			
		||||
const venusNarmerJobs: readonly string[] = [
 | 
			
		||||
    "/Lotus/Types/Gameplay/Venus/Jobs/Narmer/NarmerVenusCullJobAssassinate",
 | 
			
		||||
    "/Lotus/Types/Gameplay/Venus/Jobs/Narmer/NarmerVenusCullJobExterminate",
 | 
			
		||||
    "/Lotus/Types/Gameplay/Venus/Jobs/Narmer/NarmerVenusPreservationJobDefense",
 | 
			
		||||
    "/Lotus/Types/Gameplay/Venus/Jobs/Narmer/NarmerVenusTheftJobExcavation"
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
const microplanetJobs = [
 | 
			
		||||
const microplanetJobs: readonly string[] = [
 | 
			
		||||
    "/Lotus/Types/Gameplay/InfestedMicroplanet/Jobs/DeimosAreaDefenseBounty",
 | 
			
		||||
    "/Lotus/Types/Gameplay/InfestedMicroplanet/Jobs/DeimosAssassinateBounty",
 | 
			
		||||
    "/Lotus/Types/Gameplay/InfestedMicroplanet/Jobs/DeimosCrpSurvivorBounty",
 | 
			
		||||
@ -159,7 +168,7 @@ const microplanetJobs = [
 | 
			
		||||
    "/Lotus/Types/Gameplay/InfestedMicroplanet/Jobs/DeimosPurifyBounty"
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
const microplanetEndlessJobs = [
 | 
			
		||||
const microplanetEndlessJobs: readonly string[] = [
 | 
			
		||||
    "/Lotus/Types/Gameplay/InfestedMicroplanet/Jobs/DeimosEndlessAreaDefenseBounty",
 | 
			
		||||
    "/Lotus/Types/Gameplay/InfestedMicroplanet/Jobs/DeimosEndlessExcavateBounty",
 | 
			
		||||
    "/Lotus/Types/Gameplay/InfestedMicroplanet/Jobs/DeimosEndlessPurifyBounty"
 | 
			
		||||
@ -364,7 +373,7 @@ const getSeasonChallengePools = (syndicateTag: string): IRotatingSeasonChallenge
 | 
			
		||||
        hardWeekly: syndicate.weeklyChallenges!.filter(x =>
 | 
			
		||||
            x.startsWith("/Lotus/Types/Challenges/Seasons/WeeklyHard/")
 | 
			
		||||
        ),
 | 
			
		||||
        hasWeeklyPermanent: !!syndicate.weeklyChallenges!.find(x =>
 | 
			
		||||
        hasWeeklyPermanent: syndicate.weeklyChallenges!.some(x =>
 | 
			
		||||
            x.startsWith("/Lotus/Types/Challenges/Seasons/Weekly/SeasonWeeklyPermanent")
 | 
			
		||||
        )
 | 
			
		||||
    };
 | 
			
		||||
@ -453,19 +462,44 @@ const pushWeeklyActs = (
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const generateXpAmounts = (rng: SRng, stageCount: number, minXp: number, maxXp: number): number[] => {
 | 
			
		||||
    const step = minXp < 1000 ? 1 : 10;
 | 
			
		||||
    const totalDeciXp = rng.randomInt(minXp / step, maxXp / step);
 | 
			
		||||
    const xpAmounts: number[] = [];
 | 
			
		||||
    if (stageCount < 4) {
 | 
			
		||||
        const perStage = Math.ceil(totalDeciXp / stageCount) * step;
 | 
			
		||||
        for (let i = 0; i != stageCount; ++i) {
 | 
			
		||||
            xpAmounts.push(perStage);
 | 
			
		||||
        }
 | 
			
		||||
    } else {
 | 
			
		||||
        const perStage = Math.ceil(Math.round(totalDeciXp * 0.667) / (stageCount - 1)) * step;
 | 
			
		||||
        for (let i = 0; i != stageCount - 1; ++i) {
 | 
			
		||||
            xpAmounts.push(perStage);
 | 
			
		||||
        }
 | 
			
		||||
        xpAmounts.push(Math.ceil(totalDeciXp * 0.332) * step);
 | 
			
		||||
    }
 | 
			
		||||
    return xpAmounts;
 | 
			
		||||
};
 | 
			
		||||
// Test vectors:
 | 
			
		||||
//console.log(generateXpAmounts(new SRng(1337n), 5, 5000, 5000)); // [840, 840, 840, 840, 1660]
 | 
			
		||||
//console.log(generateXpAmounts(new SRng(1337n), 3, 40, 40)); // [14, 14, 14]
 | 
			
		||||
//console.log(generateXpAmounts(new SRng(1337n), 5, 150, 150)); // [25, 25, 25, 25, 50]
 | 
			
		||||
//console.log(generateXpAmounts(new SRng(1337n), 4, 10, 10)); // [2, 2, 2, 4]
 | 
			
		||||
//console.log(generateXpAmounts(new SRng(1337n), 4, 15, 15)); // [4, 4, 4, 5]
 | 
			
		||||
//console.log(generateXpAmounts(new SRng(1337n), 4, 20, 20)); // [5, 5, 5, 7]
 | 
			
		||||
 | 
			
		||||
export const pushClassicBounties = (syndicateMissions: ISyndicateMissionInfo[], bountyCycle: number): void => {
 | 
			
		||||
    const table = String.fromCharCode(65 + (bountyCycle % 3));
 | 
			
		||||
    const vaultTable = String.fromCharCode(65 + ((bountyCycle + 1) % 3));
 | 
			
		||||
    const deimosDTable = String.fromCharCode(65 + (bountyCycle % 2));
 | 
			
		||||
 | 
			
		||||
    // TODO: xpAmounts need to be calculated based on the jobType somehow?
 | 
			
		||||
 | 
			
		||||
    const seed = new SRng(bountyCycle).randomInt(0, 100_000);
 | 
			
		||||
    const bountyCycleStart = bountyCycle * 9000000;
 | 
			
		||||
    const bountyCycleEnd = bountyCycleStart + 9000000;
 | 
			
		||||
 | 
			
		||||
    {
 | 
			
		||||
        const rng = new SRng(seed);
 | 
			
		||||
        const pool = [...eidolonJobs];
 | 
			
		||||
        syndicateMissions.push({
 | 
			
		||||
            _id: {
 | 
			
		||||
                $oid: ((bountyCycleStart / 1000) & 0xffffffff).toString(16).padStart(8, "0") + "0000000000000008"
 | 
			
		||||
@ -477,47 +511,47 @@ export const pushClassicBounties = (syndicateMissions: ISyndicateMissionInfo[],
 | 
			
		||||
            Nodes: [],
 | 
			
		||||
            Jobs: [
 | 
			
		||||
                {
 | 
			
		||||
                    jobType: rng.randomElement(eidolonJobs),
 | 
			
		||||
                    jobType: rng.randomElementPop(pool),
 | 
			
		||||
                    rewards: `/Lotus/Types/Game/MissionDecks/EidolonJobMissionRewards/TierATable${table}Rewards`,
 | 
			
		||||
                    masteryReq: 0,
 | 
			
		||||
                    minEnemyLevel: 5,
 | 
			
		||||
                    maxEnemyLevel: 15,
 | 
			
		||||
                    xpAmounts: [430, 430, 430]
 | 
			
		||||
                    xpAmounts: generateXpAmounts(rng, 3, 1000, 1500)
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                    jobType: rng.randomElement(eidolonJobs),
 | 
			
		||||
                    jobType: rng.randomElementPop(pool),
 | 
			
		||||
                    rewards: `/Lotus/Types/Game/MissionDecks/EidolonJobMissionRewards/TierBTable${table}Rewards`,
 | 
			
		||||
                    masteryReq: 1,
 | 
			
		||||
                    minEnemyLevel: 10,
 | 
			
		||||
                    maxEnemyLevel: 30,
 | 
			
		||||
                    xpAmounts: [620, 620, 620]
 | 
			
		||||
                    xpAmounts: generateXpAmounts(rng, 3, 1750, 2250)
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                    jobType: rng.randomElement(eidolonJobs),
 | 
			
		||||
                    jobType: rng.randomElementPop(pool),
 | 
			
		||||
                    rewards: `/Lotus/Types/Game/MissionDecks/EidolonJobMissionRewards/TierCTable${table}Rewards`,
 | 
			
		||||
                    masteryReq: 2,
 | 
			
		||||
                    minEnemyLevel: 20,
 | 
			
		||||
                    maxEnemyLevel: 40,
 | 
			
		||||
                    xpAmounts: [670, 670, 670, 990]
 | 
			
		||||
                    xpAmounts: generateXpAmounts(rng, 4, 2500, 3000)
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                    jobType: rng.randomElement(eidolonJobs),
 | 
			
		||||
                    jobType: rng.randomElementPop(pool),
 | 
			
		||||
                    rewards: `/Lotus/Types/Game/MissionDecks/EidolonJobMissionRewards/TierDTable${table}Rewards`,
 | 
			
		||||
                    masteryReq: 3,
 | 
			
		||||
                    minEnemyLevel: 30,
 | 
			
		||||
                    maxEnemyLevel: 50,
 | 
			
		||||
                    xpAmounts: [570, 570, 570, 570, 1110]
 | 
			
		||||
                    xpAmounts: generateXpAmounts(rng, 5, 3250, 3750)
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                    jobType: rng.randomElement(eidolonJobs),
 | 
			
		||||
                    jobType: rng.randomElementPop(pool),
 | 
			
		||||
                    rewards: `/Lotus/Types/Game/MissionDecks/EidolonJobMissionRewards/TierETable${table}Rewards`,
 | 
			
		||||
                    masteryReq: 5,
 | 
			
		||||
                    minEnemyLevel: 40,
 | 
			
		||||
                    maxEnemyLevel: 60,
 | 
			
		||||
                    xpAmounts: [740, 740, 740, 740, 1450]
 | 
			
		||||
                    xpAmounts: generateXpAmounts(rng, 5, 4000, 4500)
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                    jobType: rng.randomElement(eidolonJobs),
 | 
			
		||||
                    jobType: rng.randomElementPop(pool),
 | 
			
		||||
                    rewards: `/Lotus/Types/Game/MissionDecks/EidolonJobMissionRewards/TierETable${table}Rewards`,
 | 
			
		||||
                    masteryReq: 10,
 | 
			
		||||
                    minEnemyLevel: 100,
 | 
			
		||||
@ -530,7 +564,7 @@ export const pushClassicBounties = (syndicateMissions: ISyndicateMissionInfo[],
 | 
			
		||||
                    masteryReq: 0,
 | 
			
		||||
                    minEnemyLevel: 50,
 | 
			
		||||
                    maxEnemyLevel: 70,
 | 
			
		||||
                    xpAmounts: [840, 840, 840, 840, 1650]
 | 
			
		||||
                    xpAmounts: generateXpAmounts(rng, 5, 4500, 5000)
 | 
			
		||||
                }
 | 
			
		||||
            ]
 | 
			
		||||
        });
 | 
			
		||||
@ -538,6 +572,7 @@ export const pushClassicBounties = (syndicateMissions: ISyndicateMissionInfo[],
 | 
			
		||||
 | 
			
		||||
    {
 | 
			
		||||
        const rng = new SRng(seed);
 | 
			
		||||
        const pool = [...venusJobs];
 | 
			
		||||
        syndicateMissions.push({
 | 
			
		||||
            _id: {
 | 
			
		||||
                $oid: ((bountyCycleStart / 1000) & 0xffffffff).toString(16).padStart(8, "0") + "0000000000000025"
 | 
			
		||||
@ -549,47 +584,47 @@ export const pushClassicBounties = (syndicateMissions: ISyndicateMissionInfo[],
 | 
			
		||||
            Nodes: [],
 | 
			
		||||
            Jobs: [
 | 
			
		||||
                {
 | 
			
		||||
                    jobType: rng.randomElement(venusJobs),
 | 
			
		||||
                    jobType: rng.randomElementPop(pool),
 | 
			
		||||
                    rewards: `/Lotus/Types/Game/MissionDecks/VenusJobMissionRewards/VenusTierATable${table}Rewards`,
 | 
			
		||||
                    masteryReq: 0,
 | 
			
		||||
                    minEnemyLevel: 5,
 | 
			
		||||
                    maxEnemyLevel: 15,
 | 
			
		||||
                    xpAmounts: [340, 340, 340]
 | 
			
		||||
                    xpAmounts: generateXpAmounts(rng, 3, 1000, 1500)
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                    jobType: rng.randomElement(venusJobs),
 | 
			
		||||
                    jobType: rng.randomElementPop(pool),
 | 
			
		||||
                    rewards: `/Lotus/Types/Game/MissionDecks/VenusJobMissionRewards/VenusTierBTable${table}Rewards`,
 | 
			
		||||
                    masteryReq: 1,
 | 
			
		||||
                    minEnemyLevel: 10,
 | 
			
		||||
                    maxEnemyLevel: 30,
 | 
			
		||||
                    xpAmounts: [660, 660, 660]
 | 
			
		||||
                    xpAmounts: generateXpAmounts(rng, 3, 1750, 2250)
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                    jobType: rng.randomElement(venusJobs),
 | 
			
		||||
                    jobType: rng.randomElementPop(pool),
 | 
			
		||||
                    rewards: `/Lotus/Types/Game/MissionDecks/VenusJobMissionRewards/VenusTierCTable${table}Rewards`,
 | 
			
		||||
                    masteryReq: 2,
 | 
			
		||||
                    minEnemyLevel: 20,
 | 
			
		||||
                    maxEnemyLevel: 40,
 | 
			
		||||
                    xpAmounts: [610, 610, 610, 900]
 | 
			
		||||
                    xpAmounts: generateXpAmounts(rng, 4, 2500, 3000)
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                    jobType: rng.randomElement(venusJobs),
 | 
			
		||||
                    jobType: rng.randomElementPop(pool),
 | 
			
		||||
                    rewards: `/Lotus/Types/Game/MissionDecks/VenusJobMissionRewards/VenusTierDTable${table}Rewards`,
 | 
			
		||||
                    masteryReq: 3,
 | 
			
		||||
                    minEnemyLevel: 30,
 | 
			
		||||
                    maxEnemyLevel: 50,
 | 
			
		||||
                    xpAmounts: [600, 600, 600, 600, 1170]
 | 
			
		||||
                    xpAmounts: generateXpAmounts(rng, 5, 3250, 3750)
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                    jobType: rng.randomElement(venusJobs),
 | 
			
		||||
                    jobType: rng.randomElementPop(pool),
 | 
			
		||||
                    rewards: `/Lotus/Types/Game/MissionDecks/VenusJobMissionRewards/VenusTierETable${table}Rewards`,
 | 
			
		||||
                    masteryReq: 5,
 | 
			
		||||
                    minEnemyLevel: 40,
 | 
			
		||||
                    maxEnemyLevel: 60,
 | 
			
		||||
                    xpAmounts: [690, 690, 690, 690, 1350]
 | 
			
		||||
                    xpAmounts: generateXpAmounts(rng, 5, 4000, 4500)
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                    jobType: rng.randomElement(venusJobs),
 | 
			
		||||
                    jobType: rng.randomElementPop(pool),
 | 
			
		||||
                    rewards: `/Lotus/Types/Game/MissionDecks/VenusJobMissionRewards/VenusTierETable${table}Rewards`,
 | 
			
		||||
                    masteryReq: 10,
 | 
			
		||||
                    minEnemyLevel: 100,
 | 
			
		||||
@ -602,7 +637,7 @@ export const pushClassicBounties = (syndicateMissions: ISyndicateMissionInfo[],
 | 
			
		||||
                    masteryReq: 0,
 | 
			
		||||
                    minEnemyLevel: 50,
 | 
			
		||||
                    maxEnemyLevel: 70,
 | 
			
		||||
                    xpAmounts: [780, 780, 780, 780, 1540]
 | 
			
		||||
                    xpAmounts: generateXpAmounts(rng, 5, 4500, 5000)
 | 
			
		||||
                }
 | 
			
		||||
            ]
 | 
			
		||||
        });
 | 
			
		||||
@ -610,6 +645,7 @@ export const pushClassicBounties = (syndicateMissions: ISyndicateMissionInfo[],
 | 
			
		||||
 | 
			
		||||
    {
 | 
			
		||||
        const rng = new SRng(seed);
 | 
			
		||||
        const pool = [...microplanetJobs];
 | 
			
		||||
        syndicateMissions.push({
 | 
			
		||||
            _id: {
 | 
			
		||||
                $oid: ((bountyCycleStart / 1000) & 0xffffffff).toString(16).padStart(8, "0") + "0000000000000002"
 | 
			
		||||
@ -621,20 +657,20 @@ export const pushClassicBounties = (syndicateMissions: ISyndicateMissionInfo[],
 | 
			
		||||
            Nodes: [],
 | 
			
		||||
            Jobs: [
 | 
			
		||||
                {
 | 
			
		||||
                    jobType: rng.randomElement(microplanetJobs),
 | 
			
		||||
                    jobType: rng.randomElementPop(pool),
 | 
			
		||||
                    rewards: `/Lotus/Types/Game/MissionDecks/DeimosMissionRewards/TierATable${table}Rewards`,
 | 
			
		||||
                    masteryReq: 0,
 | 
			
		||||
                    minEnemyLevel: 5,
 | 
			
		||||
                    maxEnemyLevel: 15,
 | 
			
		||||
                    xpAmounts: [5, 5, 5]
 | 
			
		||||
                    xpAmounts: generateXpAmounts(rng, 3, 12, 18)
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                    jobType: rng.randomElement(microplanetJobs),
 | 
			
		||||
                    jobType: rng.randomElementPop(pool),
 | 
			
		||||
                    rewards: `/Lotus/Types/Game/MissionDecks/DeimosMissionRewards/TierCTable${table}Rewards`,
 | 
			
		||||
                    masteryReq: 1,
 | 
			
		||||
                    minEnemyLevel: 15,
 | 
			
		||||
                    maxEnemyLevel: 25,
 | 
			
		||||
                    xpAmounts: [12, 12, 12]
 | 
			
		||||
                    xpAmounts: generateXpAmounts(rng, 3, 24, 36)
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                    jobType: rng.randomElement(microplanetEndlessJobs),
 | 
			
		||||
@ -646,23 +682,23 @@ export const pushClassicBounties = (syndicateMissions: ISyndicateMissionInfo[],
 | 
			
		||||
                    xpAmounts: [14, 14, 14]
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                    jobType: rng.randomElement(microplanetJobs),
 | 
			
		||||
                    jobType: rng.randomElementPop(pool),
 | 
			
		||||
                    rewards: `/Lotus/Types/Game/MissionDecks/DeimosMissionRewards/TierDTable${deimosDTable}Rewards`,
 | 
			
		||||
                    masteryReq: 2,
 | 
			
		||||
                    minEnemyLevel: 30,
 | 
			
		||||
                    maxEnemyLevel: 40,
 | 
			
		||||
                    xpAmounts: [17, 17, 17, 25]
 | 
			
		||||
                    xpAmounts: generateXpAmounts(rng, 4, 72, 88)
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                    jobType: rng.randomElement(microplanetJobs),
 | 
			
		||||
                    jobType: rng.randomElementPop(pool),
 | 
			
		||||
                    rewards: `/Lotus/Types/Game/MissionDecks/DeimosMissionRewards/TierETableARewards`,
 | 
			
		||||
                    masteryReq: 3,
 | 
			
		||||
                    minEnemyLevel: 40,
 | 
			
		||||
                    maxEnemyLevel: 60,
 | 
			
		||||
                    xpAmounts: [22, 22, 22, 22, 43]
 | 
			
		||||
                    xpAmounts: generateXpAmounts(rng, 5, 115, 135)
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                    jobType: rng.randomElement(microplanetJobs),
 | 
			
		||||
                    jobType: rng.randomElementPop(pool),
 | 
			
		||||
                    rewards: `/Lotus/Types/Game/MissionDecks/DeimosMissionRewards/TierETableARewards`,
 | 
			
		||||
                    masteryReq: 10,
 | 
			
		||||
                    minEnemyLevel: 100,
 | 
			
		||||
@ -939,6 +975,61 @@ const getCalendarSeason = (week: number): ICalendarSeason => {
 | 
			
		||||
    };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// Not very faithful, but to avoid the same node coming up back-to-back (which is not valid), I've split these into 2 arrays which we're alternating between.
 | 
			
		||||
 | 
			
		||||
const voidStormMissionsA = {
 | 
			
		||||
    VoidT1: ["CrewBattleNode519", "CrewBattleNode518", "CrewBattleNode515", "CrewBattleNode503"],
 | 
			
		||||
    VoidT2: ["CrewBattleNode501", "CrewBattleNode534", "CrewBattleNode530"],
 | 
			
		||||
    VoidT3: ["CrewBattleNode521", "CrewBattleNode516"],
 | 
			
		||||
    VoidT4: [
 | 
			
		||||
        "CrewBattleNode555",
 | 
			
		||||
        "CrewBattleNode553",
 | 
			
		||||
        "CrewBattleNode554",
 | 
			
		||||
        "CrewBattleNode539",
 | 
			
		||||
        "CrewBattleNode531",
 | 
			
		||||
        "CrewBattleNode527"
 | 
			
		||||
    ]
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const voidStormMissionsB = {
 | 
			
		||||
    VoidT1: ["CrewBattleNode509", "CrewBattleNode522", "CrewBattleNode511", "CrewBattleNode512"],
 | 
			
		||||
    VoidT2: ["CrewBattleNode535", "CrewBattleNode533"],
 | 
			
		||||
    VoidT3: ["CrewBattleNode524", "CrewBattleNode525"],
 | 
			
		||||
    VoidT4: [
 | 
			
		||||
        "CrewBattleNode542",
 | 
			
		||||
        "CrewBattleNode538",
 | 
			
		||||
        "CrewBattleNode543",
 | 
			
		||||
        "CrewBattleNode536",
 | 
			
		||||
        "CrewBattleNode550",
 | 
			
		||||
        "CrewBattleNode529"
 | 
			
		||||
    ]
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const pushVoidStorms = (arr: IVoidStorm[], hour: number): void => {
 | 
			
		||||
    const activation = hour * unixTimesInMs.hour + 40 * unixTimesInMs.minute;
 | 
			
		||||
    const expiry = activation + 90 * unixTimesInMs.minute;
 | 
			
		||||
    let accum = 0;
 | 
			
		||||
    const rng = new SRng(new SRng(hour).randomInt(0, 100_000));
 | 
			
		||||
    const voidStormMissions = structuredClone(hour & 1 ? voidStormMissionsA : voidStormMissionsB);
 | 
			
		||||
    for (const tier of ["VoidT1", "VoidT1", "VoidT2", "VoidT3", "VoidT4", "VoidT4"] as const) {
 | 
			
		||||
        const idx = rng.randomInt(0, voidStormMissions[tier].length - 1);
 | 
			
		||||
        const node = voidStormMissions[tier][idx];
 | 
			
		||||
        voidStormMissions[tier].splice(idx, 1);
 | 
			
		||||
        arr.push({
 | 
			
		||||
            _id: {
 | 
			
		||||
                $oid:
 | 
			
		||||
                    ((activation / 1000) & 0xffffffff).toString(16).padStart(8, "0") +
 | 
			
		||||
                    "0321e89b" +
 | 
			
		||||
                    (accum++).toString().padStart(8, "0")
 | 
			
		||||
            },
 | 
			
		||||
            Node: node,
 | 
			
		||||
            Activation: { $date: { $numberLong: activation.toString() } },
 | 
			
		||||
            Expiry: { $date: { $numberLong: expiry.toString() } },
 | 
			
		||||
            ActiveMissionTier: tier
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const doesTimeSatsifyConstraints = (timeSecs: number): boolean => {
 | 
			
		||||
    if (config.worldState?.eidolonOverride) {
 | 
			
		||||
        const eidolonEpoch = 1391992660;
 | 
			
		||||
@ -986,6 +1077,27 @@ const doesTimeSatsifyConstraints = (timeSecs: number): boolean => {
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (config.worldState?.duviriOverride) {
 | 
			
		||||
        const duviriMoods = ["sorrow", "fear", "joy", "anger", "envy"];
 | 
			
		||||
        const desiredMood = duviriMoods.indexOf(config.worldState.duviriOverride);
 | 
			
		||||
        if (desiredMood == -1) {
 | 
			
		||||
            logger.warn(`ignoring invalid config value for worldState.duviriOverride`, {
 | 
			
		||||
                value: config.worldState.duviriOverride,
 | 
			
		||||
                valid_values: duviriMoods
 | 
			
		||||
            });
 | 
			
		||||
        } else {
 | 
			
		||||
            const moodIndex = Math.trunc(timeSecs / 7200);
 | 
			
		||||
            const moodStart = moodIndex * 7200;
 | 
			
		||||
            const moodEnd = moodStart + 7200;
 | 
			
		||||
            if (
 | 
			
		||||
                moodIndex % 5 != desiredMood ||
 | 
			
		||||
                isBeforeNextExpectedWorldStateRefresh(timeSecs * 1000, moodEnd * 1000)
 | 
			
		||||
            ) {
 | 
			
		||||
                return false;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return true;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -1007,20 +1119,20 @@ export const getWorldState = (buildLabel?: string): IWorldState => {
 | 
			
		||||
        Alerts: [],
 | 
			
		||||
        Sorties: [],
 | 
			
		||||
        LiteSorties: [],
 | 
			
		||||
        ActiveMissions: [],
 | 
			
		||||
        GlobalUpgrades: [],
 | 
			
		||||
        VoidTraders: [],
 | 
			
		||||
        VoidStorms: [],
 | 
			
		||||
        DailyDeals: [],
 | 
			
		||||
        EndlessXpChoices: [],
 | 
			
		||||
        KnownCalendarSeasons: [],
 | 
			
		||||
        ...staticWorldState,
 | 
			
		||||
        SyndicateMissions: [...staticWorldState.SyndicateMissions]
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    // Omit void fissures for versions prior to Dante Unbound to avoid script errors.
 | 
			
		||||
    if (buildLabel && version_compare(buildLabel, "2024.03.24.20.00") < 0) {
 | 
			
		||||
        worldState.ActiveMissions = [];
 | 
			
		||||
        if (version_compare(buildLabel, "2017.10.12.17.04") < 0) {
 | 
			
		||||
            // Old versions seem to really get hung up on not being able to load these.
 | 
			
		||||
            worldState.PVPChallengeInstances = [];
 | 
			
		||||
        }
 | 
			
		||||
    // Old versions seem to really get hung up on not being able to load these.
 | 
			
		||||
    if (buildLabel && version_compare(buildLabel, "2017.10.12.17.04") < 0) {
 | 
			
		||||
        worldState.PVPChallengeInstances = [];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (config.worldState?.starDays) {
 | 
			
		||||
@ -1039,6 +1151,77 @@ export const getWorldState = (buildLabel?: string): IWorldState => {
 | 
			
		||||
            Node: "SolarisUnitedHub1"
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
    // The client gets kinda confused when multiple goals have the same tag, so considering these mutually exclusive.
 | 
			
		||||
    if (config.worldState?.galleonOfGhouls == 1) {
 | 
			
		||||
        worldState.Goals.push({
 | 
			
		||||
            _id: { $oid: "6814ddf00000000000000000" },
 | 
			
		||||
            Activation: { $date: { $numberLong: "1746198000000" } },
 | 
			
		||||
            Expiry: { $date: { $numberLong: "2000000000000" } },
 | 
			
		||||
            Count: 0,
 | 
			
		||||
            Goal: 1,
 | 
			
		||||
            Success: 0,
 | 
			
		||||
            Personal: true,
 | 
			
		||||
            Bounty: true,
 | 
			
		||||
            ClampNodeScores: true,
 | 
			
		||||
            Node: "EventNode19",
 | 
			
		||||
            MissionKeyName: "/Lotus/Types/Keys/GalleonRobberyAlert",
 | 
			
		||||
            Desc: "/Lotus/Language/Events/GalleonRobberyEventMissionTitle",
 | 
			
		||||
            Icon: "/Lotus/Interface/Icons/Player/GalleonRobberiesEvent.png",
 | 
			
		||||
            Tag: "GalleonRobbery",
 | 
			
		||||
            Reward: {
 | 
			
		||||
                items: [
 | 
			
		||||
                    "/Lotus/StoreItems/Types/Recipes/Weapons/GrnChainSawTonfaBlueprint",
 | 
			
		||||
                    "/Lotus/StoreItems/Upgrades/Skins/Clan/BountyHunterBadgeItem"
 | 
			
		||||
                ]
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    } else if (config.worldState?.galleonOfGhouls == 2) {
 | 
			
		||||
        worldState.Goals.push({
 | 
			
		||||
            _id: { $oid: "681e18700000000000000000" },
 | 
			
		||||
            Activation: { $date: { $numberLong: "1746802800000" } },
 | 
			
		||||
            Expiry: { $date: { $numberLong: "2000000000000" } },
 | 
			
		||||
            Count: 0,
 | 
			
		||||
            Goal: 1,
 | 
			
		||||
            Success: 0,
 | 
			
		||||
            Personal: true,
 | 
			
		||||
            Bounty: true,
 | 
			
		||||
            ClampNodeScores: true,
 | 
			
		||||
            Node: "EventNode28",
 | 
			
		||||
            MissionKeyName: "/Lotus/Types/Keys/GalleonRobberyAlertB",
 | 
			
		||||
            Desc: "/Lotus/Language/Events/GalleonRobberyEventMissionTitle",
 | 
			
		||||
            Icon: "/Lotus/Interface/Icons/Player/GalleonRobberiesEvent.png",
 | 
			
		||||
            Tag: "GalleonRobbery",
 | 
			
		||||
            Reward: {
 | 
			
		||||
                items: [
 | 
			
		||||
                    "/Lotus/StoreItems/Types/Recipes/Weapons/MortiforShieldAndSwordBlueprint",
 | 
			
		||||
                    "/Lotus/StoreItems/Upgrades/Skins/Clan/BountyHunterBadgeItem"
 | 
			
		||||
                ]
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    } else if (config.worldState?.galleonOfGhouls == 3) {
 | 
			
		||||
        worldState.Goals.push({
 | 
			
		||||
            _id: { $oid: "682752f00000000000000000" },
 | 
			
		||||
            Activation: { $date: { $numberLong: "1747407600000" } },
 | 
			
		||||
            Expiry: { $date: { $numberLong: "2000000000000" } },
 | 
			
		||||
            Count: 0,
 | 
			
		||||
            Goal: 1,
 | 
			
		||||
            Success: 0,
 | 
			
		||||
            Personal: true,
 | 
			
		||||
            Bounty: true,
 | 
			
		||||
            ClampNodeScores: true,
 | 
			
		||||
            Node: "EventNode19",
 | 
			
		||||
            MissionKeyName: "/Lotus/Types/Keys/GalleonRobberyAlertC",
 | 
			
		||||
            Desc: "/Lotus/Language/Events/GalleonRobberyEventMissionTitle",
 | 
			
		||||
            Icon: "/Lotus/Interface/Icons/Player/GalleonRobberiesEvent.png",
 | 
			
		||||
            Tag: "GalleonRobbery",
 | 
			
		||||
            Reward: {
 | 
			
		||||
                items: [
 | 
			
		||||
                    "/Lotus/Types/StoreItems/Packages/EventCatalystReactorBundle",
 | 
			
		||||
                    "/Lotus/StoreItems/Upgrades/Skins/Clan/BountyHunterBadgeItem"
 | 
			
		||||
                ]
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Nightwave Challenges
 | 
			
		||||
    const nightwaveSyndicateTag = getNightwaveSyndicateTag(buildLabel);
 | 
			
		||||
@ -1139,6 +1322,77 @@ export const getWorldState = (buildLabel?: string): IWorldState => {
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Baro
 | 
			
		||||
    {
 | 
			
		||||
        const baroIndex = Math.trunc((Date.now() - 910800000) / (unixTimesInMs.day * 14));
 | 
			
		||||
        const baroStart = baroIndex * (unixTimesInMs.day * 14) + 910800000;
 | 
			
		||||
        const baroActualStart = baroStart + unixTimesInMs.day * (config.baroAlwaysAvailable ? 0 : 12);
 | 
			
		||||
        const baroEnd = baroStart + unixTimesInMs.day * 14;
 | 
			
		||||
        const baroNode = ["EarthHUB", "MercuryHUB", "SaturnHUB", "PlutoHUB"][baroIndex % 4];
 | 
			
		||||
        const vt: IVoidTrader = {
 | 
			
		||||
            _id: { $oid: ((baroStart / 1000) & 0xffffffff).toString(16).padStart(8, "0") + "493c96d6067610bc" },
 | 
			
		||||
            Activation: { $date: { $numberLong: baroActualStart.toString() } },
 | 
			
		||||
            Expiry: { $date: { $numberLong: baroEnd.toString() } },
 | 
			
		||||
            Character: "Baro'Ki Teel",
 | 
			
		||||
            Node: baroNode,
 | 
			
		||||
            Manifest: []
 | 
			
		||||
        };
 | 
			
		||||
        worldState.VoidTraders.push(vt);
 | 
			
		||||
        if (isBeforeNextExpectedWorldStateRefresh(timeMs, baroActualStart)) {
 | 
			
		||||
            vt.Manifest = [];
 | 
			
		||||
            if (config.baroFullyStocked) {
 | 
			
		||||
                for (const armorSet of baro.armorSets) {
 | 
			
		||||
                    if (Array.isArray(armorSet[0])) {
 | 
			
		||||
                        for (const set of armorSet as IVoidTraderOffer[][]) {
 | 
			
		||||
                            for (const item of set) {
 | 
			
		||||
                                vt.Manifest.push(item);
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                    } else {
 | 
			
		||||
                        for (const item of armorSet as IVoidTraderOffer[]) {
 | 
			
		||||
                            vt.Manifest.push(item);
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                for (const item of baro.rest) {
 | 
			
		||||
                    vt.Manifest.push(item);
 | 
			
		||||
                }
 | 
			
		||||
            } else {
 | 
			
		||||
                const rng = new SRng(new SRng(baroIndex).randomInt(0, 100_000));
 | 
			
		||||
                // TOVERIFY: Constraint for upgrades amount?
 | 
			
		||||
                // TOVERIFY: Constraint for weapon amount?
 | 
			
		||||
                // TOVERIFY: Constraint for relics amount?
 | 
			
		||||
                let armorSet = rng.randomElement(baro.armorSets)!;
 | 
			
		||||
                if (Array.isArray(armorSet[0])) {
 | 
			
		||||
                    armorSet = rng.randomElement(baro.armorSets)!;
 | 
			
		||||
                }
 | 
			
		||||
                while (vt.Manifest.length + armorSet.length < 31) {
 | 
			
		||||
                    const item = rng.randomElement(baro.rest)!;
 | 
			
		||||
                    if (vt.Manifest.indexOf(item) == -1) {
 | 
			
		||||
                        const set = baro.allIfAny.find(set => set.indexOf(item.ItemType) != -1);
 | 
			
		||||
                        if (set) {
 | 
			
		||||
                            for (const itemType of set) {
 | 
			
		||||
                                vt.Manifest.push(baro.rest.find(x => x.ItemType == itemType)!);
 | 
			
		||||
                            }
 | 
			
		||||
                        } else {
 | 
			
		||||
                            vt.Manifest.push(item);
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                const overflow = 31 - (vt.Manifest.length + armorSet.length);
 | 
			
		||||
                if (overflow > 0) {
 | 
			
		||||
                    vt.Manifest.splice(0, overflow);
 | 
			
		||||
                }
 | 
			
		||||
                for (const armor of armorSet) {
 | 
			
		||||
                    vt.Manifest.push(armor as IVoidTraderOffer);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            for (const item of baro.evergreen) {
 | 
			
		||||
                vt.Manifest.push(item);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Sortie & syndicate missions cycling every day (at 16:00 or 17:00 UTC depending on if London, OT is observing DST)
 | 
			
		||||
    {
 | 
			
		||||
        const rollover = getSortieTime(day);
 | 
			
		||||
@ -1204,12 +1458,38 @@ export const getWorldState = (buildLabel?: string): IWorldState => {
 | 
			
		||||
        worldState.KnownCalendarSeasons.push(getCalendarSeason(week + 1));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Sentient Anomaly cycling every 30 minutes
 | 
			
		||||
    // Void Storms
 | 
			
		||||
    const hour = Math.trunc(timeMs / unixTimesInMs.hour);
 | 
			
		||||
    const overLastHourStormExpiry = hour * unixTimesInMs.hour + 10 * unixTimesInMs.minute;
 | 
			
		||||
    const thisHourStormActivation = hour * unixTimesInMs.hour + 40 * unixTimesInMs.minute;
 | 
			
		||||
    if (overLastHourStormExpiry > timeMs) {
 | 
			
		||||
        pushVoidStorms(worldState.VoidStorms, hour - 2);
 | 
			
		||||
    }
 | 
			
		||||
    pushVoidStorms(worldState.VoidStorms, hour - 1);
 | 
			
		||||
    if (isBeforeNextExpectedWorldStateRefresh(timeMs, thisHourStormActivation)) {
 | 
			
		||||
        pushVoidStorms(worldState.VoidStorms, hour);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Sentient Anomaly + Xtra Cheese cycles
 | 
			
		||||
    const halfHour = Math.trunc(timeMs / (unixTimesInMs.hour / 2));
 | 
			
		||||
    const tmp = {
 | 
			
		||||
    const hourInSeconds = 3600;
 | 
			
		||||
    const cheeseInterval = hourInSeconds * 8;
 | 
			
		||||
    const cheeseDuration = hourInSeconds * 2;
 | 
			
		||||
    const cheeseIndex = Math.trunc(timeSecs / cheeseInterval);
 | 
			
		||||
    let cheeseStart = cheeseIndex * cheeseInterval;
 | 
			
		||||
    let cheeseEnd = cheeseStart + cheeseDuration;
 | 
			
		||||
    let cheeseNext = (cheeseIndex + 1) * cheeseInterval;
 | 
			
		||||
    // Live servers only update the start time once it happens, which makes the
 | 
			
		||||
    // client show a negative countdown during off-hours. Optionally adjust the
 | 
			
		||||
    // times so the next activation is always in the future.
 | 
			
		||||
    if (config.unfaithfulBugFixes?.fixXtraCheeseTimer && timeSecs >= cheeseEnd) {
 | 
			
		||||
        cheeseStart = cheeseNext;
 | 
			
		||||
        cheeseEnd = cheeseStart + cheeseDuration;
 | 
			
		||||
        cheeseNext += cheeseInterval;
 | 
			
		||||
    }
 | 
			
		||||
    const tmp: ITmp = {
 | 
			
		||||
        cavabegin: "1690761600",
 | 
			
		||||
        PurchasePlatformLockEnabled: true,
 | 
			
		||||
        tcsn: true,
 | 
			
		||||
        pgr: {
 | 
			
		||||
            ts: "1732572900",
 | 
			
		||||
            en: "CUSTOM DECALS @ ZEVILA",
 | 
			
		||||
@ -1230,13 +1510,77 @@ export const getWorldState = (buildLabel?: string): IWorldState => {
 | 
			
		||||
        },
 | 
			
		||||
        ennnd: true,
 | 
			
		||||
        mbrt: true,
 | 
			
		||||
        fbst: {
 | 
			
		||||
            a: cheeseStart,
 | 
			
		||||
            e: cheeseEnd,
 | 
			
		||||
            n: cheeseNext
 | 
			
		||||
        },
 | 
			
		||||
        sfn: [550, 553, 554, 555][halfHour % 4]
 | 
			
		||||
    };
 | 
			
		||||
    if (Array.isArray(config.worldState?.circuitGameModes)) {
 | 
			
		||||
        tmp.edg = config.worldState.circuitGameModes as TCircuitGameMode[];
 | 
			
		||||
    }
 | 
			
		||||
    worldState.Tmp = JSON.stringify(tmp);
 | 
			
		||||
 | 
			
		||||
    return worldState;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const populateFissures = async (worldState: IWorldState): Promise<void> => {
 | 
			
		||||
    if (config.worldState?.allTheFissures) {
 | 
			
		||||
        let i = 0;
 | 
			
		||||
        for (const [tier, nodes] of Object.entries(fissureMissions)) {
 | 
			
		||||
            for (const node of nodes) {
 | 
			
		||||
                const meta = ExportRegions[node];
 | 
			
		||||
                worldState.ActiveMissions.push({
 | 
			
		||||
                    _id: { $oid: (i++).toString().padStart(8, "0") + "8e0c70ba050f1eb7" },
 | 
			
		||||
                    Region: meta.systemIndex + 1,
 | 
			
		||||
                    Seed: 1337,
 | 
			
		||||
                    Activation: { $date: { $numberLong: "1000000000000" } },
 | 
			
		||||
                    Expiry: { $date: { $numberLong: "2000000000000" } },
 | 
			
		||||
                    Node: node,
 | 
			
		||||
                    MissionType: eMissionType[meta.missionIndex].tag,
 | 
			
		||||
                    Modifier: tier,
 | 
			
		||||
                    Hard: config.worldState.allTheFissures == "hard"
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    } else {
 | 
			
		||||
        const fissures = await Fissure.find({});
 | 
			
		||||
        for (const fissure of fissures) {
 | 
			
		||||
            const meta = ExportRegions[fissure.Node];
 | 
			
		||||
            worldState.ActiveMissions.push({
 | 
			
		||||
                _id: toOid(fissure._id),
 | 
			
		||||
                Region: meta.systemIndex + 1,
 | 
			
		||||
                Seed: 1337,
 | 
			
		||||
                Activation: toMongoDate(fissure.Activation),
 | 
			
		||||
                Expiry: toMongoDate(fissure.Expiry),
 | 
			
		||||
                Node: fissure.Node,
 | 
			
		||||
                MissionType: eMissionType[meta.missionIndex].tag,
 | 
			
		||||
                Modifier: fissure.Modifier,
 | 
			
		||||
                Hard: fissure.Hard
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const populateDailyDeal = async (worldState: IWorldState): Promise<void> => {
 | 
			
		||||
    const dailyDeals = await DailyDeal.find({});
 | 
			
		||||
    for (const dailyDeal of dailyDeals) {
 | 
			
		||||
        if (dailyDeal.Expiry.getTime() > Date.now()) {
 | 
			
		||||
            worldState.DailyDeals.push({
 | 
			
		||||
                StoreItem: dailyDeal.StoreItem,
 | 
			
		||||
                Activation: toMongoDate(dailyDeal.Activation),
 | 
			
		||||
                Expiry: toMongoDate(dailyDeal.Expiry),
 | 
			
		||||
                Discount: dailyDeal.Discount,
 | 
			
		||||
                OriginalPrice: dailyDeal.OriginalPrice,
 | 
			
		||||
                SalePrice: dailyDeal.SalePrice,
 | 
			
		||||
                AmountTotal: Math.round(dailyDeal.AmountTotal * (config.worldState?.darvoStockMultiplier ?? 1)),
 | 
			
		||||
                AmountSold: dailyDeal.AmountSold
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const idToBountyCycle = (id: string): number => {
 | 
			
		||||
    return Math.trunc((parseInt(id.substring(0, 8), 16) * 1000) / 9000_000);
 | 
			
		||||
};
 | 
			
		||||
@ -1364,3 +1708,92 @@ const nightwaveTagToSeason: Record<string, number> = {
 | 
			
		||||
    RadioLegionIntermissionSyndicate: 1, // Intermission I
 | 
			
		||||
    RadioLegionSyndicate: 0 // The Wolf of Saturn Six
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const updateFissures = async (): Promise<void> => {
 | 
			
		||||
    const fissures = await Fissure.find();
 | 
			
		||||
 | 
			
		||||
    const activeNodes = new Set<string>();
 | 
			
		||||
    const tierToFurthestExpiry: Record<string, number> = {
 | 
			
		||||
        VoidT1: 0,
 | 
			
		||||
        VoidT2: 0,
 | 
			
		||||
        VoidT3: 0,
 | 
			
		||||
        VoidT4: 0,
 | 
			
		||||
        VoidT5: 0,
 | 
			
		||||
        VoidT6: 0,
 | 
			
		||||
        VoidT1Hard: 0,
 | 
			
		||||
        VoidT2Hard: 0,
 | 
			
		||||
        VoidT3Hard: 0,
 | 
			
		||||
        VoidT4Hard: 0,
 | 
			
		||||
        VoidT5Hard: 0,
 | 
			
		||||
        VoidT6Hard: 0
 | 
			
		||||
    };
 | 
			
		||||
    for (const fissure of fissures) {
 | 
			
		||||
        activeNodes.add(fissure.Node);
 | 
			
		||||
 | 
			
		||||
        const key = fissure.Modifier + (fissure.Hard ? "Hard" : "");
 | 
			
		||||
        tierToFurthestExpiry[key] = Math.max(tierToFurthestExpiry[key], fissure.Expiry.getTime());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const deadline = Date.now() - 6 * unixTimesInMs.minute;
 | 
			
		||||
    for (const [tier, expiry] of Object.entries(tierToFurthestExpiry)) {
 | 
			
		||||
        if (expiry < deadline) {
 | 
			
		||||
            const numFissures = getRandomInt(1, 3);
 | 
			
		||||
            for (let i = 0; i != numFissures; ++i) {
 | 
			
		||||
                const modifier = tier.replace("Hard", "") as
 | 
			
		||||
                    | "VoidT1"
 | 
			
		||||
                    | "VoidT2"
 | 
			
		||||
                    | "VoidT3"
 | 
			
		||||
                    | "VoidT4"
 | 
			
		||||
                    | "VoidT5"
 | 
			
		||||
                    | "VoidT6";
 | 
			
		||||
                let node: string;
 | 
			
		||||
                do {
 | 
			
		||||
                    node = getRandomElement(fissureMissions[modifier])!;
 | 
			
		||||
                } while (activeNodes.has(node));
 | 
			
		||||
                activeNodes.add(node);
 | 
			
		||||
                await Fissure.insertOne({
 | 
			
		||||
                    Activation: new Date(),
 | 
			
		||||
                    Expiry: new Date(Date.now() + getRandomInt(60, 120) * unixTimesInMs.minute),
 | 
			
		||||
                    Node: node,
 | 
			
		||||
                    Modifier: modifier,
 | 
			
		||||
                    Hard: tier.indexOf("Hard") != -1 ? true : undefined
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const updateDailyDeal = async (): Promise<void> => {
 | 
			
		||||
    let darvoIndex = Math.trunc((Date.now() - 25200000) / (26 * unixTimesInMs.hour));
 | 
			
		||||
    let darvoEnd;
 | 
			
		||||
    do {
 | 
			
		||||
        const darvoStart = darvoIndex * (26 * unixTimesInMs.hour) + 25200000;
 | 
			
		||||
        darvoEnd = darvoStart + 26 * unixTimesInMs.hour;
 | 
			
		||||
        const darvoOid = ((darvoStart / 1000) & 0xffffffff).toString(16).padStart(8, "0") + "adc51a72f7324d95";
 | 
			
		||||
        if (!(await DailyDeal.findById(darvoOid))) {
 | 
			
		||||
            const seed = new SRng(darvoIndex).randomInt(0, 100_000);
 | 
			
		||||
            const rng = new SRng(seed);
 | 
			
		||||
            let deal;
 | 
			
		||||
            do {
 | 
			
		||||
                deal = rng.randomReward(darvoDeals)!; // Using an actual sampling collected over roughly a year because I can't extrapolate an algorithm from it with enough certainty.
 | 
			
		||||
                //const [storeItem, meta] = rng.randomElement(Object.entries(darvoDeals))!;
 | 
			
		||||
                //const discount = Math.min(rng.randomInt(1, 9) * 10, (meta as { MaxDiscount?: number }).MaxDiscount ?? 1);
 | 
			
		||||
            } while (await DailyDeal.exists({ StoreItem: deal.StoreItem }));
 | 
			
		||||
            await DailyDeal.insertOne({
 | 
			
		||||
                _id: darvoOid,
 | 
			
		||||
                StoreItem: deal.StoreItem,
 | 
			
		||||
                Activation: new Date(darvoStart),
 | 
			
		||||
                Expiry: new Date(darvoEnd),
 | 
			
		||||
                Discount: deal.Discount,
 | 
			
		||||
                OriginalPrice: deal.OriginalPrice,
 | 
			
		||||
                SalePrice: deal.SalePrice, //Math.trunc(deal.OriginalPrice * (1 - discount))
 | 
			
		||||
                AmountTotal: deal.AmountTotal,
 | 
			
		||||
                AmountSold: 0
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
    } while (darvoEnd < Date.now() + 6 * unixTimesInMs.minute && ++darvoIndex);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const updateWorldStateCollections = async (): Promise<void> => {
 | 
			
		||||
    await Promise.all([updateFissures(), updateDailyDeal()]);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -40,6 +40,7 @@ export interface IInventoryDatabase
 | 
			
		||||
            | "InfestedFoundry"
 | 
			
		||||
            | "DialogueHistory"
 | 
			
		||||
            | "KubrowPetEggs"
 | 
			
		||||
            | "KubrowPetPrints"
 | 
			
		||||
            | "PendingCoupon"
 | 
			
		||||
            | "Drones"
 | 
			
		||||
            | "RecentVendorPurchases"
 | 
			
		||||
@ -56,6 +57,7 @@ export interface IInventoryDatabase
 | 
			
		||||
            | "QualifyingInvasions"
 | 
			
		||||
            | "LastInventorySync"
 | 
			
		||||
            | "EndlessXP"
 | 
			
		||||
            | "PersonalGoalProgress"
 | 
			
		||||
            | TEquipmentKey
 | 
			
		||||
        >,
 | 
			
		||||
        InventoryDatabaseEquipment {
 | 
			
		||||
@ -63,7 +65,7 @@ export interface IInventoryDatabase
 | 
			
		||||
    Created: Date;
 | 
			
		||||
    TrainingDate: Date;
 | 
			
		||||
    LoadOutPresets: Types.ObjectId; // LoadOutPresets changed from ILoadOutPresets to Types.ObjectId for population
 | 
			
		||||
    Mailbox?: IMailboxDatabase;
 | 
			
		||||
    //Mailbox?: IMailboxDatabase;
 | 
			
		||||
    GuildId?: Types.ObjectId;
 | 
			
		||||
    PendingRecipes: IPendingRecipeDatabase[];
 | 
			
		||||
    QuestKeys: IQuestKeyDatabase[];
 | 
			
		||||
@ -78,7 +80,8 @@ export interface IInventoryDatabase
 | 
			
		||||
    KahlLoadOuts: IOperatorConfigDatabase[];
 | 
			
		||||
    InfestedFoundry?: IInfestedFoundryDatabase;
 | 
			
		||||
    DialogueHistory?: IDialogueHistoryDatabase;
 | 
			
		||||
    KubrowPetEggs?: IKubrowPetEggDatabase[];
 | 
			
		||||
    KubrowPetEggs: IKubrowPetEggDatabase[];
 | 
			
		||||
    KubrowPetPrints: IKubrowPetPrintDatabase[];
 | 
			
		||||
    PendingCoupon?: IPendingCouponDatabase;
 | 
			
		||||
    Drones: IDroneDatabase[];
 | 
			
		||||
    RecentVendorPurchases?: IRecentVendorPurchaseDatabase[];
 | 
			
		||||
@ -95,6 +98,7 @@ export interface IInventoryDatabase
 | 
			
		||||
    QualifyingInvasions: IInvasionProgressDatabase[];
 | 
			
		||||
    LastInventorySync?: Types.ObjectId;
 | 
			
		||||
    EndlessXP?: IEndlessXpProgressDatabase[];
 | 
			
		||||
    PersonalGoalProgress?: IPersonalGoalProgressDatabase[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IQuestKeyDatabase {
 | 
			
		||||
@ -150,9 +154,9 @@ export interface IMailboxClient {
 | 
			
		||||
    LastInboxId: IOid;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IMailboxDatabase {
 | 
			
		||||
/*export interface IMailboxDatabase {
 | 
			
		||||
    LastInboxId: Types.ObjectId;
 | 
			
		||||
}
 | 
			
		||||
}*/
 | 
			
		||||
 | 
			
		||||
export type TSolarMapRegion =
 | 
			
		||||
    | "Earth"
 | 
			
		||||
@ -234,7 +238,7 @@ export interface IInventoryClient extends IDailyAffiliations, InventoryClientEqu
 | 
			
		||||
    HandlerPoints: number;
 | 
			
		||||
    MiscItems: IMiscItem[];
 | 
			
		||||
    HasOwnedVoidProjectionsPreviously?: boolean;
 | 
			
		||||
    ChallengesFixVersion: number;
 | 
			
		||||
    ChallengesFixVersion?: number;
 | 
			
		||||
    ChallengeProgress: IChallengeProgress[];
 | 
			
		||||
    RawUpgrades: IRawUpgrade[];
 | 
			
		||||
    ReceivedStartingGear: boolean;
 | 
			
		||||
@ -285,6 +289,7 @@ export interface IInventoryClient extends IDailyAffiliations, InventoryClientEqu
 | 
			
		||||
    ArchwingEnabled?: boolean;
 | 
			
		||||
    PendingSpectreLoadouts?: ISpectreLoadout[];
 | 
			
		||||
    SpectreLoadouts?: ISpectreLoadout[];
 | 
			
		||||
    UsedDailyDeals: string[];
 | 
			
		||||
    EmailItems: ITypeCount[];
 | 
			
		||||
    CompletedSyndicates: string[];
 | 
			
		||||
    FocusXP?: IFocusXP;
 | 
			
		||||
@ -293,7 +298,7 @@ export interface IInventoryClient extends IDailyAffiliations, InventoryClientEqu
 | 
			
		||||
    CompletedSorties: string[];
 | 
			
		||||
    LastSortieReward?: ILastSortieRewardClient[];
 | 
			
		||||
    LastLiteSortieReward?: ILastSortieRewardClient[];
 | 
			
		||||
    SortieRewardAttenuation?: ISortieRewardAttenuation[];
 | 
			
		||||
    SortieRewardAttenuation?: IRewardAttenuation[];
 | 
			
		||||
    Drones: IDroneClient[];
 | 
			
		||||
    StepSequencers: IStepSequencer[];
 | 
			
		||||
    ActiveAvatarImageType?: string;
 | 
			
		||||
@ -304,9 +309,9 @@ export interface IInventoryClient extends IDailyAffiliations, InventoryClientEqu
 | 
			
		||||
    FocusUpgrades: IFocusUpgrade[];
 | 
			
		||||
    HasContributedToDojo?: boolean;
 | 
			
		||||
    HWIDProtectEnabled?: boolean;
 | 
			
		||||
    //KubrowPetPrints: IKubrowPetPrint[];
 | 
			
		||||
    KubrowPetPrints: IKubrowPetPrintClient[];
 | 
			
		||||
    AlignmentReplay?: IAlignment;
 | 
			
		||||
    //PersonalGoalProgress: IPersonalGoalProgress[];
 | 
			
		||||
    PersonalGoalProgress?: IPersonalGoalProgressClient[];
 | 
			
		||||
    ThemeStyle: string;
 | 
			
		||||
    ThemeBackground: string;
 | 
			
		||||
    ThemeSounds: string;
 | 
			
		||||
@ -349,7 +354,6 @@ export interface IInventoryClient extends IDailyAffiliations, InventoryClientEqu
 | 
			
		||||
    //LeagueTickets: any[];
 | 
			
		||||
    //Quests: any[];
 | 
			
		||||
    //Robotics: any[];
 | 
			
		||||
    //UsedDailyDeals: any[];
 | 
			
		||||
    LibraryPersonalTarget?: string;
 | 
			
		||||
    LibraryPersonalProgress: ILibraryPersonalProgress[];
 | 
			
		||||
    CollectibleSeries?: ICollectibleEntry[];
 | 
			
		||||
@ -378,6 +382,8 @@ export interface IInventoryClient extends IDailyAffiliations, InventoryClientEqu
 | 
			
		||||
    LockedWeaponGroup?: ILockedWeaponGroupClient;
 | 
			
		||||
    HubNpcCustomizations?: IHubNpcCustomization[];
 | 
			
		||||
    Ship?: IOrbiter; // U22 and below, response only
 | 
			
		||||
    ClaimedJunctionChallengeRewards?: string[]; // U39
 | 
			
		||||
    SpecialItemRewardAttenuation?: IRewardAttenuation[]; // Baro's Void Surplus
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IAffiliation {
 | 
			
		||||
@ -446,8 +452,9 @@ export interface IVendorPurchaseHistoryEntryDatabase {
 | 
			
		||||
 | 
			
		||||
export interface IChallengeProgress {
 | 
			
		||||
    Progress: number;
 | 
			
		||||
    Name: string;
 | 
			
		||||
    Completed?: string[];
 | 
			
		||||
    ReceivedJunctionReward?: boolean; // U39
 | 
			
		||||
    Name: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface ICollectibleEntry {
 | 
			
		||||
@ -641,11 +648,11 @@ export interface IFocusUpgrade {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IFocusXP {
 | 
			
		||||
    AP_POWER: number;
 | 
			
		||||
    AP_TACTIC: number;
 | 
			
		||||
    AP_DEFENSE: number;
 | 
			
		||||
    AP_ATTACK: number;
 | 
			
		||||
    AP_WARD: number;
 | 
			
		||||
    AP_POWER?: number;
 | 
			
		||||
    AP_TACTIC?: number;
 | 
			
		||||
    AP_DEFENSE?: number;
 | 
			
		||||
    AP_ATTACK?: number;
 | 
			
		||||
    AP_WARD?: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type TFocusPolarity = keyof IFocusXP;
 | 
			
		||||
@ -718,8 +725,8 @@ export interface IKubrowPetEggDatabase {
 | 
			
		||||
    _id: Types.ObjectId;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IKubrowPetPrint {
 | 
			
		||||
    ItemType: KubrowPetPrintItemType;
 | 
			
		||||
export interface IKubrowPetPrintClient {
 | 
			
		||||
    ItemType: "/Lotus/Types/Game/KubrowPet/ImprintedTraitPrint";
 | 
			
		||||
    Name: string;
 | 
			
		||||
    IsMale: boolean;
 | 
			
		||||
    Size: number; // seems to be 0.7 to 1.0
 | 
			
		||||
@ -729,6 +736,10 @@ export interface IKubrowPetPrint {
 | 
			
		||||
    InheritedModularParts?: any[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IKubrowPetPrintDatabase extends Omit<IKubrowPetPrintClient, "ItemId" | "InheritedModularParts"> {
 | 
			
		||||
    _id: Types.ObjectId;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface ITraits {
 | 
			
		||||
    BaseColor: string;
 | 
			
		||||
    SecondaryColor: string;
 | 
			
		||||
@ -742,15 +753,11 @@ export interface ITraits {
 | 
			
		||||
    Tail?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export enum KubrowPetPrintItemType {
 | 
			
		||||
    LotusTypesGameKubrowPetImprintedTraitPrint = "/Lotus/Types/Game/KubrowPet/ImprintedTraitPrint"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IKubrowPetDetailsDatabase {
 | 
			
		||||
    Name?: string;
 | 
			
		||||
    IsPuppy?: boolean;
 | 
			
		||||
    HasCollar: boolean;
 | 
			
		||||
    PrintsRemaining?: number;
 | 
			
		||||
    PrintsRemaining: number;
 | 
			
		||||
    Status: Status;
 | 
			
		||||
    HatchDate?: Date;
 | 
			
		||||
    DominantTraits: ITraits;
 | 
			
		||||
@ -779,7 +786,7 @@ export interface ILastSortieRewardDatabase extends Omit<ILastSortieRewardClient,
 | 
			
		||||
    SortieId: Types.ObjectId;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface ISortieRewardAttenuation {
 | 
			
		||||
export interface IRewardAttenuation {
 | 
			
		||||
    Tag: string;
 | 
			
		||||
    Atten: number;
 | 
			
		||||
}
 | 
			
		||||
@ -1015,13 +1022,17 @@ export interface IPeriodicMissionCompletionResponse extends Omit<IPeriodicMissio
 | 
			
		||||
    date: IMongoDate;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IPersonalGoalProgress {
 | 
			
		||||
export interface IPersonalGoalProgressClient {
 | 
			
		||||
    Best: number;
 | 
			
		||||
    Count: number;
 | 
			
		||||
    Tag: string;
 | 
			
		||||
    Best?: number;
 | 
			
		||||
    _id: IOid;
 | 
			
		||||
    ReceivedClanReward0?: boolean;
 | 
			
		||||
    ReceivedClanReward1?: boolean;
 | 
			
		||||
    //ReceivedClanReward0?: boolean;
 | 
			
		||||
    //ReceivedClanReward1?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IPersonalGoalProgressDatabase extends Omit<IPersonalGoalProgressClient, "_id"> {
 | 
			
		||||
    goalId: Types.ObjectId;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IPersonalTechProjectDatabase {
 | 
			
		||||
 | 
			
		||||
@ -2,7 +2,7 @@ import { Types } from "mongoose";
 | 
			
		||||
 | 
			
		||||
export interface IAccountAndLoginResponseCommons {
 | 
			
		||||
    DisplayName: string;
 | 
			
		||||
    CountryCode: string;
 | 
			
		||||
    CountryCode?: string;
 | 
			
		||||
    ClientType?: string;
 | 
			
		||||
    CrossPlatformAllowed?: boolean;
 | 
			
		||||
    ForceLogoutVersion?: number;
 | 
			
		||||
@ -25,6 +25,7 @@ export interface IDatabaseAccount extends IDatabaseAccountRequiredFields {
 | 
			
		||||
    LatestEventMessageDate: Date;
 | 
			
		||||
    LastLoginRewardDate: number;
 | 
			
		||||
    LoginDays: number;
 | 
			
		||||
    DailyFirstWinDate: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Includes virtual ID
 | 
			
		||||
 | 
			
		||||
@ -17,9 +17,9 @@ export interface IMissionReward {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IMissionCredits {
 | 
			
		||||
    MissionCredits: number[];
 | 
			
		||||
    CreditBonus: number[];
 | 
			
		||||
    TotalCredits: number[];
 | 
			
		||||
    MissionCredits: [number, number];
 | 
			
		||||
    CreditsBonus: [number, number]; // "Credit Reward"; `CreditsBonus[1]` is `CreditsBonus[0] * 2` if DailyMissionBonus
 | 
			
		||||
    TotalCredits: [number, number];
 | 
			
		||||
    DailyMissionBonus?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -7,17 +7,46 @@ import {
 | 
			
		||||
    ITypeCount,
 | 
			
		||||
    IRecentVendorPurchaseClient,
 | 
			
		||||
    TEquipmentKey,
 | 
			
		||||
    ICrewMemberClient
 | 
			
		||||
    ICrewMemberClient,
 | 
			
		||||
    IKubrowPetPrintClient
 | 
			
		||||
} from "./inventoryTypes/inventoryTypes";
 | 
			
		||||
 | 
			
		||||
export enum PurchaseSource {
 | 
			
		||||
    Market = 0,
 | 
			
		||||
    VoidTrader = 1,
 | 
			
		||||
    SyndicateFavor = 2,
 | 
			
		||||
    DailyDeal = 3,
 | 
			
		||||
    Arsenal = 4,
 | 
			
		||||
    Profile = 5,
 | 
			
		||||
    Hub = 6,
 | 
			
		||||
    Vendor = 7,
 | 
			
		||||
    AppearancePreview = 8,
 | 
			
		||||
    Museum = 9,
 | 
			
		||||
    Operator = 10,
 | 
			
		||||
    PlayerShip = 11,
 | 
			
		||||
    Crewship = 12,
 | 
			
		||||
    MenuStyle = 13,
 | 
			
		||||
    MenuHud = 14,
 | 
			
		||||
    Chat = 15,
 | 
			
		||||
    Inventory = 16,
 | 
			
		||||
    StarChart = 17,
 | 
			
		||||
    PrimeVaultTrader = 18,
 | 
			
		||||
    Incubator = 19,
 | 
			
		||||
    Prompt = 20,
 | 
			
		||||
    Kaithe = 21,
 | 
			
		||||
    DuviriWeapon = 22,
 | 
			
		||||
    UpdateScreen = 23,
 | 
			
		||||
    Motorcycle = 24
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IPurchaseRequest {
 | 
			
		||||
    PurchaseParams: IPurchaseParams;
 | 
			
		||||
    buildLabel: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IPurchaseParams {
 | 
			
		||||
    Source: number;
 | 
			
		||||
    SourceId?: string; // for Source 1, 7 & 18
 | 
			
		||||
    Source: PurchaseSource;
 | 
			
		||||
    SourceId?: string; // VoidTrader, Vendor, PrimeVaultTrader
 | 
			
		||||
    StoreItem: string;
 | 
			
		||||
    StorePage: string;
 | 
			
		||||
    SearchTerm: string;
 | 
			
		||||
@ -25,10 +54,10 @@ export interface IPurchaseParams {
 | 
			
		||||
    Quantity: number;
 | 
			
		||||
    UsePremium: boolean;
 | 
			
		||||
    ExpectedPrice: number;
 | 
			
		||||
    SyndicateTag?: string; // for Source 2
 | 
			
		||||
    UseFreeFavor?: boolean; // for Source 2
 | 
			
		||||
    ExtraPurchaseInfoJson?: string; // for Source 7
 | 
			
		||||
    IsWeekly?: boolean; // for Source 7
 | 
			
		||||
    SyndicateTag?: string; // SyndicateFavor
 | 
			
		||||
    UseFreeFavor?: boolean; // SyndicateFavor
 | 
			
		||||
    ExtraPurchaseInfoJson?: string; // Vendor
 | 
			
		||||
    IsWeekly?: boolean; // Vendor
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type IInventoryChanges = {
 | 
			
		||||
@ -50,6 +79,7 @@ export type IInventoryChanges = {
 | 
			
		||||
    NewVendorPurchase?: IRecentVendorPurchaseClient; // >= 38.5.0
 | 
			
		||||
    RecentVendorPurchases?: IRecentVendorPurchaseClient; // < 38.5.0
 | 
			
		||||
    CrewMembers?: ICrewMemberClient[];
 | 
			
		||||
    KubrowPetPrints?: IKubrowPetPrintClient[];
 | 
			
		||||
} & Record<
 | 
			
		||||
        Exclude<
 | 
			
		||||
            string,
 | 
			
		||||
@ -77,6 +107,7 @@ export interface IPurchaseResponse {
 | 
			
		||||
    Standing?: IAffiliationMods[];
 | 
			
		||||
    FreeFavorsUsed?: IAffiliationMods[];
 | 
			
		||||
    BoosterPackItems?: string;
 | 
			
		||||
    DailyDealUsed?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type IBinChanges = {
 | 
			
		||||
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user