Skip to content

Calculator script API ​

A calculator is a small JavaScript function that turns a deck into a dashboard: a mana curve, a type breakdown, opening-hand odds. It returns data plus a widget type, never markup. Turny.gg draws each widget with its own trusted components, which is what makes it safe to show a calculator someone else wrote.

The no-code builder produces the same kind of script, so everything here also applies to a builder calculator you open as code. For a walkthrough of building and sharing calculators, see the calculators guide.

The contract ​

A calculator script defines one function, compute, and returns a dashboard:

js
export default function compute(pile, cardsById, ctx) {
  return {
    widgets: [{ kind: "stat", label: "Hello", value: "world" }],
  };
}

Turny.gg finds the function in one of three ways, in this order:

  1. export default function compute(...) { ... } (any name, or none, works here).
  2. export default <expression>, where the expression is a function.
  3. A top-level function declared as function compute(...) { ... }.

The script runs in the same sandbox as custom rulesets: no network, no timers, no async, and only a short list of built-in globals. See Script sandbox for exactly what is allowed. A script the safety check rejects never runs.

Arguments ​

#ArgumentTypeDescription
1pileRecord<string, number>The deck being viewed: card ref to number of copies. Refs are the keys of cardsById.
2cardsByIdRecord<string, Card>Every card in the pile, by ref. Game-specific fields live on card.game; see the card data reference for your game.
3contextCalculatorScriptContext | undefinedOptional catalog context (fields below). May be undefined: a script that reads it must work without it.

CalculatorScriptContext:

PropertyTypeDescription
globalsunknownThe game catalog's globals document (region, keyword and set lookup tables). Its shape is per game.

Things to know about the arguments:

  • They are read-only. pile, cardsById and ctx are frozen copies. Build new arrays and objects instead of changing them.
  • A card can be missing. Guard cardsById[ref] before reading from it; the default calculators treat a missing card as having no fields.
  • Card fields are per game. Everything game-specific lives on card.game. See the card data reference for Legends of Runeterra, Pokémon TCG and Riftbound.
  • ctx may be absent. Any run can call compute without it, including the editor preview. Check it before use:
js
export default function compute(pile, cardsById, ctx) {
  const hasGlobals = ctx !== undefined && ctx.globals !== undefined;

  return {
    widgets: [
      {
        kind: "stat",
        label: "Catalog globals",
        value: hasGlobals ? "Yes" : "No",
      },
    ],
  };
}

Return value ​

compute returns an object with a widgets array. Widgets are drawn in array order. Each widget is a plain object whose kind picks how it is drawn; the rest of its fields are the data for that kind.

Widget examples ​

Every widget type with a minimal working compute:

js
export default function compute(pile) {
  let total = 0;

  Object.keys(pile).forEach((ref) => {
    total += pile[ref];
  });

  return {
    widgets: [
      { kind: "stat", label: "Deck size", value: total, unit: "cards" },
    ],
  };
}
js
export default function compute(pile) {
  // Distinct cards run as 1-ofs, 2-ofs and 3+-ofs.
  const buckets = [0, 0, 0];

  Object.keys(pile).forEach((ref) => {
    const copies = Math.min(Math.max(pile[ref], 1), 3);
    buckets[copies - 1] += 1;
  });

  return {
    widgets: [
      {
        kind: "histogram",
        label: "Copies per card",
        buckets: buckets,
        labels: ["1", "2", "3+"],
      },
    ],
  };
}
js
export default function compute(pile) {
  let singles = 0;
  let multiples = 0;

  Object.keys(pile).forEach((ref) => {
    if (pile[ref] === 1) {
      singles += 1;
    } else {
      multiples += 1;
    }
  });

  return {
    widgets: [
      {
        kind: "donut",
        label: "Singles vs multiples",
        segments: [
          { key: "singles", label: "1 copy", value: singles, color: "#3a78e8" },
          { key: "multiples", label: "2+ copies", value: multiples, color: "" },
        ],
      },
    ],
  };
}
js
export default function compute(pile) {
  let singles = 0;
  let multiples = 0;

  Object.keys(pile).forEach((ref) => {
    if (pile[ref] === 1) {
      singles += pile[ref];
    } else {
      multiples += pile[ref];
    }
  });

  return {
    widgets: [
      {
        kind: "gauge",
        label: "Copies in singles vs multiples",
        segments: [
          { key: "singles", label: "Singles", value: singles, color: "" },
          { key: "multiples", label: "Multiples", value: multiples, color: "" },
        ],
      },
    ],
  };
}
js
export default function compute(pile, cardsById) {
  const rows = Object.keys(pile)
    .sort((left, right) => pile[right] - pile[left])
    .slice(0, 5)
    .map((ref) => {
      const card = cardsById[ref];
      const name = card && typeof card.name === "string" ? card.name : ref;

      return [name, pile[ref]];
    });

  return {
    widgets: [
      {
        kind: "table",
        label: "Most copies",
        columns: ["Card", "Copies"],
        rows: rows,
      },
    ],
  };
}
js
export default function compute(pile) {
  // Odds of drawing at least one copy of your most-played card in 7 cards.
  let deckSize = 0;
  let mostCopies = 0;

  Object.keys(pile).forEach((ref) => {
    deckSize += pile[ref];
    mostCopies = Math.max(mostCopies, pile[ref]);
  });

  return {
    widgets: [
      {
        kind: "hypergeometric",
        label: "Top card in opening hand",
        population: deckSize,
        successCount: mostCopies,
        draws: 7,
        exactCounts: [1, 2],
      },
    ],
  };
}
js
export default function compute(pile) {
  let copies = 0;

  Object.keys(pile).forEach((ref) => {
    copies += pile[ref];
  });

  return {
    widgets: [
      {
        kind: "ratio",
        label: "Copies per card",
        numerator: copies,
        denominator: Object.keys(pile).length,
        format: "multiplier",
      },
    ],
  };
}

