mirror of
https://github.com/thepeacockproject/Peacock
synced 2024-11-29 09:15:11 +01:00
5cc69434c6
Signed-off-by: Reece Dunham <me@rdil.rocks>
458 lines
15 KiB
TypeScript
458 lines
15 KiB
TypeScript
/*
|
|
* The Peacock Project - a HITMAN server replacement.
|
|
* Copyright (C) 2021-2024 The Peacock Project Team
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
import {
|
|
getParentLocationByName,
|
|
getSubLocationByName,
|
|
} from "../contracts/dataGen"
|
|
import { log, LogLevel } from "../loggingInterop"
|
|
import { getVersionedConfig } from "../configSwizzleManager"
|
|
import { getUserData } from "../databaseHandler"
|
|
import {
|
|
LocationMasteryData,
|
|
MasteryData,
|
|
MasteryDrop,
|
|
MasteryPackage,
|
|
MasteryPackageDrop,
|
|
UnlockableMasteryData,
|
|
} from "../types/mastery"
|
|
import {
|
|
CompletionData,
|
|
GameVersion,
|
|
ProgressionData,
|
|
Unlockable,
|
|
} from "../types/types"
|
|
import {
|
|
clampValue,
|
|
DEFAULT_MASTERY_MAXLEVEL,
|
|
isSniperLocation,
|
|
xpRequiredForEvergreenLevel,
|
|
xpRequiredForLevel,
|
|
xpRequiredForSniperLevel,
|
|
} from "../utils"
|
|
|
|
import { getUnlockablesById } from "../inventory"
|
|
import assert from "assert"
|
|
|
|
export class MasteryService {
|
|
/**
|
|
* @Key1 Game version.
|
|
* @Key2 The parent location Id.
|
|
* @Value A `MasteryPackage` object.
|
|
*/
|
|
protected masteryPackages: Record<
|
|
GameVersion,
|
|
Map<string, MasteryPackage>
|
|
> = {
|
|
h1: new Map(),
|
|
h2: new Map(),
|
|
h3: new Map(),
|
|
scpc: new Map(),
|
|
}
|
|
/**
|
|
* @Key1 Game version.
|
|
* @Key2 Unlockable Id.
|
|
* @Value A `MasteryPackage` object.
|
|
*/
|
|
private unlockableMasteryData: Record<
|
|
GameVersion,
|
|
Map<string, UnlockableMasteryData>
|
|
> = {
|
|
h1: new Map(),
|
|
h2: new Map(),
|
|
h3: new Map(),
|
|
scpc: new Map(),
|
|
}
|
|
|
|
registerMasteryData(masteryPackage: MasteryPackage) {
|
|
for (const gv of masteryPackage.GameVersions) {
|
|
this.masteryPackages[gv].set(
|
|
masteryPackage.LocationId,
|
|
masteryPackage,
|
|
)
|
|
|
|
/**
|
|
* Generates the same data in a reverse order. It could be considered redundant but this allows for
|
|
* faster access to location and level based on unlockable ID, avoiding big-O operation for `getMasteryForUnlockable`
|
|
*/
|
|
if (masteryPackage.SubPackages) {
|
|
for (const subPkg of masteryPackage.SubPackages) {
|
|
for (const drop of subPkg.Drops) {
|
|
this.unlockableMasteryData[gv].set(drop.Id, {
|
|
Location: masteryPackage.LocationId,
|
|
SubPackageId: subPkg.Id,
|
|
Level: drop.Level,
|
|
})
|
|
}
|
|
}
|
|
} else {
|
|
for (const drop of masteryPackage.Drops) {
|
|
this.unlockableMasteryData[gv].set(drop.Id, {
|
|
Location: masteryPackage.LocationId,
|
|
Level: drop.Level,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns mastery data for unlockable, if there's any
|
|
* @param unlockable
|
|
* @param gameVersion
|
|
*/
|
|
getMasteryForUnlockable(
|
|
unlockable: Unlockable,
|
|
gameVersion: GameVersion,
|
|
): UnlockableMasteryData | undefined {
|
|
return this.unlockableMasteryData[gameVersion].get(unlockable.Id)
|
|
}
|
|
|
|
/**
|
|
* Exact same thing as {@link getMasteryData}.
|
|
*/
|
|
getMasteryDataForDestination(
|
|
locationParentId: string,
|
|
gameVersion: GameVersion,
|
|
userId: string,
|
|
difficulty?: string,
|
|
): MasteryData[] {
|
|
return this.getMasteryData(
|
|
locationParentId,
|
|
gameVersion,
|
|
userId,
|
|
difficulty,
|
|
)
|
|
}
|
|
|
|
getMasteryDataForSubPackage(
|
|
locationParentId: string,
|
|
subPackageId: string,
|
|
gameVersion: GameVersion,
|
|
userId: string,
|
|
): MasteryData {
|
|
// Since we're getting a subpackage, we know there will only be one entry in this array.
|
|
// If the array is empty it will return undefined.
|
|
return this.getMasteryData(
|
|
locationParentId,
|
|
gameVersion,
|
|
userId,
|
|
subPackageId,
|
|
)[0]
|
|
}
|
|
|
|
getMasteryDataForLocation(
|
|
locationId: string,
|
|
gameVersion: GameVersion,
|
|
userId: string,
|
|
): LocationMasteryData {
|
|
const location =
|
|
getSubLocationByName(locationId, gameVersion) ??
|
|
getParentLocationByName(locationId, gameVersion)
|
|
|
|
assert.ok(location, "cannot get mastery data for unknown location")
|
|
|
|
const masteryData = this.getMasteryData(
|
|
location.Properties.ParentLocation ?? location.Id,
|
|
gameVersion,
|
|
userId,
|
|
)
|
|
|
|
return {
|
|
Location: location,
|
|
MasteryData: masteryData,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get generic completion data stored in a user's profile. Called by both `getLocationCompletion` and `getFirearmCompletion`.
|
|
* @param userId The id of the user.
|
|
* @param gameVersion The game version.
|
|
* @param locationParentId The location's parent ID, used for progression storage @since v7.0.0
|
|
* @param maxLevel The max level for this progression.
|
|
* @param levelToXpRequired A function to get the XP required for a level.
|
|
* @param subPackageId The subpackage id you want.
|
|
*/
|
|
private getCompletionData(
|
|
userId: string,
|
|
gameVersion: GameVersion,
|
|
locationParentId: string,
|
|
maxLevel: number,
|
|
levelToXpRequired: (level: number) => number,
|
|
subPackageId?: string,
|
|
) {
|
|
// Get the user profile
|
|
const userProfile = getUserData(userId, gameVersion)
|
|
|
|
const parent =
|
|
userProfile.Extensions.progression.Locations[locationParentId]
|
|
|
|
const completionData: ProgressionData = subPackageId
|
|
? (parent[subPackageId as keyof typeof parent] as ProgressionData)
|
|
: (parent as ProgressionData)
|
|
|
|
const nextLevel: number = clampValue(
|
|
completionData.Level + 1,
|
|
1,
|
|
maxLevel,
|
|
)
|
|
|
|
const nextLevelXp: number = levelToXpRequired(nextLevel)
|
|
|
|
const thisLevelXp: number = levelToXpRequired(completionData.Level)
|
|
|
|
return {
|
|
Level: completionData.Level,
|
|
MaxLevel: maxLevel,
|
|
XP: completionData.Xp,
|
|
PreviouslySeenXp: completionData.PreviouslySeenXp,
|
|
Completion:
|
|
(completionData.Xp - thisLevelXp) / (nextLevelXp - thisLevelXp),
|
|
XpLeft: nextLevelXp - completionData.Xp,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the completion data for a location.
|
|
* @param locationParentId The parent Id of the location.
|
|
* @param subLocationId The id of the sublocation.
|
|
* @param gameVersion The game version.
|
|
* @param userId The id of the user.
|
|
* @param contractType The type of the contract, only used to distinguish evergreen from other types (default).
|
|
* @param subPackageId The id of the subpackage you want.
|
|
* @returns The CompletionData object.
|
|
*/
|
|
getLocationCompletion(
|
|
locationParentId: string,
|
|
subLocationId: string,
|
|
gameVersion: GameVersion,
|
|
userId: string,
|
|
contractType = "mission",
|
|
subPackageId?: string,
|
|
): CompletionData | undefined {
|
|
// Get the mastery data
|
|
const masteryPkg = this.getMasteryPackage(locationParentId, gameVersion)
|
|
|
|
// We use the result from this function a bit, so we're just caching it
|
|
const isSniper = isSniperLocation(locationParentId)
|
|
|
|
if (!masteryPkg || (masteryPkg.SubPackages && !subPackageId)) {
|
|
return undefined
|
|
}
|
|
|
|
const subPackage = masteryPkg.SubPackages
|
|
? masteryPkg.SubPackages.filter((pkg) => pkg.Id === subPackageId)[0]
|
|
: undefined
|
|
|
|
if (!subPackage && subPackageId) {
|
|
return undefined
|
|
}
|
|
|
|
// TODO: Refactor this into the new inventory system?
|
|
const name = isSniper
|
|
? getVersionedConfig<Unlockable[]>(
|
|
"SniperUnlockables",
|
|
gameVersion,
|
|
false,
|
|
).find((unlockable) => unlockable.Id === subPackageId)?.Properties
|
|
.Name
|
|
: undefined
|
|
|
|
return {
|
|
...this.getCompletionData(
|
|
userId,
|
|
gameVersion,
|
|
locationParentId,
|
|
(subPackage ? subPackage.MaxLevel : masteryPkg.MaxLevel) ||
|
|
DEFAULT_MASTERY_MAXLEVEL,
|
|
contractType === "sniper"
|
|
? xpRequiredForSniperLevel
|
|
: contractType === "evergreen"
|
|
? xpRequiredForEvergreenLevel
|
|
: xpRequiredForLevel,
|
|
subPackageId,
|
|
),
|
|
Id: isSniper ? subPackageId! : masteryPkg.LocationId,
|
|
SubLocationId: isSniper ? "" : subLocationId,
|
|
HideProgression: masteryPkg.HideProgression || false,
|
|
IsLocationProgression: !isSniper,
|
|
Name: name!,
|
|
}
|
|
}
|
|
|
|
getMasteryPackage(
|
|
locationParentId: string,
|
|
gameVersion: GameVersion,
|
|
): MasteryPackage | undefined {
|
|
return this.masteryPackages[gameVersion].get(locationParentId)
|
|
}
|
|
|
|
private processDrops(
|
|
curLevel: number,
|
|
drops: MasteryPackageDrop[],
|
|
unlockableMap: Map<string, Unlockable>,
|
|
): MasteryDrop[] {
|
|
return drops
|
|
.filter((drop) => {
|
|
if (!unlockableMap.has(drop.Id)) {
|
|
log(LogLevel.DEBUG, `No unlockable found for ${drop.Id}`)
|
|
|
|
return false
|
|
}
|
|
|
|
return true
|
|
})
|
|
.map((drop) => {
|
|
const unlockable: Unlockable = unlockableMap.get(drop.Id)!
|
|
|
|
return {
|
|
IsLevelMarker: false,
|
|
Unlockable: unlockable,
|
|
Level: drop.Level,
|
|
IsLocked: drop.Level > curLevel,
|
|
TypeLocaKey: `UI_MENU_PAGE_MASTERY_UNLOCKABLE_NAME_${unlockable.Type}`,
|
|
}
|
|
})
|
|
}
|
|
|
|
private getMasteryData(
|
|
locationParentId: string,
|
|
gameVersion: GameVersion,
|
|
userId: string,
|
|
subPackageId?: string,
|
|
): MasteryData[] {
|
|
// Get the mastery data
|
|
const masteryPkg: MasteryPackage | undefined = this.getMasteryPackage(
|
|
locationParentId,
|
|
gameVersion,
|
|
)
|
|
|
|
if (!masteryPkg || (!masteryPkg.Drops && !masteryPkg.SubPackages)) {
|
|
return []
|
|
}
|
|
|
|
// We use the result from this function a lot in here, so we're just "caching" it
|
|
const isSniper = isSniperLocation(locationParentId)
|
|
|
|
// Put all Ids into a set for quick lookup
|
|
let dropIdSet: Set<string>
|
|
|
|
if (masteryPkg.SubPackages) {
|
|
dropIdSet = new Set(
|
|
masteryPkg.SubPackages.map((pkg) =>
|
|
subPackageId && pkg.Id !== subPackageId
|
|
? []
|
|
: pkg.Drops.map((drop) => drop.Id),
|
|
).reduce((a, e) => a.concat(e)),
|
|
)
|
|
|
|
// Add the main unlockable to the set to generate the page properly
|
|
if (isSniper) {
|
|
masteryPkg.SubPackages.forEach((pkg) => dropIdSet.add(pkg.Id))
|
|
}
|
|
|
|
if (!dropIdSet || dropIdSet.size === 0) {
|
|
log(
|
|
LogLevel.ERROR,
|
|
"Unknown subPackageId specified for location",
|
|
)
|
|
|
|
return []
|
|
}
|
|
} else {
|
|
dropIdSet = new Set(masteryPkg.Drops.map((drop) => drop.Id))
|
|
}
|
|
|
|
// Get all unlockables with matching Ids
|
|
const unlockableData = getUnlockablesById(
|
|
Array.from(dropIdSet),
|
|
gameVersion,
|
|
)
|
|
|
|
// Put all unlockabkes in a map for quick lookup
|
|
const mapped: [string, Unlockable][] = unlockableData.map(
|
|
(unlockable) => {
|
|
return [unlockable?.Id, unlockable] as unknown as [
|
|
string,
|
|
Unlockable,
|
|
]
|
|
},
|
|
)
|
|
|
|
const unlockableMap: Map<string, Unlockable> = new Map(mapped)
|
|
|
|
const masteryData: MasteryData[] = []
|
|
|
|
if (masteryPkg.SubPackages) {
|
|
for (const subPkg of masteryPkg.SubPackages) {
|
|
if (subPackageId && subPkg.Id !== subPackageId) continue
|
|
|
|
const completionData = this.getLocationCompletion(
|
|
locationParentId,
|
|
locationParentId,
|
|
gameVersion,
|
|
userId,
|
|
isSniper
|
|
? "sniper"
|
|
: locationParentId.includes("SNUG")
|
|
? "evergreen"
|
|
: "mission",
|
|
subPkg.Id,
|
|
)
|
|
|
|
if (completionData) {
|
|
masteryData.push({
|
|
CompletionData: completionData,
|
|
Drops: this.processDrops(
|
|
completionData.Level,
|
|
subPkg.Drops,
|
|
unlockableMap,
|
|
),
|
|
Unlockable: isSniper
|
|
? unlockableMap.get(subPkg.Id)
|
|
: undefined,
|
|
})
|
|
}
|
|
}
|
|
} else {
|
|
// All sniper locations are subpackages, so we don't need to add "sniper"
|
|
// to the contractType expression.
|
|
const completionData = this.getLocationCompletion(
|
|
locationParentId,
|
|
locationParentId,
|
|
gameVersion,
|
|
userId,
|
|
locationParentId.includes("SNUG") ? "evergreen" : "mission",
|
|
)
|
|
|
|
if (completionData) {
|
|
masteryData.push({
|
|
CompletionData: completionData,
|
|
Drops: this.processDrops(
|
|
completionData.Level,
|
|
masteryPkg.Drops || [],
|
|
unlockableMap,
|
|
),
|
|
})
|
|
}
|
|
}
|
|
|
|
return masteryData
|
|
}
|
|
}
|