First commit of os-league-tools-master

This commit is contained in:
2025-10-27 08:36:10 +08:00
parent a5aab68ea4
commit 31ee652bff
528 changed files with 114970 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
/**
* @see https://oldschool.runescape.wiki/w/Combat_level
* @param {{}} skills Pass in Redux stored hiscores object
*/
export default function calculateCombatLevel({
attack = {},
strength = {},
defence = {},
prayer = {},
hitpoints = {},
magic = {},
ranged = {},
} = {}) {
const base = 0.25 * (defence.level + hitpoints.level + Math.floor(prayer.level / 2));
const meeleCombat = 0.325 * (attack.level + strength.level);
const rangedCombat = 0.325 * (ranged.level * 1.5);
const magicCombat = 0.325 * (magic.level * 1.5);
const combatLevel = base + Math.max(...[meeleCombat, rangedCombat, magicCombat]);
return {
exact: combatLevel,
rounded: Math.floor(combatLevel),
};
}

View File

@@ -0,0 +1,19 @@
import QUESTS, { QUEST_STATUS } from '../data/quests';
export default function calculateQuestStats(questState) {
const completedQuests = Object.entries(questState).filter(([, status]) => status === QUEST_STATUS.FINISHED);
const inProgressQuests = Object.entries(questState).filter(([, status]) => status === QUEST_STATUS.IN_PROGRESS);
const totalQuests = QUESTS.length;
let questPoints = 0;
const finished = completedQuests.length;
const inProgress = inProgressQuests.length;
const notStarted = totalQuests - finished - inProgress;
for (const [id] of completedQuests) {
const questDetails = QUESTS.find(quest => quest.id === id);
questPoints += questDetails?.points || 0;
}
return { points: questPoints, finished, inProgress, notStarted };
}

View File

