返回提交历史
Modified
src/controllers/api/loginController.ts
+1
-2
Modified
src/controllers/api/loginRewardsController.ts
+37
-5
Added
src/controllers/api/loginRewardsSelectionController.ts
+57
-0
Modified
src/helpers/customHelpers/customHelpers.ts
+4
-5
Modified
src/models/inventoryModels/inventoryModel.ts
+1
-1
Modified
src/models/loginModel.ts
+3
-1
Modified
src/routes/api.ts
+2
-0
Added
src/services/loginRewardService.ts
+140
-0
Modified
src/services/loginService.ts
+3
-3
Modified
src/services/rngService.ts
+19
-2
Modified
src/types/loginTypes.ts
+6
-1
Deleted
static/fixed_responses/loginRewards.json
+0
-9
Added
static/fixed_responses/loginRewards/randomRewards.json
+187
-0
XFEstudio/SpaceNinjaServer
feat: daily tribute (#1241)
Closes #367 Reviewed-on: https://onlyg.it/OpenWF/SpaceNinjaServer/pulls/1241
6598318f
代码差异
13 个文件
+460
-29
@@ -42,8 +42,7 @@ export const loginController: RequestHandler = async (request, response) => {
42
42
ForceLogoutVersion: 0,
43
43
ConsentNeeded: false,
44
44
TrackedSettings: [],
45
Nonce: nonce,
46
LatestEventMessageDate: new Date(0)
45
Nonce: nonce
47
46
});
48
47
logger.debug("created new account");
49
48
response.json(createLoginResponse(myAddress, newAccount, buildLabel));
@@ -1,8 +1,40 @@
1
1
import { RequestHandler } from "express";
2
import loginRewards from "@/static/fixed_responses/loginRewards.json";
2
import { getAccountForRequest } from "@/src/services/loginService";
3
import { claimLoginReward, getRandomLoginRewards, ILoginRewardsReponse } from "@/src/services/loginRewardService";
4
import { getInventory } from "@/src/services/inventoryService";
3
5
4
const loginRewardsController: RequestHandler = (_req, res) => {
5
res.json(loginRewards);
6
};
6
export const loginRewardsController: RequestHandler = async (req, res) => {
7
const account = await getAccountForRequest(req);
8
const today = Math.trunc(Date.now() / 86400000) * 86400;
9
10
if (today == account.LastLoginRewardDate) {
11
res.end();
12
return;
13
}
14
account.LoginDays += 1;
15
account.LastLoginRewardDate = today;
16
await account.save();
7
17
8
export { loginRewardsController };
18
const inventory = await getInventory(account._id.toString());
19
const randomRewards = getRandomLoginRewards(account, inventory);
20
const isMilestoneDay = account.LoginDays == 5 || account.LoginDays % 50 == 0;
21
const response: ILoginRewardsReponse = {
22
DailyTributeInfo: {
23
Rewards: randomRewards,
24
IsMilestoneDay: isMilestoneDay,
25
IsChooseRewardSet: randomRewards.length != 1,
26
LoginDays: account.LoginDays,
27
//NextMilestoneReward: "",
28
NextMilestoneDay: account.LoginDays < 5 ? 5 : (Math.trunc(account.LoginDays / 50) + 1) * 50,
29
HasChosenReward: false
30
},
31
LastLoginRewardDate: today
32
};
33
if (!isMilestoneDay && randomRewards.length == 1) {
34
response.DailyTributeInfo.HasChosenReward = true;
35
response.DailyTributeInfo.ChosenReward = randomRewards[0];
36
response.DailyTributeInfo.NewInventory = await claimLoginReward(inventory, randomRewards[0]);
37
await inventory.save();
38
}
39
res.json(response);
40
};
@@ -0,0 +1,57 @@
1
import { getInventory } from "@/src/services/inventoryService";
2
import { claimLoginReward, getRandomLoginRewards } from "@/src/services/loginRewardService";
3
import { getAccountForRequest } from "@/src/services/loginService";
4
import { handleStoreItemAcquisition } from "@/src/services/purchaseService";
5
import { IInventoryChanges } from "@/src/types/purchaseTypes";
6
import { logger } from "@/src/utils/logger";
7
import { RequestHandler } from "express";
8
9
export const loginRewardsSelectionController: RequestHandler = async (req, res) => {
10
const account = await getAccountForRequest(req);
11
const inventory = await getInventory(account._id.toString());
12
const body = JSON.parse(String(req.body)) as ILoginRewardsSelectionRequest;
13
const isMilestoneDay = account.LoginDays == 5 || account.LoginDays % 50 == 0;
14
if (body.IsMilestoneReward != isMilestoneDay) {
15
logger.warn(`Client disagrees on login milestone (got ${body.IsMilestoneReward}, expected ${isMilestoneDay})`);
16
}
17
let chosenReward;
18
let inventoryChanges: IInventoryChanges;
19
if (body.IsMilestoneReward) {
20
chosenReward = {
21
RewardType: "RT_STORE_ITEM",
22
StoreItemType: body.ChosenReward
23
};
24
inventoryChanges = (await handleStoreItemAcquisition(body.ChosenReward, inventory)).InventoryChanges;
25
if (!evergreenRewards.find(x => x == body.ChosenReward)) {
26
inventory.LoginMilestoneRewards.push(body.ChosenReward);
27
}
28
} else {
29
const randomRewards = getRandomLoginRewards(account, inventory);
30
chosenReward = randomRewards.find(x => x.StoreItemType == body.ChosenReward)!;
31
inventoryChanges = await claimLoginReward(inventory, chosenReward);
32
}
33
await inventory.save();
34
res.json({
35
DailyTributeInfo: {
36
NewInventory: inventoryChanges,
37
ChosenReward: chosenReward
38
}
39
});
40
};
41
42
interface ILoginRewardsSelectionRequest {
43
ChosenReward: string;
44
IsMilestoneReward: boolean;
45
}
46
47
const evergreenRewards = [
48
"/Lotus/Types/StoreItems/Packages/EvergreenTripleForma",
49
"/Lotus/Types/StoreItems/Packages/EvergreenTripleRifleRiven",
50
"/Lotus/Types/StoreItems/Packages/EvergreenTripleMeleeRiven",
51
"/Lotus/Types/StoreItems/Packages/EvergreenTripleSecondaryRiven",
52
"/Lotus/Types/StoreItems/Packages/EvergreenWeaponSlots",
53
"/Lotus/Types/StoreItems/Packages/EvergreenKuva",
54
"/Lotus/Types/StoreItems/Packages/EvergreenBoosters",
55
"/Lotus/Types/StoreItems/Packages/EvergreenEndo",
56
"/Lotus/Types/StoreItems/Packages/EvergreenExilus"
57
];
@@ -1,5 +1,5 @@
1
1
import { IAccountCreation } from "@/src/types/customTypes";
2
import { IDatabaseAccount } from "@/src/types/loginTypes";
2
import { IDatabaseAccountRequiredFields } from "@/src/types/loginTypes";
3
3
import crypto from "crypto";
4
4
import { isString, parseEmail, parseString } from "../general";
5
5
@@ -40,7 +40,7 @@ const toAccountCreation = (accountCreation: unknown): IAccountCreation => {
40
40
throw new Error("incorrect account creation data: incorrect properties");
41
41
};
42
42
43
const toDatabaseAccount = (createAccount: IAccountCreation): IDatabaseAccount => {
43
const toDatabaseAccount = (createAccount: IAccountCreation): IDatabaseAccountRequiredFields => {
44
44
return {
45
45
...createAccount,
46
46
ClientType: "",
@@ -48,9 +48,8 @@ const toDatabaseAccount = (createAccount: IAccountCreation): IDatabaseAccount =>
48
48
CrossPlatformAllowed: true,
49
49
ForceLogoutVersion: 0,
50
50
TrackedSettings: [],
51
Nonce: 0,
52
LatestEventMessageDate: new Date(0)
53
} satisfies IDatabaseAccount;
51
Nonce: 0
52
} satisfies IDatabaseAccountRequiredFields;
54
53
};
55
54
56
55
export { toDatabaseAccount, toAccountCreation as toCreateAccount };
@@ -1366,7 +1366,7 @@ const inventorySchema = new Schema<IInventoryDatabase, InventoryDocumentProps>(
1366
1366
ThemeSounds: String,
1367
1367
1368
1368
//Daily LoginRewards
1369
LoginMilestoneRewards: [String],
1369
LoginMilestoneRewards: { type: [String], default: [] },
1370
1370
1371
1371
//You first Dialog with NPC or use new Item
1372
1372
NodeIntrosCompleted: [String],
@@ -21,7 +21,9 @@ const databaseAccountSchema = new Schema<IDatabaseAccountJson>(
21
21
TrackedSettings: { type: [String], default: [] },
22
22
Nonce: { type: Number, default: 0 },
23
23
Dropped: Boolean,
24
LatestEventMessageDate: { type: Date, default: 0 }
24
LatestEventMessageDate: { type: Date, default: 0 },
25
LastLoginRewardDate: { type: Number, default: 0 },
26
LoginDays: { type: Number, default: 0 }
25
27
},
26
28
opts
27
29
);
@@ -63,6 +63,7 @@ import { inventorySlotsController } from "@/src/controllers/api/inventorySlotsCo
63
63
import { joinSessionController } from "@/src/controllers/api/joinSessionController";
64
64
import { loginController } from "@/src/controllers/api/loginController";
65
65
import { loginRewardsController } from "@/src/controllers/api/loginRewardsController";
66
import { loginRewardsSelectionController } from "@/src/controllers/api/loginRewardsSelectionController";
66
67
import { logoutController } from "@/src/controllers/api/logoutController";
67
68
import { marketRecommendationsController } from "@/src/controllers/api/marketRecommendationsController";
68
69
import { missionInventoryUpdateController } from "@/src/controllers/api/missionInventoryUpdateController";
@@ -202,6 +203,7 @@ apiRouter.post("/infestedFoundry.php", infestedFoundryController);
202
203
apiRouter.post("/inventorySlots.php", inventorySlotsController);
203
204
apiRouter.post("/joinSession.php", joinSessionController);
204
205
apiRouter.post("/login.php", loginController);
206
apiRouter.post("/loginRewardsSelection.php", loginRewardsSelectionController);
205
207
apiRouter.post("/missionInventoryUpdate.php", missionInventoryUpdateController);
206
208
apiRouter.post("/modularWeaponCrafting.php", modularWeaponCraftingController);
207
209
apiRouter.post("/modularWeaponSale.php", modularWeaponSaleController);
@@ -0,0 +1,140 @@
1
import randomRewards from "@/static/fixed_responses/loginRewards/randomRewards.json";
2
import { IInventoryChanges } from "../types/purchaseTypes";
3
import { TAccountDocument } from "./loginService";
4
import { CRng, mixSeeds } from "./rngService";
5
import { TInventoryDatabaseDocument } from "../models/inventoryModels/inventoryModel";
6
import { addBooster, updateCurrency } from "./inventoryService";
7
import { handleStoreItemAcquisition } from "./purchaseService";
8
import { ExportBoosters, ExportRecipes, ExportWarframes, ExportWeapons } from "warframe-public-export-plus";
9
import { toStoreItem } from "./itemDataService";
10
11
export interface ILoginRewardsReponse {
12
DailyTributeInfo: {
13
Rewards?: ILoginReward[]; // only set on first call of the day
14
IsMilestoneDay?: boolean;
15
IsChooseRewardSet?: boolean;
16
LoginDays?: number; // when calling multiple times per day, this is already incremented to represent "tomorrow"
17
//NextMilestoneReward?: "";
18
NextMilestoneDay?: number; // seems to not be used if IsMilestoneDay
19
HasChosenReward?: boolean;
20
NewInventory?: IInventoryChanges;
21
ChosenReward?: ILoginReward;
22
};
23
LastLoginRewardDate?: number; // only set on first call of the day; today at 0 UTC
24
}
25
26
export interface ILoginReward {
27
//_id: IOid;
28
RewardType: string;
29
//CouponType: "CPT_PLATINUM";
30
Icon: string;
31
//ItemType: "";
32
StoreItemType: string; // uniquely identifies the reward
33
//ProductCategory: "Pistols";
34
Amount: number;
35
ScalingMultiplier: number;
36
//Durability: "COMMON";
37
//DisplayName: "";
38
Duration: number;
39
//CouponSku: number;
40
//Rarity: number;
41
Transmission: string;
42
}
43
44
const scaleAmount = (day: number, amount: number, scalingMultiplier: number): number => {
45
const divisor = 200 / (amount * scalingMultiplier);
46
return amount + Math.min(day, 3000) / divisor;
47
};
48
49
// Always produces the same result for the same account _id & LoginDays pair.
50
export const getRandomLoginRewards = (
51
account: TAccountDocument,
52
inventory: TInventoryDatabaseDocument
53
): ILoginReward[] => {
54
const accountSeed = parseInt(account._id.toString().substring(16), 16);
55
const rng = new CRng(mixSeeds(accountSeed, account.LoginDays));
56
const rewards = [getRandomLoginReward(rng, account.LoginDays, inventory)];
57
// Using 25% an approximate chance for pick-a-doors. More conclusive data analysis is needed.
58
if (rng.random() < 0.25) {
59
do {
60
const reward = getRandomLoginReward(rng, account.LoginDays, inventory);
61
if (!rewards.find(x => x.StoreItemType == reward.StoreItemType)) {
62
rewards.push(reward);
63
}
64
} while (rewards.length != 3);
65
}
66
return rewards;
67
};
68
69
const getRandomLoginReward = (rng: CRng, day: number, inventory: TInventoryDatabaseDocument): ILoginReward => {
70
const reward = rng.randomReward(randomRewards)!;
71
//const reward = randomRewards.find(x => x.RewardType == "RT_BOOSTER")!;
72
if (reward.RewardType == "RT_RANDOM_RECIPE") {
73
// Not very faithful implementation but roughly the same idea
74
const masteredItems = new Set();
75
for (const entry of inventory.XPInfo) {
76
masteredItems.add(entry.ItemType);
77
}
78
const unmasteredItems = new Set();
79
for (const uniqueName of Object.keys(ExportWeapons)) {
80
if (!masteredItems.has(uniqueName)) {
81
unmasteredItems.add(uniqueName);
82
}
83
}
84
for (const uniqueName of Object.keys(ExportWarframes)) {
85
if (!masteredItems.has(uniqueName)) {
86
unmasteredItems.add(uniqueName);
87
}
88
}
89
const eligibleRecipes: string[] = [];
90
for (const [uniqueName, recipe] of Object.entries(ExportRecipes)) {
91
if (unmasteredItems.has(recipe.resultType)) {
92
eligibleRecipes.push(uniqueName);
93
}
94
}
95
reward.StoreItemType = toStoreItem(rng.randomElement(eligibleRecipes));
96
}
97
return {
98
//_id: toOid(new Types.ObjectId()),
99
RewardType: reward.RewardType,
100
//CouponType: "CPT_PLATINUM",
101
Icon: reward.Icon ?? "",
102
//ItemType: "",
103
StoreItemType: reward.StoreItemType,
104
//ProductCategory: "Pistols",
105
Amount: reward.Duration ? 1 : Math.round(scaleAmount(day, reward.Amount, reward.ScalingMultiplier)),
106
ScalingMultiplier: reward.ScalingMultiplier,
107
//Durability: "COMMON",
108
//DisplayName: "",
109
Duration: reward.Duration ? Math.round(reward.Duration * scaleAmount(day, 1, reward.ScalingMultiplier)) : 0,
110
//CouponSku: 0,
111
//Rarity: 0,
112
Transmission: reward.Transmission
113
};
114
};
115
116
export const claimLoginReward = async (
117
inventory: TInventoryDatabaseDocument,
118
reward: ILoginReward
119
): Promise<IInventoryChanges> => {
120
switch (reward.RewardType) {
121
case "RT_RESOURCE":
122
case "RT_STORE_ITEM":
123
case "RT_RECIPE":
124
case "RT_RANDOM_RECIPE":
125
return (await handleStoreItemAcquisition(reward.StoreItemType, inventory, reward.Amount)).InventoryChanges;
126
127
case "RT_CREDITS":
128
return updateCurrency(inventory, -reward.Amount, false);
129
130
case "RT_BOOSTER": {
131
const ItemType = ExportBoosters[reward.StoreItemType].typeName;
132
const ExpiryDate = 3600 * reward.Duration;
133
addBooster(ItemType, ExpiryDate, inventory);
134
return {
135
Boosters: [{ ItemType, ExpiryDate }]
136
};
137
}
138
}
139
throw new Error(`unknown login reward type: ${reward.RewardType}`);
140
};
@@ -1,6 +1,6 @@
1
1
import { Account } from "@/src/models/loginModel";
2
2
import { createInventory } from "@/src/services/inventoryService";
3
import { IDatabaseAccount, IDatabaseAccountJson } from "@/src/types/loginTypes";
3
import { IDatabaseAccountJson, IDatabaseAccountRequiredFields } from "@/src/types/loginTypes";
4
4
import { createShip } from "./shipService";
5
5
import { Document, Types } from "mongoose";
6
6
import { Loadout } from "@/src/models/inventoryModels/loadoutModel";
@@ -18,7 +18,7 @@ export const isNameTaken = async (name: string): Promise<boolean> => {
18
18
return !!(await Account.findOne({ DisplayName: name }));
19
19
};
20
20
21
export const createAccount = async (accountData: IDatabaseAccount): Promise<IDatabaseAccountJson> => {
21
export const createAccount = async (accountData: IDatabaseAccountRequiredFields): Promise<IDatabaseAccountJson> => {
22
22
const account = new Account(accountData);
23
23
try {
24
24
await account.save();
@@ -62,7 +62,7 @@ export const createPersonalRooms = async (accountId: Types.ObjectId, shipId: Typ
62
62
};
63
63
64
64
// eslint-disable-next-line @typescript-eslint/ban-types
65
type TAccountDocument = Document<unknown, {}, IDatabaseAccountJson> &
65
export type TAccountDocument = Document<unknown, {}, IDatabaseAccountJson> &
66
66
IDatabaseAccountJson & { _id: Types.ObjectId; __v: number };
67
67
68
68
export const getAccountForRequest = async (req: Request): Promise<TAccountDocument> => {
@@ -18,11 +18,11 @@ export const getRandomInt = (min: number, max: number): number => {
18
18
return Math.floor(Math.random() * (max - min + 1)) + min;
19
19
};
20
20
21
export const getRandomReward = <T extends { probability: number }>(pool: T[]): T | undefined => {
21
const getRewardAtPercentage = <T extends { probability: number }>(pool: T[], percentage: number): T | undefined => {
22
22
if (pool.length == 0) return;
23
23
24
24
const totalChance = pool.reduce((accum, item) => accum + item.probability, 0);
25
const randomValue = Math.random() * totalChance;
25
const randomValue = percentage * totalChance;
26
26
27
27
let cumulativeChance = 0;
28
28
for (const item of pool) {
@@ -34,6 +34,10 @@ export const getRandomReward = <T extends { probability: number }>(pool: T[]): T
34
34
throw new Error("What the fuck?");
35
35
};
36
36
37
export const getRandomReward = <T extends { probability: number }>(pool: T[]): T | undefined => {
38
return getRewardAtPercentage(pool, Math.random());
39
};
40
37
41
export const getRandomWeightedReward = <T extends { rarity: TRarity }>(
38
42
pool: T[],
39
43
weights: Record<TRarity, number>
@@ -70,6 +74,15 @@ export const getRandomWeightedRewardUc = <T extends { Rarity: TRarity }>(
70
74
return getRandomReward(resultPool);
71
75
};
72
76
77
// ChatGPT generated this. It seems to have a good enough distribution.
78
export const mixSeeds = (seed1: number, seed2: number): number => {
79
let seed = seed1 ^ seed2;
80
seed ^= seed >>> 21;
81
seed ^= seed << 35;
82
seed ^= seed >>> 4;
83
return seed >>> 0;
84
};
85
73
86
// Seeded RNG for internal usage. Based on recommendations in the ISO C standards.
74
87
export class CRng {
75
88
state: number;
@@ -92,6 +105,10 @@ export class CRng {
92
105
randomElement<T>(arr: T[]): T {
93
106
return arr[Math.floor(this.random() * arr.length)];
94
107
}
108
109
randomReward<T extends { probability: number }>(pool: T[]): T | undefined {
110
return getRewardAtPercentage(pool, this.random());
111
}
95
112
}
96
113
97
114
// Seeded RNG for cases where we need identical results to the game client. Based on work by Donald Knuth.
@@ -11,11 +11,16 @@ export interface IAccountAndLoginResponseCommons {
11
11
Nonce: number;
12
12
}
13
13
14
export interface IDatabaseAccount extends IAccountAndLoginResponseCommons {
14
export interface IDatabaseAccountRequiredFields extends IAccountAndLoginResponseCommons {
15
15
email: string;
16
16
password: string;
17
}
18
19
export interface IDatabaseAccount extends IDatabaseAccountRequiredFields {
17
20
Dropped?: boolean;
18
21
LatestEventMessageDate: Date;
22
LastLoginRewardDate: number;
23
LoginDays: number;
19
24
}
20
25
21
26
// Includes virtual ID
@@ -1,9 +0,0 @@
1
{
2
"DailyTributeInfo": {
3
"IsMilestoneDay": false,
4
"IsChooseRewardSet": true,
5
"LoginDays": 1337,
6
"NextMilestoneReward": "",
7
"NextMilestoneDay": 50
8
}
9
}