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