Skip to content

Commit d4bbed4

Browse files
authored
refactor: Make inventory class-based (#678)
<!-- Thanks for contributing to Peacock! Here's a bit of a template to help make sure everything relevant is covered. --> ## Scope <!-- List any relevant changes you have made here. Be sure to link any issues fixed, or that are relevant to these changes. --> Two big changes here: 1. inventory is a class now, so the stateful parts are not stored on a module level 2. controller instance can be overridden for tests, so that code which hasn't yet been migrated to the IoC model can use the intended controller instance. ## Test Plan <!-- List how you have verified these changes work as intended. --> - Start the server, verify that logging in still works ## Checklist <!-- Once you create the PR, the checklist will be added below this comment automatically (based on what files you've changed). It's just a few reminders to make sure everything is perfect. You can place an "X" in the boxes to tick them off. When you have completed the checklist, press the "Ready for review" button. --> -------- #### General - [x] I've run Prettier to format any changed files - [x] I've verified that my changes work, and included a test plan -------- #### Testing - [x] I have added or considered adding unit/integration tests that cover any code changes
2 parents bf42b91 + a6c7401 commit d4bbed4

18 files changed

Lines changed: 799 additions & 631 deletions

components/candle/challengeService.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,6 @@ import { getVersionedConfig } from "../configSwizzleManager"
6868
import { SyncHook } from "../hooksImpl"
6969
import { getUserEscalationProgress } from "../contracts/escalations/escalationService"
7070

71-
import { getUnlockableById } from "../inventory"
7271
import { enqueueEvent } from "../eventHandler"
7372
import { randomUUID } from "crypto"
7473

@@ -1574,7 +1573,7 @@ export class ChallengeService extends ChallengeRegistry {
15741573
isDestination = false,
15751574
): CompiledChallengeTreeData {
15761575
const drops = challenge.Drops.map((e) =>
1577-
getUnlockableById(e, gameVersion),
1576+
this.controller.inventoryService.getUnlockableById(e, gameVersion),
15781577
).filter(Boolean) as Unlockable[]
15791578

15801579
if (drops.length !== challenge.Drops.length) {

components/candle/masteryService.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ import {
4747
xpRequiredForSniperLevel,
4848
} from "../utils"
4949

50-
import { getUnlockablesById } from "../inventory"
50+
import { controller } from "../controller"
5151
import assert from "assert"
5252

5353
export class MasteryService {
@@ -395,7 +395,7 @@ export class MasteryService {
395395
}
396396

397397
// Get all unlockables with matching Ids
398-
const unlockableData = getUnlockablesById(
398+
const unlockableData = controller.inventoryService.getUnlockablesById(
399399
Array.from(dropIdSet),
400400
gameVersion,
401401
)

components/candle/progressionService.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818

1919
import { getSubLocationByName } from "../contracts/dataGen"
2020
import { controller } from "../controller"
21-
import { getUnlockablesById, grantDrops } from "../inventory"
2221
import type {
2322
ContractSession,
2423
GameVersion,
@@ -74,11 +73,11 @@ export class ProgressionService {
7473
// Award provided drops. E.g. From challenges. Don't run this function
7574
// if there aren't any drops being granted.
7675
if (dropIds.length > 0) {
77-
grantDrops(
76+
controller.inventoryService.grantDrops(
7877
userProfile.Id,
79-
getUnlockablesById(dropIds, contractSession.gameVersion).filter(
80-
Boolean,
81-
) as Unlockable[],
78+
controller.inventoryService
79+
.getUnlockablesById(dropIds, contractSession.gameVersion)
80+
.filter(Boolean) as Unlockable[],
8281
)
8382
}
8483

@@ -116,7 +115,10 @@ export class ProgressionService {
116115
.filter((drop) => drop.Level > minLevel && drop.Level <= maxLevel)
117116
.map((drop) => drop.Id)
118117

119-
const unlockables = getUnlockablesById(unlockableIds, gameVersion)
118+
const unlockables = controller.inventoryService.getUnlockablesById(
119+
unlockableIds,
120+
gameVersion,
121+
)
120122

121123
/**
122124
* If missions type is evergreen, checks if any of the unlockables has unlockable gear, and award those too
@@ -134,7 +136,7 @@ export class ProgressionService {
134136

135137
if (evergreenGearUnlockables.length) {
136138
unlockables.push(
137-
...getUnlockablesById(
139+
...controller.inventoryService.getUnlockablesById(
138140
evergreenGearUnlockables,
139141
gameVersion,
140142
),
@@ -234,7 +236,10 @@ export class ProgressionService {
234236
previousLevel,
235237
locationData.Level,
236238
).filter(Boolean) as Unlockable[]
237-
grantDrops(userProfile.Id, masteryLocationDrops)
239+
controller.inventoryService.grantDrops(
240+
userProfile.Id,
241+
masteryLocationDrops,
242+
)
238243
}
239244
}
240245

components/controller.ts

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import { log, LogLevel } from "./loggingInterop"
4747
import * as axios from "axios"
4848
import {
4949
addDashesToPublicId,
50+
deprecated,
5051
fastClone,
5152
getRemoteService,
5253
hitmapsUrl,
@@ -69,12 +70,14 @@ import { ChallengeFilterType, Pro1FilterType } from "./candle/challengeHelpers"
6970
import { MasteryService } from "./candle/masteryService"
7071
import { MasteryPackage } from "./types/mastery"
7172
import { ProgressionService } from "./candle/progressionService"
73+
import { InventoryService } from "./inventory"
7274
import generatedPeacockRequireTable from "./generatedPeacockRequireTable"
7375
import { escalationTypes } from "./contracts/escalations/escalationService"
7476
import { orderedETAs } from "./contracts/elusiveTargetArcades"
7577
import { SMFSupport } from "./smfSupport"
7678
import { glob } from "fast-glob"
7779
import { asyncGuard } from "./databaseHandler"
80+
import { createDelegatingProxy } from "./delegation"
7881

7982
/**
8083
* An array of string arrays that contains the IDs of the featured contracts.
@@ -392,6 +395,7 @@ export class Controller {
392395
public masteryService!: MasteryService
393396
escalationMappings: Map<string, Record<string, string>> = new Map()
394397
public progressionService!: ProgressionService
398+
public inventoryService!: InventoryService
395399
public smf!: SMFSupport
396400
private _pubIdToContractId: Map<string, string> = new Map()
397401
/** Internal elusive target contracts - only accessible during bootstrap. */
@@ -420,6 +424,49 @@ export class Controller {
420424
onEscalationReset: new SyncHook(),
421425
onUserLogin: new SyncHook(),
422426
}
427+
428+
this.inventoryService = new InventoryService(this)
429+
}
430+
431+
private _patchRequireTableInventory(): void {
432+
const inventoryServiceMethods = new Set([
433+
"clearInventoryFor",
434+
"clearInventoryCache",
435+
"getUnlockableById",
436+
"getUnlockablesById",
437+
"getDefaultSuitFor",
438+
"createInventory",
439+
"grantDrops",
440+
])
441+
442+
const service = this.inventoryService
443+
444+
const key =
445+
"@peacockproject/core/inventory" as keyof typeof generatedPeacockRequireTable
446+
447+
const original = generatedPeacockRequireTable[key]
448+
449+
// @ts-expect-error Patching the generated table at runtime.
450+
generatedPeacockRequireTable[key] = new Proxy(original, {
451+
get(target, prop, receiver) {
452+
if (
453+
typeof prop === "string" &&
454+
inventoryServiceMethods.has(prop)
455+
) {
456+
deprecated(
457+
`inventory.${prop}`,
458+
`controller.inventoryService.${prop}`,
459+
"v9",
460+
)
461+
462+
return (...args: unknown[]) =>
463+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
464+
(service as any)[prop](...args)
465+
}
466+
467+
return Reflect.get(target, prop, receiver)
468+
},
469+
})
423470
}
424471

425472
/**
@@ -467,11 +514,7 @@ export class Controller {
467514
* @throws {Error} If all hope is lost. (In theory, this should never happen)
468515
*/
469516
async boot(pluginDevHost: boolean): Promise<void> {
470-
// this should never actually be hit, but it makes IntelliJ not
471-
// complain that it's unused, so...
472-
if (!this.configManager) {
473-
throw new Error("All hope is lost.")
474-
}
517+
this._patchRequireTableInventory()
475518

476519
log(
477520
LogLevel.INFO,
@@ -1526,4 +1569,18 @@ export async function preserveContracts(
15261569
}
15271570
}
15281571

1529-
export const controller = new Controller()
1572+
// We need to be able to swap out the global controller instance in tests without invalidating the module graph.
1573+
// This lets us do that until we can get everything to use the IoC model.
1574+
const controllerContainer = { instance: new Controller() }
1575+
1576+
export const controller: Controller = createDelegatingProxy(controllerContainer)
1577+
1578+
/**
1579+
* Replaces the global controller instance. Don't use outside of tests.
1580+
* @internal
1581+
*/
1582+
export function _dangerouslyOverwriteController(
1583+
newController: Controller,
1584+
): void {
1585+
controllerContainer.instance = newController
1586+
}

components/delegation.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/*
2+
* The Peacock Project - a HITMAN server replacement.
3+
* Copyright (C) 2021-2026 The Peacock Project Team
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU Affero General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU Affero General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU Affero General Public License
16+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
17+
*/
18+
19+
interface DelegationContainer<T extends object> {
20+
instance: T
21+
}
22+
23+
/**
24+
* Creates a proxy that transparently delegates all operations to
25+
* `container.instance`. Swapping `container.instance` changes what
26+
* the proxy delegates to, without changing the proxy's identity.
27+
*/
28+
export function createDelegatingProxy<T extends object>(
29+
container: DelegationContainer<T>,
30+
): T {
31+
return new Proxy(Object.create(null) as T, {
32+
get(_target, prop, receiver) {
33+
const value = Reflect.get(container.instance, prop, receiver)
34+
35+
if (typeof value === "function") {
36+
return value.bind(container.instance)
37+
}
38+
39+
return value
40+
},
41+
set(_target, prop, value) {
42+
return Reflect.set(container.instance, prop, value)
43+
},
44+
has(_target, prop) {
45+
return Reflect.has(container.instance, prop)
46+
},
47+
ownKeys() {
48+
return Reflect.ownKeys(container.instance)
49+
},
50+
getOwnPropertyDescriptor(_target, prop) {
51+
return Reflect.getOwnPropertyDescriptor(container.instance, prop)
52+
},
53+
getPrototypeOf() {
54+
return Reflect.getPrototypeOf(container.instance)
55+
},
56+
})
57+
}

0 commit comments

Comments
 (0)