ATLYSS TechPendium

Special:Common.js

Common.js

$(function () {
  "use strict";

  /* =====================================================
   * Global namespace
   * ===================================================== */

  const ATLYSS = (window.ATLYSS = window.ATLYSS || {});

  /* =====================================================
   * Constants
   * ===================================================== */

  const MODULE_BASE = "Module:TEST";
  const API_ROOT = "/api/v2/wikis/atlyss/pages/" + MODULE_BASE;

  const EQUIPMENT_SLOTS = [
    "weapon",
    "shield",
    "trinket",
    "helmet",
    "cape",
    "chestpiece",
    "leggings"
  ];

  /* =====================================================
   * Shared state
   * ===================================================== */

  const state = {
    version: null,
    ready: false,

    registries: {
      stats: null,
      curves: null,
      attributes: null
    },

    equipmentData: null,
    modifiersData: null,
    modifiersByName: null
  };

  ATLYSS.state = state;

  /* =====================================================
   * API helpers
   * ===================================================== */

  function fetchJSON(url) {
    return fetch(url).then(r => {
      if (!r.ok) {
        throw new Error("Fetch failed: " + url);
      }

      return r.json();
    }).then(data => {
      if (!data || data.exists === false) {
        throw new Error("Data not found: " + url);
      }

      if (!data.page || !data.page.content) {
        throw new Error("Invalid page payload: " + url);
      }

      const content = data.page.content
        .replace(/^\s*exports\s*=\s*/, "")
        .replace(/;\s*$/, "");

      try {
        return JSON.parse(content);
      } catch (e) {
        throw new Error("JSON parse failed for " + url + ": " + e.message);
      }
    });
  }

  function fetchLatestVersion() {
    return fetchJSON(API_ROOT + "/Versions");
  }

  function fetchModuleData(name) {
    return fetchJSON(
      API_ROOT + "/" + name + "/data/" + state.version
    );
  }

  /* =====================================================
   * Utility helpers
   * ===================================================== */

  function createEmptyStatStruct() {
    const out = {};
    Object.keys(state.registries.stats).forEach(k => (out[k] = 0));
    return out;
  }

  function applyStatStruct(target, source) {
    Object.keys(target).forEach(k => {
      target[k] += source[k] || 0;
    });
  }

  function clearSelect(select) {
    while (select.options.length > 1) select.remove(1);
  }

  function sortByName(a, b) {
    return a.name.localeCompare(b.name);
  }

  function populateSelect(select, items) {
    if (!select) return;
    clearSelect(select);

    items.slice().sort(sortByName).forEach(item => {
      const opt = document.createElement("option");
      opt.value = item.id;
      opt.textContent = item.name;
      opt.dataset.rarity = item.rarity || "";
      select.appendChild(opt);
    });
  }

  /* =====================================================
   * Grouping helpers
   * ===================================================== */

  const ARMOR_SLOT_MAP = {
    helm: "helmet",
    cape: "cape",
    chest: "chestpiece",
    legs: "leggings",
    shield: "shield",
    ring: "trinket"
  };

  function groupEquipmentBySlot(items) {
    const out = {};
    EQUIPMENT_SLOTS.forEach(s => (out[s] = []));

    for (const id in items) {
      const item = items[id];
      if (!item || item.role === "cosmetic") continue;
      if (item.type === "weapon") {
        out.weapon.push(item);
        continue;
      }
      if (!out[ARMOR_SLOT_MAP[item.slot]]) continue;
      out[ARMOR_SLOT_MAP[item.slot]].push(item);
    }

    return out;
  }

  function groupModifiersByName(modifiers) {
    const out = {};
    for (const id in modifiers.modifiers) {
      const mod = modifiers.modifiers[id];
      if (!mod || !mod.scaling || !mod.scaling.equipment) continue;
      out[id] = mod;
      out[id].name = id;
      out[id].id = id;
    }
    return out;
  }

  /* =====================================================
   * Curve evaluation (Unity-style)
   * ===================================================== */

  function evaluateCurve(curve, x) {
    const keys = curve.keys.slice().sort((a, b) => a.time - b.time);
    if (!keys.length) return 0;

    const first = keys[0];
    const last = keys[keys.length - 1];

    if (x <= first.time) return handleInfinity(curve.preInfinity, x, first);
    if (x >= last.time) return handleInfinity(curve.postInfinity, x, last);

    for (let i = 0; i < keys.length - 1; i++) {
      const k0 = keys[i];
      const k1 = keys[i + 1];
      if (x >= k0.time && x <= k1.time) {
        return hermiteInterpolate(k0, k1, x);
      }
    }

    return 0;
  }

  function hermiteInterpolate(k0, k1, x) {
    const dt = k1.time - k0.time;
    if (!dt) return k0.value;

    let t = (x - k0.time) / dt;
    t = Math.max(0, Math.min(1, t));

    const t2 = t * t;
    const t3 = t2 * t;

    const w0 = k0.outWeight ?? 1 / 3;
    const w1 = k1.inWeight ?? 1 / 3;

    const m0 = k0.outSlope * dt * w0 * 3;
    const m1 = k1.inSlope * dt * w1 * 3;

    return (
      (2 * t3 - 3 * t2 + 1) * k0.value +
      (t3 - 2 * t2 + t) * m0 +
      (-2 * t3 + 3 * t2) * k1.value +
      (t3 - t2) * m1
    );
  }

  function handleInfinity(mode, x, k) {
    if (mode === 1) return k.value + (x - k.time) * k.outSlope;
    return k.value;
  }

  /* =====================================================
   * Attribute evaluation
   * ===================================================== */

  function evaluateAttributeStats(level, attrs) {
    const result = createEmptyStatStruct();
    const { attributes, curves } = state.registries;

    for (const name in attributes) {
      const attribute = attributes[name];
      const attributeValue = attrs[name.replace("attr.", "")] || 0;

      if (!attributeValue) continue;

      for (const entry of attribute.effects) {
        const {
          stat,
          dampen,
          increasePct,
          baseMultiplier,
          value
        } = entry;

        if (value === 0 && !stat.includes("rate") && !stat.includes("evasion")) {
          continue;
        }

        const calculatedStat =
          increasePct * attributeValue * (level + (1 / dampen)); // Unity bug preserved
        const multiplier = level * baseMultiplier;

        switch (stat) {
          /* --- COMBAT POWER --- */
          case "stat.attack_power":
          case "stat.dex_power":
          case "stat.magic_power":
            result[stat] += Math.floor(
              attributeValue + (value * level * dampen)
            );
            break;

          /* --- DEFENSE --- */
          case "stat.defence":
            result[stat] +=
              1 +
              Math.floor(value * (calculatedStat / dampen)) +
              Math.floor(multiplier * 0.35);
            break;

          case "stat.magic_defence":
            result[stat] +=
              1 +
              Math.floor(value * (calculatedStat / dampen)) +
              Math.floor(multiplier * 0.25);
            break;

          /* --- CURVE-BASED VITALS --- */
          case "stat.max_health": {
            const base = evaluateCurve(curves.curve_42, level - 1);
            result[stat] += Math.floor(base + (value * attributeValue));
            break;
          }

          case "stat.max_mana": {
            const base = evaluateCurve(curves.curve_43, level - 1);
            result[stat] += Math.floor(
              base + (value * Math.floor(attributeValue * 0.45))
            );
            break;
          }

          case "stat.max_stamina": {
            const base = evaluateCurve(curves.curve_44, level - 1);
            const fromAttr = value * Math.floor(calculatedStat / dampen);
            const fromLevel = Math.floor(multiplier * 0.35);

            result[stat] +=
              Math.floor(base) +
              Math.floor(fromAttr + fromLevel);
            break;
          }

          /* --- RATES --- */
          case "stat.crit_rate":
          case "stat.magic_crit_rate":
          case "stat.evasion": {
            const newRate = result[stat] + (value * attributeValue);
            result[stat] = Math.min(newRate, 1.0);
            break;
          }

          default:
            break;
        }
      }
    }

    return result;
  }

  /* =====================================================
   * Equipment evaluation
   * ===================================================== */

  function evaluateEquipmentStats(equipment) {
    const result = createEmptyStatStruct();

    equipment.forEach(({ item, modifier }) => {
      if (!item) return;

      /* --- BASE ITEM STATS (flat, no scaling) --- */
      if (item.stats) {
        for (const [stat, value] of Object.entries(item.stats)) {
          if (!value) continue;

          // rate stats stay float
          if (stat.includes("rate") || stat.includes("evasion")) {
            result[stat] += value;
          } else {
            result[stat] += Math.floor(value);
          }
        }
      }

      /* --- MODIFIER STATS (scaled) --- */
      if (
        modifier?.stats &&
        modifier.scaling?.equipment &&
        item.requirements.level != null
      ) {
        const scale = item.requirements.level * modifier.scaling.equipment;

        for (const [stat, baseValue] of Object.entries(modifier.stats)) {
          if (!baseValue) continue;

          // rate stats stay float
          if (stat.includes("rate") || stat.includes("evasion")) {
            result[stat] += baseValue * scale;
            continue;
          }

          // flat stats: int + minimum +1 rule
          let value = Math.floor(baseValue * scale);
          if (value <= 0 && baseValue > 0) value = 1;

          result[stat] += value;
        }
      }
    });

    return result;
  }

  /* =====================================================
   * Character evaluation (public)
   * ===================================================== */

  function evaluateCharacter(level, attrs, equipment) {
    const stats = createEmptyStatStruct();

    applyStatStruct(stats, { "stat.evasion": 0.012 });
    applyStatStruct(stats, evaluateAttributeStats(level, attrs));
    applyStatStruct(stats, evaluateEquipmentStats(equipment));

    Object.keys(stats).forEach(k => {
      if (k.includes("rate") || k.includes("evasion")) {
        stats[k] = Math.min(Math.max(stats[k], 0), 1);
      }
    });

    return stats;
  }

  ATLYSS.CharacterCalculator = {
    evaluate: evaluateCharacter,
    getVersion: () => state.version
  };

  /* =====================================================
   * Equipment UI
   * ===================================================== */

  ATLYSS.EquipmentUI = {
    populate(root) {
      const grouped = groupEquipmentBySlot(state.equipmentData);
      EQUIPMENT_SLOTS.forEach(slot => {
        populateSelect(
          root.querySelector(`select[data-slot="${slot}"]`),
          grouped[slot]
        );
      });
    },

    updateShield(root) {
      const weaponSel = root.querySelector('select[data-slot="weapon"]');
      const shieldSel = root.querySelector('select[data-slot="shield"]');
      if (!weaponSel || !shieldSel) return;

      const weapon = state.equipmentData[weaponSel.value];

      const allowShield = !weapon || weapon.twoHanded !== true;

      shieldSel.disabled = !allowShield;

      if (!allowShield) {
        shieldSel.value = "— Shield —";
      }
    },

    updateModifier(root, slot) {
      const itemSel = root.querySelector(`select[data-slot="${slot}"]`);
      const modSel = root.querySelector(`select[data-slot="${slot}-mod"]`);

      if (!itemSel || !modSel) return;

      clearSelect(modSel);

      if (!state.modifiersByName) {
        modSel.disabled = true;
        return;
      }

      populateSelect(
        modSel,
        Object.values(state.modifiersByName)
      );

      modSel.disabled = false;
    }
  };

  function readAttributes($root) {
    const out = {};
    $root.find("input[data-attr]").each(function () {
      out[this.dataset.attr] = Number(this.value) || 0;
    });
    return out;
  }

  function readLevel($root) {
    const $el = $root.find("input[data-field=level]");
    return $el.length ? Number($el.val()) || 1 : 1;
  }

  function readEquipment($root) {
    const equipment = [];

    EQUIPMENT_SLOTS.forEach(slot => {
      const $itemSel = $root.find(`select[data-slot="${slot}"]`);
      if (!$itemSel.length || !$itemSel.val()) return;

      const $modSel = $root.find(`select[data-slot="${slot}-mod"]`);

      equipment.push({
        item: state.equipmentData[$itemSel.val()],
        modifier: $modSel.length && $modSel.val()
          ? state.modifiersByName[$modSel.val()]
          : null
      });
    });

    return equipment;
  }

  function renderStats($root, stats) {
    for (const key in stats) {
      const $el = $root.find(`[data-result="${key.replace("stat.", "")}"]`);
      if (!$el.length) continue;

      const val = stats[key];

      $el.text(
        key.includes("rate") || key.includes("evasion")
          ? Math.round(val * 10000) / 100 + "%"
          : Math.floor(val)
      );
    }
  }

  function recompute($root) {
    if (!state.ready) return;

    const level = readLevel($root);
    const attrs = readAttributes($root);
    const equipment = readEquipment($root);

    const stats = ATLYSS.CharacterCalculator.evaluate(
      level,
      attrs,
      equipment
    );

    renderStats($root, stats);
  }

  /* =====================================================
   * Wiring
   * ===================================================== */

  function wireCalculator(root) {
    const $root = $(root);

    ATLYSS.EquipmentUI.populate(root);
    ATLYSS.EquipmentUI.updateShield(root);

    EQUIPMENT_SLOTS.forEach(slot =>
      ATLYSS.EquipmentUI.updateModifier(root, slot)
    );

    // Item change → update modifier + shield + recompute
    $root.on("change", 'select[data-slot]', function () {
      const slot = this.dataset.slot;
      ATLYSS.EquipmentUI.updateModifier(root, slot);
      ATLYSS.EquipmentUI.updateShield(root);
      recompute($root);
    });

    // Modifier change → recompute
    $root.on("change", 'select[data-slot$="-mod"]', function () {
      recompute($root);
    });

    // Attribute inputs → recompute
    $root.on("input change", "input[data-attr]", function () {
      recompute($root);
    });

    // Level input → recompute
    $root.on("input change", "input[data-field=level]", function () {
      recompute($root);
    });

    // Initial calculation
    recompute($root);
  }

  function init() {
    $(".lgws-calculator[data-calculator='character']").each(function () {
      wireCalculator(this);
    });
  }

  /* =====================================================
   * Bootstrap
   * ===================================================== */

  if ($(".lgws-calculator[data-calculator='character']").length) {
    fetchLatestVersion()
      .then(v => {
        state.version = v.latest;
        return Promise.all([
          fetchModuleData("Stats"),
          fetchModuleData("Curves"),
          fetchModuleData("Attributes"),
          fetchModuleData("Equipment"),
          fetchModuleData("Modifiers")
        ]);
      })
      .then(([stats, curves, attrs, equipment, modifiers]) => {
        state.registries.stats = stats;
        state.registries.curves = curves;
        state.registries.attributes = attrs;
        state.equipmentData = equipment;
        state.modifiersData = modifiers;
        state.modifiersByName = groupModifiersByName(modifiers);
        state.ready = true;
        init();
      })
      .catch(err => {
        console.error("ATLYSS calculator init failed:", err);
      });
  }
});

