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
JavaScript
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("|"));
}