Compare commits

..

15 Commits

Author SHA1 Message Date
8659500408 add description for revive booster
All checks were successful
Build / build (pull_request) Successful in 4m23s
2025-11-16 04:41:44 +01:00
5688b1725f feat(webui): revive booster 2025-11-16 04:41:43 +01:00
2eb7f7ed11 fix: use polling for chokidar when running under docker (#3033)
All checks were successful
Build / build (push) Successful in 1m9s
Build Docker image / docker (push) Successful in 2m48s
Closes #3031

Reviewed-on: #3033
Co-authored-by: Sainan <63328889+Sainan@users.noreply.github.com>
Co-committed-by: Sainan <63328889+Sainan@users.noreply.github.com>
2025-11-15 00:26:32 -08:00
f3a6f60222 chore: remove networking config options when running under docker (#3032)
Some checks failed
Build / build (push) Has been cancelled
Build Docker image / docker (push) Has been cancelled
This should make it hopefully ever so slightly less confusing.

Reviewed-on: #3032
Co-authored-by: Sainan <63328889+Sainan@users.noreply.github.com>
Co-committed-by: Sainan <63328889+Sainan@users.noreply.github.com>
2025-11-15 00:26:23 -08:00
55d051ff37 fix: item count validation when starting a recipe (#3030)
Some checks failed
Build Docker image / docker (push) Has been cancelled
Build / build (push) Has been cancelled
Can't assume it's all MiscItems. Instead upgraded the warnings to errors in the inventoryService functions.

Reviewed-on: #3030
Co-authored-by: Sainan <63328889+Sainan@users.noreply.github.com>
Co-committed-by: Sainan <63328889+Sainan@users.noreply.github.com>
2025-11-15 00:26:14 -08:00
9f94a17eda chore: add tunables to config-vanilla.json (#3028)
Some checks failed
Build Docker image / docker (push) Has been cancelled
Build / build (push) Has been cancelled
Kinda forgor about this file when adding these options.

Reviewed-on: #3028
Co-authored-by: Sainan <63328889+Sainan@users.noreply.github.com>
Co-committed-by: Sainan <63328889+Sainan@users.noreply.github.com>
2025-11-15 00:26:06 -08:00
711eb7ac47 fix: Shrine Defense node not showing up despite unlocking all missions via cheat (#3027)
All checks were successful
Build / build (push) Successful in 1m12s
Build Docker image / docker (push) Successful in 2m49s
Reviewed-on: #3027
Reviewed-by: Sainan <63328889+sainan@users.noreply.github.com>
Co-authored-by: Animan8000 <animan8000@noreply.localhost>
Co-committed-by: Animan8000 <animan8000@noreply.localhost>
2025-11-14 01:50:33 -08:00
90ffd8948b fix: claiming recipes in U22.20-U24.1 + disable rush cost scaling for builds older than U18 (#3024)
Some checks failed
Build / build (push) Has started running
Build Docker image / docker (push) Has been cancelled
With this, the Foundry should be fully functional in all game versions now (excluding incorrect recipe data for things that got changed over the years).
This also disables rush cost scaling for versions older than U18, as U18 is the version that introduced it.

Reviewed-on: #3024
Reviewed-by: Sainan <63328889+sainan@users.noreply.github.com>
Co-authored-by: VoltPrime <subsonicjackal@gmail.com>
Co-committed-by: VoltPrime <subsonicjackal@gmail.com>
2025-11-14 01:50:19 -08:00
8a0f99f573 fix: selling items in old builds (#3023)
Some checks failed
Build / build (push) Has been cancelled
Build Docker image / docker (push) Has been cancelled
I'm not sure exactly which build the sell request changed in, but this solution seems to be catch-all.

Reviewed-on: #3023
Reviewed-by: Sainan <63328889+sainan@users.noreply.github.com>
Co-authored-by: VoltPrime <subsonicjackal@gmail.com>
Co-committed-by: VoltPrime <subsonicjackal@gmail.com>
2025-11-14 01:50:03 -08:00
55e2871531 fix: give ChallengeProgress with unlockAllScans cheat (#3020)
Some checks failed
Build / build (push) Has been cancelled
Build Docker image / docker (push) Has been cancelled
Closes #3013

Co-authored-by: Sainan <63328889+Sainan@users.noreply.github.com>
Reviewed-on: #3020
Reviewed-by: Sainan <63328889+sainan@users.noreply.github.com>
Co-authored-by: AMelonInsideLemon <166175391+AMelonInsideLemon@users.noreply.github.com>
Co-committed-by: AMelonInsideLemon <166175391+AMelonInsideLemon@users.noreply.github.com>
2025-11-14 01:49:48 -08:00
8088044ec8 feat(webui): echoes of umbra (#3019)
All checks were successful
Build / build (push) Successful in 2m2s
Build Docker image / docker (push) Successful in 1m45s
Reviewed-on: #3019
Reviewed-by: Sainan <63328889+sainan@users.noreply.github.com>
Co-authored-by: AMelonInsideLemon <166175391+AMelonInsideLemon@users.noreply.github.com>
Co-committed-by: AMelonInsideLemon <166175391+AMelonInsideLemon@users.noreply.github.com>
2025-11-11 23:26:01 -08:00
6167eeadb0 feat: view leaderboard U8 (#3017)
Some checks failed
Build / build (push) Has been cancelled
Build Docker image / docker (push) Has been cancelled
Reviewed-on: #3017
Reviewed-by: Sainan <63328889+sainan@users.noreply.github.com>
Co-authored-by: AMelonInsideLemon <166175391+AMelonInsideLemon@users.noreply.github.com>
Co-committed-by: AMelonInsideLemon <166175391+AMelonInsideLemon@users.noreply.github.com>
2025-11-11 23:25:40 -08:00
9c1ba17537 chore: automatically use nora's mix 6 & 7 in respective versions (#3016)
All checks were successful
Build / build (push) Successful in 1m18s
Build Docker image / docker (push) Successful in 2m45s
Reviewed-on: #3016
Co-authored-by: Sainan <63328889+Sainan@users.noreply.github.com>
Co-committed-by: Sainan <63328889+Sainan@users.noreply.github.com>
2025-11-10 23:51:13 -08:00
85a446f67f fix: acquisition of new KubrowEgg item (#3015)
Some checks failed
Build / build (push) Has been cancelled
Build Docker image / docker (push) Has been cancelled
Reviewed-on: #3015
Co-authored-by: Sainan <63328889+Sainan@users.noreply.github.com>
Co-committed-by: Sainan <63328889+Sainan@users.noreply.github.com>
2025-11-10 23:50:59 -08:00
48b7138c1c feat: initial foundry for U8 (#3014)
Some checks failed
Build Docker image / docker (push) Has been cancelled
Build / build (push) Has been cancelled
endpoints should work, but we don't have data for required recipe items for U8, so in most cases, an unknown error will occur in the game, and a more detailed error will occur in the server console.

Reviewed-on: #3014
Reviewed-by: Sainan <63328889+sainan@users.noreply.github.com>
Co-authored-by: AMelonInsideLemon <166175391+AMelonInsideLemon@users.noreply.github.com>
Co-committed-by: AMelonInsideLemon <166175391+AMelonInsideLemon@users.noreply.github.com>
2025-11-10 23:50:51 -08:00
28 changed files with 314 additions and 54 deletions

View File

@@ -14,6 +14,7 @@ SpaceNinjaServer requires a `config.json`. To set it up, you can copy the [confi
- `skipTutorial` affects only newly created accounts, so you may wish to change it before logging in for the first time.
- `logger.level` can be `fatal`, `error`, `warn`, `info`, `http`, `debug`, or `trace`.
- `bindAddress`, `httpPort`, `httpsPort` are related to how SpaceNinjaServer is reached on the network. Under Docker, these options are unchangable; modify your `docker-compose.yml`, instead.
- `ircExecutable` can be provided with a relative path to an EXE which will be ran as a child process of SpaceNinjaServer.
- `ircAddress`, `hubAddress`, and `nrsAddress` can be provided if these secondary servers are on a different machine.
- `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.

View File

@@ -68,6 +68,14 @@
"circuitGameModes": null,
"darvoStockMultiplier": 1
},
"tunables": {
"useLoginToken": false,
"prohibitSkipMissionStartTimer": false,
"prohibitFovOverride": false,
"prohibitFreecam": false,
"prohibitTeleport": false,
"prohibitScripts": false
},
"dev": {
"keepVendorsExpired": false
}

View File

@@ -7,13 +7,16 @@ services:
- ./docker-data/static-data:/app/static/data
- ./docker-data/logs:/app/logs
ports:
# The lefthand value determines the port you actually connect to. Within the container, SpaceNinjaServer will always use 80 and 443 (righthand values).
- 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`.
# Works best when using `docker compose up --force-recreate --build`.
environment:
- CHOKIDAR_USEPOLLING=true
depends_on:
- mongodb
mongodb:

View File

@@ -2,7 +2,7 @@
set -e
if [ ! -f conf/config.json ]; then
jq --arg value "mongodb://mongodb:27017/openWF" '.mongodbUrl = $value' /app/config-vanilla.json > /app/conf/config.json
jq --arg value "mongodb://mongodb:27017/openWF" '.mongodbUrl = $value | del(.bindAddress) | del(.httpPort) | del(.httpsPort)' /app/config-vanilla.json > /app/conf/config.json
fi
exec npm run raw -- --configPath conf/config.json
exec npm run raw -- --configPath conf/config.json --docker

View File

@@ -0,0 +1,24 @@
import { getAccountIdForRequest } from "../../services/loginService.ts";
import type { RequestHandler } from "express";
import { getInventory } from "../../services/inventoryService.ts";
export const checkPendingRecipesController: RequestHandler = async (req, res) => {
const accountId = await getAccountIdForRequest(req);
const inventory = await getInventory(accountId, "PendingRecipes");
const now = Date.now();
const resp: ICheckPendingRecipesResponse = {
PendingRecipes: inventory.PendingRecipes.map(recipe => ({
ItemType: recipe.ItemType,
SecondsRemaining: Math.max(0, Math.floor((recipe.CompletionDate.getTime() - now) / 1000))
}))
};
res.send(resp);
};
interface ICheckPendingRecipesResponse {
PendingRecipes: {
ItemType: string;
SecondsRemaining: number;
}[];
}

View File

@@ -22,14 +22,15 @@ import {
import type { IInventoryChanges } from "../../types/purchaseTypes.ts";
import type { IPendingRecipeDatabase } from "../../types/inventoryTypes/inventoryTypes.ts";
import { InventorySlot } from "../../types/inventoryTypes/inventoryTypes.ts";
import { fromOid, toOid2 } from "../../helpers/inventoryHelpers.ts";
import { fromOid, toOid2, version_compare } from "../../helpers/inventoryHelpers.ts";
import type { TInventoryDatabaseDocument } from "../../models/inventoryModels/inventoryModel.ts";
import type { IRecipe } from "warframe-public-export-plus";
import type { IEquipmentClient } from "../../types/equipmentTypes.ts";
import { EquipmentFeatures, Status } from "../../types/equipmentTypes.ts";
interface IClaimCompletedRecipeRequest {
RecipeIds: IOidWithLegacySupport[];
RecipeIds?: IOidWithLegacySupport[]; // U24.4 and beyond
recipeNames?: string[]; // Builds before U24.4 down to U22.20
}
interface IClaimCompletedRecipeResponse {
@@ -38,22 +39,60 @@ interface IClaimCompletedRecipeResponse {
}
export const claimCompletedRecipeController: RequestHandler = async (req, res) => {
const claimCompletedRecipeRequest = getJSONfromString<IClaimCompletedRecipeRequest>(String(req.body));
const account = await getAccountForRequest(req);
const inventory = await getInventory(account._id.toString());
const resp: IClaimCompletedRecipeResponse = {
InventoryChanges: {}
};
for (const recipeId of claimCompletedRecipeRequest.RecipeIds) {
const pendingRecipe = inventory.PendingRecipes.id(fromOid(recipeId));
if (!pendingRecipe) {
throw new Error(`no pending recipe found with id ${fromOid(recipeId)}`);
}
if (!req.query.recipeName) {
const claimCompletedRecipeRequest = getJSONfromString<IClaimCompletedRecipeRequest>(String(req.body));
const recipes = claimCompletedRecipeRequest.recipeNames ?? claimCompletedRecipeRequest.RecipeIds;
if (recipes) {
for (const recipeId of recipes) {
let pendingRecipe;
if (typeof recipeId === "string") {
pendingRecipe = inventory.PendingRecipes.find(r => r.ItemType == recipeId);
if (!pendingRecipe) {
throw new Error(`no pending recipe found of type ${recipeId}`);
}
} else {
pendingRecipe = inventory.PendingRecipes.id(fromOid(recipeId));
if (!pendingRecipe) {
throw new Error(`no pending recipe found with id ${fromOid(recipeId)}`);
}
}
//check recipe is indeed ready to be completed
// if (pendingRecipe.CompletionDate > new Date()) {
// throw new Error(`recipe ${pendingRecipe._id} is not ready to be completed`);
// }
//check recipe is indeed ready to be completed
// if (pendingRecipe.CompletionDate > new Date()) {
// throw new Error(`recipe ${pendingRecipe._id} is not ready to be completed`);
// }
inventory.PendingRecipes.pull(pendingRecipe._id);
const recipe = getRecipe(pendingRecipe.ItemType);
if (!recipe) {
throw new Error(`no completed item found for recipe ${pendingRecipe._id.toString()}`);
}
if (req.query.cancel) {
const inventoryChanges: IInventoryChanges = {};
await refundRecipeIngredients(inventory, inventoryChanges, recipe, pendingRecipe);
await inventory.save();
res.json(inventoryChanges); // Not a bug: In the specific case of cancelling a recipe, InventoryChanges are expected to be the root.
return;
}
await claimCompletedRecipe(account, inventory, recipe, pendingRecipe, resp, req.query.rush);
}
} else {
throw new Error(`recipe list from request was undefined?`);
}
} else {
const recipeName = String(req.query.recipeName); // U8
const pendingRecipe = inventory.PendingRecipes.find(r => r.ItemType == recipeName);
if (!pendingRecipe) {
throw new Error(`no pending recipe found with ItemType ${recipeName}`);
}
inventory.PendingRecipes.pull(pendingRecipe._id);
@@ -61,16 +100,14 @@ export const claimCompletedRecipeController: RequestHandler = async (req, res) =
if (!recipe) {
throw new Error(`no completed item found for recipe ${pendingRecipe._id.toString()}`);
}
if (req.query.cancel) {
const inventoryChanges: IInventoryChanges = {};
await refundRecipeIngredients(inventory, inventoryChanges, recipe, pendingRecipe);
await inventory.save();
res.json(inventoryChanges); // Not a bug: In the specific case of cancelling a recipe, InventoryChanges are expected to be the root.
return;
}
await claimCompletedRecipe(account, inventory, recipe, pendingRecipe, resp, req.query.rush);
await claimCompletedRecipe(
account,
inventory,
recipe,
pendingRecipe,
resp,
req.path.includes("instantCompleteRecipe.php") || req.query.rush
);
}
await inventory.save();
res.json(resp);
@@ -121,13 +158,20 @@ const claimCompletedRecipe = async (
}
if (rush) {
let cost = recipe.skipBuildTimePrice;
const end = Math.trunc(pendingRecipe.CompletionDate.getTime() / 1000);
const start = end - recipe.buildTime;
const secondsElapsed = Math.trunc(Date.now() / 1000) - start;
const progress = secondsElapsed / recipe.buildTime;
logger.debug(`rushing recipe at ${Math.trunc(progress * 100)}% completion`);
const cost =
progress > 0.5 ? Math.round(recipe.skipBuildTimePrice * (1 - (progress - 0.5))) : recipe.skipBuildTimePrice;
// U18 introduced rush cost scaling, don't use it for older versions.
if (account.BuildLabel && version_compare(account.BuildLabel, "2015.12.03.00.00") >= 0) {
// Haven't found the real build label for U18.0.0 yet, but this works
cost =
progress > 0.5
? Math.round(recipe.skipBuildTimePrice * (1 - (progress - 0.5)))
: recipe.skipBuildTimePrice;
}
combineInventoryChanges(resp.InventoryChanges, updateCurrency(inventory, cost, true));
}

View File

@@ -25,11 +25,24 @@ export const sellController: RequestHandler = async (req, res) => {
//console.log(JSON.stringify(payload, null, 2));
const accountId = await getAccountIdForRequest(req);
const requiredFields = new Set<keyof TInventoryDatabaseDocument>();
if (payload.SellCurrency == "SC_RegularCredits") {
let sellCurrency = "SC_RegularCredits";
if (payload.SellCurrency) {
sellCurrency = payload.SellCurrency;
} else {
if (payload.SellForFusionPoints || payload.SellForPrimeBucks) {
if (payload.SellForFusionPoints) {
sellCurrency = "SC_FusionPoints";
}
if (payload.SellForPrimeBucks) {
sellCurrency = "SC_PrimeBucks";
}
}
}
if (sellCurrency == "SC_RegularCredits") {
requiredFields.add("RegularCredits");
} else if (payload.SellCurrency == "SC_FusionPoints") {
} else if (sellCurrency == "SC_FusionPoints") {
requiredFields.add("FusionPoints");
} else if (payload.SellCurrency == "SC_CrewShipFusionPoints") {
} else if (sellCurrency == "SC_CrewShipFusionPoints") {
requiredFields.add("CrewShipFusionPoints");
} else {
requiredFields.add("MiscItems");
@@ -83,27 +96,27 @@ export const sellController: RequestHandler = async (req, res) => {
const inventory = await getInventory(accountId, Array.from(requiredFields).join(" "));
// Give currency
if (payload.SellCurrency == "SC_RegularCredits") {
if (sellCurrency == "SC_RegularCredits") {
inventory.RegularCredits += payload.SellPrice;
} else if (payload.SellCurrency == "SC_FusionPoints") {
} else if (sellCurrency == "SC_FusionPoints") {
addFusionPoints(inventory, payload.SellPrice);
} else if (payload.SellCurrency == "SC_CrewShipFusionPoints") {
} else if (sellCurrency == "SC_CrewShipFusionPoints") {
addCrewShipFusionPoints(inventory, payload.SellPrice);
} else if (payload.SellCurrency == "SC_PrimeBucks") {
} else if (sellCurrency == "SC_PrimeBucks") {
addMiscItems(inventory, [
{
ItemType: "/Lotus/Types/Items/MiscItems/PrimeBucks",
ItemCount: payload.SellPrice
}
]);
} else if (payload.SellCurrency == "SC_DistillPoints") {
} else if (sellCurrency == "SC_DistillPoints") {
addMiscItems(inventory, [
{
ItemType: "/Lotus/Types/Items/MiscItems/DistillPoints",
ItemCount: payload.SellPrice
}
]);
} else if (payload.SellCurrency == "SC_Resources") {
} else if (sellCurrency == "SC_Resources") {
// Will add appropriate MiscItems from CrewShipWeapons or CrewShipWeaponSkins
} else {
throw new Error("Unknown SellCurrency: " + payload.SellCurrency);
@@ -218,7 +231,7 @@ export const sellController: RequestHandler = async (req, res) => {
} else {
const index = inventory.CrewShipWeapons.findIndex(x => x._id.equals(sellItem.String));
if (index != -1) {
if (payload.SellCurrency == "SC_Resources") {
if (sellCurrency == "SC_Resources") {
refundPartialBuildCosts(inventory, inventory.CrewShipWeapons[index].ItemType, inventoryChanges);
}
inventory.CrewShipWeapons.splice(index, 1);
@@ -241,7 +254,7 @@ export const sellController: RequestHandler = async (req, res) => {
} else {
const index = inventory.CrewShipWeaponSkins.findIndex(x => x._id.equals(sellItem.String));
if (index != -1) {
if (payload.SellCurrency == "SC_Resources") {
if (sellCurrency == "SC_Resources") {
refundPartialBuildCosts(
inventory,
inventory.CrewShipWeaponSkins[index].ItemType,
@@ -346,7 +359,7 @@ interface ISellRequest {
WeaponSkins?: ISellItem[]; // SNS specific field
};
SellPrice: number;
SellCurrency:
SellCurrency?:
| "SC_RegularCredits"
| "SC_PrimeBucks"
| "SC_FusionPoints"
@@ -355,6 +368,9 @@ interface ISellRequest {
| "SC_Resources"
| "somethingelsewemightnotknowabout";
buildLabel: string;
// These are used in old builds (undetermined where it changed) instead of SellCurrency
SellForPrimeBucks?: boolean;
SellForFusionPoints?: boolean;
}
interface ISellItem {

View File

@@ -24,7 +24,8 @@ export const startRecipeController: RequestHandler = async (req, res) => {
const accountId = await getAccountIdForRequest(req);
const recipeName = startRecipeRequest.RecipeName;
let recipeName = startRecipeRequest.RecipeName;
if (req.query.recipeName) recipeName = String(req.query.recipeName); // U8
const recipe = getRecipe(recipeName);
if (!recipe) {
@@ -68,7 +69,9 @@ export const startRecipeController: RequestHandler = async (req, res) => {
freeUpSlot(inventory, InventorySlot.WEAPONS);
}
} else {
await addItem(inventory, recipe.ingredients[i].ItemType, recipe.ingredients[i].ItemCount * -1);
const itemType = recipe.ingredients[i].ItemType;
const itemCount = recipe.ingredients[i].ItemCount;
await addItem(inventory, itemType, itemCount * -1);
}
}

View File

@@ -35,6 +35,7 @@ export const completeAllMissionsController: RequestHandler = async (req, res) =>
await handleStoreItemAcquisition(reward.StoreItem, inventory, reward.ItemCount, undefined, true);
}
addString(inventory.NodeIntrosCompleted, "TeshinHardModeUnlocked");
addString(inventory.NodeIntrosCompleted, "CetusInvasionNodeIntro");
addString(inventory.NodeIntrosCompleted, "CetusSyndicate_IntroJob");
let syndicate = inventory.Affiliations.find(x => x.Tag == "CetusSyndicate");
if (!syndicate) {

View File

@@ -0,0 +1,22 @@
import { getAccountIdForRequest } from "../../services/loginService.ts";
import { getInventory } from "../../services/inventoryService.ts";
import type { RequestHandler } from "express";
import { broadcastInventoryUpdate } from "../../services/wsService.ts";
export const setUmbraEchoesController: RequestHandler = async (req, res) => {
const accountId = await getAccountIdForRequest(req);
const request = req.body as ISetUmbraEchoesRequest;
const inventory = await getInventory(accountId, "Suits");
const suit = inventory.Suits.id(request.oid);
if (suit) {
suit.UmbraDate = request.UmbraDate ? new Date(request.UmbraDate) : undefined;
await inventory.save();
broadcastInventoryUpdate(req);
}
res.end();
};
interface ISetUmbraEchoesRequest {
oid: string;
UmbraDate: number;
}

View File

@@ -3,10 +3,11 @@ import allScans from "../../../static/fixed_responses/allScans.json" with { type
import { ExportEnemies } from "warframe-public-export-plus";
import { getAccountIdForRequest } from "../../services/loginService.ts";
import { getStats } from "../../services/statsService.ts";
import { getInventory } from "../../services/inventoryService.ts";
export const unlockAllScansController: RequestHandler = async (req, res) => {
const accountId = await getAccountIdForRequest(req);
const stats = await getStats(accountId);
const [stats, inventory] = await Promise.all([getStats(accountId), getInventory(accountId, "ChallengeProgress")]);
const scanTypes = new Set<string>(allScans);
for (const type of Object.keys(ExportEnemies.avatars)) {
@@ -18,6 +19,17 @@ export const unlockAllScansController: RequestHandler = async (req, res) => {
stats.Scans.push({ type, scans: 9999 });
}
await stats.save();
const jsCodex = inventory.ChallengeProgress.find(x => x.Name === "JSCodexScan");
if (jsCodex) {
jsCodex.Progress = 1;
} else {
inventory.ChallengeProgress.push({
Name: "JSCodexScan",
Progress: 1
});
}
await Promise.all([stats.save(), inventory.save()]);
res.end();
};

View File

@@ -1,7 +1,7 @@
import { getLeaderboard } from "../../services/leaderboardService.ts";
import type { RequestHandler } from "express";
export const leaderboardController: RequestHandler = async (req, res) => {
export const leaderboardPostController: RequestHandler = async (req, res) => {
const payload = JSON.parse(String(req.body)) as ILeaderboardRequest;
res.json({
results: await getLeaderboard(
@@ -15,6 +15,33 @@ export const leaderboardController: RequestHandler = async (req, res) => {
});
};
export const leaderboardGetController: RequestHandler = async (req, res) => {
const payload: ILeaderboardRequest = {
field: "archived." + String(req.query.field),
before: Number(req.query.before),
after: Number(req.query.after),
pivotId: req.query.pivotAccountId ? String(req.query.pivotAccountId) : undefined,
guildId: undefined,
guildTier: undefined
};
res.json({
players: (
await getLeaderboard(
payload.field,
payload.before,
payload.after,
payload.pivotId,
payload.guildId,
payload.guildTier
)
).map(entry => ({
DisplayName: entry.n,
score: entry.s,
rank: entry.r
}))
});
};
interface ILeaderboardRequest {
field: string;
before: number;

View File

@@ -2,6 +2,7 @@ interface IArguments {
configPath?: string;
dev?: boolean;
secret?: string;
docker?: boolean;
}
export const args: IArguments = {};
@@ -19,5 +20,9 @@ for (let i = 2; i < process.argv.length; ) {
case "--secret":
args.secret = process.argv[i++];
break;
case "--docker":
args.docker = true;
break;
}
}

View File

@@ -20,6 +20,7 @@ import { cancelGuildAdvertisementController } from "../controllers/api/cancelGui
import { changeDojoRootController } from "../controllers/api/changeDojoRootController.ts";
import { changeGuildRankController } from "../controllers/api/changeGuildRankController.ts";
import { checkDailyMissionBonusController } from "../controllers/api/checkDailyMissionBonusController.ts";
import { checkPendingRecipesController } from "../controllers/api/checkPendingRecipesController.ts";
import { claimCompletedRecipeController } from "../controllers/api/claimCompletedRecipeController.ts";
import { claimJunctionChallengeRewardController } from "../controllers/api/claimJunctionChallengeRewardController.ts";
import { claimLibraryDailyTaskRewardController } from "../controllers/api/claimLibraryDailyTaskRewardController.ts";
@@ -184,6 +185,7 @@ apiRouter.get("/cancelGuildAdvertisement.php", cancelGuildAdvertisementControlle
apiRouter.get("/changeDojoRoot.php", changeDojoRootController);
apiRouter.get("/changeGuildRank.php", changeGuildRankController);
apiRouter.get("/checkDailyMissionBonus.php", checkDailyMissionBonusController);
apiRouter.get("/checkPendingRecipes.php", checkPendingRecipesController); // U8
apiRouter.get("/claimLibraryDailyTaskReward.php", claimLibraryDailyTaskRewardController);
apiRouter.get("/completeCalendarEvent.php", completeCalendarEventController);
apiRouter.get("/confirmAllianceInvitation.php", confirmAllianceInvitationController);
@@ -305,6 +307,7 @@ apiRouter.post("/guildTech.php", guildTechController);
apiRouter.post("/hostSession.php", hostSessionController);
apiRouter.post("/hubBlessing.php", hubBlessingController);
apiRouter.post("/infestedFoundry.php", infestedFoundryController);
apiRouter.post("/instantCompleteRecipe.php", claimCompletedRecipeController); // U8
apiRouter.post("/inventorySlots.php", inventorySlotsController);
apiRouter.post("/joinSession.php", joinSessionController);
apiRouter.post("/login.php", loginController);

View File

@@ -46,6 +46,7 @@ import { updateFingerprintController } from "../controllers/custom/updateFingerp
import { unlockLevelCapController } from "../controllers/custom/unlockLevelCapController.ts";
import { changeModularPartsController } from "../controllers/custom/changeModularPartsController.ts";
import { setInvigorationController } from "../controllers/custom/setInvigorationController.ts";
import { setUmbraEchoesController } from "../controllers/custom/setUmbraEchoesController.ts";
import { setAccountCheatController } from "../controllers/custom/setAccountCheatController.ts";
import { setGuildCheatController } from "../controllers/custom/setGuildCheatController.ts";
@@ -97,6 +98,7 @@ customRouter.post("/updateFingerprint", updateFingerprintController);
customRouter.post("/unlockLevelCap", unlockLevelCapController);
customRouter.post("/changeModularParts", changeModularPartsController);
customRouter.post("/setInvigoration", setInvigorationController);
customRouter.post("/setUmbraEchoes", setUmbraEchoesController);
customRouter.post("/setAccountCheat", setAccountCheatController);
customRouter.post("/setGuildCheat", setGuildCheatController);

View File

@@ -1,14 +1,15 @@
import express from "express";
import { viewController } from "../controllers/stats/viewController.ts";
import { uploadController } from "../controllers/stats/uploadController.ts";
import { leaderboardController } from "../controllers/stats/leaderboardController.ts";
import { leaderboardPostController, leaderboardGetController } from "../controllers/stats/leaderboardController.ts";
const statsRouter = express.Router();
statsRouter.get("/view.php", viewController);
statsRouter.get("/profileStats.php", viewController);
statsRouter.get("/leaderboard.php", leaderboardGetController);
statsRouter.post("/upload.php", uploadController);
statsRouter.post("/leaderboardWeekly.php", leaderboardController);
statsRouter.post("/leaderboardArchived.php", leaderboardController);
statsRouter.post("/leaderboardWeekly.php", leaderboardPostController);
statsRouter.post("/leaderboardArchived.php", leaderboardPostController);
export { statsRouter };

View File

@@ -160,6 +160,11 @@ export const configRemovedOptionsKeys = [
"relicRewardItemCountMultiplier",
"nightwaveStandingMultiplier"
];
if (args.docker) {
configRemovedOptionsKeys.push("bindAddress");
configRemovedOptionsKeys.push("httpPort");
configRemovedOptionsKeys.push("httpsPort");
}
export const configPath = path.join(repoDir, args.configPath ?? "config.json");

View File

@@ -808,6 +808,7 @@ export const addItem = async (
typeName.substring(1).split("/")[3] == "KubrowPet"
) {
if (
typeName != "/Lotus/Types/Game/KubrowPet/Eggs/KubrowEgg" &&
typeName != "/Lotus/Types/Game/KubrowPet/Eggs/KubrowPetEggItem" &&
typeName != "/Lotus/Types/Game/KubrowPet/BlankTraitPrint" &&
typeName != "/Lotus/Types/Game/KubrowPet/ImprintedTraitPrint"
@@ -1848,8 +1849,8 @@ export const addMiscItems = (inventory: TInventoryDatabaseDocument, itemsArray:
if (MiscItems[itemIndex].ItemCount == 0) {
MiscItems.splice(itemIndex, 1);
} else if (MiscItems[itemIndex].ItemCount <= 0) {
logger.warn(`inventory.MiscItems has a negative count for ${ItemType}`);
} else if (MiscItems[itemIndex].ItemCount < 0) {
throw new Error(`inventory.MiscItems has a negative count for ${ItemType} after subtracting ${ItemCount}`);
}
});
};
@@ -1870,8 +1871,10 @@ const applyArrayChanges = (
arr[itemIndex].ItemCount += change.ItemCount;
if (arr[itemIndex].ItemCount == 0) {
arr.splice(itemIndex, 1);
} else if (arr[itemIndex].ItemCount <= 0) {
logger.warn(`inventory.${key} has a negative count for ${change.ItemType}`);
} else if (arr[itemIndex].ItemCount < 0) {
throw new Error(
`inventory.${key} has a negative count for ${change.ItemType} after subtracting ${change.ItemCount}`
);
}
}
}
@@ -1917,8 +1920,10 @@ export const addMods = (inventory: TInventoryDatabaseDocument, itemsArray: IRawU
RawUpgrades[itemIndex].ItemCount += ItemCount;
if (RawUpgrades[itemIndex].ItemCount == 0) {
RawUpgrades.splice(itemIndex, 1);
} else if (RawUpgrades[itemIndex].ItemCount <= 0) {
logger.warn(`inventory.RawUpgrades has a negative count for ${ItemType}`);
} else if (RawUpgrades[itemIndex].ItemCount < 0) {
throw new Error(
`inventory.RawUpgrades has a negative count for ${ItemType} after subtracting ${ItemCount}`
);
}
});
};
@@ -1933,7 +1938,9 @@ export const addFusionTreasures = (inventory: TInventoryDatabaseDocument, itemsA
if (FusionTreasures[itemIndex].ItemCount == 0) {
FusionTreasures.splice(itemIndex, 1);
} else if (FusionTreasures[itemIndex].ItemCount <= 0) {
logger.warn(`inventory.FusionTreasures has a negative count for ${ItemType}`);
throw new Error(
`inventory.FusionTreasures has a negative count for ${ItemType} after subtracting ${ItemCount}`
);
}
} else {
FusionTreasures.push({ ItemCount, ItemType, Sockets });

View File

@@ -3819,6 +3819,12 @@ export const getNightwaveSyndicateTag = (buildLabel: string | undefined): string
if (version_compare(buildLabel, "2025.02.05.11.19") >= 0) {
return "RadioLegionIntermission12Syndicate";
}
if (version_compare(buildLabel, "2024.08.21.20.02") >= 0) {
return "RadioLegionIntermission11Syndicate";
}
if (version_compare(buildLabel, "2024.04.29.11.14") >= 0) {
return "RadioLegionIntermission10Syndicate";
}
return undefined;
};

View File

@@ -822,6 +822,22 @@
</form>
</div>
</div>
<div id="umbraEchoes-card" class="card mb-3 d-none">
<h5 class="card-header" data-loc="detailedView_umbraEchoesLabel"></h5>
<div class="card-body">
<p data-loc="detailedView_umbraEchoesDescription"></p>
<form onsubmit="submitUmbraEchoes(event)">
<div class="mb-3">
<label for="umbraEchoes-expiry" class="form-label" data-loc="detailedView_umbraEchoesExpiryLabel"></label>
<input type="datetime-local" class="form-control" max="2038-01-19T03:14" id="umbraEchoes-expiry" onblur="this.value=new Date(this.value)>new Date(this.max)?new Date(this.max).toISOString().slice(0,16):this.value"/>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary" data-loc="general_setButton"></button>
<button type="button" class="btn btn-danger" onclick="setUmbraEchoes()" data-loc="code_remove"></button>
</div>
</form>
</div>
</div>
<div id="modularParts-card" class="card mb-3 d-none">
<h5 class="card-header" data-loc="detailedView_modularPartsLabel"></h5>
<div class="card-body">

View File

@@ -1665,6 +1665,12 @@ function updateInventory() {
formatDatetime("%Y-%m-%d %H:%M", Number(item.UpgradesExpiry?.$date.$numberLong)) || "";
}
if (item.ItemType != "/Lotus/Powersuits/Excalibur/ExcaliburUmbra") {
document.getElementById("umbraEchoes-card").classList.remove("d-none");
document.getElementById("umbraEchoes-expiry").value =
formatDatetime("%Y-%m-%d %H:%M", Number(item.UmbraDate?.$date.$numberLong)) || "";
}
{
document.getElementById("loadout-card").classList.remove("d-none");
const maxModConfigNum = Math.min(2 + (item.ModSlotPurchases ?? 0), 5);
@@ -3532,6 +3538,7 @@ single.getRoute("#detailedView-route").on("beforeload", function () {
document.getElementById("loadout-card").classList.add("d-none");
document.getElementById("archonShards-card").classList.add("d-none");
document.getElementById("edit-suit-invigorations-card").classList.add("d-none");
document.getElementById("umbraEchoes-card").classList.add("d-none");
document.getElementById("modularParts-card").classList.add("d-none");
document.getElementById("modularParts-form").innerHTML = "";
document.getElementById("valenceBonus-card").classList.add("d-none");
@@ -4270,6 +4277,25 @@ function setInvigoration(data) {
});
}
function submitUmbraEchoes(event) {
event.preventDefault();
const expiry = document.getElementById("umbraEchoes-expiry").value;
setUmbraEchoes({
UmbraDate: expiry ? new Date(expiry).getTime() : Date.now() + 1 * 24 * 60 * 60 * 1000
});
}
function setUmbraEchoes(data) {
const oid = new URLSearchParams(window.location.search).get("itemId");
$.post({
url: "/custom/setUmbraEchoes?" + window.authz,
contentType: "application/json",
data: JSON.stringify({ oid, ...data })
}).done(function () {
updateInventory();
});
}
function handleAbilityOverride(event, configIndex) {
event.preventDefault();
const urlParams = new URLSearchParams(window.location.search);

View File

@@ -165,6 +165,7 @@ dict = {
detailedView_invigorationLabel: `Kräftigung`,
detailedView_loadoutLabel: `Loadouts`,
detailedView_equipmentFeaturesLabel: `[UNTRANSLATED] Equipment Features`,
detailedView_umbraEchoesLabel: `[UNTRANSLATED] Echoes Of Umbra`,
invigorations_offensive_AbilityStrength: `+200% Fähigkeitsstärke`,
invigorations_offensive_AbilityRange: `+100% Fähigkeitsreichweite`,
@@ -195,6 +196,9 @@ dict = {
abilityOverride_label: `Fähigkeitsüberschreibung`,
abilityOverride_onSlot: `auf Slot`,
detailedView_umbraEchoesDescription: `Wird ein Warframe mit dieser Flüssigkeit injiziert, kann er selbstständig an der Seite des Operators kämpfen.`,
detailedView_umbraEchoesExpiryLabel: `[UNTRANSLATED] Echo Expiry (optional)`,
mods_addRiven: `Riven hinzufügen`,
mods_fingerprint: `Fingerabdruck`,
mods_fingerprintHelp: `Benötigst du Hilfe mit dem Fingerabdruck?`,

View File

@@ -164,6 +164,7 @@ dict = {
detailedView_invigorationLabel: `Invigoration`,
detailedView_loadoutLabel: `Loadouts`,
detailedView_equipmentFeaturesLabel: `Equipment Features`,
detailedView_umbraEchoesLabel: `Echoes Of Umbra`,
invigorations_offensive_AbilityStrength: `+200% Ability Strength`,
invigorations_offensive_AbilityRange: `+100% Ability Range`,
@@ -194,6 +195,9 @@ dict = {
abilityOverride_label: `Ability Override`,
abilityOverride_onSlot: `on slot`,
detailedView_umbraEchoesDescription: `Injecting this fluid into a Warframe will imbue it with the ability to fight autonomously alongside the Operator.`,
detailedView_umbraEchoesExpiryLabel: `Echo Expiry (optional)`,
mods_addRiven: `Add Riven`,
mods_fingerprint: `Fingerprint`,
mods_fingerprintHelp: `Need help with the fingerprint?`,

View File

@@ -165,6 +165,7 @@ dict = {
detailedView_invigorationLabel: `Fortalecimiento`,
detailedView_loadoutLabel: `Equipamientos`,
detailedView_equipmentFeaturesLabel: `[UNTRANSLATED] Equipment Features`,
detailedView_umbraEchoesLabel: `[UNTRANSLATED] Echoes Of Umbra`,
invigorations_offensive_AbilityStrength: `+200% Fuerza de Habilidad`,
invigorations_offensive_AbilityRange: `+100% Alcance de Habilidad`,
@@ -195,6 +196,9 @@ dict = {
abilityOverride_label: `Intercambio de Habilidad`,
abilityOverride_onSlot: `en el espacio`,
detailedView_umbraEchoesDescription: `Inyectar este fluido en un warframe lo imbuirá con la capacidad para luchar autónomamente junto a su operador.`,
detailedView_umbraEchoesExpiryLabel: `[UNTRANSLATED] Echo Expiry (optional)`,
mods_addRiven: `Agregar Agrietado`,
mods_fingerprint: `Huella digital`,
mods_fingerprintHelp: `¿Necesitas ayuda con la huella digital?`,

View File

@@ -165,6 +165,7 @@ dict = {
detailedView_invigorationLabel: `Dynamisation`,
detailedView_loadoutLabel: `Équipements`,
detailedView_equipmentFeaturesLabel: `[UNTRANSLATED] Equipment Features`,
detailedView_umbraEchoesLabel: `[UNTRANSLATED] Echoes Of Umbra`,
invigorations_offensive_AbilityStrength: `+200% de puissance de pouvoir`,
invigorations_offensive_AbilityRange: `+100% de portée de pouvoir`,
@@ -195,6 +196,9 @@ dict = {
abilityOverride_label: `Remplacement de pouvoir`,
abilityOverride_onSlot: `Sur l'emplacement`,
detailedView_umbraEchoesDescription: `L'injection de ce fluide dans une Warframe lui donnera la possibilité de se battre de manière autonome aux côtés de l'Opérateur.`,
detailedView_umbraEchoesExpiryLabel: `[UNTRANSLATED] Echo Expiry (optional)`,
mods_addRiven: `Ajouter un riven`,
mods_fingerprint: `Empreinte`,
mods_fingerprintHelp: `Besoin d'aide pour l'empreinte ?`,

View File

@@ -165,6 +165,7 @@ dict = {
detailedView_invigorationLabel: `Воодушевление`,
detailedView_loadoutLabel: `Конфигурации`,
detailedView_equipmentFeaturesLabel: `Модификаторы снаряжения`,
detailedView_umbraEchoesLabel: `Эхо Умбры`,
invigorations_offensive_AbilityStrength: `+200% к силе способностей.`,
invigorations_offensive_AbilityRange: `+100% к зоне поражения способностей.`,
@@ -195,6 +196,9 @@ dict = {
abilityOverride_label: `Переопределение способности`,
abilityOverride_onSlot: `в ячейке`,
detailedView_umbraEchoesDescription: `Введение этой жидкости в варфрейм позволит ему автономно сражаться бок о бок с оператором.`,
detailedView_umbraEchoesExpiryLabel: `Срок действия Эха (необязательно)`,
mods_addRiven: `Добавить мод Разлома`,
mods_fingerprint: `Отпечаток`,
mods_fingerprintHelp: `Нужна помощь с отпечатком?`,

View File

@@ -165,6 +165,7 @@ dict = {
detailedView_invigorationLabel: `Зміцнення`,
detailedView_loadoutLabel: `Конфігурації`,
detailedView_equipmentFeaturesLabel: `[UNTRANSLATED] Equipment Features`,
detailedView_umbraEchoesLabel: `[UNTRANSLATED] Echoes Of Umbra`,
invigorations_offensive_AbilityStrength: `+200% до потужності здібностей.`,
invigorations_offensive_AbilityRange: `+100% до досяжності здібностей.`,
@@ -195,6 +196,9 @@ dict = {
abilityOverride_label: `Перевизначення здібностей`,
abilityOverride_onSlot: `у комірці`,
detailedView_umbraEchoesDescription: `Рідина, яка після введення дозволяє ворфрейму використовувати єдність і битися самостійно пліч-о-пліч з оператором.`,
detailedView_umbraEchoesExpiryLabel: `[UNTRANSLATED] Echo Expiry (optional)`,
mods_addRiven: `Добавити модифікатор Розколу`,
mods_fingerprint: `Відбиток`,
mods_fingerprintHelp: `Потрібна допомога з відбитком?`,

View File

@@ -165,6 +165,7 @@ dict = {
detailedView_invigorationLabel: `活化`,
detailedView_loadoutLabel: `配置`,
detailedView_equipmentFeaturesLabel: `[UNTRANSLATED] Equipment Features`,
detailedView_umbraEchoesLabel: `[UNTRANSLATED] Echoes Of Umbra`,
invigorations_offensive_AbilityStrength: `+200%技能强度`,
invigorations_offensive_AbilityRange: `+100%技能范围`,
@@ -195,6 +196,9 @@ dict = {
abilityOverride_label: `技能替换`,
abilityOverride_onSlot: `槽位`,
detailedView_umbraEchoesDescription: `将这种液体注入战甲内,使其具有与指挥官并肩作战的能力。`,
detailedView_umbraEchoesExpiryLabel: `[UNTRANSLATED] Echo Expiry (optional)`,
mods_addRiven: `添加裂罅MOD`,
mods_fingerprint: `印记`,
mods_fingerprintHelp: `需要印记相关的帮助?`,