dex/src/views/TeamView.js
chris bdc21fc4e6 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
2026-09-04 11:58:51 -04:00

682 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { el, clear, onTeardown } from '../lib/dom.js';
import { loadSnapshot } from '../data/snapshot.js';
import { settings } from '../store/settings.js';
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'],
['def', 'Defense'],
['spa', 'Sp. Atk'],
['spd', 'Sp. Def'],
['spe', 'Speed'],
];
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();
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' },
...MODES.map(([id, label]) =>
el(
'button',
{
class: `seg__btn${id === mode ? ' is-active' : ''}`,
type: 'button',
onclick: () => {
mode = id;
ui.set({ teamMode: id });
[...seg.children].forEach((b, i) => b.classList.toggle('is-active', MODES[i][0] === id));
render();
},
},
label,
),
),
);
const lineup = el('div', { class: 'lineup' });
const body = el('div', { class: 'teamview__body' });
view.append(
el(
'header',
{ class: 'view__header' },
el('h1', {}, 'Team'),
el(
'p',
{},
`Up to ${MAX_TEAM} Pokémon — check weaknesses, coverage and stats. `,
el('a', { class: 'link', href: '#/natures' }, 'Natures reference'),
),
),
seg,
lineup,
body,
);
function members() {
return team.get().members.map((id) => snap.speciesById.get(id)).filter(Boolean);
}
function renderLineup() {
const mem = members();
clear(lineup);
const style = settings.get().spriteStyle;
for (const sp of mem) {
lineup.append(
el(
'div',
{ class: 'lineup__slot' },
el(
'button',
{
class: 'lineup__x',
type: 'button',
'aria-label': `Remove ${sp.name}`,
onclick: () => {
removeFromTeam(sp.id);
},
},
'✕',
),
el(
'a',
{ href: `#/pokemon/${sp.id}`, class: 'lineup__link' },
Sprite(sp.id, { style, alt: sp.name, size: 64 }),
el('span', { class: 'lineup__name' }, sp.name.replace(/-/g, ' ')),
),
),
);
}
if (mem.length < MAX_TEAM) {
lineup.append(
el(
'button',
{
class: 'lineup__add',
type: 'button',
onclick: () => openPokemonPicker(addToTeam),
},
'',
el('span', {}, 'Add'),
),
);
}
if (mem.length) {
lineup.append(
el(
'button',
{ class: 'lineup__clear', type: 'button', onclick: () => clearTeam() },
'Clear',
),
);
}
}
function render() {
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;
}
body.append(mode === 'coverage' ? coverage(mem) : compare(mem));
}
function coverage(mem) {
const st = settings.get();
const vg = snap.versionGroupByKey.get(st.versionGroup);
const gen = vg ? vg.generation : 9;
const gameLabel = st.versionGroup === 'all' ? 'all games' : prettify(st.versionGroup);
const style = st.spriteStyle;
// Only the types that exist in the selected game (no Fairy before Gen 6,
// no Steel/Dark before Gen 2). Member typings are era-accurate too —
// pre-Gen-6 Clefairy is Normal, Gen 1 Magnemite is pure Electric.
const GEN_TYPES = TYPES.filter((t) => {
if (t === 'fairy' && gen < 6) return false;
if ((t === 'steel' || t === 'dark') && gen < 2) return false;
return true;
});
const memTypes = mem.map((sp) => typesForGen(sp, gen));
const vectors = memTypes.map((types) => defenseVector(types, gen));
// Per attacking type: which members are weak, how many resist.
const analysis = GEN_TYPES.map((t) => {
const weak = [];
let resist = 0;
vectors.forEach((v, i) => {
const m = v[t] ?? 1;
if (m >= 2) weak.push({ sp: mem[i], m });
else if (m < 1) resist += 1;
});
return { type: t, weak, weakN: weak.length, resist };
});
// A "weak spot": 2+ members weak, or someone weak and nobody resists it.
const threats = analysis
.filter((a) => a.weakN >= 2 || (a.weakN >= 1 && a.resist === 0))
.sort((a, b) => b.weakN - a.weakN || a.resist - b.resist);
const unresisted = analysis
.filter((a) => a.weakN === 0 && a.resist === 0)
.map((a) => a.type);
const weakSpots = threats.length
? el(
'div',
{ class: 'weakspots' },
...threats.map((a) =>
el(
'div',
{ class: `weakspot${a.resist === 0 ? ' is-nores' : ''}` },
TypeChip(a.type),
el('span', { class: 'weakspot__count' }, `${a.weakN} weak`),
a.resist === 0
? el('span', { class: 'weakspot__flag' }, '0 resist')
: el('span', { class: 'weakspot__res' }, `${a.resist} resist`),
el(
'span',
{ class: 'weakspot__mem' },
...a.weak.map(({ sp, m }) =>
el(
'a',
{
class: 'weakspot__chip',
href: `#/pokemon/${sp.id}`,
title: `${prettify(sp.name)} takes ${m}×`,
},
Sprite(sp.id, { style, size: 26, alt: sp.name }),
),
),
),
),
),
)
: el('p', { class: 'cov__ok' }, '✓ Nothing hits two or more of your team super-effectively.');
// Defensive grid: a diverging bar (resists ◀ | ▶ weaknesses) makes the
// rows to worry about jump out; threat rows get a tinted background.
const head = el(
'div',
{ class: 'cov__row cov__row--head' },
el('span', { class: 'cov__type' }, ''),
...mem.map((sp) =>
el(
'a',
{ class: 'cov__mem', href: `#/pokemon/${sp.id}`, title: prettify(sp.name) },
Sprite(sp.id, { style, size: 34, alt: sp.name }),
),
),
el('span', { class: 'cov__bal cov__bal--head' }, 'resist · weak'),
);
const rows = GEN_TYPES.map((t) => {
const a = analysis.find((x) => x.type === t);
const cells = vectors.map((v) => {
const m = v[t] ?? 1;
return el('span', { class: `cov__cell ${multClass(m)}` }, multText(m));
});
const bal = el(
'span',
{ class: 'cov__bal' },
el(
'span',
{ class: 'cov__bal-side cov__bal-side--l' },
el('span', { class: 'cov__bal-res', style: `width:${Math.min(a.resist, 6) * 10}px` }),
),
el(
'span',
{ class: 'cov__bal-side cov__bal-side--r' },
el('span', { class: 'cov__bal-weak', style: `width:${Math.min(a.weakN, 6) * 10}px` }, a.weakN || ''),
),
);
return el(
'div',
{ class: `cov__row${a.weakN >= 2 ? ' cov__row--threat' : ''}` },
el('span', { class: 'cov__type' }, TypeChip(t)),
...cells,
bal,
);
});
// Offensive coverage gaps: defending types no member hits super-effectively
const gaps = GEN_TYPES.filter(
(def) => !mem.some((sp, i) => memTypes[i].some((atk) => multiplier(atk, [def], gen) >= 2)),
);
// Quick team stats
const avgBst = Math.round(mem.reduce((s, m) => s + (m.bst || 0), 0) / mem.length);
const fastest = mem.slice().sort((a, b) => (b.stats?.spe || 0) - (a.stats?.spe || 0))[0];
const bulkiest = mem
.slice()
.sort(
(a, b) =>
(b.stats?.hp || 0) + (b.stats?.def || 0) + (b.stats?.spd || 0) -
((a.stats?.hp || 0) + (a.stats?.def || 0) + (a.stats?.spd || 0)),
)[0];
return el(
'div',
{},
el('h2', { class: 'ppanel__sub' }, `Weak spots · ${gameLabel} (Gen ${gen})`),
weakSpots,
unresisted.length
? el(
'p',
{ class: 'cov__unres' },
el('span', { class: 'cov__unres-k' }, 'No resist to: '),
el('span', { class: 'matchups__types' }, ...unresisted.map(TypeChip)),
)
: null,
el('h2', { class: 'ppanel__sub' }, 'Full matchup grid'),
el('div', { class: 'cov' }, head, ...rows),
el('h2', { class: 'ppanel__sub' }, 'Offensive gaps (no super-effective STAB)'),
gaps.length
? el('div', { class: 'matchups__types' }, ...gaps.map(TypeChip))
: el('p', { class: 'detail__muted' }, 'Your team hits every type super-effectively. Nice.'),
el('h2', { class: 'ppanel__sub' }, 'At a glance'),
el(
'dl',
{ class: 'pfacts' },
fact('Average BST', String(avgBst)),
fact('Fastest', `${prettify(fastest.name)} (${fastest.stats?.spe ?? '?'} Spe)`),
fact(
'Bulkiest',
`${prettify(bulkiest.name)} (${
(bulkiest.stats?.hp || 0) + (bulkiest.stats?.def || 0) + (bulkiest.stats?.spd || 0)
} HP+Def+SpD)`,
),
),
);
}
function compare(mem) {
const vg = snap.versionGroupByKey.get(settings.get().versionGroup);
const gen = vg ? vg.generation : 9;
const maxOf = (key) => Math.max(...mem.map((m) => m.stats?.[key] ?? 0));
const row = (label, cells, cls = '') =>
el('div', { class: `cmp__row ${cls}` }, el('span', { class: 'cmp__label' }, label), ...cells);
return el(
'div',
{ class: 'cmp' },
row(
'',
mem.map((sp) =>
el(
'a',
{ class: 'cmp__mem', href: `#/pokemon/${sp.id}` },
Sprite(sp.id, { size: 44, alt: sp.name }),
el('span', {}, sp.name.replace(/-/g, ' ')),
),
),
'cmp__row--head',
),
row(
'Type',
mem.map((sp) => el('span', { class: 'cmp__cell' }, ...typesForGen(sp, gen).map(TypeChip))),
),
...STAT_ROWS.map(([key, label]) => {
const mx = maxOf(key);
return row(
label,
mem.map((sp) => {
const v = sp.stats?.[key] ?? 0;
const bar = el('span', { class: 'cmp__bar' });
bar.style.width = `${(v / 200) * 100}%`;
return el(
'span',
{ class: `cmp__cell cmp__stat${v === mx ? ' is-max' : ''}` },
el('span', { class: 'cmp__val' }, String(v)),
el('span', { class: 'cmp__track' }, bar),
);
}),
);
}),
row(
'BST',
mem.map((sp) => {
const mx = Math.max(...mem.map((m) => m.bst || 0));
return el(
'span',
{ class: `cmp__cell cmp__stat${(sp.bst || 0) === mx ? ' is-max' : ''}` },
el('span', { class: 'cmp__val' }, String(sp.bst || 0)),
);
}),
'cmp__row--bst',
),
);
}
// ---- 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
// selected game drives the coverage generation + era typings.
const offSettings = settings.subscribe(render);
onTeardown(view, () => {
off();
offSettings();
});
return view;
}
function fact(k, v) {
return el('div', { class: 'fact' }, el('dt', {}, k), el('dd', {}, v));
}