Widget reference ​

kindDescription
"stat"A single headline value in a stat tile (e.g. "Average cost: 3.4").
"histogram"A bar chart: one bar per bucket (e.g. a mana curve). Bars pair with labels by index.
"donut"A donut (pie) chart of labelled, coloured segments.
"gauge"A semicircular meter: segments fill a half-circle in proportion to their values. Same payload as donut, drawn as a gauge.
"bars"Horizontal bars, one per segment (e.g. regions, domains, mana sources).
"table"A table of rows under column headers.
"hypergeometric"A closed-form hypergeometric draw-odds widget: the probability of drawing successes when taking draws cards, without replacement, from a population of which successCount match a filter (e.g. "odds of opening at least one basic in a 7-card hand"). The renderer computes and shows P(X>=1); when exactCounts is given it also shows P(X=k) for each listed k in a small table.
"ratio"A derived index number: numerator / denominator, formatted per format. Renders as a stat tile. Guards against a zero denominator.

kind: "stat" ​

A single headline value in a stat tile (e.g. "Average cost: 3.4").

PropertyTypeDescription
labelstringTitle shown on the widget.
labelKey?stringOptional i18n key under calculators.widgets.*; falls back to label.
valuenumber | stringThe value. A finite number is formatted in the viewer's locale (Intl.NumberFormat); a string is shown verbatim (e.g. "12.5%").
unit?stringOptional suffix shown after the value (e.g. "cards").

kind: "histogram" ​

A bar chart: one bar per bucket (e.g. a mana curve). Bars pair with labels by index.

PropertyTypeDescription
labelstringTitle shown on the widget.
labelKey?stringOptional i18n key under calculators.widgets.*; falls back to label.
bucketsnumber[]Bar heights, left to right.
labelsstring[]One axis label per bucket, same order as buckets.

kind: "donut" ​

A donut (pie) chart of labelled, coloured segments.

PropertyTypeDescription
labelstringTitle shown on the widget.
labelKey?stringOptional i18n key under calculators.widgets.*; falls back to label.
vocabulary?stringOptional CalculatorVocabulary id resolving segment labels and colors per game.
segmentsDeckStatSegment[]Slices, drawn in order; zero-value slices are skipped.

kind: "gauge" ​

A semicircular meter: segments fill a half-circle in proportion to their values. Same payload as donut, drawn as a gauge.

PropertyTypeDescription
labelstringTitle shown on the widget.
labelKey?stringOptional i18n key under calculators.widgets.*; falls back to label.
vocabulary?stringOptional CalculatorVocabulary id resolving segment labels and colors per game.
segmentsDeckStatSegment[]Arcs, drawn in order; zero-value arcs are skipped.

kind: "bars" ​

Horizontal bars, one per segment (e.g. regions, domains, mana sources).

PropertyTypeDescription
labelstringTitle shown on the widget.
labelKey?stringOptional i18n key under calculators.widgets.*; falls back to label.
vocabulary?stringOptional CalculatorVocabulary id resolving segment labels and colors per game.
segmentsDeckStatSegment[]Bars, drawn in order.

kind: "table" ​

A table of rows under column headers.

PropertyTypeDescription
labelstringTitle shown on the widget.
labelKey?stringOptional i18n key under calculators.widgets.*; falls back to label.
columnsstring[]Column headers, left to right.
columnKeys?string[]Optional i18n keys under calculators.widgets.*, parallel to columns.
rows(string | number)[][]Rows of cells; each row pairs with columns by index.

kind: "hypergeometric" ​

A closed-form hypergeometric draw-odds widget: the probability of drawing successes when taking draws cards, without replacement, from a population of which successCount match a filter (e.g. "odds of opening at least one basic in a 7-card hand"). The renderer computes and shows P(X>=1); when exactCounts is given it also shows P(X=k) for each listed k in a small table.

PropertyTypeDescription
labelstringTitle shown on the widget.
labelKey?stringOptional i18n key under calculators.widgets.*; falls back to label.
populationnumberPopulation size: the pile / deck size drawn from.
successCountnumberSuccesses in the population: cards matching the filter.
drawsnumberNumber of cards drawn (e.g. the opening-hand size).
exactCounts?number[]Optional exact-count targets. When present, the renderer adds a P(X=k) row per listed k alongside the headline P(X>=1) figure.

kind: "ratio" ​

A derived index number: numerator / denominator, formatted per format. Renders as a stat tile. Guards against a zero denominator.

PropertyTypeDescription
labelstringTitle shown on the widget.
labelKey?stringOptional i18n key under calculators.widgets.*; falls back to label.
numeratornumberTop of the fraction.
denominatornumberBottom of the fraction. A zero shows the no-data placeholder.
format?CalculatorRatioFormatHow to format the quotient. Defaults to number.