@@ -0,0 +1,213 @@
import { forEach } from 'lodash';
import { DIFFICULTY } from '../data/constants';
import { NONE_REGION_ID, regionsById } from '../data/regions';
import tasks from '../data/tasks';
const ALL_TASKS = {
total: {
Easy: 224,
Medium: 536,
Hard: 437,
Elite: 345,
Master: 47,
total: 1589,
},
General: {
Easy: 76,
Medium: 177,
Hard: 101,
Elite: 127,
Master: 7,
total: 488,
},
Misthalin: {
Easy: 42,
Medium: 44,
Hard: 16,
Elite: 12,
Master: 0,
total: 114,
},
Karamja: {
Easy: 5,
Medium: 17,
Hard: 13,
Elite: 12,
Master: 7,
total: 54,
},
Asgarnia: {
Easy: 8,
Medium: 32,
Hard: 30,
Elite: 22,
Master: 3,
total: 95,
},
Desert: {
Easy: 15,
Medium: 39,
Hard: 34,
Elite: 19,
Master: 5,
total: 112,
},
Fremennik: {
Easy: 14,
Medium: 32,
Hard: 41,
Elite: 23,
Master: 2,
total: 112,
},
Kandarin: {
Easy: 13,
Medium: 37,
Hard: 30,
Elite: 20,
Master: 3,
total: 103,
},
Kourend: {
Easy: 15,
Medium: 41,
Hard: 38,
Elite: 22,
Master: 3,
total: 119,
},
Morytania: {
Easy: 7,
Medium: 35,
Hard: 31,
Elite: 23,
Master: 5,
total: 101,
},
Tirannwn: {
Easy: 8,
Medium: 16,
Hard: 33,
Elite: 24,
Master: 4,
total: 85,
},
Varlamore: {
Easy: 12,
Medium: 40,
Hard: 39,
Elite: 15,
Master: 5,
total: 111,
},
Wilderness: {
Easy: 9,
Medium: 26,
Hard: 31,
Elite: 26,
Master: 3,
total: 95,
},
};
export default function calculateTaskStats(taskState, unlockedRegions) {
const tasksCount = {
complete: {
Easy: 0,
Medium: 0,
Hard: 0,
Elite: 0,
Master: 0,
},
todo: {
Easy: 0,
Medium: 0,
Hard: 0,
Elite: 0,
Master: 0,
},
ignored: {
Easy: 0,
Medium: 0,
Hard: 0,
Elite: 0,
Master: 0,
},
};
Object.keys(taskState).forEach(taskId => {
if (!tasks[taskId]) {
return;
}
const { difficulty } = tasks[taskId];
const { completed, todo, ignored } = taskState[taskId];
tasksCount.complete[difficulty.label] += completed ? 1 : 0;
tasksCount.todo[difficulty.label] += todo ? 1 : 0;
tasksCount.ignored[difficulty.label] += ignored ? 1 : 0;
});
const allTasks = { ...ALL_TASKS.General };
forEach(unlockedRegions, regionId => {
if (regionId === NONE_REGION_ID) {
return;
}
const regionName = regionsById[regionId].label;
allTasks.Easy += ALL_TASKS[regionName].Easy;
allTasks.Medium += ALL_TASKS[regionName].Medium;
allTasks.Hard += ALL_TASKS[regionName].Hard;
allTasks.Elite += ALL_TASKS[regionName].Elite;
allTasks.Master += ALL_TASKS[regionName].Master;
});
tasksCount.available = {
Easy: allTasks.Easy - tasksCount.ignored.Easy,
Medium: allTasks.Medium - tasksCount.ignored.Medium,
Hard: allTasks.Hard - tasksCount.ignored.Hard,
Elite: allTasks.Elite - tasksCount.ignored.Elite,
Master: allTasks.Master - tasksCount.ignored.Master,
};
const pointsCount = {
points: {
complete: {},
todo: {},
available: {},
},
};
Object.keys(DIFFICULTY).forEach(difficultyKey => {
const { label: difficulty, value } = DIFFICULTY[difficultyKey];
pointsCount.points.complete[difficulty] = tasksCount.complete[difficulty] * value;
pointsCount.points.todo[difficulty] = tasksCount.todo[difficulty] * value;
pointsCount.points.available[difficulty] = tasksCount.available[difficulty] * value;
});
return {
tasks: {
complete: {
...tasksCount.complete,
total: Object.values(tasksCount.complete).reduce((a, b) => a + b),
},
todo: {
...tasksCount.todo,
total: Object.values(tasksCount.todo).reduce((a, b) => a + b),
},
available: {
...tasksCount.available,
total: Object.values(tasksCount.available).reduce((a, b) => a + b),
},
},
points: {
complete: {
...pointsCount.points.complete,
total: Object.values(pointsCount.points.complete).reduce((a, b) => a + b),
},
todo: {
...pointsCount.points.todo,
total: Object.values(pointsCount.points.todo).reduce((a, b) => a + b),
},
available: {
...pointsCount.points.available,
total: Object.values(pointsCount.points.available).reduce((a, b) => a + b),
},
},
};
}

View File

@@ -0,0 +1,30 @@
export default function getAccentColorForTheme(theme) {
switch (theme) {
case 'tl-dark':
return 'rgb(164 206 39)';
case 'tb-dark':
return 'rgb(229 217 147)';
case 'sl-dark':
return 'rgb(19 213 145)';
case 'mono-dark':
return 'rgb(249 250 251)s';
case 'tl-light':
return 'rgb(100 144 68)';
case 'tb-light':
return 'rgb(99 66 40)';
case 'sl-light':
return 'rgb(0 128 118)';
case 'tr-light':
return 'rgb(180 74 30)';
case 'tr-dark':
return 'rgb(220 139 54)';
case 're-dark':
return 'rgb(198 121 160)';
case 're-light':
return 'rgb(76 33 54)';
case 'mono-light':
return 'rgb(55 65 81)';
default:
return 'rgb(19 213 145)';
}
}

View File

@@ -0,0 +1,18 @@
import { questsById } from '../data/quests';
function traverseQuestsRecursive(questId, callback) {
const { prereqs } = questsById[questId];
prereqs.forEach(id => {
callback(id);
traverseQuestsRecursive(id, callback);
});
}
export default function getAllQuestPrereqs(questIds) {
const allPrereqs = new Set();
questIds.forEach(questId => {
allPrereqs.add(questId);
traverseQuestsRecursive(questId, id => allPrereqs.add(id));
});
return Array.from(allPrereqs);
}

