UNPKG

osrs-tools

Version:

A comprehensive TypeScript library for Old School RuneScape (OSRS) data and utilities, including quest data, skill requirements, and game item information

211 lines (210 loc) 9.22 kB
import { CombatStyle, PactNodeSize } from "./DemonicPact.model"; const DEFAULT_BEAM_WIDTH = 48; export function getInitialUnlockedPactIds(tree) { return [tree.rootNodeId]; } export function getPactsByStyle(tree, style, includeUniversal = true) { if (style === CombatStyle.Universal) { return tree.pacts.filter((pact) => pact.style === CombatStyle.Universal); } return tree.pacts.filter((pact) => pact.style === style || (includeUniversal && pact.style === CombatStyle.Universal)); } export function getPactById(tree, pactId) { return tree.pacts.find((pact) => pact.id === pactId); } /** * Calculates the total points spent on a given selection of pacts based on their official point costs. * @param tree The demonic pact tree containing all pacts. * @param unlockedPactIds The IDs of the pacts that have been unlocked. * @returns The total points spent on the unlocked pacts. */ export function calculateSpentPoints(tree, unlockedPactIds) { const unlockedSet = new Set(unlockedPactIds); return tree.pacts.filter((pact) => unlockedSet.has(pact.id)).reduce((total, pact) => total + pact.pointCost, 0); } /** * Determines if a pact can be unlocked given the current tree state and already unlocked pacts. * @param tree * @param unlockedPactIds * @param pactId * @returns */ export function canUnlockPact(tree, unlockedPactIds, pactId) { const unlockedSet = new Set(unlockedPactIds); const pact = getPactById(tree, pactId); const spent = calculateSpentPoints(tree, unlockedPactIds); const pointsRemaining = tree.totalPointsAvailable - spent; if (!pact) { return { unlockable: false, reason: `Unknown pact id: ${pactId}`, pointsRemaining }; } if (unlockedSet.has(pact.id)) { return { unlockable: false, reason: `Pact already unlocked: ${pact.id}`, pointsRemaining }; } if (pact.pointCost > pointsRemaining) { return { unlockable: false, reason: `Not enough points to unlock ${pact.id}`, pointsRemaining, }; } if (unlockedSet.size === 0) { if (pact.id === tree.rootNodeId) { return { unlockable: true, pointsRemaining: pointsRemaining - pact.pointCost }; } return { unlockable: false, reason: `The root pact ${tree.rootNodeId} must be unlocked first`, pointsRemaining, missingLinkedPactIds: [tree.rootNodeId], }; } const linkedUnlockedIds = pact.linkedNodeIds.filter((linkedNodeId) => unlockedSet.has(linkedNodeId)); if (linkedUnlockedIds.length === 0) { return { unlockable: false, reason: `Pact must connect to an already unlocked pact`, pointsRemaining, missingLinkedPactIds: pact.linkedNodeIds, }; } return { unlockable: true, pointsRemaining: pointsRemaining - pact.pointCost }; } export function getUnlockablePacts(tree, unlockedPactIds, style) { const candidates = style ? getPactsByStyle(tree, style) : tree.pacts; return candidates.filter((pact) => canUnlockPact(tree, unlockedPactIds, pact.id).unlockable); } export function optimizeDemonicPactLoadout(tree, options) { const pointBudget = options.pointBudget ?? tree.totalPointsAvailable; const preferredStyles = options.preferredStyles.length > 0 ? options.preferredStyles : [CombatStyle.Universal]; const excludedPactIds = new Set(options.excludedPactIds ?? []); const requiredPactIds = new Set(options.requiredPactIds ?? []); const beamWidth = options.beamWidth ?? DEFAULT_BEAM_WIDTH; const startingPactIds = getStartingPactIds(tree, options); validateStartingPacts(tree, startingPactIds, pointBudget); let states = [ { selectedPactIds: [...startingPactIds], unlockOrder: [...startingPactIds], score: calculateSelectionScore(tree, startingPactIds, preferredStyles, options.scorer), }, ]; while (true) { const nextStates = new Map(); let expandedAnyState = false; for (const state of states) { const unlockablePacts = getUnlockablePacts(tree, state.selectedPactIds).filter((pact) => !excludedPactIds.has(pact.id)); let expandedState = false; for (const pact of unlockablePacts) { const nextSelectedPactIds = [...state.selectedPactIds, pact.id]; const spentPoints = calculateSpentPoints(tree, nextSelectedPactIds); if (spentPoints > pointBudget) { continue; } expandedAnyState = true; expandedState = true; const nextState = { selectedPactIds: nextSelectedPactIds, unlockOrder: [...state.unlockOrder, pact.id], score: state.score + scorePact(tree, pact, state.selectedPactIds, preferredStyles, options.scorer), }; const stateKey = nextSelectedPactIds.join("|"); const existingState = nextStates.get(stateKey); if (!existingState || compareStates(nextState, existingState) < 0) { nextStates.set(stateKey, nextState); } } if (!expandedState) { const stateKey = state.selectedPactIds.join("|"); const existingState = nextStates.get(stateKey); if (!existingState || compareStates(state, existingState) < 0) { nextStates.set(stateKey, state); } } } if (!expandedAnyState) { break; } states = [...nextStates.values()].sort(compareStates).slice(0, beamWidth); } const bestState = [...states].filter((state) => hasRequiredPacts(state.selectedPactIds, requiredPactIds)).sort(compareStates)[0]; if (!bestState) { throw new Error("Unable to satisfy the required pact selection within the provided point budget"); } const spentPoints = calculateSpentPoints(tree, bestState.selectedPactIds); return { selectedPactIds: [...bestState.selectedPactIds], unlockOrder: [...bestState.unlockOrder], selectedPacts: bestState.selectedPactIds.map((pactId) => getPactById(tree, pactId)).filter((pact) => Boolean(pact)), spentPoints, remainingPoints: pointBudget - spentPoints, score: bestState.score, unlockableNextPacts: getUnlockablePacts(tree, bestState.selectedPactIds).filter((pact) => !excludedPactIds.has(pact.id)), }; } function getStartingPactIds(tree, options) { const providedPactIds = options.startingPactIds ? [...new Set(options.startingPactIds)] : []; if (providedPactIds.length > 0) { return providedPactIds; } if (options.includeRoot === false) { return []; } return getInitialUnlockedPactIds(tree); } function validateStartingPacts(tree, startingPactIds, pointBudget) { const validatedPactIds = []; for (const pactId of startingPactIds) { const evaluation = canUnlockPact(tree, validatedPactIds, pactId); if (!evaluation.unlockable) { throw new Error(evaluation.reason ?? `Invalid starting pact: ${pactId}`); } validatedPactIds.push(pactId); } const spentPoints = calculateSpentPoints(tree, validatedPactIds); if (spentPoints > pointBudget) { throw new Error(`Starting pact selection spends ${spentPoints} points, exceeding the ${pointBudget} point budget`); } } function calculateSelectionScore(tree, selectedPactIds, preferredStyles, scorer) { let score = 0; const runningSelection = []; for (const pactId of selectedPactIds) { const pact = getPactById(tree, pactId); if (!pact) { continue; } score += scorePact(tree, pact, runningSelection, preferredStyles, scorer); runningSelection.push(pactId); } return score; } function scorePact(tree, pact, selectedPactIds, preferredStyles, scorer) { const scoringContext = { tree, selectedPactIds, preferredStyles, }; return scorer ? scorer(pact, scoringContext) : scorePactByStyleAndSize(pact, preferredStyles); } function scorePactByStyleAndSize(pact, preferredStyles) { const styleWeight = preferredStyles.includes(pact.style) ? 7 : pact.style === CombatStyle.Universal ? 4 : 1; const sizeWeight = pact.nodeSize === PactNodeSize.Capstone ? 4 : pact.nodeSize === PactNodeSize.Major ? 2 : 1; return styleWeight + sizeWeight + pact.linkedNodeIds.length * 0.05; } function hasRequiredPacts(selectedPactIds, requiredPactIds) { for (const pactId of requiredPactIds) { if (!selectedPactIds.includes(pactId)) { return false; } } return true; } function compareStates(left, right) { if (left.score !== right.score) { return right.score - left.score; } if (left.selectedPactIds.length !== right.selectedPactIds.length) { return right.selectedPactIds.length - left.selectedPactIds.length; } return left.selectedPactIds.join("|").localeCompare(right.selectedPactIds.join("|")); }