Segments ​

A donut slice or gauge arc (DeckStatSegment):

PropertyTypeDescription
keystringStable identifier for the segment (e.g. "spells").
labelstringLegend text.
valuenumberThe segment's size; segments are drawn in proportion to their values.
colorstringCSS color (e.g. "#3a78e8"). Empty picks a fallback palette color.

Ratio formats ​

FormatShows
"number"The raw quotient to two decimals (e.g. 1.83).
"percent"The quotient as a percentage to one decimal (e.g. 65.0%).
"multiplier"The quotient to two decimals with a multiplier sign (e.g. 1.83×).
"ratio"The pair reduced to lowest terms (e.g. 11:6).

How bad output is handled ​

A calculator's return value is never trusted. Before anything is drawn it passes through a checker (normalizeCalculatorResult) that repairs or drops whatever does not fit the shapes above. The checker never throws, so a buggy script produces a smaller dashboard, not a crash:

Your script returnsWhat is drawn
Anything other than an object with a widgets arrayAn empty dashboard
A widget that is not an object, or has an unknown kindThat widget is dropped; the rest are kept
A missing or non-text labelAn empty label
A number field (value of a segment, buckets, population, numerator, ...)Converted with Number(); NaN, Infinity and non-numbers become 0
A text field set to a number or booleanConverted to text; any other value (object, null) becomes empty text
A stat valueKept as a number if it is one (non-finite becomes 0), otherwise text
A table cellKept as a number if it is one, otherwise text; a row that is not an array is dropped
A segment that is not an objectThat segment is dropped
unit, exactCounts or format of the wrong type (or an unknown format)That optional field is left out
Extra properties on a widgetDropped
More entries or longer text than the limits allowCut to the limit

An error thrown by your script, a syntax error, or a safety-check rejection is different: the calculator shows an error message instead of a dashboard.

A widget with nothing to show draws a "No data" placeholder instead of an empty chart: a stat whose value is empty text, a histogram or donut/gauge with no positive values, a table with no columns or no rows, a hypergeometric with a population of 0 or less, or a ratio with a denominator of 0.

Limits ​

Hard caps normalizeCalculatorResult applies to guest output. Guest code is untrusted: these clamp the blast radius of a hostile or buggy script on the trusted renderer. Output past a cap is truncated (extra entries dropped, strings cut), never rejected.

CapLimitApplies to
maxWidgets50Widgets kept per dashboard; later widgets are dropped.
maxSegments100Segments kept per donut, gauge or bars widget.
maxHistogramBuckets200Entries kept in a histogram's buckets and in its labels.
maxTableRows200Rows kept per table.
maxTableColumns20Entries kept in a table's columns, and cells kept per row.
maxLabelLength200Characters kept in every short string: widget labels and label keys, stat string values and units, segment keys, labels and colors, histogram labels and table column headers and column keys.
maxCellLength200Characters kept in a string table cell.
maxExactCounts50Entries kept in a hypergeometric widget's exactCounts.

Time limit: a run that takes longer than 5 seconds is stopped and the calculator shows an error instead of a dashboard.

Builder-generated scripts ​

A calculator made with the no-code builder is an ordinary script whose first line is a marker comment holding the builder's settings:

text
// @calculator-spec:v1 {"widgets":[...]}

The marker is only a comment, so it has no effect on how the script runs. The builder uses it to re-open the calculator:

  • Drop to code switches a builder calculator to the code editor.
  • While the code is still exactly what the builder generates from the marker (checked by rebuilding it and comparing byte for byte), you can switch back to the builder.
  • Once you edit the code by hand, the rebuilt code no longer matches and the calculator is detached from the builder: it stays a code calculator. Undoing your edits so the code matches again re-enables switching back.

To make sure hand edits are never replaced by the builder's version, delete the marker line before saving. A script without a readable marker always opens in the code editor.

Examples: the default calculators ​

Every game ships default calculators, and they are ordinary calculator scripts. The code below is pulled from the source the site runs, so it is always current. Copy one as a starting point for your own.

js
const MANA_CURVE_LABELS = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "+"];

// Card-type keys in display order, with English fallback labels.
const TYPE_LABELS = {
  champions: "Champion",
  followers: "Follower",
  spells: "Spell",
  landmarks: "Landmark",
  equipment: "Equipment",
};