View File

@@ -0,0 +1,16 @@
import { STATS } from '../data/constants';
export default function getSkillsPanelData({ customExclusions = [] } = {}) {
const skillsArr = [];
const excludedItems = ['QP', 'Combat', ...customExclusions];
Object.keys(STATS).forEach(skillName => {
if (excludedItems.includes(skillName)) {
return;
}
const skillData = STATS[skillName];
skillsArr[skillData.panelOrder] = skillData;
});
return skillsArr;
}

View File

@@ -0,0 +1,41 @@
import { PASSIVE_RELICS, RELIC_UNLOCK_THRESHOLDS } from '../data/relics';
import { REGION_UNLOCK_THRESHOLDS } from '../data/regions';
import { TROPHY_THRESHOLDS } from '../data/trophies';
export function getTier(points) {
let tier = 0;
for (let i = 0; i < RELIC_UNLOCK_THRESHOLDS.length; i++) {
if (points < RELIC_UNLOCK_THRESHOLDS[i]) {
break;
}
tier++;
}
return tier;
}
export function getExpMultiplier(tier) {
const EXP_MULTIPLIERS = PASSIVE_RELICS.map(tierDetails => tierDetails.exp.multiplier);
return EXP_MULTIPLIERS[tier];
}
export function getRegionTier(tasksCompleted) {
let tier = -1;
for (let i = 0; i < REGION_UNLOCK_THRESHOLDS.length; i++) {
if (tasksCompleted < REGION_UNLOCK_THRESHOLDS[i]) {
break;
}
tier++;
}
return tier;
}
export function getTrophyTier(points) {
let tier = -1;
for (let i = 0; i < TROPHY_THRESHOLDS.length; i++) {
if (points < TROPHY_THRESHOLDS[i]) {
break;
}
tier++;
}
return tier;
}

View File

@@ -0,0 +1,53 @@
const SECOND = 1000;
const MINUTE = SECOND * 60;
const HOUR = MINUTE * 60;
const DAY = HOUR * 24;
function getTimeUnits(timestamp) {
return {
days: Math.floor(timestamp / DAY),
hours: Math.floor((timestamp % DAY) / HOUR),
minutes: Math.floor((timestamp % HOUR) / MINUTE),
seconds: Math.floor((timestamp % MINUTE) / SECOND),
};
}
export function durationAsCountdown(endDate, startDate = Date.now()) {
const remainingTime = endDate - startDate;
if (remainingTime < 0) {
return null;
}
const { days, hours, minutes, seconds } = getTimeUnits(remainingTime);
return `${days}d:${hours}h:${minutes}m:${seconds}s`;
}
export function durationAsRelativeTime(endDate, startDate = Date.now()) {
if (!endDate) {
return 'Never';
}
const timeDiff = Math.abs(endDate - startDate);
const outputPrefix = endDate > startDate ? 'in ' : '';
const outputSuffix = endDate > startDate ? '' : ' ago';
const { days, hours, minutes, seconds } = getTimeUnits(timeDiff);
if (days > 0) {
return `${outputPrefix} ${days} day(s) ${outputSuffix}`;
} else if (hours > 0) {
return `${outputPrefix} ${hours} hour(s) ${outputSuffix}`;
} else if (minutes > 0) {
return `${outputPrefix} ${minutes} minute(s) ${outputSuffix}`;
} else if (seconds > 0) {
return `${outputPrefix} ${seconds} second(s) ${outputSuffix}`;
}
return 'Just now';
}
/**
* Formats a number into a locale string
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/toLocaleString
* @param {*} number
* @param {*} options
* @returns
*/
export function numberWithCommas(number, options) {
return number.toLocaleString(undefined, { ...options });
}

View File

