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}`); } }, };