export default function compute(pile, cardsById) {
  const deck = normalizePile(pile);
  const catalog = isRecord(cardsById) ? cardsById : {};

  // Cards missing from the catalog can't be classified, so they count nowhere.
  const entries = Object.keys(deck)
    .filter((cardId) => isRecord(catalog[cardId]))
    .map((cardId) => {
      const card = catalog[cardId];
      const game = cardGame(card);

      return { card, game, quantity: deck[cardId], cost: cardCost(game), regionRefs: cardRegionRefs(game) };
    })
    .sort(compareDeckCards);
  const deckRegions = detectDeckRegions(entries);

  const manaBuckets = Array.from({ length: 11 }, () => 0);
  const typeCounts = { champions: 0, followers: 0, spells: 0, landmarks: 0, equipment: 0 };
  const rarityCounts = new Map();
  const regionCounts = new Map();

  let costTotal = 0;
  let cardTotal = 0;

  entries.forEach((entry) => {
    const game = entry.game;
    const quantity = entry.quantity;

    manaBuckets[Math.min(entry.cost, 10)] += quantity;
    costTotal += entry.cost * quantity;
    cardTotal += quantity;

    // Categorize by the canonical (English) type ref, falling back to the
    // localized value for catalogs published before the refs existed.
    const type = game.typeRef ?? game.type;
    const supertype = game.supertypeRef ?? game.supertype;

    if (type === "Unit") {
      if (supertype === "Champion") {
        typeCounts.champions += quantity;
      } else {
        typeCounts.followers += quantity;
      }
    } else if (type === "Spell") {
      typeCounts.spells += quantity;
    } else if (type === "Landmark") {
      typeCounts.landmarks += quantity;
    } else if (type === "Equipment") {
      typeCounts.equipment += quantity;
    }

    const rarityRef = typeof game.rarityRef === "string" ? game.rarityRef : "Unknown";
    rarityCounts.set(rarityRef, (rarityCounts.get(rarityRef) ?? 0) + quantity);

    // A card counts toward the first deck region it carries, so counts sum to
    // the deck size even for multi-region cards; one fitting none is skipped.
    const region = deckRegions.find((candidate) => entry.regionRefs.indexOf(candidate) !== -1);
    if (region !== undefined) {
      regionCounts.set(region, (regionCounts.get(region) ?? 0) + quantity);
    }
  });

  // Regions keep deck-region order; empty ones drop out.
  const regionSegments = deckRegions
    .filter((region) => (regionCounts.get(region) ?? 0) > 0)
    .map((region) => ({ key: region, label: region, value: regionCounts.get(region), color: "" }));

  const typeSegments = Object.keys(TYPE_LABELS)
    .filter((key) => typeCounts[key] > 0)
    .map((key) => ({ key, label: TYPE_LABELS[key], value: typeCounts[key], color: "" }));

  // Most copies first; ties keep card order.
  const raritySegments = [];
  rarityCounts.forEach((value, key) => {
    if (value > 0) {
      raritySegments.push({ key, label: key, value, color: "" });
    }
  });
  raritySegments.sort((left, right) => right.value - left.value);

  // Mean mana cost across the deck, weighted by copies, to one decimal.
  const averageCost = cardTotal > 0 ? Math.round((costTotal / cardTotal) * 10) / 10 : 0;
  // Units (champions + followers) against spells, as a reduced pair.
  const unitCount = typeCounts.champions + typeCounts.followers;
  const spellCount = typeCounts.spells;

  // Like the deck page, a breakdown with nothing to show is left out.
  const widgets = [];

  if (regionSegments.length > 0) {
    widgets.push({ kind: "bars", label: "Regions", labelKey: "lor.regions", vocabulary: "lor.region", segments: regionSegments });
  }
  widgets.push({ kind: "histogram", label: "Mana Curve", labelKey: "lor.manaCurve", buckets: manaBuckets, labels: MANA_CURVE_LABELS });
  if (typeSegments.length > 0) {
    widgets.push({ kind: "donut", label: "Card Types", labelKey: "lor.cardTypes", vocabulary: "lor.cardType", segments: typeSegments });
  }
  if (raritySegments.length > 0) {
    widgets.push({ kind: "donut", label: "Rarity", labelKey: "lor.rarity", vocabulary: "lor.rarity", segments: raritySegments });
  }
  widgets.push({ kind: "stat", label: "Average Cost", labelKey: "lor.averageCost", value: averageCost });
  widgets.push({ kind: "ratio", label: "Units : Spells", labelKey: "lor.unitsToSpells", numerator: unitCount, denominator: spellCount, format: "ratio" });

  return { widgets };
}

function normalizePile(pile) {
  if (!isRecord(pile)) {
    return {};
  }

  return Object.entries(pile).reduce((result, [cardId, quantity]) => {
    const normalizedQuantity = normalizeQuantity(quantity);

    if (normalizedQuantity > 0) {
      result[cardId] = normalizedQuantity;
    }

    return result;
  }, {});
}

function normalizeQuantity(value) {
  return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
}

function cardGame(card) {
  return isRecord(card) && isRecord(card.game) ? card.game : {};
}

function isRecord(value) {
  return value !== null && typeof value === "object" && !Array.isArray(value);
}

function cardName(card) {
  return isRecord(card) && typeof card.name === "string" ? card.name : "";
}

function cardCost(game) {
  return Number.isFinite(game.cost) ? Math.max(0, Math.floor(game.cost)) : 0;
}

function cardRegionRefs(game) {
  return Array.isArray(game.regionRefs)
    ? game.regionRefs.filter((ref) => typeof ref === "string" && ref.length > 0)
    : [];
}

// The deck page's card order: by cost, then by name.
function compareDeckCards(left, right) {
  return left.cost - right.cost || cardName(left.card).localeCompare(cardName(right.card));
}