@@ -0,0 +1,99 @@
import { difference } from 'lodash';
import { UNLOCKED_REGION_FILTER_VALUE } from '../components/CalculatorFilters';
import { STATS } from '../data/constants';
import { regionsById } from '../data/regions';
import tasks, { REGION_ANY } from '../data/tasks';
function difficultyFilter(record, filterState) {
if (filterState.difficulty === null) {
return true;
}
return filterState.difficulty.includes(record.difficulty.label);
}
function categoryFilter(record, filterState) {
if (filterState.categories === null) {
return true;
}
const recordCategory = `${record.category.label}-${record.subcategory.label}`;
return filterState.categories.includes(recordCategory);
}
function skillFilter(record, filterState, { hiscoresState }) {
const taskSkills = record.skillReqs.map(req => req.skill);
// Check if required skills are included in filter
const includeNoReq = filterState.showNoRequirements;
const hasValidSkills = difference(taskSkills, filterState.skills).length === 0;
if (!hasValidSkills || (!includeNoReq && taskSkills.length === 0)) {
return false;
}
// Check if level requirements should be ignored
if (!hiscoresState || hiscoresState.loading || hiscoresState.error || filterState.showUnmetRequirements) {
return true;
}
// Check if level requirements are met
let meetsRequirements = true;
record.skillReqs.forEach(skillReq => {
const hiscores = hiscoresState.skills[skillReq.skill.toLowerCase()];
const levelBoost = filterState.isProductionProdigy && STATS[skillReq.skill]?.productionProdigyEligible ? 12 : 0;
const level = (hiscores?.level || 1) + levelBoost;
meetsRequirements = meetsRequirements && level >= skillReq.level;
});
return meetsRequirements;
}
function prereqFilter(record, filterState, { taskState }) {
if (filterState.showIncompletePrereqs || !record.prerequisite) {
return true;
}
const prereq = tasks[record.prerequisite];
return !!taskState[prereq.id]?.completed;
}
function completedFilter(record, filterState, { taskState }) {
if (filterState.status === 'all') {
return true;
}
const status = !!taskState[record.id]?.completed;
return (filterState.status === 'cmpl') === !!status;
}
function todoFilter(record, filterState, { taskState }) {
if (filterState.todo === 'all') {
return true;
}
const todo = !!taskState[record.id]?.todo;
return (filterState.todo === 'only') === !!todo;
}
function ignoredFilter(record, filterState, { taskState }) {
if (filterState.ignored === 'all') {
return true;
}
const ignored = !!taskState[record.id]?.ignored;
return (filterState.ignored === 'only') === !!ignored;
}
function regionsFilter(record, filterState, { regionsState }) {
const unlockedRegionNames = regionsState.filter(id => id >= 0).map(id => regionsById[id].label);
if (filterState.regions[0] === UNLOCKED_REGION_FILTER_VALUE) {
return record.regions[0] === REGION_ANY || record.regions.some(area => unlockedRegionNames.includes(area));
}
return record.regions.some(area => filterState.regions.includes(area));
}
export default [
difficultyFilter,
categoryFilter,
skillFilter,
completedFilter,
todoFilter,
ignoredFilter,
prereqFilter,
regionsFilter,
];

View File

@@ -0,0 +1,14 @@
// from https://stackoverflow.com/questions/34347008/how-can-i-sort-a-javascript-array-while-ignoring-articles-a-an-the
function stripArticles(title) {
const articles = ['a', 'an', 'the'];
const re = new RegExp(`^(?:(${articles.join('|')}) )(.*)$`);
const replacor = ($0, $1, $2) => `${$2}, ${$1}`;
return title.toLowerCase().replace(re, replacor);
}
export default function titleSort(a, b) {
const cleanA = stripArticles(a);
const cleanB = stripArticles(b);
return cleanA === cleanB ? 0 : cleanA < cleanB ? -1 : 1;
}

View File

@@ -0,0 +1,21 @@
export const LEVEL_99_XP = 13034431;
export function levelToExperience(level) {
let sum = 0;
for (let i = 1; i < level; i++) {
sum += Math.floor(i + 300 * 2 ** (i / 7));
}
return Math.floor(0.25 * sum);
}
export function experienceToLevel(experience) {
let level = 0;
for (let i = 1; i <= 126; i++) {
if (levelToExperience(i + 1) > experience) {
level = i;
break;
}
}
return level;
}