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, slot, 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 { CompareTable } from '../components/CompareTable.js'; import { openPokemonPicker } from '../components/PokemonPicker.js'; import { segThumb } from '../lib/anim.js'; const prettify = (s) => s.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); const loose = (s) => s .toLowerCase() .replace(/[-\s]+/g, ' ') .trim(); 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], i) => el( 'button', { class: `seg__btn${id === mode ? ' is-active' : ''}`, type: 'button', onclick: () => { mode = id; ui.set({ teamMode: id }); seg .querySelectorAll('.seg__btn') .forEach((b, j) => b.classList.toggle('is-active', MODES[j][0] === id)); setThumb(i); render(); }, }, label, ), ), ); const setThumb = segThumb( seg, MODES.length, Math.max( 0, MODES.findIndex(([id]) => id === mode), ), ); 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( 'div', { class: 'teamview__bar' }, seg, el( 'nav', { class: 'teamview__refs', 'aria-label': 'Reference tools' }, el( 'a', { class: 'reflink', href: '#/natures' }, el('span', { class: 'reflink__ico', 'aria-hidden': 'true' }, '✦'), 'Natures', ), el( 'a', { class: 'reflink', href: '#/types' }, el('span', { class: 'reflink__ico', 'aria-hidden': 'true' }, '▦'), 'Type chart', ), el( 'a', { class: 'reflink', href: '#/breeding' }, el('span', { class: 'reflink__ico', 'aria-hidden': 'true' }, '⬡'), 'Breeding', ), ), ), lineup, body, ); function members() { return team .get() .members.map((m) => { const s = slot(m); const base = snap.speciesById.get(s.id); if (!base) return null; const f = s.form && (base.forms || []).find((x) => x.slug === s.form); if (!f) return { ...base, _slotForm: null }; // Merge the snapshot form's baked types / stats so coverage, // compare and the calc all analyse the form, not the base. return { ...base, _slotForm: s.form, name: `${base.name} (${f.name})`, spriteId: f.id, types: f.types || base.types, pastTypes: [], stats: f.stats || base.stats, bst: f.bst ?? base.bst, }; }) .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, sp._slotForm); }, }, '✕', ), el( 'a', { href: `#/pokemon/${sp.id}`, class: 'lineup__link' }, Sprite(sp.spriteId ?? 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((id) => addToTeam(id), { closeAfterPick: true }), }, '+', 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.spriteId ?? 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.spriteId ?? 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', monLink(fastest, `${fastest.stats?.spe ?? '?'} Spe`)), fact( 'Bulkiest', monLink( bulkiest, `${(bulkiest.stats?.hp || 0) + (bulkiest.stats?.def || 0) + (bulkiest.stats?.spd || 0)} HP+Def+SpD`, ), ), ), ); } function monLink(sp, tail) { return el( 'span', {}, el('a', { class: 'link', href: `#/pokemon/${sp.id}` }, prettify(sp.name)), ` (${tail})`, ); } function compare(mem) { const vg = snap.versionGroupByKey.get(settings.get().versionGroup); const gen = vg ? vg.generation : 9; return el( 'div', { class: 'teamcompare' }, el( 'p', { class: 'teamcompare__note' }, 'Your six side by side. ', el('a', { class: 'link', href: '#/compare' }, 'Compare any Pokémon →'), ), CompareTable(mem, { gen, style: settings.get().spriteStyle }), ); } // ---- 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.spriteId ?? 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.spriteId ?? 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 aren’t 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)); }