// The deck's regions, as the deck page derives them: every single-region
// card's region in card order, then (only if some card still fits none) the
// other regions of those unaccounted cards, never a Runeterra champion's
// card-code ref.
function detectDeckRegions(entries) {
  const regions = [];

  entries.forEach((entry) => {
    if (entry.regionRefs.length === 1 && regions.indexOf(entry.regionRefs[0]) === -1) {
      regions.push(entry.regionRefs[0]);
    }
  });

  entries
    .filter((entry) => !entry.regionRefs.some((ref) => regions.indexOf(ref) !== -1))
    .forEach((entry) => {
      entry.regionRefs.forEach((ref) => {
        if (!/^\d/.test(ref) && regions.indexOf(ref) === -1) {
          regions.push(ref);
        }
      });
    });

  return regions;
}
js
// Canonical Pokémon energy types, matched against spelled-out energy names.
const POKEMON_ENERGY_TYPES = ["Grass", "Fire", "Water", "Lightning", "Psychic", "Fighting", "Darkness", "Metal", "Fairy", "Dragon", "Colorless"];

// PTCGO/Live single-letter energy glyph codes -> canonical type. Basic and typed
// Special Energy print a {X} token ("Basic {L} Energy") rather than the spelled
// type, so the element is read off the glyph: {R} is Fire, {N} is Dragon.
const POKEMON_ENERGY_GLYPH_TYPES = { G: "Grass", R: "Fire", W: "Water", L: "Lightning", P: "Psychic", F: "Fighting", D: "Darkness", M: "Metal", Y: "Fairy", N: "Dragon", C: "Colorless" };

const RETREAT_LABELS = ["0", "1", "2", "3", "4+"];

