/* * The Peacock Project - a HITMAN server replacement. * Copyright (C) 2021-2023 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 { NextFunction, Response, Router } from "express" import serveStatic from "serve-static" import { join } from "path" import md5File from "md5-file" import { getConfig } from "../configSwizzleManager" import { readFile } from "atomically" import { GameVersion, RequestWithJwt } from "../types/types" import { log, LogLevel } from "../loggingInterop" import { imageFetchingMiddleware } from "./imageHandler" import { SyncBailHook, SyncHook } from "../hooksImpl" /** * Router triggered before {@link menuSystemRouter}. */ const menuSystemPreRouter = Router() const menuSystemRouter = Router() // /resources-8-11/ /** * A class for managing the menu system's fetched JSON data. */ export class MenuSystemDatabase { /** * The hooks. */ hooks: { /** * A hook for getting a list of configurations which the game should * fetch from the server. * * Params: * - configs: The configurations list (mutable). These should be full paths, * for instance, `/menusystem/data/testing.json`. * - gameVersion: The game's version. */ getDatabaseDiff: SyncHook< [/** configs */ string[], /** gameVersion */ GameVersion] > /** * A hook for getting the requested configuration. * * Params: * - configName: The requested file's name. * - gameVersion: The game's version. * * Returns: The file as an object. */ getConfig: SyncBailHook< [/** configName */ string, /** gameVersion */ GameVersion], unknown > } constructor() { this.hooks = { getDatabaseDiff: new SyncHook(), getConfig: new SyncBailHook(), } this.hooks.getDatabaseDiff.tap( "PeacockInternal", (configs, gameVersion) => { if (gameVersion === "h3") { configs.push( "menusystem/elements/settings/data/isnonvroptionvisible.json", ) configs.push( "images/unlockables/outfit_ef223b60-b53a-4c7b-b914-13c3310fc61a_0.jpg", ) } if (["h3", "h1"].includes(gameVersion)) { configs.push("menusystem/pages/hub/hub_page.json") } configs.push("menusystem/data/ishitman3available.json") configs.push("menusystem/pages/hub/modals/roadmap/modal.json") configs.push( "menusystem/pages/hub/data/isfullmenuavailable.json", ) if (["h3", "h2"].includes(gameVersion)) { configs.push( "menusystem/pages/hub/dashboard/dashboard.json", ) configs.push( "menusystem/pages/hub/dashboard/category_escalation/result.json", ) configs.push( "menusystem/elements/contract/hitscategory_elusive.json", ) // The following is to allow restart/replan/save/load on elusive contracts // alongside removing the warning when starting one in H2/3 - AF configs.push( "menusystem/pages/pause/pausemenu/restart.json", ) configs.push("menusystem/pages/pause/pausemenu/replan.json") configs.push("menusystem/pages/pause/pausemenu/save.json") configs.push("menusystem/pages/pause/pausemenu/load.json") configs.push( "menusystem/pages/planning/actions/actions_contextbutton_play.json", ) } if (gameVersion === "h2") { configs.push("menusystem/data/ismultiplayeravailable.json") configs.push( "menusystem/pages/multiplayer/content/lobbyslim.json", ) configs.push( "menusystem/elements/contract/contractshitcategoryloading.json", ) } }, ) this.hooks.getConfig.tap("PeacockInternal", (name, gameVersion) => { switch (name) { case "/elements/settings/data/isnonvroptionvisible.json": return { $if: { $condition: { $and: ["$isingame", "$not $isineditor"], }, $then: "$eq($vrmode,off)", $else: true, }, } case "/elements/contract/hitscategory_elusive.json": return getConfig("HitsCategoryElusiveTemplate", false) case "/elements/contract/contractshitcategoryloading.json": return { controller: "group", view: "menu3.containers.ScrollingListContainer", layoutchildren: true, id: "hitscategory_container", nrows: 3, ncols: 10, pressable: false, data: { direction: "horizontal" }, actions: { activated: { "load-async": { path: { "$if $eq ($.Category,Elusive_Target_Hits)": { $then: "menusystem/elements/contract/hitscategory_elusive.json", $else: "menusystem/elements/contract/hitscategory.json", }, }, from: { url: "hitscategory", args: { page: 0, type: "$.Category", mode: "dataonly", }, }, target: "hitscategory_container", showloadingindicator: true, blocksinput: false, "post-load-action": [ { "set-selected": { target: "hitscategory_container", }, }, ], }, }, }, children: [{ pressable: false, selectable: true }], } case "/data/ishitman3available.json": return { "$if $eq (0,0)": { $then: "$isonline", $else: false, }, } case "/pages/hub/modals/roadmap/modal.json": return getConfig("Roadmap", false) case "/pages/hub/hub_page.json": return getConfig("HubPageData", false) case "/pages/hub/data/isfullmenuavailable.json": return { "$if $not $isuser freeprologue": { $then: true, $else: { $and: [ "$not $eq($platform,izumo)", "$isonline", ], }, }, } case "/pages/hub/dashboard/dashboard.json": if (gameVersion === "h3") { return getConfig("EiderDashboard", false) } else if (gameVersion === "h2") { return getConfig("H2DashboardTemplate", false) } return undefined case "/pages/hub/dashboard/category_escalation/result.json": return getConfig("DashboardCategoryEscalation", false) case "/data/ismultiplayeravailable.json": return { "$if $eq ($platform,stadia)": { $then: false, $else: true, }, } case "/pages/multiplayer/content/lobbyslim.json": return getConfig("LobbySlimTemplate", false) case "/pages/pause/pausemenu/restart.json": return { $if: { $condition: { $or: [ "$not $eq({$currentcontractcontext}.ContractType,evergreen)", "$isallowedtorestart", ], }, $then: { $include: { $path: "menusystem/pages/pause/pausemenu/restartnoconditions.json", }, }, }, } case "/pages/pause/pausemenu/replan.json": return { $if: { $condition: { $and: [ "$not $eq({$currentcontractcontext}.ContractLocation,LOCATION_ICA_FACILITY)", "$not $eq({$currentcontractcontext}.ContractType,tutorial)", "$not $eq({$currentcontractcontext}.ContractType,vsrace)", "$not $eq({$currentcontractcontext}.ContractType,evergreen)", ], }, $then: { "$if $isallowedtorestart": { $then: { view: "menu3.basic.ListElementSmall", pressable: "$not $isnull {$currentcontractcontext}.Contract", selectable: "$not $isnull {$currentcontractcontext}.Contract", data: { showningame: "$isingame", title: "$loc UI_MENU_PAGE_PAUSE_REPLAN", icon: "planning", }, actions: { accept: { "$if $eq ({$currentcontractcontext}.ContractType,placeholder)": { $then: { $datacontext: { in: "$.", datavalues: { AutoStart: false, }, do: { $include: { $path: "menusystem/pages/pause/pausemenu/replanplaceholder.json", }, }, }, }, $else: { "replan-level": {}, }, }, }, }, }, }, }, }, } case "/pages/pause/pausemenu/save.json": return { $if: { $condition: { $and: [ "$not $eq({$currentcontractcontext}.ContractType,arcade)", "$not $eq({$currentcontractcontext}.ContractType,evergreen)", "$not $eq({$currentcontractcontext}.ContractType,sniper)", "$not $eq({$currentcontractcontext}.ContractType,vsrace)", ], }, $then: { $datacontext: { in: "$.", datavalues: { CanSave: "$cansave", }, do: { view: "menu3.basic.ListElementSmall", selectable: "$.CanSave", pressable: "$.CanSave", data: { showningame: "$isingame", title: "$loc UI_MENU_PAGE_PAUSE_SAVE", icon: { "$if $.CanSave": { $then: "save", $else: "savedisabled", }, }, greyelement: "$not $.CanSave", }, actions: { "$if $.CanSave": { $then: { accept: { link: { page: "savepage", args: { url: "save", args: { saveorload: "save", }, saveorload: "save", }, }, }, }, }, }, }, }, }, }, } case "/pages/pause/pausemenu/load.json": return { $if: { $condition: { $and: [ "$not $eq({$currentcontractcontext}.ContractType,arcade)", "$not $eq({$currentcontractcontext}.ContractType,evergreen)", "$not $eq({$currentcontractcontext}.ContractType,sniper)", "$not $eq({$currentcontractcontext}.ContractType,vsrace)", ], }, $then: { view: "menu3.basic.ListElementSmall", data: { showningame: "$isingame", title: "$loc UI_MENU_PAGE_PAUSE_LOAD_GAME", icon: "load", }, actions: { accept: { link: { page: "loadpage", args: { url: "load", args: { saveorload: "load", }, saveorload: "load", mainmenu: false, reloadonchange: true, }, }, }, }, }, }, } // Following exists in the files of H3, but not H2. No need to put it in the diff. - AF case "/pages/pause/pausemenu/restartnoconditions.json": return { view: "menu3.basic.ListElementSmall", data: { showningame: "$isingame", title: { "$if $eq ({$currentcontractcontext}.ContractType,vsrace)": { $then: "$loc UI_MENU_LOBBY_REMATCH", $else: "$loc UI_MENU_PAGE_PAUSE_RESTART", }, }, icon: "replay", }, actions: { accept: { "$if $eq ({$currentcontractcontext}.ContractType,placeholder)": { $then: { $datacontext: { in: "$.", datavalues: { AutoStart: true, }, do: { $include: { $path: "menusystem/pages/pause/pausemenu/replanplaceholder.json", }, }, }, }, $else: { "restart-level": {}, }, }, }, }, } case "/pages/planning/actions/actions_contextbutton_play.json": return { $mergeobjects: [ { accept: { "start-contract": { contract: "$.Contract", difficulty: "$.@parent.CurrentDifficulty", objectives: "$.@parent.Objectives", }, }, }, { $include: { $path: "menusystem/pages/planning/actions/actions_contextbutton_common.json", }, }, ], } default: return undefined } }) } /** * @internal */ _getNamedConfig(configName: string, gameVersion: GameVersion): unknown { return this.hooks.getConfig.call(configName, gameVersion) } /** * Express middleware for fetching configurations. * * @param req The request. * @param res The response. * @param next The next function. */ static configMiddleware( req: RequestWithJwt, res: Response, next: NextFunction, ): void { const config = menuSystemDatabase._getNamedConfig( req.path, req.gameVersion, ) if (config) { res.json(config) return } log(LogLevel.DEBUG, `Unable to resolve config ${req.path}, skipping...`) next() } } export const menuSystemDatabase = new MenuSystemDatabase() menuSystemRouter.get( "/dynamic_resources_pc_release_rpkg", async (req: RequestWithJwt, res) => { const dynamicResourceName = `dynamic_resources_${req.gameVersion}.rpkg` const dynamicResourcePath = join( PEACOCK_DEV ? process.cwd() : __dirname, "resources", dynamicResourceName, ) log( LogLevel.DEBUG, `Serving dynamic resources from file ${dynamicResourceName}.`, ) const hash = await md5File(dynamicResourcePath) res.set("Content-Type", "application/octet-stream") res.set("Content-MD5", Buffer.from(hash, "hex").toString("base64")) res.send(await readFile(dynamicResourcePath)) }, ) menuSystemRouter.use("/menusystem/", MenuSystemDatabase.configMiddleware) // Miranda Jamison's image path in the repository is escaped for some reason menuSystemPreRouter.get( "/images%5Cactors%5Celusive_goldendoublet_face.jpg", (req, res, next) => { req.url = "/images/actors/elusive_goldendoublet_face.jpg" next("router") }, ) // Sully Bowden is the same (come on IOI!) menuSystemPreRouter.get( "/images%5Cactors%5Celusive_redsnapper_face.jpg", (req, res, next) => { req.url = "/images/actors/elusive_redsnapper_face.jpg" next("router") }, ) menuSystemRouter.use( "/images/", serveStatic("images", { fallthrough: true }), imageFetchingMiddleware, ) export { menuSystemRouter, menuSystemPreRouter }