Appearance
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:
export default function compute(...) { ... }(any name, or none, works here).export default <expression>, where the expression is a function.- 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
| # | Argument | Type | Description |
|---|---|---|---|
| 1 | pile | Record<string, number> | The deck being viewed: card ref to number of copies. Refs are the keys of cardsById. |
| 2 | cardsById | Record<string, Card> | Every card in the pile, by ref. Game-specific fields live on card.game; see the card data reference for your game. |
| 3 | context | CalculatorScriptContext | undefined | Optional catalog context (fields below). May be undefined: a script that reads it must work without it. |
CalculatorScriptContext:
| Property | Type | Description |
|---|---|---|
globals | unknown | The 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,cardsByIdandctxare 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. ctxmay be absent. Any run can callcomputewithout 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
| kind | Description |
|---|---|
"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").
| Property | Type | Description |
|---|---|---|
label | string | Title shown on the widget. |
labelKey? | string | Optional i18n key under calculators.widgets.*; falls back to label. |
value | number | string | The value. A finite number is formatted in the viewer's locale (Intl.NumberFormat); a string is shown verbatim (e.g. "12.5%"). |
unit? | string | Optional 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.
| Property | Type | Description |
|---|---|---|
label | string | Title shown on the widget. |
labelKey? | string | Optional i18n key under calculators.widgets.*; falls back to label. |
buckets | number[] | Bar heights, left to right. |
labels | string[] | One axis label per bucket, same order as buckets. |
kind: "donut"
A donut (pie) chart of labelled, coloured segments.
| Property | Type | Description |
|---|---|---|
label | string | Title shown on the widget. |
labelKey? | string | Optional i18n key under calculators.widgets.*; falls back to label. |
vocabulary? | string | Optional CalculatorVocabulary id resolving segment labels and colors per game. |
segments | DeckStatSegment[] | 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.
| Property | Type | Description |
|---|---|---|
label | string | Title shown on the widget. |
labelKey? | string | Optional i18n key under calculators.widgets.*; falls back to label. |
vocabulary? | string | Optional CalculatorVocabulary id resolving segment labels and colors per game. |
segments | DeckStatSegment[] | Arcs, drawn in order; zero-value arcs are skipped. |
kind: "bars"
Horizontal bars, one per segment (e.g. regions, domains, mana sources).
| Property | Type | Description |
|---|---|---|
label | string | Title shown on the widget. |
labelKey? | string | Optional i18n key under calculators.widgets.*; falls back to label. |
vocabulary? | string | Optional CalculatorVocabulary id resolving segment labels and colors per game. |
segments | DeckStatSegment[] | Bars, drawn in order. |
kind: "table"
A table of rows under column headers.
| Property | Type | Description |
|---|---|---|
label | string | Title shown on the widget. |
labelKey? | string | Optional i18n key under calculators.widgets.*; falls back to label. |
columns | string[] | 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.
| Property | Type | Description |
|---|---|---|
label | string | Title shown on the widget. |
labelKey? | string | Optional i18n key under calculators.widgets.*; falls back to label. |
population | number | Population size: the pile / deck size drawn from. |
successCount | number | Successes in the population: cards matching the filter. |
draws | number | Number 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.
| Property | Type | Description |
|---|---|---|
label | string | Title shown on the widget. |
labelKey? | string | Optional i18n key under calculators.widgets.*; falls back to label. |
numerator | number | Top of the fraction. |
denominator | number | Bottom of the fraction. A zero shows the no-data placeholder. |
format? | CalculatorRatioFormat | How to format the quotient. Defaults to number. |
Segments
A donut slice or gauge arc (DeckStatSegment):
| Property | Type | Description |
|---|---|---|
key | string | Stable identifier for the segment (e.g. "spells"). |
label | string | Legend text. |
value | number | The segment's size; segments are drawn in proportion to their values. |
color | string | CSS color (e.g. "#3a78e8"). Empty picks a fallback palette color. |
Ratio formats
| Format | Shows |
|---|---|
"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 returns | What is drawn |
|---|---|
Anything other than an object with a widgets array | An empty dashboard |
A widget that is not an object, or has an unknown kind | That widget is dropped; the rest are kept |
A missing or non-text label | An 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 boolean | Converted to text; any other value (object, null) becomes empty text |
A stat value | Kept as a number if it is one (non-finite becomes 0), otherwise text |
A table cell | Kept as a number if it is one, otherwise text; a row that is not an array is dropped |
| A segment that is not an object | That 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 widget | Dropped |
| More entries or longer text than the limits allow | Cut 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.
| Cap | Limit | Applies to |
|---|---|---|
maxWidgets | 50 | Widgets kept per dashboard; later widgets are dropped. |
maxSegments | 100 | Segments kept per donut, gauge or bars widget. |
maxHistogramBuckets | 200 | Entries kept in a histogram's buckets and in its labels. |
maxTableRows | 200 | Rows kept per table. |
maxTableColumns | 20 | Entries kept in a table's columns, and cells kept per row. |
maxLabelLength | 200 | Characters 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. |
maxCellLength | 200 | Characters kept in a string table cell. |
maxExactCounts | 50 | Entries 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);
}
});
}