export default function compute(pile, cardsById) {
  const deck = normalizePile(pile);
  const catalog = isRecord(cardsById) ? cardsById : {};

  let pokemon = 0;
  let trainer = 0;
  let energy = 0;
  let total = 0;
  let basicPokemon = 0;

  const energyTypeCounts = new Map();
  const retreatBuckets = Array.from({ length: 5 }, () => 0);
  const regulationCounts = new Map();

  Object.keys(deck).forEach((cardId) => {
    const quantity = deck[cardId];
    const card = catalog[cardId];
    const game = cardGame(card);
    const supertype = typeof game.supertype === "string" ? game.supertype : "";
    const subtypes = Array.isArray(game.subtypes) ? game.subtypes : [];

    total += quantity;

    // The catalog ships the accented "Pokémon" token; tolerate the ASCII form.
    if (supertype === "Pokémon" || supertype === "Pokemon") {
      pokemon += quantity;

      if (subtypes.indexOf("Basic") !== -1) {
        basicPokemon += quantity;
      }

      const retreat = Number.isFinite(game.convertedRetreatCost)
        ? Math.max(0, Math.floor(game.convertedRetreatCost))
        : 0;
      retreatBuckets[Math.min(retreat, 4)] += quantity;
    } else if (supertype === "Trainer") {
      trainer += quantity;
    } else if (supertype === "Energy") {
      energy += quantity;

      // Energy-type donut counts ONLY Energy-supertype cards, so Pokémon (which
      // carry their own game.types) never leak into the energy breakdown. A basic
      // Energy derives its element from its identity id (or name); a special
      // Energy that declares an element uses it, and one with no element falls
      // into a "Special" bucket so the donut still sums to the deck's energy count.
      const energyTypes = pokemonCardEnergyTypes(card);
      const elements = Array.isArray(energyTypes)
        ? energyTypes.filter((type) => typeof type === "string" && type.length > 0)
        : [];
      if (elements.length > 0) {
        elements.forEach((type) => {
          energyTypeCounts.set(type, (energyTypeCounts.get(type) ?? 0) + quantity);
        });
      } else {
        energyTypeCounts.set("Special", (energyTypeCounts.get("Special") ?? 0) + quantity);
      }
    }

    // Regulation mark (A–J), when the card carries one. Basic energy is
    // format-evergreen (not rotation-relevant), so exclude it from the gauge;
    // Pokémon, Trainers, and special (non-basic) energy still count.
    const isBasicEnergy = supertype === "Energy" && subtypes.indexOf("Basic") !== -1;
    const mark = typeof game.regulationMark === "string" ? game.regulationMark : "";
    if (mark.length > 0 && !isBasicEnergy) {
      regulationCounts.set(mark, (regulationCounts.get(mark) ?? 0) + quantity);
    }
  });

  const breakdown = [
    { key: "pokemon", label: "Pokémon", value: pokemon, color: "" },
    { key: "trainer", label: "Trainer", value: trainer, color: "" },
    { key: "energy", label: "Energy", value: energy, color: "" },
  ].filter((segment) => segment.value > 0);

  // Mulligan: P(no Basic Pokémon in the opening 7). Running product of draw
  // ratios (no factorial). 0 when a basic can't be avoided (too few non-basics).
  const handSize = 7;
  const drawCount = Math.min(handSize, total);
  let mulligan = 0;
  if (total > 0 && total - basicPokemon >= drawCount) {
    let probability = 1;
    for (let i = 0; i < drawCount; i = i + 1) {
      probability = (probability * (total - basicPokemon - i)) / (total - i);
    }
    mulligan = probability;
  }

  const widgets = [];

  widgets.push({ kind: "donut", label: "Deck breakdown", labelKey: "pokemon.deckBreakdown", vocabulary: "pokemon.supertype", segments: breakdown });
  widgets.push({ kind: "stat", label: "Total cards", labelKey: "pokemon.totalCards", value: total });
  widgets.push({ kind: "donut", label: "Energy types", labelKey: "pokemon.energyTypes", vocabulary: "pokemon.energyType", segments: energyTypeSegments(energyTypeCounts) });
  widgets.push({ kind: "stat", label: "Mulligan chance", labelKey: "pokemon.mulliganChance", value: formatPercentValue(mulligan) });

  {
    // Distinct-card table: one row per distinct card (keyed by name) in the
    // match set, with the configured metric columns. All combinatorics are
    // inline — the sandbox cannot import a helper.
    const containsValue = (haystack, needle) => {
      if (Array.isArray(haystack)) {
        return haystack.indexOf(needle) !== -1;
      }
      if (haystack === undefined || haystack === null) {
        return false;
      }
      return String(haystack).indexOf(String(needle)) !== -1;
    };
    const lineCopies = new Map();
    const linePrintings = new Map();
    const lineCards = new Map();
    const lineOrder = [];
    let deckSize = 0;
    let matchTotal = 0;
    Object.keys(pile).forEach((ref) => {
      const quantity = Number(pile[ref]) || 0;
      if (quantity <= 0) {
        return;
      }
      deckSize = deckSize + quantity;
      const card = cardsById[ref];
      const row = { quantity: quantity, card: card, ref: ref };
      if (!(["Pokémon", "Pokemon"].indexOf(row.card?.game?.supertype) !== -1 && containsValue(row.card?.game?.subtypes, "Basic"))) {
        return;
      }
      matchTotal = matchTotal + quantity;
      const key = String(row.card !== null && row.card !== undefined && typeof row.card.name === "string" && row.card.name.length > 0 ? row.card.name : row.ref);
      if (!lineCopies.has(key)) {
        lineCopies.set(key, 0);
        linePrintings.set(key, 0);
        lineCards.set(key, row);
        lineOrder.push(key);
      }
      lineCopies.set(key, lineCopies.get(key) + quantity);
      linePrintings.set(key, linePrintings.get(key) + 1);
    });

    // P(the opening hand contains none of a pool of `excluded` cards), drawing
    // `drawCount` without replacement — a running product of draw ratios so a
    // 60-card deck never overflows a factorial. 0 when the pool leaves too few
    // safe cards to fill the hand.
    const probNoneOf = (excluded, drawCount) => {
      if (deckSize - excluded < drawCount) {
        return 0;
      }
      let probability = 1;
      for (let i = 0; i < drawCount; i = i + 1) {
        probability = (probability * (deckSize - excluded - i)) / (deckSize - i);
      }
      return probability;
    };

    // Clamp to [0,1] and render as a 2-decimal percentage string.
    const formatPercent = (value) => {
      const clamped = value < 0 ? 0 : value > 1 ? 1 : value;
      return (Math.round(clamped * 10000) / 100).toFixed(2) + "%";
    };

    const entries = lineOrder.map((key) => {
      const copies = lineCopies.get(key);
      return {
        key: key,
        copies: copies,
        printings: linePrintings.get(key),
        row: lineCards.get(key),
        otherMatches: matchTotal - copies,
      };
    });

    // Highest copy count first (== highest possible-starter odds for fixed
    // draws); ties broken by key.
    entries.sort((left, right) => {
      if (right.copies !== left.copies) {
        return right.copies - left.copies;
      }
      return left.key < right.key ? -1 : left.key > right.key ? 1 : 0;
    });

    widgets.push({
      kind: "table",
      label: "Opening Hand Starters",
      columns: ["Pokémon", "Possible Starter", "Forced Starter"],
      rows: entries.map((entry) => {
        const row = entry.row;
        return [entry.key, formatPercent(1 - probNoneOf(entry.copies, Math.min(7, deckSize))), formatPercent(probNoneOf(entry.otherMatches, Math.min(7, deckSize)) - probNoneOf(entry.otherMatches + entry.copies, Math.min(7, deckSize)))];
      }),
    });
    const starterTable = widgets[widgets.length - 1];
    starterTable.labelKey = "pokemon.openingHandStarters";
    starterTable.columnKeys = [
      "pokemon.starterTable.pokemon",
      "pokemon.starterTable.possibleStarter",
      "pokemon.starterTable.forcedStarter",
    ];
  }

  widgets.push({ kind: "histogram", label: "Retreat cost", labelKey: "pokemon.retreatCost", buckets: retreatBuckets, labels: RETREAT_LABELS });
  widgets.push({ kind: "gauge", label: "Regulation marks", labelKey: "pokemon.regulationMarks", segments: regulationSegments(regulationCounts) });

  return { widgets: widgets };
}

function normalizePile(pile) {
  if (!isRecord(pile)) {
    return {};
  }

  return Object.entries(pile).reduce((result, [cardId, quantity]) => {
    const normalizedQuantity = normalizeQuantity(quantity);

    if (normalizedQuantity > 0) {
      result[cardId] = normalizedQuantity;
    }

    return result;
  }, {});
}

function normalizeQuantity(value) {
  return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
}

function cardGame(card) {
  return isRecord(card) && isRecord(card.game) ? card.game : {};
}

function isRecord(value) {
  return value !== null && typeof value === "object" && !Array.isArray(value);
}

