/* * The Peacock Project - a HITMAN server replacement. * Copyright (C) 2021-2023 The Peacock Project Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. */ import { ChallengeProgressionData, CompiledChallengeRewardData, CompiledChallengeRuntimeData, InclusionData, MissionManifest, RegistryChallenge, } from "../types/types" import assert from "assert" import { SavedChallengeGroup } from "../types/challenges" import { controller } from "../controller" import { gameDifficulty } from "../utils" 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", /** Only used for the CAREER -> CHALLENGES page */ Contracts = "Contracts", } export type ChallengeFilterOptions = | { type: ChallengeFilterType.None } | { type: ChallengeFilterType.Contract contractId: string locationId: string isFeatured?: boolean difficulty: number } | { type: ChallengeFilterType.Contracts contractIds: string[] locationId: string } /** * Checks if the metadata of a contract matches the definition in the InclusionData of a challenge. * @param incData The inclusion data of the challenge in question. Will return true if this is null. * @param contract The contract in question. * @returns A boolean as the result. */ export function inclusionDataCheck( incData: InclusionData, contract: MissionManifest, ): boolean { if (!incData) return true return ( incData.ContractIds?.includes(contract.Metadata.Id) || incData.ContractTypes?.includes(contract.Metadata.Type) || incData.Locations?.includes(contract.Metadata.Location) || contract.Metadata?.Gamemodes?.some((r) => incData.GameModes?.includes(r), ) ) } export function isChallengeForDifficulty( difficulty: number, challenge: RegistryChallenge, ): boolean { return ( !challenge.DifficultyLevels || challenge.DifficultyLevels.length === 0 || gameDifficulty[challenge.DifficultyLevels[0]] <= difficulty ) } /** * Judges whether a challenge should be included in the challenges list of a contract. * @requires The challenge and the contract share the same parent location. * @param contractId The id of the contract. * @param locationId The sublocation ID of the challenge. * @param difficulty The upper bound on the difficulty of the challenges to return. * @param challenge The challenge in question. * @param forCareer Whether the result is used to decide what is shown the CAREER -> CHALLENGES page. Defaulted to false. * @returns A boolean value, denoting the result. */ function isChallengeInContract( contractId: string, locationId: string, difficulty: number, challenge: RegistryChallenge, forCareer = false, ): boolean { assert.ok(contractId) assert.ok(locationId) if (!challenge) { return false } if (!isChallengeForDifficulty(difficulty, challenge)) { return false } if ( locationId === "LOCATION_HOKKAIDO_SHIM_MAMUSHI" && challenge.LocationId === "LOCATION_HOKKAIDO" ) { // Special case: winter festival has its own locationId, but for Hokkaido-wide challenges, // the locationId is "LOCATION_HOKKAIDO", not "LOCATION_PARENT_HOKKAIDO". return true } if (challenge.Type === "global") { return inclusionDataCheck( // Global challenges should not be shown for "tutorial" missions unless for the career page, // despite the InclusionData somehow saying otherwise. forCareer ? challenge.InclusionData : { ...challenge.InclusionData, ContractTypes: challenge.InclusionData.ContractTypes.filter( (type) => type !== "tutorial", ), }, controller.resolveContract(contractId), ) } // Is this for the current contract? const isForContract = (challenge.InclusionData?.ContractIds || []).includes( contractId, ) // Is this a location-wide challenge? // "location" is more widely used, but "parentlocation" is used in Ambrose and Berlin, as well as some "Discover XX" challenges. const isForLocation = challenge.Type === "location" || challenge.Type === "parentlocation" // Is this for the current location? const isCurrentLocation = // Is this challenge's location one of these things: // 1. The current sub-location, e.g. "LOCATION_COASTALTOWN_NIGHT". This is the most common. // 2. The parent location (yup, that can happen), e.g. "LOCATION_PARENT_HOKKAIDO" in Discover Hokkaido. challenge.LocationId === locationId || challenge.LocationId === challenge.ParentLocationId return isForContract || (isForLocation && isCurrentLocation) } export function filterChallenge( options: ChallengeFilterOptions, challenge: RegistryChallenge, ): boolean { switch (options.type) { case ChallengeFilterType.None: return true case ChallengeFilterType.Contract: { return isChallengeInContract( options.contractId, options.locationId, options.difficulty, challenge, ) } case ChallengeFilterType.Contracts: { return options.contractIds.some((contractId) => isChallengeInContract( contractId, options.locationId, gameDifficulty.master, // Get challenges of all difficulties challenge, true, ), ) } } } /** * Merges the Challenge field two SavedChallengeGroup objects and returns a new object. Does not modify the original objects. For all the other fields, the values of g1 is used. * @param g1 One of the SavedChallengeGroup objects. * @param g2 The other SavedChallengeGroup object. * @returns A new object with the Challenge arrays merged. */ export function mergeSavedChallengeGroups( g1: SavedChallengeGroup, g2: SavedChallengeGroup, ): SavedChallengeGroup { return { ...g1, Challenges: [...(g1?.Challenges ?? []), ...(g2?.Challenges ?? [])], } }