1
mirror of https://github.com/thepeacockproject/Peacock synced 2024-11-29 09:15:11 +01:00
Peacock/components/candle/masteryService.ts
Reece Dunham 5cc69434c6
Enable strict types mode (#362)
Signed-off-by: Reece Dunham <me@rdil.rocks>
2024-02-02 14:46:44 -05:00

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
}
}