// Clamp to [0,1] and render as a 2-decimal percentage string.
function formatPercentValue(value) {
  const clamped = value < 0 ? 0 : value > 1 ? 1 : value;
  return (Math.round(clamped * 10000) / 100).toFixed(2) + "%";
}

// Energy-type donut segments (canonical English type keys, or "Special"),
// highest count first.
function energyTypeSegments(counts) {
  const entries = [];
  counts.forEach((value, key) => {
    if (value > 0) {
      entries.push({ key: key, value: value });
    }
  });
  return entries
    .sort((left, right) => right.value - left.value)
    .map((entry) => ({ key: entry.key, label: entry.key, value: entry.value, color: "" }));
}

// Regulation-mark gauge segments, in alphabetical (A–J) mark order. Colors are
// left empty; the renderer fills them from its fallback palette.
function regulationSegments(counts) {
  const entries = [];
  counts.forEach((value, key) => {
    if (value > 0) {
      entries.push({ key: key, value: value });
    }
  });
  return entries
    .sort((left, right) => (left.key < right.key ? -1 : left.key > right.key ? 1 : 0))
    .map((entry) => ({ key: entry.key, label: entry.key, value: entry.value, color: "" }));
}

// The energy element an Energy card provides, read from its identity id. The
// catalog slugs every identity from its canonical English name in every locale
// ("basic-d-energy--935d278dfee1", "bubbly-w-energy--6fce5d2069c1"), while
// card.name is localized ("基本悪エネルギー" under /ja/), so the id is the
// locale-independent source.
function pokemonEnergyTypesFromId(id) {
  const slug = String(id === undefined || id === null ? "" : id).split("--")[0].toLowerCase();

  const glyph = slug.match(/(?:^|-)([a-z])-energy$/);
  if (glyph) {
    const type = POKEMON_ENERGY_GLYPH_TYPES[glyph[1].toUpperCase()];
    return type ? [type] : [];
  }

  const base = slug.replace(/^basic-/, "").replace(/-energy$/, "");
  return POKEMON_ENERGY_TYPES.filter((type) => type.toLowerCase() === base);
}

// The energy element(s) an Energy card provides, read from its NAME ("Fire
// Energy" -> Fire; "Basic {L} Energy" -> Lightning) — the fallback for ids that
// carry no element (printing-keyed or JP-only identities, multi-glyph names).
// {X} glyph tokens win; failing that, a spelled-out Basic Energy name is
// matched against the canonical types.
function pokemonEnergyTypesFromName(name) {
  const text = String(name === undefined || name === null ? "" : name);

  const glyphTypes = [];
  const glyphTokens = text.match(/\{[A-Za-z]\}/g) || [];
  glyphTokens.forEach((token) => {
    const type = POKEMON_ENERGY_GLYPH_TYPES[token.slice(1, 2).toUpperCase()];
    if (type && glyphTypes.indexOf(type) === -1) {
      glyphTypes.push(type);
    }
  });
  if (glyphTypes.length > 0) {
    return glyphTypes;
  }

  const base = text.replace(/^basic\s+/i, "").replace(/\s+energy$/i, "").trim().toLowerCase();
  return POKEMON_ENERGY_TYPES.filter((type) => type.toLowerCase() === base);
}

// The energy type(s) to group a card by. A Pokémon carries a declared game.types
// array, used as-is. An Energy card carries none, so its element is derived from
// its identity id, then its name. Any other card keeps its raw game.types
// (undefined for Trainers).
function pokemonCardEnergyTypes(card) {
  const game = cardGame(card);
  const declared = game.types;
  if (Array.isArray(declared) && declared.length > 0) {
    return declared;
  }
  if (game.supertype === "Energy") {
    const fromId = pokemonEnergyTypesFromId(isRecord(card) ? card.id : "");
    return fromId.length > 0 ? fromId : pokemonEnergyTypesFromName(isRecord(card) ? card.name : "");
  }
  return declared;
}
js
const ENERGY_CURVE_LABELS = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "+"];

// Might shares the 0–9 + overflow bucket shape of the energy curve.
const MIGHT_LABELS = ENERGY_CURVE_LABELS;

// Card types that sit in a separate zone, not the main deck; excluded from the
// energy curve, domain pips, and card-type breakdown. Runes are handled first
// (their own domain split) before this main-deck exclusion.
const EXCLUDED_ZONE_TYPES = ["legend", "rune", "battlefield"];

// Main-deck card types that are Units (Champions are Units) — the population of
// the Might distribution.
const UNIT_TYPES = ["unit", "champion"];

// Known domains in display order; unknown domain refs are not charted.
const DOMAIN_ORDER = ["body", "calm", "chaos", "fury", "mind", "order", "colorless", "neutral"];

// Card-type display order; unexpected types trail by count.
const TYPE_ORDER = ["champion", "unit", "spell", "gear"];

