diff --git a/src/games/slaythespire2/core/item-data/cards.ts b/src/games/slaythespire2/core/item-data/cards.ts index 082ecbe..be65800 100644 --- a/src/games/slaythespire2/core/item-data/cards.ts +++ b/src/games/slaythespire2/core/item-data/cards.ts @@ -25,15 +25,7 @@ const CARDS: SlayTheSpire2CardItem[] = [ energy: 2, secondary: undefined, }, - modifiers: [ - { - modifier: { - damage: 8, - vulnerable: 2, - }, - trigger: "attack", - }, - ], + modifiers: undefined, }, ]; diff --git a/src/games/slaythespire2/core/types.ts b/src/games/slaythespire2/core/types.ts index b6de12a..4fc2def 100644 --- a/src/games/slaythespire2/core/types.ts +++ b/src/games/slaythespire2/core/types.ts @@ -91,7 +91,7 @@ type BaseSlayTheSpire2Item = AppItem< dlc: SlayTheSpire2DLC; location: SlayTheSpire2ItemLocation | undefined; modifiers: - | Array<{ modifier: Partial; trigger: ModifierTrigger }> + | Array<{ modifier: Partial; trigger?: ModifierTrigger }> | undefined; }; diff --git a/src/games/slaythespire2/wiki/potions.ts b/src/games/slaythespire2/wiki/potions.ts index 30cdd08..b057c9d 100644 --- a/src/games/slaythespire2/wiki/potions.ts +++ b/src/games/slaythespire2/wiki/potions.ts @@ -15,6 +15,8 @@ import { } from "#/games/slaythespire2/core/item-data/potions"; import { type CompareResult, + getString, + resolveMapped, syncWikiCategory, } from "#/games/slaythespire2/wiki/sync-category.ts"; import type { @@ -39,25 +41,17 @@ const normalizeEntry = ( name: string, fields: Record, ): WikiPotion => { - const rawText = typeof fields.Text === "string" ? fields.Text : ""; - const rawRarity = - typeof fields.Rarity === "string" ? fields.Rarity.toLowerCase() : ""; - const rawCharacter = - typeof fields.Character === "string" ? fields.Character.toLowerCase() : ""; - const rawImage = typeof fields.Image === "string" ? fields.Image : ""; + const rawRarity = getString(fields, "Rarity").toLowerCase(); + const rawCharacter = getString(fields, "Character").toLowerCase(); - const description = cleanWikiText(rawText); - const rarity = POTION_RARITY_MAP[rawRarity]; - if (rawRarity && !rarity) { - console.warn(` ! unknown rarity '${rawRarity}' for ${name}`); - } - - const character = rawCharacter ? (CHARACTER_MAP[rawCharacter] ?? null) : null; - if (rawCharacter && !character) { - console.warn(` ! unknown character '${rawCharacter}' for ${name}`); - } - - return { name, description, rarity, character, image: rawImage }; + return { + name, + description: cleanWikiText(getString(fields, "Text")), + image: getString(fields, "Image"), + rarity: resolveMapped(rawRarity, POTION_RARITY_MAP, "rarity", name), + character: + resolveMapped(rawCharacter, CHARACTER_MAP, "character", name) ?? null, + }; }; const comparePotion = (local: LocalPotion, wiki: WikiPotion): CompareResult => { diff --git a/src/games/slaythespire2/wiki/relics.ts b/src/games/slaythespire2/wiki/relics.ts index 6dcf162..8d8ba5b 100644 --- a/src/games/slaythespire2/wiki/relics.ts +++ b/src/games/slaythespire2/wiki/relics.ts @@ -16,6 +16,8 @@ import { } from "#/games/slaythespire2/core/item-data/relics.ts"; import { type CompareResult, + getString, + resolveMapped, syncWikiCategory, } from "#/games/slaythespire2/wiki/sync-category.ts"; import type { @@ -44,100 +46,75 @@ const normalizeEntry = ( name: string, fields: Record, ): WikiRelic => { - const rawText = - typeof fields.Description === "string" ? fields.Description : ""; - const rawRarity = - typeof fields.Rarity === "string" ? fields.Rarity.toLowerCase() : ""; - const rawCharacter = - typeof fields.Character === "string" ? fields.Character.toLowerCase() : ""; - const rawAncient = - typeof fields.Ancient === "string" ? fields.Ancient.toLowerCase() : ""; - const rawImage = typeof fields.Image === "string" ? fields.Image : ""; - const rawFlavorText = typeof fields.Flavor === "string" ? fields.Flavor : ""; - const rawUpgrade = typeof fields.Upgrade === "string" ? fields.Upgrade : ""; - const rawIsUpgrade = rawUpgrade.toLowerCase() === "yes"; - - const description = cleanWikiText(rawText); - const rarity = RELIC_RARITY_MAP[rawRarity]; - if (rawRarity && !rarity) { - console.warn(` ! unknown rarity '${rawRarity}' for ${name}`); - } - - const character = rawCharacter ? (CHARACTER_MAP[rawCharacter] ?? null) : null; - if (rawCharacter && !character) { - console.warn(` ! unknown character '${rawCharacter}' for ${name}`); - } - - const ancient = rawAncient ? (ANCIENT_MAP[rawAncient] ?? null) : null; - if (rawAncient && !ancient) { - console.warn(` ! unknown ancient '${rawAncient}' for ${name}`); - } + const rawRarity = getString(fields, "Rarity").toLowerCase(); + const rawCharacter = getString(fields, "Character").toLowerCase(); + const rawAncient = getString(fields, "Ancient").toLowerCase(); return { name, - description, - rarity, - character, - ancient, - flavorText: rawFlavorText, - isUpgrade: rawIsUpgrade, - image: rawImage, + description: cleanWikiText(getString(fields, "Description")), + flavorText: getString(fields, "Flavor"), + isUpgrade: getString(fields, "Upgrade").toLowerCase() === "yes", + image: getString(fields, "Image"), + rarity: resolveMapped(rawRarity, RELIC_RARITY_MAP, "rarity", name), + character: + resolveMapped(rawCharacter, CHARACTER_MAP, "character", name) ?? null, + ancient: resolveMapped(rawAncient, ANCIENT_MAP, "ancient", name) ?? null, }; }; -const compareRelic = (local: LocalRelic, wiki: WikiRelic): CompareResult => { - const differingFields: string[] = []; - - const localDesc = JSON.stringify(local.description); - const wikiDesc = JSON.stringify(wiki.description); - if (localDesc !== wikiDesc) { - differingFields.push("description"); +const diffLinkedItem = ( + fieldName: string, + localValue: string | null, + wikiValue: string | null, +): string | null => { + if (wikiValue === localValue) return null; + if (wikiValue && !localValue) { + return `missing linkedItems.${fieldName} (expected '${wikiValue}')`; } - - const isUpgradeDiffers = local.isUpgrade !== wiki.isUpgrade; - if (isUpgradeDiffers) { - differingFields.push("isUpgrade"); + if (!wikiValue && localValue) { + return `unexpected linkedItems.${fieldName} '${localValue}' (wiki has none)`; } + return `linkedItems.${fieldName} mismatch (local '${localValue}' vs wiki '${wikiValue}')`; +}; - const localFlavorText = JSON.stringify(local.flavorText); - const wikiFlavorText = JSON.stringify(wiki.flavorText); - if (localFlavorText !== wikiFlavorText) { - differingFields.push("flavorText"); - } +type ScalarDiff = { field: string; local: string; wiki: string }; - const localCharacter = local.linkedItems?.character?.name ?? null; - const localAncient = local.linkedItems?.ancient?.name ?? null; - const linkedItemIssues: string[] = []; - if (wiki.character !== localCharacter) { - if (wiki.character && !localCharacter) { - linkedItemIssues.push( - `missing linkedItems.character (expected '${wiki.character}')`, - ); - } else if (!wiki.character && localCharacter) { - linkedItemIssues.push( - `unexpected linkedItems.character '${localCharacter}' (wiki has none)`, - ); - } else { - linkedItemIssues.push( - `linkedItems.character mismatch (local '${localCharacter}' vs wiki '${wiki.character}')`, - ); - } - } - if (wiki.ancient !== localAncient) { - if (wiki.ancient && !localAncient) { - linkedItemIssues.push( - `missing linkedItems.ancient (expected '${wiki.ancient}')`, - ); - } else if (!wiki.ancient && localAncient) { - linkedItemIssues.push( - `unexpected linkedItems.ancient '${localAncient}' (wiki has none)`, - ); - } else { - linkedItemIssues.push( - `linkedItems.ancient mismatch (local '${localAncient}' vs wiki '${wiki.ancient}')`, - ); - } - } +const compareRelic = (local: LocalRelic, wiki: WikiRelic): CompareResult => { + const scalarDiffs: ScalarDiff[] = ( + [ + { + field: "description", + local: JSON.stringify(local.description), + wiki: JSON.stringify(wiki.description), + }, + { + field: "isUpgrade", + local: String(local.isUpgrade), + wiki: String(wiki.isUpgrade), + }, + { + field: "flavorText", + local: JSON.stringify(local.flavorText), + wiki: JSON.stringify(wiki.flavorText), + }, + ] satisfies ScalarDiff[] + ).filter((d) => d.local !== d.wiki); + + const linkedItemIssues = [ + diffLinkedItem( + "character", + local.linkedItems?.character?.name ?? null, + wiki.character, + ), + diffLinkedItem( + "ancient", + local.linkedItems?.ancient?.name ?? null, + wiki.ancient, + ), + ].filter((issue): issue is string => issue !== null); + + const differingFields = scalarDiffs.map((d) => d.field); if (linkedItemIssues.length > 0) { differingFields.push("linkedItems"); } @@ -145,17 +122,9 @@ const compareRelic = (local: LocalRelic, wiki: WikiRelic): CompareResult => { return { differingFields, printDetails: () => { - if (localDesc !== wikiDesc) { - console.log(` description local: ${localDesc}`); - console.log(` description wiki: ${wikiDesc}`); - } - if (isUpgradeDiffers) { - console.log(` isUpgrade local: ${local.isUpgrade}`); - console.log(` isUpgrade wiki: ${wiki.isUpgrade}`); - } - if (localFlavorText !== wikiFlavorText) { - console.log(` flavorText local: ${localFlavorText}`); - console.log(` flavorText wiki: ${wikiFlavorText}`); + for (const diff of scalarDiffs) { + console.log(` ${diff.field} local: ${diff.local}`); + console.log(` ${diff.field} wiki: ${diff.wiki}`); } for (const issue of linkedItemIssues) { console.log(` ! ${issue}`); diff --git a/src/games/slaythespire2/wiki/sync-category.ts b/src/games/slaythespire2/wiki/sync-category.ts index b2313f1..fe1b819 100644 --- a/src/games/slaythespire2/wiki/sync-category.ts +++ b/src/games/slaythespire2/wiki/sync-category.ts @@ -21,6 +21,35 @@ type CompareResult = { printDetails: () => void; }; +/** + * Reads a string field from a parsed Lua table, returning `""` if absent or + * non-string. Cuts the repeated `typeof fields.X === "string" ? ... : ""` + * guards out of per-category `normalizeEntry` functions. + */ +const getString = (fields: Record, key: string): string => + typeof fields[key] === "string" ? (fields[key] as string) : ""; + +/** + * Looks up `raw` in `map` and warns (to console) when the value is non-empty + * but unmapped. `label` and `name` are used only for the warn message. + * + * Returns `undefined` for both "no raw input" and "unknown raw input" — call + * sites that need `null` should `?? null` the result. + */ +const resolveMapped = ( + raw: string, + map: Record, + label: string, + name: string, +): T | undefined => { + if (!raw) return undefined; + const value = map[raw]; + if (!value) { + console.warn(` ! unknown ${label} '${raw}' for ${name}`); + } + return value; +}; + type SyncCategoryOptions< TLocal extends { name: string }, TWiki extends { name: string }, @@ -32,79 +61,103 @@ type SyncCategoryOptions< compareItem: (local: TLocal, wiki: TWiki) => CompareResult; }; -const syncWikiCategory = async < - TLocal extends { name: string }, - TWiki extends { name: string }, ->( - opts: SyncCategoryOptions, -): Promise => { - const { wikiUrl, label, localItems, normalizeEntry, compareItem } = opts; +type WikiSyncStats = { + matched: number; + new: number; + stale: number; + diffCounts: Map; +}; +const fetchWikiItems = async ( + wikiUrl: string, + normalizeEntry: (name: string, fields: Record) => TWiki, +): Promise => { console.log(`Fetching ${wikiUrl}\n`); const res = await fetchWithUserAgent(wikiUrl); if (!res.ok) { throw new Error(`Wiki fetch failed: ${res.status} ${res.statusText}`); } - const raw = await res.text(); - - const parsed = parseLuaModule(raw); - const wikiItems: TWiki[] = Object.entries(parsed).map(([name, fields]) => + const parsed = parseLuaModule(await res.text()); + return Object.entries(parsed).map(([name, fields]) => normalizeEntry(name, fields as Record), ); +}; +const reportMatch = ( + local: TLocal, + wiki: TWiki, + compareItem: (local: TLocal, wiki: TWiki) => CompareResult, + diffCounts: Map, +): void => { + const { differingFields, printDetails } = compareItem(local, wiki); + for (const field of differingFields) { + diffCounts.set(field, (diffCounts.get(field) ?? 0) + 1); + } + if (differingFields.length === 0) { + console.log(`✓ matched: ${wiki.name}`); + return; + } console.log( - `\nFetched ${wikiItems.length} ${label} from wiki; local has ${localItems.length}.\n`, + `~ matched (${differingFields.join(", ")} differs): ${wiki.name}`, ); + printDetails(); +}; - const localByName = new Map(localItems.map((p) => [p.name, p] as const)); - const wikiByName = new Map(wikiItems.map((p) => [p.name, p] as const)); +const reportNew = (wiki: TWiki): void => { + console.log(`+ new (wiki only): ${wiki.name}`); + console.log(JSON.stringify(wiki, null, 2)); +}; - let matchedCount = 0; - let newCount = 0; - let staleCount = 0; - const diffCounts = new Map(); +const formatSummary = (stats: WikiSyncStats): string => { + const diffSummary = Array.from(stats.diffCounts.entries()) + .map(([field, count]) => `${count} with ${field} diffs`) + .join(", "); + const diffPart = diffSummary ? ` (${diffSummary})` : ""; + return `Summary: ${stats.matched} matched${diffPart}, ${stats.new} new, ${stats.stale} stale.`; +}; - for (const w of wikiItems) { - const local = localByName.get(w.name); - if (!local) { - newCount++; - console.log(`+ new (wiki only): ${w.name}`); - console.log(`${JSON.stringify(w, null, 2)}`); - continue; - } +const syncWikiCategory = async < + TLocal extends { name: string }, + TWiki extends { name: string }, +>( + opts: SyncCategoryOptions, +): Promise => { + const { wikiUrl, label, localItems, normalizeEntry, compareItem } = opts; - matchedCount++; - const { differingFields, printDetails } = compareItem(local, w); + const wikiItems = await fetchWikiItems(wikiUrl, normalizeEntry); + console.log( + `\nFetched ${wikiItems.length} ${label} from wiki; local has ${localItems.length}.\n`, + ); - for (const field of differingFields) { - diffCounts.set(field, (diffCounts.get(field) ?? 0) + 1); - } + const localByName = new Map(localItems.map((p) => [p.name, p] as const)); + const wikiNames = new Set(wikiItems.map((w) => w.name)); + const stats: WikiSyncStats = { + matched: 0, + new: 0, + stale: 0, + diffCounts: new Map(), + }; - if (differingFields.length === 0) { - console.log(`✓ matched: ${w.name}`); - continue; + for (const w of wikiItems) { + const local = localByName.get(w.name); + if (local) { + stats.matched++; + reportMatch(local, w, compareItem, stats.diffCounts); + } else { + stats.new++; + reportNew(w); } - - console.log(`~ matched (${differingFields.join(", ")} differs): ${w.name}`); - printDetails(); } for (const l of localItems) { - if (!wikiByName.has(l.name)) { - staleCount++; + if (!wikiNames.has(l.name)) { + stats.stale++; console.log(`- stale (local only): ${l.name}`); } } - const diffSummary = Array.from(diffCounts.entries()) - .map(([field, count]) => `${count} with ${field} diffs`) - .join(", "); - const diffPart = diffSummary ? ` (${diffSummary})` : ""; - - console.log( - `\nSummary: ${matchedCount} matched${diffPart}, ${newCount} new, ${staleCount} stale.`, - ); + console.log(`\n${formatSummary(stats)}`); }; export type { CompareResult, SyncCategoryOptions }; -export { syncWikiCategory }; +export { getString, resolveMapped, syncWikiCategory };