Team builder: add a damage calculator

New "Calc" mode alongside Coverage/Compare. Pick (or quick-pick from your
current team) an attacker, a move, and a defender; get a damage range, %
of the defender's HP, hits-to-KO, and STAB/effectiveness badges.

- src/lib/damage-calc.js: the standard Gen 3+ stat and damage formulas
  (statAt, calcDamage), evaluated across the 85–100% roll.
- Move data is era-accurate: picking a move fetches it live for
  past_values (power/type/class changes), reusing MovesList's
  forGeneration (now exported) via a snapshot-shaped shim so the instant
  preview and the refined post-fetch result use the same code path.
- Level/nature/EV inputs per side (defaults: level 100, neutral nature,
  0 EV, 31 IV assumed) plus a crit toggle.
- Explicitly out of scope and called out in the UI: items, abilities,
  weather, terrain — this is a base-stats estimate, not a battle-exact
  number.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
This commit is contained in:
chris 2026-09-04 11:58:51 -04:00
parent 1adc06d11f
commit bdc21fc4e6
5 changed files with 534 additions and 12 deletions

View File

@ -97,9 +97,14 @@ Working app with a type-themed UI:
summary (types that hit 2+ members, or that someone's weak to and nobody
resists), then a full matchup grid with a diverging resist◀│▶weak bar per
type; plus offensive STAB gaps and at-a-glance stats. A Compare table
stacks the six side by side. All matchup maths follow the selected game —
its generation's type chart and era-accurate typings (pre-Gen-6 Clefairy
is Normal, etc.). Toggleable in the nav from Settings.
stacks the six side by side. A third Calc mode is a damage calculator —
pick (or quick-pick from your team) an attacker + move + defender, and
get a damage range, % of HP, hits-to-KO, STAB/effectiveness badges, with
level/nature/EV controls; it's a base-stats estimate (31 IVs assumed,
no items/abilities/weather/terrain). All three follow the selected
game — its generation's type chart, era-accurate typings (pre-Gen-6
Clefairy is Normal, etc.), and era move data. Toggleable in the nav
from Settings.
- **Settings** — theme (System / Light / Dark / Black / Sepia) + accent
colour, sprite style, JSON export/import.
- **PWA** — Workbox SW: precache shell + snapshot, SWR for API JSON,

View File

