1
mirror of https://github.com/thepeacockproject/Peacock synced 2024-11-16 11:03:30 +01:00

Initial commit

Co-authored-by: Tino Roivanen <tino.roivanen98@gmail.com>
Co-authored-by: Govert de Gans <grappigegovert@hotmail.com>
Co-authored-by: Gray Olson <gray@grayolson.com>
Co-authored-by: Alexandre Sanchez <alex73630@gmail.com>
Co-authored-by: Anthony Fuller <24512050+anthonyfuller@users.noreply.github.com>
Co-authored-by: atampy25 <24306974+atampy25@users.noreply.github.com>
Co-authored-by: David <davidstulemeijer@gmail.com>
Co-authored-by: c0derMo <c0dermo@users.noreply.github.com>
Co-authored-by: Jeevat Singh <jeevatt.singh@gmail.com>
Signed-off-by: Reece Dunham <me@rdil.rocks>
This commit is contained in:
Reece Dunham 2022-10-19 21:18:35 -04:00
commit 6245e91624
669 changed files with 515860 additions and 0 deletions

73
.cirrus.yml Normal file
View File

@ -0,0 +1,73 @@
env:
# don't clone the entire repository history, saves time
CIRRUS_CLONE_DEPTH: 1
Patcher_task:
# we don't need to tell GitHub if this isn't done
skip_notifications: "!changesInclude('.cirrus.yml', '**.{cs,resx,config,settings,ico,csproj,sln}')"
# skip if patcher remains unchanged
skip: "!changesInclude('.cirrus.yml', '**.{cs,resx,config,settings,ico,csproj,sln}')"
windows_container:
image: cirrusci/windowsservercore:visualstudio2019
Build_script:
- patcher/BuildCI.cmd
Patcher_Windows_artifacts:
path: patcher/bin/x64/Release/PeacockPatcher.exe
type: application/vnd.microsoft.portable-executable
Patcher_Linux_artifacts:
path: patcher/bin/x64/Linux.Release/PeacockPatcher.exe
type: application/vnd.microsoft.portable-executable
Build_task:
container:
image: node:18-slim
cpu: 4
memory: 4gb
Yarn_cache:
folder: .yarn/cache
Yarn_Populate_script:
- yarn
Install_System_Dependencies_script:
- apt update --yes
- apt install zip jq --yes
Build_Modules_script:
- yarn build
Optimize_script:
- yarn optimize
Assemble_Full_script:
- ./packaging/ciAssemble.sh
Assemble_Lite_script:
- ./packaging/ciAssemble.sh lite
Peacock_Release_artifacts:
path: Peacock-v*.zip
type: application/zip
SourceMap_artifacts:
path: chunk0.js.map
task:
container:
image: node:18-slim
Yarn_cache:
folder: .yarn/cache
Yarn_Populate_script:
- yarn
Type_Check_script:
- yarn typecheck
matrix:
- only_if: $CIRRUS_TAG != ''
name: Types Publish
env:
NPM_AUTH_TOKEN: ENCRYPTED[f8bceee158d69277081075585ade5affc81890ef5f66385d9ae50ab6a75fa3a27a5501ecb8dadc95a6e4e3cace0b915d]
Install_System_Dependencies_script:
- apt update --yes
- apt install rsync --yes
Push_Types_script:
- rsync -a --include '*/' --include '*.d.ts' --exclude '*' build/ packaging/typedefs/
- yarn typedefs reversion
- yarn typedefs pack
- yarn config set npmAuthToken $NPM_AUTH_TOKEN
- yarn typedefs npm publish
- only_if: $CIRRUS_TAG == ''
name: Linting
Lint_script:
- yarn lint

12
.editorconfig Normal file
View File

@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
max_line_length = 80
[*.xml]
insert_final_newline = false
indent_size = 2

11
.eslintignore Normal file
View File

@ -0,0 +1,11 @@
packaging
*.d.ts
build
.eslintrc.js
chunk*.js
chunk*.mjs
webui/dist
*.plugin.js
*Plugin.js
packaging/livesplit-node-client/build
webstorm.config.js

64
.eslintrc.js Normal file
View File

@ -0,0 +1,64 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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/>.
*/
module.exports = {
env: {
node: true,
es2021: true,
},
plugins: ["@typescript-eslint", "promise", "react-hooks"],
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"prettier",
],
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaVersion: "es2022",
sourceType: "module",
project: [
// server full
"./tsconfig.json",
// web UI
"./webui/tsconfig.json",
],
},
rules: {
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-extra-semi": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/require-await": "warn",
"@typescript-eslint/prefer-ts-expect-error": "error",
eqeqeq: "error",
"no-duplicate-imports": "warn",
"promise/always-return": "error",
"promise/no-return-wrap": "error",
"promise/param-names": "error",
"promise/catch-or-return": "error",
"promise/no-native": "off",
"promise/no-nesting": "warn",
"promise/no-promise-in-callback": "warn",
"promise/no-callback-in-promise": "warn",
"promise/avoid-new": "off",
"promise/no-new-statics": "error",
"promise/no-return-in-finally": "warn",
"promise/valid-params": "warn",
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
},
}

3
.gitattributes vendored Normal file
View File

@ -0,0 +1,3 @@
* text=auto eol=lf
*.{png,jpg,exe} binary
.yarn/releases/*.cjs linguist-generated

38
.gitignore vendored Normal file
View File

@ -0,0 +1,38 @@
node_modules
.DS_Store
*/.DS_Store
contracts/*.ocre
/chunk*.js
build
/.idea/workspace.xml
contractSessions
*.map
userdata
peacock_patcher.conf
resources/rpkg-cli.exe
components/contracts.json
DEBUG_PROFILE.json
assembled
webui/dist
.yarn/cache
.yarn/install-state.gz
.yarn/unplugged
options.ini
*.tsbuildinfo
images
/.idea/shelf/
offlineassets.zip
resources/contracts.br
resources/challenges

5
.idea/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/

40
.idea/Peacock.iml Normal file
View File

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/components" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/contractdata" type="java-resource" />
<sourceFolder url="file://$MODULE_DIR$/patcher" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/static" type="java-resource" />
<sourceFolder url="file://$MODULE_DIR$/webui" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/build" />
<excludeFolder url="file://$MODULE_DIR$/contractSessions" />
<excludeFolder url="file://$MODULE_DIR$/contracts" />
<excludeFolder url="file://$MODULE_DIR$/userdata" />
<excludeFolder url="file://$MODULE_DIR$/resources/dynamic_resources_h1" />
<excludeFolder url="file://$MODULE_DIR$/resources/dynamic_resources_h2" />
<excludeFolder url="file://$MODULE_DIR$/resources/dynamic_resources_h3" />
<excludeFolder url="file://$MODULE_DIR$/resources/LOCR" />
<excludeFolder url="file://$MODULE_DIR$/patcher/.vs" />
<excludeFolder url="file://$MODULE_DIR$/webui/dist" />
<excludeFolder url="file://$MODULE_DIR$/assembled" />
<excludeFolder url="file://$MODULE_DIR$/.yarn/cache" />
<excludeFolder url="file://$MODULE_DIR$/.yarn/releases" />
<excludeFolder url="file://$MODULE_DIR$/.yarn/plugins" />
<excludeFolder url="file://$MODULE_DIR$/patcher/obj" />
<excludeFolder url="file://$MODULE_DIR$/packages/livesplit-node-client/build" />
<excludeFolder url="file://$MODULE_DIR$/images/actors" />
<excludeFolder url="file://$MODULE_DIR$/.vscode" />
<excludeFolder url="file://$MODULE_DIR$/.yarn/unplugged" />
<excludeFolder url="file://$MODULE_DIR$/patcher/bin" />
<excludeFolder url="file://$MODULE_DIR$/images/sarajevo_six" />
<excludeFolder url="file://$MODULE_DIR$/resources/challenges" />
<excludeFolder url="file://$MODULE_DIR$/patcher/.idea/.idea.HitmanPatcher/.idea/shelf" />
<excludePattern pattern="chunk*.js" />
<excludePattern pattern="components/contracts.json,*.tsbuildinfo,offlineassets.zip,chunk0.js.map" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@ -0,0 +1,61 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<HTMLCodeStyleSettings>
<option name="HTML_SPACE_INSIDE_EMPTY_TAG" value="true" />
<option name="HTML_ENFORCE_QUOTES" value="true" />
</HTMLCodeStyleSettings>
<JSCodeStyleSettings version="0">
<option name="USE_SEMICOLON_AFTER_STATEMENT" value="false" />
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="WhenMultiline" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</JSCodeStyleSettings>
<TypeScriptCodeStyleSettings version="0">
<option name="USE_SEMICOLON_AFTER_STATEMENT" value="false" />
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="WhenMultiline" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
<option name="USE_PATH_MAPPING" value="NEVER" />
</TypeScriptCodeStyleSettings>
<VueCodeStyleSettings>
<option name="INTERPOLATION_NEW_LINE_AFTER_START_DELIMITER" value="false" />
<option name="INTERPOLATION_NEW_LINE_BEFORE_END_DELIMITER" value="false" />
</VueCodeStyleSettings>
<codeStyleSettings language="CSS">
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="HTML">
<option name="SOFT_MARGINS" value="80" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JSON">
<indentOptions>
<option name="INDENT_SIZE" value="4" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JavaScript">
<option name="KEEP_BLANK_LINES_IN_CODE" value="1" />
<option name="SOFT_MARGINS" value="80" />
</codeStyleSettings>
<codeStyleSettings language="TypeScript">
<option name="SOFT_MARGINS" value="80" />
</codeStyleSettings>
<codeStyleSettings language="Vue">
<option name="SOFT_MARGINS" value="80" />
<indentOptions>
<option name="INDENT_SIZE" value="4" />
<option name="TAB_SIZE" value="4" />
</indentOptions>
</codeStyleSettings>
</code_scheme>
</component>

View File

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

6
.idea/compiler.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="TypeScriptCompiler">
<option name="recompileOnChanges" value="true" />
</component>
</project>

View File

@ -0,0 +1,6 @@
<component name="CopyrightManager">
<copyright>
<option name="notice" value=" The Peacock Project - a HITMAN server replacement.&#10; Copyright (C) 2021-2022 The Peacock Project Team&#10;&#10; This program is free software: you can redistribute it and/or modify&#10; it under the terms of the GNU Affero General Public License as published by&#10; the Free Software Foundation, either version 3 of the License, or&#10; (at your option) any later version.&#10;&#10; This program is distributed in the hope that it will be useful,&#10; but WITHOUT ANY WARRANTY; without even the implied warranty of&#10; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the&#10; GNU Affero General Public License for more details.&#10;&#10; You should have received a copy of the GNU Affero General Public License&#10; along with this program. If not, see &lt;https://www.gnu.org/licenses/&gt;." />
<option name="myName" value="AGPL-3.0" />
</copyright>
</component>

View File

@ -0,0 +1,3 @@
<component name="CopyrightManager">
<settings default="AGPL-3.0" />
</component>

View File

@ -0,0 +1,59 @@
<component name="ProjectDictionaryState">
<dictionary name="reece">
<words>
<w>ascensionist</w>
<w>bosco</w>
<w>calluna</w>
<w>cereus</w>
<w>coastaltown</w>
<w>cooldown</w>
<w>customkill</w>
<w>desertrose</w>
<w>gamechanger</w>
<w>gamechangers</w>
<w>gartersnake</w>
<w>grasssnake</w>
<w>hantu</w>
<w>herospawn</w>
<w>invertus</w>
<w>kashmirian</w>
<w>killmethod</w>
<w>kingcobra</w>
<w>livesplit</w>
<w>loadout</w>
<w>lunaria</w>
<w>makoyana</w>
<w>movieset</w>
<w>newzealand</w>
<w>nightphlox</w>
<w>northamerica</w>
<w>orbis</w>
<w>polarbear</w>
<w>protea</w>
<w>radler</w>
<w>rafflesia</w>
<w>redsnapper</w>
<w>sambuca</w>
<w>sapienza</w>
<w>scpc</w>
<w>setpiece</w>
<w>setpieces</w>
<w>sgáil</w>
<w>sheepsorrel</w>
<w>singleplayer</w>
<w>situs</w>
<w>smoothsnake</w>
<w>snakeshead</w>
<w>sniperrifle</w>
<w>snowcrane</w>
<w>specialassignment</w>
<w>stashpoints</w>
<w>statemachine</w>
<w>torenia</w>
<w>unlockable</w>
<w>usercreated</w>
<w>vsrace</w>
<w>yardbird</w>
</words>
</dictionary>
</component>

7
.idea/discord.xml Normal file
View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DiscordProjectSettings">
<option name="show" value="PROJECT" />
<option name="description" value="A HITMAN trilogy server replacement." />
</component>
</project>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalDependencies">
<plugin id="JavaScript" />
<plugin id="JavaScriptDebugger" />
<plugin id="NodeJS" />
<plugin id="com.intellij.microservices.ui" />
<plugin id="intellij.prettierJS" />
</component>
</project>

View File

@ -0,0 +1,45 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="ConditionalExpressionWithIdenticalBranchesJS" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="DivideByZeroJS" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="DuplicateConditionJS" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="ES6PossiblyAsyncFunction" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="EmptyFinallyBlockJS" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="ForLoopReplaceableByWhileJS" enabled="true" level="WARNING" enabled_by_default="true">
<option name="m_ignoreLoopsWithoutConditions" value="false" />
</inspection_tool>
<inspection_tool class="HttpUrlsUsage" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<scope name="Configs and Contracts" level="INFORMATION" enabled="true" />
</inspection_tool>
<inspection_tool class="IfStatementWithIdenticalBranchesJS" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="IncorrectHttpHeaderInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="customHeaders">
<set>
<option value="Peacock-Version" />
</set>
</option>
</inspection_tool>
<inspection_tool class="JSConstructorReturnsPrimitive" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="JSEqualityComparisonWithCoercion.TS" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="JSXNamespaceValidation" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="NestedSwitchStatementJS" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="ObjectAllocationIgnoredJS" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
<inspection_tool class="PointlessBitwiseExpressionJS" enabled="true" level="WARNING" enabled_by_default="true">
<option name="m_ignoreExpressionsContainingConstants" value="false" />
</inspection_tool>
<inspection_tool class="ReuseOfLocalVariableJS" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="SpellCheckingInspection" enabled="true" level="TYPO" enabled_by_default="true">
<scope name="Configs and Contracts" level="INFORMATION" enabled="true">
<option name="processCode" value="true" />
<option name="processLiterals" value="true" />
<option name="processComments" value="false" />
</scope>
<option name="processCode" value="true" />
<option name="processLiterals" value="true" />
<option name="processComments" value="true" />
</inspection_tool>
<inspection_tool class="UpdateDependencyToLatestVersion" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
</profile>
</component>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JavaScriptLibraryMappings">
<includedPredefinedLibrary name="Node.js Core" />
<excludedPredefinedLibrary name="HTML" />
</component>
</project>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="EslintConfiguration">
<files-pattern value="{**/*,*}.{js,ts,jsx,tsx,html,mjs,cjs}" />
</component>
</project>

25
.idea/jsonSchemas.xml Normal file
View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JsonSchemaMappingsProjectConfiguration">
<state>
<map>
<entry key=".cirrus.yml">
<value>
<SchemaInfo>
<option name="name" value=".cirrus.yml" />
<option name="relativePathToSchema" value="https://json.schemastore.org/cirrus.json" />
<option name="applicationDefined" value="true" />
<option name="patterns">
<list>
<Item>
<option name="path" value=".cirrus.yml" />
</Item>
</list>
</option>
</SchemaInfo>
</value>
</entry>
</map>
</state>
</component>
</project>

9
.idea/markdown.xml Normal file
View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MarkdownSettings">
<enabledExtensions>
<entry key="MermaidLanguageExtension" value="false" />
<entry key="PlantUMLLanguageExtension" value="false" />
</enabledExtensions>
</component>
</project>

4
.idea/misc.xml Normal file
View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" project-jdk-name="1.8" project-jdk-type="JavaSDK" />
</project>

8
.idea/modules.xml Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/Peacock.iml" filepath="$PROJECT_DIR$/.idea/Peacock.iml" />
</modules>
</component>
</project>

8
.idea/prettier.xml Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PrettierConfiguration">
<option name="myRunOnSave" value="true" />
<option name="myRunOnReformat" value="true" />
<option name="myFilesPattern" value="{**/*,*}.{js,ts,jsx,tsx,json,html,css,mjs,cjs}" />
</component>
</project>

View File

@ -0,0 +1,12 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Prettier" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="prettier" />
</scripts>
<node-interpreter value="project" />
<envs />
<method v="2" />
</configuration>
</component>

View File

@ -0,0 +1,8 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run Server" type="NodeJSConfigurationType" application-parameters="--hmr" node-parameters="--harmony" path-to-js-file="packaging/devLoader.mjs" working-dir="$PROJECT_DIR$">
<EXTENSION ID="com.jetbrains.nodejs.run.NodeJSProfilingRunConfigurationExtension">
<profiling allow-runtime-heap-snapshot="true" />
</EXTENSION>
<method v="2" />
</configuration>
</component>

View File

@ -0,0 +1,8 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Web UI: Attach to Browser" type="JavascriptDebugType" uri="http://localhost:3000" useFirstLineBreakpoints="true">
<mapping url="http://localhost:3000/@fs/$PROJECT_DIR$/node_modules" local-file="$PROJECT_DIR$/node_modules" />
<mapping url="http://localhost:3000" local-file="$PROJECT_DIR$/webui/index.html" />
<mapping url="http://localhost:3000/src" local-file="$PROJECT_DIR$/webui/src" />
<method v="2" />
</configuration>
</component>

View File

@ -0,0 +1,13 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Web UI: Dev Server" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="webui" />
</scripts>
<arguments value="start" />
<node-interpreter value="project" />
<envs />
<method v="2" />
</configuration>
</component>

View File

@ -0,0 +1,3 @@
<component name="DependencyValidationManager">
<scope name="Configs and Contracts" pattern="file[Peacock]:static//*||file[Peacock]:contractdata//*||file:components/contracts.json" />
</component>

3
.idea/scopes/Patcher.xml Normal file
View File

@ -0,0 +1,3 @@
<component name="DependencyValidationManager">
<scope name="Patcher" pattern="file[Peacock]:patcher//*" />
</component>

View File

@ -0,0 +1,3 @@
<component name="DependencyValidationManager">
<scope name="Server Source" pattern="file[Peacock]:components//*" />
</component>

3
.idea/scopes/Web_UI.xml Normal file
View File

@ -0,0 +1,3 @@
<component name="DependencyValidationManager">
<scope name="Web UI" pattern="file[Peacock]:webui//*" />
</component>

13
.idea/vcs.xml Normal file
View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GithubSharedProjectSettings">
<option name="branchProtectionPatterns">
<list>
<option value="main" />
</list>
</option>
</component>
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
v18.10.0

9
.prettierignore Normal file
View File

@ -0,0 +1,9 @@
node_modules
userdata
build
contractSessions
chunk*.js
webui/dist
*.plugin.js
*Plugin.js
components/contracts.json

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
}

35
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,35 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "pwa-node",
"request": "launch",
"name": "Dev Server (run-dev)",
"skipFiles": ["<node_internals>/**"],
"env": {
"DEBUG": "peacock",
"Path": "${workspaceFolder}/nodedist;${env:Path}"
},
"runtimeExecutable": "yarn",
"runtimeArgs": ["run-dev"],
"internalConsoleOptions": "openOnSessionStart"
},
{
"type": "pwa-node",
"request": "launch",
"name": "Prod Server (build && launch chunk0)",
"skipFiles": ["<node_internals>/**"],
"program": "${workspaceFolder}/chunk0.js",
"preLaunchTask": "yarn build",
"outFiles": ["${workspaceFolder}/chunk*.js"],
"env": {
"DEBUG": "peacock"
},
"runtimeExecutable": "${workspaceFolder}/nodedist/node",
"internalConsoleOptions": "openOnSessionStart"
}
]
}

9
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,9 @@
{
"eslint.runtime": "nodedist\\node.exe",
"editor.codeActionsOnSave": {
"source.organizeImports": false
},
"typescript.enablePromptUseWorkspaceTsdk": true,
"npm.packageManager": "yarn",
"eslint.packageManager": "yarn"
}

51
.vscode/tasks.json vendored Normal file
View File

@ -0,0 +1,51 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
"version": "2.0.0",
"tasks": [
{
"type": "shell",
"options": {
"env": {
"Path": "${workspaceFolder}/nodedist;${env:Path}"
}
},
"command": "yarn build",
"label": "yarn build",
"problemMatcher": []
},
{
"type": "shell",
"options": {
"env": {
"Path": "${workspaceFolder}/nodedist;${env:Path}"
}
},
"command": "yarn optimize",
"label": "yarn optimize",
"problemMatcher": []
},
{
"type": "shell",
"options": {
"env": {
"Path": "${workspaceFolder}/nodedist;${env:Path}"
}
},
"command": "yarn install",
"label": "yarn install",
"problemMatcher": []
},
{
"type": "shell",
"options": {
"env": {
"Path": "${workspaceFolder}/nodedist;${env:Path}"
}
},
"command": "yarn typecheck",
"label": "yarn typecheck",
"problemMatcher": []
}
]
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,28 @@
diff --git a/index.js b/index.js
index c425f1ee9d0944b1e2274ebb78528febf563d17e..2ee7a5c40090311ac8971a62f30d838d51ae5181 100644
--- a/index.js
+++ b/index.js
@@ -12,10 +12,9 @@
* @private
*/
-var deprecate = require('depd')('http-errors')
-var setPrototypeOf = require('setprototypeof')
+var setPrototypeOf = Object.setPrototypeOf
var statuses = require('statuses')
-var inherits = require('inherits')
+var inherits = require('util').inherits
var toIdentifier = require('toidentifier')
/**
@@ -69,10 +68,6 @@ function createError () {
}
}
- if (typeof status === 'number' && (status < 400 || status >= 600)) {
- deprecate('non-error status code; use only 4xx or 5xx status codes')
- }
-
if (typeof status !== 'number' ||
(!statuses.message[status] && (status < 400 || status >= 600))) {
status = 500

View File

@ -0,0 +1,86 @@
diff --git a/encodings/dbcs-codec.js b/encodings/dbcs-codec.js
index fa839170367b271072dc097d29b2c05f085e7681..03616ca08753e29ed31ceb327da25abe58de4bfe 100644
--- a/encodings/dbcs-codec.js
+++ b/encodings/dbcs-codec.js
@@ -1,5 +1,4 @@
"use strict";
-var Buffer = require("safer-buffer").Buffer;
// Multibyte codec. In this scheme, a character is represented by 1 or more bytes.
// Our codec supports UTF-16 surrogates, extensions for GB18030 and unicode sequences.
diff --git a/encodings/internal.js b/encodings/internal.js
index dc1074f04f11a31c0e962846f5d162eab9556d38..61f574b8121e4d8b417f3058597b1ff7c62c88f0 100644
--- a/encodings/internal.js
+++ b/encodings/internal.js
@@ -1,5 +1,4 @@
"use strict";
-var Buffer = require("safer-buffer").Buffer;
// Export Node.js internal encodings.
diff --git a/encodings/sbcs-codec.js b/encodings/sbcs-codec.js
index abac5ffaac97da29fa5c5d8aedf5b47763fc7c58..56d6d49746b332213177e31b15c4f0d920d70124 100644
--- a/encodings/sbcs-codec.js
+++ b/encodings/sbcs-codec.js
@@ -1,5 +1,4 @@
"use strict";
-var Buffer = require("safer-buffer").Buffer;
// Single-byte codec. Needs a 'chars' string parameter that contains 256 or 128 chars that
// correspond to encoded bytes (if 128 - then lower half is ASCII).
diff --git a/encodings/utf16.js b/encodings/utf16.js
index 97d066925bbd5dfaa7213e0433570a113c461f3e..df1ba233fc96924709ce7ceadead8477409cfb4f 100644
--- a/encodings/utf16.js
+++ b/encodings/utf16.js
@@ -1,5 +1,4 @@
"use strict";
-var Buffer = require("safer-buffer").Buffer;
// Note: UTF16-LE (or UCS2) codec is Node.js native. See encodings/internal.js
diff --git a/encodings/utf32.js b/encodings/utf32.js
index 2fa900a12eb3562e38fc9442dd3f57ea919b3c74..c80e72d32eacdde6d1fdfcece7fa4aa5f60ea742 100644
--- a/encodings/utf32.js
+++ b/encodings/utf32.js
@@ -1,7 +1,5 @@
'use strict';
-var Buffer = require('safer-buffer').Buffer;
-
// == UTF32-LE/BE codec. ==========================================================
exports._utf32 = Utf32Codec;
diff --git a/encodings/utf7.js b/encodings/utf7.js
index eacae34d5f80d0b406ad63104406ddd5f3232f4a..ad0bf4fc39c4fab1a92724c8ab3fa283e1d42c13 100644
--- a/encodings/utf7.js
+++ b/encodings/utf7.js
@@ -1,5 +1,4 @@
"use strict";
-var Buffer = require("safer-buffer").Buffer;
// UTF-7 codec, according to https://tools.ietf.org/html/rfc2152
// See also below a UTF-7-IMAP codec, according to http://tools.ietf.org/html/rfc3501#section-5.1.3
diff --git a/lib/index.js b/lib/index.js
index 657701c38d243b8af1cd3d4a67056e095a0ede5e..f224ea462925af6ae4fb753e193b76eb83ed3e5d 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -1,7 +1,5 @@
"use strict";
-var Buffer = require("safer-buffer").Buffer;
-
var bomHandling = require("./bom-handling"),
iconv = module.exports;
diff --git a/lib/streams.js b/lib/streams.js
index a1506482f580162d5b3a07b1ef82fcca22b40e5a..bd00f0674054c0d9d5ddb8cb5852686da71fe5f2 100644
--- a/lib/streams.js
+++ b/lib/streams.js
@@ -1,7 +1,5 @@
"use strict";
-var Buffer = require("safer-buffer").Buffer;
-
// NOTE: Due to 'stream' module being pretty large (~100Kb, significant in browser environments),
// we opt to dependency-inject it instead of creating a hard dependency.
module.exports = function(stream_module) {

View File

@ -0,0 +1,54 @@
diff --git a/lib/psSupported.js b/lib/psSupported.js
deleted file mode 100644
index 8c04144a14cc4f68210545769f5a23c03f808063..0000000000000000000000000000000000000000
--- a/lib/psSupported.js
+++ /dev/null
@@ -1,3 +0,0 @@
-var semver = require('semver');
-
-module.exports = semver.satisfies(process.version, '^6.12.0 || >=8.0.0');
diff --git a/sign.js b/sign.js
index f649ce4ff48ffc8ce7863422e3880e78ce86c322..e275fe51ce73e403bcb26196662188f3a843d8ef 100644
--- a/sign.js
+++ b/sign.js
@@ -1,5 +1,4 @@
var timespan = require('./lib/timespan');
-var PS_SUPPORTED = require('./lib/psSupported');
var jws = require('jws');
var includes = require('lodash.includes');
var isBoolean = require('lodash.isboolean');
@@ -10,9 +9,7 @@ var isString = require('lodash.isstring');
var once = require('lodash.once');
var SUPPORTED_ALGS = ['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512', 'HS256', 'HS384', 'HS512', 'none']
-if (PS_SUPPORTED) {
- SUPPORTED_ALGS.splice(3, 0, 'PS256', 'PS384', 'PS512');
-}
+SUPPORTED_ALGS.splice(3, 0, 'PS256', 'PS384', 'PS512');
var sign_options_schema = {
expiresIn: { isValid: function(value) { return isInteger(value) || (isString(value) && value); }, message: '"expiresIn" should be a number of seconds or string representing a timespan' },
diff --git a/verify.js b/verify.js
index 1df99f8dd429e392da8a0b84af7d7b02c0b36d0e..c9f7cef6263dd420259ebbac60efe64fb058c342 100644
--- a/verify.js
+++ b/verify.js
@@ -3,17 +3,14 @@ var NotBeforeError = require('./lib/NotBeforeError');
var TokenExpiredError = require('./lib/TokenExpiredError');
var decode = require('./decode');
var timespan = require('./lib/timespan');
-var PS_SUPPORTED = require('./lib/psSupported');
var jws = require('jws');
var PUB_KEY_ALGS = ['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512'];
var RSA_KEY_ALGS = ['RS256', 'RS384', 'RS512'];
var HS_ALGS = ['HS256', 'HS384', 'HS512'];
-if (PS_SUPPORTED) {
- PUB_KEY_ALGS.splice(3, 0, 'PS256', 'PS384', 'PS512');
- RSA_KEY_ALGS.splice(3, 0, 'PS256', 'PS384', 'PS512');
-}
+PUB_KEY_ALGS.splice(3, 0, 'PS256', 'PS384', 'PS512');
+RSA_KEY_ALGS.splice(3, 0, 'PS256', 'PS384', 'PS512');
module.exports = function (jwtString, secretOrPublicKey, options, callback) {
if ((typeof options === 'function') && !callback) {

View File

@ -0,0 +1,42 @@
diff --git a/picocolors.browser.js b/picocolors.browser.js
index 5eb9fbe8b5a0d60e91c88a2851b3b975d70261bf..85cdbe731e277f5a780eba50b1515db31b6c2bf7 100644
--- a/picocolors.browser.js
+++ b/picocolors.browser.js
@@ -1,4 +1,4 @@
var x=String;
-var create=function() {return {isColorSupported:false,reset:x,bold:x,dim:x,italic:x,underline:x,inverse:x,hidden:x,strikethrough:x,black:x,red:x,green:x,yellow:x,blue:x,magenta:x,cyan:x,white:x,gray:x,bgBlack:x,bgRed:x,bgGreen:x,bgYellow:x,bgBlue:x,bgMagenta:x,bgCyan:x,bgWhite:x}};
+var create=function() {return {isColorSupported:false,reset:x,bold:x,dim:x,italic:x,underline:x,inverse:x,hidden:x,strikethrough:x,black:x,red:x,green:x,yellow:x,blue:x,magenta:x,cyan:x,white:x,gray:x,bgBlack:x,bgRed:x,bgGreen:x,bgYellow:x,bgBlue:x,bgMagenta:x,bgCyan:x,bgWhite:x,redBright:x,greenBright:x,yellowBright:x,blueBright:x,magentaBright:x,cyanBright:x}};
module.exports=create();
module.exports.createColors = create;
diff --git a/picocolors.js b/picocolors.js
index fdb630451d89b134b069355bb97556e39b0b171a..389dee8a5d5c91c66a480f8230242b651deddce3 100644
--- a/picocolors.js
+++ b/picocolors.js
@@ -52,6 +52,12 @@ let createColors = (enabled = isColorSupported) => ({
bgMagenta: enabled ? formatter("\x1b[45m", "\x1b[49m") : String,
bgCyan: enabled ? formatter("\x1b[46m", "\x1b[49m") : String,
bgWhite: enabled ? formatter("\x1b[47m", "\x1b[49m") : String,
+ redBright: enabled ? formatter("\x1b[91m", "\x1b[39m") : String,
+ greenBright: enabled ? formatter("\x1b[92m", "\x1b[39m") : String,
+ yellowBright: enabled ? formatter("\x1b[93m", "\x1b[39m") : String,
+ blueBright: enabled ? formatter("\x1b[94m", "\x1b[39m") : String,
+ magentaBright: enabled ? formatter("\x1b[95m", "\x1b[39m") : String,
+ cyanBright: enabled ? formatter("\x1b[96m", "\x1b[39m") : String,
})
module.exports = createColors()
diff --git a/types.ts b/types.ts
index b4bacee4909e7f562fb13f89720c8ae57c4922fc..7b8327138390bfa3c397690a62a2c269cdbac06e 100644
--- a/types.ts
+++ b/types.ts
@@ -27,4 +27,10 @@ export interface Colors {
bgMagenta: Formatter
bgCyan: Formatter
bgWhite: Formatter
+ redBright: Formatter
+ greenBright: Formatter
+ yellowBright: Formatter
+ blueBright: Formatter
+ magentaBright: Formatter
+ cyanBright: Formatter
}

View File

@ -0,0 +1,181 @@
diff --git a/index.js b/index.js
index 89afd7e584a50233d6948255c4a4f52edbaf297c..6fdcbe7d7883027bff5ba7b8e729ae4170e057ce 100644
--- a/index.js
+++ b/index.js
@@ -14,10 +14,7 @@
var createError = require('http-errors')
var debug = require('debug')('send')
-var deprecate = require('depd')('send')
var destroy = require('destroy')
-var encodeUrl = require('encodeurl')
-var escapeHtml = require('escape-html')
var etag = require('etag')
var fresh = require('fresh')
var fs = require('fs')
@@ -124,10 +121,6 @@ function SendStream (req, path, options) {
this._hidden = Boolean(opts.hidden)
- if (opts.hidden !== undefined) {
- deprecate('hidden: use dotfiles: \'' + (this._hidden ? 'allow' : 'ignore') + '\' instead')
- }
-
// legacy support
if (opts.dotfiles === undefined) {
this._dotfiles = undefined
@@ -172,51 +165,6 @@ function SendStream (req, path, options) {
util.inherits(SendStream, Stream)
-/**
- * Enable or disable etag generation.
- *
- * @param {Boolean} val
- * @return {SendStream}
- * @api public
- */
-
-SendStream.prototype.etag = deprecate.function(function etag (val) {
- this._etag = Boolean(val)
- debug('etag %s', this._etag)
- return this
-}, 'send.etag: pass etag as option')
-
-/**
- * Enable or disable "hidden" (dot) files.
- *
- * @param {Boolean} path
- * @return {SendStream}
- * @api public
- */
-
-SendStream.prototype.hidden = deprecate.function(function hidden (val) {
- this._hidden = Boolean(val)
- this._dotfiles = undefined
- debug('hidden %s', this._hidden)
- return this
-}, 'send.hidden: use dotfiles option')
-
-/**
- * Set index `paths`, set to a falsy
- * value to disable index support.
- *
- * @param {String|Boolean|Array} paths
- * @return {SendStream}
- * @api public
- */
-
-SendStream.prototype.index = deprecate.function(function index (paths) {
- var index = !paths ? [] : normalizeList(paths, 'paths argument')
- debug('index %o', paths)
- this._index = index
- return this
-}, 'send.index: pass index as option')
-
/**
* Set root `path`.
*
@@ -231,31 +179,6 @@ SendStream.prototype.root = function root (path) {
return this
}
-SendStream.prototype.from = deprecate.function(SendStream.prototype.root,
- 'send.from: pass root as option')
-
-SendStream.prototype.root = deprecate.function(SendStream.prototype.root,
- 'send.root: pass root as option')
-
-/**
- * Set max-age to `maxAge`.
- *
- * @param {Number} maxAge
- * @return {SendStream}
- * @api public
- */
-
-SendStream.prototype.maxage = deprecate.function(function maxage (maxAge) {
- this._maxage = typeof maxAge === 'string'
- ? ms(maxAge)
- : Number(maxAge)
- this._maxage = !isNaN(this._maxage)
- ? Math.min(Math.max(0, this._maxage), MAX_MAXAGE)
- : 0
- debug('max-age %d', this._maxage)
- return this
-}, 'send.maxage: pass maxAge as option')
-
/**
* Emit error with `status`.
*
@@ -272,7 +195,7 @@ SendStream.prototype.error = function error (status, err) {
var res = this.res
var msg = statuses.message[status] || String(status)
- var doc = createHtmlDocument('Error', escapeHtml(msg))
+ var doc = `Error: ${msg}`
// clear existing headers
clearHeaders(res)
@@ -284,7 +207,7 @@ SendStream.prototype.error = function error (status, err) {
// send basic response
res.statusCode = status
- res.setHeader('Content-Type', 'text/html; charset=UTF-8')
+ res.setHeader('Content-Type', 'text/plain; charset=UTF-8')
res.setHeader('Content-Length', Buffer.byteLength(doc))
res.setHeader('Content-Security-Policy', "default-src 'none'")
res.setHeader('X-Content-Type-Options', 'nosniff')
@@ -476,23 +399,7 @@ SendStream.prototype.redirect = function redirect (path) {
return
}
- if (this.hasTrailingSlash()) {
- this.error(403)
- return
- }
-
- var loc = encodeUrl(collapseLeadingSlashes(this.path + '/'))
- var doc = createHtmlDocument('Redirecting', 'Redirecting to <a href="' + escapeHtml(loc) + '">' +
- escapeHtml(loc) + '</a>')
-
- // redirect
- res.statusCode = 301
- res.setHeader('Content-Type', 'text/html; charset=UTF-8')
- res.setHeader('Content-Length', Buffer.byteLength(doc))
- res.setHeader('Content-Security-Policy', "default-src 'none'")
- res.setHeader('X-Content-Type-Options', 'nosniff')
- res.setHeader('Location', loc)
- res.end(doc)
+ this.error(403)
}
/**
@@ -945,27 +852,6 @@ function contentRange (type, size, range) {
return type + ' ' + (range ? range.start + '-' + range.end : '*') + '/' + size
}
-/**
- * Create a minimal HTML document.
- *
- * @param {string} title
- * @param {string} body
- * @private
- */
-
-function createHtmlDocument (title, body) {
- return '<!DOCTYPE html>\n' +
- '<html lang="en">\n' +
- '<head>\n' +
- '<meta charset="utf-8">\n' +
- '<title>' + title + '</title>\n' +
- '</head>\n' +
- '<body>\n' +
- '<pre>' + body + '</pre>\n' +
- '</body>\n' +
- '</html>\n'
-}
-
/**
* Create a HttpError object from simple arguments.
*

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

801
.yarn/releases/yarn-3.2.4.cjs generated vendored Executable file

File diff suppressed because one or more lines are too long

20
.yarnrc.yml Normal file
View File

@ -0,0 +1,20 @@
enableInlineHunks: true
enableMessageNames: false
initScope: peacockproject
nodeLinker: node-modules
packageExtensions:
express@*:
dependencies:
mime: 1.6.0
plugins:
- path: .yarn/plugins/@yarnpkg/plugin-outdated.cjs
spec: "https://mskelton.dev/yarn-outdated/v2"
- path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs
spec: "@yarnpkg/plugin-workspace-tools"
yarnPath: .yarn/releases/yarn-3.2.4.cjs

661
LICENSE Normal file
View File

@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
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/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

BIN
PeacockPatcher.exe Normal file

Binary file not shown.

3
Start Server.cmd Normal file
View File

@ -0,0 +1,3 @@
@echo off
.\nodedist\node.exe chunk0.js
PAUSE

3263
THIRDPARTYNOTICES.txt Normal file

File diff suppressed because it is too large Load Diff

3
Tools.cmd Normal file
View File

@ -0,0 +1,3 @@
@echo off
.\nodedist\node.exe chunk0.js tools
PAUSE

View File

@ -0,0 +1,166 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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 { Router } from "express"
import { gameDifficulty, nilUuid, ServerVer, uuidRegex } from "../utils"
import { json as jsonMiddleware } from "body-parser"
import { enqueueEvent, newSession } from "../eventHandler"
import { _legacyBull, _theLastYardbirdScpc, controller } from "../controller"
import { log, LogLevel } from "../loggingInterop"
import { getConfig } from "../configSwizzleManager"
import type { GameChanger, RequestWithJwt } from "../types/types"
import { randomUUID } from "crypto"
const legacyContractRouter = Router()
legacyContractRouter.post(
"/GetForPlay",
jsonMiddleware(),
(req: RequestWithJwt, res) => {
if (!uuidRegex.test(req.body.id)) {
res.status(400).end()
return
}
const contractData =
req.gameVersion === "h1" &&
req.body.id === "42bac555-bbb9-429d-a8ce-f1ffdf94211c"
? _legacyBull
: req.body.id === "ff9f46cf-00bd-4c12-b887-eac491c3a96d"
? _theLastYardbirdScpc
: controller.resolveContract(req.body.id)
if (!contractData) {
log(
LogLevel.ERROR,
`Requested unknown contract in LGFP: ${req.body.id}`,
)
res.status(400).send("no such contract")
return
}
if (!contractData.Data.GameChangers) {
contractData.Data.GameChangers = []
}
for (const gamechangerId of req.body.extraGameChangerIds) {
contractData.Data.GameChangers.push(gamechangerId)
}
if (contractData.Data.GameChangers.length > 0) {
type GCPConfig = Record<string, GameChanger>
const gameChangerData: GCPConfig = {
...getConfig<GCPConfig>("GameChangerProperties", true),
...getConfig<GCPConfig>("PeacockGameChangerProperties", true),
}
contractData.Data.GameChangerReferences = []
for (const gameChangerId of contractData.Data.GameChangers) {
const gameChanger = gameChangerData[gameChangerId]
if (!gameChanger) {
log(
LogLevel.ERROR,
`Encountered unknown GameChanger id: ${gameChangerId}`,
)
res.status(500)
continue
}
gameChanger.Id = gameChangerId
delete gameChanger.ObjectivesCategory
contractData.Data.GameChangerReferences.push(gameChanger)
if (gameChanger.Resource) {
contractData.Data.Bricks.push(...gameChanger.Resource)
}
if (gameChanger.Objectives) {
contractData.Data.Objectives?.push(
...gameChanger.Objectives,
)
}
}
}
res.json(contractData)
},
)
legacyContractRouter.post(
"/Start",
jsonMiddleware(),
(req: RequestWithJwt, res) => {
if (req.body.profileId !== req.jwt.unique_name) {
res.status(400).end() // requested for different user id
return
}
if (!uuidRegex.test(req.body.contractId)) {
res.status(400).end()
return
}
const c = controller.resolveContract(req.body.contractId)
if (!c) {
res.status(404).end()
return
}
const contractSessionId = `${process.hrtime
.bigint()
.toString()}-${randomUUID()}`
// all event stuff is handled in h3 event handler
enqueueEvent(req.jwt.unique_name, {
Version: ServerVer,
IsReplicated: false,
CreatedContract: null,
Id: randomUUID(),
Name: "ContractSessionMarker",
UserId: nilUuid,
ContractId: nilUuid,
SessionId: null,
ContractSessionId: contractSessionId,
Timestamp: 0.0,
Value: {
Currency: {
ContractPaymentAllowed: true,
ContractPayment: null,
},
},
Origin: null,
})
res.json(contractSessionId)
newSession(
contractSessionId,
req.body.contractId,
req.jwt.unique_name,
gameDifficulty.normal,
req.gameVersion,
)
},
)
export { legacyContractRouter }

View File

@ -0,0 +1,41 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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 { Router } from "express"
import { json as jsonMiddleware } from "body-parser"
const legacyEventRouter = Router()
legacyEventRouter.post(
"/SaveAndSynchronizeEvents3",
jsonMiddleware({ limit: "10Mb" }),
(req, res, next) => {
// call /SaveAndSynchronizeEvents4 but add/remove dummy pushMessages
req.url = "/SaveAndSynchronizeEvents4"
req.body.lastPushDt = "0"
const originalJsonFunc = res.json
res.json = function (originalData) {
delete originalData.PushMessages
return originalJsonFunc.call(this, originalData)
}
next()
},
)
export { legacyEventRouter }

View File

@ -0,0 +1,247 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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 { Router } from "express"
import { RequestWithJwt } from "../types/types"
import { getConfig } from "../configSwizzleManager"
import { getDefaultSuitFor, uuidRegex } from "../utils"
import { json as jsonMiddleware } from "body-parser"
import { controller } from "../controller"
import { generateUserCentric, getSubLocationByName } from "../contracts/dataGen"
import { getUserData } from "../databaseHandler"
import { log, LogLevel } from "../loggingInterop"
import { createInventory } from "../inventory"
import { getFlag } from "../flags"
import { loadouts } from "../loadouts"
const legacyMenuDataRouter = Router()
legacyMenuDataRouter.get(
"/stashpoint",
(req: RequestWithJwt<{ contractid: string; slotname: string }>, res) => {
// stashpoint?contractid=4e45e91a-94ca-4d89-89fc-1b250e608e73&stashpoint=&allowlargeitems=true&slotname=concealedweapon2
if (!uuidRegex.test(req.query.contractid)) {
res.status(400).send("contract id was not a uuid")
return
}
const contractData = controller.resolveContract(req.query.contractid)
if (!contractData) {
res.status(404).send()
return
}
const loadoutSlots = [
"carriedweapon",
"carrieditem",
"concealedweapon",
"disguise",
"gear",
"gear",
"stashpoint",
]
if (loadoutSlots.includes(req.query.slotname.slice(0, -1))) {
req.query.slotid = req.query.slotname.slice(0, -1)
} else {
log(
LogLevel.ERROR,
`Unknown slotname in legacy stashpoint: ${req.query.slotname}`,
)
return
}
const userProfile = getUserData(req.jwt.unique_name, req.gameVersion)
const inventory = createInventory(
req.jwt.unique_name,
req.gameVersion,
userProfile.Extensions.entP,
)
const userCentricContract = generateUserCentric(
contractData,
req.jwt.unique_name,
"h1",
)
const sublocation = getSubLocationByName(
contractData.Metadata.Location,
req.gameVersion,
)
const defaultLoadout = {
2: "FIREARMS_HERO_PISTOL_TACTICAL_001_SU_SKIN01",
3: getDefaultSuitFor(sublocation?.Properties?.ParentLocation),
4: "TOKEN_FIBERWIRE",
5: "PROP_TOOL_COIN",
}
const getLoadoutItem = (id: number) => {
if (getFlag("loadoutSaving") === "LEGACY") {
const dl = userProfile.Extensions.defaultloadout
if (!dl) {
return defaultLoadout[id]
}
const forLocation = (userProfile.Extensions.defaultloadout ||
{})[sublocation?.Properties?.ParentLocation]
if (!forLocation) {
return defaultLoadout[id]
}
return forLocation[id]
} else {
let dl = loadouts.getLoadoutFor("h1")
if (!dl) {
dl = loadouts.createDefault("h1")
}
const forLocation =
dl.data[sublocation?.Properties?.ParentLocation]
if (!forLocation) {
return defaultLoadout[id]
}
return forLocation[id]
}
}
res.json({
template: getConfig("LegacyStashpointTemplate", false),
data: {
ContractId: req.query.contractid,
// the game actually only needs the loadoutdata from the requested slotid, but this is what IOI servers do
LoadoutData: [...loadoutSlots.entries()].map(
([slotid, slotname]) => ({
SlotName: slotname,
SlotId: slotid.toString(),
Items: inventory
.filter((item) => {
return (
item.Unlockable.Properties.LoadoutSlot && // only display items
(item.Unlockable.Properties.LoadoutSlot ===
slotname || // display items for requested slot
(slotname === "stashpoint" && // else: if stashpoint
item.Unlockable.Properties
.LoadoutSlot !== "disguise")) && // => display all non-disguise items
(req.query.allowlargeitems === "true" ||
item.Unlockable.Properties
.LoadoutSlot !== "carriedweapon")
) // not sure about this one
})
.map((item) => ({
Item: item,
ItemDetails: {
Capabilities: [],
StatList: item.Unlockable.Properties
.Gameplay
? Object.entries(
item.Unlockable.Properties
.Gameplay,
).map(([key, value]) => ({
Name: key,
Ratio: value,
}))
: [],
PropertyTexts: [],
},
SlotId: slotid.toString(),
SlotName: slotname,
})),
Page: 0,
Recommended: getLoadoutItem(slotid)
? {
item: inventory.find(
(item) =>
item.Unlockable.Id ===
getLoadoutItem(slotid),
),
type: loadoutSlots[slotid],
owned: true,
}
: null,
HasMore: false,
HasMoreLeft: false,
HasMoreRight: false,
OptionalData:
slotid === 6
? {
stashpoint: req.query.stashpoint,
AllowLargeItems:
req.query.allowlargeitems ||
!req.query.stashpoint,
}
: {},
}),
),
Contract: userCentricContract.Contract,
ShowSlotName: req.query.slotname,
UserCentric: userCentricContract,
},
})
},
)
legacyMenuDataRouter.get("/Safehouse", (req: RequestWithJwt, res, next) => {
const template = getConfig("LegacySafehouseTemplate", false)
// call /SafehouseCategory but rewrite the result a bit
req.url = `/SafehouseCategory?page=0&type=${req.query.type}&subtype=`
const originalJsonFunc = res.json
res.json = function (originalData) {
return originalJsonFunc.call(this, {
template,
data: {
SafehouseData: originalData.data,
},
})
}
next()
})
legacyMenuDataRouter.get(
"/debriefingchallenges",
jsonMiddleware(),
(
req: RequestWithJwt<{ contractSessionId: string; contractId: string }>,
res,
) => {
// debriefingchallenges?contractSessionId=00000000000000-00000000-0000-0000-0000-000000000001&contractId=dd906289-7c32-427f-b689-98ae645b407f
res.json({
template: getConfig("LegacyDebriefingChallengesTemplate", false),
data: {
ChallengeData: {
// FIXME: This may not work correctly; I don't know the actual format so I'm assuming challenge tree
Children:
controller.challengeService.getChallengeTreeForContract(
req.query.contractId,
req.gameVersion,
req.jwt.unique_name,
),
},
},
})
},
)
export { legacyMenuDataRouter }

View File

@ -0,0 +1,57 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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 serveStatic from "serve-static"
import { Router } from "express"
import { join } from "path"
import md5File from "md5-file"
import { readFile } from "atomically"
import { imageFetchingMiddleware } from "../menus/imageHandler"
import { MenuSystemDatabase } from "../menus/menuSystem"
const legacyMenuSystemRouter = Router()
// /resources-6-74/
legacyMenuSystemRouter.get(
"/dynamic_resources_pc_release_rpkg",
async (req, res) => {
const filePath = join(
PEACOCK_DEV ? process.cwd() : __dirname,
"resources",
"dynamic_resources_h1.rpkg",
)
const md5 = await md5File(filePath)
res.set("Content-Type", "application/octet-stream")
res.set("Content-MD5", Buffer.from(md5, "hex").toString("base64"))
res.send(await readFile(filePath))
},
)
legacyMenuSystemRouter.use(MenuSystemDatabase.configMiddleware)
legacyMenuSystemRouter.use(
"/images/",
serveStatic("images", { fallthrough: true }),
imageFetchingMiddleware,
)
export { legacyMenuSystemRouter }

View File

@ -0,0 +1,158 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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 type {
ChallengeProgressionData,
CompiledChallengeIngameData,
RequestWithJwt,
} from "../types/types"
import { log, LogLevel } from "../loggingInterop"
import { getConfig } from "../configSwizzleManager"
import { Router } from "express"
import { controller } from "../controller"
import { getPlatformEntitlements } from "../platformEntitlements"
import { json as jsonMiddleware } from "body-parser"
import { uuidRegex } from "../utils"
import { menuSystemDatabase } from "../menus/menuSystem"
import { compileRuntimeChallenge } from "../candle/challengeHelpers"
import { LegacyGetProgressionBody } from "../types/gameSchemas"
const legacyProfileRouter = Router()
// /authentication/api/userchannel/
legacyProfileRouter.post(
"/ProfileService/GetPlatformEntitlements",
jsonMiddleware(),
getPlatformEntitlements,
)
legacyProfileRouter.post(
"/AuthenticationService/GetBlobOfflineCacheDatabaseDiff",
(req: RequestWithJwt, res) => {
const configs = []
menuSystemDatabase.hooks.getDatabaseDiff.call(configs, req.gameVersion)
res.json(configs)
},
)
legacyProfileRouter.post(
"/ChallengesService/GetActiveChallenges",
jsonMiddleware(),
(req: RequestWithJwt, res) => {
if (!uuidRegex.test(req.body.contractId)) {
return res.status(404).send("invalid contract")
}
const legacyGlobalChallenges = getConfig<CompiledChallengeIngameData[]>(
"LegacyGlobalChallenges",
false,
)
const json = controller.resolveContract(req.body.contractId)
if (!json) {
log(
LogLevel.ERROR,
`Unknown contract in LGAC: ${req.body.contractId}`,
)
return res.status(404).send("contract not found")
}
if (json.Metadata.Type === "creation") {
return res.json([])
}
const challenges: CompiledChallengeIngameData[] = legacyGlobalChallenges
challenges.push(
...Object.values(
controller.challengeService.getChallengesForContract(
json.Metadata.Id,
req.gameVersion,
),
)
.flat()
.map(
(challengeData) =>
compileRuntimeChallenge(
challengeData,
controller.challengeService.getChallengeProgression(
req.jwt.unique_name,
challengeData.Id,
req.gameVersion,
),
).Challenge,
),
)
res.json(challenges)
},
)
legacyProfileRouter.post(
"/ChallengesService/GetProgression",
jsonMiddleware(),
(req: RequestWithJwt<never, LegacyGetProgressionBody>, res) => {
const legacyGlobalChallenges = getConfig<CompiledChallengeIngameData[]>(
"LegacyGlobalChallenges",
false,
)
const challenges: ChallengeProgressionData[] =
legacyGlobalChallenges.map((challenge) => ({
ChallengeId: challenge.Id,
ProfileId: req.jwt.unique_name,
Completed: false,
State: {},
ETag: `W/"datetime'${encodeURIComponent(
new Date().toISOString(),
)}'"`,
CompletedAt: null,
MustBeSaved: false,
}))
/*
challenges.push(
...Object.values(
controller.challengeService.getChallengesForContract(
req.body.contractId,
req.gameVersion,
),
)
.flat()
.map((challengeData) =>
controller.challengeService.getChallengeProgression(
req.jwt.unique_name,
challengeData.Id,
req.gameVersion,
),
),
)
*/
// TODO: atampy broke this - please fix
// (no contract ID given on this route!!)
res.json(challenges)
},
)
export { legacyProfileRouter }

View File

@ -0,0 +1,9 @@
# Candle
Lighting the way through the new era of Hitman.
Candle is the subsystem for handling Challenges, Progression, and XP.
## Credits
Thanks to Moo for the name suggestion.

View File

@ -0,0 +1,129 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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 {
ChallengeProgressionData,
CompiledChallengeRewardData,
CompiledChallengeRuntimeData,
RegistryChallenge,
} from "../types/types"
import assert from "assert"
export function compileScoringChallenge(
challenge: RegistryChallenge,
): CompiledChallengeRewardData {
return {
ChallengeId: challenge.Id,
ChallengeName: challenge.Name,
ChallengeDescription: challenge.Description,
ChallengeImageUrl: challenge.ImageName,
XPGain: challenge.Rewards?.MasteryXP || 0,
}
}
export function compileRuntimeChallenge(
challenge: RegistryChallenge,
progression: ChallengeProgressionData,
): CompiledChallengeRuntimeData {
return {
// GetActiveChallengesAndProgression
Challenge: {
Id: challenge.Id,
GroupId: challenge.inGroup,
Name: challenge.Name,
Type: challenge.RuntimeType || "contract",
Description: challenge.Description,
ImageName: challenge.ImageName,
InclusionData: challenge.InclusionData || undefined,
Definition: challenge.Definition,
Tags: challenge.Tags,
Drops: challenge.Drops,
LastModified: "2021-01-06T23:00:32.0117635", // this is a lie 👍
PlayableSince: null,
PlayableUntil: null,
Xp: challenge.Rewards.MasteryXP || 0,
XpModifier: challenge.XpModifier || {},
},
Progression: progression,
}
}
export enum ChallengeFilterType {
None = "None",
Contract = "Contract",
ParentLocation = "ParentLocation",
}
export type ChallengeFilterOptions =
| {
type: ChallengeFilterType.None
}
| {
type: ChallengeFilterType.Contract
contractId: string
locationId: string
locationParentId: string
}
| {
type: ChallengeFilterType.ParentLocation
locationParentId: string
}
export function filterChallenge(
options: ChallengeFilterOptions,
challenge: RegistryChallenge,
): boolean {
switch (options.type) {
case ChallengeFilterType.None:
return true
case ChallengeFilterType.Contract: {
assert.ok(options.contractId)
assert.ok(options.locationId)
assert.ok(options.locationParentId)
if (!challenge) {
return false
}
// is this for the current contract?
const isForContract = (
challenge.InclusionData?.ContractIds || []
).includes(options.contractId)
// is this a location-wide challenge?
const isForLocation = challenge.Type === "location"
// is this for the current location?
const isCurrentLocation =
// is this challenge for the current parent location?
challenge.ParentLocationId === options.locationParentId &&
// and, is this challenge's location the current sub-location
// or the parent location? (yup, that can happen)
(challenge.LocationId === options.locationId ||
challenge.LocationId === options.locationParentId)
return isForContract || (isForLocation && isCurrentLocation)
}
case ChallengeFilterType.ParentLocation:
assert.ok(options.locationParentId)
return (
(challenge?.ParentLocationId || "") === options.locationParentId
)
}
}

View File

@ -0,0 +1,935 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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 type {
ChallengeProgressionData,
ClientToServerEvent,
CompiledChallengeTreeCategory,
CompiledChallengeTreeData,
ContractSession,
GameVersion,
PeacockLocationsData,
RegistryChallenge,
} from "../types/types"
import { getUserData, writeUserData } from "../databaseHandler"
import { Controller } from "../controller"
import {
generateCompletionData,
generateUserCentric,
getSubLocationFromContract,
} from "../contracts/dataGen"
import { log, LogLevel } from "../loggingInterop"
import {
parseContextListeners,
ParsedContextListenerInfo,
} from "../statemachines/contextListeners"
import {
handleEvent,
HandleEventOptions,
Timer,
} from "@peacockproject/statemachine-parser"
import { SavedChallengeGroup } from "../types/challenges"
import { fastClone } from "../utils"
import {
ChallengeFilterOptions,
ChallengeFilterType,
filterChallenge,
} from "./challengeHelpers"
import assert from "assert"
import { getVersionedConfig } from "../configSwizzleManager"
import { SyncHook } from "../hooksImpl"
/**
* The structure for a pending write to a user's challenge progression data.
*/
type PendingProgressionWrite = {
userId: string
challengeId: string
gameVersion: GameVersion
progression: ChallengeProgressionData
}
type ChallengeDefinitionLike = {
Context?: Record<string, unknown>
}
type GroupIndexedChallengeLists = {
[groupId: string]: RegistryChallenge[]
}
/**
* A base class providing challenge registration support.
*/
export abstract class ChallengeRegistry {
protected challenges: Map<string, RegistryChallenge> = new Map()
protected groups: Map<string, SavedChallengeGroup> = new Map()
protected groupContents: Map<string, Set<string>> = new Map()
/**
* A map of a challenge ID to a list of challenge IDs that it depends on.
*/
protected readonly _dependencyTree: Map<string, readonly string[]> =
new Map()
protected constructor(protected readonly controller: Controller) {}
registerChallenge(challenge: RegistryChallenge, groupId: string): void {
challenge.inGroup = groupId
this.challenges.set(challenge.Id, challenge)
if (!this.groupContents.has(groupId)) {
this.groupContents.set(groupId, new Set())
}
const set = this.groupContents.get(groupId)!
set.add(challenge.Id)
this.groupContents.set(groupId, set)
this.checkHeuristics(challenge)
}
registerGroup(group: SavedChallengeGroup): void {
this.groups.set(group.CategoryId, group)
}
getChallengeById(challengeId: string): RegistryChallenge | undefined {
return this.challenges.get(challengeId)
}
getGroupById(groupId: string): SavedChallengeGroup | undefined {
return this.groups.get(groupId)
}
getDependenciesForChallenge(challengeId: string): readonly string[] {
return this._dependencyTree.get(challengeId) || []
}
private checkHeuristics(challenge: RegistryChallenge): void {
const ctxListeners = ChallengeRegistry._parseContextListeners(challenge)
if (ctxListeners.challengeTreeIds.length > 0) {
this._dependencyTree.set(
challenge.Id,
ctxListeners.challengeTreeIds,
)
}
}
/**
* Parse a challenge's context listeners into the format used internally.
*
* @param challenge The challenge.
* @returns The context listener details.
*/
protected static _parseContextListeners(
challenge: RegistryChallenge,
): ParsedContextListenerInfo {
return parseContextListeners(
challenge.Definition?.ContextListeners || {},
{
...(challenge.Definition?.Context || {}),
...(challenge.Definition?.Constants || {}),
},
)
}
}
export class ChallengeService extends ChallengeRegistry {
private readonly _challengeContexts: {
[sessionId: string]: {
[challengeId: string]: {
context: unknown
state: string
timers: Timer[]
}
}
}
// we'll use this after writing details to the user's profile
private _justCompletedChallengeIds: string[] = []
public hooks: {
/**
* A hook that is called when a challenge is completed.
*
* Params:
* - userId: The user's ID.
* - challenge: The challenge.
* - gameVersion: The game version.
*/
onChallengeCompleted: SyncHook<[string, RegistryChallenge, GameVersion]>
}
constructor(controller: Controller) {
super(controller)
this._challengeContexts = {}
this.hooks = {
onChallengeCompleted: new SyncHook(),
}
}
getBatchChallengeProgression(
userId: string,
gameVersion: GameVersion,
): Record<string, ChallengeProgressionData> {
const userData = getUserData(userId, gameVersion)
userData.Extensions.PeacockChallengeProgression ??= {}
return userData.Extensions.PeacockChallengeProgression
}
getChallengeProgression(
userId: string,
challengeId: string,
gameVersion: GameVersion,
batchedData?: Record<string, ChallengeProgressionData>,
): ChallengeProgressionData {
const data =
batchedData ||
this.getBatchChallengeProgression(userId, gameVersion)
const challenge = this.getChallengeById(challengeId)
if (this._justCompletedChallengeIds.includes(challengeId)) {
return {
ChallengeId: challengeId,
ProfileId: userId,
Completed: true,
State: {
CurrentState: "Success",
},
ETag: "",
CompletedAt: null,
MustBeSaved: true,
}
}
// prevent game crash
if (data[challengeId]?.Completed) {
data[challengeId].State = {
CurrentState: "Success",
}
}
return (
data[challengeId] || {
ChallengeId: challengeId,
ProfileId: userId,
Completed: false,
State: (<ChallengeDefinitionLike>challenge?.Definition)
?.Context,
ETag: "",
CompletedAt: null,
MustBeSaved: true,
}
)
}
/**
* Get challenge lists sorted into groups.
*
* @param locationId The parent location's ID.
* @param filter The filter to use.
*/
getGroupedChallengeLists(
locationId: string,
filter: ChallengeFilterOptions,
): GroupIndexedChallengeLists {
let challenges: [string, RegistryChallenge[]][] = []
for (const groupId of this.groups.keys()) {
const groupContents = this.groupContents.get(groupId)
if (groupContents) {
let groupChallenges: RegistryChallenge[] | string[] = [
...groupContents,
]
groupChallenges = groupChallenges
.map((challengeId) => {
const challenge = this.getChallengeById(challengeId)
// early return if the challenge is falsy
if (!challenge) {
return challenge
}
return filterChallenge(filter, challenge)
? challenge
: undefined
})
.filter(Boolean) as RegistryChallenge[]
challenges.push([groupId, [...groupChallenges]])
}
}
// remove empty groups
challenges = challenges.filter(
([, challenges]) => challenges.length > 0,
)
return Object.fromEntries(challenges)
}
getChallengesForContract(
contractId: string,
gameVersion: GameVersion,
): GroupIndexedChallengeLists {
const contract = this.controller.resolveContract(contractId)
assert.ok(contract)
const contractParentLocation = getSubLocationFromContract(
contract,
gameVersion,
)?.Properties.ParentLocation
assert.ok(contractParentLocation)
return this.getGroupedChallengeLists(contractParentLocation, {
type: ChallengeFilterType.Contract,
contractId: contractId,
locationId: contract.Metadata.Location,
locationParentId: contractParentLocation,
})
}
startContract(
userId: string,
sessionId: string,
session: ContractSession,
): void {
this._challengeContexts[sessionId] = {}
const { gameVersion, contractId } = session
const challengeGroups = this.getChallengesForContract(
contractId,
session.gameVersion,
)
const batchChallengeProgression = this.getBatchChallengeProgression(
userId,
gameVersion,
)
const writeQueue: PendingProgressionWrite[] = []
for (const group of Object.keys(challengeGroups)) {
for (const challenge of challengeGroups[group]) {
let progression = batchChallengeProgression[challenge.Id]
if (!progression) {
const ctx = fastClone(
(<ChallengeDefinitionLike>challenge.Definition)
?.Context,
)
progression = {
ChallengeId: challenge.Id,
ProfileId: userId,
Completed: false,
State: ctx,
ETag: "",
CompletedAt: null,
MustBeSaved: true,
}
writeQueue.push({
userId,
gameVersion,
progression,
challengeId: challenge.Id,
})
}
this._challengeContexts[sessionId][challenge.Id] = {
context:
fastClone(challenge.Definition?.Context || {}) || {},
state: progression.Completed ? "Success" : "Start",
timers: [],
}
}
}
this.writePendingProgression(writeQueue, userId, gameVersion)
}
onContractEvent(
event: ClientToServerEvent,
sessionId: string,
session: ContractSession,
): void {
const writeQueue: PendingProgressionWrite[] = []
for (const challengeId of Object.keys(
this._challengeContexts[sessionId],
)) {
const challenge = this.getChallengeById(challengeId)
const data = this._challengeContexts[sessionId][challengeId]
if (!challenge) {
log(LogLevel.WARN, `Challenge ${challengeId} not found`)
continue
}
try {
const options: HandleEventOptions = {
eventName: event.Name,
currentState: data.state,
timers: data.timers,
timestamp: event.Timestamp,
}
const previousState = data.state
const result = handleEvent(
// @ts-expect-error Needs to be fixed upstream.
challenge.Definition,
fastClone(data.context),
event.Value,
options,
)
this._challengeContexts[sessionId][challengeId].state =
result.state
this._challengeContexts[sessionId][challengeId].context =
result.context || challenge.Definition?.Context || {}
if (previousState !== "Success" && result.state === "Success") {
this.hooks.onChallengeCompleted.call(
session.userId,
challenge,
session.gameVersion,
)
this._justCompletedChallengeIds.push(challengeId)
writeQueue.push({
challengeId,
gameVersion: session.gameVersion,
userId: session.userId,
progression: {
ChallengeId: challenge.Id,
ProfileId: session.userId,
Completed: true,
State: (
challenge.Definition as ChallengeDefinitionLike
).Context,
ETag: "",
CompletedAt: new Date().toISOString(),
MustBeSaved: true,
},
})
this.checkWaterfallCompletion(
writeQueue,
session,
challenge,
)
}
} catch (e) {
log(LogLevel.ERROR, e)
}
}
this.writePendingProgression(
writeQueue,
session.userId,
session.gameVersion,
)
}
// TODO: Rewrite this function out, as we can just modify the context!
writePendingProgression(
writeQueue: PendingProgressionWrite[],
userId: string,
gameVersion: GameVersion,
): void {
if (writeQueue.length === 0) {
return
}
const userData = getUserData(userId, gameVersion)
userData.Extensions.PeacockChallengeProgression ??= {}
for (const write of writeQueue) {
userData.Extensions.PeacockChallengeProgression[write.challengeId] =
write.progression
}
writeUserData(userId, gameVersion)
}
/**
* Get the challenge tree for a contract.
*
* @param contractId The ID of the contract.
* @param gameVersion The game version requesting the challenges.
* @param userId The user requesting the challenges' ID.
* @returns The challenge tree.
*/
getChallengeTreeForContract(
contractId: string,
gameVersion: GameVersion,
userId: string,
): CompiledChallengeTreeCategory[] {
const contractData = this.controller.resolveContract(contractId)
if (!contractData) {
log(LogLevel.WARN, `Contract ${contractId} not found`)
return []
}
const subLocation = getSubLocationFromContract(
contractData,
gameVersion,
)
if (!subLocation) {
log(
LogLevel.WARN,
`Failed to get location data in CTREE [${contractData.Metadata.Location}]`,
)
return []
}
const forContract = this.getChallengesForContract(
contractId,
gameVersion,
)
return Object.entries(forContract).map(
([groupId, challenges], index) => {
const groupData = this.getGroupById(groupId)
const challengeProgressionData = challenges.map(
(challengeData) =>
this.getChallengeProgression(
userId,
challengeData.Id,
gameVersion,
),
)
assert.ok(groupData, `Group ${groupId} not found`)
const lastGroup = this.getGroupById(
Object.keys(forContract)[index - 1],
)
const nextGroup = this.getGroupById(
Object.keys(forContract)[index + 1],
)
return {
Name: groupData.Name,
Description: groupData.Description,
Image: groupData.Image,
CategoryId: groupData.CategoryId,
Icon: groupData.Icon,
CompletedChallengesCount: challengeProgressionData.filter(
(progressionData) => progressionData.Completed,
).length,
ChallengesCount: challenges.length,
CompletionData: generateCompletionData(
contractData.Metadata.Location,
userId,
gameVersion,
),
Location: subLocation,
IsLocked: subLocation.Properties.IsLocked || false,
ImageLocked: subLocation.Properties.LockedIcon || "",
RequiredResources:
subLocation.Properties.RequiredResources!,
SwitchData: {
Data: {
Challenges: this.mapSwitchChallenges(
challenges,
userId,
gameVersion,
),
HasPrevious: index !== 0, // whether we are not at the first group
HasNext:
index !== Object.keys(forContract).length - 1, // whether we are not at the final group
PreviousCategoryIcon:
index !== 0 ? lastGroup?.Icon : "",
NextCategoryIcon:
index !== Object.keys(forContract).length - 1
? nextGroup?.Icon
: "",
CategoryData: {
Name: groupData.Name,
Image: groupData.Image,
Icon: groupData.Icon,
ChallengesCount: challenges.length,
CompletedChallengesCount:
challengeProgressionData.filter(
(progressionData) =>
progressionData.Completed,
).length,
},
CompletionData: generateCompletionData(
contractData.Metadata.Location,
userId,
gameVersion,
),
},
IsLeaf: true,
},
}
},
)
}
private mapSwitchChallenges(
challenges: RegistryChallenge[],
userId: string,
gameVersion: GameVersion,
): CompiledChallengeTreeData[] {
return challenges.map((challengeData) => {
// Handle challenge dependencies
const dependencies = this.getDependenciesForChallenge(
challengeData.Id,
)
const completed: string[] = []
const missing: string[] = []
for (const dependency of dependencies) {
if (
this.getChallengeProgression(
userId,
challengeData.Id,
gameVersion,
).Completed
) {
completed.push(dependency)
continue
}
missing.push(dependency)
}
const compiled = this.compileRegistryChallengeTreeData(
challengeData,
this.getChallengeProgression(
userId,
challengeData.Id,
gameVersion,
),
gameVersion,
userId,
)
const { challengeCountData } =
ChallengeService._parseContextListeners(challengeData)
if (dependencies.length > 0) {
compiled.ChallengeProgress = {
count: completed.length,
completed,
total: dependencies.length,
missing: missing.length,
all: dependencies,
}
} else if (challengeCountData.total > 0) {
compiled.ChallengeProgress = {
count: challengeCountData.count,
total: challengeCountData.total,
}
} else {
compiled.ChallengeProgress = null
}
return compiled
})
}
getChallengePlanningDataForContract(
contractId: string,
gameVersion: GameVersion,
userId: string,
): unknown[] {
// TODO: fix return type signature
const contractData = this.controller.resolveContract(contractId)
if (!contractData) {
log(LogLevel.WARN, `Contract ${contractId} not found`)
return []
}
const locationData = getSubLocationFromContract(
contractData,
gameVersion,
)
if (!locationData) {
log(
LogLevel.WARN,
`Failed to get location data in CSERV [${contractData.Metadata.Location}]`,
)
return []
}
const forContract = this.getChallengesForContract(
contractId,
gameVersion,
)
return this.reBatchIntoSwitchedData(forContract, userId, gameVersion)
}
getChallengeDataForDestination(
locationParentId: string,
gameVersion: GameVersion,
userId: string,
): unknown[] {
// TODO: fix return type signature
const locationsData = getVersionedConfig<PeacockLocationsData>(
"LocationsData",
gameVersion,
false,
)
const locationData = locationsData.parents[locationParentId]
if (!locationData) {
log(
LogLevel.WARN,
`Failed to get location data in CSERV [${locationParentId}]`,
)
return []
}
const forLocation = this.getGroupedChallengeLists(locationParentId, {
type: ChallengeFilterType.ParentLocation,
locationParentId,
})
return this.reBatchIntoSwitchedData(
forLocation,
userId,
gameVersion,
true,
)
}
private reBatchIntoSwitchedData(
challenges: GroupIndexedChallengeLists,
userId: string,
gameVersion: GameVersion,
isDestination = false,
) {
const entries = Object.entries(challenges)
const compiler = isDestination
? this.compileRegistryDestinationChallengeData.bind(this)
: this.compileRegistryChallengeTreeData.bind(this)
return entries.map(([groupId, challenges]) => {
const groupData = this.getGroupById(groupId)
const challengeProgressionData = challenges.map((challengeData) =>
this.getChallengeProgression(
userId,
challengeData.Id,
gameVersion,
),
)
return {
Name: groupData?.Name,
ChallengesCount: challenges.length,
CompletedChallengesCount: challengeProgressionData.filter(
(progressionData) => progressionData.Completed,
).length,
SwitchData: {
Data: {
Challenges: challenges.map((challengeData) =>
compiler(
challengeData,
this.getChallengeProgression(
userId,
challengeData.Id,
gameVersion,
),
gameVersion,
userId,
),
),
},
},
}
})
}
compileRegistryChallengeTreeData(
challenge: RegistryChallenge,
progression: ChallengeProgressionData,
gameVersion: GameVersion,
userId: string,
): CompiledChallengeTreeData {
return {
// GetChallengeTreeFor
Id: challenge.Id,
Name: challenge.Name,
ImageName: challenge.ImageName,
Description: challenge.Description,
Rewards: {
MasteryXP: challenge.Rewards.MasteryXP,
},
Drops: [],
Completed: progression.Completed,
IsPlayable: challenge.IsPlayable || false,
IsLocked: challenge.IsLocked || false,
HideProgression: false,
CategoryName:
this.getGroupById(challenge.inGroup!)?.Name || "NOTFOUND",
Icon: challenge.Icon,
LocationId: challenge.LocationId,
ParentLocationId: challenge.ParentLocationId,
Type: challenge.Type || "contract",
ChallengeProgress: null,
DifficultyLevels: [],
CompletionData: generateCompletionData(
challenge.ParentLocationId,
userId,
gameVersion,
true,
),
}
}
compileRegistryDestinationChallengeData(
challenge: RegistryChallenge,
progression: ChallengeProgressionData,
gameVersion: GameVersion,
userId: string,
): CompiledChallengeTreeData {
let contract
// TODO: Properly get escalation groups for this
if (challenge.Type === "contract") {
contract = this.controller.resolveContract(
challenge.InclusionData?.ContractIds?.[0] || "",
)
// This is so we can remove unused data and make it more like official - AF
contract =
contract === undefined
? null
: {
// The null is for escalations as we cannot currently get groups
Data: {
Bricks: contract.Data.Bricks,
DevOnlyBricks: null,
GameChangerReferences:
contract.Data.GameChangerReferences || [],
GameChangers: contract.Data.GameChangers || [],
GameDifficulties:
contract.Data.GameDifficulties || [],
},
Metadata: {
CreationTimestamp: null,
CreatorUserId: contract.Metadata.CreatorUserId,
DebriefingVideo:
contract.Metadata.DebriefingVideo || "",
Description: contract.Metadata.Description,
Drops: contract.Metadata.Drops || null,
Entitlements:
contract.Metadata.Entitlements || [],
GroupTitle: contract.Metadata.GroupTitle || "",
Id: contract.Metadata.Id,
IsPublished:
contract.Metadata.IsPublished || true,
LastUpdate: null,
Location: contract.Metadata.Location,
PublicId: contract.Metadata.PublicId || "",
ScenePath: contract.Metadata.ScenePath,
Subtype: contract.Metadata.Subtype || "",
TileImage: contract.Metadata.TileImage,
Title: contract.Metadata.Title,
Type: contract.Metadata.Type,
},
}
}
return {
...this.compileRegistryChallengeTreeData(
challenge,
progression,
gameVersion,
userId,
),
UserCentricContract:
challenge.Type === "contract"
? generateUserCentric(contract, userId, gameVersion)
: (null as unknown as undefined),
}
}
private checkWaterfallCompletion(
writeQueue: PendingProgressionWrite[],
session: ContractSession,
challenge: RegistryChallenge,
): void {
// find any dependency trees that depend on the challenge
for (const depTreeId of this._dependencyTree.keys()) {
const allDeps = this._dependencyTree.get(depTreeId)
assert.ok(allDeps, `No dep tree for ${depTreeId}`)
// check if the dependency tree is completed
const completed = allDeps.every((depId) => {
const depProgression = this.getChallengeProgression(
session.userId,
depId,
session.gameVersion,
)
return depProgression?.Completed
})
if (!completed) {
continue
}
if (PEACOCK_DEV) {
log(
LogLevel.DEBUG,
`${challenge.Id}'s completion caused all conditions to be met for ${depTreeId}`,
)
}
writeQueue.push({
challengeId: depTreeId,
gameVersion: session.gameVersion,
userId: session.userId,
progression: {
ChallengeId: depTreeId,
ProfileId: session.userId,
Completed: true,
State: {
...((challenge?.Definition as ChallengeDefinitionLike)
?.Context || {}),
CurrentState: "Success",
},
ETag: "",
CompletedAt: new Date().toISOString(),
MustBeSaved: true,
},
})
}
}
}

View File

@ -0,0 +1,315 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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/>.
*/
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"
import { join } from "path"
import { log, LogLevel } from "./loggingInterop"
import Roadmap from "../static/Roadmap.json"
import StoreData from "../static/StoreData.json"
import FilterData from "../static/FilterData.json"
import LocationsData from "../static/LocationsData.json"
import GameChangerProperties from "../static/GameChangerProperties.json"
import allunlockables from "../static/allunlockables.json"
import Destinations from "../static/Destinations.json"
import connectionConfigTemplate from "../static/config.json"
import onlineconfig from "../static/onlineconfig.json"
import privacypolicy from "../static/privacypolicy.json"
import UserDefault from "../static/UserDefault.json"
import AgencyPickups from "../static/AgencyPickups.json"
import Entrances from "../static/Entrances.json"
import LeaderboardEntriesTemplate from "../static/LeaderboardEntriesTemplate.json"
import LeaderboardsViewTemplate from "../static/LeaderboardsViewTemplate.json"
import MissionEndReadyTemplate from "../static/MissionEndReadyTemplate.json"
import MissionEndNotReadyTemplate from "../static/MissionEndNotReadyTemplate.json"
import SelectAgencyPickupTemplate from "../static/SelectAgencyPickupTemplate.json"
import SelectEntranceTemplate from "../static/SelectEntranceTemplate.json"
import StashpointTemplate from "../static/StashpointTemplate.json"
import LoadMenuTemplate from "../static/LoadMenuTemplate.json"
import SaveMenuTemplate from "../static/SaveMenuTemplate.json"
import Playstyles from "../static/Playstyles.json"
import HubPageData from "../static/HubPageData.json"
import DashboardCategoryEscalation from "../static/DashboardCategoryEscalation.json"
import GlobalChallenges from "../static/GlobalChallenges.json"
import ContractsTemplate from "../static/ContractsTemplate.json"
import CreateContractPlanningTemplate from "../static/CreateContractPlanningTemplate.json"
import CreateContractReturnTemplate from "../static/CreateContractReturnTemplate.json"
import PlayerProfilePage from "../static/PlayerProfileView.json"
import Legacyallunlockables from "../static/Legacyallunlockables.json"
import LegacyGlobalChallenges from "../static/LegacyGlobalChallenges.json"
import LegacySafehouseTemplate from "../static/LegacySafehouseTemplate.json"
import LegacyHubTemplate from "../static/LegacyHubTemplate.json"
import LegacyPlanningTemplate from "../static/LegacyPlanningTemplate.json"
import LegacySelectAgencyPickupTemplate from "../static/LegacySelectAgencyPickupTemplate.json"
import LegacySelectEntranceTemplate from "../static/LegacySelectEntranceTemplate.json"
import LegacyStashpointTemplate from "../static/LegacyStashpointTemplate.json"
import LegacyUserDefault from "../static/LegacyUserDefault.json"
import LegacyContractSearchResponseTemplate from "../static/LegacyContractSearchResponseTemplate.json"
import LegacyFilterData from "../static/LegacyFilterData.json"
import PlayNextTemplate from "../static/PlayNextTemplate.json"
import LookupContractByIdTemplate from "../static/LookupContractByIdTemplate.json"
import LookupContractFavoriteTemplate from "../static/LookupContractFavoriteTemplate.json"
import MissionStories from "../static/MissionStories.json"
import DebriefingLeaderboardsTemplate from "../static/DebriefingLeaderboardsTemplate.json"
import LegacyHitsCategoryTemplate from "../static/LegacyHitsCategoryTemplate.json"
import LegacyStoreData from "../static/LegacyStoreData.json"
import LegacyDestinations from "../static/LegacyDestinations.json"
import LegacyDestinationTemplate from "../static/LegacyDestinationTemplate.json"
import LegacyLocationsData from "../static/LegacyLocationsData.json"
import LegacySaveMenuTemplate from "../static/LegacySaveMenuTemplate.json"
import LegacyLoadMenuTemplate from "../static/LegacyLoadMenuTemplate.json"
import LegacyLookupContractByIdTemplate from "../static/LegacyLookupContractByIdTemplate.json"
import EiderDashboard from "../static/EiderDashboard.json"
import PersistentBools from "../static/PersistentBools.json"
import H2allunlockables from "../static/H2allunlockables.json"
import H2DestinationsData from "../static/H2DestinationsData.json"
import H2StoreData from "../static/H2StoreData.json"
import H2ContractSearchResponseTemplate from "../static/H2ContractSearchResponseTemplate.json"
import H2LocationsData from "../static/H2LocationsData.json"
import H2FilterData from "../static/H2FilterData.json"
import H2DashboardTemplate from "../static/H2DashboardTemplate.json"
import FrankensteinHubTemplate from "../static/FrankensteinHubTemplate.json"
import FrankensteinMmSpTemplate from "../static/FrankensteinMmSpTemplate.json"
import FrankensteinMmMpTemplate from "../static/FrankensteinMmMpTemplate.json"
import FrankensteinScoreOverviewTemplate from "../static/FrankensteinScoreOverviewTemplate.json"
import FrankensteinPlanningTemplate from "../static/FrankensteinPlanningTemplate.json"
import Videos from "../static/Videos.json"
import ContractSearchPageTemplate from "../static/ContractSearchPageTemplate.json"
import ContractSearchResponseTemplate from "../static/ContractSearchResponseTemplate.json"
import LegacyDebriefingChallengesTemplate from "../static/LegacyDebriefingChallengesTemplate.json"
import MasteryUnlockablesTemplate from "../static/MasteryUnlockablesTemplate.json"
import SniperLoadouts from "../static/SniperLoadouts.json"
import Scpcallunlockables from "../static/Scpcallunlockables.json"
import DiscordRichAssetsForBricks from "../static/DiscordRichAssetsForBricks.json"
import EscalationCodenames from "../static/EscalationCodenames.json"
import scoreoverviewtemplate from "../static/scoreoverviewtemplate.json"
import PeacockGameChangerProperties from "../static/PeacockGameChangerProperties.json"
import MultiplayerPresets from "../static/MultiplayerPresets.json"
import LobbySlimTemplate from "../static/LobbySlimTemplate.json"
import type { GameVersion } from "./types/types"
import { fastClone } from "./utils"
/**
* All the configurations. Gets modified before being exported.
*
* @private
*/
const configs: Record<string, unknown> = {
Roadmap,
StoreData,
FilterData,
LocationsData,
LeaderboardsViewTemplate,
LeaderboardEntriesTemplate,
GameChangerProperties,
allunlockables,
Destinations,
config: connectionConfigTemplate,
onlineconfig,
privacypolicy,
UserDefault,
AgencyPickups,
Entrances,
MissionEndReadyTemplate,
MissionEndNotReadyTemplate,
SelectAgencyPickupTemplate,
SelectEntranceTemplate,
StashpointTemplate,
LoadMenuTemplate,
SaveMenuTemplate,
Playstyles,
HubPageData,
DashboardCategoryEscalation,
GlobalChallenges,
ContractsTemplate,
CreateContractPlanningTemplate,
CreateContractReturnTemplate,
PlayerProfilePage,
Legacyallunlockables,
LegacyGlobalChallenges,
LegacySafehouseTemplate,
LegacyHubTemplate,
LegacyPlanningTemplate,
LegacySelectAgencyPickupTemplate,
LegacySelectEntranceTemplate,
LegacyStashpointTemplate,
LegacyUserDefault,
LegacyFilterData,
PlayNextTemplate,
LookupContractByIdTemplate,
LookupContractFavoriteTemplate,
MissionStories,
DebriefingLeaderboardsTemplate,
LegacyHitsCategoryTemplate,
LegacyStoreData,
LegacyDestinations,
LegacyDestinationTemplate,
LegacyLocationsData,
LegacySaveMenuTemplate,
LegacyLoadMenuTemplate,
LegacyContractSearchResponseTemplate,
LegacyDebriefingChallengesTemplate,
LegacyLookupContractByIdTemplate,
EiderDashboard,
PersistentBools,
FrankensteinHubTemplate,
H2allunlockables,
H2DestinationsData,
H2StoreData,
H2ContractSearchResponseTemplate,
H2LocationsData,
H2FilterData,
H2DashboardTemplate,
FrankensteinMmSpTemplate,
FrankensteinMmMpTemplate,
FrankensteinPlanningTemplate,
FrankensteinScoreOverviewTemplate,
Videos,
ContractSearchPageTemplate,
ContractSearchResponseTemplate,
MasteryUnlockablesTemplate,
SniperLoadouts,
Scpcallunlockables,
DiscordRichAssetsForBricks,
EscalationCodenames,
scoreoverviewtemplate,
PeacockGameChangerProperties,
MultiplayerPresets,
LobbySlimTemplate,
}
Object.keys(configs).forEach((cfg) => {
const overridePath = join("overrides", `${cfg}.json`)
if (existsSync(overridePath)) {
log(LogLevel.INFO, `Loaded override config for ${cfg}.`)
configs[cfg] = JSON.parse(readFileSync(overridePath).toString())
}
})
export { configs }
/**
* Get a config file.
* Configs for H1 start with "Legacy", "H2" for HITMAN 2, and no prefix for HITMAN 3.
*
* @param config The name of the config file.
* @param clone If the value should be cloned (saves memory if false, but as a side effect, modifications will affect the actual config).
* @returns The config.
* @throws {Error} If the config file specified doesn't exist.
*/
export function getConfig<T = unknown>(config: string, clone: boolean): T {
if (configs.hasOwnProperty.call(configs, config)) {
if (!clone) {
return configs[config]
}
// properly create object clones
// this could be better, but this is the best temporary solution
return fastClone(configs[config])
}
throw new Error(`Tried to lookup config that does not exist: ${config}`)
}
/**
* Get a config file intended for the specified game version.
*
* @param config The name of the config file.
* @param gameVersion The game's version ("h1", "h2", or "h3").
* @param clone If the config should be cloned (saves memory if false, but as a side effect, modifications will affect the actual config).
* @returns The config.
* @see getConfig
* @throws {Error} If the config file specified doesn't exist.
*/
export function getVersionedConfig<T = unknown>(
config: string,
gameVersion: GameVersion,
clone: boolean,
): T {
let h1Prefix = ""
if (
// is this scpc, do we have a scpc config?
gameVersion === "scpc" &&
Object.prototype.hasOwnProperty.call(configs, `Scpc${config}`)
) {
h1Prefix = "Scpc"
} else {
// the above condition wasn't true
if (["scpc", "h1"].includes(gameVersion)) {
h1Prefix = "Legacy"
}
}
// if this is H2, but we don't have a h2 specific config, fall back to h3
if (
gameVersion === "h2" &&
!Object.prototype.hasOwnProperty.call(configs, `H2${config}`)
) {
return getConfig(config, clone)
}
return getConfig(
`${h1Prefix}${gameVersion === "h2" ? "H2" : ""}${config}`,
clone,
)
}
/**
* Creates an override config.
*
* @param name The name of the config to override.
*/
export function swizzle(name: string): void {
if (existsSync(join("overrides", `${name}.json`))) {
log(
LogLevel.ERROR,
`That file already exists in overrides/${name}.json - Aborting.`,
)
process.exit(1)
}
if (!Object.prototype.hasOwnProperty.call(configs, name)) {
log(LogLevel.ERROR, `No configs have the name ${name} - Aborting.`)
process.exit(1)
}
if (!existsSync("overrides")) {
mkdirSync("overrides")
}
writeFileSync(
join("overrides", `${name}.json`),
JSON.stringify(configs[name]),
)
log(LogLevel.INFO, `Done! Wrote override to overrides/${name}.json`)
}
/**
* Gets a list of swizzleable configurations.
*
* @returns A list of swizzleable configs.
*/
export function getSwizzleable(): string[] {
return Object.keys(configs)
}

View File

@ -0,0 +1,308 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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 { Router } from "express"
import { nilUuid, ServerVer, uuidRegex } from "../utils"
import { json as jsonMiddleware } from "body-parser"
import {
enqueueEvent,
getSession,
newSession,
registerObjectiveListener,
} from "../eventHandler"
import { controller } from "../controller"
import { getConfig } from "../configSwizzleManager"
import type {
CreateFromParamsBody,
GameChanger,
MissionManifest,
MissionManifestObjective,
RequestWithJwt,
} from "../types/types"
import {
contractIdToEscalationGroupId,
getPlayEscalationInfo,
} from "./escalations/escalationService"
import { log, LogLevel } from "../loggingInterop"
import { randomUUID } from "crypto"
import {
createTimeLimit,
TargetCreator,
} from "../statemachines/contractCreation"
import { createSniperLoadouts } from "../menus/sniper"
import { GetForPlay2Body } from "../types/gameSchemas"
import assert from "assert"
const contractRoutingRouter = Router()
contractRoutingRouter.post(
"/GetForPlay2",
jsonMiddleware(),
async (req: RequestWithJwt<never, GetForPlay2Body>, res) => {
if (!req.body.id || !uuidRegex.test(req.body.id)) {
res.status(400).end()
return // user sent some nasty info
}
const contractData = controller.resolveContract(req.body.id)
if (!contractData) {
log(
LogLevel.ERROR,
`Requested unknown contract in GetForPlay2: ${req.body.id}`,
)
res.status(404).end()
return
}
const sniperloadouts = createSniperLoadouts(contractData)
const loadoutData = {
CharacterLoadoutData:
sniperloadouts.length !== 0 ? sniperloadouts : null,
}
// Add escalation data to Contract data HERE
// @ts-expect-error TypeScript going crazy
contractData.Metadata = {
...contractData.Metadata,
...(await getPlayEscalationInfo(
contractData.Metadata.Type === "escalation",
req.jwt.unique_name,
contractIdToEscalationGroupId(req.body.id),
req.gameVersion,
)),
...loadoutData,
}
const contractSesh = {
Contract: contractData,
ContractSessionId: `${process.hrtime
.bigint()
.toString()}-${randomUUID()}`,
ContractProgressionData: null,
}
if (
contractData.Data.GameChangers &&
contractData.Data.GameChangers.length > 0
) {
type GCPConfig = Record<string, GameChanger>
const gameChangerData: GCPConfig = {
...getConfig<GCPConfig>("GameChangerProperties", true),
...getConfig<GCPConfig>("PeacockGameChangerProperties", true),
}
contractData.Data.GameChangerReferences =
contractData.Data.GameChangerReferences || []
for (const gameChangerId of contractData.Data.GameChangers) {
if (
!Object.prototype.hasOwnProperty.call(
gameChangerData,
gameChangerId,
)
) {
log(
LogLevel.ERROR,
`GetForPlay has detected a missing GameChanger: ${gameChangerId}! This is a bug.`,
)
}
const gameChanger = gameChangerData[gameChangerId]
gameChanger.Id = gameChangerId
delete gameChanger.ObjectivesCategory
if (
contractData.Data.GameChangerReferences.includes(
gameChanger,
)
) {
continue
}
contractData.Data.GameChangerReferences.push(gameChanger)
contractData.Data.Bricks = [
...(contractData.Data.Bricks ?? []),
...(gameChanger.Resource ?? []),
]
contractData.Data.Objectives = [
...(contractData.Data.Objectives ?? []),
...(gameChanger.Objectives ?? []),
]
}
}
enqueueEvent(req.jwt.unique_name, {
Version: ServerVer,
IsReplicated: false,
CreatedContract: null,
Id: randomUUID(),
Name: "ContractSessionMarker",
UserId: nilUuid,
ContractId: nilUuid,
SessionId: null,
ContractSessionId: contractSesh.ContractSessionId,
Timestamp: 0.0,
Value: {
Currency: {
ContractPaymentAllowed: true,
ContractPayment: null,
},
},
Origin: null,
})
res.json(contractSesh)
newSession(
contractSesh.ContractSessionId,
contractSesh.Contract.Metadata.Id,
req.jwt.unique_name,
req.body.difficultyLevel!,
req.gameVersion,
)
const theSession = getSession(req.jwt.unique_name)
assert.ok(theSession, "Session should exist")
for (const obj of contractData.Data.Objectives || []) {
// register the objective as a tracked statemachine
registerObjectiveListener(theSession, obj)
}
},
)
contractRoutingRouter.post(
"/CreateFromParams",
jsonMiddleware(),
async (
req: RequestWithJwt<Record<never, never>, CreateFromParamsBody>,
res,
) => {
const gameChangerData = getConfig<Record<string, GameChanger>>(
"GameChangerProperties",
true,
)
const objectives: MissionManifestObjective[] = []
const gamechangers: string[] = []
const sessionDetails = getSession(req.jwt.unique_name)
if (!sessionDetails) {
res.status(400).end()
log(
LogLevel.WARN,
`CreateFromParams called without a valid session`,
)
return
}
// I'm using Math.ceil here to round the time to the nearest next full second
// IOI servers don't do this, but that means that the displayed time in the objective
// is not accurate with the actual time limit.
// If you change this, also change it in menuData.ts
const timeLimit = Math.ceil(
(sessionDetails.timerEnd as number) -
(sessionDetails.timerStart as number),
)
const contractData = controller.resolveContract(
sessionDetails.contractId,
)
if (!contractData) {
res.status(400).end()
log(
LogLevel.ERROR,
`No such contract creation contract: ${sessionDetails.contractId}`,
)
return
}
req.body.creationData.Targets.forEach((target) => {
if (!target.Selected) {
return
}
objectives.push(...new TargetCreator(target).build())
})
req.body.creationData.ContractConditionIds.forEach(
(contractConditionId) => {
if (
Object.prototype.hasOwnProperty.call(
gameChangerData,
contractConditionId,
)
) {
gamechangers.push(contractConditionId)
} else if (
contractConditionId ===
"1a596216-381e-4592-9798-26f156973942"
) {
// Optional time limit
objectives.push(createTimeLimit(timeLimit, true))
} else if (
contractConditionId ===
"3d6f9119-7ec8-496f-ab4c-ed9757d976a4"
) {
// Mandatory time limit
objectives.push(createTimeLimit(timeLimit, false))
}
},
)
const theVersion = `${ServerVer._Major}.${ServerVer._Minor}.${ServerVer._Build}.${ServerVer._Revision}`
const manifest: MissionManifest = {
Data: {
Objectives: objectives,
GameChangers: gamechangers,
Bricks: [],
},
Metadata: {
Title: req.body.creationData.Title,
Description: req.body.creationData.Description,
Entitlements: contractData.Metadata.Entitlements,
ScenePath: contractData.Metadata.ScenePath,
Location: contractData.Metadata.Location,
IsPublished: true,
CreatorUserId: "fadb923c-e6bb-4283-a537-eb4d1150262e",
GameVersion: theVersion,
ServerVersion: theVersion,
Type: "usercreated",
Id: req.body.creationData.ContractId,
PublicId: req.body.creationData.ContractPublicId,
TileImage: `$($repository ${req.body.creationData.Targets[0]?.RepositoryId}).Image`,
GroupObjectiveDisplayOrder: req.body.creationData.Targets.map(
(t) => ({
Id: t.RepositoryId,
}),
),
CreationTimestamp: new Date().toISOString(),
},
UserData: {},
}
await controller.commitNewContract(manifest)
res.json(manifest)
},
)
export { contractRoutingRouter }

View File

@ -0,0 +1,58 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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 type { RequestWithJwt } from "../types/types"
import type { Response } from "express"
import { getConfig, getVersionedConfig } from "../configSwizzleManager"
import { getUserData } from "../databaseHandler"
import { generateUserCentric } from "./dataGen"
import { controller } from "../controller"
import { createLocationsData } from "../menus/destinations"
export function contractsModeHome(req: RequestWithJwt, res: Response): void {
const contractsHomeTemplate = getConfig("ContractsTemplate", false)
const userData = getUserData(req.jwt.unique_name, req.gameVersion)
const contractCreationTutorial = controller.resolveContract(
"d7e2607c-6916-48e2-9588-976c7d8998bb",
)
res.json({
template: contractsHomeTemplate,
data: {
CreateContractTutorial: generateUserCentric(
contractCreationTutorial!,
req.jwt.unique_name,
req.gameVersion,
),
LocationsData: createLocationsData(req.gameVersion, true),
FilterData: getVersionedConfig(
"FilterData",
req.gameVersion,
false,
),
PlayerProfileXpData: {
XP: userData.Extensions.progression.PlayerProfileXP.Total,
Level: userData.Extensions.progression.PlayerProfileXP
.ProfileLevel,
MaxLevel: 5000,
},
},
})
}

View File

@ -0,0 +1,807 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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 { getConfig, getVersionedConfig } from "../configSwizzleManager"
import type {
CompletionData,
GameChanger,
GameVersion,
GroupObjectiveDisplayOrderItem,
MissionManifest,
MissionManifestObjective,
PeacockLocationsData,
Unlockable,
UserCentricContract,
} from "../types/types"
import { fastClone, nilUuid } from "../utils"
import { log, LogLevel } from "../loggingInterop"
import { getUserData } from "../databaseHandler"
import { controller } from "../controller"
import {
getLevelCount,
getUserEscalationProgress,
} from "./escalations/escalationService"
import { translateEntitlements } from "../ownership"
// TODO: In the near future, this file should be cleaned up where possible.
/**
* Get the sub-location from a contract's location field.
*
* @param contractData The contract data.
* @param gameVersion The game version.
* @returns The sub-location.
*/
export function getSubLocationFromContract(
contractData: MissionManifest,
gameVersion: GameVersion,
): Unlockable | undefined {
if (
gameVersion === "h1" &&
contractData.Metadata.Location.includes("LOCATION_ICA_FACILITY")
) {
contractData.Metadata.Location = "LOCATION_ICA_FACILITY"
}
return getSubLocationByName(contractData.Metadata.Location, gameVersion)
}
/**
* Get a sub-location by name.
*
* @param name The sublocation's name (e.g. `LOCATION_NORTHAMERICA_GARTERSNAKE`).
* @param gameVersion The game's version.
* @returns The sub-location.
*/
export function getSubLocationByName(
name: string,
gameVersion: GameVersion,
): Unlockable | undefined {
const locationsData = getVersionedConfig<PeacockLocationsData>(
"LocationsData",
gameVersion,
false,
)
return fastClone(locationsData.children[name])
}
/**
* Generates a CompletionData object.
*
* @param subLocationId The ID of the targeted sub-location.
* @param userId The ID of the user.
* @param gameVersion The game's version.
* @param isParentLocation If this is meant to be focused on a parent location.
* If true, the SubLocationId property will not be set.
* @returns The completion data object.
*/
export function generateCompletionData(
subLocationId: string,
userId: string,
gameVersion: GameVersion,
isParentLocation = false,
): CompletionData {
// TODO(v6/v7): fetch actual statistics from the user's profile.
const subLocation = getSubLocationByName(subLocationId, gameVersion)
return {
Level: 20,
MaxLevel: 20,
XP: 0,
Completion: 1,
XpLeft: 6000,
Id: isParentLocation
? subLocationId
: subLocation?.Properties?.ParentLocation,
SubLocationId: subLocation?.Id,
HideProgression: false,
IsLocationProgression: true,
Name: null,
}
}
/**
* Changes a contract to the "UserCentric" format.
*
* @param contractData Data about the contract.
* @param userId The target user's ID.
* @param gameVersion The game version.
* @returns The user-centric contract.
*/
export function generateUserCentric(
contractData: MissionManifest | undefined,
userId: string,
gameVersion: GameVersion,
): UserCentricContract | undefined {
if (!contractData) {
return undefined
}
const subLocation = getSubLocationFromContract(contractData, gameVersion)
if (!subLocation) {
log(
LogLevel.DEBUG,
`Missing ${contractData.Metadata.Location} in ${gameVersion} config!`,
)
return undefined
}
subLocation.DisplayNameLocKey = `UI_${subLocation!.Id}_NAME`
if (gameVersion === "h1" || gameVersion === "h2") {
// fix h1/h2 entitlements
contractData.Metadata.Entitlements = translateEntitlements(
gameVersion,
contractData.Metadata.Entitlements,
)
}
const uc: UserCentricContract = {
Contract: contractData,
Data: {
IsLocked: subLocation?.Properties?.IsLocked || false,
LockedReason: "",
LocationLevel: 1,
LocationMaxLevel: 1,
LocationCompletion: 1,
LocationXpLeft: 0,
LocationHideProgression: false,
ElusiveContractState: "",
IsFeatured: false,
//LastPlayedAt: '2020-01-01T00:00:00.0000000Z', // ISO timestamp
Completed: false, // relevant for featured contracts
LocationId: subLocation.Id,
ParentLocationId: subLocation.Properties.ParentLocation!,
CompletionData: generateCompletionData(
contractData.Metadata.Location,
userId,
gameVersion,
),
DlcName: subLocation.Properties.DlcName!,
DlcImage: subLocation.Properties.DlcImage!,
},
}
if (contractData.Metadata.Type === "escalation") {
const userData = getUserData(userId, gameVersion)
const eGroupId = contractData.Metadata.InGroup
if (eGroupId) {
const p = getUserEscalationProgress(userData, eGroupId)
log(
LogLevel.DEBUG,
`Get EscalationUCProps - group: ${eGroupId} prog: ${p}`,
)
// I have absolutely no idea why,
// but this is incorrect on the destinations
// screen unless we do proper count - 1
// ANOTHER NOTE - Anthony:
// this currently doesn't mark it as completed when it is,
// unknown to why
uc.Data.EscalationCompletedLevels = p - 1
uc.Data.EscalationTotalLevels = getLevelCount(
controller.escalationMappings[eGroupId],
)
uc.Data.InGroup = eGroupId
}
}
return uc
}
/**
* Converts a series of objectives into the format that the planning screen expects.
*
* @param objectives The objectives.
* @param gameChangers The game changers.
* @param displayOrder The order in which to display the objectives.
* @returns The converted objectives.
*/
export function mapObjectives(
objectives: MissionManifestObjective[],
gameChangers: string[],
displayOrder: GroupObjectiveDisplayOrderItem[],
): MissionManifestObjective[] {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = new Map<string, any>()
const gameChangerObjectives: MissionManifestObjective[] = []
if (gameChangers && gameChangers.length > 0) {
const gameChangerData = getConfig<Record<string, GameChanger>>(
"GameChangerProperties",
true,
)
for (const gamechangerId of gameChangers) {
const gameChangerProps = gameChangerData[gamechangerId]
if (gameChangerProps) {
if (gameChangerProps.IsHidden) {
if (gameChangerProps.Objectives?.length === 1) {
// Either 0 or 1 I think.
const objective = gameChangerProps.Objectives[0]!
objective.Id = gamechangerId
gameChangerObjectives.push(objective)
}
} else {
result.set(gamechangerId, {
Type: "gamechanger",
Properties: {
Id: gamechangerId,
Name: gameChangerProps.Name,
Description: gameChangerProps.Description,
LongDescription:
gameChangerProps.LongDescription === undefined
? gameChangerProps.Description
: gameChangerProps.LongDescription,
TileImage: gameChangerProps.TileImage,
Icon: gameChangerProps.Icon || "",
ObjectivesCategory:
gameChangerProps.ObjectivesCategory,
},
})
}
}
}
}
for (const objective of (objectives || []).concat(gameChangerObjectives)) {
if (!objective.Category) {
objective.Category = objective.Primary ? "primary" : "secondary"
}
if (
objective.Activation ||
(objective.OnActive?.IfInProgress &&
objective.OnActive.IfInProgress.Visible === false) ||
(objective.OnActive?.IfCompleted &&
objective.OnActive.IfCompleted.Visible === false &&
objective.Definition &&
objective.Definition.States &&
objective.Definition.States.Start &&
objective.Definition.States.Start["-"] &&
objective.Definition.States.Start["-"].Transition === "Success")
) {
continue // do not show objectives with 'ForceShowOnLoadingScreen: false' or objectives that are not visible on start
}
if (
objective.SuccessEvent &&
objective.SuccessEvent.EventName === "Kill" &&
objective.SuccessEvent.EventValues &&
objective.SuccessEvent.EventValues.RepositoryId
) {
result.set(objective.Id, {
Type: "kill",
Properties: {
Id: objective.SuccessEvent.EventValues.RepositoryId,
Conditions: [],
},
})
} else if (
objective.HUDTemplate &&
["custom", "customkill", "setpiece"].includes(
objective.ObjectiveType || "",
)
) {
let id: string | null | undefined = null
if (
objective.Definition?.Context?.Targets &&
(objective.Definition.Context.Targets as string[]).length === 1
) {
id = objective.Definition.Context.Targets[0]
}
const properties = {
Id: id,
BriefingText: objective.BriefingText || "",
LongBriefingText:
objective.LongBriefingText === undefined
? objective.BriefingText || ""
: objective.LongBriefingText,
Image: objective.Image,
BriefingName: objective.BriefingName,
DisplayAsKill: objective.DisplayAsKillObjective || false,
ObjectivesCategory: objective.Category as
| typeof objective.Category
| undefined,
ForceShowOnLoadingScreen: (objective.ForceShowOnLoadingScreen ||
false) as boolean | undefined,
}
// noinspection GrazieInspection
switch (objective.ObjectiveType) {
case "customkill":
properties.Image = undefined
properties.ForceShowOnLoadingScreen = undefined
properties.BriefingName = undefined
break
case "setpiece":
properties.ObjectivesCategory = undefined
break
default: // only add Id for customkill and setpiece
properties.Id = undefined
break
}
result.set(objective.Id, {
Type: objective.ObjectiveType,
Properties: properties,
})
} else if (
objective.Type === "statemachine" &&
objective.Definition &&
objective.Definition.Context &&
objective.Definition.Context.Targets &&
(objective.Definition.Context.Targets as unknown[]).length === 1 &&
objective.HUDTemplate
) {
// This objective will be displayed as a kill objective
const Conditions = objective.TargetConditions
? objective.TargetConditions.map((condition) => ({
Type: condition.Type,
RepositoryId: condition.RepositoryId || nilUuid,
HardCondition: condition.HardCondition || false,
ObjectiveId: condition.ObjectiveId || nilUuid,
KillMethod: condition.KillMethod || "",
}))
: []
result.set(objective.Id, {
Type: "kill",
Properties: {
Id: objective.Definition.Context.Targets[0],
Conditions: Conditions,
},
})
}
// objective not shown on planning screen
}
const sortedResult: MissionManifestObjective[] = []
const resultIds: Set<string> = new Set()
for (const { Id, IsNew } of displayOrder || []) {
if (!resultIds.has(Id)) {
// if not yet added
const objective = result.get(Id)
if (objective) {
if (IsNew) {
objective.Properties.IsNew = true
}
sortedResult.push(objective)
resultIds.add(Id)
}
}
}
// add each objective or gamechanger that is not already in the result
for (const { Id, ExcludeFromScoring, ForceShowOnLoadingScreen } of (
objectives || []
).concat((gameChangers || []).map((x) => ({ Id: x })))) {
if (!resultIds.has(Id)) {
const resultobjective = result.get(Id)
if (
resultobjective &&
(!ExcludeFromScoring || ForceShowOnLoadingScreen)
) {
sortedResult.push(resultobjective)
resultIds.add(Id)
}
}
}
return sortedResult
}
const noDisguiseChanges = {
IsCompleted: true,
ContractConditionType: "PrimarySecondary",
Primary: [
{
Type: "gamechanger",
Properties: {
Id: "63055f1a-bcd2-4e0f-8caf-b446f01d02f3",
Name: "UI_GAMECHANGERS_GLOBAL_CONTRACTCONDITION_NO_DISGUISE_CHANGES_PRIMARY_NAME",
Description:
"UI_GAMECHANGERS_GLOBAL_CONTRACTCONDITION_NO_DISGUISE_CHANGES_PRIMARY_DESC",
LongDescription:
"UI_GAMECHANGERS_GLOBAL_CONTRACTCONDITION_NO_DISGUISE_CHANGES_PRIMARY_DESC",
TileImage:
"images/contractconditions/condition_contract_no_disguise_changes.jpg",
Icon: "images/challenges/default_challenge_icon.png",
ObjectivesCategory: "primary",
},
},
],
Secondary: [
{
Type: "gamechanger",
Properties: {
Id: "008d2eb9-c1c8-44e0-a636-ccca63629f3c",
Name: "UI_GAMECHANGERS_GLOBAL_CONTRACTCONDITION_NO_DISGUISE_CHANGES_SECONDARY_NAME",
Description:
"UI_GAMECHANGERS_GLOBAL_CONTRACTCONDITION_NO_DISGUISE_CHANGES_SECONDARY_DESC",
LongDescription:
"UI_GAMECHANGERS_GLOBAL_CONTRACTCONDITION_NO_DISGUISE_CHANGES_SECONDARY_DESC",
TileImage:
"images/contractconditions/condition_contract_no_disguise_changes.jpg",
Icon: "images/challenges/default_challenge_icon.png",
ObjectivesCategory: "secondary",
},
},
],
}
const targetsOnly = {
IsCompleted: true,
ContractConditionType: "PrimarySecondary",
Primary: [
{
Type: "gamechanger",
Properties: {
Id: "f41f18fe-0fe5-416a-a793-50727e594655",
Name: "UI_GAMECHANGERS_GLOBAL_CONTRACTCONDITION_TARGETS_ONLY_PRIMARY_NAME",
Description:
"UI_GAMECHANGERS_GLOBAL_CONTRACTCONDITION_TARGETS_ONLY_PRIMARY_DESC",
LongDescription:
"UI_GAMECHANGERS_GLOBAL_CONTRACTCONDITION_TARGETS_ONLY_PRIMARY_DESC",
TileImage:
"images/contractconditions/condition_contrac_targets_only.jpg",
Icon: "images/challenges/default_challenge_icon.png",
ObjectivesCategory: "primary",
},
},
],
Secondary: [
{
Type: "gamechanger",
Properties: {
Id: "8618ebaa-f42b-42ce-be20-00d2b0a04897",
Name: "UI_GAMECHANGERS_GLOBAL_CONTRACTCONDITION_TARGETS_ONLY_SECONDARY_NAME",
Description:
"UI_GAMECHANGERS_GLOBAL_CONTRACTCONDITION_TARGETS_ONLY_SECONDARY_DESC",
LongDescription:
"UI_GAMECHANGERS_GLOBAL_CONTRACTCONDITION_TARGETS_ONLY_SECONDARY_DESC",
TileImage:
"images/contractconditions/condition_contrac_targets_only.jpg",
Icon: "images/challenges/default_challenge_icon.png",
ObjectivesCategory: "secondary",
},
},
],
}
const noRecordings = {
IsCompleted: true,
ContractConditionType: "PrimarySecondary",
Primary: [
{
Type: "gamechanger",
Properties: {
Id: "1f1f3c9e-1490-4fcc-aee6-5fde7c6c48ca",
Name: "UI_GAMECHANGERS_GLOBAL_CONTRACTCONDITION_NO_RECORDINGS_PRIMARY_NAME",
Description:
"UI_GAMECHANGERS_GLOBAL_CONTRACTCONDITION_NO_RECORDINGS_PRIMARY_DESC",
LongDescription:
"UI_GAMECHANGERS_GLOBAL_CONTRACTCONDITION_NO_RECORDINGS_PRIMARY_DESC",
TileImage:
"images/contracts/gamechangers/gamechanger_global_bigbrother.jpg",
Icon: "images/challenges/default_challenge_icon.png",
ObjectivesCategory: "primary",
},
},
],
Secondary: [
{
Type: "gamechanger",
Properties: {
Id: "1f8f0b8b-1f65-4d6c-a2f4-fc8adffa394a",
Name: "UI_GAMECHANGERS_GLOBAL_CONTRACTCONDITION_NO_RECORDINGS_SECONDARY_NAME",
Description:
"UI_GAMECHANGERS_GLOBAL_CONTRACTCONDITION_NO_RECORDINGS_SECONDARY_DESC",
LongDescription:
"UI_GAMECHANGERS_GLOBAL_CONTRACTCONDITION_NO_RECORDINGS_SECONDARY_DESC",
TileImage:
"images/contracts/gamechangers/gamechanger_global_bigbrother.jpg",
Icon: "images/challenges/default_challenge_icon.png",
ObjectivesCategory: "secondary",
},
},
],
}
const headshotsOnly = {
IsCompleted: true,
ContractConditionType: "PrimarySecondary",
Primary: [
{
Type: "gamechanger",
Properties: {
Id: "3fea3aea-0233-46bb-8bc1-08757a2f6a74",
Name: "UI_GAMECHANGERS_GLOBAL_CONTRACTCONDITION_HEADSHOTS_ONLY_PRIMARY_NAME",
Description:
"UI_GAMECHANGERS_GLOBAL_CONTRACTCONDITION_HEADSHOTS_ONLY_PRIMARY_DESC",
LongDescription:
"UI_GAMECHANGERS_GLOBAL_CONTRACTCONDITION_HEADSHOTS_ONLY_PRIMARY_DESC",
TileImage:
"images/contractconditions/condition_contrac_headshots_only.jpg",
Icon: "images/challenges/default_challenge_icon.png",
ObjectivesCategory: "primary",
},
},
],
Secondary: [
{
Type: "gamechanger",
Properties: {
Id: "1efef5c0-7381-4e22-ac04-ffbd0822cc96",
Name: "UI_GAMECHANGERS_GLOBAL_CONTRACTCONDITION_HEADSHOTS_ONLY_SECONDARY_NAME",
Description:
"UI_GAMECHANGERS_GLOBAL_CONTRACTCONDITION_HEADSHOTS_ONLY_SECONDARY_DESC",
LongDescription:
"UI_GAMECHANGERS_GLOBAL_CONTRACTCONDITION_HEADSHOTS_ONLY_SECONDARY_DESC",
TileImage:
"images/contractconditions/condition_contrac_headshots_only.jpg",
Icon: "images/challenges/default_challenge_icon.png",
ObjectivesCategory: "secondary",
},
},
],
}
const noMissedShots = {
IsCompleted: true,
ContractConditionType: "PrimarySecondary",
Primary: [
{
Type: "gamechanger",
Properties: {
Id: "25760ea6-958b-4aab-97d4-b539c5b025c8",
Name: "UI_GAMECHANGERS_GLOBAL_CONTRACTCONDITION_NO_MISSED_SHOTS_PRIMARY_NAME",
Description:
"UI_GAMECHANGERS_GLOBAL_CONTRACTCONDITION_NO_MISSED_SHOTS_PRIMARY_DESC",
LongDescription:
"UI_GAMECHANGERS_GLOBAL_CONTRACTCONDITION_NO_MISSED_SHOTS_PRIMARY_DESC",
TileImage:
"images/contractconditions/condition_contrac_no_missed_shots.jpg",
Icon: "images/challenges/default_challenge_icon.png",
ObjectivesCategory: "primary",
},
},
],
Secondary: [
{
Type: "gamechanger",
Properties: {
Id: "f96e94b7-1c0e-49c9-9332-07346a955fd2",
Name: "UI_GAMECHANGERS_GLOBAL_CONTRACTCONDITION_NO_MISSED_SHOTS_SECONDARY_NAME",
Description:
"UI_GAMECHANGERS_GLOBAL_CONTRACTCONDITION_NO_MISSED_SHOTS_SECONDARY_DESC",
LongDescription:
"UI_GAMECHANGERS_GLOBAL_CONTRACTCONDITION_NO_MISSED_SHOTS_SECONDARY_DESC",
TileImage:
"images/contractconditions/condition_contrac_no_missed_shots.jpg",
Icon: "images/challenges/default_challenge_icon.png",
ObjectivesCategory: "secondary",
},
},
],
}
const noPacifications = {
IsCompleted: true,
ContractConditionType: "PrimarySecondary",
Primary: [
{
Type: "gamechanger",
Properties: {
Id: "ce154566-a4ba-43c5-be4e-79240ce0f3f9",
Name: "UI_GAMECHANGERS_GLOBAL_CONTRACTCONDITION_NO_PACIFICATIONS_PRIMARY_NAME",
Description:
"UI_GAMECHANGERS_GLOBAL_CONTRACTCONDITION_NO_PACIFICATIONS_PRIMARY_DESC",
LongDescription:
"UI_GAMECHANGERS_GLOBAL_CONTRACTCONDITION_NO_PACIFICATIONS_PRIMARY_DESC",
TileImage:
"images/contracts/gamechangers/Gamechanger_Global_NoPacifications.jpg",
Icon: "images/challenges/default_challenge_icon.png",
ObjectivesCategory: "primary",
},
},
],
Secondary: [
{
Type: "gamechanger",
Properties: {
Id: "95690829-7da4-4225-a087-08918cccf120",
Name: "UI_GAMECHANGERS_GLOBAL_CONTRACTCONDITION_NO_PACIFICATIONS_SECONDARY_NAME",
Description:
"UI_GAMECHANGERS_GLOBAL_CONTRACTCONDITION_NO_PACIFICATIONS_SECONDARY_DESC",
LongDescription:
"UI_GAMECHANGERS_GLOBAL_CONTRACTCONDITION_NO_PACIFICATIONS_SECONDARY_DESC",
TileImage:
"images/contracts/gamechangers/Gamechanger_Global_NoPacifications.jpg",
Icon: "images/challenges/default_challenge_icon.png",
ObjectivesCategory: "secondary",
},
},
],
}
export function complications(timeString: string) {
return [
{
IsCompleted: true,
ContractConditionType: "PrimarySecondary",
Primary: [
{
Type: "custom",
Properties: {
Id: "3d6f9119-7ec8-496f-ab4c-ed9757d976a4",
BriefingName: "$loc UI_CONTRACT_UGC_TIME_LIMIT_NAME",
BriefingText: {
$loc: {
key: "UI_CONTRACT_UGC_TIME_LIMIT_PRIMARY_DESC",
data: `$formatstring ${timeString}`,
},
},
LongBriefingText: {
$loc: {
key: "UI_CONTRACT_UGC_TIME_LIMIT_PRIMARY_DESC",
data: `$formatstring ${timeString}`,
},
},
Image: "images/contractconditions/condition_contrac_time_limit.jpg",
ObjectivesCategory: "primary",
},
},
],
Secondary: [
{
Type: "custom",
Properties: {
Id: "1a596216-381e-4592-9798-26f156973942",
BriefingName: "$loc UI_CONTRACT_UGC_TIME_LIMIT_NAME",
BriefingText: {
$loc: {
key: "UI_CONTRACT_UGC_TIME_LIMIT_SECONDARY_DESC",
data: `$formatstring ${timeString}`,
},
},
LongBriefingText: {
$loc: {
key: "UI_CONTRACT_UGC_TIME_LIMIT_SECONDARY_DESC",
data: `$formatstring ${timeString}`,
},
},
Image: "images/contractconditions/condition_contrac_time_limit.jpg",
ObjectivesCategory: "secondary",
},
},
],
},
{
IsCompleted: true,
ContractConditionType: "Single",
ObjectiveInfo: [
{
Type: "custom",
Properties: {
Id: "05080d1d-e3c4-4960-a087-661d141363eb",
BriefingName: "$loc UI_CONTRACT_UGC_REQUIRED_EXIT_NAME",
BriefingText: "$loc UI_CONTRACT_UGC_REQUIRED_EXIT_DESC",
LongBriefingText:
"$loc UI_CONTRACT_UGC_REQUIRED_EXIT_DESC",
Image: "images/contractconditions/condition_contrac_required_exit.jpg",
ObjectivesCategory: "primary",
},
},
],
},
noDisguiseChanges,
noPacifications,
noRecordings,
noMissedShots,
headshotsOnly,
targetsOnly,
]
}
/*
const PISTOL_ELIM = {
$any: {
"?": {
$or: [
{
$eq: ["$.#", "pistol"],
},
{
$eq: ["$.#", "close_combat_pistol_elimination"],
},
],
},
in: ["$Value.KillMethodBroad", "$Value.KillMethodStrict"],
},
}
export class StateMachineGenerationContext {
public createKillMethodStateMachine(): void {
this.weaponTargetStateMachine = {
_comment: `Eliminate ${this.targetId} using weapon`,
Type: "statemachine",
Id: this.weaponTargetStateMachineId,
Category: "secondary",
Definition: {
Scope: "Hit",
Context: {
Targets: [this.targetId],
},
States: {
Start: {
Kill: [
{
Condition: {
$and: [
{
$eq: [
"$Value.RepositoryId",
this.targetId,
],
},
{
$eq: [
"$Value.KillMethodStrict",
this.genContextParams
.KillMethodStrict,
],
},
],
},
Transition: "Success",
},
{
Condition: {
$eq: ["$Value.RepositoryId", this.targetId],
},
Transition: "Failure",
},
],
},
},
},
}
}
private kmConditions(killMethodBroad) {
switch (killMethodBroad) {
case "pistol":
return PISTOL_ELIM
default:
// weapon
return {
$eq: [
"$Value.KillMethodStrict",
this.genContextParams.KillMethodStrict,
],
}
}
}
}
*/

View File

@ -0,0 +1,62 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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/>.
*/
export const orderedETs = [
"8813e0a4-08ac-494f-a847-687a2da3582f",
"ff188c8b-e1eb-4c59-af75-6b6fe3da5955",
"0fd17346-bcb4-4bcc-acc3-5e1b6b184ef4",
"13680605-83ed-4b8c-a44d-30cc5b4fb17a",
"0d938ef9-05c7-4eb8-89cc-ae79b73c6992",
"e87217e3-4809-4855-80d5-74bed66be58d",
"8f13ea71-b207-4955-9eb8-ede757f3baa6",
"158b600a-6448-45d3-907f-77351b9656ee",
"2b928d67-c244-4601-bafb-7af664fb17bb",
"a9d93d2a-c541-49ab-8ba1-9e345cf7e806",
"ad549098-eb3d-4132-8ef8-fe77c6afbbaa",
"16d78245-5392-413c-b3db-989d6685c32a",
"b0bed170-8652-4188-8b9a-92caf9f97e5b",
"92a87b10-a230-4986-bb35-06f16e84b11f",
"c3c7126e-32cd-4502-b5ce-90b5ae436806",
"0dc242ce-084e-4f6d-980f-e65885cd6955",
"b0b8995c-7b3f-4fa6-91a2-be4bc8edc046",
"550c4d75-ca87-4be7-a18e-caf30e6c8136",
"5dc115d3-e5d4-4023-a11a-27c6f7194bea",
"87f8293a-29cd-4cb1-ade7-dd6bb056d38e",
"1c0377f3-6e32-4563-8baf-9677cdb3bb60",
"655c5a57-69d1-48b6-a14b-2ae396c16174",
"0fea5e55-9aec-41ef-9e5b-4e5e5f536f82",
"b555d6a4-8b4d-4e1e-b6bd-ebd135ad1e01",
"deace35f-ab6d-44c9-b1a6-98757e854f74",
"2e2c3f33-92ad-412f-a351-b7267697ff70",
"8462b2e5-4d34-4300-896f-fe1dc98fa877",
"3716b654-a42c-45df-9db9-61795a6a3e46",
"06a58b66-56f4-45c3-ba1b-d03998212289",
"01e38e22-b8d8-4266-af3b-f3330c41e6f2",
"263eca3d-d25d-40ce-ba0a-48a221cd0b9e",
"44fd7474-d7be-4d3d-b944-6c1cf6ca09d1",
"ecf353e8-3dd8-4958-b255-f963926aea51",
"332e588b-80a3-4cb0-abc6-dc8de3d89e83",
"cbc86bed-51ce-4699-89d4-0ded8f200cbc",
"92951377-419d-4c31-aa21-2a3f03ef82d0",
"fa002472-2120-44b6-bf48-41d14af97f51",
"38dba4d9-a361-46c9-bdae-7350945d6526",
"d8219c26-4122-4dde-bc42-382cdb374090",
"1fcaff1b-7fa3-4b9f-a586-9c7a1689b48d",
"b2c0251e-1803-4e12-b860-b9fa6ce5c004",
"6fad7901-279f-45df-ab8d-087a3cb06dcc",
]

View File

@ -0,0 +1,386 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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/>.
*/
/**
* An escalation group.
* Format: level number -> contract ID.
*/
export type EscalationGroup = Record<number, string>
/*
* /profiles/pages/Planning -> type = "escalation", New escalation only values
* (InGroup, EscalationCompletedLevels, EscalationTotalLevels, "IsNew" on Objectives, etc..)
* /authentication/api/userchannel/ContractsService/GetForPlay2 -> "NextContractId" pointing to the next escalation level,
* "GroupData" with data of the entire escalation
*/
/**
* A mapping of escalation group ID to a mapping of escalation level numbers to contract IDs.
*/
export const escalationMappings: {
[groupId: string]: EscalationGroup
} = {
/**
* Berlin Egg Hunt
*/
"9d88605f-6871-46a8-bd46-9804ea04fca9": {
1: "5452c904-e7e2-4cf4-939c-d3b41dd8dfb8",
2: "a9e69460-73f2-4928-806d-f79d9e6368bc",
3: "f5ebd915-3fc8-4cb7-95fd-f666f98e8b45",
},
/**
* The Dexter Discordance
*/
"e96fb040-a13f-466c-9d96-c8f3b2b8a09a": {
1: "d5e97d48-e58b-4d43-be35-ec29a51df452",
2: "3e94d080-c6e4-4a2d-9a7d-74322440c877",
3: "5db4c764-7ab7-40c1-8688-e2b98176fa35",
},
/**
* The KOats Conspiracy
*/
"07ffa72a-bbac-45ca-8c9f-b9c1b526153a": {
1: "a68b6d02-c769-4b22-a470-c7b88f3f3978",
2: "864b5daa-1322-40f4-9708-04b5eee35317",
3: "3b160b4f-1222-40a1-9a67-423c05b32340",
},
/**
* The Proloff Parable
*/
"078a50d1-6427-4fc3-9099-e46390e637a0": {
1: "645c9dd8-19e6-4cce-87ab-0e731fbaeab9",
2: "20156bab-35f4-4a61-96f8-271041e38bf6",
3: "40651beb-edaa-41d0-aa9d-6bd4a14a8f81",
},
/**
* The Gauchito Antiquity
*/
"72aaaa7b-4386-4ee7-9e9e-73fb8ff8e416": {
1: "e14bbb5d-bd8a-4b6b-9749-4f147db0ebe0",
2: "95e7f0d5-f066-4f8c-bfc6-6505c13055ed",
3: "3849e8d5-3876-48ef-b4e1-9b3a4489589a",
},
/**
* The Corky Commotion
*/
"4f6ee6ec-b6d7-4958-9838-0352c10294a0": {
1: "f89027eb-8ed9-49e3-8bb4-a6306f72e3d9",
2: "2eb41963-a140-4ecb-9a05-327d4fd65408",
3: "5cb1a153-3b56-417a-8e75-10066bf397b6",
},
/**
* The Cheveyo Calibration
*/
"b49de2a1-fe8e-49c4-8331-17aaa9d65d32": {
1: "d201ebf6-adc7-4d6f-87a4-f3d37a116a1b",
2: "ba68c0d7-d77d-44b4-9401-72b2ff2d73cb",
3: "e2dd58f3-f5ff-41b3-9ba9-4d0420fc773b",
},
/**
* The Dalton Dissection
*/
"55063d85-e84a-4c76-8bf7-e70fe2cab651": {
1: "44ac6a37-0ef8-42ea-bf39-1d5f9afd235d",
2: "9a25fade-424f-481a-86f0-8c827d43b62e",
3: "5adfcf3a-0696-4593-b755-c2c8d44f59a6",
},
/**
* The CurryMaker Chaos
*/
"115425b1-e797-47bf-b517-410dc7507397": {
1: "cac9f9c2-1a31-4ed8-b2f3-0da9bf5e515e",
2: "3ef4e3b0-36f3-4c9e-a7bb-e98ae067b41a",
3: "af09780c-eee9-4478-932c-e21c7bbe10b5",
},
/**
* The Khakiasp Documentation
*/
"667f48a3-7f6b-486e-8f6b-2f782a5c4857": {
1: "0677d534-b3eb-46f9-af67-23ff27b8475f",
2: "b4934ad7-ef15-44cd-9ba1-7752755788b4",
3: "7153609a-d24a-4f44-905d-d33d0b0b9a73",
},
/**
* The Yannini Yearning
*/
"1e4423b7-d4ff-448f-a8a8-4bb600cab7e3": {
1: "c6490c57-a033-4c6d-beed-cf4c8c7be552",
2: "176e578f-3572-4fd0-9314-71b021ba1bad",
3: "34674ed1-e76e-45cc-b575-e6f3f520bf7b",
},
/**
* The Baskerville Barney
*/
"b12d08ea-c842-498a-82ea-889653588592": {
1: "087d118b-57c7-4f52-929c-0e567ede6f5d",
2: "19bf57ec-47c1-46b7-9e7f-ecc8309ae0c2",
3: "77bcec76-323d-4e1e-bd0e-bf6d777c3745",
},
/**
* The Jeffrey Consultation
*/
"0cceeecb-c8fe-42a4-aee4-d7b575f56a1b": {
1: "408d03c6-46db-45f4-ab05-9f380eae4670",
2: "4c41ac07-ad1d-47ff-a2db-3df85108b9b0",
3: "d71b56ad-4134-4fb6-8e46-a7377a0e2a54",
},
/**
* The mendietinha Madness
*/
"ccdc7043-62af-44e8-a5fc-38b008c2044e": {
1: "b7401d91-7705-40c9-84a3-bf8f236444de",
2: "6ee9d8b0-d0db-426d-bbf6-64a2983b274c",
3: "ffb1da03-fcbf-4d7f-8371-de685498516e",
},
/**
* The Turms Infatuation
*/
"0042ab2c-8aa3-48e5-a75f-4558c691adff": {
1: "cbdd649b-bada-441c-9b0d-1e2d7849b055",
2: "0d84d2e9-9b2b-4801-bee6-80adf3afe5e6",
3: "b0719294-b3ca-11eb-8529-0242ac130003",
},
/**
* The Delgado Larceny
*/
"11e632a1-e246-4641-927b-6fd7daf83016": {
1: "4e846e60-c98b-4581-9487-083c0353b5a7",
2: "af558186-5ca1-41b9-ab87-b854345b77b5",
3: "4f726edc-a0dd-11eb-bcbc-0242ac130002",
},
/**
* The Calvino Cacophony
*/
"d7cac2f8-e870-4e68-92ba-19b6a88d1053": {
1: "d265e641-dfaa-4b91-8f5f-227a8bed947a",
2: "ede95e7f-36b1-4c1b-a4c5-fba9edee296d",
3: "031097b4-b17f-11eb-8529-0242ac130003",
},
/**
* The Merle Revelation
*/
"3bdf8b88-c795-4f30-aa69-c04c3d05d8ce": {
1: "68ac028f-e83f-4496-95a2-eb3c5b8825c9",
2: "50a56b1f-668f-402d-a6b7-0f759b33ca56",
3: "b0718c68-b3ca-11eb-8529-0242ac130003",
},
/**
* The MacMillan Surreptition
*/
"e88c9be7-a802-40b4-b2ae-487b3d047e2c": {
1: "9f18dff5-6412-4240-91e4-4170d816c0fe",
2: "c976b9ea-1921-4ce9-8651-dce488ffeb36",
3: "0310987c-b17f-11eb-8529-0242ac130003",
},
/**
* The Montague Audacity
*/
"256845d8-d8dd-4073-a69a-e5c0ddb3ff61": {
1: "309eba43-f514-4ed1-ab9e-9f76547f4b6f",
2: "69b8a95b-03ff-4d4c-89b1-eb0ca4dbe6c0",
3: "b0718f1a-b3ca-11eb-8529-0242ac130003",
},
/**
* The Truman Contravention
*/
"35b6a403-54f4-4faa-9b19-448d6840d837": {
1: "42c11cac-309c-47ae-a293-ee8bde6918ab",
2: "31516bfe-694d-418f-89eb-c9b4740af5dd",
3: "b071900a-b3ca-11eb-8529-0242ac130003",
},
/**
* The Ataro Caliginosity
*/
"c2e16fb7-d49f-49ef-9d76-46b8b31b3389": {
1: "044cb8a3-bb83-4484-811a-7644ae1f7b8b",
2: "c3322acb-bb6c-4f3f-a48d-a654aea83ec7",
3: "a2e4d7e7-f9e3-4e37-ae56-6739a6f17a4f",
4: "f4bec62f-0fd6-4071-9bc7-003a5260118b",
5: "8eed3a7f-b903-412d-85b6-e4262e7246d7",
},
/**
* The McVeigh Ascension
*/
"9e0188e8-bdad-476c-b4ce-2faa5d2be56c": {
1: "b5f3a898-fb25-4988-b530-a32ec5b6bad5",
2: "3c882fd9-63ee-4981-abf3-006a1335c04d",
3: "4ccf2b51-4a99-4a6d-a37c-31ef5d27e703",
},
/**
* The Mills Reverie
*/
"3efc73f9-33f0-4af6-9508-7208e6851394": {
1: "8b3241a8-3a71-43c2-a9b2-2282271ad01e",
2: "3dd4effa-c919-471d-a3ee-becf7504ce82",
3: "97c4148b-ecea-4735-87cd-563e9a4ad343",
},
/**
* The Dubious Cohabitation
*/
"e302a045-0250-4824-9416-675cf936e035": {
1: "987c40f7-bf23-4f8d-84d6-169101edf953",
2: "aa3afd89-e080-4bee-83fe-87e26fbd7e3a",
3: "b071941a-b3ca-11eb-8529-0242ac130003",
},
/**
* The Dartmoor Garden Show
*/
"5680108a-19dc-4448-9344-3d0290217162": {
1: "bdd43a59-b74f-4159-8e7d-7209e5a13f84",
2: "cef8d7c3-35e5-44b4-8c41-3b0f074bf8cd",
3: "f7ad71b6-9553-4d58-86dc-e3e288849849",
},
/**
* The Barbegue Befuddlement
*/
"448d89e8-2026-43e3-86f0-205018cbd87e": {
1: "b82fd894-c12c-44e9-99fd-07b860b76c72",
2: "2d1bada4-aa46-4954-8cf5-684989f1668a",
3: "519c097f-2e1f-48f2-8f9d-3c76223cc950",
},
/**
* The Pirates Problem
*/
"f19f7ac8-39ec-498b-aa23-44c8e75d8693": {
1: "88725ca6-cf32-41e5-bd18-1c2c9aafd8aa",
2: "3f5c032b-1429-455e-acfd-5ceab5a4e26d",
3: "bdd4bdee-6720-44c2-908d-769f58c0cf12",
},
/**
* The Susumu Obsession
*/
"85a2b618-2e3c-444f-931c-b89d566e45f7": {
1: "ae4db4c3-32bb-4717-8df3-83d8f77a6d0f",
2: "7f5d1e2a-9c89-48c2-a370-85d851c3cc21",
3: "6b1fcdc7-e2c9-48c4-b1fb-0a8dd817f3b2",
4: "d80abc24-f7d5-4e6b-a6c2-fd318135d160",
5: "b007a400-66b8-43c3-a919-3195e343f7b1",
},
/**
* The Marinello Motivation
*/
"d0a0fa03-08a7-43ef-b5e8-d8662d015372": {
1: "f33d4dee-8d07-45e0-9816-55646dcb341f",
2: "aa4fb2e6-3494-4b88-a882-43ce135f8b1b",
3: "9f17c5ee-b402-11eb-8529-0242ac130003",
},
/**
* The Sinbad Stringent
*/
"be14d4f1-f1aa-4dea-8c9b-a5b1a1dea931": {
1: "b1f59afe-1b57-470d-80a1-982cb37e0c05",
2: "4396c59b-9fa2-46ab-8cc8-0bd782225054",
3: "e928e04a-922f-462a-9b44-0f8e42a05102",
},
/**
* The Agana Abyss
*/
"74739eda-6ed5-4318-a501-2fa0bd53ef5a": {
1: "9943bcc6-8897-42b9-93eb-12ff5be8b7ac",
2: "5548a549-3c55-4014-8cc7-47145f7f75d6",
3: "bab3704b-0bbb-4d0d-b5bf-ebf715f419cd",
4: "0434d0ac-5e74-4d1f-8aef-8abbadabd1aa",
5: "4ed3bdfe-ecfc-41ab-ba6d-25d053838e15",
},
/**
* The dez Dichotomy
*/
"78628e05-93ce-4f87-8a17-b910d32df51f": {
1: "4804d74a-96d9-40af-8a37-a1f377781fc1",
2: "1a9978ae-0cfa-44ff-bd16-ca3ffab226fe",
3: "0fc24d6e-5870-44d3-897a-15f19c4ccef2",
},
/**
* The Caden Composition
*/
"ccbde3e2-67e7-4534-95ec-e9bd7ef65273": {
1: "3c93370d-e6d2-48b3-b37a-8fa27f63027c",
2: "4e1cac0d-0f16-4c58-ad8c-f5dc003fe368",
3: "ed54d12a-51e3-470d-b712-cb2a364c95d0",
4: "79511294-9054-409d-8062-c24d66fb1ff0",
5: "2e83eda3-230f-429c-965a-c89e7ada97e3",
},
/**
* The PapaLevy Plunderage
*/
"9a461f89-86c5-44e4-998e-f2f66b496aa7": {
1: "5e380d27-930d-4bc7-9ad9-411486a7147c",
2: "d93d8114-3284-4306-80c5-117fa03de533",
3: "6968e2a0-b7cf-4cb8-8da5-4871d8c564a5",
},
/**
* The Aquatic Retribution
*/
"69b8eb0c-77d5-42e8-b604-26aba8bd835f": {
1: "e155844a-032c-4b71-91a4-b1206e0f6a8c",
2: "3063ccc6-9fa5-439f-9a9f-72a1b81c369e",
3: "4a0a66d4-0a53-4cfd-8122-978226b4e072",
},
/**
* The Dammchicu Disaster
*/
"218302a3-f682-46f9-9ffd-bb3e82487b7c": {
1: "9d0b3322-4dd4-4388-b79f-4aae7dba297c",
2: "b07af7b6-01cb-4cee-83bf-2c73f71bf2a3",
3: "45b1b927-5bf0-4dae-bc73-8ee1730652cc",
},
/**
* The Holmwood Disturbance
*/
"d6961637-effe-4c39-b99a-f2df4402657d": {
1: "30f4a862-35f8-4f34-ba0d-552ac87ccbbe",
2: "1e307dd6-74cb-4e4a-9829-50106a95c3ef",
3: "6cc11563-1101-4c63-9d24-483fca17915b",
4: "dbd36aab-ed98-47e2-823a-867ba6e070d1",
5: "4aba4161-608c-4f2c-b1b7-e6669a5eac44",
},
/**
* The PurpleKey Peril
*/
"74415eca-d01e-4070-9bc9-5ef9b4e8f7d2": {
1: "f556bfdd-3be1-4f1b-9a41-5c6747766262",
2: "97ab1c9a-3236-41f0-b22e-b46728ffc9fd",
3: "490cbf23-92ca-4cdb-b301-9a576442ad2b",
},
/**
* The Hamartia Compulsion
*/
"4b6739eb-bcdb-48ad-8c45-a829794175e1": {
1: "56684a64-70c1-4845-a2c4-b49ddd78a45e",
2: "7d911fca-b4bb-4b31-8564-5f5fd7b82a9b",
3: "daf0ab18-dc1b-481b-9731-3aec536f231f",
4: "e11b70db-f436-4eee-ac69-8d76eb1d3e9d",
5: "d48ed63e-2542-4215-8e6c-6ad3c29feb42",
},
/**
* The Argentine Acrimony
*/
"edbacf4b-e402-4548-b723-cd4351571537": {
1: "4bab4282-ad93-45e3-ace7-c9daf78dec94",
2: "1e96f1f2-eaf7-4947-a60d-d2190f503b0b",
3: "a7ddf3f3-7fd9-4749-b63b-f2579bbd0f6c",
},
/**
* The sleazeball Situation
*/
"35f1f534-ae2d-42be-8472-dd55e96625ea": {
1: "3edb330f-5129-49c6-9afd-70111ce72ae5",
2: "c59baa15-5946-4354-875e-1c98ef7f1bfe",
3: "83655c86-012f-4d2b-a57d-5b021af99af1",
},
}

View File

@ -0,0 +1,193 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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 { contractIdToHitObject, controller } from "../../controller"
import type { GameVersion, IHit, UserProfile } from "../../types/types"
import { getUserData } from "../../databaseHandler"
import { log, LogLevel } from "../../loggingInterop"
import type { EscalationGroup } from "../escalationMappings"
/**
* Put a level in here to hide it from the menus on 2016.
* This should only be used if:
* - The content is custom.
* - The content is on a 2016 map.
*/
const no2016 = [
"0cceeecb-c8fe-42a4-aee4-d7b575f56a1b",
"9e0188e8-bdad-476c-b4ce-2faa5d2be56c",
"115425b1-e797-47bf-b517-410dc7507397",
"74415eca-d01e-4070-9bc9-5ef9b4e8f7d2",
]
/**
* Gets a user's progress on the specified escalation's group ID.
*
* @param userData The user's profile object.
* @param eGroupId The escalation's group ID.
* @returns The level of the escalation the user is on.
*/
export function getUserEscalationProgress(
userData: UserProfile,
eGroupId: string,
): number {
userData.Extensions.PeacockEscalations ??= { [eGroupId]: 1 }
if (!userData.Extensions.PeacockEscalations[eGroupId]) {
userData.Extensions.PeacockEscalations[eGroupId] = 1
return 1
}
return userData.Extensions.PeacockEscalations[eGroupId]
}
/**
* Resets a user's progress on the specified escalation.
*
* @param userData The user's profile object.
* @param eGroupId The escalation's group ID.
*/
export function resetUserEscalationProgress(
userData: UserProfile,
eGroupId: string,
): void {
userData.Extensions.PeacockEscalations ??= {}
userData.Extensions.PeacockEscalations[eGroupId] = 1
if (userData.Extensions.PeacockCompletedEscalations?.includes(eGroupId)) {
userData.Extensions.PeacockCompletedEscalations =
userData.Extensions.PeacockCompletedEscalations!.filter(
(e) => e !== eGroupId,
)
}
}
/**
* Translates a contract ID to the escalation group that it is in's ID.
*
* @param id The contract ID.
* @returns The escalation's group ID or null if it isn't in a group.
*/
export function contractIdToEscalationGroupId(id: string): string | undefined {
let name: string | undefined = undefined
for (const groupId of Object.keys(controller.escalationMappings)) {
for (const level of Object.keys(
controller.escalationMappings[groupId],
)) {
if (controller.escalationMappings[groupId][level].includes(id)) {
name = groupId
}
}
}
return name
}
export function getMenuDetailsForEscalation(
eGroupId: string,
userId: string,
gameVersion: GameVersion,
): IHit | undefined {
const userData = getUserData(userId, gameVersion)
const level = getUserEscalationProgress(userData, eGroupId)
const escalationGroup = controller.escalationMappings[eGroupId]
if (gameVersion === "h1" && no2016.includes(eGroupId)) {
return undefined
}
// not great for performance, but it's fine for now
if (!controller.resolveContract(escalationGroup[level])) {
return undefined
}
return contractIdToHitObject(escalationGroup[level], gameVersion, userId)
}
/**
* Get the number of levels in the specified group.
*
* @param group The escalation group.
* @returns The number of levels.
*/
export function getLevelCount(group: EscalationGroup): number {
let levels = 1
while (Object.prototype.hasOwnProperty.call(group, levels + 1)) {
levels++
}
return levels
}
/**
* Get the "play" escalation info.
*
* @param b Internal control for code cleanliness. Setting to false returns an empty object.
* @param userId The current user's ID.
* @param eGroupId The escalation's group ID.
* @param gameVersion The game's version.
* @returns The escalation play details.
*/
export function getPlayEscalationInfo(
b: boolean,
userId: string,
eGroupId: string,
gameVersion: GameVersion,
) {
if (!b) {
return {}
}
const userData = getUserData(userId, gameVersion)
const p = getUserEscalationProgress(userData, eGroupId)
const group = controller.escalationMappings[eGroupId]
const totalLevelCount = getLevelCount(
controller.escalationMappings[eGroupId],
)
let nextContractId = "00000000-0000-0000-0000-000000000000"
if (p < totalLevelCount) {
nextContractId = group[p + 1]
}
log(
LogLevel.DEBUG,
`Get Play-EscalationInfo - group: ${eGroupId} prog: ${p} next: ${nextContractId}`,
)
return {
Type: "escalation",
InGroup: eGroupId,
NextContractId: nextContractId,
GroupData: {
Level: p,
TotalLevels: totalLevelCount,
Completed:
userData.Extensions.PeacockCompletedEscalations?.includes(
eGroupId,
) || false,
FirstContractId: group[1],
},
}
}

View File

@ -0,0 +1,212 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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 { HookMap, SyncHook } from "../hooksImpl"
import { GameVersion, HitsCategoryCategory } from "../types/types"
import {
contractIdToHitObject,
controller,
featuredContractGroups,
} from "../controller"
import { getUserData } from "../databaseHandler"
import { orderedETs } from "./elusiveTargets"
function paginate<Element>(
elements: Element[],
displayPerPage: number,
): Element[][] {
const totalElementCount: number = elements.length
const pageCount = Math.ceil(totalElementCount / displayPerPage)
const pages: Element[][] = []
let perPageArray: Element[] = []
let index = 0
let condition = 0
let pendingDispatchCount = 0
for (let i = 0; i < pageCount; i++) {
if (i === 0) {
index = 0
condition = displayPerPage
}
for (let j = index; j < condition; j++) {
perPageArray.push(elements[j])
}
pages.push(perPageArray)
if (i === 0) {
pendingDispatchCount = totalElementCount - perPageArray.length
} else {
pendingDispatchCount = pendingDispatchCount - perPageArray.length
}
if (pendingDispatchCount > 0) {
if (pendingDispatchCount > displayPerPage) {
index = index + displayPerPage
condition = condition + displayPerPage
} else {
index = index + perPageArray.length
condition = condition + pendingDispatchCount
}
}
perPageArray = []
}
return pages
}
export class HitsCategoryService {
/**
* A hook map for all the hits categories.
*/
public hitsCategories: HookMap<
SyncHook<
[
/** gameVersion */ GameVersion,
/** contractIds */ string[],
/** hitsCategory */ HitsCategoryCategory,
/** userId */ string,
]
>
>
/**
* Hits categories that should not be automatically paginated.
*/
public paginationExempt = ["Elusive_Target_Hits", "Arcade", "Sniper"]
/**
* The number of hits per page.
*/
public hitsPerPage = 22
constructor() {
this.hitsCategories = new HookMap(() => new SyncHook())
this._useDefaultHitsCategories()
}
/**
* Enable the default hits categories.
*/
_useDefaultHitsCategories(): void {
const tapName = "HitsCategoryServiceImpl"
this.hitsCategories
.for("Sniper")
.tap(tapName, (gameVersion, contracts) => {
contracts.push("ff9f46cf-00bd-4c12-b887-eac491c3a96d")
contracts.push("00e57709-e049-44c9-a2c3-7655e19884fb")
contracts.push("25b20d86-bb5a-4ebd-b6bb-81ed2779c180")
})
this.hitsCategories
.for("Elusive_Target_Hits")
.tap(tapName, (gameVersion, contracts) => {
contracts.push(...orderedETs)
})
this.hitsCategories
.for("MyContracts")
.tap(tapName, (gameVersion, contracts, hitsCategory) => {
hitsCategory.CurrentSubType = "MyContracts"
for (const contract of controller.contracts.values()) {
contracts.push(contract.Metadata.Id)
}
})
this.hitsCategories
.for("Featured")
.tap(tapName, (gameVersion, contracts) => {
for (const fcGroup of featuredContractGroups) {
contracts.push(...fcGroup)
}
})
this.hitsCategories
.for("MyPlaylist")
.tap(tapName, (gameVersion, contracts, hitsCategory, userId) => {
const userProfile = getUserData(userId, gameVersion)
const favs =
userProfile?.Extensions.PeacockFavoriteContracts ?? []
contracts.push(...favs)
hitsCategory.CurrentSubType = "MyPlaylist_all"
})
// intentionally don't handle Trending
// intentionally don't handle MostPlayedLastWeek
// intentionally don't handle Arcade
}
/**
* Generate a {@link HitsCategoryCategory} object for the current page.
*
* @param categoryName The hits category's ID (the key for the hooks map).
* @param pageNumber The current page's number.
* @param gameVersion The game version being used for the request.
* @param userId The current user's ID.
* @returns The {@link HitsCategoryCategory} object.
*/
public paginateHitsCategory(
categoryName: string,
pageNumber: number,
gameVersion: GameVersion,
userId: string,
): HitsCategoryCategory {
const hitsCategory: HitsCategoryCategory = {
Category: categoryName,
Data: {
Type: categoryName,
Hits: [],
Page: pageNumber,
HasMore: false,
},
CurrentSubType: categoryName,
}
const hook = this.hitsCategories.for(categoryName)
const hits: string[] = []
hook.call(gameVersion, hits, hitsCategory, userId)
const hitObjectList = hits
.map((id) => contractIdToHitObject(id, gameVersion, userId))
.filter(Boolean)
if (!this.paginationExempt.includes(categoryName)) {
const paginated = paginate(hitObjectList, this.hitsPerPage)
// ts-expect-error Type things.
hitsCategory.Data.Hits = paginated[pageNumber]
hitsCategory.Data.HasMore = paginated.length > pageNumber + 1
} else {
// ts-expect-error Type things.
hitsCategory.Data.Hits = hitObjectList
}
return hitsCategory
}
}
export const hitsCategoryService = new HitsCategoryService()

View File

@ -0,0 +1,162 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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/>.
*/
/**
* A mapping of location ID to an array of missions IDs.
*/
export const missionsInLocations = {
LOCATION_ICA_FACILITY_ARRIVAL: ["1436cbe4-164b-450f-ad2c-77dec88f53dd"],
LOCATION_ICA_FACILITY_SHIP: [
"1d241b00-f585-4e3d-bc61-3095af1b96e2",
"b573932d-7a34-44f1-bcf4-ea8f79f75710",
],
LOCATION_ICA_FACILITY: ["ada5f2b1-8529-48bb-a596-717f75f5eacb"],
LOCATION_PARIS: [
"00000000-0000-0000-0000-000000000200",
"4e45e91a-94ca-4d89-89fc-1b250e608e73",
],
LOCATION_COASTALTOWN: ["00000000-0000-0000-0000-000000000600"],
LOCATION_COASTALTOWN_NIGHT: ["00000000-0000-0000-0001-000000000005"],
LOCATION_COASTALTOWN_EBOLA: ["7e3f758a-2435-42de-93bd-d8f0b72c63a4"],
LOCATION_COASTALTOWN_MOVIESET: ["00000000-0000-0000-0001-000000000006"],
LOCATION_MARRAKECH: ["00000000-0000-0000-0000-000000000400"],
LOCATION_MARRAKECH_NIGHT: ["ced93d8f-9535-425a-beb9-ef219e781e81"],
LOCATION_BANGKOK: ["db341d9f-58a4-411d-be57-0bc4ed85646b"],
LOCATION_BANGKOK_ZIKA: [
"024b6964-a3bb-4457-b085-08f9a7dc7fb7",
"90c291f6-7ac3-46de-99b2-082e38fccb24",
],
LOCATION_COLORADO: ["42bac555-bbb9-429d-a8ce-f1ffdf94211c"],
LOCATION_COLORADO_RABIES: ["ada6205e-6ee8-4189-9cdb-4947cccd84f4"],
LOCATION_HOKKAIDO: [
"0e81a82e-b409-41e9-9e3b-5f82e57f7a12",
"c414a084-a7b9-43ce-b6ca-590620acd87e",
],
LOCATION_HOKKAIDO_FLU: ["a2befcec-7799-4987-9215-6a152cb6a320"],
LOCATION_NEWZEALAND: ["c65019e5-43a8-4a33-8a2a-84c750a5eeb3"],
LOCATION_MIAMI: ["c1d015b4-be08-4e44-808e-ada0f387656f"],
LOCATION_MIAMI_COTTONMOUTH: ["f1ba328f-e3dd-4ef8-bb26-0363499fdd95"],
LOCATION_COLOMBIA: ["422519be-ed2e-44df-9dac-18f739d44fd9"],
LOCATION_COLOMBIA_ANACONDA: ["179563a4-727a-4072-b354-c9fff4e8bff0"],
LOCATION_MUMBAI: ["0fad48d7-3d0f-4c66-8605-6cbe9c3a46d7"],
LOCATION_MUMBAI_KINGCOBRA: ["a8036782-de0a-4353-b522-0ab7a384bade"],
LOCATION_NORTHAMERICA: ["82f55837-e26c-41bf-bc6e-fa97b7981fbc"],
LOCATION_NORTHAMERICA_GARTERSNAKE: ["0b616e62-af0c-495b-82e3-b778e82b5912"],
LOCATION_NORTHSEA: ["0d225edf-40cd-4f20-a30f-b62a373801d3"],
LOCATION_GREEDY_RACCOON: ["7a03a97d-238c-48bd-bda0-e5f279569cce"],
LOCATION_OPULENT_STINGRAY: ["095261b5-e15b-4ca1-9bb7-001fb85c5aaa"],
LOCATION_GOLDEN_GECKO: ["7d85f2b0-80ca-49be-a2b7-d56f67faf252"],
LOCATION_ANCESTRAL_BULLDOG: ["755984a8-fb0b-4673-8637-95cfe7d34e0f"],
LOCATION_EDGY_FOX: ["ebcd14b2-0786-4ceb-a2a4-e771f60d0125"],
LOCATION_WET_RAT: [
"3d0cbb8c-2a80-442a-896b-fea00e98768c",
"99bd3287-1d83-4429-a769-45045dfcbf31",
],
LOCATION_ELEGANT_LLAMA: ["d42f850f-ca55-4fc9-9766-8c6a2b5c3129"],
LOCATION_TRAPPED_WOLVERINE: ["a3e19d55-64a6-4282-bb3c-d18c3f3e6e29"],
LOCATION_ROCKY_DUGONG: ["b2aac100-dfc7-4f85-b9cd-528114436f6c"],
escalations: {
LOCATION_PARIS: [
"4f6ee6ec-b6d7-4958-9838-0352c10294a0",
"d6961637-effe-4c39-b99a-f2df4402657d",
],
LOCATION_COASTALTOWN: [
"9e0188e8-bdad-476c-b4ce-2faa5d2be56c",
"74415eca-d01e-4070-9bc9-5ef9b4e8f7d2",
"4b6739eb-bcdb-48ad-8c45-a829794175e1",
],
LOCATION_COASTALTOWN_MOVIESET: ["74739eda-6ed5-4318-a501-2fa0bd53ef5a"],
LOCATION_COASTALTOWN_EBOLA: ["0cceeecb-c8fe-42a4-aee4-d7b575f56a1b"],
LOCATION_MARRAKECH_NIGHT: [
"b49de2a1-fe8e-49c4-8331-17aaa9d65d32",
"c2e16fb7-d49f-49ef-9d76-46b8b31b3389",
],
LOCATION_BANGKOK: ["ccbde3e2-67e7-4534-95ec-e9bd7ef65273"],
LOCATION_HOKKAIDO: [
"e96fb040-a13f-466c-9d96-c8f3b2b8a09a",
"115425b1-e797-47bf-b517-410dc7507397",
"85a2b618-2e3c-444f-931c-b89d566e45f7",
],
LOCATION_NEWZEALAND: ["3efc73f9-33f0-4af6-9508-7208e6851394"],
LOCATION_MIAMI: [
"448d89e8-2026-43e3-86f0-205018cbd87e",
"69b8eb0c-77d5-42e8-b604-26aba8bd835f",
],
LOCATION_COLOMBIA: [
"0042ab2c-8aa3-48e5-a75f-4558c691adff",
"11e632a1-e246-4641-927b-6fd7daf83016",
"d7cac2f8-e870-4e68-92ba-19b6a88d1053",
"3bdf8b88-c795-4f30-aa69-c04c3d05d8ce",
"e88c9be7-a802-40b4-b2ae-487b3d047e2c",
"256845d8-d8dd-4073-a69a-e5c0ddb3ff61",
"35b6a403-54f4-4faa-9b19-448d6840d837",
],
LOCATION_MUMBAI: [
"667f48a3-7f6b-486e-8f6b-2f782a5c4857",
"e302a045-0250-4824-9416-675cf936e035",
],
LOCATION_NORTHAMERICA: ["218302a3-f682-46f9-9ffd-bb3e82487b7c"],
LOCATION_NORTHSEA: ["d0a0fa03-08a7-43ef-b5e8-d8662d015372"],
LOCATION_GREEDY_RACCOON: [
"55063d85-e84a-4c76-8bf7-e70fe2cab651",
"9a461f89-86c5-44e4-998e-f2f66b496aa7",
],
LOCATION_OPULENT_STINGRAY: [
"f19f7ac8-39ec-498b-aa23-44c8e75d8693",
"35f1f534-ae2d-42be-8472-dd55e96625ea",
],
LOCATION_GOLDEN_GECKO: ["be14d4f1-f1aa-4dea-8c9b-a5b1a1dea931"],
LOCATION_ANCESTRAL_BULLDOG: [
"b12d08ea-c842-498a-82ea-889653588592",
"78628e05-93ce-4f87-8a17-b910d32df51f",
],
LOCATION_ANCESTRAL_SMOOTHSNAKE: [
"5680108a-19dc-4448-9344-3d0290217162",
],
LOCATION_EDGY_FOX: [
"9d88605f-6871-46a8-bd46-9804ea04fca9",
"ccdc7043-62af-44e8-a5fc-38b008c2044e",
],
LOCATION_WET_RAT: ["07ffa72a-bbac-45ca-8c9f-b9c1b526153a"],
LOCATION_ELEGANT_LLAMA: [
"72aaaa7b-4386-4ee7-9e9e-73fb8ff8e416",
"1e4423b7-d4ff-448f-a8a8-4bb600cab7e3",
"edbacf4b-e402-4548-b723-cd4351571537",
],
LOCATION_TRAPPED_WOLVERINE: ["078a50d1-6427-4fc3-9099-e46390e637a0"],
},
sniper: {
LOCATION_AUSTRIA: ["ff9f46cf-00bd-4c12-b887-eac491c3a96d"],
LOCATION_SALTY_SEAGULL: ["00e57709-e049-44c9-a2c3-7655e19884fb"],
LOCATION_CAGED_FALCON: ["25b20d86-bb5a-4ebd-b6bb-81ed2779c180"],
},
elusive: {},
sarajevo: {},
/**
* Special property for pro mode missions (2016 exclusive).
* Mapping of location parent to pro mode contract ID, instead of the typical mission array.
*/
pro1: {
LOCATION_PARENT_PARIS: "5ee4d771-6ab3-41fa-ab4f-04970d0ca327",
LOCATION_PARENT_COASTALTOWN: "644d36bd-1f88-44f9-9fed-14a51e5e3f6b",
LOCATION_PARENT_MARRAKECH: "7b2d5500-7853-4ad0-b68a-14be791cfba2",
LOCATION_PARENT_BANGKOK: "ad5f9051-045d-4b8e-8a4d-d84429f467f8",
LOCATION_PARENT_COLORADO: "69b58abc-6535-4092-9afe-c046b26303e6",
LOCATION_PARENT_HOKKAIDO: "3d885714-fa9a-4438-9e0f-c58dbcaab8b8",
},
}

1093
components/controller.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,248 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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 { readFile, writeFile } from "atomically"
import { join } from "path"
import type { ContractSession, GameVersion, UserProfile } from "./types/types"
import { serializeSession, deserializeSession } from "./sessionSerialization"
import { castUserProfile } from "./utils"
import { getConfig } from "./configSwizzleManager"
import { log, LogLevel } from "./loggingInterop"
/**
* Container for functions that handle file read/writes,
* which could otherwise break if writing partial data.
*/
class AsyncUserDataGuard {
private readonly userData: Record<string, UserProfile> = {}
private readonly dirtyProfiles: Set<string> = new Set()
getProfile(id: string): UserProfile {
return this.userData[id]
}
addLoadedProfile(id: string, profile: UserProfile): void {
if (!this.userData[id]) {
setInterval(() => {
if (!this.dirtyProfiles.has(id)) {
return
}
this.dirtyProfiles.delete(id)
this.write(id)
.then(() => undefined)
.catch((e) => {
log(
LogLevel.ERROR,
`Failed to write user profile ${id}: ${e}`,
)
})
}, 3000).unref()
}
this.userData[id] = profile
// just in case
this.dirtyProfiles.delete(id)
}
markDirty(id: string): void {
this.dirtyProfiles.add(id)
}
private async write(versionedId: string): Promise<void> {
let path
const [id, gameVersion] = versionedId.split(".")
if (["scpc", "h1", "h2"].includes(gameVersion)) {
path = join("userdata", gameVersion, "users", `${id}.json`)
} else {
path = join("userdata", "users", `${id}.json`)
}
await writeFile(path, JSON.stringify(this.getProfile(versionedId)))
}
}
const asyncGuard = new AsyncUserDataGuard()
/**
* Gets a user's profile data.
*
* @param userId The user's ID.
* @param gameVersion The game's version.
* @returns The user's profile
*/
export function getUserData(
userId: string,
gameVersion: GameVersion,
): UserProfile {
const data = asyncGuard.getProfile(`${userId}.${gameVersion}`)
// we may not have this profile
if (data?.Extensions?.gamepersistentdata?.PersistentBool) {
data.Extensions.gamepersistentdata.PersistentBool = {
...data.Extensions.gamepersistentdata.PersistentBool,
...getConfig("PersistentBools", true),
}
}
return data
}
/**
* Loads a user's profile data.
*
* @param userId The user's ID.
* @param gameVersion The game's version.
* @returns The raw JSON data.
*/
export async function loadUserData(
userId: string,
gameVersion: GameVersion,
): Promise<UserProfile> {
let path
if (["scpc", "h1", "h2"].includes(gameVersion)) {
path = join("userdata", gameVersion, "users", `${userId}.json`)
} else {
path = join("userdata", "users", `${userId}.json`)
}
const userProfile = castUserProfile(
JSON.parse((await readFile(path)).toString()),
)
asyncGuard.addLoadedProfile(`${userId}.${gameVersion}`, userProfile)
return userProfile
}
/**
* Marks a user's profile as dirty.
* Dirty profiles are automatically saved by a background thread every 3 seconds.
*
* @param userId The user's ID.
* @param gameVersion The game's version.
*/
export function writeUserData(userId: string, gameVersion: GameVersion): void {
asyncGuard.markDirty(`${userId}.${gameVersion}`)
}
/**
* Writes a previously-non existent user profile.
* This is used for creating new profiles.
*
* @param userId The user's ID.
* @param userProfile
* @param gameVersion The game's version.
*/
export function writeNewUserData(
userId: string,
userProfile: UserProfile,
gameVersion: GameVersion,
): void {
asyncGuard.addLoadedProfile(`${userId}.${gameVersion}`, userProfile)
asyncGuard.markDirty(`${userId}.${gameVersion}`)
}
/**
* Gets the value of an external provider binding.
*
* @param userId The user's ID.
* @param externalFolder The folder where this provider's users are stored.
* @param gameVersion The game's version.
*/
export async function getExternalUserData(
userId: string,
externalFolder: string,
gameVersion: GameVersion,
): Promise<string> {
if (["scpc", "h1", "h2"].includes(gameVersion)) {
return (
await readFile(
join("userdata", gameVersion, externalFolder, `${userId}.json`),
)
).toString()
}
return (
await readFile(join("userdata", externalFolder, `${userId}.json`))
).toString()
}
/**
* Writes the value of an external provider binding.
*
* @param userId The user's ID.
* @param externalFolder The folder where this provider's users are stored.
* @param userData The data to write to the binding.
* @param gameVersion The game's version.
*/
export async function writeExternalUserData(
userId: string,
externalFolder: string,
userData: string,
gameVersion: GameVersion,
): Promise<void> {
if (["scpc", "h1", "h2"].includes(gameVersion)) {
return await writeFile(
join("userdata", gameVersion, externalFolder, `${userId}.json`),
userData,
)
}
return await writeFile(
join("userdata", externalFolder, `${userId}.json`),
userData,
)
}
/**
* Reads a contract session from the contractSessions folder.
*
* @param sessionId The ID of the session to load.
* @returns The contract session.
*/
export async function getContractSession(
sessionId: string,
): Promise<ContractSession> {
return deserializeSession(
JSON.parse(
(
await readFile(join("contractSessions", `${sessionId}.json`))
).toString(),
),
)
}
/**
* Writes a contract session to the contractsSessions folder.
*
* @param sessionId The session's ID.
* @param session The contract session.
*/
export async function writeContractSession(
sessionId: string,
session: ContractSession,
): Promise<void> {
return await writeFile(
join("contractSessions", `${sessionId}.json`),
JSON.stringify(serializeSession(session)),
)
}

View File

@ -0,0 +1,230 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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 EventEmitter from "events"
import { clearTimeout, setTimeout } from "timers"
import { IPCTransport } from "./ipc"
import { randomUUID } from "crypto"
function subKey(event, args) {
return `${event}${JSON.stringify(args)}`
}
export interface PresenceButton {
label: string
url: string
}
export interface Presence {
state?: string
details?: string
startTimestamp?: number | Date
endTimestamp?: number | Date
largeImageKey?: string
largeImageText?: string
smallImageKey?: string
smallImageText?: string
instance?: boolean
buttons?: PresenceButton[]
timestamps?: {
start?: number
end?: number
}
assets?: {
large_image?: string
large_text?: string
small_image?: string
small_text?: string
}
}
export interface ClientOptions {
clientId: string
}
export class RPCClient extends EventEmitter {
clientId?: string | undefined = undefined
user?: unknown | undefined = undefined
private transport: IPCTransport
private _expecting: Map<string, Promise<unknown> & PromiseConstructor> =
new Map()
private _subscriptions: Map<string, Promise<unknown>> = new Map()
private _connectPromise: Promise<unknown> | undefined = undefined
constructor() {
super()
this.transport = new IPCTransport(this)
this.transport.on("message", this._onRpcMessage.bind(this))
}
connect(clientId: string): Promise<unknown> {
if (this._connectPromise) {
return this._connectPromise
}
this._connectPromise = new Promise((resolve, reject) => {
this.clientId = clientId
const timeout = setTimeout(() => {
if (this.transport.unavailable) {
return
}
reject(new Error("RPC_CONNECTION_TIMEOUT"))
}, 10e3)
timeout.unref()
this.once("connected", () => {
clearTimeout(timeout)
resolve(this)
})
this.transport.once("close", () => {
this._expecting.forEach((e) => {
e.reject(new Error("connection closed"))
})
this.emit("disconnected")
reject(new Error("connection closed"))
})
this.transport.connect().catch(reject)
})
return this._connectPromise
}
async login(options: ClientOptions): Promise<this> {
const { clientId } = options
await this.connect(clientId)
this.emit("ready")
return this
}
request(
cmd: string,
args: { pid: number; activity?: Presence; instance?: boolean },
evt?: undefined,
): Promise<unknown> {
return new Promise((resolve, reject) => {
const nonce = randomUUID()
this.transport.send({ cmd, args, evt, nonce })
// @ts-expect-error It's a partial promise.
this._expecting.set(nonce, { resolve, reject })
})
}
_onRpcMessage(message): void {
if (message.cmd === "DISPATCH" && message.evt === "READY") {
if (message.data.user) {
this.user = message.data.user
}
this.emit("connected")
return
}
if (!this._expecting.has(message.nonce)) {
const subid = subKey(message.evt, message.args)
if (!this._subscriptions.has(subid)) {
return
}
// @ts-expect-error Strange promise call.
this._subscriptions.get(subid)(message.data)
return
}
const { resolve, reject } = this._expecting.get(
message.nonce,
) as PromiseConstructor
if (message.evt === "ERROR") {
const e = new Error(message.data.message)
// @ts-expect-error The Error object shouldn't have this.
e.data = message.data
reject(e)
} else {
resolve(message.data)
}
this._expecting.delete(message.nonce)
}
async setActivity(
args: Presence = {},
pid = process.pid,
): Promise<unknown> {
let timestamps
let assets
if (args.startTimestamp || args.endTimestamp) {
// eslint-disable-next-line prefer-const
timestamps = {
start: args.startTimestamp,
end: args.endTimestamp,
}
if (timestamps.start instanceof Date) {
timestamps.start = Math.round(timestamps.start.getTime())
}
if (timestamps.end instanceof Date) {
timestamps.end = Math.round(timestamps.end.getTime())
}
}
if (
args.largeImageKey ||
args.largeImageText ||
args.smallImageKey ||
args.smallImageText
) {
// eslint-disable-next-line prefer-const
assets = {
large_image: args.largeImageKey,
large_text: args.largeImageText,
small_image: args.smallImageKey,
small_text: args.smallImageText,
}
}
return await this.request("SET_ACTIVITY", {
pid,
activity: {
state: args.state,
details: args.details,
timestamps,
assets,
buttons: args.buttons,
instance: !!args.instance,
},
})
}
async clearActivity(pid = process.pid): Promise<Awaited<unknown>> {
return await this.request("SET_ACTIVITY", {
pid,
})
}
async destroy(): Promise<void> {
await this.transport.close()
}
}

235
components/discord/ipc.ts Normal file
View File

@ -0,0 +1,235 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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 net from "net"
import EventEmitter from "events"
import axios from "axios"
import { randomUUID } from "crypto"
import type { RPCClient } from "./client"
import { log, LogLevel } from "../loggingInterop"
const enum OPCodes {
HANDSHAKE = 0,
FRAME = 1,
CLOSE = 2,
PING = 3,
PONG = 4,
}
function getIPCPath(id: number): string {
if (process.platform === "win32") {
return `\\\\?\\pipe\\discord-ipc-${id}`
}
const {
env: { XDG_RUNTIME_DIR, TMPDIR, TMP, TEMP },
} = process
const prefix = XDG_RUNTIME_DIR || TMPDIR || TMP || TEMP || "/tmp"
return `${prefix.replace(/\/$/, "")}/discord-ipc-${id}`
}
function getIPC(id = 0): Promise<net.Socket | undefined> {
return new Promise((resolve) => {
const path = getIPCPath(id)
const onerror = (err: Error) => {
if (id < 10) {
resolve(getIPC(id + 1))
return
}
log(
LogLevel.WARN,
"Failed to connect to Discord for rich presence. Discord may not be running.",
)
if (err.stack) {
log(LogLevel.DEBUG, err.stack)
} else {
log(LogLevel.DEBUG, err.message)
}
resolve(undefined)
}
const sock = net.createConnection(path, () => {
sock.removeListener("error", onerror)
resolve(sock)
})
sock.once("error", onerror)
})
}
async function findEndpoint(tries = 0): Promise<string> {
if (tries > 30) {
throw new Error("Could not find endpoint")
}
const endpoint = `http://127.0.0.1:${6463 + (tries % 10)}`
try {
const r = await axios(endpoint)
if (r.status === 404) {
return endpoint
}
return findEndpoint(tries + 1)
} catch (e) {
return findEndpoint(tries + 1)
}
}
function encode(op: number, data): Buffer {
data = JSON.stringify(data)
const len = Buffer.byteLength(data)
const packet = Buffer.alloc(8 + len)
packet.writeInt32LE(op, 0)
packet.writeInt32LE(len, 4)
packet.write(data, 8, len)
return packet
}
const working = {
full: "",
op: undefined,
}
function decode(socket: net.Socket, callback): void {
const packet = socket.read()
if (!packet) {
return
}
let { op } = working
let raw: string
if (working.full === "") {
op = working.op = packet.readInt32LE(0)
const len = packet.readInt32LE(4)
raw = packet.slice(8, len + 8)
} else {
raw = packet.toString()
}
try {
const data = JSON.parse(working.full + raw)
callback({ op, data }) // eslint-disable-line callback-return
working.full = ""
working.op = undefined
} catch (err) {
working.full += raw
}
decode(socket, callback)
}
export class IPCTransport extends EventEmitter {
/**
* This will only be true if the initial connection failed, to prevent unhandled promise rejections.
*/
public unavailable = false
private socket: net.Socket | undefined = undefined
constructor(private readonly client: RPCClient) {
super()
}
async connect() {
const socket = (this.socket = await getIPC())
if (!this.socket) {
// failed to connect
this.unavailable = true
return
}
socket!.on("close", this.onClose.bind(this))
socket!.on("error", this.onClose.bind(this))
this.emit("open")
socket!.write(
encode(OPCodes.HANDSHAKE, {
v: 1,
client_id: this.client.clientId,
}),
)
socket!.pause()
socket!.on("readable", () => {
decode(socket!, ({ op, data }) => {
switch (op) {
case OPCodes.PING:
this.send(data, OPCodes.PONG)
break
case OPCodes.FRAME:
if (!data) {
return
}
if (data.cmd === "AUTHORIZE" && data.evt !== "ERROR") {
findEndpoint()
.then((endpoint) => {
// @ts-expect-error Unexpected property.
this.client.request.endpoint = endpoint
return
})
.catch((e) => {
this.client.emit("error", e)
})
}
this.emit("message", data)
break
case OPCodes.CLOSE:
this.emit("close", data)
break
default:
break
}
})
})
}
onClose(e): void {
this.emit("close", e)
}
send(data, op = OPCodes.FRAME): void {
if (this.unavailable) {
log(
LogLevel.DEBUG,
"Skipping RPC data send: transport unavailable.",
)
return
}
this.socket?.write(encode(op, data))
}
async close(): Promise<void> {
return new Promise((resolve) => {
this.once("close", resolve)
this.send({}, OPCodes.CLOSE)
this.socket?.end()
})
}
ping(): void {
this.send(randomUUID(), OPCodes.PING)
}
}

379
components/discordRp.ts Normal file
View File

@ -0,0 +1,379 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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 { RPCClient } from "./discord/client"
import { getConfig } from "./configSwizzleManager"
import type { GameVersion, MissionType } from "./types/types"
import { getFlag } from "./flags"
import { log, LogLevel } from "./loggingInterop"
let rpcClient: undefined | RPCClient
/*@__NOINLINE__*/
const processStartTime = Math.round(Date.now() / 1000)
export function initRp(): void {
Object.keys(getConfig("Entrances", false)).forEach((key) => {
if (!scenePathToRpAsset(key, []) && PEACOCK_DEV) {
log(LogLevel.DEBUG, `WARNING missing scene ${key} for RP!`)
}
})
// creates a new rp client, pretty self-explanatory.
rpcClient = new RPCClient()
// connects to the Peacock discord developer app, which contains the images for the rp.
rpcClient.login({
clientId: "846361353027584013",
})
// checks if the rp is on the state "ready", and starts the rp, displaying the details on discord.
rpcClient.on("ready", () => {
swapToIdle()
})
}
/**
* Sets the user's Discord RP status to "browsing menus" if the feature is enabled.
*
* @param gameVersion The game version.
*/
export function swapToBrowsingMenusStatus(gameVersion: GameVersion): void {
if (getFlag("discordRp") === true) {
rpcClient?.setActivity({
details: `Playing HITMAN™ ${gameVersion
.substring(1)
.replace("1", "(2016)")}`,
largeImageKey: gameVersion,
largeImageText: "Browsing Menus",
smallImageKey: "peacock",
smallImageText: "Using Peacock",
startTimestamp:
getFlag("discordRpAppTime") === true
? processStartTime
: Math.round(Date.now() / 1000),
})
}
}
/**
* Sets the user's Discord RP status to "idle" if the feature is enabled.
*/
export function swapToIdle(): void {
if (getFlag("discordRp") === true) {
rpcClient?.setActivity({
details: "Idle",
largeImageKey: "peacock",
largeImageText: "Idling",
startTimestamp:
getFlag("discordRpAppTime") === true
? processStartTime
: Math.round(Date.now() / 1000),
})
}
}
/**
* Sets the user's Discord RP status to playing something if the feature is enabled.
*
* @param scenePath The path to the loaded scene, used to determine names/assets used.
* @param missionType The mission's type.
* @param bricks The mission's loaded bricks.
*/
export function swapToLocationStatus(
scenePath: string,
missionType: MissionType,
bricks: string[],
): void {
if (getFlag("discordRp") !== true) {
return
}
const details = scenePathToRpAsset(scenePath, bricks)
if (!Array.isArray(details)) {
// we need to handle this somehow in the future
return
}
const formattedMissionType =
missionType === "orbis"
? "Sarajevo Six"
: `${missionType
.substring(0, 1)
.toUpperCase()}${missionType.substring(1)}`
rpcClient?.setActivity({
state: `${formattedMissionType} in ${details[2]}`,
details: details[1],
largeImageKey: details[0],
largeImageText: details[1],
smallImageKey: "peacock",
smallImageText: "Using Peacock",
startTimestamp:
getFlag("discordRpAppTime") === true
? processStartTime
: Math.round(Date.now() / 1000),
})
}
type BrickDataMap = Record<string, [string, string, string]>
/**
* Translates a scene path to a Discord RP asset and human-readable name.
*
* @param scenePath The scene path.
* @param bricks The mission's loaded bricks.
* @returns An array of the RP asset, human-readable name, and location human-readable name.
*/
export function scenePathToRpAsset(
scenePath: string,
bricks: string[],
): string[] | undefined {
const brickAssetsMap = getConfig<BrickDataMap>(
"DiscordRichAssetsForBricks",
false,
)
for (const brick of bricks) {
if (
Object.prototype.hasOwnProperty.call(
brickAssetsMap,
brick.toLowerCase(),
)
) {
return brickAssetsMap[brick.toLowerCase()]!
}
}
switch (scenePath.toLowerCase()) {
// paris
case "assembly:/_pro/scenes/missions/paris/_scene_paris.entity":
case "assembly:/_pro/scenes/missions/paris/_scene_paris_torenia.entity":
case "assembly:/_pro/scenes/missions/paris/_scene_fashionshowhit_01.entity":
case "assembly:/_pro/scenes/missions/paris/_scene_tutorial_contractcreationparis.entity":
case "assembly:/_pro/scenes/missions/paris/_scene_fashionshowhit_amd.entity":
return ["parisshowstopper", "The Showstopper", "Paris"]
case "assembly:/_pro/scenes/missions/paris/_scene_tequilasunrise_01.entity":
return ["elusivetequilasunrise", "The Forger", "Paris"]
case "assembly:/_pro/scenes/missions/paris/_scene_whiterussian_01.entity":
return ["elusivewhiterussian", "The Identity Thief", "Paris"]
// sapienza
case "assembly:/_pro/scenes/missions/coastaltown/_scene_copperhead.entity":
case "assembly:/_pro/scenes/missions/coastaltown/_scene_mission_copperhead.entity":
return ["sapienzaicon", "The Icon", "Sapienza"]
case "assembly:/_pro/scenes/missions/coastaltown/_scene_mamba.entity":
case "assembly:/_pro/scenes/missions/coastaltown/_scene_mission_mamba.entity":
return ["sapienzalandslide", "Landslide", "Sapienza"]
case "assembly:/_pro/scenes/missions/coastaltown/mission01.entity":
case "assembly:/_pro/scenes/missions/coastaltown/_scene_octopus.entity":
return ["sapienzaworldoftomorrow", "World of Tomorrow", "Sapienza"]
case "assembly:/_pro/scenes/missions/coastaltown/scene_ebola.entity":
return ["sapienzapzauthor", "The Author", "Sapienza"]
// marrakesh
case "assembly:/_pro/scenes/missions/marrakesh/_scene_mission_spider.entity":
case "assembly:/_pro/scenes/missions/marrakesh/_scene_spider.entity":
return ["marrakeshguildedcage", "A Gilded Cage", "Marrakesh"]
case "assembly:/_pro/scenes/missions/marrakesh/_scene_mission_python.entity":
case "assembly:/_pro/scenes/missions/marrakesh/_scene_python_hellebore.entity":
case "assembly:/_pro/scenes/missions/marrakesh/_scene_python.entity":
return ["marrakeshahbos", "A House Built On Sand", "Marrakesh"]
// bangkok missions
case "assembly:/_pro/scenes/missions/bangkok/_scene_mission_tiger.entity":
case "assembly:/_pro/scenes/missions/bangkok/_scene_tiger.entity":
return ["bangkokclub27", "Club 27", "Bangkok"]
case "assembly:/_pro/scenes/missions/bangkok/scene_zika.entity":
return ["bangkokpzsource", "The Source", "Bangkok"]
// colorado missions
case "assembly:/_pro/scenes/missions/colorado_2/_scene_bull.entity":
case "assembly:/_pro/scenes/missions/colorado_2/_scene_mission_bull.entity":
return ["coloradofreedomfighters", "Freedom Fighters", "Colorado"]
case "assembly:/_pro/scenes/missions/colorado_2/scene_rabies.entity":
return ["coloradopzvector", "The Vector", "Colorado"]
// hokkaido missions
case "assembly:/_pro/scenes/missions/hokkaido/_scene_mission_snowcrane.entity":
case "assembly:/_pro/scenes/missions/hokkaido/_scene_snowcrane_tumbleweed.entity":
case "assembly:/_pro/scenes/missions/hokkaido/_scene_snowcrane.entity":
return ["hokkaidositusinvertus", "Situs Invertus", "Hokkaido"]
case "assembly:/_pro/scenes/missions/hokkaido/scene_mamushi.entity":
return [
"hokkaidosnowfestival",
"Hokkaido Snow Festival",
"Hokkaido",
]
case "assembly:/_pro/scenes/missions/hokkaido/_scene_flu.entity":
return ["hokkaidopz", "Patient Zero", "Hokkaido"]
// dartmoor
case "assembly:/_pro/scenes/missions/ancestral/scene_bulldog.entity":
return ["dartmoordeathoffamily", "Death in the Family", "Dartmoor"]
case "assembly:/_pro/scenes/missions/ancestral/scene_smoothsnake.entity":
return ["dartmoorgardenshow", "Dartmoor Garden Show", "Dartmoor"]
case "assembly:/_pro/scenes/missions/ancestral/scene_ancestral_vesper.entity":
return ["elusivevesper", "The Procurers", "Dartmoor"]
// columbia
case "assembly:/_pro/scenes/missions/colombia/mission_millipede/scene_millipede.entity":
case "assembly:/_pro/scenes/missions/colombia/scene_anaconda.entity":
return [
"santafortunaembraceserpent",
"Embrace of the Serpent",
"Santa Fortuna",
]
case "assembly:/_pro/scenes/missions/colombia/scene_hippo.entity":
case "assembly:/_pro/scenes/missions/colombia/scene_hippo_calluna.entity":
case "assembly:/_pro/scenes/missions/colombia/scene_hippo_rafflesia.entity":
return [
"santafortunathreeheadedserpent",
"Three-Headed Serpent",
"Santa Fortuna",
]
// berlin
case "assembly:/_pro/scenes/missions/edgy/mission_fox/scene_radler.entity":
return ["elusiveradler", "The Liability", "Berlin"]
case "assembly:/_pro/scenes/missions/edgy/mission_fox/scene_fox_basic.entity":
case "assembly:/_pro/scenes/missions/edgy/mission_fox/scene_fox_contractcreation.entity":
case "assembly:/_pro/scenes/missions/edgy/mission_fox/scene_fox_cornflower.entity":
case "assembly:/_pro/scenes/missions/edgy/mission_fox/scene_fox_nightphlox.entity":
case "assembly:/_pro/scenes/missions/edgy/mission_fox/scene_fox_smilax.entity":
case "assembly:/_pro/scenes/missions/edgy/mission_fox/scene_fox_smilax_level2.entity":
case "assembly:/_pro/scenes/missions/edgy/mission_fox/scene_fox_smilax_level3.entity":
return ["berlinapexpredator", "Apex Predator", "Berlin"]
case "assembly:/_pro/scenes/missions/edgy/mission_fox/scene_grasssnake.entity":
return ["berlinegghunt", "Berlin Egg Hunt", "Berlin"]
// mendozer
case "assembly:/_pro/scenes/missions/elegant/scene_llama_elusive_clerico.entity":
return ["elusiveclerico", "The Heartbreaker", "Mendoza"]
case "assembly:/_pro/scenes/missions/elegant/scene_llama_elusive_jockeyclub.entity":
return ["elusivejockeyclub", "The Iconoclast", "Mendoza"]
case "assembly:/_pro/scenes/missions/elegant/scene_llama.entity":
case "assembly:/_pro/scenes/missions/elegant/scene_llama_jacaranda.entity":
case "assembly:/_pro/scenes/missions/elegant/scene_whitedryas.entity":
case "assembly:/_pro/scenes/missions/elegant/scene_whitedryas_level2.entity":
case "assembly:/_pro/scenes/missions/elegant/scene_whitedryas_level3.entity":
return ["mendozafarewell", "The Farewell", "Mendoza"]
// dubai
case "assembly:/_pro/scenes/missions/golden/mission_gecko/scene_gecko_angelica.entity":
case "assembly:/_pro/scenes/missions/golden/mission_gecko/scene_gecko_basic.entity":
case "assembly:/_pro/scenes/missions/golden/mission_gecko/scene_gecko_desertrose.entity":
case "assembly:/_pro/scenes/missions/golden/mission_gecko/scene_gecko_lunaria.entity":
case "assembly:/_pro/scenes/missions/golden/mission_gecko/scene_gecko_sinstest.entity":
case "assembly:/_pro/scenes/missions/golden/mission_gecko/scene_gecko_sheepsorrel.entity":
return ["dubaiontopoftheworld", "On Top Of The World", "Dubai"]
case "assembly:/_pro/scenes/missions/golden/mission_gecko/scene_gibson.entity":
return ["elusivegibson", "The Ascensionist", "Dubai"]
// chongqing
case "assembly:/_pro/scenes/missions/wet/scene_wet_makoyana.entity":
case "assembly:/_pro/scenes/missions/wet/scene_rat_ginseng.entity":
case "assembly:/_pro/scenes/missions/wet/scene_rat_basic.entity":
case "assembly:/_pro/scenes/missions/wet/scene_magnolia.entity":
return ["chongqingendofanera", "End Of An Era", "Chongqing"]
case "assembly:/_pro/scenes/missions/wet/scene_rat_elusive_redsnapper.entity":
return ["elusiveredsnapper", "The Rage", "Chongqing"]
// training
case "assembly:/_pro/scenes/missions/thefacility/_scene_polarbear_005.entity":
case "assembly:/_pro/scenes/missions/thefacility/_scene_mission_polarbear_002_for_escalation_.entity":
case "assembly:/_pro/scenes/missions/thefacility/_scene_mission_polarbear_002_contracts_creation_tutorial.entity":
case "assembly:/_pro/scenes/missions/thefacility/_scene_mission_polarbear_intro_firsttime.entity":
case "assembly:/_pro/scenes/missions/thefacility/_scene_mission_polarbear_module_002.entity":
case "assembly:/_pro/scenes/missions/thefacility/_scene_mission_polarbear_module_002_b.entity":
case "assembly:/_pro/scenes/missions/thefacility/_scene_mission_polarbear_module_005.entity":
return ["icafacilityfinaltest", "ICA Facility", "Greenland"]
// new york
case "assembly:/_pro/scenes/missions/greedy/mission_raccoon/scene_raccoon_basic.entity":
case "assembly:/_pro/scenes/missions/greedy/mission_raccoon/scene_raccoon_basic_dandelion.entity":
return ["newyorkgoldenhandshake", "Golden Handshake", "New York"]
// haven
case "assembly:/_pro/scenes/missions/opulent/mission_stingray/scene_stingray_basic.entity":
case "assembly:/_pro/scenes/missions/opulent/mission_stingray/scene_stingray_arcticthyme.entity":
return [
"havenlastresort",
"The Last Resort",
"Haven - The Maldives",
]
// miami
case "assembly:/_pro/scenes/missions/miami/scene_et_sambuca.entity":
return ["elusivesambuca", "The Undying", "Miami"]
case "assembly:/_pro/scenes/missions/miami/scene_flamingo.entity":
return ["miamifinishline", "The Finish Line", "Miami"]
case "assembly:/_pro/scenes/missions/miami/scene_cottonmouth.entity":
return ["miamisilvertounge", "A Silver Tongue", "Miami"]
// hawkes bay
case "assembly:/_pro/scenes/missions/sheep/scene_adonis.entity":
return ["elusiveadonis", "The Politician", "New Zealand"]
case "assembly:/_pro/scenes/missions/sheep/scene_sheep.entity":
return ["hawkenightcall", "Nightcall", "New Zealand"]
case "assembly:/_pro/scenes/missions/sheep/scene_opuntia.entity":
return ["opuntia", "Opuntia", "New Zealand"]
// sgail
case "assembly:/_pro/scenes/missions/theark/scene_magpie.entity":
case "assembly:/_pro/scenes/missions/theark/_scene_magpie_pansy.entity":
case "assembly:/_pro/scenes/missions/theark/_scene_magpie_lotus.entity":
return ["sgailarksociety", "The Ark Society", "Isle of Sgáil"]
// whittleton
case "assembly:/_pro/scenes/missions/skunk/scene_skunk.entity":
case "assembly:/_pro/scenes/missions/skunk/mission_grasshopper/scene_grasshopper.entity":
return ["whittletonanotherlife", "Another Life", "Whittleton Creek"]
case "assembly:/_pro/scenes/missions/skunk/scene_gartersnake.entity":
return ["whittletonbitterpill", "A Bitter Pill", "Whittleton Creek"]
// mumbai
case "assembly:/_pro/scenes/missions/mumbai/scene_mongoose.entity":
return ["mumbaichasingghost", "Chasing A Ghost", "Mumbai"]
case "assembly:/_pro/scenes/missions/mumbai/scene_kingcobra.entity":
return ["mumbaikingcobra", "Illusions of Grandeur", "Mumbai"]
// romania
case "assembly:/_pro/scenes/missions/trapped/scene_bellflower.entity":
case "assembly:/_pro/scenes/missions/trapped/scene_wolverine.entity":
return ["romaniatile", "Untouchable", "Romania"]
// ambrose
case "assembly:/_pro/scenes/missions/rocky/scene_dugong.entity":
return [
"shadowsinthewater",
"Shadows in the Water",
"Ambrose Island",
]
// sniper
case "assembly:/_pro/scenes/missions/caged/mission_falcon/scene_falcon_sniper.entity":
return ["sniper_siberia", "Crime and Punishment", "Siberia"]
case "assembly:/_pro/scenes/missions/hawk/scene_hawk.entity":
return ["austria", "The Last Yardbird", "Austria"]
case "assembly:/_pro/scenes/missions/salty/mission_seagull/scene_seagull.entity":
return ["hantuport", "The Pen and the Sword", "Hantu Port"]
}
return undefined
}

View File

@ -0,0 +1,155 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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 { log, LogLevel } from "./loggingInterop"
import { userAuths } from "./officialServerAuth"
import {
EPIC_NAMESPACE_2021,
FRANKENSTEIN_SNIPER_ENTITLEMENTS,
getEpicEntitlements,
H2_STEAM_ENTITLEMENTS,
STEAM_NAMESPACE_2016,
} from "./platformEntitlements"
import { GameVersion } from "./types/types"
/**
* The base class for an entitlement strategy.
*
* @internal
*/
abstract class EntitlementStrategy {
abstract get(
accessToken: string,
userId: string,
): string[] | Promise<string[]>
}
/**
* Provider for HITMAN 3 on Epic Games Store.
*
* @internal
*/
export class EpicH3Strategy extends EntitlementStrategy {
override async get(accessToken: string, userId: string) {
return await getEpicEntitlements(
EPIC_NAMESPACE_2021,
userId,
accessToken,
)
}
}
/**
* Provider for any game using the official servers.
*
* @internal
*/
export class IOIStrategy extends EntitlementStrategy {
private readonly _remoteService: string
constructor(gameVersion: GameVersion, private readonly issuerId: string) {
super()
this.issuerId = issuerId
this._remoteService =
gameVersion === "h3"
? "hm3-service"
: gameVersion === "h2"
? "pc2-service"
: "pc-service"
}
override async get(userId: string) {
if (!userAuths.has(userId)) {
log(LogLevel.ERROR, `No data found for ${userId}.`)
return []
}
const user = userAuths.get(userId)
const resp = await user?._useService<string[]>(
`https://${this._remoteService}.hitman.io/authentication/api/userchannel/ProfileService/GetPlatformEntitlements`,
false,
{
issuerId: this.issuerId,
},
)
return resp?.data || []
}
}
/**
* Provider for HITMAN 2016 on Epic Games.
* TODO: This does not work due to old Epic API version. We should probably not hard-code this!
*
* @internal
*/
export class EpicH1Strategy extends EntitlementStrategy {
override get() {
return [
"0a73eaedcac84bd28b567dbec764c5cb", // Hitman 1 standard edition
"81aecb49a60b47478e61e1cbd68d63c5", // Hitman 1 GOTY upgrade
]
}
}
/**
* Provider for HITMAN: Sniper Challenge (Hawk) on Steam.
*
* @internal
*/
export class SteamScpcStrategy extends EntitlementStrategy {
override get() {
return FRANKENSTEIN_SNIPER_ENTITLEMENTS
}
}
/**
* Provider for HITMAN 2016 on Steam.
* TODO: This does not work because of the Steam API. We should probably not hard-code this!
*
* @internal
*/
export class SteamH1Strategy extends EntitlementStrategy {
override get() {
return [
STEAM_NAMESPACE_2016,
"439870",
"439890",
"440930",
"440940",
"440960",
"440961",
"440962",
"505180",
"588780",
]
}
}
/**
* Provider for HITMAN 2 on Steam.
* TODO: This does not work because of the Steam API. We should probably not hard-code this!
*
* @internal
*/
export class SteamH2Strategy extends EntitlementStrategy {
override get() {
return H2_STEAM_ENTITLEMENTS
}
}

818
components/eventHandler.ts Normal file
View File

@ -0,0 +1,818 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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 { Router } from "express"
import {
ClientToServerEvent,
ContractSession,
GameVersion,
MissionManifestObjective,
PeacockCameraStatus,
PushMessage,
RatingKill,
RequestWithJwt,
S2CEventWithTimestamp,
Seconds,
ServerToClientEvent,
} from "./types/types"
import { extractToken, ServerVer } from "./utils"
import { json as jsonMiddleware } from "body-parser"
import { log, LogLevel } from "./loggingInterop"
import { getContractSession, writeContractSession } from "./databaseHandler"
import { controller } from "./controller"
import { swapToLocationStatus } from "./discordRp"
import { randomUUID } from "crypto"
import { liveSplitManager } from "./livesplit/liveSplitManager"
import { handleMultiplayerEvent } from "./multiplayer/multiplayerService"
import { handleEvent } from "@peacockproject/statemachine-parser"
import picocolors from "picocolors"
import { encodePushMessage } from "./multiplayer/multiplayerUtils"
import {
ActorTaggedC2SEvent,
AmbientChangedC2SEvent,
BodyHiddenC2SEvent,
ContractStartC2SEvent,
HeroSpawn_LocationC2SEvent,
ItemDroppedC2SEvent,
ItemPickedUpC2SEvent,
KillC2SEvent,
MurderedBodySeenC2SEvent,
ObjectiveCompletedC2SEvent,
PacifyC2SEvent,
SecuritySystemRecorderC2SEvent,
SetpiecesC2SEvent,
SpottedC2SEvent,
WitnessesC2SEvent,
} from "./types/events"
const eventRouter = Router()
// /authentication/api/userchannel/EventsService/
const eventQueue = new Map<string, S2CEventWithTimestamp[]>()
const pushMessageQueue = new Map<string, PushMessage[]>()
/**
* Enqueue a server to client push message.
* It will be sent back the next time the client calls `SaveAndSynchronizeEvents4`.
*
* @param userId The push message's target user.
* @param message The encoded push message to send.
* @see enqueueEvent
* @author grappigegovert
*/
export function enqueuePushMessage(userId: string, message: unknown): void {
let userQueue
const time = process.hrtime.bigint()
if ((userQueue = pushMessageQueue.get(userId))) {
userQueue.push({
time,
message: encodePushMessage(time, message),
})
} else {
userQueue = [
{
time,
message: encodePushMessage(time, message),
},
]
pushMessageQueue.set(userId, userQueue)
}
}
/**
* Register a listener for an objective. Allows server-side tracking of the objective's state as events come in.
*
* @param session The contract session.
* @param objective The objective object.
* @author Reece Dunham
*/
export function registerObjectiveListener(
session: ContractSession,
objective: MissionManifestObjective,
): void {
if (!objective.Definition) {
return
}
let context = objective.Definition.Context || {}
let state = "Start"
session.objectiveDefinitions.set(objective.Id, objective.Definition)
const immediate = handleEvent(
// @ts-expect-error Type issue, needs to be corrected in sm-p.
objective.Definition,
context,
{},
{
eventName: "-",
currentState: state,
},
)
if (immediate.state) {
state = immediate.state
}
if (immediate.context) {
context = immediate.context
}
session.objectiveContexts.set(objective.Id, context)
session.objectiveStates.set(objective.Id, state)
}
/**
* Enqueue a server to client event.
* It will be sent back the next time the client calls `SaveAndSynchronizeEvents4`.
*
* @param userId The event's target user.
* @param event The event to send.
* @see enqueuePushMessage
* @author grappigegovert
*/
export function enqueueEvent(userId: string, event: ServerToClientEvent): void {
let userQueue: S2CEventWithTimestamp[] | undefined
const time = process.hrtime.bigint().toString()
event.CreatedAt = new Date().toISOString().slice(0, -1)
event.Token = time.toString()
if ((userQueue = eventQueue.get(userId))) {
userQueue.push({
time,
event,
})
} else {
userQueue = [
{
time,
event,
},
]
eventQueue.set(userId, userQueue)
}
}
/**
* Like the game's internal enum for EDeathContext.
*
* @see https://github.com/OrfeasZ/ZHMModSDK/blob/ba9512092a37d3b1f4de047bdd6acf15a7b9ac7c/ZHMModSDK/Include/Glacier/Enums.h#L6158
*/
export const enum EDeathContext {
eDC_UNDEFINED = 0,
eDC_NOT_HERO = 1,
eDC_HIDDEN = 2,
eDC_ACCIDENT = 3,
eDC_MURDER = 4,
}
export const contractSessions = new Map<string, ContractSession>()
const userIdToTempSession = new Map<string, string>()
/**
* Get the current state of an objective.
*
* @param sessionId The session ID.
* @param objectiveId The objective ID.
*/
export function getCurrentState(
sessionId: string,
objectiveId: string,
): string | undefined {
// Note: after the double-layered maps are merged into the session object, this should be rewritten.
const session = contractSessions.get(sessionId)
if (!session) {
return "Start"
}
return session.objectiveStates.get(objectiveId)
}
/**
* Creates a new contract session.
*
* @param sessionId The ID for the session.
* @param contractId The ID of the contract the session is for.
* @param userId The ID of the user playing the session.
* @param difficulty The difficulty of the game.
* @param gameVersion The game version.
* @param doScoring If true, this will be treated like a normal session. If false, this session will not be scored/put on the leaderboards. This should be false if we don't have full session details, e.g. if this is a save from the official servers loaded on Peacock.
*/
export function newSession(
sessionId: string,
contractId: string,
userId: string,
difficulty: number,
gameVersion: GameVersion,
doScoring = true,
): void {
const timestamp = new Date()
const contract = controller.resolveContract(contractId)
if (!contract) {
log(LogLevel.ERROR, `Failed to load ${contractId}`)
throw new Error("no ct")
}
swapToLocationStatus(
contract.Metadata.ScenePath,
contract.Metadata.Type,
contract.Data.Bricks || [],
)
contractSessions.set(sessionId, {
gameVersion,
sessionStart: timestamp,
lastUpdate: timestamp,
contractId,
userId: userId,
timerStart: 0,
timerEnd: 0,
duration: 0,
crowdNpcKills: 0,
targetKills: new Set(),
npcKills: new Set(),
bodiesHidden: new Set(),
pacifications: new Set(),
disguisesUsed: new Set(),
disguisesRuined: new Set(),
spottedBy: new Set(),
witnesses: new Set(),
bodiesFoundBy: new Set(),
legacyHasBodyBeenFound: false,
killsNoticedBy: new Set(),
completedObjectives: new Set(),
failedObjectives: new Set(),
recording: PeacockCameraStatus.NotSpotted,
lastAccident: 0,
lastKill: {},
kills: new Set(),
compat: doScoring,
markedTargets: new Set(),
currentDisguise: "4fc9396e-2619-4e66-a51e-2bd366230da7", // sig suit
difficulty,
objectiveContexts: new Map(),
objectiveStates: new Map(),
objectiveDefinitions: new Map(),
ghost: {
deaths: 0,
unnoticedKills: 0,
Opponents: [],
OpponentScore: 0,
Score: 0,
IsDraw: false,
IsWinner: false,
timerEnd: null,
},
})
userIdToTempSession.set(userId, sessionId)
controller.challengeService.startContract(
userId,
sessionId,
contractSessions.get(sessionId)!,
)
}
eventRouter.post(
"/SaveAndSynchronizeEvents4",
extractToken,
jsonMiddleware({ limit: "10Mb" }),
(
req: RequestWithJwt<
unknown,
{
lastPushDt: number | string
lastEventTicks: number | string
userId?: string
values?: []
}
>,
res,
) => {
if (req.body.userId !== req.jwt.unique_name) {
res.status(403).send() // Trying to save events for other user
return
}
if (!Array.isArray(req.body.values)) {
res.status(400).end() // malformed request
return
}
const savedTokens = req.body.values.length
? saveEvents(req.body.userId, req.body.values, req)
: null
let userQueue: S2CEventWithTimestamp[] | undefined
let newEvents: ServerToClientEvent[] | null = null
// events: (server -> client)
if ((userQueue = eventQueue.get(req.jwt.unique_name))) {
userQueue = userQueue.filter(
(item) => item.time > req.body.lastEventTicks,
)
eventQueue.set(req.jwt.unique_name, userQueue)
newEvents = Array.from(userQueue, (item) => item.event)
}
// push messages: (server -> client)
let userPushQueue: PushMessage[] | undefined
let pushMessages: string[] | null = null
if ((userPushQueue = pushMessageQueue.get(req.jwt.unique_name))) {
userPushQueue = userPushQueue.filter(
(item) => item.time > req.body.lastPushDt,
)
pushMessageQueue.set(req.jwt.unique_name, userPushQueue)
pushMessages = Array.from(userPushQueue, (item) => item.message)
}
res.json({
SavedTokens: savedTokens,
NewEvents: newEvents || null,
NextPoll: 10.0,
PushMessages: pushMessages || null,
})
},
)
eventRouter.post(
"/SaveEvents2",
extractToken,
jsonMiddleware({ limit: "10Mb" }),
(req: RequestWithJwt, res) => {
if (req.jwt.unique_name !== req.body.userId) {
res.status(403).send() // Trying to save events for other user
return
}
res.json(saveEvents(req.body.userId, req.body.values, req))
},
)
/**
* Gets the active session's ID for the specified user (by their ID).
*
* @param uId The user's ID.
* @returns The ID for the user's active session.
* @author Reece Dunham
*/
export function getActiveSessionIdForUser(uId: string): string | undefined {
return userIdToTempSession.get(uId)
}
/**
* Gets the active session for the specified user (by their ID).
*
* @param uId The user's ID.
* @returns The user's active contract session.
* @author Reece Dunham
*/
export function getSession(uId: string): ContractSession | undefined {
const currentSession = getActiveSessionIdForUser(uId)
if (!currentSession) {
return undefined
}
return contractSessions.get(currentSession)
}
function contractFailed(
event: ClientToServerEvent,
session: ContractSession,
): void {
session.markedTargets.clear()
const json = controller.resolveContract(session.contractId)!
let realName: string
if (json.Metadata.Type === "creation") {
realName =
event.Value === "Contract ended manually: OnRestartLevel"
? "GameRestart"
: `ContractFailed:${event.Value}`
} else {
realName = `ContractFailed:${event.Value}`
}
// if still in cutscene, end mission with 0 time pass -- this will get converted to minimum time within split manager
if (session.timerStart !== 0) {
// @ts-expect-error TypeScript still hates dates
const timeTotal: Seconds = session.timerEnd - session.timerStart
liveSplitManager.failMission(timeTotal)
} else {
liveSplitManager.failMission(0)
}
enqueueEvent(session.userId, {
CreatedAt: new Date().toISOString(),
Token: process.hrtime.bigint().toString(),
Id: randomUUID(),
Name: "SegmentClosing",
UserId: session.userId,
ContractId: session.contractId,
SessionId: null,
ContractSessionId: event.ContractSessionId,
Timestamp: 0.0,
Value: {
SegmentIndex: 0,
LastEventName: "ContractFailed",
LastEventTime: (session.lastUpdate as Date).toISOString(),
CloseType: realName,
},
Origin: "ContractSessionService",
Version: ServerVer,
IsReplicated: false,
})
}
function saveEvents(
userId: string,
events: ClientToServerEvent[],
req: RequestWithJwt<unknown, unknown>,
): string[] {
const response: string[] = []
const processed: string[] = []
events.forEach((event) => {
const session = contractSessions.get(event.ContractSessionId)
if (
!session ||
session.contractId !== event.ContractId ||
session.userId !== userId
) {
if (PEACOCK_DEV) {
log(LogLevel.DEBUG, "No session or session user ID mismatch!")
console.debug(session)
console.debug(event)
}
return // session does not exist or contractid/userid doesn't match
}
session.duration = event.Timestamp
session.lastUpdate = new Date()
// @ts-expect-error Issue with request type mismatch.
controller.hooks.newEvent.call(event, req, session)
for (const objectiveId of session.objectiveStates.keys()) {
try {
const objectiveDefinition =
session.objectiveDefinitions.get(objectiveId)
const objectiveState = session.objectiveStates.get(objectiveId)
const objectiveContext =
session.objectiveContexts.get(objectiveId)
const val = handleEvent(
objectiveDefinition as never,
objectiveContext,
event.Value,
{
eventName: event.Name,
currentState: objectiveState,
timestamp: event.Timestamp,
},
)
if (val.state === "Failure") {
if (PEACOCK_DEV) {
log(LogLevel.DEBUG, `Objective failed: ${objectiveId}`)
}
session.failedObjectives.add(objectiveId)
}
if (val.context) {
session.objectiveContexts.set(objectiveId, val.context)
session.objectiveStates.set(objectiveId, val.state)
}
} catch (e) {
log(
LogLevel.ERROR,
"An error occurred while tracing C2S events, please report this!",
)
log(LogLevel.ERROR, e)
log(LogLevel.ERROR, e.stack)
}
}
controller.challengeService.onContractEvent(
event,
event.ContractSessionId,
session,
)
// these events are important but may be fired after the timer is over
const canGetAfterTimerOver = [
"ContractEnd",
"ObjectiveCompleted",
"CpdSet",
]
if (
!canGetAfterTimerOver.includes(event.Name) &&
session.timerEnd !== 0 &&
event.Timestamp > session.timerEnd
) {
// Do not handle events that occur after exiting the level
response.push(process.hrtime.bigint().toString())
return
}
// @ts-expect-error Tapable types not sufficient
controller.hooks.newEvent.call(event, req)
const contract = controller.resolveContract(session.contractId)
const contractType = contract?.Metadata?.Type?.toLowerCase()
if (handleMultiplayerEvent(event, session)) {
processed.push(event.Name)
}
switch (event.Name) {
case "HeroSpawn_Location":
liveSplitManager.missionIntentResolved(
event.ContractId,
(<HeroSpawn_LocationC2SEvent>event).Value.RepositoryId,
)
break
case "Kill": {
const killValue = (event as KillC2SEvent).Value
if (session.lastKill.timestamp === event.Timestamp) {
session.lastKill.repositoryIds?.push(killValue.RepositoryId)
} else {
session.lastKill = {
timestamp: event.Timestamp,
repositoryIds: [killValue.RepositoryId],
}
}
if (killValue.KillContext === EDeathContext.eDC_NOT_HERO) {
// this is not 47, so we keep silent assassin
log(
LogLevel.DEBUG,
`${killValue.RepositoryId} eliminated, 47 not responsible`,
)
response.push(process.hrtime.bigint().toString())
return
}
log(
LogLevel.DEBUG,
`Actor ${killValue.RepositoryId} eliminated.`,
)
if (killValue.IsTarget || contractType === "creation") {
const kill: RatingKill = {
KillClass: killValue.KillClass,
KillMethodBroad: killValue.KillMethodBroad,
KillItemCategory: killValue.KillItemCategory,
IsHeadshot: killValue.IsHeadshot,
KillMethodStrict: killValue.KillMethodStrict,
KillItemRepositoryId: killValue.KillItemRepositoryId,
_RepositoryId: killValue.RepositoryId,
OutfitRepoId: session.currentDisguise,
}
session.kills.add(kill)
session.targetKills.add(killValue.RepositoryId)
} else {
session.npcKills.add(killValue.RepositoryId)
}
break
}
case "CrowdNPC_Died":
session.crowdNpcKills += 1
break
case "Pacify":
session.pacifications.add(
(<PacifyC2SEvent>event).Value.RepositoryId,
)
break
case "BodyHidden":
session.bodiesHidden.add(
(<BodyHiddenC2SEvent>event).Value.RepositoryId,
)
break
case "BodyFound":
if (req.gameVersion === "h1") {
session.legacyHasBodyBeenFound = true
}
break
case "Disguise":
log(LogLevel.DEBUG, `Now disguised: ${event.Value as string}`)
session.currentDisguise = event.Value as string
session.disguisesUsed.add(event.Value as string)
break
case "ContractStart": {
const disguise = (<ContractStartC2SEvent>event).Value.Disguise
session.currentDisguise = disguise
session.disguisesUsed.add(disguise)
liveSplitManager.startMission(
session.contractId,
req.gameVersion,
req.jwt.unique_name,
)
break
}
case "DisguiseBlown":
session.disguisesRuined.add(event.Value as string)
break
case "BrokenDisguiseCleared":
session.disguisesRuined.delete(event.Value as string)
break
case "Spotted":
for (const actor of (event as SpottedC2SEvent).Value) {
session.spottedBy.add(actor)
}
break
case "Witnesses":
for (const actor of (event as WitnessesC2SEvent).Value) {
session.witnesses.add(actor)
}
break
case "SecuritySystemRecorder": {
const eventValue = (<SecuritySystemRecorderC2SEvent>event).Value
if (
eventValue.event === "spotted" &&
session.recording !== PeacockCameraStatus.Erased
) {
session.recording = PeacockCameraStatus.Spotted
} else if (
eventValue.event === "destroyed" ||
eventValue.event === "erased"
) {
session.recording = PeacockCameraStatus.Erased
}
break
}
case "IntroCutEnd":
if (!session.timerStart) {
session.timerStart = event.Timestamp
}
break
case "exit_gate":
session.timerEnd = event.Timestamp
break
case "ContractEnd":
if (!session.timerEnd) {
session.timerEnd = event.Timestamp
}
break
case "ObjectiveCompleted":
session.completedObjectives.add(
(<ObjectiveCompletedC2SEvent>event).Value.Id,
)
break
case "AccidentBodyFound":
session.lastAccident = event.Timestamp
break
case "MurderedBodySeen":
if (
(event.Timestamp as unknown as number) !==
session.lastAccident
) {
session.bodiesFoundBy.add(
(<MurderedBodySeenC2SEvent>event).Value.Witness,
)
if (event.Timestamp === session.lastKill.timestamp) {
session.killsNoticedBy.add(
(<MurderedBodySeenC2SEvent>event).Value.Witness,
)
}
}
break
case "ActorTagged": {
const val = (<ActorTaggedC2SEvent>event).Value
if (!val.Tagged) {
session.markedTargets.delete(val.RepositoryId)
} else if (val.Tagged) {
session.markedTargets.add(val.RepositoryId)
}
break
}
case "ItemPickedUp":
log(
LogLevel.INFO,
`Picked up item with repository ID: ${
(<ItemPickedUpC2SEvent>event).Value.RepositoryId
}`,
)
break
case "StartingSuit":
session.currentDisguise = event.Value as string
break
case "ContractFailed":
session.timerEnd = event.Timestamp
contractFailed(event, session)
break
case "setpieces":
log(
LogLevel.DEBUG,
`Setpiece: ${
(<SetpiecesC2SEvent>event).Value.RepositoryId
}`,
)
break
case "ItemDropped":
log(
LogLevel.DEBUG,
`Item dropped: ${
(<ItemDroppedC2SEvent>event).Value.RepositoryId
}`,
)
break
case "AmbientChanged":
log(
LogLevel.DEBUG,
`Ambient switched to ${
(<AmbientChangedC2SEvent>event).Value.AmbientValue
}`,
)
break
case "Hero_Health":
case "NPC_Distracted":
case "ShotsHit":
case "FirstNonHeadshot":
case "OpportunityEvents":
case "FirstMissedShot":
// we don't care about these
break
default:
// no-op on our part
break
}
processed.push(event.Name)
response.push(process.hrtime.bigint().toString())
})
if (PEACOCK_DEV && processed.length > 0) {
log(
LogLevel.DEBUG,
`Event summary: ${picocolors.gray(processed.join(", "))}`,
)
}
return response
}
export async function saveSession(
sessionId: string,
token: string,
): Promise<void> {
if (!contractSessions.has(sessionId)) {
log(LogLevel.WARN, `Refusing to save ${sessionId} as it doesn't exist`)
return
}
await writeContractSession(
token + "_" + sessionId,
contractSessions.get(sessionId)!,
)
}
export async function loadSession(
sessionId: string,
token: string,
sessionData?: ContractSession,
): Promise<void> {
if (!sessionData) {
sessionData = await getContractSession(token + "_" + sessionId)
}
contractSessions.set(sessionId, sessionData)
}
export { eventRouter }

170
components/flags.ts Normal file
View File

@ -0,0 +1,170 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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 { existsSync, readFileSync, unlinkSync, writeFileSync } from "fs"
import type { Flags } from "./types/types"
import { log, LogLevel } from "./loggingInterop"
import { parse } from "js-ini"
import type { IIniObject } from "js-ini/lib/interfaces/ini-object"
let flags: IIniObject = {}
const defaultFlags: Flags = {
discordRp: {
desc: "Toggle Discord rich presence on or off.",
default: false,
},
discordRpAppTime: {
desc: "For Discord Rich Presence, if set to false, the time playing the current level will be shown, and if set to true, the total time using Peacock will be shown.",
default: false,
},
officialAuthentication: {
desc: "Use official servers for contract downloading",
default: true,
},
autoSplitterCampaign: {
desc: "Which (main) campaign to use for the AutoSplitter. Can be set to 1, 2, 3, or 'trilogy'.",
default: "trilogy",
},
autoSplitterRacetimegg: {
desc: "When set to true, autosplitter is set in a special mode for use with livesplit integration for racetime.gg realtime races.",
default: false,
},
autoSplitterForceSilentAssassin: {
desc: "When set to true, the autosplitter will only accept missions completed with silent assassin to be valid completions. When false, any completion will split.",
default: true,
},
jokes: {
desc: "The Peacock server window will tell you a joke on startup if this is set to true.",
default: false,
},
leaderboardsHost: {
desc: "Please do not modify - intended for development only",
default: "https://backend.rdil.rocks",
},
leaderboards: {
desc: "Allow your times to be submitted to the ingame leaderboards. If you do not want your times on the leaderboards, change this to false.",
default: true,
},
updateChecking: {
desc: "Allow Peacock to check for updates on startup.",
default: true,
},
loadoutSaving: {
desc: "Default loadout mode - either PROFILES (loadout profiles) or LEGACY for per-user saving",
default: "PROFILES",
},
elusivesAreShown: {
desc: "Show elusive targets in instinct like normal targets would appear on normal missions. (for speedrunners who are submitting to speedrun.com, just as a reminder, this tool is for practice only!)",
default: false,
},
imageLoading: {
desc: "How images are loaded. SAVEASREQUESTED will fetch images from online when needed (and save them in the images folder), ONLINE will fetch them without saving, and OFFLINE will load them from the image folder",
default: "SAVEASREQUESTED",
},
overrideFrameworkChecks: {
desc: "Forcibly disable installed mod checks",
default: false,
},
}
const OLD_FLAGS_FILE = "flags.json5"
const NEW_FLAGS_FILE = "options.ini"
/**
* Get a flag from the flag file.
*
* @param flagId The flag's name.
* @returns The flag's value.
*/
export function getFlag(flagId: string): string | boolean | number {
return (
(flags[flagId] as string | boolean | number) ??
defaultFlags[flagId].default
)
}
/**
* At this point, you may be asking "what on Earth does this do?" - I completely understand.
*
* It should do something along the lines of generating a string that is the flags
* file with the appropriate comments (js-ini's stringify doesn't support them),
* and all the flags will either be the default value, or what they are set to already.
*/
const makeFlagsIni = (
_flags: IIniObject | { desc: string; default: string }[],
): string =>
Object.keys(defaultFlags)
.map((flagId) => {
return `; ${defaultFlags[flagId].desc}
${flagId} = ${_flags[flagId]}`
})
.join("\n\n")
/**
* Loads all flags.
*/
export function loadFlags(): void {
// somebody please, clean this method up, I hate it
if (existsSync(OLD_FLAGS_FILE)) {
log(
LogLevel.WARN,
"The flags file (flags.json5) has been revamped in the latest Peacock version, and we had to remove your settings.",
)
log(
LogLevel.INFO,
"You can take a look at the new options.ini file, which includes descriptions and more!",
)
unlinkSync(OLD_FLAGS_FILE)
}
if (!existsSync(NEW_FLAGS_FILE)) {
const allTheFlags = {}
Object.keys(defaultFlags).forEach((f) => {
allTheFlags[f] = defaultFlags[f].default
})
const ini = makeFlagsIni(allTheFlags)
writeFileSync(NEW_FLAGS_FILE, ini)
}
flags = parse(readFileSync(NEW_FLAGS_FILE).toString())
Object.keys(defaultFlags).forEach((key) => {
if (!Object.prototype.hasOwnProperty.call(flags, key)) {
flags[key] = defaultFlags[key].default
}
})
writeFileSync(NEW_FLAGS_FILE, makeFlagsIni(flags))
log(LogLevel.DEBUG, "Loaded flags.")
}
/**
* Get the values of all flags. Only intended for debugging purposes, since this could cause memory issues.
*
* @internal
* @return The flags.
*/
export function getAllFlags(): IIniObject {
return flags
}

258
components/hooksImpl.ts Normal file
View File

@ -0,0 +1,258 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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/>.
*/
/**
* A HookMap is a helper class for a Map with Hooks.
*
* @example
* const myHookMap = new HookMap(key => new SyncHook())
*
* @example
* hookMap.for("some-key").tap("MyPlugin", (arg) => { })
*
* @example
* const hook = hookMap.for("some-key")
* hook.call("some value", 123456)
*/
export class HookMap<Hook> {
private readonly _map: Map<string, Hook>
public constructor(private readonly _createFunc: (key: string) => Hook) {
this._map = new Map()
}
/**
* Get a hook for the given key.
*
* @param key The hook to get.
* @returns The hook.
*/
public for(key: string): Hook {
if (this._map.has(key)) {
return this._map.get(key)!
}
const hook = this._createFunc(key)
this._map.set(key, hook)
return hook
}
}
/**
* The options for a hook. Will either be just the name (as a string), or an object containing the additional options.
*/
export type TapOptions = string | { name: string; context: boolean }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AsArray<T> = T extends any[] ? T : [T]
/**
* An internal interface containing the properties held by a single taps' container object.
*/
interface Tap<T, R> {
name: string
func: (...args: AsArray<T>) => R
enableContext: boolean
}
/**
* The structure of an intercept.
*
* @see name
* @see call
* @see tap
*/
export interface Intercept<Params, Return> {
/**
* The name of the intercept.
*/
name: string
/**
* A function called just after the hook is called, and before all taps run.
*
* @param context The context object. Can be modified.
* @param params The parameters that the taps will get. Can be modified.
*/
call(context, ...params: AsArray<Params>): void
/**
* A function called when the hook is tapped. Note that it will not be called when an interceptor is registered, since that doesn't count as a tap.
*
* @param name The name of the tap.
* @param func The tap's function.
*/
tap(name: string, func: (...args: AsArray<Params>) => Return): void
}
/**
* The base for a hook, including {@link tap} and {@link intercept} functionality.
*
* @see SyncHook
* @see SyncBailHook
* @see AsyncSeriesHook
*/
export abstract class BaseImpl<Params, Return = void> {
protected _intercepts: Intercept<Params, Return>[]
protected _taps: Tap<Params, Return>[]
/**
* Register an interceptor.
* Interceptors can listen for certain events, and control things like context for them.
*
* @param intercept An object containing the intercept.
* @see Intercept
*/
public intercept(intercept: Intercept<Params, Return>): void {
this._intercepts.push(intercept)
}
/**
* Tap the hook.
*
* @param nameOrOptions A string containing the tap's name, or an object containing the tap's details.
* @param consumer The function that will be called when the hook is.
* @see TapOptions
*/
public tap(
nameOrOptions: TapOptions,
consumer: (...args: AsArray<Params>) => Return,
): void {
const name =
typeof nameOrOptions === "string"
? nameOrOptions
: nameOrOptions.name
const enableContext =
typeof nameOrOptions === "string" ? false : nameOrOptions.context
for (const intercept of this._intercepts) {
if (intercept.tap) {
intercept.tap(name, consumer)
}
}
this._taps.push({
name,
func: consumer,
enableContext,
})
}
}
/**
* A hook that runs each tap one-by-one.
*/
export class SyncHook<Params> extends BaseImpl<Params> {
public constructor() {
super()
this._taps = []
this._intercepts = []
}
public call(...params: AsArray<Params>): void {
const context = {}
for (const intercept of this._intercepts) {
if (intercept.call) {
intercept.call(context, ...params)
}
}
for (const tap of this._taps) {
const args = tap.enableContext ? [context, ...params] : [...params]
// @ts-expect-error TypeScript things.
tap.func(...args)
}
return
}
}
/**
* A hook that runs each tap one-by-one until one returns a result.
*/
export class SyncBailHook<Params, Return> extends BaseImpl<Params, Return> {
public constructor() {
super()
this._taps = []
this._intercepts = []
}
public call(...params: AsArray<Params>): Return | null {
const context = {}
for (const intercept of this._intercepts) {
if (intercept.call) {
intercept.call(context, ...params)
}
}
for (const tap of this._taps) {
const args = tap.enableContext ? [context, ...params] : [...params]
// @ts-expect-error TypeScript things.
const result = tap.func(...args)
if (result) {
return result
}
}
return null
}
}
/**
* A hook that runs each tap, one-by-one, in an async context (each tap may be an async function).
*/
export class AsyncSeriesHook<Params> extends BaseImpl<Params, Promise<void>> {
public constructor() {
super()
this._taps = []
this._intercepts = []
}
/**
* Async hooks cannot be called from a sync function - use {@link callAsync} instead!
* This function will only throw an error.
*
* @throws {Error} Always throws an error, see the note above.
* @deprecated
*/
public call(): Promise<void> {
throw new Error("Can't call an async hook with the sync method.")
}
public async callAsync(...params: AsArray<Params>): Promise<void> {
const context = {}
for (const intercept of this._intercepts) {
if (intercept.call) {
await intercept.call(context, ...params)
}
}
for (const tap of this._taps) {
const args = tap.enableContext ? [context, ...params] : [...params]
// @ts-expect-error TypeScript things.
await tap.func(...args)
}
}
}

View File

@ -0,0 +1,44 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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 { FileChangeInfo, watch } from "fs/promises"
/**
* Set up a listener for file/folder changes.
*
* @param target The file or folder name to watch.
* @param callback What to do when the hot updater is triggered.
*/
export async function setupHotListener(
target: string,
callback: (event: FileChangeInfo<string>) => void,
): Promise<void> {
try {
const watcher = watch(target, {})
for await (const event of watcher) {
callback(event)
}
} catch (err) {
if (err.name === "AbortError") {
return
}
throw err
}
}

630
components/index.ts Normal file
View File

@ -0,0 +1,630 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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/>.
*/
/* eslint-disable no-inner-declarations */
// noinspection RequiredAttributes
import { setFlagsFromString } from "v8"
import { program } from "commander"
import express, { Request, Router } from "express"
import http from "http"
import {
checkForUpdates,
extractToken,
handleAxiosError,
IS_LAUNCHER,
jokes,
PEACOCKVER,
PEACOCKVERSTRING,
ServerVer,
} from "./utils"
import { getConfig, getSwizzleable, swizzle } from "./configSwizzleManager"
import { handleOauthToken } from "./oauthToken"
import type {
RequestWithJwt,
S2CEventWithTimestamp,
ServerConnectionConfig,
} from "./types/types"
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"
import { join } from "path"
import { log, loggingMiddleware, LogLevel } from "./loggingInterop"
import { eventRouter } from "./eventHandler"
import { contractRoutingRouter } from "./contracts/contractRouting"
import { profileRouter } from "./profileHandler"
import { firstPassRouter, menuDataRouter } from "./menuData"
import { menuSystemRouter } from "./menus/menuSystem"
import { legacyEventRouter } from "./2016/legacyEventRouter"
import { legacyMenuSystemRouter } from "./2016/legacyMenuSystem"
import { _theLastYardbirdScpc, controller } from "./controller"
import {
STEAM_NAMESPACE_2016,
STEAM_NAMESPACE_2018,
STEAM_NAMESPACE_2021,
STEAM_NAMESPACE_SCPC,
} from "./platformEntitlements"
import { legacyProfileRouter } from "./2016/legacyProfileRouter"
import { legacyMenuDataRouter } from "./2016/legacyMenuData"
import { legacyContractRouter } from "./2016/legacyContractHandler"
import { getFlag, loadFlags } from "./flags"
import { initRp } from "./discordRp"
import random from "random"
import { generateUserCentric } from "./contracts/dataGen"
import { json as jsonMiddleware, urlencoded } from "body-parser"
import { loadoutRouter, loadouts } from "./loadouts"
import { setupHotListener } from "./hotReloadService"
import type { AxiosError } from "axios"
import serveStatic from "serve-static"
import { webFeaturesRouter } from "./webFeatures"
import { toolsMenu } from "./tools"
import picocolors from "picocolors"
import { multiplayerRouter } from "./multiplayer/multiplayerService"
import { multiplayerMenuDataRouter } from "./multiplayer/multiplayerMenuData"
import { pack, unpack } from "msgpackr"
// welcome to the bleeding edge
setFlagsFromString("--harmony")
const host = process.env.HOST || "0.0.0.0"
const port = process.env.PORT || 80
function uncaught(error: Error): void {
if (
(error.message || "").includes("EADDRINUSE") ||
(error.stack || "").includes("EADDRINUSE")
) {
log(LogLevel.ERROR, `Failed to use the server on ${host}:${port}!`)
log(
LogLevel.ERROR,
"This is likely due to one of the following reasons:",
)
log(LogLevel.ERROR, ` - Peacock is already running on this port`)
log(
LogLevel.ERROR,
` - Another app is already using this port (like IIS server)`,
)
log(
LogLevel.ERROR,
` - Your user account doesn't have permission (firewall can block it)`,
)
process.exit(1)
}
if ((error as AxiosError).isAxiosError) {
handleAxiosError(error as AxiosError)
}
log(LogLevel.ERROR, error.message)
error.stack && log(LogLevel.ERROR, error.stack)
}
process.on("uncaughtException", uncaught)
loadFlags()
const app = express()
app.use(loggingMiddleware)
app.use("/_wf", webFeaturesRouter)
if (getFlag("loadoutSaving") === "PROFILES") {
app.use("/loadouts", loadoutRouter)
}
app.get("/", (req: Request, res) => {
if (PEACOCK_DEV) {
res.send("dev active, you need to access the UI from port 3000")
} else {
const data = readFileSync("webui/dist/index.html").toString()
res.send(data)
}
})
app.use("/assets", serveStatic("webui/dist/assets"))
app.get(
"/config/:audience/:serverVersion(\\d+_\\d+_\\d+)",
(req: RequestWithJwt<{ issuer: string }>, res) => {
const proto = req.protocol
const config = getConfig("config", true) as ServerConnectionConfig
const serverhost = req.get("Host")
config.Versions[0].GAME_VER = req.params.serverVersion.startsWith("8")
? `${ServerVer._Major}.${ServerVer._Minor}.${ServerVer._Build}`
: req.params.serverVersion.startsWith("7")
? "7.17.0"
: "6.74.0"
if (req.params.serverVersion.startsWith("8")) {
config.Versions[0].SERVER_VER.GlobalAuthentication.RequestedAudience =
"pc-prod_8"
}
if (req.params.serverVersion.startsWith("7")) {
config.Versions[0].SERVER_VER.GlobalAuthentication.RequestedAudience =
"pc-prod_7"
}
if (req.params.serverVersion.startsWith("6")) {
config.Versions[0].SERVER_VER.GlobalAuthentication.RequestedAudience =
"pc-prod_6"
}
if (req.query.issuer === STEAM_NAMESPACE_2021) {
config.Versions[0].SERVER_VER.GlobalAuthentication.RequestedAudience =
"steam-prod_8"
}
if (req.params.audience === "scpc-prod") {
log(LogLevel.DEBUG, "Entering special mode.")
// sniper challenge is a different game/audience
config.Versions[0].Name = "scpc-prod"
config.Versions[0].GAME_VER = "7.3.0"
config.Versions[0].SERVER_VER.GlobalAuthentication.RequestedAudience =
"scpc-prod"
}
config.Versions[0].ISSUER_ID = req.query.issuer || "*"
config.Versions[0].SERVER_VER.Metrics.MetricsServerHost = `${proto}://${serverhost}`
config.Versions[0].SERVER_VER.Authentication.AuthenticationHost = `${proto}://${serverhost}`
config.Versions[0].SERVER_VER.Configuration.Url = `${proto}://${serverhost}/files/onlineconfig.json`
config.Versions[0].SERVER_VER.Configuration.AgreementUrl = `${proto}://${serverhost}/files/privacypolicy/hm3/privacypolicy.json`
config.Versions[0].SERVER_VER.Resources.ResourcesServicePath = `${proto}://${serverhost}/files`
config.Versions[0].SERVER_VER.GlobalAuthentication.AuthenticationHost = `${proto}://${serverhost}`
res.json(config)
},
)
app.get("/files/privacypolicy/hm3/privacypolicy_*.json", (req, res) => {
res.set("Content-Type", "application/octet-stream")
res.set("x-ms-meta-version", "20181001")
res.send(getConfig("privacypolicy", false))
})
app.post(
"/api/metrics/*",
jsonMiddleware(),
(req: RequestWithJwt<never, S2CEventWithTimestamp[]>, res) => {
req.body.forEach((event) => {
controller.hooks.newMetricsEvent.call(event, req)
})
res.send()
},
)
app.use("/oauth/token", urlencoded())
app.post("/oauth/token", (req: RequestWithJwt, res) =>
handleOauthToken(req, res),
)
app.get("/files/onlineconfig.json", (req, res) => {
res.set("Content-Type", "application/octet-stream")
res.send(getConfig("onlineconfig", false))
})
app.get(
"/profiles/page//dashboard//Dashboard_Category_Sniper_Singleplayer/00000000-0000-0000-0000-000000000015/Contract/ff9f46cf-00bd-4c12-b887-eac491c3a96d",
(req: RequestWithJwt, res) => {
res.json({
template: getConfig("FrankensteinMmSpTemplate", false),
data: {
Item: {
Id: "ff9f46cf-00bd-4c12-b887-eac491c3a96d",
Type: "Contract",
Title: "UI_CONTRACT_HAWK_TITLE",
Date: new Date().toISOString(),
Data: generateUserCentric(
_theLastYardbirdScpc,
req.jwt.unique_name,
"h1",
),
},
},
})
},
)
// We handle this for now, but it's not used. For the future though.
app.get(
"/profiles/page//dashboard//Dashboard_Category_Sniper_Multiplayer/00000000-0000-0000-0000-000000000015/Contract/ff9f46cf-00bd-4c12-b887-eac491c3a96d",
(req: RequestWithJwt, res) => {
const template = getConfig("FrankensteinMmMpTemplate", false)
/* To enable multiplayer:
* Change MultiplayerNotSupported to false
* NOTE: REMOVING THIS FULLY WILL BREAK THE EDITED TEMPLATE!
*/
res.json({
template: template,
data: {
Item: {
Id: "ff9f46cf-00bd-4c12-b887-eac491c3a96d",
Type: "Contract",
Title: "UI_CONTRACT_HAWK_TITLE",
Date: new Date().toISOString(),
Disabled: true,
Data: {
...generateUserCentric(
_theLastYardbirdScpc,
req.jwt.unique_name,
"h1",
),
...{ MultiplayerNotSupported: true },
},
},
},
})
},
)
// NOTE! All routes attached after this point will be checked for a JWT or blob signature.
// If you are adding a route that does NOT require authentication, put it ABOVE this message!
app.use(
Router()
.use(
"/resources-:serverVersion(\\d+-\\d+)/",
(req: RequestWithJwt, res, next) => {
req.serverVersion = req.params.serverVersion
req.gameVersion = req.serverVersion.startsWith("8")
? "h3"
: req.serverVersion.startsWith("7") &&
// prettier-ignore
req.serverVersion !== "7.3.0"
? // prettier-ignore
"h2"
: // prettier-ignore
"h1"
if (req.serverVersion === "7.3.0") {
req.gameVersion = "scpc"
}
next("router")
},
)
// we're fine with skipping to the next router if we don't have auth
.use(extractToken, (req: RequestWithJwt, res, next) => {
switch (req.jwt?.pis) {
case "egp_io_interactive_hitman_the_complete_first_season":
case STEAM_NAMESPACE_2016:
case STEAM_NAMESPACE_SCPC:
req.serverVersion = "6-74"
break
case STEAM_NAMESPACE_2018:
req.serverVersion = "7-17"
break
case "fghi4567xQOCheZIin0pazB47qGUvZw4":
case STEAM_NAMESPACE_2021:
req.serverVersion = "8-10"
break
default:
res.status(400).json({ message: "no game data" })
return
}
req.gameVersion = req.serverVersion.startsWith("8")
? "h3"
: req.serverVersion.startsWith("7")
? "h2"
: "h1"
if (req.jwt?.aud === "scpc-prod") {
req.gameVersion = "scpc"
}
next()
}),
)
function generateBlobConfig(req: RequestWithJwt) {
return {
bloburl: `${req.protocol}://${req.get("Host")}/resources-${
req.serverVersion
}/`,
blobsig: `?sv=2018-03-28&ver=${req.gameVersion}`,
blobsigduration: 7200000.0,
}
}
app.get(
"/authentication/api/configuration/Init?*",
extractToken,
(req: RequestWithJwt, res) => {
// configName=pc-prod&lockedContentDisabled=false&isFreePrologueUser=false&isIntroPackUser=false&isFullExperienceUser=false
res.json({
token: `${req.jwt.exp}-${req.jwt.nbf}-${req.jwt.platform}-${req.jwt.userid}`,
blobconfig: generateBlobConfig(req),
profileid: req.jwt.unique_name,
serverversion: `${ServerVer._Major}.${ServerVer._Minor}.${ServerVer._Build}.${ServerVer._Revision}`,
servertimeutc: new Date().toISOString(),
ias: 2,
})
},
)
app.post(
"/authentication/api/userchannel/AuthenticationService/RenewBlobSignature",
(req: RequestWithJwt, res) => {
res.json(generateBlobConfig(req))
},
)
const legacyRouter = Router()
const primaryRouter = Router()
legacyRouter.use(
"/authentication/api/userchannel/EventsService/",
legacyEventRouter,
)
legacyRouter.use("/resources-(\\d+-\\d+)/", legacyMenuSystemRouter)
legacyRouter.use("/authentication/api/userchannel/", legacyProfileRouter)
legacyRouter.use("/profiles/page/", legacyMenuDataRouter)
legacyRouter.use(
"/authentication/api/userchannel/ContractsService/",
legacyContractRouter,
)
legacyRouter.use(
"/authentication/api/userchannel/ContractSessionsService/",
legacyContractRouter,
)
primaryRouter.use(
"/authentication/api/userchannel/MultiplayerService/",
multiplayerRouter,
)
primaryRouter.use("/authentication/api/userchannel/EventsService/", eventRouter)
primaryRouter.use(
"/authentication/api/userchannel/ContractsService/",
contractRoutingRouter,
)
primaryRouter.use("/authentication/api/userchannel/", profileRouter)
primaryRouter.use("/profiles/page/", firstPassRouter)
primaryRouter.use("/profiles/page", multiplayerMenuDataRouter)
primaryRouter.use("/profiles/page/", menuDataRouter)
primaryRouter.use("/resources-(\\d+-\\d+)/", menuSystemRouter)
app.use(
Router()
.use((req: RequestWithJwt, res, next) => {
if (req.shouldCease) {
return next("router")
}
if (req.serverVersion === "6-74" || req.serverVersion === "7-3") {
return next() // continue along h1router
}
next("router")
})
.use(legacyRouter),
Router()
.use((req: RequestWithJwt, res, next) => {
if (req.shouldCease) {
return next("router")
}
if (
["6-74", "7-3", "7-17", "8-10"].includes(
<string>req.serverVersion,
)
) {
return next() // continue along h3 router
}
next("router")
})
.use(primaryRouter),
)
app.all("*", (req, res) => {
log(LogLevel.WARN, `Unhandled URL: ${req.url}`)
res.status(404).send("Not found!")
})
program.description(
"The Peacock Project is a HITMAN™ World of Assassination Trilogy server built for general use.",
)
const PEECOCK_ART = `
`
function startServer(options: { hmr: boolean; pluginDevHost: boolean }): void {
checkForUpdates()
if (!IS_LAUNCHER) {
console.log(
Math.random() < 0.001
? PEECOCK_ART
: picocolors.greenBright(`
`),
)
}
log(
LogLevel.INFO,
`This is Peacock v${PEACOCKVERSTRING} (rev ${PEACOCKVER}), with Node v${process.versions.node}.`,
)
// jokes lol
if (getFlag("jokes") === true) {
log(
LogLevel.INFO,
picocolors.yellowBright(
`${jokes[random.int(0, jokes.length - 1)]}`,
),
)
}
// make sure required folder structure is in place
for (const dir of [
"contractSessions",
"userdata",
"contracts",
join("userdata", "epicids"),
join("userdata", "steamids"),
join("userdata", "users"),
join("userdata", "h1", "steamids"),
join("userdata", "h1", "epicids"),
join("userdata", "h1", "users"),
join("userdata", "h2", "steamids"),
join("userdata", "h2", "users"),
join("userdata", "scpc", "users"),
join("userdata", "scpc", "steamids"),
join("images", "actors"),
]) {
if (existsSync(dir)) {
continue
}
log(LogLevel.DEBUG, `Creating missing directory ${dir}`)
mkdirSync(dir, { recursive: true })
}
if (options.hmr) {
log(LogLevel.DEBUG, "Experimental HMR enabled.")
// eslint-disable-next-line @typescript-eslint/require-await
setupHotListener("contracts", () => {
log(LogLevel.INFO, "Detected a change in contracts! Re-indexing...")
controller.index()
})
}
// once contracts directory is present, we are clear to boot
loadouts.init()
controller.boot(options.pluginDevHost)
const httpServer = http.createServer(app)
// @ts-expect-error Non-matching method sig
httpServer.listen(port, host)
log(LogLevel.INFO, "Server started.")
if (getFlag("discordRp") === true) {
initRp()
}
}
program.option("--hmr", "enable experimental hot reloading of contracts")
program.option(
"--plugin-dev-host",
"activate plugin development features - requires plugin dev workspace setup",
)
program.action(startServer)
program
.command("swizzle")
.option(
"-c, --config <name>",
"the config file to generate an override for",
"",
)
.option("--list", "get a list of config files that can be overridden")
.description(
"generates a file that overrides its internal config counterpart",
)
.action((args: { list: boolean; config: string }) => {
if (args.list) {
log(LogLevel.INFO, "The following configurations can be swizzled:")
getSwizzleable().forEach((swizzleable) => {
log(LogLevel.INFO, ` - ${swizzleable}`)
})
return
}
// doesn't want list, but hasn't specified a swizzleable
if (!args.list && args.config === "") {
log(LogLevel.ERROR, "No config specified! - Aborting.")
return process.exit(1)
}
return swizzle(args.config)
})
program
.command("tools")
.description("open the tools UI")
.action(() => {
toolsMenu()
})
program
.command("pack")
.argument("<input>", "input file to pack")
.option("-o, --output <path>", "where to output the packed file to", "")
.description("packs an input file into a Challenge Resource Package")
.action((input, options: { output: string }) => {
const outputPath =
options.output !== ""
? options.output
: input.replace(/\.[^/\\.]+$/, ".crp")
writeFileSync(
outputPath,
pack(JSON.parse(readFileSync(input).toString())),
)
log(LogLevel.INFO, `Packed "${input}" to "${outputPath}" successfully.`)
})
program
.command("unpack")
.argument("<input>", "input file to unpack")
.option("-o, --output <path>", "where to output the unpacked file to", "")
.description("unpacks a Challenge Resource Package")
.action((input, options: { output: string }) => {
const outputPath =
options.output !== ""
? options.output
: input.replace(/\.[^/\\.]+$/, ".json")
writeFileSync(outputPath, JSON.stringify(unpack(readFileSync(input))))
log(
LogLevel.INFO,
`Unpacked "${input}" to "${outputPath}" successfully.`,
)
})
program.parse(process.argv)

262
components/inventory.ts Normal file
View File

@ -0,0 +1,262 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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 { getVersionedConfig } from "./configSwizzleManager"
import type { GameVersion, Unlockable } from "./types/types"
import {
brokenItems,
DELUXE_UNLOCKABLES,
EXECUTIVE_UNLOCKABLES,
H1_GOTY_UNLOCKABLES,
H1_REQUIEM_UNLOCKABLES,
H2_RACCOON_STINGRAY_UNLOCKABLES,
SIN_ENVY_UNLOCKABLES,
SIN_GLUTTONY_UNLOCKABLES,
SIN_GREED_UNLOCKABLES,
SIN_LUST_UNLOCKABLES,
SIN_PRIDE_UNLOCKABLES,
SIN_SLOTH_UNLOCKABLES,
SIN_WRATH_UNLOCKABLES,
TRINITY_UNLOCKABLES,
WINTERSPORTS_UNLOCKABLES,
} from "./ownership"
import { EPIC_NAMESPACE_2016 } from "./platformEntitlements"
/**
* An inventory item.
*/
export interface InventoryItem {
InstanceId: string
ProfileId: string
Unlockable: Unlockable
Properties: Record<string, string>
}
const inventoryUserCache: Map<string, InventoryItem[]> = new Map()
/**
* Clears a user's inventory.
*
* @param userId The user's ID.
*/
export function clearInventoryFor(userId: string): void {
inventoryUserCache.delete(userId)
}
/**
* Clears the entire inventory cache.
*/
export function clearInventoryCache(): void {
inventoryUserCache.clear()
}
export function createInventory(
profileId: string,
gameVersion: GameVersion,
entP: string[],
): InventoryItem[] {
if (inventoryUserCache.has(profileId)) {
return inventoryUserCache.get(profileId)!
}
// add all unlockables to player's inventory
const allunlockables = getVersionedConfig<Unlockable[]>(
"allunlockables",
gameVersion,
true,
).filter((u) => u.Type !== "location") // locations not in inventory
// ts-expect-error It cannot be undefined.
const filtered: InventoryItem[] = allunlockables
.map((unlockable) => {
if (brokenItems.includes(unlockable.Guid)) {
return undefined
}
if (unlockable.Guid === "1efe1010-4fff-4ee2-833e-7c58b6518e3e") {
unlockable.Properties.Name =
"char_reward_hero_halloweenoutfit_m_pro140008_name_ebf1e362-671f-47e8-8c88-dd490d8ad866"
unlockable.Properties.Description =
"char_reward_hero_halloweenoutfit_m_pro140008_description_ebf1e362-671f-47e8-8c88-dd490d8ad866"
}
unlockable.GameAsset = null
unlockable.DisplayNameLocKey = `UI_${unlockable.Id}_NAME`
return {
InstanceId: unlockable.Guid,
ProfileId: profileId,
Unlockable: unlockable,
Properties: {},
}
})
// filter again, this time removing legacy unlockables
.filter((unlockContainer) => {
if (!unlockContainer) {
return false
}
if (gameVersion === "h1") {
return true
}
const e = entP
const { Id: id } = unlockContainer!.Unlockable
if (!e) {
return false
}
if (gameVersion === "h3") {
if (WINTERSPORTS_UNLOCKABLES.includes(id)) {
return (
e.includes("afa4b921503f43339c360d4b53910791") ||
e.includes("1829590")
)
}
if (EXECUTIVE_UNLOCKABLES.includes(id)) {
return (
e.includes("6408de14f7dc46b9a33adcf6cbc4d159") ||
e.includes("afa4b921503f43339c360d4b53910791") ||
e.includes("1829590")
)
}
if (H1_REQUIEM_UNLOCKABLES.includes(id)) {
return (
e.includes("e698e1a4b63947b0bc9349a5ae2dc015") ||
e.includes("1843460")
)
}
if (H1_GOTY_UNLOCKABLES.includes(id)) {
return (
e.includes("894d1e6771044f48a8fdde934b8e443a") ||
e.includes("1843460") ||
e.includes("1829595")
)
}
if (H2_RACCOON_STINGRAY_UNLOCKABLES.includes(id)) {
return (
e.includes("afa4b921503f43339c360d4b53910791") ||
e.includes("1829590")
)
}
} else if (gameVersion === "h2") {
if (WINTERSPORTS_UNLOCKABLES.includes(id)) {
return e.includes("957693")
}
} else if (
// @ts-expect-error The types do actually overlap, but there is no way to show that.
gameVersion === "h1" &&
(e.includes("0a73eaedcac84bd28b567dbec764c5cb") ||
e.includes(EPIC_NAMESPACE_2016))
) {
// h1 EGS
if (
H1_REQUIEM_UNLOCKABLES.includes(id) ||
H1_GOTY_UNLOCKABLES.includes(id)
) {
return e.includes("81aecb49a60b47478e61e1cbd68d63c5")
}
}
if (DELUXE_UNLOCKABLES.includes(id)) {
return (
e.includes("bc610b36c75442299edcbe99f6f0fb60") ||
e.includes("1829591")
)
}
/*
TODO: Fix this entitlement check (confirmed its broken with Blazer)
if (LEGACY_UNLOCKABLES.includes(id)) {
return (
e.includes("0b59243cb8aa420691b66be1ecbe68c0") ||
e.includes("1829593")
)
}
*/
if (SIN_GREED_UNLOCKABLES.includes(id)) {
return (
e.includes("0e8632b4cdfb415e94291d97d727b98d") ||
e.includes("1829580")
)
}
if (SIN_PRIDE_UNLOCKABLES.includes(id)) {
return (
e.includes("3f9adc216dde44dda5e829f11740a0a2") ||
e.includes("1829581")
)
}
if (SIN_SLOTH_UNLOCKABLES.includes(id)) {
return (
e.includes("aece009ff59441c0b526f8aa69e24cfb") ||
e.includes("1829582")
)
}
if (SIN_LUST_UNLOCKABLES.includes(id)) {
return (
e.includes("dfe5aeb89976450ba1e0e2c208b63d33") ||
e.includes("1829583")
)
}
if (SIN_GLUTTONY_UNLOCKABLES.includes(id)) {
return (
e.includes("30107bff80024d1ab291f9cd3bac9fac") ||
e.includes("1829584")
)
}
if (SIN_ENVY_UNLOCKABLES.includes(id)) {
return (
e.includes("0403062df0d347619c8dcf043c65c02e") ||
e.includes("1829585")
)
}
if (SIN_WRATH_UNLOCKABLES.includes(id)) {
return (
e.includes("9e936ed2507a473db6f53ad24d2da587") ||
e.includes("1829586")
)
}
if (TRINITY_UNLOCKABLES.includes(id)) {
return (
e.includes("5d06a6c6af9b4875b3530d5328f61287") ||
e.includes("1829596")
)
}
return true
})
for (const unlockable of filtered) {
unlockable!.ProfileId = profileId
}
inventoryUserCache.set(profileId, filtered)
return filtered
}

View File

@ -0,0 +1,379 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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 { Socket } from "net"
import { EventEmitter } from "events"
export type LiveSplitResult = Promise<string | boolean | undefined>
/**
* Node.js client for the LiveSplit Server running instance.
*
* @see https://github.com/LiveSplit/LiveSplit.Server LiveSplit server component
* @see https://github.com/satanch/livesplit-node-client Original source code
* @author satanch (https://github.com/satanch)
* @license MIT
*/
export class LiveSplitClient extends EventEmitter {
timeout: number
private readonly _connectionDetails: {
ip: string
port: number
}
private _connected: boolean
private _initGameTimeOnce: boolean
private _socket: Socket | undefined
/**
* Creates a new instance.
*
* @param address - Connection address, in the format of 127.0.0.1:1234.
*/
constructor(address: string) {
super()
const formatted: string[] = address.split(":")
if (formatted.length !== 2) {
throw new Error(
"Failed to parse connection details! IP:PORT expected.",
)
}
this._connectionDetails = {
ip: formatted[0],
port: parseInt(formatted[1]),
}
this._connected = false
this.timeout = 100
/*
According to: https://github.com/LiveSplit/LiveSplit.Server/blob/a4a57716dce90936606bfc8f8ac84f7623773aa5/README.md#commands
When using Game Time, it's important that you call "initgametime" once. Once "initgametime" is used, an additional comparison will appear, and you can switch to it via the context menu (Compare Against > Game Time). This special comparison will show everything based on the Game Time (every component now shows Game Time based information).
*/
this._initGameTimeOnce = false
}
/**
* Checks that no disallowed symbols are present in the command.
*
* @param str The command.
* @throws {Error} If the command includes `\r\n`.
*/
private static _checkDisallowedSymbols(str: string): void {
if (str.indexOf("\r\n") !== -1) {
throw new Error("No newline symbols allowed!")
}
}
/**
* Performs connection attempt to the LiveSplit Server instance.
*/
connect(): Promise<boolean> {
this._socket = new Socket()
return new Promise((resolve, reject) => {
this._socket!.connect(
this._connectionDetails.port,
this._connectionDetails.ip,
() => {
this._connected = true
this.emit("connected")
resolve(this._connected)
},
)
this._socket!.on("data", (data) => {
// noinspection TypeScriptValidateJSTypes
this.emit("data", data.toString("utf-8").replace("\r\n", ""))
})
this._socket!.on("error", (err) => {
reject(err)
})
this._socket!.on("close", () => {
this._connected = false
this.emit("disconnected")
})
})
}
/**
* Disconnect client from the server.
*/
disconnect(): boolean {
if (!this._connected) {
return false
}
this._socket?.destroy()
this._connected = false
return true
}
/**
* Send command to the LiveSplit Server instance.
*/
async send(command: string, expectResponse = true): LiveSplitResult {
if (!this._connected) {
throw new Error("Client must be connected to the server!")
}
LiveSplitClient._checkDisallowedSymbols(command)
this._socket?.write(`${command}\r\n`)
if (expectResponse) {
return await this._waitForResponse()
}
return true
}
/**
* Start the timer.
*/
async startTimer(): LiveSplitResult {
return await this.send("starttimer", false)
}
/**
* Start or split.
*/
async startOrSplit(): LiveSplitResult {
return await this.send("startorsplit", false)
}
/**
* Split.
*/
async split(): LiveSplitResult {
return await this.send("split", false)
}
/**
* Unsplit.
*/
async unsplit(): LiveSplitResult {
return await this.send("unsplit", false)
}
/**
* Skip split.
*/
async skipSplit(): LiveSplitResult {
return await this.send("skipsplit", false)
}
/**
* Pause.
*/
async pause(): LiveSplitResult {
return await this.send("pause", false)
}
/**
* Resume.
*/
async resume(): LiveSplitResult {
return await this.send("resume", false)
}
/**
* Reset.
*/
async reset(): LiveSplitResult {
return await this.send("reset", false)
}
/**
* Init game time. Can only be called once according to LiveSplit Server documentation.
*/
async initGameTime(): LiveSplitResult {
if (this._initGameTimeOnce) {
return false
}
this._initGameTimeOnce = true
return await this.send("initgametime", false)
}
/**
* Set game time.
*
* @param time Game time.
*/
async setGameTime(time: string): LiveSplitResult {
return await this.send(`setgametime ${time}`, false)
}
/**
* Set loading times.
*
* @param time Loading times.
*/
async setLoadingTimes(time: string): LiveSplitResult {
return await this.send(`setloadingtimes ${time}`, false)
}
/**
* Pause game time.
*/
async pauseGameTime(): LiveSplitResult {
return await this.send("pausegametime", false)
}
/**
* Unpause game time.
*/
async unpauseGameTime(): LiveSplitResult {
return await this.send("unpausegametime", false)
}
/**
* Set comparison.
*
* @param comparison The comparison.
*/
async setComparison(comparison: string): LiveSplitResult {
return await this.send(`setcomparison ${comparison}`, false)
}
/**
* Get delta.
*
* @param comparison The comparison.
*/
async getDelta(comparison = ""): LiveSplitResult {
if (comparison.length > 0) {
comparison = ` ${comparison}`
}
return await this.send(`getdelta${comparison}`, true)
}
/**
* Get last split time.
*/
async getLastSplitTime(): LiveSplitResult {
return await this.send("getlastsplittime", true)
}
/**
* Get comparison split time.
*/
async getComparisonSplitTime(): LiveSplitResult {
return await this.send("getcomparisonsplittime", true)
}
/**
* Get the current time.
*/
async getCurrentTime(): LiveSplitResult {
return await this.send("getcurrenttime", true)
}
/**
* Get the final time.
*
* @param comparison The comparison.
*/
async getFinalTime(comparison = ""): LiveSplitResult {
if (comparison.length > 0) {
comparison = ` ${comparison}`
}
return await this.send(`getfinaltime${comparison}`, true)
}
/**
* Get predicted time.
*
* @param comparison The comparison.
*/
async getPredictedTime(comparison: string): LiveSplitResult {
return await this.send(`getpredictedtime ${comparison}`, true)
}
/**
* Get the best possible time.
*/
async getBestPossibleTime(): LiveSplitResult {
return await this.send("getbestpossibletime", true)
}
/**
* Get split index.
*/
async getSplitIndex(): LiveSplitResult {
return await this.send("getsplitindex", true)
}
/**
* Get current split name.
*/
async getCurrentSplitName(): LiveSplitResult {
return await this.send("getcurrentsplitname", true)
}
/**
* Get previous split name.
*/
async getPreviousSplitname(): LiveSplitResult {
return await this.send("getprevioussplitname", true)
}
/**
* Get current timer phase.
*/
async getCurrentTimerPhase(): LiveSplitResult {
return await this.send("getcurrenttimerphase", true)
}
/**
* @internal
*/
private async _waitForResponse(): LiveSplitResult {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let listener: ((...args: any[]) => void) | undefined = undefined
const responseRecieved = new Promise<Awaited<LiveSplitResult>>(
(resolve) => {
listener = (data) => {
resolve(data)
}
this.once("data", listener)
},
)
const responseTimeout = new Promise<Awaited<LiveSplitResult>>(
(resolve) => {
setTimeout(() => {
this.removeListener("data", listener)
resolve(undefined)
}, this.timeout)
},
)
return await Promise.race([responseRecieved, responseTimeout])
}
}

View File

@ -0,0 +1,446 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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 { LiveSplitClient, LiveSplitResult } from "./liveSplitClient"
import { log, LogLevel } from "../loggingInterop"
import { getAllCampaigns } from "../menus/campaigns"
import { Campaign, GameVersion, IHit, Seconds, StoryData } from "../types/types"
import { getFlag } from "../flags"
export class LiveSplitManager {
private readonly _liveSplitClient: LiveSplitClient
// https://youtrack.jetbrains.com/issue/WEB-54745
// noinspection TypeScriptFieldCanBeMadeReadonly
private _initialized: boolean
private _initializationAttempted: boolean
private _resetMinimum: Seconds
private _currentCampaign: string[]
private _inValidCampaignRun: boolean
private _currentMission: string | undefined
private _currentMissionTotalTime: number
private _campaignTotalTime: number
private _completedMissions: string[]
private _raceMode: boolean | undefined // gets late-initialized, use _isRaceMode to access
constructor() {
this._initialized = false
this._initializationAttempted = false
this._resetMinimum = 1
this._currentMission = undefined
this._inValidCampaignRun = false
this._completedMissions = []
this._currentMissionTotalTime = 0
this._campaignTotalTime = 0
this._raceMode = undefined
this._liveSplitClient = new LiveSplitClient("127.0.0.1:16834")
this.init()
.then(() => {
this._initialized = true
this._initializationAttempted = true
return
})
.catch((e) => {
log(LogLevel.DEBUG, "Failed to initialize LiveSplit: ")
log(LogLevel.DEBUG, e)
this._initializationAttempted = true
})
}
/*
* PUBLIC INTERFACE
*/
missionIntentResolved(contractId: string, startId: string): void {
if (
LiveSplitManager._isClub27(contractId) &&
LiveSplitManager._isBangkokDefaultStartLocation(startId)
) {
this._resetMinimum = 10
return
}
this._resetMinimum = 0
}
async startMission(
contractId: string,
gameVersion: GameVersion,
userId: string,
) {
if (!this._checkInit()) {
return
}
const campaign = getCampaignMissions(contractId, gameVersion, userId)
if (campaign === undefined) {
this._invalidateRun(contractId)
return
}
this._currentCampaign = campaign
const isStartOfCampaign =
this._currentCampaign.indexOf(contractId) === 0
if (isStartOfCampaign) {
this._currentMission = contractId
await this._resetCampaign()
await this._pushGameTime()
return
}
if (this._inValidCampaignRun) {
const numComplete = this._completedMissions.length
if (contractId === this._currentMission) {
const lastCompleted = this._completedMissions[numComplete - 1]
if (contractId === lastCompleted) {
// for whatever reason, previous mission completed but still resetting,
// un-split. total time is not reset on mission complete, so it should still be valid.
// do pop the completed mission though as we're entering a new attempt
this._completedMissions.pop()
if (!this._isRaceMode) {
logLiveSplitError(
await this._liveSplitClient.unsplit(),
"unsplit",
)
logLiveSplitError(
await this._liveSplitClient.startTimer(),
"startTimer",
)
logLiveSplitError(
await this._liveSplitClient.pauseGameTime(),
"pauseGameTime",
)
}
}
await this._pushGameTime()
// was a reset, don't need to do anything more
return
}
// we've started a new mission
// figure out what the current mission should be based on the active campaign and
// the previous completed mission
const nextCampaignMission = this._currentCampaign[numComplete]
if (contractId === nextCampaignMission) {
// if it does match, we're on a valid next mission so reset the state
this._currentMission = contractId
this._currentMissionTotalTime = 0
await this._pushGameTime()
if (!this._isRaceMode) {
logLiveSplitError(
await this._liveSplitClient.startTimer(),
"startTimer",
)
logLiveSplitError(
await this._liveSplitClient.pauseGameTime(),
"pauseGameTime",
)
}
} else {
// if it doesn't match, invalidate the current run
this._invalidateRun(contractId)
}
}
}
async failMission(attemptTime: Seconds) {
if (!this._checkInit()) {
return
}
if (this._inValidCampaignRun) {
this._addMissionTime(attemptTime)
LiveSplitManager._logAttempt(attemptTime)
await this._pushGameTime()
}
}
async completeMission(attemptTime: Seconds) {
LiveSplitManager._logAttempt(attemptTime)
if (!this._checkInit()) {
return
}
if (this._inValidCampaignRun) {
this._addMissionTime(attemptTime)
log(
LogLevel.INFO,
`Total mission time with resets: ${this._currentMissionTotalTime}`,
)
this._completedMissions.push(this._currentMission)
await this._pushGameTime()
if (this._isRaceMode) {
if (
this._completedMissions.length ===
this._currentCampaign.length
) {
// Campaign is complete, racetimegg in livesplit uses first split as completion
logLiveSplitError(
await this._liveSplitClient.split(),
"split",
)
logLiveSplitError(
await this._liveSplitClient.pause(),
"pause",
)
const flooredTime = Math.floor(this._campaignTotalTime)
const minutes = Math.floor(flooredTime / 60)
const seconds = Math.floor(flooredTime % 60)
log(
LogLevel.INFO,
`Total campaign in-game time with resets: ${minutes}:${seconds}`,
)
}
} else {
logLiveSplitError(await this._liveSplitClient.split(), "split")
if (
this._completedMissions.length ===
this._currentCampaign.length
) {
// Pause real time timer because campaign is potentially complete
logLiveSplitError(
await this._liveSplitClient.pause(),
"pause",
)
}
}
// purposely do not reset this._currentMissionTotalTime yet in case mission complete
// but runner still wants to reset (could happen if lose SA at the end of a fast map like Dubai)
}
}
/*
* PRIVATE METHODS
*/
private async init() {
logLiveSplitError(await this._liveSplitClient.connect(), "connect")
logLiveSplitError(
await this._liveSplitClient.initGameTime(),
"initGameTime",
)
logLiveSplitError(
await this._liveSplitClient.pauseGameTime(),
"pauseGameTime",
)
}
private _checkInit(): boolean {
if (!this._initializationAttempted) {
log(
LogLevel.ERROR,
"Tried to perform LiveSplit action before LiveSplit initialization finished.",
)
}
return this._initialized
}
private static _logAttempt(attemptTime: number): void {
log(
LogLevel.INFO,
`Time (with milliseconds): ${Math.floor(
attemptTime,
)} (${attemptTime})`,
)
}
private get _isRaceMode(): boolean {
if (this._raceMode === undefined) {
this._raceMode = getFlag("autoSplitterRacetimegg") === true
}
return this._raceMode as boolean
}
private async _invalidateRun(contractId: string) {
log(
LogLevel.INFO,
"Entered invalid mission for current campaign state, invalidating autosplitter run. Start first mission of a campaign to reset and start a new run.",
)
const nextCampaignMission =
this._currentCampaign[this._completedMissions.length]
log(
LogLevel.INFO,
"Detected campaign missions: " + this._currentCampaign,
)
log(LogLevel.INFO, "Completed missions: " + this._completedMissions)
log(
LogLevel.INFO,
"Next campaign mission detected: " + nextCampaignMission,
)
log(LogLevel.INFO, "Attempted to start mission: " + contractId)
this._inValidCampaignRun = false
// if we're in race mode, we don't pause the timer because the user can just manually complete the run if they need to
// and verification will take care of the validity
if (!this._isRaceMode) {
logLiveSplitError(await this._liveSplitClient.pause(), "pause")
}
}
private async _resetCampaign() {
log(LogLevel.INFO, "Starting new autosplitter campaign.")
log(
LogLevel.INFO,
`Detected campaign missions: ${this._currentCampaign}`,
)
this._completedMissions = []
this._inValidCampaignRun = true
this._currentMissionTotalTime = 0
this._campaignTotalTime = 0
// race mode does this automatically with racetimegg integration
if (!this._isRaceMode) {
logLiveSplitError(await this._liveSplitClient.reset(), "reset")
logLiveSplitError(
await this._liveSplitClient.startTimer(),
"startTimer",
)
logLiveSplitError(
await this._liveSplitClient.pauseGameTime(),
"pauseGameTime",
)
}
}
private static _isClub27(contractId: string): boolean {
return [
"db341d9f-58a4-411d-be57-0bc4ed85646b",
"ad5f9051-045d-4b8e-8a4d-d84429f467f8",
].includes(contractId)
}
private static _isBangkokDefaultStartLocation(
startLocationId: string,
): boolean {
return [
"9ddbd515-2519-4c16-98aa-0f87af5d8ef5",
// maybe more?
].includes(startLocationId)
}
private async _setGameTime(totalTime: Seconds) {
// IMPORTANT to floor to int before sending to livesplit or else parsing will fail silently...
const flooredTime = Math.floor(totalTime)
const minutes = Math.floor(flooredTime / 60)
const seconds = Math.floor(flooredTime % 60)
logLiveSplitError(
await this._liveSplitClient.pauseGameTime(),
"pauseGameTime",
)
logLiveSplitError(
await this._liveSplitClient.setGameTime(`${minutes}:${seconds}.00`),
"setGameTime",
)
}
private _addMissionTime(time: Seconds) {
// always add at least minimum
if (time <= this._resetMinimum) {
this._currentMissionTotalTime += this._resetMinimum
this._campaignTotalTime += this._resetMinimum
} else if (time > 0 && time <= 1) {
// if in game time is between 0 and 1, add full second
this._currentMissionTotalTime += 1
this._campaignTotalTime += 1
} else {
// important to always floor before adding time
this._currentMissionTotalTime += Math.floor(time)
this._campaignTotalTime += Math.floor(time)
}
}
private async _pushGameTime() {
if (!this._initialized) {
return
}
await this._setGameTime(this._campaignTotalTime)
}
}
function logLiveSplitError(
result: Awaited<LiveSplitResult>,
failureCall: string,
) {
if (!result) {
log(
LogLevel.ERROR,
"LiveSplit Server internal error on: " + failureCall,
)
}
}
function getCampaignMissions(
missionId: string,
gameVersion: GameVersion,
userId: string,
): string[] | undefined {
// find the campaign this mission is in
const allCampaigns = getAllCampaigns(gameVersion, userId)
const findNamedCampaign = (name: string): Campaign | undefined => {
return allCampaigns.find((campaign) => campaign.Name === name)
}
const campaignMissionIds = (campaignStoryData: StoryData[]): string[] => {
return campaignStoryData
.filter((data) => data.Type === "Mission")
.map((sd) => (sd.Data as IHit).Id)
}
// the trilogy is the only place where multiple campaigns are merged together
const trilogy: string[] = campaignMissionIds([
...(findNamedCampaign("UI_SEASON_1")?.StoryData ?? []),
...(findNamedCampaign("UI_SEASON_2")?.StoryData ?? []),
...(findNamedCampaign("UI_SEASON_3")?.StoryData ?? []),
])
if (trilogy.indexOf(missionId) === -1) {
// not in the trilogy, fall back to all campaigns
return allCampaigns
.map((c) => campaignMissionIds(c.StoryData ?? []))
.find((mc) => mc.includes(missionId))
} else {
const campaignFlag = getFlag("autoSplitterCampaign") as string | number
switch (campaignFlag) {
case 1:
return trilogy.slice(0, 6)
case 2:
return trilogy.slice(6, 14)
case 3:
return trilogy.slice(14, trilogy.length)
default:
return trilogy
}
}
}
export const liveSplitManager = new LiveSplitManager()

277
components/loadouts.ts Normal file
View File

@ -0,0 +1,277 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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 { existsSync, readFileSync, writeFileSync } from "fs"
import type {
GameVersion,
Loadout,
LoadoutFile,
LoadoutsGameVersion,
} from "./types/types"
import { Request, Router } from "express"
import { json as jsonMiddleware } from "body-parser"
import { writeFile } from "atomically"
import { nanoid } from "nanoid"
const LOADOUT_PROFILES_FILE = "userdata/users/lop.json"
const defaultValue: LoadoutFile = {
h1: {
selected: null,
loadouts: [],
},
h2: {
selected: null,
loadouts: [],
},
h3: {
selected: null,
loadouts: [],
},
}
/**
* A class for managing loadouts.
*/
export class Loadouts {
private _loadouts: LoadoutFile
/**
* Creates a new instance of the class.
*/
public constructor() {
this._loadouts = undefined
}
/**
* Get the loadouts data.
*
* @returns The loadouts data.
*/
public get loadouts(): LoadoutFile {
return this._loadouts
}
/**
* Mutate the current LoadoutFile object.
*
* @internal Intended for internal use only.
* @param newValue The object after the mutation.
*/
set loadouts(newValue: LoadoutFile) {
this._loadouts = newValue
}
/**
* Initializes the loadouts manager.
*/
public init(): void {
if (!existsSync(LOADOUT_PROFILES_FILE)) {
this._loadouts = defaultValue
writeFileSync(LOADOUT_PROFILES_FILE, JSON.stringify(defaultValue))
return
}
this._loadouts = JSON.parse(
readFileSync(LOADOUT_PROFILES_FILE).toString(),
)
let dirty = false
// make sure they all have IDs
for (const gameVersion of ["h1", "h2", "h3"]) {
for (const loadout of this._loadouts[gameVersion].loadouts) {
if (!loadout.id) {
dirty = true
loadout.id = nanoid()
}
}
// if the selected value is null/undefined or is not length 0 or 21, it's not a valid id
if (
!this._loadouts[gameVersion].selected ||
![0, 21].includes(this._loadouts[gameVersion].selected.length)
) {
dirty = true
// long story short: find a loadout with a name matching the selected value,
// and if found, set selected to the id
this._loadouts[gameVersion].selected =
this._loadouts[gameVersion].loadouts.find(
(lo) =>
lo.name === this._loadouts[gameVersion].selected,
)?.id || ""
}
}
if (dirty === true) {
writeFileSync(LOADOUT_PROFILES_FILE, JSON.stringify(this._loadouts))
}
}
/**
* Create the default loadout (or just a new loadout) for the specified game version, and optionally with a name.
*
* @param gameVersion The game version to perform the operation on.
* @param name The optional name for the new loadout set, defaults to "Unnamed loadout set".
* @returns The Loadout object.
*/
public createDefault(
gameVersion: GameVersion,
name = "Unnamed loadout set",
): Loadout {
if (gameVersion === "scpc") {
gameVersion = "h1"
}
const l: Loadout = {
name,
id: nanoid(),
data: {},
}
this._loadouts[gameVersion].loadouts.push(l)
this._loadouts[gameVersion].selected = l.id
return l
}
/**
* Get the active loadout profile for the specified game version. May be undefined.
*
* @param gameVersion The game version.
* @returns The loadout profile or undefined if one isn't selected or none exist.
*/
public getLoadoutFor(gameVersion: GameVersion): Loadout | undefined {
if (gameVersion === "scpc") {
gameVersion = "h1"
}
const theLoadouts = this._loadouts[gameVersion] as LoadoutsGameVersion
return theLoadouts.loadouts.find((s) => s.id === theLoadouts.selected)
}
/**
* Saves the loadout data to the Peacock userdata/users folder.
*/
public async save(): Promise<void> {
await writeFile(LOADOUT_PROFILES_FILE, JSON.stringify(this._loadouts))
}
}
/**
* A synthetic default bind to the global Loadouts instance.
*/
export const loadouts = new Loadouts()
/**
* Router object for loadout-related web requests.
*/
export const loadoutRouter = Router()
if (PEACOCK_DEV) {
loadoutRouter.use((_req, res, next) => {
res.set("Access-Control-Allow-Origin", "*")
res.set(
"Access-Control-Allow-Methods",
"GET,HEAD,PUT,PATCH,POST,DELETE",
)
res.set("Access-Control-Allow-Headers", "Content-Type")
next()
})
}
loadoutRouter.get("/all-loadouts", (req, res) => {
res.json(loadouts.loadouts)
})
loadoutRouter.patch("/update", jsonMiddleware(), async (req, res) => {
// todo: perform validation on this
loadouts.loadouts = req.body
await loadouts.save()
res.json({ message: "request completed" })
})
loadoutRouter.patch(
"/remove",
jsonMiddleware(),
async (
req: Request<
never,
string,
{ gameVersion: "h1" | "h2" | "h3"; id: string }
>,
res,
) => {
// check for gameVersion
if (!req.body.gameVersion) {
res.status(400).json({ error: "missing gv" })
return
}
// validate gameVersion
if (!["h1", "h2", "h3"].includes(req.body.gameVersion)) {
res.status(400).json({ error: "invalid gv" })
return
}
// check for id
if (!req.body.id) {
res.status(400).json({ error: "missing id" })
return
}
const data = loadouts.loadouts
const withoutDeletionTarget = data[
req.body.gameVersion
].loadouts.filter((l) => {
return l.id !== data[req.body.gameVersion].selected
})
if (withoutDeletionTarget.length === 0) {
data[req.body.gameVersion].loadouts = []
// we have no other loadouts, so make a default one
loadouts.createDefault(req.body.gameVersion)
} else {
// we have other loadouts, so pick the first one
data[req.body.gameVersion].loadouts = withoutDeletionTarget
data[req.body.gameVersion].selected = withoutDeletionTarget[0]?.id
}
loadouts.loadouts = data
await loadouts.save()
res.json({ message: "request completed" })
},
)
loadoutRouter.post("/create", jsonMiddleware(), async (req, res) => {
if (!["h1", "h2", "h3"].includes(req.body.gameVersion)) {
res.status(400).json({ message: "invalid gv" })
return
}
loadouts.createDefault(req.body.gameVersion)
await loadouts.save()
res.json({ message: "success" })
})

View File

@ -0,0 +1,142 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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 type { NextFunction, Response } from "express"
import type { RequestWithJwt } from "./types/types"
import picocolors from "picocolors"
/**
* Represents the different log levels.
*/
export enum LogLevel {
/**
* For errors. Displays in red.
*/
ERROR,
/**
* For warnings. Displays in yellow.
*/
WARN,
/**
* For information. Displays in blue.
* This is also the fallback for invalid log level values.
*/
INFO,
/**
* For debugging.
* Displays in light blue, but only if the `DEBUG` environment variable is set to "*", "yes", "true", or "peacock".
*/
DEBUG,
/**
* For outputting stacktraces.
*/
TRACE,
}
const isDebug = ["*", "true", "peacock", "yes"].includes(
process.env.DEBUG || "false",
)
/**
* Adds leading zeros to a number so that the length of the string will always
* be the number of places specified.
*
* @param num The number.
* @param places The intended width of the number (character count).
* @example
* zeroPad(5, 2) // -> "05"
*/
const zeroPad = (num: string | number, places: number) =>
String(num).padStart(places, "0")
/**
* Outputs a log message to the console.
*
* @param level The message's level.
* @param data The data to output.
* @see LogLevel
*/
export function log(level: LogLevel, data: string): void {
const m = data ?? "No message specified"
const now = new Date()
const stampParts: number[] = [
now.getHours(),
now.getMinutes(),
now.getSeconds(),
]
const millis = zeroPad(now.getMilliseconds(), 3)
const timestamp = `${stampParts
.map((part) => zeroPad(part, 2))
.join(":")}:${millis}`
const header = picocolors.gray(timestamp)
let outputTransport: (
message?: unknown,
...optionalParams: unknown[]
) => void
let levelString: string
switch (level) {
case LogLevel.ERROR:
outputTransport = console.error
levelString = picocolors.red("Error")
break
case LogLevel.WARN:
outputTransport = console.warn
levelString = picocolors.yellow("Warn")
break
case LogLevel.INFO:
default:
outputTransport = console.log
levelString = picocolors.blue("Info")
break
case LogLevel.DEBUG:
if (!isDebug) {
return
}
outputTransport = console.log
levelString = picocolors.blueBright("Debug")
break
case LogLevel.TRACE:
outputTransport = console.trace
levelString = picocolors.bgYellow("Trace")
break
}
outputTransport(`[${header}] [${levelString}] ${m}`)
}
/**
* Express middleware that logs all requests and their details with the info log level.
*
* @param req The Express request object.
* @param res The Express response object.
* @param next The Express next function.
* @see LogLevel.INFO
*/
export function loggingMiddleware(
req: RequestWithJwt,
res: Response,
next?: NextFunction,
): void {
log(
LogLevel.INFO,
`${picocolors.green(req.method)} ${picocolors.underline(req.url)}`,
)
next?.()
}

1703
components/menuData.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,452 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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 { contractIdToHitObject, controller } from "../controller"
import type {
Campaign,
GameVersion,
GenSingleMissionFunc,
ICampaignMission,
ICampaignVideo,
IVideo,
StoryData,
} from "../types/types"
import { log, LogLevel } from "../loggingInterop"
import { getConfig } from "../configSwizzleManager"
import { fastClone } from "../utils"
/* eslint-disable prefer-const */
const genSingleMissionFactory = (userId: string): GenSingleMissionFunc => {
return function genSingleMission(
contractId: string,
gameVersion: GameVersion,
): ICampaignMission {
const actualContractData = controller.resolveContract(contractId)
if (!actualContractData) {
log(LogLevel.ERROR, `Failed to resolve contract ${contractId}!`)
}
return {
Type: "Mission",
Data: contractIdToHitObject(contractId, gameVersion, userId),
}
}
}
function genSingleVideo(
videoId: string,
gameVersion: GameVersion,
): ICampaignVideo {
const videos = getConfig<Record<string, IVideo>>("Videos", true) // we modify videos so we need to clone this
const video = videos[videoId]
switch (gameVersion) {
// H1 is not included here as there should be no edits required for the videos from H1
case "h2": {
if (video.Data.DlcName === "GAME_STORE_METADATA_GAME_TITLE") {
video.Data.DlcName = "GAME_STORE_METADATA_S2_GAME_TITLE"
video.Data.DlcImage =
"images/livetile/dlc/wide_logo_hitman2.png"
}
if (video.Data.DlcName.includes("METADATA_DLC")) {
video.Data.DlcName.replace(
"METADATA_DLC",
"METADATA_LEGACY_DLC",
)
video.Data.DlcImage.replace("wide_logo", "wide_logo_legacy")
}
// Void entitlements unless it's LOCATION_NEWZEALAND, that is currently the only known entitlement for S2 maps
if (!video.Entitlements.includes("LOCATION_NEWZEALAND")) {
video.Entitlements = []
}
break
}
case "h3": {
video.Data = {
DlcName: "GAME_STORE_METADATA_S3_GAME_TITLE",
DlcImage: "images/livetile/dlc/tile_hitman3.jpg",
}
if (video.Entitlements.includes("GOTY_PATIENT_ZERO")) {
video.Entitlements = ["H1_LEGACY_STANDARD"]
}
if (video.Entitlements.includes("LOCATION_NEWZEALAND")) {
video.Entitlements = ["H2_LEGACY_STANDARD"]
}
break
}
default:
break
}
return {
Type: "Video",
Data: video,
}
}
/**
* Generates the campaigns data fed to the game's hub route.
*
* @param gameVersion The game's version.
* @param userId The current user's ID.
* @returns The campaigns.
*/
export function makeCampaigns(
gameVersion: GameVersion,
userId: string,
): Campaign[] {
const genSingleMission = genSingleMissionFactory(userId)
let c: Campaign[] = []
const prologueStoryData: StoryData[] = [
genSingleMission("1436cbe4-164b-450f-ad2c-77dec88f53dd", gameVersion),
genSingleVideo("prologue_intermission1", gameVersion),
genSingleMission("1d241b00-f585-4e3d-bc61-3095af1b96e2", gameVersion),
genSingleVideo("prologue_intermission2", gameVersion),
genSingleMission("b573932d-7a34-44f1-bcf4-ea8f79f75710", gameVersion),
genSingleVideo("prologue_intermission3", gameVersion),
genSingleMission("ada5f2b1-8529-48bb-a596-717f75f5eacb", gameVersion),
genSingleVideo("prologue_intermission4", gameVersion),
]
const s1StoryData: StoryData[] = [
genSingleMission("00000000-0000-0000-0000-000000000200", gameVersion),
genSingleVideo("debriefing_peacock", gameVersion),
genSingleMission("00000000-0000-0000-0000-000000000600", gameVersion),
genSingleVideo("debriefing_octopus", gameVersion),
genSingleMission("00000000-0000-0000-0000-000000000400", gameVersion),
genSingleVideo("debriefing_spider", gameVersion),
genSingleMission("db341d9f-58a4-411d-be57-0bc4ed85646b", gameVersion),
genSingleVideo("debriefing_tiger", gameVersion),
genSingleMission("42bac555-bbb9-429d-a8ce-f1ffdf94211c", gameVersion),
genSingleVideo("bull_secret_room", gameVersion),
genSingleVideo("debriefing_bull", gameVersion),
genSingleMission("0e81a82e-b409-41e9-9e3b-5f82e57f7a12", gameVersion),
genSingleVideo("debriefing_snowcrane", gameVersion),
]
let prologueCampaign: Campaign,
s1Campaign: Campaign,
s2Campaign: Campaign,
s3Campaign: Campaign | undefined
if (gameVersion !== "h1") {
const s2StoryData: StoryData[] = [
genSingleMission(
"c65019e5-43a8-4a33-8a2a-84c750a5eeb3",
gameVersion,
),
genSingleVideo("debriefing_sheep", gameVersion),
genSingleMission(
"c1d015b4-be08-4e44-808e-ada0f387656f",
gameVersion,
),
genSingleVideo("debriefing_flamingo", gameVersion),
genSingleMission(
"422519be-ed2e-44df-9dac-18f739d44fd9",
gameVersion,
),
genSingleVideo("debriefing_hippo", gameVersion),
genSingleMission(
"0fad48d7-3d0f-4c66-8605-6cbe9c3a46d7",
gameVersion,
),
genSingleVideo("debriefing_mongoose", gameVersion),
genSingleVideo("intro_skunk", gameVersion),
genSingleMission(
"82f55837-e26c-41bf-bc6e-fa97b7981fbc",
gameVersion,
),
genSingleVideo("debriefing_skunk", gameVersion),
genSingleVideo("intro_magpie", gameVersion),
genSingleMission(
"0d225edf-40cd-4f20-a30f-b62a373801d3",
gameVersion,
),
genSingleVideo("debriefing_magpie", gameVersion),
genSingleMission(
"7a03a97d-238c-48bd-bda0-e5f279569cce",
gameVersion,
),
genSingleMission(
"095261b5-e15b-4ca1-9bb7-001fb85c5aaa",
gameVersion,
),
]
const s3StoryData: StoryData[] | undefined =
gameVersion === "h3"
? [
genSingleVideo("intro_gecko", gameVersion),
genSingleMission(
"7d85f2b0-80ca-49be-a2b7-d56f67faf252",
gameVersion,
),
genSingleVideo("debriefing_gecko", gameVersion),
genSingleMission(
"755984a8-fb0b-4673-8637-95cfe7d34e0f",
gameVersion,
),
genSingleVideo("debriefing_bulldog", gameVersion),
genSingleMission(
"ebcd14b2-0786-4ceb-a2a4-e771f60d0125",
gameVersion,
),
genSingleVideo("debriefing_fox", gameVersion),
genSingleMission(
"3d0cbb8c-2a80-442a-896b-fea00e98768c",
gameVersion,
),
genSingleVideo("debriefing_rat", gameVersion),
genSingleMission(
"d42f850f-ca55-4fc9-9766-8c6a2b5c3129",
gameVersion,
),
genSingleVideo("debriefing_llama", gameVersion),
genSingleMission(
"a3e19d55-64a6-4282-bb3c-d18c3f3e6e29",
gameVersion,
),
genSingleVideo("debriefing_wolverine", gameVersion),
]
: undefined
// BackgroundImage is duplicated as H3 uses properties, H2 doesn't
prologueCampaign = {
BackgroundImage: "images/story/background_training.jpg",
Image: "",
Name: "UI_CAMPAIGN_ICA_FACILITY_TITLE",
Properties: {
BackgroundImage: "images/story/background_training.jpg",
},
StoryData: prologueStoryData,
Type: "training",
}
s1Campaign = {
BackgroundImage: "images/story/background_season1.jpg",
Image: "",
Name: "UI_SEASON_1",
Properties: {
BackgroundImage: "images/story/background_season1.jpg",
},
StoryData: s1StoryData,
Type: "mission",
}
s2Campaign = {
BackgroundImage: "images/story/background_season2.jpg",
Image: "",
Name: "UI_SEASON_2",
Properties: {
BackgroundImage: "images/story/background_season2.jpg",
},
StoryData: s2StoryData,
Type: "mission",
}
s3Campaign =
gameVersion === "h3"
? {
BackgroundImage: null,
Image: "",
Name: "UI_SEASON_3",
Properties: {
BackgroundImage:
"images/story/background_season3.jpg",
},
StoryData: s3StoryData!,
Type: "mission",
}
: undefined
}
const pzCampaign: Campaign = {
Name: "UI_CONTRACT_CAMPAIGN_WHITE_SPIDER_TITLE",
Image: "images/story/tile_whitespider.jpg",
Type: "campaign",
BackgroundImage:
gameVersion === "h1"
? null
: "images/story/background_whitespider.jpg",
StoryData: [
genSingleMission(
"024b6964-a3bb-4457-b085-08f9a7dc7fb7",
gameVersion,
),
genSingleMission(
"7e3f758a-2435-42de-93bd-d8f0b72c63a4",
gameVersion,
),
genSingleMission(
"ada6205e-6ee8-4189-9cdb-4947cccd84f4",
gameVersion,
),
genSingleMission(
"a2befcec-7799-4987-9215-6a152cb6a320",
gameVersion,
),
],
}
switch (gameVersion) {
case "h1": {
c.push(
{
Name: "UI_SEASON_1",
Image: "images/story/tile_season1.jpg",
Type: "mission",
BackgroundImage: null,
StoryData: prologueStoryData.concat(s1StoryData),
},
pzCampaign,
)
break
}
case "h2": {
c.push(prologueCampaign!, s1Campaign!, s2Campaign!, pzCampaign)
break
}
case "h3": {
c.push(prologueCampaign!, s1Campaign!, s2Campaign!, s3Campaign!, {
Name: "UI_MENU_PAGE_SIDE_MISSIONS_TITLE",
Image: "",
Type: "mission",
BackgroundImage: null,
Subgroups: [
pzCampaign,
{
Name: "UI_MENU_PAGE_BONUS_MISSIONS_TITLE",
Image: "",
Type: "campaign",
BackgroundImage:
"images/story/background_bonus_missions.jpg",
StoryData: [
genSingleMission(
"00000000-0000-0000-0001-000000000006",
gameVersion,
),
genSingleMission(
"00000000-0000-0000-0001-000000000005",
gameVersion,
),
genSingleMission(
"ced93d8f-9535-425a-beb9-ef219e781e81",
gameVersion,
),
genSingleMission(
"c414a084-a7b9-43ce-b6ca-590620acd87e",
gameVersion,
),
genSingleMission(
"4e45e91a-94ca-4d89-89fc-1b250e608e73",
gameVersion,
),
genSingleMission(
"99bd3287-1d83-4429-a769-45045dfcbf31",
gameVersion,
),
],
},
{
Name: "UI_MENU_PAGE_SPECIAL_ASSIGNMENTS_TITLE",
Image: "",
Type: "campaign",
BackgroundImage:
"images/story/background_special_assignments.jpg",
StoryData: [
genSingleMission(
"179563a4-727a-4072-b354-c9fff4e8bff0",
gameVersion,
),
genSingleMission(
"a8036782-de0a-4353-b522-0ab7a384bade",
gameVersion,
),
genSingleMission(
"f1ba328f-e3dd-4ef8-bb26-0363499fdd95",
gameVersion,
),
genSingleMission(
"0b616e62-af0c-495b-82e3-b778e82b5912",
gameVersion,
),
],
},
].filter((o) => o !== undefined),
StoryData: [],
Properties: {
BackgroundImage:
"images/story/background_side_missions.jpg",
},
})
break
}
default:
break
}
controller.hooks.contributeCampaigns.call(
c,
genSingleMission,
genSingleVideo,
gameVersion,
)
return c.filter(Boolean)
}
/**
* Get all campaigns (including sub-campaigns) for the specified game version.
*
* @param gameVersion The game version.
* @param userId The user's ID.
*/
export function getAllCampaigns(
gameVersion: GameVersion,
userId: string,
): Campaign[] {
// Warning: cloning this is required, as pushing to this field would normally
// modify the actual campaigns object.
const list = fastClone(makeCampaigns(gameVersion, userId))
const deepIterateCampaigns = (current: Campaign[]) => {
for (const c of current) {
if (c.Subgroups) {
list.push(...c.Subgroups)
deepIterateCampaigns(c.Subgroups)
}
}
}
deepIterateCampaigns(list)
return list
}

View File

@ -0,0 +1,151 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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 { getVersionedConfig } from "../configSwizzleManager"
import type {
CompletionData,
DestinationsMenuDataObject,
GameLocationsData,
GameVersion,
PeacockLocationsData,
RequestWithJwt,
Unlockable,
} from "../types/types"
import { controller } from "../controller"
import { generateCompletionData } from "../contracts/dataGen"
type GameFacingDestination = {
ChallengeCompletion: {
ChallengesCount: number
CompletedChallengesCount: number
}
CompletionData: CompletionData
OpportunityStatistics: {
Count: number
Completed: number
}
LocationCompletionPercent: number
Location: Unlockable
}
export function destinationsMenu(req: RequestWithJwt): GameFacingDestination[] {
const destinations = getVersionedConfig<DestinationsMenuDataObject[]>(
"Destinations",
req.gameVersion,
false,
)
const result: GameFacingDestination[] = []
const locations = getVersionedConfig<PeacockLocationsData>(
"LocationsData",
req.gameVersion,
true,
)
for (const destination of destinations) {
const parent = locations.parents[destination.ParentId]
parent.GameAsset = null
parent.DisplayNameLocKey =
"UI_LOCATION_PARENT_" + destination.ParentId.substring(16) + "_NAME"
const template = {
ChallengeCompletion: {
ChallengesCount: 0,
CompletedChallengesCount: 0, // TODO: Hook this up to challenge counts.
},
CompletionData: generateCompletionData(
destination.ParentId,
req.jwt.unique_name,
req.gameVersion,
),
OpportunityStatistics: {
Count: 0,
Completed: 0,
},
LocationCompletionPercent: 0,
Location: parent,
}
result.push(template)
}
return result
}
/**
* Creates the game's LocationsData object, and optionally removes locations
* that don't provide a contract creation ID.
*
* @param gameVersion The game version.
* @param excludeIfNoContracts If true, locations that don't support contract
* creation will not be returned.
* @returns The locations that can be played.
*/
export function createLocationsData(
gameVersion: GameVersion,
excludeIfNoContracts = false,
): GameLocationsData {
const locData = getVersionedConfig<PeacockLocationsData>(
"LocationsData",
gameVersion,
true,
)
const allSublocationIds = Object.keys(locData.children)
const finalData: GameLocationsData = {
Data: {
HasMore: false,
Page: 0,
Locations: [],
},
}
for (const sublocationId of allSublocationIds) {
const sublocation = locData.children[sublocationId]
const parentLocation =
locData.parents[sublocation.Properties.ParentLocation]
const creationContract = controller.resolveContract(
sublocation.Properties.CreateContractId,
)
if (!creationContract && excludeIfNoContracts) {
continue
}
const toAdd = {
Location: parentLocation,
SubLocation: sublocation,
Contract: {
Metadata: {},
Data: {},
},
}
if (creationContract) {
toAdd.Contract = creationContract
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
finalData.Data.Locations.push(toAdd as any)
}
return finalData
}

View File

@ -0,0 +1,149 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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 type {
GameVersion,
MissionManifest,
RequestWithJwt,
Unlockable,
UserCentricContract,
} from "../types/types"
import { controller } from "../controller"
import { generateUserCentric } from "../contracts/dataGen"
import { getUserData, writeUserData } from "../databaseHandler"
import { getConfig, getVersionedConfig } from "../configSwizzleManager"
import type { Response } from "express"
export function withLookupDialog(
req: RequestWithJwt<{ contractId: string }>,
res: Response,
): void {
const lookupFavoriteTemplate = getConfig(
"LookupContractFavoriteTemplate",
false,
)
if (!req.query.contractId) {
res.status(400).send("no contract id")
return
}
const contract = controller.resolveContract(req.query.contractId)
if (!contract) {
res.status(404).send("contract does not exist!")
return
}
interface Result {
template: unknown
data: {
Contract: MissionManifest
UserCentricContract: UserCentricContract
Location: unknown
AddedSuccessfully?: boolean
}
}
const sublocation = getVersionedConfig<readonly Unlockable[]>(
"allunlockables",
req.gameVersion,
false,
).find((entry) => entry.Id === contract.Metadata.Location)
const result: Result = {
template: lookupFavoriteTemplate,
data: {
Contract: contract,
Location: sublocation,
UserCentricContract: generateUserCentric(
contract,
req.jwt.unique_name,
req.gameVersion,
),
},
}
result.data.AddedSuccessfully = toggleFavorite(
req.jwt.unique_name,
req.query.contractId,
req.gameVersion,
)
res.json(result)
}
/**
* Toggles a contract as a user's favorite.
*
* @param userId The user's ID.
* @param contractId The contract's ID.
* @param gameVersion The game's version.
* @returns If the contract is a favorite after the operation or not.
*/
export function toggleFavorite(
userId: string,
contractId: string,
gameVersion: GameVersion,
): boolean {
let flag = false
const userProfile = getUserData(userId, gameVersion)
if (!Array.isArray(userProfile.Extensions.PeacockFavoriteContracts)) {
userProfile.Extensions.PeacockFavoriteContracts = []
}
if (userProfile.Extensions.PeacockFavoriteContracts.includes(contractId)) {
userProfile.Extensions.PeacockFavoriteContracts =
userProfile.Extensions.PeacockFavoriteContracts.filter(
(f) => f !== contractId,
)
} else {
userProfile.Extensions.PeacockFavoriteContracts.push(contractId)
flag = true
}
writeUserData(userId, gameVersion)
return flag
}
export function directRoute(req: RequestWithJwt, res: Response): void {
if (!req.params.contractId) {
res.status(400).send("no contract id")
return
}
const contract = controller.resolveContract(req.params.contractId)
if (!contract) {
res.status(404).send("contract does not exist!")
return
}
res.json({
template: null,
data: {
ContractId: req.params.contractId,
IsInPlaylist: toggleFavorite(
req.jwt.unique_name,
req.params.contractId,
req.gameVersion,
),
},
})
}

View File

@ -0,0 +1,104 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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 type { RequestWithJwt } from "../types/types"
import type { Response } from "express"
import parseUrl from "parseurl"
import axios from "axios"
import { log, LogLevel } from "../loggingInterop"
import { getFlag } from "../flags"
import { createWriteStream } from "fs"
const dangerousBidiChars = [
"\u061C",
"\u200E",
"\u200F",
"\u202A",
"\u202B",
"\u202C",
"\u202D",
"\u202E",
"\u2066",
"\u2067",
"\u2068",
"\u2069",
]
export async function imageFetchingMiddleware(
req: RequestWithJwt,
res: Response,
): Promise<void> {
if (getFlag("imageLoading") === "OFFLINE") {
res.status(404).send("Image not offline, unable to provide.")
return
}
const originalUrl = parseUrl.original(req)
const path = parseUrl(req)?.pathname
if (!path) {
return
}
// make sure redirect occurs at mount
if (path === "/" && originalUrl?.pathname?.slice(-1) !== "/") {
res.status(404).send("Not found!")
return
}
if (path.includes("./")) {
res.status(400).send("Hey, you can't write/read arbitrary files!!")
return
}
for (const char of dangerousBidiChars) {
if (path.includes(char)) {
res.status(400).send(
"Hey, you can't put malicious bidi characters in the URL!!",
)
return
}
}
try {
const axiosResponse = await axios(
`https://img.rdil.rocks/images${path}`,
{
responseType: "stream",
},
)
if (path.endsWith(".jpg")) {
res.header("Content-Type", "image/jpg")
} else if (path.endsWith(".png")) {
res.header("Content-Type", "image/png")
}
axiosResponse.data.pipe(res)
if (getFlag("imageLoading") === "SAVEONREQUESTED") {
// we got the image, we should be fine
// may need to introduce extra security here in the future, not sure though
// we've got bidi and escape paths taken care of, so it should be enough, I hope?
axiosResponse.data.pipe(createWriteStream(`images${path}`))
}
} catch (e) {
log(LogLevel.DEBUG, `Err ${e} ${e.stack}`)
res.status(500).send("failed to get data")
}
}

View File

@ -0,0 +1,256 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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 send from "send"
import { imageFetchingMiddleware } from "./imageHandler"
import { SyncBailHook, SyncHook } from "../hooksImpl"
const menuSystemRouter = Router()
// /resources-8-10/
/**
* 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",
)
}
if (gameVersion === "h3" || gameVersion === "h1") {
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",
)
}
if (gameVersion === "h2") {
configs.push("menusystem/data/ismultiplayeravailable.json")
configs.push(
"menusystem/pages/multiplayer/content/lobbyslim.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 "/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)
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)
menuSystemRouter.use(
"/images/",
serveStatic("images", { fallthrough: true }),
imageFetchingMiddleware,
)
// Miranda Jamison's image path in the repository is escaped for some reason
menuSystemRouter.get(
"/images%5Cactors%5Celusive_goldendoublet_face.jpg",
(req, res) => {
send(req, "images/actors/elusive_goldendoublet_face.jpg").pipe(res)
},
)
// Sully Bowden is the same (come on IOI!)
menuSystemRouter.get(
"/images%5Cactors%5Celusive_redsnapper_face.jpg",
(req, res) => {
send(req, "images/actors/elusive_redsnapper_face.jpg").pipe(res)
},
)
export { menuSystemRouter }

View File

@ -0,0 +1,443 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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 type { RequestWithJwt, SceneConfig } from "../types/types"
import { log, LogLevel } from "../loggingInterop"
import { _legacyBull, _theLastYardbirdScpc, controller } from "../controller"
import {
contractIdToEscalationGroupId,
getLevelCount,
getUserEscalationProgress,
resetUserEscalationProgress,
} from "../contracts/escalations/escalationService"
import {
generateUserCentric,
getSubLocationFromContract,
mapObjectives,
} from "../contracts/dataGen"
import { getConfig } from "../configSwizzleManager"
import { getUserData, writeUserData } from "../databaseHandler"
import { nilUuid, unlockorderComparer } from "../utils"
import type { Response } from "express"
import { createInventory } from "../inventory"
import { createSniperLoadouts } from "./sniper"
import { getFlag } from "../flags"
import { loadouts } from "../loadouts"
import { resolveProfiles } from "../profileHandler"
import { PlanningQuery } from "../types/gameSchemas"
export async function planningView(
req: RequestWithJwt<PlanningQuery>,
res: Response,
): Promise<void> {
if (!req.query.contractid || !req.query.resetescalation) {
res.status(400).send("invalid query")
return
}
const entranceData = getConfig<SceneConfig>("Entrances", false)
const missionStories = getConfig<Record<string, unknown>>(
"MissionStories",
false,
)
const userData = getUserData(req.jwt.unique_name, req.gameVersion)
const isForReset = req.query.resetescalation === "true"
if (isForReset) {
const escalationGroupId = contractIdToEscalationGroupId(
req.query.contractid,
)
resetUserEscalationProgress(userData, escalationGroupId)
writeUserData(req.jwt.unique_name, req.gameVersion)
// now reassign properties and continue
req.query.contractid =
controller.escalationMappings[escalationGroupId]["1"]
}
const contractData =
req.gameVersion === "h1" &&
req.query.contractid === "42bac555-bbb9-429d-a8ce-f1ffdf94211c"
? _legacyBull
: req.query.contractid === "ff9f46cf-00bd-4c12-b887-eac491c3a96d"
? _theLastYardbirdScpc
: controller.resolveContract(req.query.contractid)
if (!contractData) {
log(LogLevel.ERROR, `Not found: ${req.query.contractid}.`)
res.status(400).send("no ct")
return
}
const groupData = {
GroupId: undefined as string | undefined,
GroupTitle: undefined as string | undefined,
CompletedLevels: undefined as number | undefined,
Completed: undefined as boolean | undefined,
TotalLevels: undefined as number | undefined,
BestScore: undefined as number | undefined,
BestPlayer: undefined as string | undefined,
BestLevel: undefined as number | undefined,
}
const escalationGroupId = contractIdToEscalationGroupId(
req.query.contractid,
)
if (escalationGroupId) {
const p = getUserEscalationProgress(userData, escalationGroupId)
const done =
userData.Extensions.PeacockCompletedEscalations.includes(
escalationGroupId,
)
groupData.GroupId = escalationGroupId
groupData.GroupTitle = contractData.Metadata.Title
groupData.CompletedLevels = done ? p : p - 1
groupData.Completed = done
groupData.TotalLevels = getLevelCount(
controller.escalationMappings[escalationGroupId],
)
groupData.BestScore = 0
groupData.BestPlayer = nilUuid
groupData.BestLevel = 0
}
if (!contractData) {
log(LogLevel.WARN, `Unknown contract: ${req.query.contractid}`)
res.status(404).send("contract not found!")
return
}
const creatorProfile = (
await resolveProfiles(
[
contractData.Metadata.CreatorUserId || "",
"fadb923c-e6bb-4283-a537-eb4d1150262e",
],
req.gameVersion,
)
)[0]
const scenePath = contractData.Metadata.ScenePath.toLowerCase()
log(
LogLevel.DEBUG,
`Looking up details for contract - Location:${contractData.Metadata.Location} (${scenePath})`,
)
const sublocation = getSubLocationFromContract(
contractData,
req.gameVersion,
)
if (!Object.prototype.hasOwnProperty.call(entranceData, scenePath)) {
log(
LogLevel.ERROR,
`Could not find Entrance data for ${scenePath} (loc Planning)! This may cause an unhandled promise rejection.`,
)
}
const entrancesInScene = entranceData[scenePath]
const typedInv = createInventory(
req.jwt.unique_name,
req.gameVersion,
userData.Extensions.entP,
)
const unlockedEntrances = typedInv
.filter((item) => item.Unlockable.Type === "access")
.map((i) => i.Unlockable)
.filter((unlockable) => unlockable.Properties.RepositoryId)
if (!unlockedEntrances) {
log(
LogLevel.ERROR,
"No matching entrance data found in planning, this is a bug!",
)
}
sublocation.DisplayNameLocKey = `UI_${sublocation.Id}_NAME`
// Default loadout
let currentLoadout = loadouts.getLoadoutFor(req.gameVersion)
if (!currentLoadout) {
currentLoadout = loadouts.createDefault(req.gameVersion)
}
let pistol = "FIREARMS_HERO_PISTOL_TACTICAL_ICA_19"
let suit = "TOKEN_OUTFIT_HITMANSUIT"
let tool1 = "TOKEN_FIBERWIRE"
let tool2 = "PROP_TOOL_COIN"
let briefcaseProp: string | undefined = undefined
let briefcaseId: string | undefined = undefined
const hasOwn = Object.prototype.hasOwnProperty.bind(currentLoadout.data)
const dlForLocation =
getFlag("loadoutSaving") === "LEGACY"
? // older default loadout setting (per-person)
userData.Extensions.defaultloadout?.[
contractData.Metadata.Location
]
: // new loadout profiles system
hasOwn(contractData.Metadata.Location) &&
currentLoadout.data[contractData.Metadata.Location]
if (dlForLocation) {
pistol = dlForLocation["2"]
suit = dlForLocation["3"]
tool1 = dlForLocation["4"]
tool2 = dlForLocation["5"]
for (const key of Object.keys(dlForLocation)) {
if (["2", "3", "4", "5"].includes(key)) {
// we're looking for keys that aren't taken up by other things
continue
}
briefcaseId = key
briefcaseProp = dlForLocation[key]
}
}
const i = typedInv.find((item) => item.Unlockable.Id === briefcaseProp)
const escalation = contractData.Metadata.Type === "escalation"
const userCentric = generateUserCentric(
contractData,
req.jwt.unique_name,
req.gameVersion,
)
if (userCentric.Contract.Metadata.Type === "elusive") {
// change the type until we figure out why they become unplayable
userCentric.Contract.Metadata.Type = "mission"
}
const sniperLoadouts = createSniperLoadouts(contractData)
if (req.gameVersion === "scpc") {
sniperLoadouts.forEach((loadout) => {
loadout["LoadoutData"] = loadout["Loadout"]["LoadoutData"]
delete loadout["Loadout"]
})
}
res.json({
template:
req.gameVersion === "h1"
? getConfig("LegacyPlanningTemplate", false)
: req.gameVersion === "scpc"
? getConfig("FrankensteinPlanningTemplate", false)
: null,
data: {
Contract: contractData,
ElusiveContractState: "",
UserCentric: userCentric,
IsFirstInGroup: escalation
? controller.escalationMappings[escalationGroupId]["1"] ===
req.query.contractid
: true,
Creator: creatorProfile,
UserContract: creatorProfile.DevId !== "IOI",
UnlockedEntrances:
contractData.Metadata.Type === "sniper"
? null
: typedInv
.filter(
(item) =>
item.Unlockable.Subtype ===
"startinglocation",
)
.filter(
(item) =>
item.Unlockable.Properties.Difficulty ===
contractData.Metadata.Difficulty,
)
.map((i) => i.Unlockable.Properties.RepositoryId)
.filter((id) => id),
UnlockedAgencyPickups:
contractData.Metadata.Type === "sniper"
? null
: typedInv
.filter(
(item) => item.Unlockable.Type === "agencypickup",
)
.filter(
(item) =>
item.Unlockable.Properties.Difficulty ===
contractData.Metadata.Difficulty,
)
.map((i) => i.Unlockable.Properties.RepositoryId)
.filter((id) => id),
Objectives: mapObjectives(
contractData.Data.Objectives,
contractData.Data.GameChangers || [],
contractData.Metadata.GroupObjectiveDisplayOrder || [],
),
GroupData: groupData,
Entrances:
contractData.Metadata.Type === "sniper"
? null
: unlockedEntrances
.filter((unlockable) =>
entrancesInScene.includes(
unlockable.Properties.RepositoryId,
),
)
.filter(
(unlockable) =>
unlockable.Properties.Difficulty ===
contractData.Metadata.Difficulty,
)
.sort(unlockorderComparer),
Location: sublocation,
LoadoutData:
contractData.Metadata.Type === "sniper"
? null
: [
{
SlotName: "carriedweapon",
SlotId: "0",
Recommended: null,
},
{
SlotName: "carrieditem",
SlotId: "1",
Recommended: null,
},
{
SlotName: "concealedweapon",
SlotId: "2",
Recommended: {
item:
contractData.Peacock?.noCarriedWeapon ===
true
? null
: typedInv.find(
(item) =>
item.Unlockable.Id ===
pistol,
),
type: "concealedweapon",
},
},
{
SlotName: "disguise",
SlotId: "3",
Recommended: {
item: typedInv.find(
(item) => item.Unlockable.Id === suit,
),
type: "disguise",
},
},
{
SlotName: "gear",
SlotId: "4",
Recommended: {
item:
contractData.Peacock?.noGear === true
? null
: typedInv.find(
(item) =>
item.Unlockable.Id ===
tool1,
),
type: "gear",
},
},
{
SlotName: "gear",
SlotId: "5",
Recommended: {
item:
contractData.Peacock?.noGear === true
? null
: typedInv.find(
(item) =>
item.Unlockable.Id ===
tool2,
),
type: "gear",
},
},
{
SlotName: "stashpoint",
SlotId: "6",
Recommended: null,
},
briefcaseId && {
SlotName: briefcaseProp,
SlotId: briefcaseId,
Recommended: {
item: {
...i,
Properties: {},
},
type: i.Unlockable.Id,
owned: true,
},
IsContainer: true,
},
].filter(Boolean),
LimitedLoadoutUnlockLevel: 0, // Hokkaido
CharacterLoadoutData:
sniperLoadouts.length !== 0 ? sniperLoadouts : null,
ChallengeData: {
Children:
controller.challengeService.getChallengePlanningDataForContract(
req.query.contractid,
req.gameVersion,
req.jwt.unique_name,
),
},
Currency: {
Balance: 0,
},
PaymentDetails: {
Currency: "Merces",
Amount: 0,
MaximumDeduction: 85,
Bonuses: null,
Expenses: null,
Entrance: null,
Pickup: null,
SideMission: null,
},
OpportunityData: (contractData.Metadata.Opportunities || [])
.map((value) => missionStories[value])
.filter(Boolean),
PlayerProfileXpData: {
XP: userData.Extensions.progression.PlayerProfileXP.Total,
Level: userData.Extensions.progression.PlayerProfileXP
.ProfileLevel,
MaxLevel: 5000,
},
},
})
}

View File

@ -0,0 +1,100 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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 { generateUserCentric } from "../contracts/dataGen"
import { controller } from "../controller"
import type { GameVersion, PlayNextCampaignDetails } from "../types/types"
export const orderedMissions: string[] = [
"00000000-0000-0000-0000-000000000200",
"00000000-0000-0000-0000-000000000600",
"00000000-0000-0000-0000-000000000400",
"db341d9f-58a4-411d-be57-0bc4ed85646b",
"42bac555-bbb9-429d-a8ce-f1ffdf94211c",
"0e81a82e-b409-41e9-9e3b-5f82e57f7a12",
"c65019e5-43a8-4a33-8a2a-84c750a5eeb3",
"c1d015b4-be08-4e44-808e-ada0f387656f",
"422519be-ed2e-44df-9dac-18f739d44fd9",
"0fad48d7-3d0f-4c66-8605-6cbe9c3a46d7",
"82f55837-e26c-41bf-bc6e-fa97b7981fbc",
"0d225edf-40cd-4f20-a30f-b62a373801d3",
"7a03a97d-238c-48bd-bda0-e5f279569cce",
"095261b5-e15b-4ca1-9bb7-001fb85c5aaa",
"7d85f2b0-80ca-49be-a2b7-d56f67faf252",
"755984a8-fb0b-4673-8637-95cfe7d34e0f",
"ebcd14b2-0786-4ceb-a2a4-e771f60d0125",
"3d0cbb8c-2a80-442a-896b-fea00e98768c",
"d42f850f-ca55-4fc9-9766-8c6a2b5c3129",
"a3e19d55-64a6-4282-bb3c-d18c3f3e6e29",
]
/**
* Gets the ID for a season.
*
* @param index The index in orderedMissions.
* @returns The season's ID. ("1", "2", or "3")
* @see orderedMissions
*/
export function getSeasonId(index: number): string {
if (index <= 5) {
return "1"
}
if (index <= 13) {
return "2"
}
return "3"
}
/**
* Generates a tile for play next given a contract ID and other details.
*
* @param userId The user's ID.
* @param contractId The next contract ID.
* @param gameVersion The game version.
* @param campaignInfo The campaign information.
* @returns The tile object.
*/
export function createPlayNextTile(
userId: string,
contractId: string,
gameVersion: GameVersion,
campaignInfo: PlayNextCampaignDetails,
) {
return {
CategoryType: "NextMission",
CategoryName: "UI_PLAYNEXT_CONTINUE_STORY_TITLE",
Items: [
{
ItemType: null,
ContentType: "Contract",
Content: {
ContractId: contractId,
UserCentricContract: generateUserCentric(
controller.resolveContract(contractId),
userId,
gameVersion,
),
CampaignInfo: campaignInfo,
},
CategoryType: "NextMission",
},
],
}
}

138
components/menus/sniper.ts Normal file
View File

@ -0,0 +1,138 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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 { getConfig } from "../configSwizzleManager"
import type {
CompletionData,
MissionManifest,
SniperLoadout,
} from "../types/types"
export type SniperLoadoutConfig = {
[locationId: string]: {
[firearmCharacter: string]: SniperLoadout
}
}
/**
* Creates the sniper loadouts data.
*
* @author Anthony Fuller
* @param contractData The contract's data.
* @param loadoutData Should the output just contain loadout data in an array?
* @returns The sniper loadouts data.
*/
export function createSniperLoadouts(
contractData: MissionManifest,
loadoutData = false,
) {
const sniperLoadouts = []
if (contractData.Metadata.Type === "sniper") {
const sLoadouts = getConfig<SniperLoadoutConfig>("SniperLoadouts", true)
for (const index in sLoadouts[contractData.Metadata.Location]) {
const character = sLoadouts[contractData.Metadata.Location][index]
const data = {
Id: character.ID,
Loadout: {
LoadoutData: [
{
SlotId: "0",
SlotName: "carriedweapon",
Items: [
{
Item: {
InstanceId: character.InstanceID,
ProfileId:
"00000000-0000-0000-0000-000000000000",
Unlockable: character.Unlockable,
Properties: {},
},
ItemDetails: {
Capabilities: [],
StatList: [
{
Name: "clipsize",
Ratio: 0.2,
},
{
Name: "damage",
Ratio: 1.0,
},
{
Name: "range",
Ratio: 1.0,
},
{
Name: "rateoffire",
Ratio: 0.3,
},
],
PropertyTexts: [],
},
},
],
Page: 0,
Recommended: {
item: {
InstanceId:
"00000000-0000-0000-0000-000000000000",
ProfileId:
"00000000-0000-0000-0000-000000000000",
Unlockable: character.Unlockable,
Properties: {},
},
type: "carriedweapon",
owned: true,
},
HasMore: false,
HasMoreLeft: false,
HasMoreRight: false,
OptionalData: {},
},
],
LimitedLoadoutUnlockLevel: 0 as number | undefined,
},
// TODO(Anthony): Use the function to get these details.
CompletionData: {
Level: 20,
MaxLevel: 20,
XP: 0,
Completion: 1,
XpLeft: 0,
Id: index,
SubLocationId: "",
HideProgression: false,
IsLocationProgression: false,
Name: index,
} as CompletionData,
}
if (loadoutData) {
delete data.Loadout.LimitedLoadoutUnlockLevel
sniperLoadouts.push(data.Loadout)
continue
}
sniperLoadouts.push(data)
}
}
return sniperLoadouts
}

View File

@ -0,0 +1,163 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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 { RequestWithJwt } from "../types/types"
import { contractSessions } from "../eventHandler"
import { getUserData } from "../databaseHandler"
import {
calculateMpScore,
getMultiplayerLoadoutData,
MultiplayerScore,
} from "./multiplayerUtils"
import { Router } from "express"
import { getConfig } from "../configSwizzleManager"
import { MultiplayerPreset } from "./multiplayerService"
import { generateUserCentric } from "components/contracts/dataGen"
import { controller } from "../controller"
import {
MissionEndRequestQuery,
MultiplayerMatchStatsQuery,
MultiplayerQuery,
} from "../types/gameSchemas"
export const multiplayerMenuDataRouter = Router()
multiplayerMenuDataRouter.post(
"/multiplayermatchstatsready",
(req: RequestWithJwt<MissionEndRequestQuery>, res) => {
res.json({
template: null,
data: {
contractSessionId: req.query.contractSessionId,
isReady: true,
retryCount: 1,
},
})
},
)
multiplayerMenuDataRouter.post(
"/multiplayermatchstats",
(req: RequestWithJwt<MultiplayerMatchStatsQuery>, res) => {
const sessionDetails = contractSessions.get(req.query.contractSessionId)
if (!sessionDetails) {
// contract session not found
res.status(404).end()
return
}
const scores: MultiplayerScore[] = [calculateMpScore(sessionDetails)]
if (!sessionDetails.ghost) {
throw new Error("no mp details on mp session")
}
for (const opponentId in sessionDetails.ghost.Opponents) {
const opponentSessionDetails = contractSessions.get(
sessionDetails.ghost.Opponents[opponentId],
)
if (opponentSessionDetails) {
scores.push(calculateMpScore(opponentSessionDetails))
} else {
// opponent contract session not found
scores.push({})
}
}
res.json({
template: null,
data: {
Players: scores,
},
})
},
)
interface MultiplayerPresetsQuery {
gamemode?: string
disguiseUnlockableId?: string
}
multiplayerMenuDataRouter.get(
"/multiplayerpresets",
(req: RequestWithJwt<MultiplayerPresetsQuery>, res) => {
if (req.query.gamemode !== "versus") {
res.status(401).send("unknown gamemode")
return
}
const presets = getConfig<MultiplayerPreset[]>(
"MultiplayerPresets",
false,
)
const userData = getUserData(req.jwt.unique_name, req.gameVersion)
const contractIds: Set<string> = new Set()
for (const preset of presets) {
for (const contractId of preset.Data.Contracts) {
contractIds.add(contractId)
}
}
res.json({
template: null,
data: {
Presets: presets,
UserCentricContracts: [...contractIds].map((contractId) => {
return generateUserCentric(
controller.resolveContract(contractId),
req.jwt.unique_name,
req.gameVersion,
)
}),
LoadoutData: getMultiplayerLoadoutData(
userData,
req.query.disguiseUnlockableId,
req.gameVersion,
),
},
})
},
)
multiplayerMenuDataRouter.get(
"/multiplayer",
(req: RequestWithJwt<MultiplayerQuery>, res) => {
// /multiplayer?gamemode=versus&disguiseUnlockableId=TOKEN_OUTFIT_ELUSIVE_COMPLETE_15_SUIT
if (req.query.gamemode !== "versus") {
return
}
const userData = getUserData(req.jwt.unique_name, req.gameVersion)
res.json({
template: null,
data: {
LoadoutData: getMultiplayerLoadoutData(
userData,
req.query.disguiseUnlockableId,
req.gameVersion,
),
},
})
},
)

View File

@ -0,0 +1,280 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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 { log, LogLevel } from "../loggingInterop"
import { Router } from "express"
import { enqueuePushMessage } from "../eventHandler"
import { json as jsonMiddleware } from "body-parser"
import {
ClientToServerEvent,
ContractSession,
RequestWithJwt,
UserCentricContract,
} from "../types/types"
import { nilUuid } from "../utils"
import { randomUUID } from "crypto"
import { getConfig } from "../configSwizzleManager"
import { generateUserCentric } from "../contracts/dataGen"
import { controller } from "../controller"
import { MatchOverC2SEvent } from "../types/events"
/**
* A multiplayer preset.
*/
export interface MultiplayerPreset {
/**
* The preset's ID.
*/
Id: string
/**
* The preset's game mode.
*/
GameMode: "versus" | string
Metadata: {
Title: string
Header: string
Image: string
IsDefault: boolean
}
Data: {
Contracts: string[]
Properties: {
mode: string
active: boolean
__comment?: string
}
}
}
export interface MatchData {
Players: string[]
MatchData: {
contractId: string
[key: string]: unknown
}
}
/**
* Extension for a session providing ghost mode details.
*/
export interface SessionGhostModeDetails {
deaths: number
unnoticedKills: number
Opponents: string[]
IsWinner: boolean
Score: number
OpponentScore: number
IsDraw: boolean
timerEnd: number | null
}
export const multiplayerRouter = Router()
const activeMatches: Map<string, MatchData> = new Map()
multiplayerRouter.post(
"/GetRequiredResourcesForPreset",
jsonMiddleware(),
(req: RequestWithJwt, res) => {
const allPresets = getConfig<MultiplayerPreset[]>(
"MultiplayerPresets",
false,
)
const requestedPreset = allPresets.find(
(preset) => preset.Id === req.body.id,
)
if (!requestedPreset) {
res.status(404).end()
log(LogLevel.WARN, "unknown multiplayer preset id requested")
return
}
const contractIds = requestedPreset.Data.Contracts
const userCentrics = contractIds
.map((id) =>
generateUserCentric(
controller.resolveContract(id),
req.jwt.unique_name,
req.gameVersion,
),
)
.filter(Boolean)
res.json(
userCentrics.map((userCentric: UserCentricContract) => ({
Id: userCentric.Contract.Metadata.Id,
DlcId: userCentric.Data.DlcName,
Resources: [
userCentric.Contract.Metadata.ScenePath,
...(userCentric.Contract.Data.Bricks ?? []),
],
})),
)
},
)
multiplayerRouter.post(
"/RegisterToMatch",
jsonMiddleware(),
(req: RequestWithJwt, res) => {
// get a random contract from the list of possible ones in the selected preset
const multiplayerPresets = getConfig<MultiplayerPreset[]>(
"MultiplayerPresets",
false,
)
if (!req.body.presetId) {
req.body.presetId = "d72d7cc9-ee26-4c7d-857a-75abdc9ccb61" // default to miami invite preset
}
const preset = multiplayerPresets.find(
(preset) => preset.Id === req.body.presetId,
)
if (!preset) {
res.status(404).end()
log(
LogLevel.WARN,
`Unknown preset id requested (${req.body.presetId})`,
)
return
}
const contractId =
preset.Data.Contracts[
Math.trunc(Math.random() * preset.Data.Contracts.length)
]
if (req.body.matchId === nilUuid) {
// create new match
req.body.matchId = randomUUID()
activeMatches.set(req.body.matchId, {
MatchData: {
contractId: contractId,
},
Players: [req.jwt.unique_name],
})
} else if (activeMatches.has(req.body.matchId)) {
// join existing match
const match = activeMatches.get(req.body.matchId)!
match.Players.forEach((playerId) =>
enqueuePushMessage(playerId, {
MatchId: req.body.matchId,
Type: 1,
PlayerId: req.jwt.unique_name,
MatchData: null,
}),
)
match.Players.push(req.jwt.unique_name)
} else {
// MatchId not found
res.status(404).end()
return
}
enqueuePushMessage(req.jwt.unique_name, {
MatchId: req.body.matchId,
Type: 3,
PlayerId: nilUuid,
MatchData: activeMatches.get(req.body.matchId)!.MatchData,
})
res.json({
MatchId: req.body.matchId,
PreferedHostIndex: 0,
Tickets: [],
MatchMode: null,
MatchData: null,
MatchStats: {},
MatchType: 0,
})
},
)
multiplayerRouter.post(
"/SetMatchData",
jsonMiddleware(),
(req: RequestWithJwt, res) => {
const match = activeMatches.get(req.body.matchId)
if (!(match && match.Players.includes(req.jwt.unique_name))) {
res.status(404).end()
return
}
match.MatchData[req.body.key] = req.body.value
res.json({
MatchId: req.body.matchId,
PreferedHostIndex: 0,
Tickets: [],
MatchMode: null,
MatchData: match.MatchData,
MatchStats: {},
MatchType: 0,
})
},
)
multiplayerRouter.post("/RegisterToPreset", jsonMiddleware(), (req, res) => {
// matchmaking
// TODO: implement matchmaking
// req.body.presetId
// req.body.lobbyId (this is just a timestamp?)
res.status(500).end()
})
export function handleMultiplayerEvent(
event: ClientToServerEvent,
session: ContractSession,
): boolean {
const emptySession = <SessionGhostModeDetails>{}
const ghost = session.ghost || emptySession
switch (event.Name) {
case "Ghost_PlayerDied":
ghost.deaths += 1
return true
case "Ghost_TargetUnnoticed":
ghost.unnoticedKills += 1
return true
case "Opponents": {
const value = event.Value as {
ConnectedSessions: string[]
}
ghost.Opponents = value.ConnectedSessions
return true
}
case "MatchOver": {
const matchOverValue = (event as MatchOverC2SEvent).Value
ghost.Score = matchOverValue.MyScore
ghost.OpponentScore = matchOverValue.OpponentScore
ghost.IsWinner = matchOverValue.IsWinner
ghost.IsDraw = matchOverValue.IsDraw
ghost.timerEnd = event.Timestamp
return true
}
default:
return false
}
}

View File

@ -0,0 +1,185 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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 {
ContractSession,
GameVersion,
Unlockable,
UserProfile,
} from "../types/types"
import { getVersionedConfig } from "../configSwizzleManager"
import { createInventory } from "../inventory"
import { randomUUID } from "crypto"
import { nilUuid } from "../utils"
import { log, LogLevel } from "../loggingInterop"
import assert from "assert"
export interface MultiplayerScore {
Header?: {
GameMode: "Ghost" | string
Result: "Win" | "Loss" | string
}
Metadata?: Record<string, never>
Data?: {
Score: number
OpponentScore: number
PacifiedNpcs: number
DisguisesUsed: number
DisguisesRuined: number
BodiesHidden: number
UnnoticedKills: number
KilledNpcs: number
Deaths: number
Duration: number | Date
}
}
export function calculateMpScore(
sessionDetails: ContractSession,
): MultiplayerScore {
if (!sessionDetails.ghost) {
throw new Error("no mp details on mp session")
}
return {
Header: {
GameMode: "Ghost",
Result: sessionDetails.ghost.IsWinner ? "Win" : "Loss",
},
Metadata: {},
Data: {
Score: sessionDetails.ghost.Score,
OpponentScore: sessionDetails.ghost.OpponentScore,
PacifiedNpcs: [...sessionDetails.pacifications].filter(
(id) =>
!sessionDetails.npcKills.has(id) &&
!sessionDetails.targetKills.has(id),
).length,
DisguisesUsed: sessionDetails.disguisesUsed.size,
DisguisesRuined: sessionDetails.disguisesRuined.size,
BodiesHidden: sessionDetails.bodiesHidden.size,
UnnoticedKills: sessionDetails.ghost.unnoticedKills,
KilledNpcs:
sessionDetails.npcKills.size + sessionDetails.crowdNpcKills,
Deaths: sessionDetails.ghost.deaths,
Duration: sessionDetails.duration,
},
}
}
export function getMultiplayerLoadoutData(
userData: UserProfile,
disguiseUnlockableId: string,
gameVersion: GameVersion,
) {
const allunlockables = getVersionedConfig<Unlockable[]>(
"allunlockables",
gameVersion,
false,
)
const inventory = createInventory(
userData.Id,
gameVersion,
userData.Extensions.entP,
)
let unlockable = inventory.find(
(unlockable) =>
unlockable.Unlockable.Id === disguiseUnlockableId &&
unlockable.Unlockable.Type === "disguise",
)?.Unlockable
if (!unlockable) {
unlockable = allunlockables.find(
(unlockable) => unlockable.Id === "TOKEN_OUTFIT_HITMANSUIT",
)
assert.ok(unlockable)
}
unlockable.GameAsset = null
unlockable.DisplayNameLocKey = `UI_${unlockable.Id}_NAME`
return [
{
SlotName: "disguise",
SlotId: "3",
Recommended: {
item: {
InstanceId: randomUUID(),
ProfileId: nilUuid,
Unlockable: unlockable,
Properties: {},
},
type: "disguise",
owned: true,
},
},
]
}
export function encodePushMessage<T>(timestamp: bigint, message: T): string {
const msgstr = JSON.stringify(message)
const msglength = Buffer.byteLength(msgstr, "utf8")
let totallength = msglength + 8 + 80 // using a fixed length of 8 for the timestamp for now...
totallength += 4 - (totallength % 4) // pad to the nearest multiple of 4
const output = Buffer.alloc(totallength)
let offset = 0
offset = output.writeUInt32LE(totallength, offset)
// no idea what these first two chunks are for
offset = output.writeUInt32LE(0x0000000c, offset)
offset = output.writeUInt16LE(0x0008, offset)
offset = output.writeUInt16LE(0x000e, offset)
offset = output.writeUInt16LE(0x0007, offset)
offset = output.writeUInt16LE(0x0008, offset)
offset = output.writeUInt32LE(0x00000008, offset)
offset = output.writeUInt32BE(0x00000002, offset)
offset = output.writeUInt32LE(0x00000014, offset)
offset = output.writeUInt16LE(0x0000, offset)
offset = output.writeUInt16LE(0x000e, offset)
offset = output.writeUInt16LE(0x0014, offset)
offset = output.writeUInt16LE(0x0006, offset)
offset = output.writeUInt16LE(0x0000, offset)
offset = output.writeUInt16LE(0x0005, offset)
offset = output.writeUInt16LE(0x0008, offset)
offset = output.writeUInt16LE(0x000c, offset)
offset = output.writeUInt32LE(0x0000000e, offset)
offset = output.writeUInt32BE(0x00010300, offset)
offset = output.writeUInt32LE(0x0c + 8, offset)
offset = output.writeBigUInt64LE(timestamp, offset)
offset = output.writeUInt16LE(0x0008, offset)
offset = output.writeUInt16LE(0x000c, offset)
offset = output.writeUInt16LE(0x0006, offset)
offset = output.writeUInt16LE(0x0008, offset)
offset = output.writeUInt32LE(0x00000008, offset)
offset = output.writeUInt32BE(0x00008300, offset)
offset = output.writeUInt32LE(0x04, offset)
offset = output.writeUInt32LE(msglength, offset)
offset = output.write(msgstr, offset, "utf8")
if (PEACOCK_DEV) {
log(LogLevel.DEBUG, `Encoded message offset: ${offset}`)
}
return output.toString("base64")
}

310
components/oauthToken.ts Normal file
View File

@ -0,0 +1,310 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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 type { Response } from "express"
import { decode, sign } from "jsonwebtoken"
import { extractToken, uuidRegex } from "./utils"
import type { GameVersion, RequestWithJwt, UserProfile } from "./types/types"
import { getVersionedConfig } from "./configSwizzleManager"
import { log, LogLevel } from "./loggingInterop"
import {
STEAM_NAMESPACE_2018,
STEAM_NAMESPACE_2021,
} from "./platformEntitlements"
import {
getExternalUserData,
getUserData,
loadUserData,
writeExternalUserData,
writeNewUserData,
} from "./databaseHandler"
import { OfficialServerAuth, userAuths } from "./officialServerAuth"
import { randomUUID } from "crypto"
import { getFlag } from "./flags"
import { clearInventoryFor } from "./inventory"
import {
EpicH1Strategy,
EpicH3Strategy,
IOIStrategy,
SteamH1Strategy,
SteamH2Strategy,
SteamScpcStrategy,
} from "./entitlementStrategies"
export async function handleOauthToken(
req: RequestWithJwt,
res: Response,
): Promise<void> {
const isFrankenstein = req.body.gs === "scpc-prod"
const signOptions = {
notBefore: -60000,
expiresIn: 6000,
issuer: "auth.hitman.io",
audience: isFrankenstein ? "scpc-prod" : "pc_prod_8",
noTimestamp: true,
}
//#region Refresh tokens
if (req.body.grant_type === "refresh_token") {
// send back the token from the request (re-signed so the timestamps update)
extractToken(req) // init req.jwt
// remove signOptions from existing jwt
// ts-expect-error Non-optional, we're reassigning.
delete req.jwt.nbf // notBefore
// ts-expect-error Non-optional, we're reassigning.
delete req.jwt.exp // expiresIn
// ts-expect-error Non-optional, we're reassigning.
delete req.jwt.iss // issuer
// ts-expect-error Non-optional, we're reassigning.
delete req.jwt.aud // audience
if (getFlag("officialAuthentication") === true && !isFrankenstein) {
if (userAuths.has(req.jwt.unique_name)) {
userAuths
.get(req.jwt.unique_name)!
._doRefresh()
.then(() => undefined)
.catch(() => {
log(LogLevel.WARN, "Failed authentication refresh.")
userAuths.get(req.jwt.unique_name)!.initialized = false
})
}
}
res.json({
access_token: sign(req.jwt, "secret", signOptions),
token_type: "bearer",
expires_in: 5000,
refresh_token: randomUUID(),
})
return
}
//#endregion
let external_platform: "steam" | "epic",
external_userid: string,
external_users_folder: "steamids" | "epicids",
external_appid: string
if (req.body.grant_type === "external_steam") {
if (!/^\d{1,20}$/.test(req.body.steam_userid)) {
res.status(400).end() // invalid steam user id
return
}
external_platform = "steam"
external_userid = req.body.steam_userid
external_users_folder = "steamids"
external_appid = req.body.steam_appid
} else if (req.body.grant_type === "external_epic") {
if (!/^[\da-f]{32}$/.test(req.body.epic_userid)) {
res.status(400).end() // invalid epic user id
return
}
const epic_token = decode(
req.body.access_token.replace(/^eg1~/, ""),
) as {
appid: string
app: string
}
if (!epic_token || !(epic_token.appid || epic_token.app)) {
res.status(400).end() // invalid epic access token
return
}
external_appid = epic_token.appid || epic_token.app
external_platform = "epic"
external_userid = req.body.epic_userid
external_users_folder = "epicids"
} else {
res.status(406).end() // unsupported auth method
return
}
if (req.body.pId && !uuidRegex.test(req.body.pId)) {
res.status(400).end() // pId is not a GUID
return
}
const isHitman3 =
external_appid === "fghi4567xQOCheZIin0pazB47qGUvZw4" ||
external_appid === STEAM_NAMESPACE_2021
const gameVersion: GameVersion = isFrankenstein
? "scpc"
: isHitman3
? "h3"
: external_appid === STEAM_NAMESPACE_2018
? "h2"
: "h1"
if (!req.body.pId) {
// if no profile id supplied
try {
req.body.pId = (
await getExternalUserData(
external_userid,
external_users_folder,
gameVersion,
)
).toString()
} catch (e) {
req.body.pId = randomUUID()
await writeExternalUserData(
external_userid,
external_users_folder,
req.body.pId,
gameVersion,
)
}
} else {
// if a profile id is supplied
getExternalUserData(external_userid, external_users_folder, gameVersion)
.then(() => null)
.catch(async () => {
// external id is not yet linked to this profile
await writeExternalUserData(
external_userid,
external_users_folder,
req.body.pId,
gameVersion,
)
})
}
try {
await loadUserData(req.body.pId, gameVersion)
} catch (e) {
log(LogLevel.DEBUG, "Unable to load profile information.")
}
if (getFlag("officialAuthentication") === true && !isFrankenstein) {
const authContainer = new OfficialServerAuth(
gameVersion,
req.body.access_token,
)
log(LogLevel.DEBUG, `Setting up container with ID ${req.body.pId}.`)
userAuths.set(req.body.pId, authContainer)
await authContainer._initiallyAuthenticate(req)
}
if (getUserData(req.body.pId, gameVersion) === undefined) {
// User does not exist, create new profile from default:
log(LogLevel.DEBUG, `Create new profile ${req.body.pId}`)
const userData = getVersionedConfig(
"UserDefault",
gameVersion,
true,
) as UserProfile
userData.Id = req.body.pId
userData.LinkedAccounts[external_platform] = external_userid
if (external_platform === "steam") {
userData.SteamId = req.body.steam_userid
} else if (external_platform === "epic") {
userData.EpicId = req.body.epic_userid
}
// eslint-disable-next-line no-inner-declarations
async function getEntitlements(): Promise<string[]> {
if (isFrankenstein) {
return new SteamScpcStrategy().get()
}
if (gameVersion === "h1") {
if (external_platform === "steam") {
return new SteamH1Strategy().get()
} else if (external_platform === "epic") {
return new EpicH1Strategy().get()
} else {
log(LogLevel.ERROR, "Unsupported platform.")
return []
}
}
if (gameVersion === "h2") {
return new SteamH2Strategy().get()
}
if (gameVersion === "h3") {
if (external_platform === "epic") {
return await new EpicH3Strategy().get(
req.body.access_token,
req.body.epic_userid,
)
} else if (external_platform === "steam") {
return await new IOIStrategy(
gameVersion,
STEAM_NAMESPACE_2021,
).get(req.body.pId)
} else {
log(LogLevel.ERROR, "Unsupported platform.")
return []
}
}
log(LogLevel.ERROR, "Unsupported platform.")
return []
}
userData.Extensions.entP = await getEntitlements()
if (
Object.prototype.hasOwnProperty.call(
userData.Extensions,
"inventory",
)
) {
// @ts-expect-error No longer in the typedefs.
delete userData.Extensions.inventory
}
writeNewUserData(req.body.pId, userData, gameVersion)
}
// Format here follows steam_external, Epic jwt has some different fields
const userinfo = {
"auth:method": req.body.grant_type,
roles: "user",
sub: req.body.pId,
unique_name: req.body.pId,
userid: external_userid,
platform: external_platform,
locale: req.body.locale,
rgn: req.body.rgn,
pis: external_appid,
cntry: req.body.locale,
}
clearInventoryFor(req.body.pId)
res!.json({
access_token: sign(userinfo, "secret", signOptions),
token_type: "bearer",
expires_in: 5000,
refresh_token: randomUUID(),
})
}

Some files were not shown because too many files have changed in this diff Show More