diff --git a/src/features/game/items/ItemCard.tsx b/src/features/game/items/ItemCard.tsx
index 077d3b5..b0eeba5 100644
--- a/src/features/game/items/ItemCard.tsx
+++ b/src/features/game/items/ItemCard.tsx
@@ -2,6 +2,7 @@ import { ActionIcon, Box, Flex, Text } from "@mantine/core";
import clsx from "clsx";
import { LuCheck, LuInfo, LuPlus, LuX } from "react-icons/lu";
import { GameImage } from "#/components/GameImage";
+import { ItemDescription } from "#/features/game/items/ItemDescription";
import type { AppItem, CollectItemInput } from "#/features/game/items/types";
import classes from "./ItemCard.module.css";
@@ -106,17 +107,15 @@ const ItemCard = ({
)}
- {item.description.length > 0 && item.description[0] !== "" && (
-
- {item.description[0]}
-
- )}
+
diff --git a/src/features/game/items/ItemDescription.tsx b/src/features/game/items/ItemDescription.tsx
new file mode 100644
index 0000000..31f508d
--- /dev/null
+++ b/src/features/game/items/ItemDescription.tsx
@@ -0,0 +1,78 @@
+import { Text, type TextProps } from "@mantine/core";
+import { Fragment, type ReactNode } from "react";
+import {
+ type DescriptionSegment,
+ parseDescriptionSegments,
+} from "#/features/game/items/description-tokens";
+
+type ItemDescriptionProps = {
+ description: string[];
+ /** Render only the first description line (e.g. compact card preview). */
+ firstOnly?: boolean;
+ /** Which side of `[base|upgraded]` tokens to show. Defaults to "base". */
+ variant?: "base" | "upgraded";
+} & TextProps;
+
+/**
+ * Splits a string on `\n` (introduced by a `
` inside an upgrade token) into
+ * text nodes separated by
elements.
+ */
+const withLineBreaks = (text: string, keyPrefix: string): ReactNode[] => {
+ const parts = text.split("\n");
+ return parts.flatMap((part, index) =>
+ index === 0
+ ? [part]
+ : // biome-ignore lint/suspicious/noArrayIndexKey: order never changes
+ [
, part],
+ );
+};
+
+const renderSegment = (
+ segment: DescriptionSegment,
+ variant: "base" | "upgraded",
+ key: string,
+): ReactNode => {
+ if (segment.kind === "text") {
+ return {withLineBreaks(segment.text, key)};
+ }
+
+ const value = variant === "base" ? segment.base : segment.upgraded;
+ if (value === "") return null;
+
+ return (
+
+ {withLineBreaks(value, key)}
+
+ );
+};
+
+const renderLine = (line: string, variant: "base" | "upgraded"): ReactNode[] =>
+ parseDescriptionSegments(line).map((segment, index) =>
+ renderSegment(segment, variant, `seg-${index}`),
+ );
+
+const ItemDescription = ({
+ description,
+ firstOnly = false,
+ variant = "base",
+ ...textProps
+}: ItemDescriptionProps) => {
+ const hasDescription = description.length > 0 && description[0] !== "";
+ if (!hasDescription) return null;
+
+ const lines = firstOnly ? description.slice(0, 1) : description;
+
+ return (
+ <>
+ {lines.map((line, index) => (
+ // biome-ignore lint/suspicious/noArrayIndexKey: static description lines never reorder, and lines may repeat so content alone is not unique
+
+ {renderLine(line, variant)}
+
+ ))}
+ >
+ );
+};
+
+export type { ItemDescriptionProps };
+export { ItemDescription };
diff --git a/src/features/game/items/ItemInfoModal.tsx b/src/features/game/items/ItemInfoModal.tsx
index 5e40266..3201360 100644
--- a/src/features/game/items/ItemInfoModal.tsx
+++ b/src/features/game/items/ItemInfoModal.tsx
@@ -16,6 +16,7 @@ import { useEffect, useRef, useState } from "react";
import { LuCamera, LuCheck, LuPlus } from "react-icons/lu";
import { GameImage } from "#/components/GameImage";
import { useGameId } from "#/features/game/core/use-game-id";
+import { ItemDescription } from "#/features/game/items/ItemDescription";
import type { AppItem, CollectItemInput } from "#/features/game/items/types";
import { getGameMetadata } from "#/features/game/registry/game-registry";
import {
@@ -110,13 +111,14 @@ const ItemInfoModal = ({
{item.name}
-
+
{String(item.category)}
-
+
+
{item.subcategory && (
-
+
{String(item.subcategory)}
-
+
)}
{!screenshotMode &&
@@ -154,13 +156,7 @@ const ItemInfoModal = ({
{hasDescription && (
<>
-
- {item.description.map((line) => (
-
- {line}
-
- ))}
-
+
>
)}
@@ -232,5 +228,5 @@ const ItemInfoModal = ({
);
};
-export { ItemInfoModal };
export type { ItemInfoModalProps };
+export { ItemInfoModal };
diff --git a/src/features/game/items/description-tokens.ts b/src/features/game/items/description-tokens.ts
new file mode 100644
index 0000000..7953ce6
--- /dev/null
+++ b/src/features/game/items/description-tokens.ts
@@ -0,0 +1,58 @@
+// Parses inline upgrade tokens embedded in item descriptions.
+//
+// Some games (e.g. Slay the Spire 2 cards) describe values that differ between
+// the base and upgraded version of an item using a `[base|upgraded]` token,
+// matching the wiki source 1:1.
+//
+// Examples:
+// "Deal [6|9] damage." -> base "6", upgraded "9"
+// "...Discard Pile[| +3]." -> base "", upgraded " +3"
+// "Gain 5 Block.[\nExhaust.|]" -> a token may add/remove a line
+//
+// Either side of a token may be empty, and either side may contain a line break
+// via
.
+
+type DescriptionSegment =
+ | { kind: "text"; text: string }
+ | { kind: "upgrade"; base: string; upgraded: string };
+
+// Group 1 = base side, group 2 = upgraded side. Neither side may contain `]`,
+// and the base side stops at the `|` separator.
+const UPGRADE_TOKEN_REGEX = /\[([^\]|]*)\|([^\]]*)]/g;
+
+const parseDescriptionSegments = (line: string): DescriptionSegment[] => {
+ const segments: DescriptionSegment[] = [];
+ let lastIndex = 0;
+
+ // Use a fresh regex each call to avoid shared lastIndex state.
+ const regex = new RegExp(UPGRADE_TOKEN_REGEX.source, "g");
+ let match = regex.exec(line);
+ while (match !== null) {
+ if (match.index > lastIndex) {
+ segments.push({ kind: "text", text: line.slice(lastIndex, match.index) });
+ }
+ segments.push({ kind: "upgrade", base: match[1]!, upgraded: match[2]! });
+ lastIndex = match.index + match[0].length;
+ match = regex.exec(line);
+ }
+
+ if (lastIndex < line.length) {
+ segments.push({ kind: "text", text: line.slice(lastIndex) });
+ }
+
+ return segments;
+};
+
+const renderDescriptionVariant = (
+ line: string,
+ variant: "base" | "upgraded",
+): string =>
+ line.replace(UPGRADE_TOKEN_REGEX, (_match, base: string, upgraded: string) =>
+ variant === "base" ? base : upgraded,
+ );
+
+const hasUpgradeTokens = (line: string): boolean =>
+ new RegExp(UPGRADE_TOKEN_REGEX.source).test(line);
+
+export type { DescriptionSegment };
+export { hasUpgradeTokens, parseDescriptionSegments, renderDescriptionVariant };
diff --git a/src/features/wiki-sync/clean-wiki-text.ts b/src/features/wiki-sync/clean-wiki-text.ts
index db5fe3d..f1e8022 100644
--- a/src/features/wiki-sync/clean-wiki-text.ts
+++ b/src/features/wiki-sync/clean-wiki-text.ts
@@ -90,7 +90,7 @@ const cleanWikiTags = (text: string): string => {
console.warn(` ! template syntax remains after pass: ${result}`);
}
- // 2. $Word -> Word (single bareword only — apostrophes allowed).
+ // 2. $Word -> Word (single bareword only - apostrophes allowed).
result = result.replace(/\$([A-Za-z][\w']*)/g, (_m, word: string) =>
capitalize(word),
);
@@ -139,4 +139,59 @@ const cleanWikiText = (text: string): string[] => {
return splitOnLineBreaks(text).map(cleanWikiTags);
};
-export { cleanWikiTags, cleanWikiText, splitOnLineBreaks };
+/**
+ * Similar to splitOnLineBreaks, but only splits on a
that lives at the top level.
+ * A
inside a `[base|upgraded]` upgrade token is left in
+ * place so the token is not torn across array elements.
+ */
+const splitOnTopLevelLineBreaks = (text: string): string[] => {
+ const segments: string[] = [];
+ let depth = 0;
+ let current = "";
+
+ for (let i = 0; i < text.length; i++) {
+ const char = text[i]!;
+ if (char === "[") {
+ depth++;
+ current += char;
+ continue;
+ }
+ if (char === "]") {
+ if (depth > 0) depth--;
+ current += char;
+ continue;
+ }
+ if (depth === 0 && char === "<") {
+ const brMatch = /^
/i.exec(text.slice(i));
+ if (brMatch) {
+ segments.push(current);
+ current = "";
+ i += brMatch[0].length - 1;
+ continue;
+ }
+ }
+ current += char;
+ }
+ segments.push(current);
+
+ return segments.map((s) => s.trim()).filter((s) => s.length > 0);
+};
+
+/**
+ * Variant of cleanWikiText that preserves `[base|upgraded]` upgrade tokens.
+ * Top-level
become separate array elements (as usual); a
*inside* a
+ * token is converted to `\n` so a single variant can still introduce a line
+ * break.
+ */
+const cleanWikiTextPreservingTokens = (text: string): string[] => {
+ return splitOnTopLevelLineBreaks(text).map((segment) =>
+ cleanWikiTags(segment).replace(/
/gi, "\n"),
+ );
+};
+
+export {
+ cleanWikiTags,
+ cleanWikiText,
+ cleanWikiTextPreservingTokens,
+ splitOnLineBreaks,
+};
diff --git a/src/games/slaythespire2/core/item-data/cards.ts b/src/games/slaythespire2/core/item-data/cards.ts
index be65800..6fc6b3c 100644
--- a/src/games/slaythespire2/core/item-data/cards.ts
+++ b/src/games/slaythespire2/core/item-data/cards.ts
@@ -3,6 +3,13 @@ import type { BaseSlayTheSpire2Item } from "#/games/slaythespire2/core/types";
type SlayTheSpire2CardItem = BaseSlayTheSpire2Item & {
cost: {
energy: number;
+ /**
+ * Absolute energy cost once the card is upgraded,
+ * when it differs from the cost field.
+ * `energy` (mirrors the wiki `CostPlus` field).
+ * `undefined` means the cost is unchanged on upgrade.
+ */
+ energyUpgraded: number | undefined;
secondary:
| Partial<{
hp: number;
@@ -11,18 +18,27 @@ type SlayTheSpire2CardItem = BaseSlayTheSpire2Item & {
};
};
+/**
+ * `description` lines may embed `[base|upgraded]` upgrade tokens, matching the
+ * wiki source 1:1 — e.g. `"Deal [8|10] damage."` shows 8 base / 10 upgraded.
+ * Either side may be empty (`"...Discard Pile[| +3]."`). The `ItemDescription`
+ * component parses these for display;
+ *
+ * see `#/features/game/items/description-tokens`.
+ */
const CARDS: SlayTheSpire2CardItem[] = [
{
name: "Bash",
category: "CARD",
id: "bash",
dlc: "BASE",
- description: [`Deal 8 damage. Apply 2 Vulnerable.`],
+ description: [`Deal [8|10] damage.`, `Apply [2|3] Vulnerable.`],
imageUrl: "", // TODO
wikiUrl: `https://slaythespire.wiki.gg/wiki/Slay_the_Spire_2:Bash`,
location: undefined,
cost: {
energy: 2,
+ energyUpgraded: undefined,
secondary: undefined,
},
modifiers: undefined,
diff --git a/src/games/slaythespire2/wiki/cards.ts b/src/games/slaythespire2/wiki/cards.ts
index be979df..87a7a34 100644
--- a/src/games/slaythespire2/wiki/cards.ts
+++ b/src/games/slaythespire2/wiki/cards.ts
@@ -8,7 +8,8 @@
* Run with: pnpm tsx src/games/slaythespire2/wiki/cards.ts
*/
-import { cleanWikiText } from "#/features/wiki-sync/clean-wiki-text";
+import { renderDescriptionVariant } from "#/features/game/items/description-tokens";
+import { cleanWikiTextPreservingTokens } from "#/features/wiki-sync/clean-wiki-text";
import {
CARDS,
type SlayTheSpire2CardItem,
@@ -39,6 +40,7 @@ type WikiCard = {
name: string;
description: string[];
cost: number | undefined;
+ costPlus: number | undefined;
type: string;
rarity: string;
color: string;
@@ -56,36 +58,64 @@ const normalizeEntry = (
fields: Record,
): WikiCard => ({
name,
- description: cleanWikiText(getString(fields, "Text")),
+ description: cleanWikiTextPreservingTokens(getString(fields, "Text")),
cost: getNumber(fields, "Cost"),
+ costPlus: getNumber(fields, "CostPlus"),
type: getString(fields, "Type"),
rarity: getString(fields, "Rarity"),
color: getString(fields, "Color"),
image: getString(fields, "Image"),
});
+/**
+ * Renders every line to one variant and serializes, so base and upgraded text
+ * are compared independently.
+ */
+const renderVariant = (lines: string[], variant: "base" | "upgraded"): string =>
+ JSON.stringify(lines.map((line) => renderDescriptionVariant(line, variant)));
+
const compareCard = (local: LocalCard, wiki: WikiCard): CompareResult => {
- const localDesc = JSON.stringify(local.description);
- const wikiDesc = JSON.stringify(wiki.description);
- const localCost = local.cost.energy;
- const wikiCost = wiki.cost;
+ const localBase = renderVariant(local.description, "base");
+ const wikiBase = renderVariant(wiki.description, "base");
+ const localUpgraded = renderVariant(local.description, "upgraded");
+ const wikiUpgraded = renderVariant(wiki.description, "upgraded");
+
+ const localEnergy = local.cost.energy;
+ const wikiEnergy = wiki.cost;
+ const localEnergyUpgraded = local.cost.energyUpgraded;
+ const wikiEnergyUpgraded = wiki.costPlus;
+
+ const baseDiffers = localBase !== wikiBase;
+ const upgradedDiffers = localUpgraded !== wikiUpgraded;
+ const energyDiffers = wikiEnergy !== undefined && localEnergy !== wikiEnergy;
+ const energyUpgradedDiffers =
+ wikiEnergyUpgraded !== undefined &&
+ localEnergyUpgraded !== wikiEnergyUpgraded;
const differingFields: string[] = [];
- if (localDesc !== wikiDesc) differingFields.push("description");
- if (wikiCost !== undefined && localCost !== wikiCost) {
- differingFields.push("cost.energy");
- }
+ if (baseDiffers) differingFields.push("description (base)");
+ if (upgradedDiffers) differingFields.push("description (upgraded)");
+ if (energyDiffers) differingFields.push("cost.energy");
+ if (energyUpgradedDiffers) differingFields.push("cost.energyUpgraded");
return {
differingFields,
printDetails: () => {
- if (localDesc !== wikiDesc) {
- console.log(` description local: ${localDesc}`);
- console.log(` description wiki: ${wikiDesc}`);
+ if (baseDiffers) {
+ console.log(` description (base) local: ${localBase}`);
+ console.log(` description (base) wiki: ${wikiBase}`);
+ }
+ if (upgradedDiffers) {
+ console.log(` description (upgraded) local: ${localUpgraded}`);
+ console.log(` description (upgraded) wiki: ${wikiUpgraded}`);
+ }
+ if (energyDiffers) {
+ console.log(` cost.energy local: ${localEnergy}`);
+ console.log(` cost.energy wiki: ${wikiEnergy}`);
}
- if (wikiCost !== undefined && localCost !== wikiCost) {
- console.log(` cost.energy local: ${localCost}`);
- console.log(` cost.energy wiki: ${wikiCost}`);
+ if (energyUpgradedDiffers) {
+ console.log(` cost.energyUpgraded local: ${localEnergyUpgraded}`);
+ console.log(` cost.energyUpgraded wiki: ${wikiEnergyUpgraded}`);
}
},
};