@ -47,7 +47,7 @@ const PHYSICAL_TYPES = new Set([
'normal', 'fighting', 'poison', 'ground', 'flying', 'bug', 'rock', 'ghost', 'steel',
]);
function forGeneration(d, gen, genOfVg) {
export function forGeneration(d, gen, genOfVg) {
const v = {
power: d.power,
accuracy: d.accuracy,

46
src/lib/damage-calc.js Normal file
View File

@ -0,0 +1,46 @@
import { NATURES } from '../data/natures.js';
import { multiplier } from '../data/type-chart.js';
/** ±10%, or 1 for the five neutral natures. Unaffected stat -> 1. */
export function natureMultiplier(natureName, statKey) {
const n = NATURES.find((x) => x.name === natureName);
if (!n || n.up === n.down) return 1;
if (n.up === statKey) return 1.1;
if (n.down === statKey) return 0.9;
return 1;
}
/**
* A real in-game stat from a base stat, level, IVs/EVs and nature the
* standard Gen 3+ formula (also close enough for earlier gens, which this
* calculator otherwise already treats generically).
*/
export function statAt(base, level, { iv = 31, ev = 0, nature = 'Hardy', statKey } = {}) {
if (statKey === 'hp') {
if (base <= 1) return 1; // Shedinja
return Math.floor(((2 * base + iv + Math.floor(ev / 4)) * level) / 100) + level + 10;
}
const raw = Math.floor(((2 * base + iv + Math.floor(ev / 4)) * level) / 100 + 5);
return Math.floor(raw * natureMultiplier(nature, statKey));
}
/**
* The standard damage formula, evaluated across the 85100% random roll.
* Ignores items, abilities, weather, terrain and spread-move reduction
* none of those are part of this app's data model, so treat the result as
* a base-stats estimate rather than a battle-exact number.
*
* Returns null for status moves or 0-power moves.
*/
export function calcDamage({ level, power, moveType, atkTypes, defTypes, atkStat, defStat, gen, crit = false }) {
if (!power) return null;
const base = Math.floor((Math.floor((2 * level) / 5 + 2) * power * (atkStat / defStat)) / 50) + 2;
const stab = atkTypes.includes(moveType) ? 1.5 : 1;
const eff = multiplier(moveType, defTypes, gen);
const critMult = crit ? (gen <= 5 ? 2 : 1.5) : 1;
if (eff === 0) return { min: 0, max: 0, stab: stab > 1, eff: 0 };
const modifier = stab * eff * critMult;
const min = Math.max(1, Math.floor(Math.floor((base * 85) / 100) * modifier));
const max = Math.max(1, Math.floor(base * modifier));
return { min, max, stab: stab > 1, eff };
}

View File

@ -2347,6 +2347,180 @@
background: var(--good);
}
/* ---- Damage calculator ---------------------------------------- */
.calc__sides {
display: flex;
align-items: center;
gap: 10px;
}
.calc__side {
flex: 1;
min-width: 0;
}
.calc__vs {
flex: none;
font-size: 1.1rem;
color: var(--text-dim);
margin-top: 18px;
}
.calc__slot {
margin-top: 4px;
}
.calc__pick {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: 14px;
background: var(--surface-2);
font: inherit;
font-weight: 700;
font-size: 0.85rem;
text-align: left;
cursor: pointer;
}
.calc__pick--empty {
color: var(--text-dim);
font-weight: 600;
justify-content: center;
}
.calc__quick {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
.calc__quick-btn {
width: 30px;
height: 30px;
padding: 2px;
border: none;
border-radius: 8px;
background: var(--surface-2);
display: grid;
place-items: center;
cursor: pointer;
}
.calc__movebar {
display: flex;
align-items: center;
gap: 8px;
margin-top: 14px;
}
.calc__move-input {
flex: 1;
min-width: 0;
padding: 9px 12px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--ink, inherit);
font: inherit;
font-size: 0.85rem;
}
.calc__move-note {
font-size: 0.72rem;
font-weight: 600;
color: var(--text-dim);
white-space: nowrap;
}
.calc__controls {
display: flex;
flex-wrap: wrap;
gap: 10px 14px;
margin-top: 14px;
}
.calc__field {
display: flex;
flex-direction: column;
gap: 3px;
font-size: 0.68rem;
font-weight: 700;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.calc__field input[type='number'],
.calc__field select {
width: 92px;
padding: 6px 8px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--ink, inherit);
font: inherit;
font-size: 0.8rem;
text-transform: none;
letter-spacing: normal;
}
.calc__field--check {
flex-direction: row;
align-items: center;
gap: 6px;
align-self: flex-end;
}
.calc__result {
margin-top: 16px;
padding: 14px 16px;
border-radius: 16px;
background: color-mix(in srgb, var(--type-main, var(--accent)) 12%, var(--surface-2));
}
.calc__headline {
font-size: 1.15rem;
font-weight: 800;
}
.calc__pct {
font-weight: 700;
color: var(--text-dim);
}
.calc__sub {
margin-top: 2px;
font-size: 0.82rem;
color: var(--text-dim);
font-weight: 600;
}
.calc__badges {
display: flex;
gap: 6px;
margin-top: 8px;
}
.calc__badge {
font-size: 0.68rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.03em;
padding: 3px 8px;
border-radius: 999px;
background: var(--surface-2);
}
.calc__badge--stab {
background: color-mix(in srgb, var(--accent) 30%, var(--surface-2));
}
.calc__badge--up {
background: color-mix(in srgb, var(--danger) 25%, var(--surface-2));
color: var(--danger);
}
.calc__badge--down {
background: color-mix(in srgb, var(--good) 25%, var(--surface-2));
color: var(--good);
}
.calc__caveat {
margin-top: 10px;
font-size: 0.7rem;
color: var(--text-dim);
}
@media (max-width: 420px) {
.calc__sides {
flex-direction: column;
}
.calc__vs {
margin-top: 0;
transform: rotate(90deg);
}
}
/* ---- Picker + team button ---------------------------------- */
.picker__list {
overflow-y: auto;

View File

@ -5,11 +5,16 @@ import { ui } from '../store/ui.js';
import { team, addToTeam, removeFromTeam, clearTeam, MAX_TEAM } from '../store/team.js';
import { TYPES, defenseVector, multiplier } from '../data/type-chart.js';
import { typesForGen } from '../lib/type-resolve.js';
import { forGeneration } from '../components/MovesList.js';
import { getMove } from '../data/api.js';
import { statAt, calcDamage } from '../lib/damage-calc.js';
import { NATURES } from '../data/natures.js';
import { Sprite } from '../components/Sprite.js';
import { TypeChip } from '../components/TypeChip.js';
import { openPokemonPicker } from '../components/PokemonPicker.js';
const prettify = (s) => s.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
const loose = (s) => s.toLowerCase().replace(/[-\s]+/g, ' ').trim();
const STAT_ROWS = [
['hp', 'HP'],
['atk', 'Attack'],
@ -21,19 +26,23 @@ const STAT_ROWS = [
const multClass = (m) =>
m === 0 ? 'm0' : m === 0.25 ? 'm025' : m === 0.5 ? 'm05' : m === 2 ? 'm2' : m === 4 ? 'm4' : 'm1';
const multText = (m) => (m === 0.25 ? '¼' : m === 0.5 ? '½' : m === 1 ? '' : `${m}×`);
const effWord = (eff) =>
eff === 0 ? 'No effect' : eff >= 4 ? '4× effective' : eff >= 2 ? 'Super effective' : eff <= 0.25 ? 'Barely effective' : eff <= 0.5 ? 'Not very effective' : null;
export async function TeamView() {
const view = el('section', { class: 'view teamview' });
const snap = await loadSnapshot();
let mode = ui.get().teamMode === 'compare' ? 'compare' : 'coverage';
const MODES = [
['coverage', 'Coverage'],
['compare', 'Compare'],
['calc', 'Calc'],
];
let mode = MODES.some(([id]) => id === ui.get().teamMode) ? ui.get().teamMode : 'coverage';
const seg = el(
'div',
{ class: 'seg' },
...[
['coverage', 'Coverage'],
['compare', 'Compare'],
].map(([id, label]) =>
...MODES.map(([id, label]) =>
el(
'button',
{
@ -42,9 +51,7 @@ export async function TeamView() {
onclick: () => {
mode = id;
ui.set({ teamMode: id });
[...seg.children].forEach((b, i) =>
b.classList.toggle('is-active', ['coverage', 'compare'][i] === id),
);
[...seg.children].forEach((b, i) => b.classList.toggle('is-active', MODES[i][0] === id));
render();
},
},
@ -136,6 +143,10 @@ export async function TeamView() {
renderLineup();
const mem = members();
clear(body);
if (mode === 'calc') {
body.append(calcTool(mem));
return;
}
if (!mem.length) {
body.append(el('p', { class: 'detail__muted' }, 'Add some Pokémon to get started.'));
return;
@ -367,6 +378,292 @@ export async function TeamView() {
);
}
// ---- Damage calculator ------------------------------------------
// Attacker + move vs. defender -> a damage range, using the standard
// formula off base stats/level/nature/EVs. No items, abilities, weather
// or terrain — those aren't part of this app's data model, so it's a
// base-stats estimate rather than a battle-exact number.
function calcTool(mem) {
const st = settings.get();
const vg = snap.versionGroupByKey.get(st.versionGroup);
const gen = vg ? vg.generation : 9;
const genOfVg = (name) => snap.versionGroupByKey.get(name)?.generation ?? 9;
const style = st.spriteStyle;
const state = {
atk: mem[0] || null,
def: mem[1] || mem[0] || null,
move: null, // { id, name, ...current-gen snapshot fields }
moveFull: null, // live /move/:id once fetched (adds past_values)
level: 100,
atkNature: 'Hardy',
defNature: 'Hardy',
atkEv: 0,
defEv: 0,
hpEv: 0,
crit: false,
};
const natureOptions = NATURES.map((n) => [n.name, n.name]);
function numField(label, value, { min = 0, max = 999 } = {}, onChange) {
return el(
'label',
{ class: 'calc__field' },
el('span', {}, label),
el('input', {
type: 'number',
min: String(min),
max: String(max),
value: String(value),
onchange: (e) => {
const n = Math.max(min, Math.min(max, Number(e.target.value) || 0));
e.target.value = String(n);
onChange(n);
recalc();
},
}),
);
}
function natureField(label, value, onChange) {
return el(
'label',
{ class: 'calc__field' },
el('span', {}, label),
el(
'select',
{
onchange: (e) => {
onChange(e.target.value);
recalc();
},
},
...natureOptions.map(([v, t]) => el('option', { value: v, selected: v === value }, t)),
),
);
}
function slot(which, sideLabel) {
const holder = el('div', { class: 'calc__slot' });
function paint() {
const sp = state[which];
clear(holder).append(
sp
? el(
'button',
{ type: 'button', class: 'calc__pick', onclick: () => pick() },
Sprite(sp.id, { style, size: 48, alt: sp.name }),
el('span', {}, prettify(sp.name)),
)
: el('button', { type: 'button', class: 'calc__pick calc__pick--empty', onclick: () => pick() }, `Choose ${sideLabel}`),
);
}
function pick() {
openPokemonPicker((id) => {
state[which] = snap.speciesById.get(id);
paint();
recalc();
});
}
paint();
const quick = mem.length
? el(
'div',
{ class: 'calc__quick' },
...mem.map((sp) =>
el(
'button',
{
type: 'button',
class: 'calc__quick-btn',
title: prettify(sp.name),
onclick: () => {
state[which] = sp;
paint();
recalc();
},
},
Sprite(sp.id, { style, size: 26, alt: sp.name }),
),
),
)
: null;
return el('div', { class: 'calc__side' }, el('h3', { class: 'ppanel__sub' }, sideLabel), holder, quick);
}
const atkSlot = slot('atk', 'Attacker');
const defSlot = slot('def', 'Defender');
const moveNote = el('span', { class: 'calc__move-note' });
const moveDatalist = el(
'datalist',
{ id: 'calc-movelist' },
...snap.moves.map((m) => el('option', { value: m.name.replace(/-/g, ' ') })),
);
const moveInput = el('input', {
type: 'text',
list: 'calc-movelist',
class: 'calc__move-input',
placeholder: 'Move…',
onchange: (e) => {
const q = loose(e.target.value);
const hit = snap.moves.find((m) => loose(m.name) === q);
state.move = hit || null;
state.moveFull = null;
e.target.value = hit ? hit.name.replace(/-/g, ' ') : '';
recalc();
if (hit) {
getMove(hit.id)
.then((full) => {
if (state.move?.id !== hit.id) return; // superseded
state.moveFull = full;
recalc();
})
.catch(() => {});
}
},
});
const critField = el(
'label',
{ class: 'calc__field calc__field--check' },
el('input', {
type: 'checkbox',
onchange: (e) => {
state.crit = e.target.checked;
recalc();
},
}),
'Critical hit',
);
const result = el('div', { class: 'calc__result' });
function recalc() {
const { atk, def } = state;
moveNote.textContent = '';
if (!state.move) {
clear(result).append(el('p', { class: 'detail__muted' }, 'Pick a move.'));
return;
}
const raw = state.moveFull || {
power: state.move.power,
accuracy: state.move.accuracy,
type: { name: state.move.type },
damage_class: state.move.damageClass ? { name: state.move.damageClass } : null,
past_values: [],
};
const v = forGeneration(raw, gen, genOfVg);
moveNote.textContent = `${prettify(v.type)} · ${v.damage_class ? prettify(v.damage_class) : 'Status'}${v.power ? ` · ${v.power} power` : ''}`;
if (!atk || !def) {
clear(result).append(el('p', { class: 'detail__muted' }, 'Choose an attacker and a defender.'));
return;
}
if (!v.damage_class || v.damage_class === 'status' || !v.power) {
clear(result).append(
el('p', { class: 'detail__muted' }, `${prettify(state.move.name)} doesn't deal direct damage.`),
);
return;
}
const statKey = v.damage_class === 'special' ? 'spa' : 'atk';
const defKey = v.damage_class === 'special' ? 'spd' : 'def';
const atkTypes = typesForGen(atk, gen);
const defTypes = typesForGen(def, gen);
const atkStat = statAt(atk.stats?.[statKey] ?? 0, state.level, {
ev: state.atkEv,
nature: state.atkNature,
statKey,
});
const defStat = statAt(def.stats?.[defKey] ?? 0, state.level, {
ev: state.defEv,
nature: state.defNature,
statKey: defKey,
});
const defHp = statAt(def.stats?.hp ?? 0, state.level, { ev: state.hpEv, statKey: 'hp' });
const dmg = calcDamage({
level: state.level,
power: v.power,
moveType: v.type,
atkTypes,
defTypes,
atkStat,
defStat,
gen,
crit: state.crit,
});
clear(result);
if (!dmg) {
result.append(el('p', { class: 'detail__muted' }, 'No power data for this move.'));
return;
}
if (dmg.eff === 0) {
result.append(
el('p', { class: 'calc__headline' }, `${prettify(def.name)} is immune — no effect.`),
);
return;
}
const pctMin = (dmg.min / defHp) * 100;
const pctMax = (dmg.max / defHp) * 100;
const hitsMin = Math.ceil(100 / pctMax);
const hitsMax = Math.ceil(100 / pctMin);
const word = effWord(dmg.eff);
result.append(
el(
'p',
{ class: 'calc__headline' },
`${dmg.min}${dmg.max} dmg`,
el('span', { class: 'calc__pct' }, ` · ${pctMin.toFixed(1)}${pctMax.toFixed(1)}% HP`),
),
el(
'p',
{ class: 'calc__sub' },
hitsMin === hitsMax ? `${hitsMin} hit${hitsMin > 1 ? 's' : ''} to KO` : `${hitsMin}${hitsMax} hits to KO`,
),
el(
'div',
{ class: 'calc__badges' },
dmg.stab ? el('span', { class: 'calc__badge calc__badge--stab' }, 'STAB') : null,
word ? el('span', { class: `calc__badge${dmg.eff >= 2 || dmg.eff === 4 ? ' calc__badge--up' : ' calc__badge--down'}` }, word) : null,
),
);
}
recalc();
return el(
'div',
{ class: 'calc' },
el(
'div',
{ class: 'calc__sides' },
atkSlot,
el('span', { class: 'calc__vs', 'aria-hidden': 'true' }, '⇄'),
defSlot,
),
moveDatalist,
el('div', { class: 'calc__movebar' }, moveInput, moveNote),
el(
'div',
{ class: 'calc__controls' },
numField('Level', state.level, { min: 1, max: 100 }, (n) => (state.level = n)),
natureField('Attacker nature', state.atkNature, (v) => (state.atkNature = v)),
numField('Attacker EV', state.atkEv, { min: 0, max: 252 }, (n) => (state.atkEv = n)),
natureField('Defender nature', state.defNature, (v) => (state.defNature = v)),
numField('Defender EV', state.defEv, { min: 0, max: 252 }, (n) => (state.defEv = n)),
numField('Defender HP EV', state.hpEv, { min: 0, max: 252 }, (n) => (state.hpEv = n)),
critField,
),
result,
el(
'p',
{ class: 'calc__caveat' },
'Estimate from base stats (31 IVs assumed) — items, abilities, weather and terrain arent modeled.',
),
);
}
render();
const off = team.subscribe(render);
// Re-render on any settings change: sprite style feeds the lineup, and the