XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

SpaceNinjaServer

A simple server for a small space ninja game

公开
关注 0 Fork 1 Star 0
返回提交历史

XFEstudio/SpaceNinjaServer

feat: daily tribute (#1241)

Closes #367 Reviewed-on: https://onlyg.it/OpenWF/SpaceNinjaServer/pulls/1241

6598318f
Sainan <sainan@calamity.inc>
提交于

代码差异

13 个文件 +460 -29
Modified src/controllers/api/loginController.ts +1 -2
@@ -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));
Modified src/controllers/api/loginRewardsController.ts +37 -5
@@ -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 };
Added src/controllers/api/loginRewardsSelectionController.ts +57 -0
@@ -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 ];
Modified src/helpers/customHelpers/customHelpers.ts +4 -5
@@ -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 };
Modified src/models/inventoryModels/inventoryModel.ts +1 -1
@@ -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],
Modified src/models/loginModel.ts +3 -1
@@ -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 );
Modified src/routes/api.ts +2 -0
@@ -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);
Added src/services/loginRewardService.ts +140 -0
@@ -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 };
Modified src/services/loginService.ts +3 -3
@@ -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> => {
Modified src/services/rngService.ts +19 -2
@@ -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.
Modified src/types/loginTypes.ts +6 -1
@@ -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
Deleted static/fixed_responses/loginRewards.json +0 -9
@@ -1,9 +0,0 @@
1 {
2 "DailyTributeInfo": {
3 "IsMilestoneDay": false,
4 "IsChooseRewardSet": true,
5 "LoginDays": 1337,
6 "NextMilestoneReward": "",
7 "NextMilestoneDay": 50
8 }
9 }
Added static/fixed_responses/loginRewards/randomRewards.json +187 -0