dotaconstants
Version:
Constant data for Dota 2 applications
1,403 lines (1,331 loc) • 68.9 kB
text/typescript
import fs from "node:fs";
import vdfparser from "vdf-parser";
import { cleanupArray } from "./util.ts";
const extraStrings = {
DOTA_ABILITY_BEHAVIOR_NONE: "None",
DOTA_ABILITY_BEHAVIOR_PASSIVE: "Passive",
DOTA_ABILITY_BEHAVIOR_UNIT_TARGET: "Unit Target",
DOTA_ABILITY_BEHAVIOR_CHANNELLED: "Channeled",
DOTA_ABILITY_BEHAVIOR_POINT: "Point Target",
DOTA_ABILITY_BEHAVIOR_ROOT_DISABLES: "Root",
DOTA_ABILITY_BEHAVIOR_AOE: "AOE",
DOTA_ABILITY_BEHAVIOR_NO_TARGET: "No Target",
DOTA_ABILITY_BEHAVIOR_AUTOCAST: "Autocast",
DOTA_ABILITY_BEHAVIOR_ATTACK: "Attack Modifier",
DOTA_ABILITY_BEHAVIOR_IMMEDIATE: "Instant Cast",
DOTA_ABILITY_BEHAVIOR_HIDDEN: "Hidden",
DAMAGE_TYPE_PHYSICAL: "Physical",
DAMAGE_TYPE_MAGICAL: "Magical",
DAMAGE_TYPE_PURE: "Pure",
SPELL_IMMUNITY_ENEMIES_YES: "Yes",
SPELL_IMMUNITY_ENEMIES_NO: "No",
SPELL_IMMUNITY_ALLIES_YES: "Yes",
SPELL_IMMUNITY_ALLIES_NO: "No",
SPELL_DISPELLABLE_YES: "Yes",
SPELL_DISPELLABLE_YES_STRONG: "Strong Dispels Only",
SPELL_DISPELLABLE_NO: "No",
DOTA_UNIT_TARGET_TEAM_BOTH: "Both",
DOTA_UNIT_TARGET_TEAM_ENEMY: "Enemy",
DOTA_UNIT_TARGET_TEAM_FRIENDLY: "Friendly",
DOTA_UNIT_TARGET_HERO: "Hero",
DOTA_UNIT_TARGET_BASIC: "Basic",
DOTA_UNIT_TARGET_BUILDING: "Building",
DOTA_UNIT_TARGET_TREE: "Tree",
};
const ignoreStrings = new Set([
"DOTA_ABILITY_BEHAVIOR_ROOT_DISABLES",
"DOTA_ABILITY_BEHAVIOR_DONT_RESUME_ATTACK",
"DOTA_ABILITY_BEHAVIOR_DONT_RESUME_MOVEMENT",
"DOTA_ABILITY_BEHAVIOR_IGNORE_BACKSWING",
"DOTA_ABILITY_BEHAVIOR_TOGGLE",
"DOTA_ABILITY_BEHAVIOR_IGNORE_PSEUDO_QUEUE",
"DOTA_ABILITY_BEHAVIOR_SHOW_IN_GUIDES",
]);
const badNames = new Set([
"Version",
"npc_dota_hero_base",
"npc_dota_hero_target_dummy",
"npc_dota_units_base",
"npc_dota_thinker",
"npc_dota_companion",
"npc_dota_loadout_generic",
"npc_dota_techies_remote_mine",
"npc_dota_treant_life_bomb",
"npc_dota_lich_ice_spire",
"npc_dota_mutation_pocket_roshan",
"npc_dota_scout_hawk",
"npc_dota_greater_hawk",
]);
const extraAttribKeys = [
"AbilityCastRange",
"AbilityChargeRestoreTime",
"AbilityDuration",
"AbilityChannelTime",
"AbilityCastPoint",
"AbilityCharges",
"AbilityManaCost",
"AbilityCooldown",
];
// Use standardized names for base attributes
const generatedHeaders = {
abilitycastrange: "CAST RANGE",
abilitycastpoint: "CAST TIME",
abilitycharges: "MAX CHARGES",
max_charges: "MAX CHARGES",
abilitychargerestoretime: "CHARGE RESTORE TIME",
charge_restore_time: "CHARGE RESTORE TIME",
abilityduration: "DURATION",
abilitychanneltime: "CHANNEL TIME",
};
// Already formatted for mc and cd
const excludeAttributes = new Set(["abilitymanacost", "abilitycooldown"]);
// Some attributes we remap, so keep track of them if there's dupes
const remapAttributes = {
abilitychargerestoretime: "charge_restore_time",
abilitycharges: "max_charges",
};
const notAbilities = new Set([
"Version",
"version",
"ability_base",
"default_attack",
"attribute_bonus",
"ability_deward",
]);
const itemQualOverrides = {
fluffy_hat: "component",
overwhelming_blink: "artifact",
swift_blink: "artifact",
arcane_blink: "artifact",
moon_shard: "common",
aghanims_shard: "consumable",
kaya: "artifact",
helm_of_the_dominator: "common",
helm_of_the_overlord: "common",
desolator: "epic",
mask_of_madness: "common",
orb_of_corrosion: "common",
falcon_blade: "common",
mage_slayer: "artifact",
revenants_brooch: "epic",
};
const idsUrl =
"https://raw.githubusercontent.com/dotabuff/d2vpkr/master/dota/scripts/npc/npc_ability_ids.txt";
const heroesUrl =
"https://raw.githubusercontent.com/dotabuff/d2vpkr/master/dota/scripts/npc/npc_heroes.txt";
const abilitiesLoc =
"https://raw.githubusercontent.com/dotabuff/d2vpkr/master/dota/resource/localization/abilities_english.txt";
const npcAbilitiesUrl =
"https://raw.githubusercontent.com/dotabuff/d2vpkr/master/dota/scripts/npc/npc_abilities.txt";
const npcUnitsUrl =
"https://raw.githubusercontent.com/dotabuff/d2vpkr/master/dota/scripts/npc/npc_units.txt";
const localizationUrl =
"https://raw.githubusercontent.com/dotabuff/d2vpkr/master/dota/resource/localization/dota_english.txt";
let aghsAbilityValues = {};
const heroDataUrls: string[] = [];
const heroDataIndex: string[] = [];
const abilitiesUrls = [abilitiesLoc, npcAbilitiesUrl];
start();
async function start() {
const resp = await fetch(heroesUrl);
const heroesVdf = parseJsonOrVdf(await resp.text(), heroesUrl);
let ids = Object.keys(heroesVdf.DOTAHeroes)
.filter((name) => !badNames.has(name))
.map((key) => heroesVdf.DOTAHeroes[key].HeroID)
.sort((a, b) => Number(a) - Number(b));
ids.forEach((key) => {
heroDataUrls.push(
"http://www.dota2.com/datafeed/herodata?language=english&hero_id=" + key,
);
});
let names = Object.keys(heroesVdf.DOTAHeroes).filter(
(name) => !badNames.has(name),
);
names.forEach((name) => {
// The hero abilities were moved to individual hero files, e.g. https://raw.githubusercontent.com/dotabuff/d2vpkr/master/dota/scripts/npc/heroes/npc_dota_hero_abaddon.txt
abilitiesUrls.push(
"https://raw.githubusercontent.com/dotabuff/d2vpkr/master/dota/scripts/npc/heroes/" +
name +
".txt",
);
heroDataIndex.push(name);
});
const sources = [
{
key: "items",
url: [
abilitiesLoc,
"https://raw.githubusercontent.com/dotabuff/d2vpkr/master/dota/scripts/npc/items.txt",
"https://raw.githubusercontent.com/dotabuff/d2vpkr/master/dota/scripts/npc/neutral_items.txt",
idsUrl,
],
transform: (respObj: any) => {
const strings = respObj[0].lang.Tokens;
const scripts = respObj[1].DOTAAbilities;
const neutrals = respObj[2].neutral_items;
const idLookup = respObj[3].DOTAAbilityIDs.ItemAbilities.Locked;
// parse neutral items into name => tier map
const neutralItemNameTierMap = getNeutralItemNameTierMap(neutrals);
// Fix places where valve doesnt care about correct case
Object.keys(strings).forEach((key) => {
if (key.includes("DOTA_Tooltip_Ability_")) {
strings[
key.replace("DOTA_Tooltip_Ability_", "DOTA_Tooltip_ability_")
] = strings[key];
}
});
let items = {};
Object.keys(scripts)
.filter((key) => {
return (
!(key.includes("item_recipe") && scripts[key].ItemCost === "0") &&
key !== "Version"
);
})
.forEach((key) => {
const specialAttrs = getSpecialAttrs(scripts[key]);
let item = {
...replaceSpecialAttribs(
strings[`DOTA_Tooltip_ability_${key}_Description`],
specialAttrs,
true,
scripts[key],
key,
),
};
item.id = Number(idLookup[key]);
item.img = `/apps/dota2/images/dota_react/items/${key.replace(
/^item_/,
"",
)}.png?t=${1593393829403}`;
if (key.includes("item_recipe")) {
item.img = `/apps/dota2/images/dota_react/items/recipe.png?t=${1593393829403}`;
}
item.dname =
strings[`DOTA_Tooltip_ability_${key}`] ||
strings[`DOTA_Tooltip_ability_${key}:n`];
item.qual = itemQualOverrides[key] ?? scripts[key].ItemQuality;
item.cost = parseInt(scripts[key].ItemCost);
const behavior = formatBehavior(scripts[key].AbilityBehavior);
item.behavior = behavior !== "Passive" ? behavior : undefined;
item.dmg_type =
formatBehavior(scripts[key].AbilityUnitDamageType) || undefined;
item.bkbpierce =
formatBehavior(scripts[key].SpellImmunityType) || undefined;
item.dispellable =
formatBehavior(scripts[key].SpellDispellableType) || undefined;
item.target_team =
formatBehavior(scripts[key].AbilityUnitTargetTeam) || undefined;
item.target_type =
formatBehavior(scripts[key].AbilityUnitTargetType) || undefined;
let notes: any[] = [];
for (
let i = 0;
strings[`DOTA_Tooltip_ability_${key}_Note${i}`];
i++
) {
notes.push(
replaceSpecialAttribs(
strings[`DOTA_Tooltip_ability_${key}_Note${i}`],
specialAttrs,
false,
scripts[key],
key,
),
);
}
item.notes = notes.join("\n");
item.attrib = scripts[key].AbilityValues
? Object.entries(scripts[key].AbilityValues).map(
([abilityKey, val]: [string, any]) => {
let display;
const tooltipKey = `DOTA_Tooltip_ability_${key}_${abilityKey}`;
const string = strings[tooltipKey];
if (tooltipKey in strings) {
if (string.includes("$") || /[a-z]/.test(string)) {
// Normal attributes, e.g. + 25 Movement Speed
display = string.replace(
/(%)?([+-])?(\$\w+)?/,
(str, pct = "", pm = "", variable) =>
`${pm} {value}${pct} ` +
(strings[
`dota_ability_variable_${variable?.replace(
"$",
"",
)}`
] || ""),
);
}
if (!/[a-z]/.test(string)) {
// Upper case stats, where the number is displayed in the end
display = string.replace(
/^(%)?(.+)/,
(str, pct = "", rest) => {
return `${rest} {value}${pct}`;
},
);
}
}
return {
key: abilityKey,
display: display?.replace(/<[^>]*>/g, ""),
value: (val.value ?? val).split(" ").join(" / "),
};
},
)
: [];
item.mc = parseInt(scripts[key].AbilityManaCost) || false;
item.hc = parseInt(scripts[key].AbilityHealthCost) || false;
item.cd = parseInt(scripts[key].AbilityCooldown) || false;
item.lore = (
strings[`DOTA_Tooltip_ability_${key}_Lore`] || ""
).replace(/\\n/g, "\r\n");
item.components = null;
item.created = false;
item.charges = parseInt(scripts[key].ItemInitialCharges) || false;
if (neutralItemNameTierMap[key]) {
item.tier = neutralItemNameTierMap[key];
}
items[key.replace(/^item_/, "")] = item;
});
// Load recipes
Object.keys(scripts)
.filter(
(key) => scripts[key].ItemRequirements && scripts[key].ItemResult,
)
.forEach((key) => {
const result_key = scripts[key].ItemResult.replace(/^item_/, "");
items[result_key].components = scripts[key].ItemRequirements["01"]
?.split(";")
.map((item) => item.replace(/^item_/, "").replace("*", ""));
items[result_key].created = true;
});
//Manually Adding DiffBlade2 for match data prior to 7.07
items["diffusal_blade_2"] = {
id: 196,
img: "/apps/dota2/images/dota_react/items/diffusal_blade_2.png?3",
dname: "Diffusal Blade",
qual: "artifact",
cost: 3850,
desc: "Active: Purge Targets an enemy, removing buffs from the target and slowing it for 4 seconds.Range: 600\nPassive: ManabreakEach attack burns 50 mana from the target, and deals 0.8 physical damage per burned mana. Burns 16 mana per attack from melee illusions and 8 mana per attack from ranged illusions. Dispel Type: Basic Dispel",
notes: "Does not stack with other manabreak abilities.",
attrib: [
{
key: "bonus_agility",
display: "+ {value} Agility",
value: "25 / 35",
},
{
key: "bonus_intellect",
display: "+ {value} Intelligence",
value: "10 / 15",
},
{
key: "initial_charges",
value: "8",
},
{
key: "feedback_mana_burn",
value: "50",
},
{
key: "feedback_mana_burn_illusion_melee",
value: "16",
},
{
key: "feedback_mana_burn_illusion_ranged",
value: "8",
},
{
key: "purge_summoned_damage",
value: "99999",
},
{
key: "purge_rate",
value: "5",
},
{
key: "purge_root_duration",
value: "3",
},
{
key: "purge_slow_duration",
value: "4",
},
{
key: "damage_per_burn",
value: "0.8",
},
{
key: "cast_range_tooltip",
value: "600",
},
],
mc: false,
cd: 4,
lore: "An enchanted blade that allows the user to cut straight into the enemy's soul.",
components: ["diffusal_blade", "recipe_diffusal_blade"],
created: true,
};
//Manually added for match data prior to 7.07
items["recipe_iron_talon"] = {
id: 238,
img: "/apps/dota2/images/dota_react/items/recipe.png?3",
dname: "Iron Talon Recipe",
cost: 125,
desc: "",
notes: "",
attrib: [],
mc: false,
cd: false,
lore: "",
components: null,
created: false,
};
return items;
},
},
{
key: "item_ids",
url: idsUrl,
transform: (respObj: any) => {
const data = respObj.DOTAAbilityIDs.ItemAbilities.Locked;
// Flip the keys and values
const itemIds: any = {};
Object.entries(data).forEach(([key, val]: [string, any]) => {
// Remove item_ prefix
itemIds[val] = key.replace("item_", "");
});
//manually adding DiffBlade2
itemIds[196] = "diffusal_blade_2";
return itemIds;
},
},
{
key: "abilities",
url: abilitiesUrls,
transform: (respObj: any) => {
const strings = respObj[0].lang.Tokens;
let scripts = respObj[1].DOTAAbilities;
// Merge into scripts all the hero abilities (the rest of the array)
for (let i = 2; i < respObj.length; i++) {
// console.log(respObj[i]);
const heroAbs = respObj[i].DOTAAbilities;
scripts = { ...scripts, ...heroAbs };
}
let abilities = {};
Object.keys(scripts)
.filter((key) => !notAbilities.has(key))
.forEach((key) => {
let ability: any = {};
let specialAttr = getSpecialAttrs(scripts[key]);
ability.dname = replaceSValues(
strings[`DOTA_Tooltip_ability_${key}`] ??
strings[`DOTA_Tooltip_Ability_${key}`],
specialAttr,
key,
);
// Check for unreplaced `s:bonus_<talent>`
if (
scripts[key].ad_linked_abilities &&
scripts[scripts[key].ad_linked_abilities]
) {
ability.dname = replaceBonusSValues(
key,
ability.dname,
scripts[scripts[key].ad_linked_abilities].AbilityValues,
);
}
if (scripts[key].Innate === "1") {
ability.is_innate = true;
}
ability.behavior =
formatBehavior(scripts[key].AbilityBehavior) || undefined;
ability.dmg_type =
formatBehavior(scripts[key].AbilityUnitDamageType) || undefined;
ability.bkbpierce =
formatBehavior(scripts[key].SpellImmunityType) || undefined;
ability.dispellable =
formatBehavior(scripts[key].SpellDispellableType) || undefined;
ability.target_team =
formatBehavior(scripts[key].AbilityUnitTargetTeam) || undefined;
ability.target_type =
formatBehavior(scripts[key].AbilityUnitTargetType) || undefined;
ability.desc = replaceSpecialAttribs(
strings[`DOTA_Tooltip_ability_${key}_Description`],
specialAttr,
false,
scripts[key],
key,
);
ability.dmg =
scripts[key].AbilityDamage &&
formatValues(scripts[key].AbilityDamage);
// Clean up duplicate remapped values (we needed dupes for the tooltip)
if (specialAttr) {
Object.entries(remapAttributes).forEach(([oldAttr, newAttr]) => {
const oldAttrIdx = specialAttr.findIndex(
(attr) => Object.keys(attr)[0] === oldAttr,
);
const newAttrIdx = specialAttr.findIndex(
(attr) => Object.keys(attr)[0] === newAttr,
);
if (oldAttrIdx !== -1 && newAttrIdx !== -1) {
specialAttr.splice(oldAttrIdx, 1);
}
});
}
ability.attrib = formatAttrib(
specialAttr,
strings,
`DOTA_Tooltip_ability_${key}_`,
);
ability.lore = strings[`DOTA_Tooltip_ability_${key}_Lore`];
const ManaCostKey =
scripts[key].AbilityManaCost ??
scripts[key].AbilityValues?.AbilityManaCost;
const CooldownKey =
scripts[key].AbilityCooldown ??
scripts[key].AbilityValues?.AbilityCooldown;
if (ManaCostKey) {
const manaCost = isObj(ManaCostKey)
? ManaCostKey["value"]
: ManaCostKey;
ability.mc = formatValues(manaCost, false, "/");
}
if (CooldownKey) {
const cooldown = isObj(CooldownKey)
? CooldownKey["value"]
: CooldownKey;
ability.cd = formatValues(cooldown, false, "/");
}
ability.img = `/apps/dota2/images/dota_react/abilities/${key}.png`;
if (key.indexOf("special_bonus") === 0) {
ability = { dname: ability.dname };
}
abilities[key] = ability;
if (specialAttr) {
let aghsObj = {};
if (
scripts[key].IsGrantedByScepter ||
scripts[key].IsGrantedByShard
) {
// simple straight copy to lookup
for (const attrib of specialAttr) {
for (const key of Object.keys(attrib)) {
const val = attrib[key];
if (isObj(val)) {
aghsObj[key] = val["value"];
} else {
aghsObj[key] = val;
}
}
}
} else {
for (const attrib of specialAttr) {
for (const key of Object.keys(attrib)) {
const val = attrib[key];
if (val === null) {
continue;
}
// handle bonus objects
if (isObj(val)) {
// first case: standard attribute with aghs bonus
for (const bonus of Object.keys(val)) {
if (
bonus.indexOf("scepter") !== -1 ||
bonus.indexOf("shard") !== -1
) {
const rawBonus = val[bonus]
.replace("+", "")
.replace("-", "")
.replace("x", "")
.replace("%", "");
// bonus_bonus doesn't exist, it's shard_bonus or scepter_bonus at that point
const aghsPrefix =
bonus.indexOf("scepter") !== -1
? "scepter"
: "shard";
const bonusKey = key.startsWith("bonus_")
? `${aghsPrefix}_${key}`
: `bonus_${key}`;
aghsObj[bonusKey] = rawBonus;
aghsObj[`${key}`] = calculateValueFromBonus(
val["value"],
val[bonus],
);
}
}
// second case: aghs bonus attribute
if (
key.indexOf("scepter") !== -1 ||
key.indexOf("shard") !== -1
) {
const bonus = Object.keys(val)
.filter((k) => k !== key)
.find(
(k) =>
k.indexOf("scepter") !== -1 ||
k.indexOf("shard") !== -1,
);
if (bonus) {
aghsObj[key] = calculateValueFromBonus(
val["value"] ?? val[key],
val[bonus],
);
} else {
aghsObj[key] = val["value"] ?? val[key];
}
}
// third case: value requires aghs
if (Object.keys(val).length == 2) {
// value and requires attr
if (
(val["value"] && val["RequiresScepter"]) ||
val["RequiresShard"]
) {
aghsObj[key] = val["value"];
}
}
} else {
// simple key to value
aghsObj[key] = val;
}
}
}
}
aghsAbilityValues[key] = aghsObj;
}
});
// Some unique talents don't show up in each hero data file
// e.g. special_bonus_unique_axe_8
// Do a pass through the strings and if any are missing, add them with just basic description
Object.keys(strings).forEach((str) => {
if (
str.startsWith("DOTA_Tooltip_ability_special_bonus_unique") &&
!str.endsWith("Description")
) {
const abName = str.slice("DOTA_Tooltip_ability_".length);
if (!abilities[abName]) {
abilities[abName] = {
dname: strings[str],
};
}
}
});
return abilities;
},
},
{
key: "ability_ids",
url: idsUrl,
transform: (respObj: any) => {
const data = respObj.DOTAAbilityIDs.UnitAbilities.Locked;
// Flip the keys and values
const abilityIds = {};
Object.entries(data).forEach(([key, val]: [string, any]) => {
abilityIds[val] = key;
});
return abilityIds;
},
},
{
key: "neutral_abilities",
url: npcUnitsUrl,
transform: (respObj: any) => {
const abilitySlots = [
"Ability1",
"Ability2",
"Ability3",
"Ability4",
"Ability5",
"Ability6",
"Ability7",
"Ability8",
];
// filter out placeholder abilities
const badNeutralAbilities = new Set([
"creep_piercing",
"creep_irresolute",
"flagbearer_creep_aura_effect",
"creep_siege",
"backdoor_protection",
"backdoor_protection_in_base",
"filler_ability",
"neutral_upgrade",
]);
// filter out attachable units, couriers, buildings and siege creeps
const badUnitRelationships = new Set([
"DOTA_NPC_UNIT_RELATIONSHIP_TYPE_ATTACHED",
"DOTA_NPC_UNIT_RELATIONSHIP_TYPE_COURIER",
"DOTA_NPC_UNIT_RELATIONSHIP_TYPE_BUILDING",
"DOTA_NPC_UNIT_RELATIONSHIP_TYPE_BARRACKS",
"DOTA_NPC_UNIT_RELATIONSHIP_TYPE_SIEGE",
]);
const units = respObj.DOTAUnits;
const baseUnit = units["npc_dota_units_base"];
function getUnitProp(unit, prop, name = "") {
if (unit[prop] !== undefined) {
return unit[prop];
}
// include from other unit
if (unit.include_keys_from) {
return getUnitProp(units[unit.include_keys_from], prop);
}
// check if BaseClass is defined non-natively, if so, read from that
// also make sure we aren't reading from itself
if (
unit.BaseClass &&
unit.BaseClass !== name &&
units[unit.BaseClass]
) {
return getUnitProp(units[unit.BaseClass], prop, unit.BaseClass);
}
// Fallback to the base unit
return baseUnit[prop];
}
const keys = Object.keys(units).filter((name) => {
if (badNames.has(name)) {
return false;
}
const unit = units[name];
// only special units have a minimap icon
if (unit.MinimapIcon) {
return false;
}
if (getUnitProp(unit, "BountyXP") === "0") {
return false;
}
// if HasInventory=0 explicitly (derived from hero), then we can filter it out
// if it has an inventory, it's not an neutral
if (
unit.HasInventory === "0" ||
getUnitProp(unit, "HasInventory") === "1"
) {
return false;
}
if (
badUnitRelationships.has(getUnitProp(unit, "UnitRelationshipClass"))
) {
return false;
}
let hasAbility = false;
for (const slot of abilitySlots) {
const ability = getUnitProp(unit, slot);
if (ability && !badNeutralAbilities.has(ability)) {
hasAbility = true;
break;
}
}
return hasAbility;
});
const neutralAbilities = {};
keys.forEach((key) => {
const unit = units[key];
for (const slot of abilitySlots) {
const ability = getUnitProp(unit, slot);
if (
ability &&
!badNeutralAbilities.has(ability) &&
!neutralAbilities[ability]
) {
neutralAbilities[ability] = {
img: `/assets/images/dota2/neutral_abilities/${ability}.png`,
};
}
}
});
return neutralAbilities;
},
},
{
key: "ancients",
url: npcUnitsUrl,
transform: (respObj: any) => {
// filter out attachable units, couriers, buildings and siege creeps
const badUnitRelationships = new Set([
"DOTA_NPC_UNIT_RELATIONSHIP_TYPE_ATTACHED",
"DOTA_NPC_UNIT_RELATIONSHIP_TYPE_COURIER",
"DOTA_NPC_UNIT_RELATIONSHIP_TYPE_BUILDING",
"DOTA_NPC_UNIT_RELATIONSHIP_TYPE_BARRACKS",
"DOTA_NPC_UNIT_RELATIONSHIP_TYPE_SIEGE",
]);
const units = respObj.DOTAUnits;
const baseUnit = units["npc_dota_units_base"];
function getUnitProp(unit, prop, name = "") {
if (unit[prop] !== undefined) {
return unit[prop];
}
// include from other unit
if (unit.include_keys_from) {
return getUnitProp(units[unit.include_keys_from], prop);
}
// check if BaseClass is defined non-natively, if so, read from that
// also make sure we aren't reading from itself
if (
unit.BaseClass &&
unit.BaseClass !== name &&
units[unit.BaseClass]
) {
return getUnitProp(units[unit.BaseClass], prop, unit.BaseClass);
}
// Fallback to the base unit
return baseUnit[prop];
}
const keys = Object.keys(units).filter((name) => {
if (badNames.has(name)) {
return false;
}
const unit = units[name];
// only special units have a minimap icon
if (unit.MinimapIcon) {
return false;
}
if (getUnitProp(unit, "BountyXP") === "0") {
return false;
}
// if HasInventory=0 explicitly (derived from hero), then we can filter it out
// if it has an inventory, it's not an neutral
if (
unit.HasInventory === "0" ||
getUnitProp(unit, "HasInventory") === "1"
) {
return false;
}
if (
getUnitProp(unit, "UnitRelationshipClass") !==
"DOTA_NPC_UNIT_RELATIONSHIP_TYPE_DEFAULT"
) {
return false;
}
const level = getUnitProp(unit, "Level");
if (level === "0" || level === "1") {
return false;
}
if (getUnitProp(unit, "TeamName") !== "DOTA_TEAM_NEUTRALS") {
return false;
}
if (getUnitProp(unit, "IsNeutralUnitType") === "0") {
return false;
}
if (getUnitProp(unit, "IsRoshan") === "1") {
return false;
}
return getUnitProp(unit, "IsAncient") === "1";
});
const ancients = {};
keys.forEach((key) => {
ancients[key] = 1;
});
return ancients;
},
},
{
key: "heroes",
url: [localizationUrl, heroesUrl],
transform: (respObj: any) => {
let heroes: any = [];
let keys = Object.keys(respObj[1].DOTAHeroes).filter(
(name) => !badNames.has(name),
);
keys.forEach((name) => {
let h: any = formatVpkHero(name, respObj[1]);
h.localized_name =
respObj[1].DOTAHeroes[name].workshop_guide_name ??
respObj[0].lang.Tokens[name + ":n"];
heroes.push(h);
});
heroes = heroes.sort((a, b) => a.id - b.id);
let heroesObj = {};
for (let hero of heroes) {
hero.id = Number(hero.id);
heroesObj[hero.id] = hero;
}
return heroesObj;
},
},
{
key: "hero_lore",
url: [
"https://raw.githubusercontent.com/dotabuff/d2vpkr/master/dota/resource/localization/hero_lore_english.txt",
heroesUrl,
],
transform: (respObj: any) => {
let keys = Object.keys(respObj[1].DOTAHeroes).filter(
(name) => !badNames.has(name),
);
let sortedHeroes: { name: string; id: number }[] = [];
keys.forEach((name) => {
const hero = respObj[1].DOTAHeroes[name];
sortedHeroes.push({ name, id: hero.HeroID });
});
sortedHeroes = sortedHeroes.sort((a, b) => a.id - b.id);
const lore = respObj[0].lang.Tokens;
const heroLore = {};
sortedHeroes.forEach((hero) => {
const heroKey = hero.name.replace("npc_dota_hero_", "");
heroLore[heroKey] = lore[`${hero.name}_bio`]
.replace(/\t+/g, " ")
.replace(/\n+/g, " ")
.replace(/<br>+/g, " ")
.replace(/\s+/g, " ")
.replace(/\\/g, "")
.replace(/"/g, "'")
.trim();
});
return heroLore;
},
},
{
key: "hero_abilities",
url: [heroesUrl, ...abilitiesUrls],
transform: (respObj: any) => {
const [heroObj, abilityLoc, _, ...heroAbils] = respObj;
const strings = abilityLoc.lang.Tokens;
// Fix places where valve doesn't care about correct case
Object.keys(strings).forEach((key) => {
strings[key.toLowerCase()] = strings[key];
});
let scripts = {};
for (let i = 0; i < heroAbils.length; i++) {
scripts[heroDataIndex[i]] = heroAbils[i].DOTAAbilities;
}
let heroes = heroObj.DOTAHeroes;
const heroAbilities = {};
Object.keys(heroes).forEach(function (heroKey) {
if (
heroKey != "Version" &&
heroKey != "npc_dota_hero_base" &&
heroKey != "npc_dota_hero_target_dummy"
) {
const newHero = {
abilities: [] as any[],
talents: [] as any[],
facets: [],
};
let talentCounter = 2;
Object.keys(heroes[heroKey]).forEach(function (key) {
let talentIndexStart =
heroes[heroKey]["AbilityTalentStart"] != undefined
? heroes[heroKey]["AbilityTalentStart"]
: 10;
let abilityRegexMatch = key.match(/Ability([0-9]+)/);
if (abilityRegexMatch && heroes[heroKey][key] != "") {
let abilityNum = parseInt(abilityRegexMatch[1]);
if (abilityNum < talentIndexStart) {
newHero["abilities"].push(heroes[heroKey][key]);
} else {
// -8 not -10 because going from 0-based index -> 1 and flooring divison result
newHero["talents"].push({
name: heroes[heroKey][key],
level: Math.floor(talentCounter / 2),
});
talentCounter++;
}
}
});
heroAbilities[heroKey] = newHero;
}
Object.entries(heroes[heroKey].Facets ?? []).forEach(
([key, value]: [string, any], i) => {
const abilities = Object.values(value.Abilities || {}).map(
(a: any) => a.AbilityName,
);
const [ability] = abilities;
const title =
strings[`dota_tooltip_facet_${key}`] ||
strings[`dota_tooltip_ability_${ability}`] ||
"";
let description =
strings[`dota_tooltip_facet_${key}_description`] ||
strings[`dota_tooltip_ability_${ability}_description`] ||
"";
if (description.includes("{s:facet_ability_name}")) {
description = description.replace(
"{s:facet_ability_name}",
title,
);
}
const allAttribs: any[] = [];
Object.values(scripts[heroKey]).forEach((ability) => {
const specialAttribs = getSpecialAttrs(ability) || [];
allAttribs.push(...specialAttribs);
});
if (abilities.length > 0) {
description = replaceSpecialAttribs(
description,
getSpecialAttrs(scripts[heroKey][ability]) || [],
false,
scripts[heroKey][ability],
ability,
);
}
const matches = description.matchAll(/{s:bonus_(\w+)}/g);
for (let [, bonusKey] of matches) {
const obj =
allAttribs.find((obj) => bonusKey in obj)?.[bonusKey] ?? {};
const facetKey = Object.keys(obj).find(
(k) => k.startsWith("special_bonus_facet") && k.includes(key),
);
if (facetKey) {
description = description.replace(
`{s:bonus_${bonusKey}}`,
removeSigns(obj[facetKey]),
);
}
}
heroAbilities[heroKey].facets.push({
id: i,
name: key,
deprecated: value.Deprecated,
icon: value.Icon?.toLowerCase(),
color: value.Color,
gradient_id: Number(value.GradientID),
title,
description: removeHTML(description),
abilities: abilities.length > 0 ? abilities : undefined,
});
},
);
});
return heroAbilities;
},
},
// {
// key: "region",
// url: "https://raw.githubusercontent.com/dotabuff/d2vpkr/master/dota/scripts/regions.txt",
// transform: (respObj: any) => {
// const region = {};
// const regions = respObj.regions;
// for (const key in regions) {
// if (Number(regions[key].region) > 0) {
// region[regions[key].region] = regions[key].display_name
// .slice("#dota_region_".length)
// .split("_")
// .map((s) => s.toUpperCase())
// .join(" ");
// }
// }
// return region;
// },
// },
// {
// key: "cluster",
// url: "https://api.stratz.com/api/v1/Cluster",
// transform: (respObj: any) => {
// const cluster = {};
// respObj.forEach(({ id, regionId }) => {
// cluster[id] = regionId;
// });
// return cluster;
// },
// },
{
key: "countries",
url: "https://raw.githubusercontent.com/mledoze/countries/master/countries.json",
transform: (respObj: any) => {
const countries = {};
respObj
.map((c: any) => ({
name: {
common: c.name.common,
},
cca2: c.cca2,
}))
.forEach((c) => {
countries[c.cca2] = c;
});
return countries;
},
},
{
key: "chat_wheel",
url: [
"https://raw.githubusercontent.com/dotabuff/d2vpkr/master/dota/scripts/chat_wheel.txt",
localizationUrl,
"https://raw.githubusercontent.com/dotabuff/d2vpkr/master/dota/resource/localization/hero_chat_wheel_english.txt",
],
transform: (respObj: any) => {
const chat_wheel = respObj[0].chat_wheel;
const lang = respObj[1].lang.Tokens;
const chat_wheel_lang = respObj[2].hero_chat_wheel;
const result = {};
function localize(input) {
if (!/^#/.test(input)) {
return input;
}
let key = input.replace(/^#/, "");
return lang[key] || chat_wheel_lang[key] || key;
}
function addMessage(key, message) {
let data = {
id: parseInt(message.message_id),
name: key,
all_chat: message.all_chat == "1" ? true : undefined,
label: localize(message.label),
message: localize(message.message),
image: message.image,
badge_tier: message.unlock_hero_badge_tier,
sound_ext: undefined as string | undefined,
};
if (message.sound) {
if (/^soundboard\./.test(message.sound) || /^wisp_/.test(key)) {
// All of the soundboard clips and the IO responses are wav files
data.sound_ext = "wav";
} else if (message.message_id / 1000 >= 121) {
// Gets the hero id from the message id
// If the hero is grimstroke or newer, the files are aac
data.sound_ext = "aac";
} else {
// All other response clips used are mp3s
data.sound_ext = "mp3";
}
}
result[data.id] = data;
}
for (let key in chat_wheel.messages) {
addMessage(key, chat_wheel.messages[key]);
}
for (let hero_id in chat_wheel.hero_messages) {
for (let key in chat_wheel.hero_messages[hero_id]) {
addMessage(key, chat_wheel.hero_messages[hero_id][key]);
}
}
return result;
},
},
// Requires items and hero names so needs to run after
{
key: "patchnotes",
url: "https://raw.githubusercontent.com/dotabuff/d2vpkr/master/dota/resource/localization/patchnotes/patchnotes_english.txt",
transform: (respObj: any) => {
let items = Object.keys(
JSON.parse(fs.readFileSync("./build/items.json").toString()),
);
let heroes = Object.keys(
JSON.parse(fs.readFileSync("./build/hero_lore.json").toString()),
);
const data = respObj.patch;
let result: any = {};
let keys = Object.keys(data);
for (let key of keys) {
let keyArr = key.replace("DOTA_Patch_", "").split("_");
let patch = keyArr.splice(0, 2).join("_");
if (!result[patch])
result[patch] = {
general: [],
items: {},
heroes: {},
};
if (keyArr[0].toLowerCase() == "general") {
result[patch].general.push(data[key]);
} else if (keyArr[0] == "item") {
let searchName = keyArr.slice(1);
let itemName = parseNameFromArray(searchName, items);
if (itemName) {
if (!result[patch].items[itemName])
result[patch].items[itemName] = [];
const cleanEntry = data[key].replace(/<[^>]*>/g, "");
if (cleanEntry !== "") {
result[patch].items[itemName].push(cleanEntry);
}
} else {
if (!result[patch].items.misc) result[patch].items.misc = [];
result[patch].items.misc.push(data[key]);
}
} else {
let heroName = parseNameFromArray(keyArr, heroes);
// Extracting ability name
let abilityName =
keyArr.length > 1
? keyArr.join("_").replace(`${heroName}_`, "")
: "general";
abilityName =
!abilityName || abilityName === heroName
? "general"
: abilityName;
if (heroName) {
if (!result[patch].heroes[heroName])
result[patch].heroes[heroName] = {};
let isTalent = data[key].startsWith("Talent:");
isTalent = isTalent || abilityName.startsWith("talent");
if (isTalent) {
if (!result[patch].heroes[heroName].talents)
result[patch].heroes[heroName].talents = [];
result[patch].heroes[heroName].talents.push(
data[key].replace("Talent:", "").trim(),
);
} else {
// DOTA_Patch_7_32_shredder_shredder_chakram_2_2
// DOTA_Patch_7_32_tinker_tinker_rearm_3_info
// remove everything to the right of the first _n that is found, where n is a number
abilityName = abilityName.replace(/_[0-9]+.*/, "");
// if abilityName is just an underscore and number like "_n" then set it to general
abilityName = parseInt(abilityName.replace(/_/, ""))
? "general"
: abilityName;
if (!result[patch].heroes[heroName][abilityName])
result[patch].heroes[heroName][abilityName] = [];
result[patch].heroes[heroName][abilityName].push(
data[key].trim(),
);
}
} else {
if (!result[patch].heroes.misc) result[patch].heroes.misc = [];
result[patch].heroes.misc.push(data[key].trim());
}
}
}
return result;
},
},
// NOTE: This needs to run after abilities since it depends on aghsAbilityValues
{
key: "aghs_desc",
url: heroDataUrls,
transform: (respObj: any) => {
const herodata = respObj;
const aghs_desc_arr: any[] = [];
// for every hero
herodata.forEach((hd_hero) => {
if (!hd_hero) {
return;
}
hd_hero = hd_hero.result.data.heroes[0];
// object to store data about aghs scepter/shard for a hero
let aghs_element = {
hero_name: hd_hero.name,
hero_id: hd_hero.id,
has_scepter: false,
scepter_desc: "",
scepter_skill_name: "",
scepter_new_skill: false,
has_shard: false,
shard_desc: "",
shard_skill_name: "",
shard_new_skill: false,
};
hd_hero.abilities.forEach((ability) => {
// skip unused skills
if (ability.name_loc == "" || ability.desc_loc == "") {
return; // i guess this is continue in JS :|
}
let scepterName = null;
let shardName = null;
// ------------- Scepter -------------
if (ability.ability_is_granted_by_scepter) {
// scepter grants new ability
aghs_element.scepter_desc = ability.desc_loc;
aghs_element.scepter_skill_name = ability.name_loc;
scepterName = ability.name;
aghs_element.scepter_new_skill = true;
aghs_element.has_scepter = true;
} else if (
ability.ability_has_scepter &&
!(ability.scepter_loc == "")
) {
// scepter ugprades an ability
aghs_element.scepter_desc = ability.scepter_loc;
aghs_element.scepter_skill_name = ability.name_loc;
scepterName = ability.name;
aghs_element.scepter_new_skill = false;
aghs_element.has_scepter = true;
}
// -------------- Shard --------------
if (ability.ability_is_granted_by_shard) {
// scepter grants new ability
aghs_element.shard_desc = ability.desc_loc;
aghs_element.shard_skill_name = ability.name_loc;
shardName = ability.name;
aghs_element.shard_new_skill = true;
aghs_element.has_shard = true;
} else if (
ability.ability_has_shard &&
!(ability.shard_loc == "")
) {
// scepter ugprades an ability
aghs_element.shard_desc = ability.shard_loc;
aghs_element.shard_skill_name = ability.name_loc;
shardName = ability.name;
aghs_element.shard_new_skill = false;
aghs_element.has_shard = true;
}
if (scepterName) {
const values = aghsAbilityValues[scepterName];
aghs_element.scepter_desc = aghs_element.scepter_desc.replace(
/%([^% ]*)%/g,
findAghsAbilityValue(values),
);
}
if (shardName) {
const values = aghsAbilityValues[shardName];
aghs_element.shard_desc = aghs_element.shard_desc.replace(
/%([^% ]*)%/g,
findAghsAbilityValue(values),
);
}
// clean up <br> and double % signs
aghs_element.scepter_desc = aghs_element.scepter_desc
.replace(/<br>/gi, "\n")
.replace("%%", "%");
aghs_element.shard_desc = aghs_element.shard_desc
.replace(/<br>/gi, "\n")
.replace("%%", "%");
});
// Error handling
if (!aghs_element.has_shard) {
console.log(
aghs_element.hero_name +
"[" +
aghs_element.hero_id +
"]" +
": Didn't find a scepter...",
);
}
if (!aghs_element.has_scepter) {
console.log(
aghs_element.hero_name +
"[" +
aghs_element.hero_id +
"]" +
": Didn't find a shard...",
);
}
// push the current hero"s element into the array
aghs_desc_arr.push(aghs_element);
});
return aghs_desc_arr;
},
},
];
for (let i = 0; i < sources.length; i++) {
const s = sources[i];
const url = s.url;
// Make all urls into array
const arr = Array.isArray(url) ? url : [url];
// console.log(arr);
const resps = await Promise.all(
arr.map(async (url) => {
console.log(url);
const resp = await fetch(url);
return parseJsonOrVdf(await resp.text(), url);
}),
);
let final: any = resps;
if (s.transform) {
final = s.transform(resps.length === 1 ? resps[0] : resps);
}
fs.writeFileSync(
"./build/" + s.key + ".json",
JSON.stringify(final, null, 2),
);
}
// Copy manual json files to build
const jsons = fs.readdirSync("./json");
jsons.forEach((filename) => {
fs.writeFileSync(
"./build/" + filename,
fs.readFileSync("./json/" + filename, "utf-8"),
);
});
// Reference built files i