Skip to content

Commit df26373

Browse files
committed
initial commit
0 parents  commit df26373

8 files changed

Lines changed: 1382 additions & 0 deletions

File tree

README.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# Skyrim calculators
2+
3+
A small static site with an **Enchanting** calculator for *The Elder Scrolls V: Skyrim*. You pick an enchantment, skill, perks, potions, and patch behavior; it shows the **net magnitude** and a **step-by-step formula breakdown**.
4+
5+
The math follows [UESP: Enchanting Effects](https://en.uesp.net/wiki/Skyrim:Enchanting_Effects): skill multiplier curve, **Unofficial Patch vs vanilla** Fortify Enchanting, apparel soul-gem scaling, weapon max magnitude (soul size affects charges, not displayed max strength), integer flooring, and **Destruction perks** on elemental weapon enchantments.
6+
7+
Alchemy and Smithing in the sidebar are placeholders for future calculators.
8+
9+
---
10+
11+
## Open the calculator
12+
13+
**After you enable GitHub Pages** (see below), the app is served from the repository root. GitHub serves **[index.html](index.html)** as the default document when you open the site URL (you do not need to add `/index.html` in the browser).
14+
15+
Use this pattern for your live site (copy into the address bar after Pages is enabled):
16+
17+
`https://YOUR_GITHUB_USERNAME.github.io/skyrim-calculators/`
18+
19+
Replace `YOUR_GITHUB_USERNAME` with your GitHub user or organization name (the same segment as in `github.com/YOUR_GITHUB_USERNAME/skyrim-calculators`). If the repository name on GitHub is not `skyrim-calculators`, use that name as the last path segment instead.
20+
21+
### Enable GitHub Pages (simplest setup)
22+
23+
1. Push this repository to GitHub.
24+
2. On GitHub: **Settings → Pages** (under “Code and automation”).
25+
3. Under **Build and deployment**, set **Source** to **Deploy from a branch**.
26+
4. Choose branch **`main`** and folder **`/ (root)`**, then save.
27+
5. After a short build, open the URL above (with your username filled in).
28+
29+
No build step or `dist` folder is required; the HTML, CSS, and JS in the repo root are the published site.
30+
31+
---
32+
33+
## Run locally
34+
35+
The app uses **ES modules**. Use a local HTTP server so imports resolve (opening `index.html` as a `file://` URL is unreliable):
36+
37+
```bash
38+
npx --yes serve .
39+
```
40+
41+
Then open the URL shown in the terminal (for example **http://localhost:3000**). That URL loads **[index.html](index.html)** as the default page.
42+
43+
---
44+
45+
## Tests
46+
47+
```bash
48+
npm test
49+
```
50+
51+
Pure math lives in `enchanting-math.js` and is covered by `enchanting-math.test.js`.
52+
53+
---
54+
55+
## Files
56+
57+
| File | Role |
58+
|------|------|
59+
| `index.html` | Page shell; loads `calculator.js` |
60+
| `styles.css` | Layout and theme |
61+
| `enchantments.js` | Base magnitudes and perk tags per enchant |
62+
| `enchanting-math.js` | UESP-style formulas (shared + testable) |
63+
| `calculator.js` | DOM wiring and breakdown UI |
64+
65+
---
66+
67+
## Notes
68+
69+
- **Weapons:** Maximum displayed magnitude does **not** use soul-gem size; soul size affects **charges**, not max strength (UESP).
70+
- **Vanilla (no USKP):** Fortify Enchanting is modeled as a **flat bonus** to the skill term used in the skill multiplier, not as a separate multiplier (and not as `skill × (1 + potion)`).
71+
- **With USKP:** Fortify Enchanting is a separate multiplier; the skill term uses skill plus direct bonuses (e.g. Ahzidal’s Genius).

calculator.js

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
import { skyrimEnchantments } from './enchantments.js';
2+
import {
3+
skillMultiplierFromS,
4+
resolveSkillAndPotion,
5+
computeMagnitude
6+
} from './enchanting-math.js';
7+
8+
document.addEventListener('DOMContentLoaded', () => {
9+
const db = skyrimEnchantments;
10+
if (!db?.weapon?.length || !db?.apparel?.length) {
11+
console.error('skyrimEnchantments data is missing or invalid.');
12+
return;
13+
}
14+
15+
const elements = {
16+
calcType: document.getElementById('calc-type'),
17+
enchantmentSelect: document.getElementById('enchantment-select'),
18+
skillDisplay: document.getElementById('skill-display'),
19+
enchantingSkill: document.getElementById('enchanting-skill'),
20+
potionMagnitude: document.getElementById('potion-magnitude'),
21+
soulGem: document.getElementById('soul-gem'),
22+
soulGemGroup: document.querySelector('#soul-gem')?.closest('.form-group'),
23+
enchanterRank: document.getElementById('enchanter-rank'),
24+
25+
perkInsightful: document.getElementById('perk-insightful'),
26+
perkCorpus: document.getElementById('perk-corpus'),
27+
perkFire: document.getElementById('perk-fire'),
28+
perkFrost: document.getElementById('perk-frost'),
29+
perkStorm: document.getElementById('perk-storm'),
30+
31+
destFire: document.getElementById('dest-fire'),
32+
destFrost: document.getElementById('dest-frost'),
33+
destShock: document.getElementById('dest-shock'),
34+
35+
effectSeeker: document.getElementById('effect-seeker'),
36+
effectAhzidal: document.getElementById('effect-ahzidal'),
37+
unofficialPatch: document.getElementById('unofficial-patch'),
38+
39+
netMagnitude: document.getElementById('net-magnitude'),
40+
netMagnitudeLabel: document.getElementById('net-magnitude-label'),
41+
resultFixedWrap: document.getElementById('result-fixed-wrap'),
42+
netMagnitudeFixed: document.getElementById('net-magnitude-fixed'),
43+
netMagnitudeFixedHint: document.getElementById('net-magnitude-fixed-hint'),
44+
breakdownGrid: document.getElementById('breakdown-grid'),
45+
destructionPerksSection: document.getElementById('destruction-perks')
46+
};
47+
48+
function setupEventListeners() {
49+
const inputs = Object.values(elements).filter(
50+
(el) => el && (el.tagName === 'INPUT' || el.tagName === 'SELECT')
51+
);
52+
53+
inputs.forEach((input) => {
54+
input.addEventListener('input', updateCalculation);
55+
});
56+
57+
elements.enchantingSkill.addEventListener('input', (e) => {
58+
elements.skillDisplay.textContent = e.target.value;
59+
});
60+
61+
elements.calcType.addEventListener('change', (e) => {
62+
populateEnchantmentsDropdown(e.target.value);
63+
updateUIForType(e.target.value);
64+
updateCalculation();
65+
});
66+
67+
elements.enchantmentSelect.addEventListener('change', () => {
68+
updateCalculation();
69+
});
70+
}
71+
72+
function populateEnchantmentsDropdown(type) {
73+
const list = type === 'weapon' ? db.weapon : db.apparel;
74+
elements.enchantmentSelect.innerHTML = '';
75+
list.forEach((ench, index) => {
76+
const option = document.createElement('option');
77+
option.value = String(index);
78+
option.textContent = `${ench.name} (Base: ${ench.base})`;
79+
elements.enchantmentSelect.appendChild(option);
80+
});
81+
}
82+
83+
function updateUIForType(type) {
84+
if (type === 'weapon') {
85+
elements.destructionPerksSection.style.display = 'flex';
86+
if (elements.soulGemGroup) {
87+
elements.soulGemGroup.querySelector('label')?.setAttribute(
88+
'title',
89+
'Soul size affects weapon charges, not maximum effect magnitude (UESP).'
90+
);
91+
}
92+
} else {
93+
elements.destructionPerksSection.style.display = 'none';
94+
if (elements.soulGemGroup) {
95+
elements.soulGemGroup.querySelector('label')?.removeAttribute('title');
96+
}
97+
}
98+
}
99+
100+
function getSelectedEnchantment() {
101+
const type = elements.calcType.value;
102+
const list = type === 'weapon' ? db.weapon : db.apparel;
103+
const index = parseInt(elements.enchantmentSelect.value, 10);
104+
return list[Number.isFinite(index) ? index : 0] || list[0];
105+
}
106+
107+
function disableIrrelevantPerks(ench) {
108+
const allPerks = [
109+
'perk-insightful',
110+
'perk-corpus',
111+
'perk-fire',
112+
'perk-frost',
113+
'perk-storm',
114+
'dest-fire',
115+
'dest-frost',
116+
'dest-shock'
117+
];
118+
allPerks.forEach((p) => {
119+
const el = document.getElementById(p);
120+
if (el) {
121+
const label = el.closest('label');
122+
if (ench.perks.includes(p)) {
123+
el.disabled = false;
124+
if (label) label.style.opacity = '1';
125+
} else {
126+
el.disabled = true;
127+
el.checked = false;
128+
if (label) label.style.opacity = '0.3';
129+
}
130+
}
131+
});
132+
}
133+
134+
function appendBreakdownRow(fragment, name, value) {
135+
const row = document.createElement('div');
136+
row.className = 'breakdown-row';
137+
const left = document.createElement('span');
138+
left.textContent = name;
139+
const right = document.createElement('span');
140+
right.className = 'breakdown-value';
141+
right.textContent = value;
142+
row.appendChild(left);
143+
row.appendChild(right);
144+
fragment.appendChild(row);
145+
}
146+
147+
function updateCalculation() {
148+
const type = elements.calcType.value;
149+
const ench = getSelectedEnchantment();
150+
if (!ench) return;
151+
152+
disableIrrelevantPerks(ench);
153+
154+
const baseMag = ench.base;
155+
const baseSkill = parseFloat(elements.enchantingSkill.value) || 0;
156+
const potionMag = parseFloat(elements.potionMagnitude.value) || 0;
157+
const soulGem = parseFloat(elements.soulGem.value) || 1.0;
158+
const enchanterRank = parseFloat(elements.enchanterRank.value) || 0;
159+
const hasUnofficialPatch = elements.unofficialPatch.checked;
160+
const hasAhzidal = elements.effectAhzidal.checked;
161+
const hasSeeker = elements.effectSeeker.checked;
162+
163+
const { skillForMultiplier, potionMultiplier } = resolveSkillAndPotion({
164+
baseSkill,
165+
potionMag,
166+
hasAhzidal,
167+
hasUnofficialPatch
168+
});
169+
const skillMultiplier = skillMultiplierFromS(skillForMultiplier);
170+
171+
let specificPerkModifier = 0;
172+
if (ench.perks.includes('perk-insightful') && elements.perkInsightful.checked) specificPerkModifier += 0.25;
173+
if (ench.perks.includes('perk-corpus') && elements.perkCorpus.checked) specificPerkModifier += 0.25;
174+
if (ench.perks.includes('perk-fire') && elements.perkFire.checked) specificPerkModifier += 0.25;
175+
if (ench.perks.includes('perk-frost') && elements.perkFrost.checked) specificPerkModifier += 0.25;
176+
if (ench.perks.includes('perk-storm') && elements.perkStorm.checked) specificPerkModifier += 0.25;
177+
178+
const multSoul = soulGem;
179+
const multEnchanter = 1 + enchanterRank;
180+
const multSpecific = 1 + specificPerkModifier;
181+
const multSeeker = 1 + (hasSeeker ? 0.1 : 0);
182+
183+
let destructPerkModifier = 0;
184+
if (type === 'weapon') {
185+
if (ench.perks.includes('dest-fire') && elements.destFire.checked) destructPerkModifier += 0.5;
186+
if (ench.perks.includes('dest-frost') && elements.destFrost.checked) destructPerkModifier += 0.5;
187+
if (ench.perks.includes('dest-shock') && elements.destShock.checked) destructPerkModifier += 0.5;
188+
}
189+
const multDestruction = 1 + destructPerkModifier;
190+
191+
const fragment = document.createDocumentFragment();
192+
193+
appendBreakdownRow(fragment, ench.baseLabel || 'Base Magnitude', String(baseMag));
194+
195+
if (type === 'apparel') {
196+
appendBreakdownRow(fragment, 'Soul Gem Ext.', `${multSoul}x`);
197+
} else {
198+
appendBreakdownRow(
199+
fragment,
200+
'Soul gem (max magnitude)',
201+
'— (affects charges only)'
202+
);
203+
}
204+
205+
appendBreakdownRow(fragment, 'Skill Multiplier', `${skillMultiplier.toFixed(4)}x`);
206+
207+
if (hasUnofficialPatch && potionMag > 0) {
208+
appendBreakdownRow(fragment, 'Potion Mod', `${potionMultiplier}x`);
209+
} else if (!hasUnofficialPatch && potionMag > 0) {
210+
appendBreakdownRow(fragment, 'Fortify Enchanting', 'In effective skill (vanilla)');
211+
}
212+
213+
appendBreakdownRow(fragment, 'Enchanter Perk', `${multEnchanter}x`);
214+
if (multSpecific > 1) appendBreakdownRow(fragment, 'Specific Perks', `${multSpecific}x`);
215+
if (multSeeker > 1) appendBreakdownRow(fragment, 'Seeker of Sorc.', `${multSeeker}x`);
216+
217+
const result = computeMagnitude({
218+
itemType: type,
219+
baseMag,
220+
soulMult: multSoul,
221+
skillMultiplier,
222+
potionMultiplier,
223+
enchanterMult: multEnchanter,
224+
specificMult: multSpecific,
225+
seekerMult: multSeeker,
226+
destructionMult: multDestruction
227+
});
228+
229+
if (type === 'weapon' && multDestruction > 1 && result.preDestruction != null) {
230+
appendBreakdownRow(fragment, 'Pre-Destruction', String(result.preDestruction));
231+
appendBreakdownRow(fragment, 'Destruction', `${multDestruction}x`);
232+
}
233+
234+
const fixed = ench.fixedSecondary;
235+
if (fixed) {
236+
appendBreakdownRow(
237+
fragment,
238+
`${fixed.label} (fixed)`,
239+
`+${fixed.percent}% (does not scale with skill/soul/perks)`
240+
);
241+
}
242+
243+
const formattedMagnitude = result.net;
244+
245+
const totalRow = document.createElement('div');
246+
totalRow.className = 'breakdown-row total';
247+
totalRow.style.marginTop = '10px';
248+
totalRow.style.paddingTop = '10px';
249+
totalRow.style.borderTop = '1px solid rgba(255,255,255,0.2)';
250+
const totalLeft = document.createElement('span');
251+
totalLeft.textContent = fixed ? 'School line (floor)' : 'Final Output (Floor)';
252+
const totalRight = document.createElement('span');
253+
totalRight.className = 'breakdown-value';
254+
totalRight.textContent = String(formattedMagnitude);
255+
totalRow.appendChild(totalLeft);
256+
totalRow.appendChild(totalRight);
257+
fragment.appendChild(totalRow);
258+
259+
elements.netMagnitude.textContent = String(formattedMagnitude);
260+
if (elements.netMagnitudeLabel) {
261+
elements.netMagnitudeLabel.textContent = fixed
262+
? 'Fortify school (scaled magnitude)'
263+
: 'Net Magnitude';
264+
}
265+
if (elements.resultFixedWrap && elements.netMagnitudeFixed && elements.netMagnitudeFixedHint) {
266+
if (fixed) {
267+
elements.resultFixedWrap.hidden = false;
268+
elements.netMagnitudeFixed.textContent = `+${fixed.percent}% ${fixed.label}`;
269+
elements.netMagnitudeFixedHint.textContent =
270+
'Second effect on this enchant; magnitude fixed (UESP)';
271+
} else {
272+
elements.resultFixedWrap.hidden = true;
273+
elements.netMagnitudeFixed.textContent = '';
274+
elements.netMagnitudeFixedHint.textContent = '';
275+
}
276+
}
277+
278+
elements.netMagnitude.style.transform = 'scale(1.1)';
279+
elements.netMagnitude.style.color = '#fff';
280+
setTimeout(() => {
281+
elements.netMagnitude.style.transform = 'scale(1)';
282+
elements.netMagnitude.style.color = '';
283+
}, 150);
284+
285+
elements.breakdownGrid.replaceChildren(fragment);
286+
}
287+
288+
populateEnchantmentsDropdown('apparel');
289+
updateUIForType('apparel');
290+
setupEventListeners();
291+
updateCalculation();
292+
});

0 commit comments

Comments
 (0)