返回提交历史
Modified
package-lock.json
+4
-4
Modified
package.json
+1
-1
Modified
src/controllers/api/tradingController.ts
+317
-6
Modified
src/routes/api.ts
+1
-0
Modified
src/services/importService.ts
+2
-2
Modified
src/services/inventoryService.ts
+2
-1
Modified
src/types/inventoryTypes/inventoryTypes.ts
+11
-29
XFEstudio/SpaceNinjaServer
feat: trading (#3160)
Reviewed-on: https://onlyg.it/OpenWF/SpaceNinjaServer/pulls/3160 Co-authored-by: Sainan <63328889+Sainan@users.noreply.github.com> Co-committed-by: Sainan <63328889+Sainan@users.noreply.github.com>
35af72ec
代码差异
7 个文件
+338
-43
@@ -17,7 +17,7 @@
17
17
"morgan": "^1.10.0",
18
18
"ncp": "^2.0.0",
19
19
"undici": "^7.10.0",
20
"warframe-public-export-plus": "^0.5.96",
20
"warframe-public-export-plus": "^0.5.97",
21
21
"warframe-riven-info": "^0.1.2",
22
22
"winston": "^3.17.0",
23
23
"winston-daily-rotate-file": "^5.0.0",
@@ -5421,9 +5421,9 @@
5421
5421
}
5422
5422
},
5423
5423
"node_modules/warframe-public-export-plus": {
5424
"version": "0.5.96",
5425
"resolved": "https://registry.npmjs.org/warframe-public-export-plus/-/warframe-public-export-plus-0.5.96.tgz",
5426
"integrity": "sha512-ipOJEXzmrI+AryyrpHfd0ZzTV7arVQLm+ih9zi1FxlO96sDtNcZevqL32Q3OZvOk70aDywK1KeGc5gWshCcPxw=="
5424
"version": "0.5.97",
5425
"resolved": "https://registry.npmjs.org/warframe-public-export-plus/-/warframe-public-export-plus-0.5.97.tgz",
5426
"integrity": "sha512-Xe07uNXT7Pci0yDIdREHuJBx+uTspf/qmPNh0oprjSlyFlib4HjrKm2iSNnw9q4NUqxzv42SwumMD8hCsAOnng=="
5427
5427
},
5428
5428
"node_modules/warframe-riven-info": {
5429
5429
"version": "0.1.2",
@@ -34,7 +34,7 @@
34
34
"morgan": "^1.10.0",
35
35
"ncp": "^2.0.0",
36
36
"undici": "^7.10.0",
37
"warframe-public-export-plus": "^0.5.96",
37
"warframe-public-export-plus": "^0.5.97",
38
38
"warframe-riven-info": "^0.1.2",
39
39
"winston": "^3.17.0",
40
40
"winston-daily-rotate-file": "^5.0.0",
@@ -1,15 +1,125 @@
1
1
import { getGuildForRequestEx, hasAccessToDojo, hasGuildPermission } from "../../services/guildService.ts";
2
import { getInventory } from "../../services/inventoryService.ts";
3
import { getAccountIdForRequest } from "../../services/loginService.ts";
2
import {
3
addFusionTreasures,
4
addMiscItem,
5
addMods,
6
addRecipes,
7
getInventory,
8
updateCurrency
9
} from "../../services/inventoryService.ts";
10
import { getAccountForRequest } from "../../services/loginService.ts";
4
11
import { GuildPermission } from "../../types/guildTypes.ts";
5
12
import type { RequestHandler } from "express";
13
import type { IPendingTrade, ITradeOffer } from "../../types/inventoryTypes/inventoryTypes.ts";
14
import { getJSONfromString } from "../../helpers/stringHelpers.ts";
15
import { logger } from "../../utils/logger.ts";
16
import { version_compare } from "../../helpers/inventoryHelpers.ts";
17
import gameToBuildVersion from "../../constants/gameToBuildVersion.ts";
18
import type { TInventoryDatabaseDocument } from "../../models/inventoryModels/inventoryModel.ts";
19
import { importUpgrade } from "../../services/importService.ts";
20
import { Guild } from "../../models/guildModel.ts";
21
import { ExportArcanes, ExportResources, ExportUpgrades, type TRarity } from "warframe-public-export-plus";
6
22
7
23
export const tradingController: RequestHandler = async (req, res) => {
8
const accountId = await getAccountIdForRequest(req);
9
const inventory = await getInventory(accountId, "GuildId LevelKeys");
10
const guild = await getGuildForRequestEx(req, inventory);
24
const account = await getAccountForRequest(req);
25
const accountId = account._id.toString();
11
26
const op = req.query.op as string;
12
if (op == "5") {
27
if (op == "0" || op == "1") {
28
const buddyId = req.query.buddyId as string;
29
const tradeId = getTradeId(accountId, buddyId);
30
const offer = getJSONfromString<ITradeOffer>(String(req.body));
31
pendingTrades[tradeId] ??= {
32
trader: accountId,
33
tradee: buddyId,
34
traderOffer: { _SlotOrderInfo: ["", "", "", "", "", ""] },
35
tradeeOffer: { _SlotOrderInfo: ["", "", "", "", "", ""] },
36
traderReady: false,
37
tradeeReady: false,
38
traderAccepted: false,
39
tradeeAccepted: false,
40
revision: -1,
41
clanTax: (req.query.guildId ? await Guild.findById(req.query.guildId as string) : undefined)?.TradeTax ?? 10 // When trading in Maroo's Bazaar, a 10% "Hub Tax" applies.
42
};
43
const us = accountId == pendingTrades[tradeId].trader ? "trader" : "tradee";
44
logger.debug(`${us} offer:`, offer);
45
{
46
const tax = calcuateTax(offer);
47
const clanTaxRate = pendingTrades[tradeId].clanTax ? 1 / pendingTrades[tradeId].clanTax : 0;
48
const clanTax = clanTaxRate * tax;
49
logger.debug(`calculated tax: ${tax} + ${clanTax}`);
50
}
51
pendingTrades[tradeId][`${us}Offer`] = offer;
52
pendingTrades[tradeId].traderReady = false;
53
pendingTrades[tradeId].tradeeReady = false;
54
pendingTrades[tradeId].traderAccepted = false;
55
pendingTrades[tradeId].tradeeAccepted = false;
56
pendingTrades[tradeId].revision += 1;
57
res.json({
58
PendingTrades: [exportTrade(pendingTrades[tradeId], us)]
59
});
60
} else if (op == "2") {
61
const buddyId = req.query.buddyId as string;
62
const tradeId = getTradeId(accountId, buddyId);
63
if (parseInt(req.query.revision as string) != pendingTrades[tradeId].revision) {
64
throw new Error(`can't accept trade because it was unexpectedly revised`);
65
}
66
const us = accountId == pendingTrades[tradeId].trader ? "trader" : "tradee";
67
pendingTrades[tradeId][`${us}Accepted`] = true;
68
const them = us == "trader" ? "tradee" : "trader";
69
if (pendingTrades[tradeId][`${them}Accepted`]) {
70
const [traderInventory, tradeeInventory] = await Promise.all([
71
getInventory(pendingTrades[tradeId].trader),
72
getInventory(pendingTrades[tradeId].tradee)
73
]);
74
75
applyOfferToInventory(traderInventory, pendingTrades[tradeId].traderOffer, -1); // Traders gives up their items
76
applyOfferToInventory(traderInventory, pendingTrades[tradeId].tradeeOffer, +1); // to get what tradee offered
77
// and pays tax for it
78
await chargeTax(
79
traderInventory,
80
pendingTrades[tradeId].tradeeOffer,
81
pendingTrades[tradeId].clanTax,
82
req.query.guildId as string | undefined
83
);
84
applyOfferToInventory(tradeeInventory, pendingTrades[tradeId].tradeeOffer, -1); // Conversely, tradee gives up their items
85
applyOfferToInventory(tradeeInventory, pendingTrades[tradeId].traderOffer, +1); // to get what trader offered
86
// and pays tax for it
87
await chargeTax(
88
tradeeInventory,
89
pendingTrades[tradeId].traderOffer,
90
pendingTrades[tradeId].clanTax,
91
req.query.guildId as string | undefined
92
);
93
94
traderInventory.TradesRemaining -= 1;
95
tradeeInventory.TradesRemaining -= 1;
96
97
await Promise.all([traderInventory.save(), tradeeInventory.save()]);
98
delete pendingTrades[tradeId];
99
res.send(
100
account.BuildLabel && version_compare(account.BuildLabel, gameToBuildVersion["40.0.0"]) < 0 ? "9" : "12"
101
).end();
102
} else {
103
res.send(
104
account.BuildLabel && version_compare(account.BuildLabel, gameToBuildVersion["40.0.0"]) < 0 ? "8" : "11"
105
).end();
106
}
107
} else if (op == "3") {
108
const buddyId = req.query.buddyId as string;
109
const tradeId = getTradeId(accountId, buddyId);
110
delete pendingTrades[tradeId];
111
res.json({
112
pendingTrades: []
113
});
114
} else if (op == "4") {
115
const tradeId = getTradeId(accountId, req.query.buddyId as string);
116
const us = accountId == pendingTrades[tradeId].trader ? "trader" : "tradee";
117
res.json({
118
PendingTrades: [exportTrade(pendingTrades[tradeId], us)]
119
});
120
} else if (op == "5") {
121
const inventory = await getInventory(accountId, "GuildId LevelKeys");
122
const guild = await getGuildForRequestEx(req, inventory);
13
123
if (!hasAccessToDojo(inventory) || !(await hasGuildPermission(guild, accountId, GuildPermission.Treasurer))) {
14
124
res.status(400).send("-1").end();
15
125
return;
@@ -17,7 +127,208 @@ export const tradingController: RequestHandler = async (req, res) => {
17
127
guild.TradeTax = parseInt(req.query.tax as string);
18
128
await guild.save();
19
129
res.send(guild.TradeTax).end();
130
} else if (op == "6") {
131
const buddyId = req.query.buddyId as string;
132
const tradeId = getTradeId(accountId, buddyId);
133
const us = accountId == pendingTrades[tradeId].trader ? "trader" : "tradee";
134
if (parseInt(req.query.revision as string) == pendingTrades[tradeId].revision) {
135
pendingTrades[tradeId][`${us}Ready`] = true;
136
}
137
res.json({
138
PendingTrades: [exportTrade(pendingTrades[tradeId], us)]
139
});
140
} else if (op == "7") {
141
const buddyId = req.query.buddyId as string;
142
const tradeId = getTradeId(accountId, buddyId);
143
const us = accountId == pendingTrades[tradeId].trader ? "trader" : "tradee";
144
pendingTrades[tradeId][`${us}Ready`] = false;
145
pendingTrades[tradeId].tradeeAccepted = false;
146
pendingTrades[tradeId].traderAccepted = false;
147
res.json({
148
PendingTrades: [exportTrade(pendingTrades[tradeId], us)]
149
});
20
150
} else {
151
if (req.body) {
152
logger.debug(`data provided to ${req.path}: ${String(req.body)}`);
153
}
21
154
throw new Error(`unknown trading op: ${op}`);
22
155
}
23
156
};
157
158
interface IPendingTradeMem {
159
trader: string;
160
tradee: string;
161
traderOffer: ITradeOffer;
162
tradeeOffer: ITradeOffer;
163
traderReady: boolean;
164
tradeeReady: boolean;
165
traderAccepted: boolean;
166
tradeeAccepted: boolean;
167
revision: number;
168
clanTax: number;
169
}
170
171
const pendingTrades: Record<string, IPendingTradeMem> = {};
172
173
const getTradeId = (accountId: string, buddyId: string): string => {
174
return accountId.localeCompare(buddyId) < 0 ? accountId + buddyId : buddyId + accountId;
175
};
176
177
const exportTrade = (mem: IPendingTradeMem, us: "trader" | "tradee"): IPendingTrade => {
178
const them = us == "trader" ? "tradee" : "trader";
179
return {
180
ItemId: { $oid: mem[them] },
181
Giving: mem[`${us}Offer`],
182
Getting: mem[`${them}Offer`],
183
SelfReady: mem[`${us}Ready`],
184
BuddyReady: mem[`${them}Ready`],
185
State: mem[`${us}Accepted`] ? (mem[`${them}Accepted`] ? 4 : 3) : 2,
186
Revision: mem.revision,
187
ClanTax: mem.clanTax
188
};
189
};
190
191
const applyOfferToInventory = (inventory: TInventoryDatabaseDocument, offer: ITradeOffer, factor: 1 | -1): void => {
192
if (offer.RandomUpgrades) {
193
for (const item of offer.RandomUpgrades) {
194
if (factor == -1) {
195
inventory.Upgrades.pull({ _id: item.ItemId.$oid });
196
} else {
197
inventory.Upgrades.push(importUpgrade(item));
198
}
199
}
200
}
201
if (offer.Upgrades) {
202
for (const item of offer.Upgrades) {
203
if (factor == -1) {
204
inventory.Upgrades.pull({ _id: item.ItemId.$oid });
205
} else {
206
inventory.Upgrades.push(importUpgrade(item));
207
}
208
}
209
}
210
if (offer.RawUpgrades) {
211
if (factor == 1) {
212
addMods(inventory, offer.RawUpgrades);
213
} else {
214
for (const item of offer.RawUpgrades) {
215
if (item.ItemCount < 0) {
216
throw new Error(`negative quantity in trade offer`);
217
}
218
addMods(inventory, [{ ...item, ItemCount: item.ItemCount * -1 }]);
219
}
220
}
221
}
222
if (offer.MiscItems) {
223
for (const item of offer.MiscItems) {
224
if (item.ItemCount < 0) {
225
throw new Error(`negative quantity in trade offer`);
226
}
227
addMiscItem(inventory, item.ItemType, item.ItemCount * factor);
228
}
229
}
230
if (offer.Recipes) {
231
if (factor == 1) {
232
addRecipes(inventory, offer.Recipes);
233
} else {
234
for (const item of offer.Recipes) {
235
if (item.ItemCount < 0) {
236
throw new Error(`negative quantity in trade offer`);
237
}
238
addRecipes(inventory, [{ ...item, ItemCount: item.ItemCount * -1 }]);
239
}
240
}
241
}
242
if (offer.FusionTreasures) {
243
if (factor == 1) {
244
addFusionTreasures(inventory, offer.FusionTreasures);
245
} else {
246
for (const item of offer.FusionTreasures) {
247
if (item.ItemCount < 0) {
248
throw new Error(`negative quantity in trade offer`);
249
}
250
addFusionTreasures(inventory, [{ ...item, ItemCount: item.ItemCount * -1 }]);
251
}
252
}
253
}
254
if (offer.PremiumCredits) {
255
if (offer.PremiumCredits < 0) {
256
throw new Error(`negative quantity in trade offer`);
257
}
258
updateCurrency(inventory, offer.PremiumCredits * factor * -1, true);
259
}
260
};
261
262
const RARITY_TAX_RATES: Record<TRarity, number> = { COMMON: 2000, UNCOMMON: 4000, RARE: 8000, LEGENDARY: 1_000_000 };
263
const LEGENDARY_ARCANE_TAX_RATES: number[] = [100_000, 300_000, 600_000, 1_000_000, 1_500_000, 2_100_000];
264
const calcuateTax = (offer: ITradeOffer): number => {
265
let tax = 0;
266
if (offer.RandomUpgrades) {
267
tax += offer.RandomUpgrades.length * 8000;
268
}
269
if (offer.Upgrades) {
270
for (const item of offer.Upgrades) {
271
if (item.ItemType in ExportArcanes) {
272
const rarity = ExportArcanes[item.ItemType].rarity;
273
if (rarity == "LEGENDARY") {
274
tax += LEGENDARY_ARCANE_TAX_RATES[(JSON.parse(item.UpgradeFingerprint!) as { lvl: number }).lvl];
275
}
276
} else {
277
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
278
tax += RARITY_TAX_RATES[ExportUpgrades[item.ItemType]?.rarity ?? "COMMON"];
279
}
280
}
281
}
282
if (offer.RawUpgrades) {
283
for (const item of offer.RawUpgrades) {
284
tax +=
285
RARITY_TAX_RATES[
286
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
287
ExportUpgrades[item.ItemType]?.rarity ?? ExportArcanes[item.ItemType]?.rarity ?? "COMMON"
288
] * item.ItemCount;
289
}
290
}
291
if (offer.MiscItems) {
292
for (const item of offer.MiscItems) {
293
if (
294
item.ItemType.startsWith("/Lotus/Types/Game/Projections/T3") ||
295
item.ItemType.startsWith("/Lotus/Types/Game/Projections/T4")
296
) {
297
tax += 4000 * item.ItemCount;
298
} else {
299
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
300
tax += RARITY_TAX_RATES[ExportResources[item.ItemType]?.rarity ?? "COMMON"] * item.ItemCount;
301
}
302
}
303
}
304
if (offer.Recipes) {
305
tax += offer.Recipes.length * 2000;
306
}
307
if (offer.FusionTreasures) {
308
for (const item of offer.FusionTreasures) {
309
tax += item.ItemCount * (item.Sockets ? 12000 : 4000);
310
}
311
}
312
tax += (offer.PremiumCredits ?? 0) * 500;
313
return tax;
314
};
315
316
const chargeTax = async (
317
inventory: TInventoryDatabaseDocument,
318
offer: ITradeOffer,
319
clanTaxInt: number,
320
guildId: string | undefined
321
): Promise<void> => {
322
const tax = calcuateTax(offer);
323
const clanTaxRate = clanTaxInt ? 1 / clanTaxInt : 0;
324
const clanTax = clanTaxRate * tax;
325
updateCurrency(inventory, tax + clanTax, false);
326
if (guildId && clanTax) {
327
const guild = await Guild.findById(guildId);
328
if (guild) {
329
guild.VaultRegularCredits ??= 0;
330
guild.VaultRegularCredits += clanTax;
331
await guild.save();
332
}
333
}
334
};
@@ -372,6 +372,7 @@ apiRouter.post("/stepSequencers.php", stepSequencersController);
372
372
apiRouter.post("/syndicateSacrifice.php", syndicateSacrificeController);
373
373
apiRouter.post("/syndicateStandingBonus.php", syndicateStandingBonusController);
374
374
apiRouter.post("/tauntHistory.php", tauntHistoryController);
375
apiRouter.post("/trading.php", tradingController);
375
376
apiRouter.post("/trainingResult.php", trainingResultPostController);
376
377
apiRouter.post("/umbra.php", umbraController);
377
378
apiRouter.post("/unlockShipFeature.php", unlockShipFeatureController);
@@ -115,7 +115,7 @@ const convertWeaponSkin = (client: IWeaponSkinClient): IWeaponSkinDatabase => {
115
115
};
116
116
};
117
117
118
const convertUpgrade = (client: IUpgradeClient): IUpgradeDatabase => {
118
export const importUpgrade = (client: IUpgradeClient): IUpgradeDatabase => {
119
119
const { ItemId, ...rest } = client;
120
120
return {
121
121
...rest,
@@ -266,7 +266,7 @@ export const importInventory = (db: TInventoryDatabaseDocument, client: Partial<
266
266
}
267
267
for (const key of ["Upgrades", "CrewShipSalvagedWeaponSkins", "CrewShipWeaponSkins"] as const) {
268
268
if (client[key] !== undefined) {
269
replaceArray<IUpgradeDatabase>(db[key], client[key].map(convertUpgrade));
269
replaceArray<IUpgradeDatabase>(db[key], client[key].map(importUpgrade));
270
270
}
271
271
}
272
272
for (const key of [
@@ -1398,11 +1398,12 @@ export const updateCurrency = (
1398
1398
): IInventoryChanges => {
1399
1399
if (price != 0 && isCurrencyTracked(inventory, usePremium)) {
1400
1400
if (usePremium) {
1401
if (inventory.PremiumCreditsFree > 0) {
1401
if (price > 0 && inventory.PremiumCreditsFree > 0) {
1402
1402
const premiumCreditsFreeDelta = Math.min(price, inventory.PremiumCreditsFree) * -1;
1403
1403
inventoryChanges.PremiumCreditsFree ??= 0;
1404
1404
inventoryChanges.PremiumCreditsFree += premiumCreditsFreeDelta;
1405
1405
inventory.PremiumCreditsFree += premiumCreditsFreeDelta;
1406
logger.debug(`spending ${-premiumCreditsFreeDelta} starter plat`);
1406
1407
}
1407
1408
inventoryChanges.PremiumCredits ??= 0;
1408
1409
inventoryChanges.PremiumCredits -= price;
@@ -9,7 +9,7 @@ import type {
9
9
ILotusCustomization,
10
10
IShipCustomization
11
11
} from "./commonInventoryTypes.ts";
12
import type { IFingerprintStat, RivenFingerprint } from "../../helpers/rivenHelper.ts";
12
import type { IFingerprintStat } from "../../helpers/rivenHelper.ts";
13
13
import type { IOrbiterClient } from "../personalRoomsTypes.ts";
14
14
import type { ICountedStoreItem } from "warframe-public-export-plus";
15
15
import type { IEquipmentClient, IEquipmentDatabase, ITraits } from "../equipmentTypes.ts";
@@ -892,23 +892,22 @@ export interface IPendingTrade {
892
892
State: number;
893
893
SelfReady: boolean;
894
894
BuddyReady: boolean;
895
Giving?: IGiving;
895
Giving?: ITradeOffer;
896
896
Revision: number;
897
Getting: IGetting;
897
Getting: ITradeOffer;
898
898
ItemId: IOid;
899
899
ClanTax?: number;
900
900
}
901
901
902
export interface IGetting {
903
RandomUpgrades?: IRandomUpgrade[];
904
_SlotOrderInfo: GettingSlotOrderInfo[];
902
export interface ITradeOffer {
903
RandomUpgrades?: IUpgradeClient[];
904
Upgrades?: IUpgradeClient[];
905
RawUpgrades?: IRawUpgrade[];
906
MiscItems?: IMiscItem[];
907
Recipes?: ITypeCount[];
908
FusionTreasures?: IFusionTreasure[];
905
909
PremiumCredits?: number;
906
}
907
908
export interface IRandomUpgrade {
909
UpgradeFingerprint: RivenFingerprint;
910
ItemType: string;
911
ItemId: IOid;
910
_SlotOrderInfo: string[];
912
911
}
913
912
914
913
export interface IInnateDamageFingerprint {
@@ -931,23 +930,6 @@ export interface INemesisPetTargetFingerprint {
931
930
Name: string;
932
931
}
933
932
934
export enum GettingSlotOrderInfo {
935
Empty = "",
936
LotusUpgradesModsRandomizedPlayerMeleeWeaponRandomModRare0 = "/Lotus/Upgrades/Mods/Randomized/PlayerMeleeWeaponRandomModRare:0",
937
P = "P"
938
}
939
940
export interface IGiving {
941
RawUpgrades: ITypeCount[];
942
_SlotOrderInfo: GivingSlotOrderInfo[];
943
}
944
945
export enum GivingSlotOrderInfo {
946
Empty = "",
947
LotusTypesSentinelsSentinelPreceptsItemVacum = "/Lotus/Types/Sentinels/SentinelPrecepts/ItemVacum",
948
LotusUpgradesModsPistolDualStatElectEventPistolMod = "/Lotus/Upgrades/Mods/Pistol/DualStat/ElectEventPistolMod"
949
}
950
951
933
export interface IPeriodicMissionCompletionDatabase {
952
934
date: Date;
953
935
tag: string;