export default function compute(pile, cardsById) {
  const deck = normalizePile(pile);
  const catalog = isRecord(cardsById) ? cardsById : {};

  const energyBuckets = Array.from({ length: 11 }, () => 0);
  const mightBuckets = Array.from({ length: 11 }, () => 0);
  const domainCounts = new Map();
  const runeDomainCounts = new Map();
  const typeCounts = new Map();

  let energyTotal = 0;
  let mainCount = 0;

  Object.keys(deck).forEach((cardId) => {
    if (!isRecord(catalog[cardId])) {
      return;
    }

    const quantity = deck[cardId];
    const game = cardGame(catalog[cardId]);

    const type = typeof game.type === "string" && game.type.length > 0 ? game.type : "unknown";

    // The Rune deck has its own domain split (the mana base — 6/6 vs 8/4), read
    // off the rune zone alone and kept out of the main-deck counts.
    if (type === "rune") {
      addDomains(runeDomainCounts, game.domains, quantity);
      return;
    }

    // Legend / battlefield cards are separate zones, not the main deck.
    if (EXCLUDED_ZONE_TYPES.indexOf(type) !== -1) {
      return;
    }

    // Riftbound uses energy as its cost.
    const energy = Number.isFinite(game.energy) ? Math.max(0, Math.floor(game.energy)) : 0;
    energyBuckets[Math.min(energy, 10)] += quantity;
    energyTotal += energy * quantity;
    mainCount += quantity;

    typeCounts.set(type, (typeCounts.get(type) ?? 0) + quantity);

    // Might distribution over Units (Champions are Units). Units with no numeric
    // might are skipped so missing data never piles into the 0 bucket.
    if (UNIT_TYPES.indexOf(type) !== -1 && Number.isFinite(game.might)) {
      const might = Math.max(0, Math.floor(game.might));
      mightBuckets[Math.min(might, 10)] += quantity;
    }

    addDomains(domainCounts, game.domains, quantity);
  });

  // Mean energy over the main deck (zone-excluded), to one decimal.
  const averageEnergy = mainCount > 0 ? Math.round((energyTotal / mainCount) * 10) / 10 : 0;

  // The balance of the three main card types. A ratio widget holds only two
  // values, so the three-way split renders as a small donut.
  const typeBalance = ["unit", "spell", "gear"].map((key) => ({
    key,
    label: titleCase(key),
    value: typeCounts.get(key) ?? 0,
    color: "",
  }));

  const runeDomains = orderedSegments(runeDomainCounts, DOMAIN_ORDER);
  const hasMight = mightBuckets.some((count) => count > 0);

  // Like the panel, the rune split and Might chart only appear when they have data.
  return {
    widgets: [
      { kind: "bars", label: "Domains", labelKey: "riftbound.domains", vocabulary: "riftbound.domain", segments: orderedSegments(domainCounts, DOMAIN_ORDER) },
      ...(runeDomains.length > 0
        ? [{ kind: "bars", label: "Rune domains", labelKey: "riftbound.runeDomains", vocabulary: "riftbound.domain", segments: runeDomains }]
        : []),
      { kind: "histogram", label: "Energy curve", labelKey: "riftbound.energyCurve", buckets: energyBuckets, labels: ENERGY_CURVE_LABELS },
      ...(hasMight
        ? [{ kind: "histogram", label: "Might", labelKey: "riftbound.might", buckets: mightBuckets, labels: MIGHT_LABELS }]
        : []),
      { kind: "donut", label: "Card types", labelKey: "riftbound.cardTypes", vocabulary: "riftbound.cardType", segments: orderedSegments(typeCounts, TYPE_ORDER) },
      { kind: "stat", label: "Average energy", labelKey: "riftbound.averageEnergy", value: averageEnergy },
      { kind: "donut", label: "Unit / Spell / Gear", labelKey: "riftbound.typeBalance", vocabulary: "riftbound.cardType", segments: typeBalance },
    ],
  };
}

function normalizePile(pile) {
  if (!isRecord(pile)) {
    return {};
  }

  return Object.entries(pile).reduce((result, [cardId, quantity]) => {
    const normalizedQuantity = normalizeQuantity(quantity);

    if (normalizedQuantity > 0) {
      result[cardId] = normalizedQuantity;
    }

    return result;
  }, {});
}

function normalizeQuantity(value) {
  return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
}

function cardGame(card) {
  return isRecord(card) && isRecord(card.game) ? card.game : {};
}

function isRecord(value) {
  return value !== null && typeof value === "object" && !Array.isArray(value);
}

function titleCase(value) {
  return value.length > 0 ? value.slice(0, 1).toUpperCase() + value.slice(1) : value;
}

// Segments in a fixed display order, then any unlisted key by count (highest
// first). Labels and colors are English/empty fallbacks: the widget's
// vocabulary lets the web domain supply both.
function orderedSegments(map, order) {
  const rank = (key) => {
    const index = order.indexOf(key);
    return index === -1 ? order.length : index;
  };
  const entries = [];

  map.forEach((value, key) => {
    if (value > 0) {
      entries.push({ key, value });
    }
  });

  return entries
    .sort((left, right) => rank(left.key) - rank(right.key) || right.value - left.value)
    .map((entry) => ({
      key: entry.key,
      label: titleCase(entry.key),
      value: entry.value,
      color: "",
    }));
}

function addDomains(counts, domains, quantity) {
  (Array.isArray(domains) ? domains : []).forEach((domain) => {
    if (DOMAIN_ORDER.indexOf(domain) !== -1) {
      counts.set(domain, (counts.get(domain) ?? 0) + quantity);
    }
  });
}

Turny.gg documentation