$(function() {
    let $activeTooltip = null;

    function showTooltip(event, tooltipId) {
        const $tooltip = $('#' + tooltipId.replace(/:/g, "\\:")); // escape colon for jQuery
        if (!$tooltip.length) return;

        $activeTooltip = $tooltip;
        $tooltip.css({
            opacity: 1,
            display: "block"
        });

        positionTooltip(event);
    }

    function positionTooltip(event) {
        if (!$activeTooltip) return;

        const offsetX = 15;
        const offsetY = 15;
        let x = event.pageX + offsetX;
        let y = event.pageY + offsetY;

        const tooltipWidth = $activeTooltip.outerWidth();
        const tooltipHeight = $activeTooltip.outerHeight();
        const windowRight = $(window).scrollLeft() + $(window).width();
        const windowBottom = $(window).scrollTop() + $(window).height();

        if (x + tooltipWidth > windowRight) x = event.pageX - tooltipWidth - offsetX;
        if (y + tooltipHeight > windowBottom) y = event.pageY - tooltipHeight - offsetY;

        $activeTooltip.css({ left: x + 'px', top: y + 'px' });
    }

    function hideTooltip() {
        if ($activeTooltip) {
            $activeTooltip.css({ opacity: 0 });
            $activeTooltip = null;
        }
    }

    // Attach hover events to all elements referencing tooltips
    $('body').on('mouseenter', '[data-tooltip-id]', function(e) {
        const tooltipId = $(this).data('tooltip-id');
        if (tooltipId) showTooltip(e, tooltipId);
    }).on('mousemove', '[data-tooltip-id]', function(e) {
        positionTooltip(e);
    }).on('mouseleave', '[data-tooltip-id]', function() {
        hideTooltip();
    });
});
Last Edited by on 2/7/2026, 12:15:18 AM