let armor_powder_node = new (class extends ComputeNode { constructor() { super('builder-armor-powder-input'); } compute_func(input_map) { let damage_boost = 0; let def_boost = 0; let statMap = new Map(); for (const [e, elem] of zip2(skp_elements, skp_order)) { let val = parseInt(document.getElementById(elem+"_boost_armor").value); statMap.set(e+'DamPct', val); } return statMap; } })().update(); /* Updates PASSIVE powder special boosts (armors) */ function update_armor_powder_specials(elem_id) { //we only update the powder special + external stats if the player has a build let wynn_elem = elem_id.split("_")[0]; //str, dex, int, def, agi //update the label associated w/ the slider let elem = document.getElementById(elem_id); let label = document.getElementById(elem_id + "_label"); let value = elem.value; label.textContent = label.textContent.split(":")[0] + ": " + value //update the slider's graphics let bg_color = elem_colors[skp_order.indexOf(wynn_elem)]; let pct = Math.round(100 * value / powderSpecialStats[skp_order.indexOf(wynn_elem)].cap); elem.style.background = `linear-gradient(to right, ${bg_color}, ${bg_color} ${pct}%, #AAAAAA ${pct}%, #AAAAAA 100%)`; armor_powder_node.mark_dirty().update(); } let boosts_node = new (class extends ComputeNode { constructor() { super('builder-boost-input'); } compute_func(input_map) { let damage_boost = 0; let def_boost = 0; for (const [key, value] of damageMultipliers) { let elem = document.getElementById(key + "-boost") if (elem.classList.contains("toggleOn")) { damage_boost += value; if (key === "warscream") { def_boost += .20 } if (key === "vanish") { def_boost += .15 } } } let res = new Map(); res.set('damageMultiplier', 1+damage_boost); res.set('defMultiplier', 1-def_boost); return res; } })().update(); /* Updates all spell boosts */ function update_boosts(buttonId) { let elem = document.getElementById(buttonId); if (elem.classList.contains("toggleOn")) { elem.classList.remove("toggleOn"); } else { elem.classList.add("toggleOn"); } boosts_node.mark_dirty().update(); } let specialNames = ["Quake", "Chain Lightning", "Curse", "Courage", "Wind Prison"]; let powder_special_input = new (class extends ComputeNode { constructor() { super('builder-powder-special-input'); } compute_func(input_map) { let powder_specials = []; // [ [special, power], [special, power]] for (const sName of specialNames) { for (let i = 1;i < 6; i++) { if (document.getElementById(sName.replace(" ","_") + "-" + i).classList.contains("toggleOn")) { let powder_special = powderSpecialStats[specialNames.indexOf(sName.replace("_"," "))]; powder_specials.push([powder_special, i]); break; } } } return powder_specials; } })(); function updatePowderSpecials(buttonId) { let prefix = (buttonId).split("-")[0].replace(' ', '_') + '-'; let elem = document.getElementById(buttonId); if (elem.classList.contains("toggleOn")) { elem.classList.remove("toggleOn"); } else { for (let i = 1;i < 6; i++) { //toggle all pressed buttons of the same powder special off //name is same, power is i const elem2 = document.getElementById(prefix + i); if(elem2.classList.contains("toggleOn")) { elem2.classList.remove("toggleOn"); } } //toggle the pressed button on elem.classList.add("toggleOn"); } powder_special_input.mark_dirty().update(); } class PowderSpecialCalcNode extends ComputeNode { constructor() { super('builder-powder-special-apply'); } compute_func(input_map) { const powder_specials = input_map.get('powder-specials'); let stats = new Map(); for (const [special, power] of powder_specials) { if (special["weaponSpecialEffects"].has("Damage Boost")) { let name = special["weaponSpecialName"]; if (name === "Courage" || name === "Curse") { //courage and curse are is universal damage boost stats.set("sdPct", special.weaponSpecialEffects.get("Damage Boost")[power-1]); stats.set("mdPct", special.weaponSpecialEffects.get("Damage Boost")[power-1]); stats.set("poisonPct", special.weaponSpecialEffects.get("Damage Boost")[power-1]); } else if (name === "Wind Prison") { stats.set("aDamPct", special.weaponSpecialEffects.get("Damage Boost")[power-1]); } } } return stats; } } class PowderSpecialDisplayNode extends ComputeNode { // TODO: Refactor this entirely to be adding more spells to the spell list constructor() { super('builder-powder-special-display'); this.fail_cb = true; } compute_func(input_map) { const powder_specials = input_map.get('powder-specials'); const stats = input_map.get('stats'); const weapon = input_map.get('build').weapon; displayPowderSpecials(document.getElementById("powder-special-stats"), powder_specials, stats, weapon.statMap, true); } } /** * Node for getting an item's stats from an item input field. * * Signature: ItemInputNode(powdering: Optional[list[powder]]) => Item | null */ class ItemInputNode extends InputNode { /** * Make an item stat pulling compute node. * * @param name: Name of this node. * @param item_input_field: Input field (html element) to listen for item names from. * @param none_item: Item object to use as the "none" for this field. */ constructor(name, item_input_field, none_item) { super(name, item_input_field); this.none_item = new Item(none_item); this.category = this.none_item.statMap.get('category'); if (this.category == 'armor' || this.category == 'weapon') { this.none_item.statMap.set('powders', []); apply_weapon_powders(this.none_item.statMap); // Needed to put in damagecalc zeros } this.none_item.statMap.set('NONE', true); } compute_func(input_map) { const powdering = input_map.get('powdering'); // built on the assumption of no one will type in CI/CR letter by letter let item_text = this.input_field.value; if (!item_text) { return this.none_item; } let item; if (item_text.slice(0, 3) == "CI-") { item = getCustomFromHash(item_text); } else if (item_text.slice(0, 3) == "CR-") { item = getCraftFromHash(item_text); } else if (itemMap.has(item_text)) { item = new Item(itemMap.get(item_text)); } else if (tomeMap.has(item_text)) { item = new Item(tomeMap.get(item_text)); } if (item) { if (powdering !== undefined) { const max_slots = item.statMap.get('slots'); item.statMap.set('powders', powdering.slice(0, max_slots)); } let type_match; if (this.category == 'weapon') { type_match = item.statMap.get('category') == 'weapon'; } else { type_match = item.statMap.get('type') == this.none_item.statMap.get('type'); } if (type_match) { if (item.statMap.get('category') == 'armor') { applyArmorPowders(item.statMap); } else if (item.statMap.get('category') == 'weapon') { apply_weapon_powders(item.statMap); } return item; } } else if (this.none_item.statMap.get('category') === 'weapon' && item_text.startsWith("Morph-")) { let replace_items = [ "Morph-Stardust", "Morph-Steel", "Morph-Iron", "Morph-Gold", "Morph-Topaz", "Morph-Emerald", "Morph-Amethyst", "Morph-Ruby", item_text.substring(6) ] for (const [i, x] of zip2(equipment_inputs, replace_items)) { setValue(i, x); } for (const node of item_nodes) { calcSchedule(node, 10); } return this.compute_func(input_map); } return null; } } /** * Node for updating item input fields from parsed items. * * Signature: ItemInputDisplayNode(item: Item) => null */ class ItemInputDisplayNode extends ComputeNode { constructor(name, eq, item_image) { super(name); this.input_field = document.getElementById(eq+"-choice"); this.health_field = document.getElementById(eq+"-health"); this.level_field = document.getElementById(eq+"-lv"); this.powder_field = document.getElementById(eq+"-powder"); // possibly None this.image = item_image; this.fail_cb = true; } compute_func(input_map) { if (input_map.size !== 1) { throw "ItemInputDisplayNode accepts exactly one input (item)"; } const [item] = input_map.values(); // Extract values, pattern match it into size one list and bind to first element this.input_field.classList.remove("text-light", "is-invalid", 'Normal', 'Unique', 'Rare', 'Legendary', 'Fabled', 'Mythic', 'Set', 'Crafted', 'Custom'); this.input_field.classList.add("text-light"); this.image.classList.remove('Normal-shadow', 'Unique-shadow', 'Rare-shadow', 'Legendary-shadow', 'Fabled-shadow', 'Mythic-shadow', 'Set-shadow', 'Crafted-shadow', 'Custom-shadow'); if (this.health_field) { // Doesn't exist for weapons. this.health_field.textContent = "0"; } if (this.level_field) { // Doesn't exist for tomes. this.level_field.textContent = "0"; } if (!item) { this.input_field.classList.add("is-invalid"); return null; } if (item.statMap.has('powders')) { this.powder_field.placeholder = "powders"; } if (item.statMap.has('NONE')) { return null; } if (item.statMap.has('powders')) { this.powder_field.placeholder = item.statMap.get('slots') + ' slots'; } const tier = item.statMap.get('tier'); this.input_field.classList.add(tier); if (this.health_field) { // Doesn't exist for weapons. this.health_field.textContent = item.statMap.get('hp'); } if (this.level_field) { // Doesn't exist for tomes. this.level_field.textContent = item.statMap.get('lvl'); } this.image.classList.add(tier + "-shadow"); return null; } } /** * Node for rendering an item. * * Signature: ItemDisplayNode(item: Item) => null */ class ItemDisplayNode extends ComputeNode { constructor(name, target_elem) { super(name); this.target_elem = target_elem; } compute_func(input_map) { if (input_map.size !== 1) { throw "ItemInputDisplayNode accepts exactly one input (item)"; } const [item] = input_map.values(); // Extract values, pattern match it into size one list and bind to first element displayExpandedItem(item.statMap, this.target_elem); collapse_element("#"+this.target_elem); } } /** * Change the weapon to match correct type. * * Signature: WeaponInputDisplayNode(item: Item) => null */ class WeaponInputDisplayNode extends ComputeNode { constructor(name, image_field, dps_field) { super(name); this.image = image_field; this.dps_field = dps_field; } compute_func(input_map) { if (input_map.size !== 1) { throw "WeaponDisplayNode accepts exactly one input (item)"; } const [item] = input_map.values(); // Extract values, pattern match it into size one list and bind to first element const type = item.statMap.get('type'); this.image.setAttribute('src', '../media/items/new/generic-'+type+'.png'); let dps = get_base_dps(item.statMap); if (isNaN(dps)) { dps = dps[1]; if (isNaN(dps)) dps = 0; } this.dps_field.textContent = Math.round(dps); } } /** * Encode the build into a url-able string. * * Signature: BuildEncodeNode(build: Build, * helmet-powder: List[powder], * chestplate-powder: List[powder], * leggings-powder: List[powder], * boots-powder: List[powder], * weapon-powder: List[powder]) => str */ class BuildEncodeNode extends ComputeNode { constructor() { super("builder-encode"); } compute_func(input_map) { const build = input_map.get('build'); const atree = input_map.get('atree'); const atree_state = input_map.get('atree-state'); let powders = [ input_map.get('helmet-powder'), input_map.get('chestplate-powder'), input_map.get('leggings-powder'), input_map.get('boots-powder'), input_map.get('weapon-powder') ]; const skillpoints = [ input_map.get('str'), input_map.get('dex'), input_map.get('int'), input_map.get('def'), input_map.get('agi') ]; // TODO: grr global state for copy button.. player_build = build; build_powders = powders; return encodeBuild(build, powders, skillpoints, atree, atree_state); } } /** * Update the window's URL. * * Signature: URLUpdateNode(build_str: str) => null */ class URLUpdateNode extends ComputeNode { constructor() { super("builder-url-update"); } compute_func(input_map) { if (input_map.size !== 1) { throw "URLUpdateNode accepts exactly one input (build_str)"; } const [build_str] = input_map.values(); // Extract values, pattern match it into size one list and bind to first element location.hash = build_str; } } /** * Create a "build" object from a set of equipments. * Returns a new Build object, or null if all items are NONE items. * * Signature: BuildAssembleNode(helmet-input: Item, * chestplate-input: Item, * leggings-input: Item, * boots-input: Item, * ring1-input: Item, * ring2-input: Item, * bracelet-input: Item, * necklace-input: Item, * weapon-input: Item, * level-input: int) => Build | null */ class BuildAssembleNode extends ComputeNode { constructor() { super("builder-make-build"); } compute_func(input_map) { let equipments = [ input_map.get('helmet-input'), input_map.get('chestplate-input'), input_map.get('leggings-input'), input_map.get('boots-input'), input_map.get('ring1-input'), input_map.get('ring2-input'), input_map.get('bracelet-input'), input_map.get('necklace-input'), input_map.get('weaponTome1-input'), input_map.get('weaponTome2-input'), input_map.get('armorTome1-input'), input_map.get('armorTome2-input'), input_map.get('armorTome3-input'), input_map.get('armorTome4-input'), input_map.get('guildTome1-input') ]; let weapon = input_map.get('weapon-input'); let level = input_map.get('level-input'); let all_none = weapon.statMap.has('NONE'); for (const item of equipments) { all_none = all_none && item.statMap.has('NONE'); } if (all_none && !location.hash) { return null; } return new Build(level, equipments, weapon); } } class PlayerClassNode extends ValueCheckComputeNode { constructor(name) { super(name); } compute_func(input_map) { if (input_map.size !== 1) { throw "PlayerClassNode accepts exactly one input (build)"; } const [build] = input_map.values(); // Extract values, pattern match it into size one list and bind to first element return wep_to_class.get(build.weapon.statMap.get('type')); } } /** * Read an input field and parse into a list of powderings. * Every two characters makes one powder. If parsing fails, NULL is returned. * * Signature: PowderInputNode() => List[powder] | null */ class PowderInputNode extends InputNode { constructor(name, input_field) { super(name, input_field); } compute_func(input_map) { // TODO: haha improve efficiency to O(n) dumb // also, error handling is missing let input = this.input_field.value.trim(); let powdering = []; let errorederrors = []; while (input) { let first = input.slice(0, 2); let powder = powderIDs.get(first); if (powder === undefined) { return null; } else { powdering.push(powder); } input = input.slice(2); } return powdering; } } /** * Select a spell+spell "variation" based on a build / spell idx. * Right now this isn't much logic and is only used to abstract away major id interactions * but will become significantly more complex in wynn2. * * Signature: SpellSelectNode(build: Build) => [Spell, SpellParts] */ class SpellSelectNode extends ComputeNode { constructor(spell) { super("builder-spell"+spell.base_spell+"-select"); this.spell = spell; } compute_func(input_map) { const build = input_map.get('build'); let stats = build.statMap; // TODO: apply major ids... DOOM..... return [this.spell, this.spell.parts]; } } /* * Get all defensive stats for this build. */ function getDefenseStats(stats) { let defenseStats = []; let def_pct = skillPointsToPercentage(stats.get('def')) * skillpoint_final_mult[3]; let agi_pct = skillPointsToPercentage(stats.get('agi')) * skillpoint_final_mult[4]; //total hp let totalHp = stats.get("hp") + stats.get("hpBonus"); if (totalHp < 5) totalHp = 5; defenseStats.push(totalHp); //EHP let ehp = [totalHp, totalHp]; let defMult = (2 - stats.get("classDef")) * stats.get("defMultiplier"); // newehp = oldehp / [0.1 * A(x) + (1 - A(x)) * (1 - D(x))] ehp[0] = ehp[0] / (0.1*agi_pct + (1-agi_pct) * (1-def_pct)); ehp[0] /= defMult; // ehp[0] /= (1-def_pct)*(1-agi_pct)*defMult; ehp[1] /= (1-def_pct)*defMult; defenseStats.push(ehp); //HPR let totalHpr = rawToPct(stats.get("hprRaw"), stats.get("hprPct")/100.); defenseStats.push(totalHpr); //EHPR let ehpr = [totalHpr, totalHpr]; ehpr[0] /= (1-def_pct)*(1-agi_pct)*defMult; ehpr[1] /= (1-def_pct)*defMult; defenseStats.push(ehpr); //skp stats defenseStats.push([ def_pct*100, agi_pct*100]); //eledefs - TODO POWDERS let eledefs = [0, 0, 0, 0, 0]; for(const i in skp_elements){ //kinda jank but ok eledefs[i] = rawToPct(stats.get(skp_elements[i] + "Def"), stats.get(skp_elements[i] + "DefPct")/100.); } defenseStats.push(eledefs); //[total hp, [ehp w/ agi, ehp w/o agi], total hpr, [ehpr w/ agi, ehpr w/o agi], [def%, agi%], [edef,tdef,wdef,fdef,adef]] return defenseStats; } /** * Compute spell damage of spell parts. * Currently kinda janky / TODO while we rework the internal rep. of spells. * * Signature: SpellDamageCalcNode(weapon-input: Item, * stats: StatMap, * spell-info: [Spell, SpellParts]) => List[SpellDamage] */ class SpellDamageCalcNode extends ComputeNode { constructor(spell_num) { super("builder-spell"+spell_num+"-calc"); } compute_func(input_map) { const weapon = input_map.get('build').weapon.statMap; const spell_info = input_map.get('spell-info'); const spell = spell_info[0]; const spell_parts = spell_info[1]; const stats = input_map.get('stats'); const damage_mult = stats.get('damageMultiplier'); const skillpoints = [ stats.get('str'), stats.get('dex'), stats.get('int'), stats.get('def'), stats.get('agi') ]; let spell_results = [] let spell_result_map = new Map(); const use_speed = (('use_atkspd' in spell) ? spell.use_atkspd : true); const use_spell = (('scaling' in spell) ? spell.scaling === 'spell' : true); // TODO: move preprocessing to separate node/node chain for (const part of spell_parts) { let spell_result; if ('multipliers' in part) { // damage type spell let results = calculateSpellDamage(stats, weapon, part.multipliers, use_spell, !use_speed); spell_result = { type: "damage", normal_min: results[2].map(x => x[0]), normal_max: results[2].map(x => x[1]), normal_total: results[0], crit_min: results[2].map(x => x[2]), crit_max: results[2].map(x => x[3]), crit_total: results[1], } } else if ('power' in part) { // TODO: wynn2 formula let _heal_amount = (part.power * getDefenseStats(stats)[0] * Math.max(0.5,Math.min(1.75, 1 + 0.5 * stats.get("wDamPct")/100))); spell_result = { type: "heal", heal_amount: _heal_amount } } else { continue; } spell_result.name = part.name; spell_results.push(spell_result); spell_result_map.set(part.name, spell_result); } for (const part of spell_parts) { if ('hits' in part) { let spell_result = { normal_min: [0, 0, 0, 0, 0, 0], normal_max: [0, 0, 0, 0, 0, 0], normal_total: [0, 0], crit_min: [0, 0, 0, 0, 0, 0], crit_max: [0, 0, 0, 0, 0, 0], crit_total: [0, 0], heal_amount: 0 } const dam_res_keys = ['normal_min', 'normal_max', 'normal_total', 'crit_min', 'crit_max', 'crit_total']; for (const [subpart_name, hits] of Object.entries(part.hits)) { const subpart = spell_result_map.get(subpart_name); if (!subpart) { continue; } if (spell_result.type) { if (subpart.type !== spell_result.type) { throw "SpellCalc total subpart type mismatch"; } } else { spell_result.type = subpart.type; } if (spell_result.type === 'damage') { for (const key of dam_res_keys) { for (let i in spell_result.normal_min) { spell_result[key][i] += subpart[key][i] * hits; } } } else { spell_result.heal_amount += subpart.heal_amount; } } spell_result.name = part.name; spell_results.push(spell_result); spell_result_map.set(part.name, spell_result); } } return spell_results; } } /** * Display spell damage from spell parts. * Currently kinda janky / TODO while we rework the internal rep. of spells. * * Signature: SpellDisplayNode(stats: StatMap, * spell-info: [Spell, SpellParts], * spell-damage: List[SpellDamage]) => null */ class SpellDisplayNode extends ComputeNode { constructor(spell_num) { super("builder-spell"+spell_num+"-display"); this.spell_idx = spell_num; } compute_func(input_map) { const stats = input_map.get('stats'); const spell_info = input_map.get('spell-info'); const damages = input_map.get('spell-damage'); const spell = spell_info[0]; const i = this.spell_idx; let parent_elem = document.getElementById("spell"+i+"-info"); let overallparent_elem = document.getElementById("spell"+i+"-infoAvg"); displaySpellDamage(parent_elem, overallparent_elem, stats, spell, i+1, damages); } } /* Get melee stats for build. Returns an array in the order: */ function getMeleeStats(stats, weapon) { stats = new Map(stats); // Shallow copy const weapon_stats = weapon.statMap; const skillpoints = [ stats.get('str'), stats.get('dex'), stats.get('int'), stats.get('def'), stats.get('agi') ]; if (weapon_stats.get("tier") === "Crafted") { stats.set("damageBases", [weapon_stats.get("nDamBaseHigh"),weapon_stats.get("eDamBaseHigh"),weapon_stats.get("tDamBaseHigh"),weapon_stats.get("wDamBaseHigh"),weapon_stats.get("fDamBaseHigh"),weapon_stats.get("aDamBaseHigh")]); } let adjAtkSpd = attackSpeeds.indexOf(stats.get("atkSpd")) + stats.get("atkTier"); if(adjAtkSpd > 6){ adjAtkSpd = 6; }else if(adjAtkSpd < 0){ adjAtkSpd = 0; } if (weapon_stats.get("type") === "relik") { stats.set('damageMultiplier', 0.99); // CURSE YOU WYNNCRAFT //One day we will create WynnWynn and no longer have shaman 99% melee injustice. //In all seriousness 99% is because wynn uses 0.33 to estimate dividing the damage by 3 to split damage between 3 beams. } let results = calculateSpellDamage(stats, weapon_stats, [100, 0, 0, 0, 0, 0], false, true); let dex = skillpoints[1]; let totalDamNorm = results[0]; let totalDamCrit = results[1]; totalDamNorm.push(1-skillPointsToPercentage(dex)); totalDamCrit.push(skillPointsToPercentage(dex)); let damages_results = results[2]; let singleHitTotal = ((totalDamNorm[0]+totalDamNorm[1])*(totalDamNorm[2]) +(totalDamCrit[0]+totalDamCrit[1])*(totalDamCrit[2]))/2; //Now do math let normDPS = (totalDamNorm[0]+totalDamNorm[1])/2 * baseDamageMultiplier[adjAtkSpd]; let critDPS = (totalDamCrit[0]+totalDamCrit[1])/2 * baseDamageMultiplier[adjAtkSpd]; let avgDPS = (normDPS * (1 - skillPointsToPercentage(dex))) + (critDPS * (skillPointsToPercentage(dex))); //[[n n n n] [e e e e] [t t t t] [w w w w] [f f f f] [a a a a] [lowtotal hightotal normalChance] [critlowtotal crithightotal critChance] normalDPS critCPS averageDPS adjAttackSpeed, singleHit] return damages_results.concat([totalDamNorm,totalDamCrit,normDPS,critDPS,avgDPS,adjAtkSpd, singleHitTotal]).concat(results[3]); } /** * Display build stats. * * Signature: BuildDisplayNode(build: Build) => null */ class BuildDisplayNode extends ComputeNode { constructor() { super("builder-stats-display"); } compute_func(input_map) { const build = input_map.get('build'); const stats = input_map.get('stats'); displayBuildStats('overall-stats', build, build_all_display_commands, stats); displayBuildStats("offensive-stats", build, build_offensive_display_commands, stats); displaySetBonuses("set-info", build); let meleeStats = getMeleeStats(stats, build.weapon); // TODO: move weapon out? displayMeleeDamage(document.getElementById("build-melee-stats"), document.getElementById("build-melee-statsAvg"), meleeStats); displayDefenseStats(document.getElementById("defensive-stats"), stats); displayPoisonDamage(document.getElementById("build-poison-stats"), build); displayEquipOrder(document.getElementById("build-order"), build.equip_order); } } /** * Show warnings for skillpoints, level, set bonus for a build * Also shosw skill point remaining and other misc. info * * Signature: DisplayBuildWarningNode(build: Build, str: int, dex: int, int: int, def: int, agi: int) => null */ class DisplayBuildWarningsNode extends ComputeNode { constructor() { super("builder-show-warnings"); } compute_func(input_map) { const build = input_map.get('build'); const min_assigned = build.base_skillpoints; const base_totals = build.total_skillpoints; const skillpoints = [ input_map.get('str'), input_map.get('dex'), input_map.get('int'), input_map.get('def'), input_map.get('agi') ]; let skp_effects = ["% more damage dealt.","% chance to crit.","% spell cost reduction.","% less damage taken.","% chance to dodge."]; let total_assigned = 0; for (let i in skp_order){ //big bren const assigned = skillpoints[i] - base_totals[i] + min_assigned[i] setText(skp_order[i] + "-skp-base", "Original: " + base_totals[i]); setText(skp_order[i] + "-skp-assign", "Assign: " + assigned); setValue(skp_order[i] + "-skp", skillpoints[i]); let linebreak = document.createElement("br"); linebreak.classList.add("itemp"); setText(skp_order[i] + "-skp-pct", (skillPointsToPercentage(skillpoints[i])*100*skillpoint_final_mult[i]).toFixed(1).concat(skp_effects[i])); document.getElementById(skp_order[i]+"-warnings").textContent = '' if (assigned > 100) { let skp_warning = document.createElement("p"); skp_warning.classList.add("warning", "small-text"); skp_warning.textContent += "Cannot assign " + assigned + " skillpoints in " + ["Strength","Dexterity","Intelligence","Defense","Agility"][i] + " manually."; document.getElementById(skp_order[i]+"-warnings").appendChild(skp_warning); } total_assigned += assigned; } let summarybox = document.getElementById("summary-box"); summarybox.textContent = ""; let skpRow = document.createElement("p"); let remainingSkp = document.createElement("p"); remainingSkp.classList.add("scaled-font"); let remainingSkpTitle = document.createElement("b"); remainingSkpTitle.textContent = "Assigned " + total_assigned + " skillpoints. Remaining skillpoints: "; let remainingSkpContent = document.createElement("b"); remainingSkpContent.textContent = "" + (levelToSkillPoints(build.level) - total_assigned); remainingSkpContent.classList.add(levelToSkillPoints(build.level) - total_assigned < 0 ? "negative" : "positive"); remainingSkp.appendChild(remainingSkpTitle); remainingSkp.appendChild(remainingSkpContent); summarybox.append(skpRow); summarybox.append(remainingSkp); if(total_assigned > levelToSkillPoints(build.level)){ let skpWarning = document.createElement("span"); //skpWarning.classList.add("itemp"); skpWarning.classList.add("warning"); skpWarning.textContent = "WARNING: Too many skillpoints need to be assigned!"; let skpCount = document.createElement("p"); skpCount.classList.add("warning"); skpCount.textContent = "For level " + (build.level>101 ? "101+" : build.level) + ", there are only " + levelToSkillPoints(build.level) + " skill points available."; summarybox.append(skpWarning); summarybox.append(skpCount); } let lvlWarning; for (const item of build.items) { let item_lvl; if (item.statMap.get("crafted")) { //item_lvl = item.get("lvlLow") + "-" + item.get("lvl"); item_lvl = item.statMap.get("lvlLow"); } else { item_lvl = item.statMap.get("lvl"); } if (build.level < item_lvl) { if (!lvlWarning) { lvlWarning = document.createElement("p"); lvlWarning.classList.add("itemp"); lvlWarning.classList.add("warning"); lvlWarning.textContent = "WARNING: A level " + build.level + " player cannot use some piece(s) of this build." } let baditem = document.createElement("p"); baditem.classList.add("nocolor"); baditem.classList.add("itemp"); baditem.textContent = item.statMap.get("displayName") + " requires level " + item_lvl + " to use."; lvlWarning.appendChild(baditem); } } if(lvlWarning){ summarybox.append(lvlWarning); } for (const [setName, count] of build.activeSetCounts) { const bonus = sets.get(setName).bonuses[count-1]; if (bonus["illegal"]) { let setWarning = document.createElement("p"); setWarning.classList.add("itemp"); setWarning.classList.add("warning"); setWarning.textContent = "WARNING: illegal item combination: " + setName summarybox.append(setWarning); } } } } /** * Aggregate stats from all inputs (merges statmaps). * * Signature: AggregateStatsNode(*args) => StatMap */ class AggregateStatsNode extends ComputeNode { constructor() { super("builder-aggregate-stats"); } compute_func(input_map) { const output_stats = new Map(); for (const [k, v] of input_map.entries()) { for (const [k2, v2] of v.entries()) { if (output_stats.has(k2)) { // TODO: ugly AF if (k2 === 'damageMultiplier' || k2 === 'defMultiplier') { output_stats.set(k2, v2 * output_stats.get(k2)); } else { output_stats.set(k2, v2 + output_stats.get(k2)); } } else { output_stats.set(k2, v2); } } } return output_stats; } } /** * Aggregate editable ID stats with build and weapon type. * * Signature: AggregateEditableIDNode(build: Build, weapon: Item, *args) => StatMap */ class AggregateEditableIDNode extends ComputeNode { constructor() { super("builder-aggregate-inputs"); } compute_func(input_map) { const build = input_map.get('build'); input_map.delete('build'); const output_stats = new Map(build.statMap); for (const [k, v] of input_map.entries()) { output_stats.set(k, v); } output_stats.set('classDef', classDefenseMultipliers.get(build.weapon.statMap.get("type"))); return output_stats; } } let edit_id_output; function resetEditableIDs() { edit_id_output.notify(); } /** * Set the editble id fields. * * Signature: EditableIDSetterNode(build: Build) => null */ class EditableIDSetterNode extends ComputeNode { constructor(notify_nodes) { super("builder-id-setter"); this.notify_nodes = notify_nodes.slice(); } compute_func(input_map) { if (input_map.size !== 1) { throw "EditableIDSetterNode accepts exactly one input (build)"; } const [build] = input_map.values(); // Extract values, pattern match it into size one list and bind to first element for (const id of editable_item_fields) { const val = build.statMap.get(id); document.getElementById(id).value = val; document.getElementById(id+'-base').textContent = 'Original Value: ' + val; } } notify() { this.mark_dirty(); this.update(); // NOTE: DO NOT merge these loops for performance reasons!!! for (const node of this.notify_nodes) { node.mark_dirty(); } for (const node of this.notify_nodes) { node.update(); } } } /** * Set skillpoint fields from build. * This is separate because..... because of the way we work with edit ids vs skill points during the load sequence.... * * Signature: SkillPointSetterNode(build: Build) => null */ class SkillPointSetterNode extends ComputeNode { constructor(notify_nodes) { super("builder-skillpoint-setter"); this.notify_nodes = notify_nodes.slice(); } compute_func(input_map) { if (input_map.size !== 1) { throw "SkillPointSetterNode accepts exactly one input (build)"; } const [build] = input_map.values(); // Extract values, pattern match it into size one list and bind to first element for (const [idx, elem] of skp_order.entries()) { document.getElementById(elem+'-skp').value = build.total_skillpoints[idx]; } // NOTE: DO NOT merge these loops for performance reasons!!! for (const node of this.notify_nodes) { node.mark_dirty(); } for (const node of this.notify_nodes) { node.update(); } } } /** * Get number (possibly summed) from a text input. * * Signature: SumNumberInputNode() => int */ class SumNumberInputNode extends InputNode { compute_func(input_map) { let value = this.input_field.value; if (value === "") { value = 0; } let input_num = 0; if (value.includes("+")) { let skp = value.split("+"); for (const s of skp) { const val = parseInt(s,10); if (isNaN(val)) { return null; } input_num += val; } } else { input_num = parseInt(value,10); if (isNaN(input_num)) { return null; } } return input_num; } } let item_nodes = []; let powder_nodes = []; let edit_input_nodes = []; let skp_inputs = []; let build_node; let stat_agg_node; let edit_agg_node; let atree_graph_creator; function builder_graph_init() { // Phase 1/3: Set up item input, propagate updates, etc. // Bind item input fields to input nodes, and some display stuff (for auto colorizing stuff). for (const [eq, display_elem, none_item] of zip3(equipment_fields, build_fields, none_items)) { let input_field = document.getElementById(eq+"-choice"); let item_image = document.getElementById(eq+"-img"); let item_input = new ItemInputNode(eq+'-input', input_field, none_item); item_nodes.push(item_input); new ItemInputDisplayNode(eq+'-input-display', eq, item_image).link_to(item_input); new ItemDisplayNode(eq+'-item-display', display_elem).link_to(item_input); //new PrintNode(eq+'-debug').link_to(item_input); //document.querySelector("#"+eq+"-tooltip").setAttribute("onclick", "collapse_element('#"+ eq +"-tooltip');"); //toggle_plus_minus('" + eq + "-pm'); } for (const [eq, none_item] of zip2(tome_fields, [none_tomes[0], none_tomes[0], none_tomes[1], none_tomes[1], none_tomes[1], none_tomes[1], none_tomes[2]])) { let input_field = document.getElementById(eq+"-choice"); let item_image = document.getElementById(eq+"-img"); let item_input = new ItemInputNode(eq+'-input', input_field, none_item); item_nodes.push(item_input); new ItemInputDisplayNode(eq+'-input-display', eq, item_image).link_to(item_input); } // weapon image changer node. let weapon_image = document.getElementById("weapon-img"); let weapon_dps = document.getElementById("weapon-dps"); new WeaponInputDisplayNode('weapon-type', weapon_image, weapon_dps).link_to(item_nodes[8]); // Level input node. let level_input = new InputNode('level-input', document.getElementById('level-choice')); // "Build" now only refers to equipment and level (no powders). Powders are injected before damage calculation / stat display. build_node = new BuildAssembleNode(); for (const input of item_nodes) { build_node.link_to(input); } build_node.link_to(level_input); let build_encode_node = new BuildEncodeNode(); build_encode_node.link_to(build_node, 'build'); let url_update_node = new URLUpdateNode(); url_update_node.link_to(build_encode_node, 'build-str'); for (const input of powder_inputs) { let powder_node = new PowderInputNode(input, document.getElementById(input)); powder_nodes.push(powder_node); build_encode_node.link_to(powder_node, input); } item_nodes[0].link_to(powder_nodes[0], 'powdering'); item_nodes[1].link_to(powder_nodes[1], 'powdering'); item_nodes[2].link_to(powder_nodes[2], 'powdering'); item_nodes[3].link_to(powder_nodes[3], 'powdering'); item_nodes[8].link_to(powder_nodes[4], 'powdering'); // Phase 2/3: Set up editable IDs, skill points; use decodeBuild() skill points, calculate damage let build_disp_node = new BuildDisplayNode() build_disp_node.link_to(build_node, 'build'); // Create one node that will be the "aggregator node" (listen to all the editable id nodes, as well as the build_node (for non editable stats) and collect them into one statmap) stat_agg_node = new AggregateStatsNode(); edit_agg_node = new AggregateEditableIDNode(); edit_agg_node.link_to(build_node, 'build'); for (const field of editable_item_fields) { // Create nodes that listens to each editable id input, the node name should match the "id" const elem = document.getElementById(field); const node = new SumNumberInputNode('builder-'+field+'-input', elem); edit_agg_node.link_to(node, field); edit_input_nodes.push(node); } // Edit IDs setter declared up here to set ids so they will be populated by default. edit_id_output = new EditableIDSetterNode(edit_input_nodes); // Makes shallow copy of list. edit_id_output.link_to(build_node); for (const skp of skp_order) { const elem = document.getElementById(skp+'-skp'); const node = new SumNumberInputNode('builder-'+skp+'-input', elem); edit_agg_node.link_to(node, skp); build_encode_node.link_to(node, skp); edit_input_nodes.push(node); skp_inputs.push(node); } stat_agg_node.link_to(edit_agg_node); build_disp_node.link_to(stat_agg_node, 'stats'); // Phase 3/3: Set up atree stuff. let class_node = new PlayerClassNode('builder-class').link_to(build_node); // These two are defined in `atree.js` atree_node.link_to(class_node, 'player-class'); atree_merge.link_to(build_node, 'build'); atree_graph_creator = new AbilityTreeEnsureNodesNode(build_node, stat_agg_node) .link_to(atree_collect_spells, 'spells'); stat_agg_node.link_to(atree_stats, 'atree-stats'); build_encode_node.link_to(atree_node, 'atree').link_to(atree_state_node, 'atree-state'); // --------------------------------------------------------------- // Trigger the update cascade for build! // --------------------------------------------------------------- for (const input_node of item_nodes.concat(powder_nodes)) { input_node.update(); } level_input.update(); // kinda janky, manually set atree and update. Some wasted compute here if (atree_data !== null && atree_node.value !== null) { // janky check if atree is valid const atree_state = atree_state_node.value; if (atree_data.length > 0) { const active_nodes = decode_atree(atree_node.value, atree_data); for (const node of active_nodes) { atree_set_state(atree_state.get(node.ability.id), true); } atree_state_node.mark_dirty().update(); } } // Powder specials. let powder_special_calc = new PowderSpecialCalcNode().link_to(powder_special_input, 'powder-specials'); new PowderSpecialDisplayNode().link_to(powder_special_input, 'powder-specials') .link_to(stat_agg_node, 'stats').link_to(build_node, 'build'); stat_agg_node.link_to(powder_special_calc, 'powder-boost'); stat_agg_node.link_to(armor_powder_node, 'armor-powder'); powder_special_input.update(); // Potion boost. stat_agg_node.link_to(boosts_node, 'potion-boost'); // Also do something similar for skill points for (const node of edit_input_nodes) { node.update(); } let skp_output = new SkillPointSetterNode(edit_input_nodes); skp_output.link_to(build_node); let build_warnings_node = new DisplayBuildWarningsNode(); build_warnings_node.link_to(build_node, 'build'); for (const [skp_input, skp] of zip2(skp_inputs, skp_order)) { build_warnings_node.link_to(skp_input, skp); } build_warnings_node.update(); // call node.update() for each skillpoint node and stat edit listener node manually // NOTE: the text boxes for skill points are already filled out by decodeBuild() so this will fix them // this will propagate the update to the `stat_agg_node`, and then to damage calc console.log("Set up graph"); }