@idosgames/mcp 0.1.11 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -34,23 +34,40 @@
34
34
  "name": "iDos Games",
35
35
  "url": "https://idosgames.com"
36
36
  },
37
- "version": "0.1.0"
37
+ "version": "0.1.0",
38
+ "events": {
39
+ "emits": [
40
+ {
41
+ "topic": "idle-rpg:character-upgraded@1",
42
+ "when": "a hero's level went up (after a successful client.character.upgradeCharacterLevel)",
43
+ "payload": {
44
+ "characterId": "string",
45
+ "level": "number"
46
+ }
47
+ }
48
+ ]
49
+ }
38
50
  },
39
51
  "dependencies": {
40
- "@idosgames/core": "0.11.0",
41
- "@idosgames/module-sdk": "0.1.12",
42
- "@idosgames/react": "0.2.4",
43
- "@idosgames/wallet": "0.2.4",
52
+ "@idosgames/core": "0.12.0",
53
+ "@idosgames/module-sdk": "0.2.0",
54
+ "@idosgames/react": "0.2.5",
55
+ "@idosgames/wallet": "0.2.5",
44
56
  "@tanstack/react-query": "5.101.2",
45
57
  "phaser": "4.2.1",
46
58
  "react": "19.2.7",
47
59
  "viem": "2.55.2",
48
60
  "wagmi": "3.7.2"
49
61
  },
62
+ "contentHash": "c395a20365d53fd5aee509d40dfd9dfad5976b822ec12e808e460f0d0cba432d",
50
63
  "files": [
64
+ {
65
+ "path": "chrome.ts",
66
+ "content": "// Which shared chrome the Idle RPG dock draws itself. In a mix with a shared-UI provider (e.g.\n// game-hud) the dock hides its currency bar, wallet and status line — `setup()` asks\n// `ctx.sharedUi.shouldDraw(role)` once and closes over the answer. The roster, character detail and\n// equipment are this module's own UI and always stay.\n\nexport interface IdleChrome {\n /** The wallet panel in the detail column (role \"wallet\"). */\n wallet: boolean;\n /** The currency bar at the top of the dock (role \"currency-bar\"). */\n balances: boolean;\n /** The status line at the bottom of the dock (role \"status\"). */\n status: boolean;\n}\n\n/** Everything drawn — Idle RPG installed alone. */\nexport const FULL_CHROME: IdleChrome = {\n wallet: true,\n balances: true,\n status: true,\n};\n"
67
+ },
51
68
  {
52
69
  "path": "components/CharacterDetail.tsx",
53
- "content": "import { useState, type CSSProperties, type ReactNode } from \"react\";\nimport type { CharacterModel } from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport { useUserState } from \"@idosgames/react\";\nimport { useStatus } from \"@idosgames/react\";\nimport {\n readCharacterConfig,\n resolveAvailability,\n resolveStats,\n computeStatValue,\n computeUpgradeCost,\n} from \"../data/characterConfig\";\n\nfunction formatStat(value: number): string {\n return Number.isInteger(value) ? String(value) : value.toFixed(2);\n}\n\nconst panel: CSSProperties = {\n border: \"1px solid #1e59c8\",\n background: \"#04276b\",\n borderRadius: 12,\n padding: 16,\n minWidth: 240,\n color: \"#fff\",\n};\nconst button: CSSProperties = {\n background: \"#0d66fe\",\n color: \"#fff\",\n border: \"none\",\n borderRadius: 8,\n padding: \"8px 14px\",\n cursor: \"pointer\",\n fontWeight: 600,\n};\nconst statRow: CSSProperties = {\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n gap: 8,\n padding: \"4px 0\",\n};\n\nexport function CharacterDetail({\n characterID,\n}: {\n characterID: string | null;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const { setStatus } = useStatus();\n const [busy, setBusy] = useState(false);\n\n if (!characterID)\n return <div style={panel}>Pick a character to see details.</div>;\n\n const config = readCharacterConfig(client);\n const def = config.Definitions?.[characterID];\n const character: CharacterModel | undefined =\n state?.Character?.Characters?.[characterID];\n const availability = resolveAvailability(def, character !== undefined);\n const name = def?.Identity?.DisplayName ?? characterID;\n const subtitle = [def?.Classification?.RarityID, def?.Classification?.ClassID]\n .filter(Boolean)\n .join(\" · \");\n\n const run = async (\n label: string,\n action: () => Promise<{ ok: boolean; error?: string }>,\n ): Promise<void> => {\n setBusy(true);\n const result = await action();\n setStatus(\n result.ok ? `${label} ✓` : `${label} failed: ${result.error}`,\n result.ok ? \"success\" : \"error\",\n );\n setBusy(false);\n };\n\n const header = (\n <>\n <div style={{ fontWeight: 700, fontSize: 18 }}>{name}</div>\n {subtitle ? (\n <div style={{ opacity: 0.7, fontSize: 12, marginTop: 2 }}>\n {subtitle}\n </div>\n ) : null}\n </>\n );\n\n // Locked: offer unlock.\n if (availability === \"locked\") {\n return (\n <div style={panel}>\n {header}\n <div style={{ opacity: 0.7, margin: \"10px 0\" }}>\n Locked — unlock to add it to your roster.\n </div>\n <button\n type=\"button\"\n style={{ ...button, opacity: busy ? 0.6 : 1 }}\n disabled={busy}\n onClick={() =>\n void run(\"Unlock\", () =>\n client.character.unlockCharacter(characterID),\n )\n }\n >\n Unlock\n </button>\n </div>\n );\n }\n\n // Available (unlocked by default, no model yet): first level-up instantiates it.\n if (availability === \"available\") {\n return (\n <div style={panel}>\n {header}\n <div style={{ opacity: 0.7, margin: \"10px 0\" }}>\n Unlocked by default. Level up to instantiate this character.\n </div>\n <button\n type=\"button\"\n style={{ ...button, opacity: busy ? 0.6 : 1 }}\n disabled={busy}\n onClick={() =>\n void run(\"Level up\", () =>\n client.character.upgradeCharacterLevel(characterID),\n )\n }\n >\n Level up\n </button>\n </div>\n );\n }\n\n // Owned: full controls.\n const statDefs = resolveStats(config, def);\n const statLevels: Record<string, number> = character?.StatLevels ?? {};\n\n return (\n <div style={panel}>\n {header}\n <div style={{ opacity: 0.85, margin: \"8px 0 12px\" }}>\n Level {character?.Level ?? 0} · Power {character?.Power ?? 0}\n </div>\n\n <button\n type=\"button\"\n style={{ ...button, opacity: busy ? 0.6 : 1 }}\n disabled={busy}\n onClick={() =>\n void run(\"Level up\", () =>\n client.character.upgradeCharacterLevel(characterID),\n )\n }\n >\n Upgrade level\n </button>\n\n <div style={{ marginTop: 16, fontWeight: 600, opacity: 0.8 }}>Stats</div>\n {statDefs.length === 0 ? (\n <div style={{ opacity: 0.5, fontSize: 13 }}>no stats configured</div>\n ) : (\n statDefs.map((stat) => {\n const level = statLevels[stat.statID] ?? 0;\n const value = computeStatValue(stat, level);\n const atMax = stat.maxLevel != null && level >= stat.maxLevel;\n const cost = computeUpgradeCost(stat, level + 1);\n return (\n <div key={stat.statID} style={statRow}>\n <span>\n {stat.displayName}: <b>{formatStat(value)}</b>\n <span style={{ opacity: 0.5 }}>\n {\" \"}\n · Lv {level}\n {stat.maxLevel ? `/${stat.maxLevel}` : \"\"}\n </span>\n </span>\n <span style={{ display: \"flex\", alignItems: \"center\", gap: 6 }}>\n {!atMax && cost > 0 ? (\n <span style={{ fontSize: 11, opacity: 0.6 }}>\n {cost} {stat.costCurrency}\n </span>\n ) : null}\n <button\n type=\"button\"\n style={{\n ...button,\n padding: \"2px 10px\",\n background: \"#1e59c8\",\n opacity: busy || atMax ? 0.5 : 1,\n }}\n disabled={busy || atMax}\n onClick={() =>\n void run(`Upgrade ${stat.displayName}`, () =>\n client.character.upgradeStatLevel(\n characterID,\n stat.statID,\n ),\n )\n }\n >\n {atMax ? \"MAX\" : \"+\"}\n </button>\n </span>\n </div>\n );\n })\n )}\n </div>\n );\n}\n"
70
+ "content": "import { useState, type CSSProperties, type ReactNode } from \"react\";\nimport type { CharacterModel } from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport { useUserState } from \"@idosgames/react\";\nimport { useStatus } from \"@idosgames/react\";\nimport {\n readCharacterConfig,\n resolveAvailability,\n resolveStats,\n computeStatValue,\n computeUpgradeCost,\n} from \"../data/characterConfig\";\n\nfunction formatStat(value: number): string {\n return Number.isInteger(value) ? String(value) : value.toFixed(2);\n}\n\nconst panel: CSSProperties = {\n border: \"1px solid #1e59c8\",\n background: \"#04276b\",\n borderRadius: 12,\n padding: 16,\n minWidth: 240,\n color: \"#fff\",\n};\nconst button: CSSProperties = {\n background: \"#0d66fe\",\n color: \"#fff\",\n border: \"none\",\n borderRadius: 8,\n padding: \"8px 14px\",\n cursor: \"pointer\",\n fontWeight: 600,\n};\nconst statRow: CSSProperties = {\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n gap: 8,\n padding: \"4px 0\",\n};\n\nexport function CharacterDetail({\n characterID,\n onUpgraded,\n}: {\n characterID: string | null;\n /** Called after a successful level-up with the character's new level. */\n onUpgraded?: (characterId: string, level: number) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const { setStatus } = useStatus();\n const [busy, setBusy] = useState(false);\n\n if (!characterID)\n return <div style={panel}>Pick a character to see details.</div>;\n\n const config = readCharacterConfig(client);\n const def = config.Definitions?.[characterID];\n const character: CharacterModel | undefined =\n state?.Character?.Characters?.[characterID];\n const availability = resolveAvailability(def, character !== undefined);\n const name = def?.Identity?.DisplayName ?? characterID;\n const subtitle = [def?.Classification?.RarityID, def?.Classification?.ClassID]\n .filter(Boolean)\n .join(\" · \");\n\n const run = async (\n label: string,\n action: () => Promise<{ ok: boolean; error?: string }>,\n onOk?: () => void,\n ): Promise<void> => {\n setBusy(true);\n const result = await action();\n setStatus(\n result.ok ? `${label} ✓` : `${label} failed: ${result.error}`,\n result.ok ? \"success\" : \"error\",\n );\n if (result.ok) onOk?.();\n setBusy(false);\n };\n\n // The level AFTER the upgrade: the call refreshes the player cache, so read it back from there\n // (falling back to \"one more than before\" if the cache has not caught up).\n const announceUpgrade = (): void => {\n const level =\n client.data.user.state?.Character?.Characters?.[characterID]?.Level ??\n (character?.Level ?? 0) + 1;\n onUpgraded?.(characterID, level);\n };\n\n const header = (\n <>\n <div style={{ fontWeight: 700, fontSize: 18 }}>{name}</div>\n {subtitle ? (\n <div style={{ opacity: 0.7, fontSize: 12, marginTop: 2 }}>\n {subtitle}\n </div>\n ) : null}\n </>\n );\n\n // Locked: offer unlock.\n if (availability === \"locked\") {\n return (\n <div style={panel}>\n {header}\n <div style={{ opacity: 0.7, margin: \"10px 0\" }}>\n Locked — unlock to add it to your roster.\n </div>\n <button\n type=\"button\"\n style={{ ...button, opacity: busy ? 0.6 : 1 }}\n disabled={busy}\n onClick={() =>\n void run(\"Unlock\", () =>\n client.character.unlockCharacter(characterID),\n )\n }\n >\n Unlock\n </button>\n </div>\n );\n }\n\n // Available (unlocked by default, no model yet): first level-up instantiates it.\n if (availability === \"available\") {\n return (\n <div style={panel}>\n {header}\n <div style={{ opacity: 0.7, margin: \"10px 0\" }}>\n Unlocked by default. Level up to instantiate this character.\n </div>\n <button\n type=\"button\"\n style={{ ...button, opacity: busy ? 0.6 : 1 }}\n disabled={busy}\n onClick={() =>\n void run(\n \"Level up\",\n () => client.character.upgradeCharacterLevel(characterID),\n announceUpgrade,\n )\n }\n >\n Level up\n </button>\n </div>\n );\n }\n\n // Owned: full controls.\n const statDefs = resolveStats(config, def);\n const statLevels: Record<string, number> = character?.StatLevels ?? {};\n\n return (\n <div style={panel}>\n {header}\n <div style={{ opacity: 0.85, margin: \"8px 0 12px\" }}>\n Level {character?.Level ?? 0} · Power {character?.Power ?? 0}\n </div>\n\n <button\n type=\"button\"\n style={{ ...button, opacity: busy ? 0.6 : 1 }}\n disabled={busy}\n onClick={() =>\n void run(\n \"Level up\",\n () => client.character.upgradeCharacterLevel(characterID),\n announceUpgrade,\n )\n }\n >\n Upgrade level\n </button>\n\n <div style={{ marginTop: 16, fontWeight: 600, opacity: 0.8 }}>Stats</div>\n {statDefs.length === 0 ? (\n <div style={{ opacity: 0.5, fontSize: 13 }}>no stats configured</div>\n ) : (\n statDefs.map((stat) => {\n const level = statLevels[stat.statID] ?? 0;\n const value = computeStatValue(stat, level);\n const atMax = stat.maxLevel != null && level >= stat.maxLevel;\n const cost = computeUpgradeCost(stat, level + 1);\n return (\n <div key={stat.statID} style={statRow}>\n <span>\n {stat.displayName}: <b>{formatStat(value)}</b>\n <span style={{ opacity: 0.5 }}>\n {\" \"}\n · Lv {level}\n {stat.maxLevel ? `/${stat.maxLevel}` : \"\"}\n </span>\n </span>\n <span style={{ display: \"flex\", alignItems: \"center\", gap: 6 }}>\n {!atMax && cost > 0 ? (\n <span style={{ fontSize: 11, opacity: 0.6 }}>\n {cost} {stat.costCurrency}\n </span>\n ) : null}\n <button\n type=\"button\"\n style={{\n ...button,\n padding: \"2px 10px\",\n background: \"#1e59c8\",\n opacity: busy || atMax ? 0.5 : 1,\n }}\n disabled={busy || atMax}\n onClick={() =>\n void run(`Upgrade ${stat.displayName}`, () =>\n client.character.upgradeStatLevel(\n characterID,\n stat.statID,\n ),\n )\n }\n >\n {atMax ? \"MAX\" : \"+\"}\n </button>\n </span>\n </div>\n );\n })\n )}\n </div>\n );\n}\n"
54
71
  },
55
72
  {
56
73
  "path": "components/CharacterRoster.tsx",
@@ -78,23 +95,27 @@
78
95
  },
79
96
  {
80
97
  "path": "data/characterConfig.ts",
81
- "content": "import type { IDosGamesClient, ScalarCurveSpec } from \"@idosgames/core\";\nimport { curveMultiplier, evaluateCurve, roundAmount } from \"@idosgames/core\";\n\n// Lightweight, hand-written views over the (opaque) server `Character` config section.\n// Shapes mirror what the live backend returns (titleID URLV9SUP): per-character definitions +\n// shared Stats/Equipment presets referenced through a Presets binding (merge-by-key semantics —\n// see Core/Presets/Models/PresetBinding.cs on the backend).\n\nexport interface PresetBinding {\n PresetID?: string;\n Remove?: string[];\n}\n\nexport interface EquipmentSlotRule {\n SlotID?: string;\n MinCharacterLevel?: number;\n AllowedItemTags?: string[];\n AllowedRarityIDs?: string[];\n MaxItemLevel?: number;\n MinItemLevel?: number;\n}\n\nexport interface EquipmentPreset {\n Equipment?: { Slots?: Record<string, EquipmentSlotRule> };\n}\n\nexport interface PriceOption {\n Cost?: {\n Standard?: { Entries?: { Amount?: number; CurrencyID?: string }[] };\n };\n}\n\nexport interface StatDefinition {\n StatID?: string;\n DisplayName?: string;\n MaxLevel?: number;\n BaseStatValue?: number;\n /** Growth of the value over the stat's own level; the step is numbered from **0**. */\n ValueCurve?: ScalarCurveSpec;\n /** Cost curve; the step is the TARGET level, numbered from 1. */\n CostCurve?: ScalarCurveSpec;\n /** Ways to pay; key = the option id. Replaced the removed `BaseCostResource`. */\n PriceOptions?: Record<string, PriceOption>;\n}\n\nexport interface StatsPreset {\n Stats?: Record<string, StatDefinition>;\n}\n\nexport interface CharacterPresetBindings {\n Stats?: PresetBinding;\n Levels?: PresetBinding;\n Equipment?: PresetBinding;\n}\n\nexport interface CharacterDefinition {\n CharacterID?: string;\n Presets?: CharacterPresetBindings;\n Equipment?: { Slots?: Record<string, EquipmentSlotRule> };\n Stats?: Record<string, StatDefinition>;\n Classification?: { ClassID?: string; RarityID?: string };\n Identity?: { DisplayName?: string; Description?: string; SortOrder?: number };\n Unlock?: { UnlockedByDefault?: boolean };\n}\n\nexport interface CharacterPresetRegistry {\n Stats?: Record<string, StatsPreset>;\n Equipment?: Record<string, EquipmentPreset>;\n}\n\nexport interface CharacterConfig {\n Definitions?: Record<string, CharacterDefinition>;\n Presets?: CharacterPresetRegistry;\n}\n\nexport function readCharacterConfig(client: IDosGamesClient): CharacterConfig {\n return client.data.config.getSection<CharacterConfig>(\"Character\") ?? {};\n}\n\nexport interface SlotInfo {\n slotID: string;\n minCharacterLevel: number;\n}\n\n/**\n * Equipment slots for a character: preset (via Presets.Equipment) merged with inline\n * Equipment.Slots by SlotID (preset base + inline override/add, Remove drops keys).\n */\nexport function resolveSlots(\n config: CharacterConfig,\n def: CharacterDefinition | undefined,\n): SlotInfo[] {\n if (!def) return [];\n const binding = def.Presets?.Equipment;\n const presetSlots = binding?.PresetID\n ? config.Presets?.Equipment?.[binding.PresetID]?.Equipment?.Slots\n : undefined;\n const slots = mergeByKey(presetSlots, def.Equipment?.Slots, binding?.Remove);\n return Object.entries(slots).map(([slotID, rule]) => ({\n slotID,\n minCharacterLevel: rule.MinCharacterLevel ?? 0,\n }));\n}\n\nexport interface StatInfo {\n statID: string;\n displayName: string;\n maxLevel?: number;\n baseValue: number;\n valueCurve?: ScalarCurveSpec;\n costCurve?: ScalarCurveSpec;\n costAmount: number;\n costCurrency?: string;\n}\n\n/**\n * Upgradable stats for a character: preset (via Presets.Stats) merged with inline Stats by\n * StatID (preset base + inline override/add, Remove drops keys).\n */\nexport function resolveStats(\n config: CharacterConfig,\n def: CharacterDefinition | undefined,\n): StatInfo[] {\n if (!def) return [];\n const binding = def.Presets?.Stats;\n const presetStats = binding?.PresetID\n ? config.Presets?.Stats?.[binding.PresetID]?.Stats\n : undefined;\n const stats = mergeByKey(presetStats, def.Stats, binding?.Remove);\n return Object.entries(stats).map(([statID, rule]) => {\n // The first option's first entry, mirroring the server's \"default cost\" for shop-like\n // displays (`PriceOptionSelector.DefaultCost`): the player has not chosen a way to pay yet.\n const option = Object.values(rule.PriceOptions ?? {})[0];\n const entry = option?.Cost?.Standard?.Entries?.[0];\n return {\n statID,\n displayName: rule.DisplayName ?? statID,\n maxLevel: rule.MaxLevel,\n baseValue: rule.BaseStatValue ?? 0,\n valueCurve: rule.ValueCurve,\n costCurve: rule.CostCurve,\n costAmount: entry?.Amount ?? 0,\n costCurrency: entry?.CurrencyID,\n };\n });\n}\n\n/** Preset base + inline override/add by key, Remove drops keys. No preset -> inline as-is. */\nfunction mergeByKey<T>(\n preset: Record<string, T> | undefined,\n inline: Record<string, T> | undefined,\n remove: string[] | undefined,\n): Record<string, T> {\n if (!preset) return inline ?? {};\n const result: Record<string, T> = { ...preset, ...inline };\n for (const key of remove ?? []) delete result[key];\n return result;\n}\n\n/**\n * Effective stat value at a given level — the shared curve, evaluated exactly as the\n * server does it in `PvPBattleEngine.CalculateStats`.\n *\n * The step is the stat level with **firstStep = 0**: a stat the player never upgraded is\n * level 0 and is worth the plain base. This used to be a third hand-written copy of the\n * formula here, and it was wrong in two ways at once — it assumed the multiplicative\n * shape (the server's is whatever the publisher configured) and counted from level 1, so\n * every stat was displayed one step behind what the battle actually used.\n */\nexport function computeStatValue(stat: StatInfo, level: number): number {\n return evaluateCurve(stat.valueCurve, stat.baseValue, level, 0);\n}\n\n/**\n * Cost to upgrade a stat TO `nextLevel`. The step is the TARGET level, numbered from 1,\n * and the total is rounded UP once — the platform's single rounding convention. The old\n * `Math.round` here disagreed with the server by one unit on half the levels.\n */\nexport function computeUpgradeCost(stat: StatInfo, nextLevel: number): number {\n if (stat.costAmount <= 0) return 0;\n return roundAmount(\n stat.costAmount * curveMultiplier(stat.costCurve, nextLevel, 1),\n );\n}\n\nexport type Availability = \"owned\" | \"available\" | \"locked\";\n\n/** owned = server has a CharacterModel; available = unlocked-by-default but not yet instantiated\n * (first level-up creates the model); locked = must be unlocked. */\nexport function resolveAvailability(\n def: CharacterDefinition | undefined,\n hasModel: boolean,\n): Availability {\n if (hasModel) return \"owned\";\n if (def?.Unlock?.UnlockedByDefault) return \"available\";\n return \"locked\";\n}\n"
98
+ "content": "import type { IDosGamesClient, ScalarCurveSpec } from \"@idosgames/core\";\nimport { curveMultiplier, evaluateCurve, roundAmount } from \"@idosgames/core\";\n\n// Lightweight, hand-written views over the (opaque) server `Character` config section.\n// Shapes mirror what the live backend returns (titleID URLV9SUP): per-character definitions +\n// shared Stats/Equipment presets referenced through a Presets binding (merge-by-key semantics —\n// see Core/Presets/Models/PresetBinding.cs on the backend).\n\nexport interface PresetBinding {\n PresetID?: string;\n Remove?: string[];\n}\n\nexport interface EquipmentSlotRule {\n SlotID?: string;\n MinCharacterLevel?: number;\n AllowedItemTags?: string[];\n AllowedRarityIDs?: string[];\n MaxItemLevel?: number;\n MinItemLevel?: number;\n}\n\nexport interface EquipmentPreset {\n Equipment?: { Slots?: Record<string, EquipmentSlotRule> };\n}\n\nexport interface PriceOption {\n Cost?: {\n Standard?: { Entries?: { Amount?: number; CurrencyID?: string }[] };\n };\n}\n\nexport interface StatDefinition {\n StatID?: string;\n DisplayName?: string;\n MaxLevel?: number;\n BaseStatValue?: number;\n /** Growth of the value over the stat's own level; the step is numbered from **0**. */\n ValueCurve?: ScalarCurveSpec;\n /** Cost curve; the step is the TARGET level, numbered from 1. */\n CostCurve?: ScalarCurveSpec;\n /** Ways to pay; key = the option id. Replaced the removed `BaseCostResource`. */\n PriceOptions?: Record<string, PriceOption>;\n}\n\nexport interface StatsPreset {\n Stats?: Record<string, StatDefinition>;\n}\n\nexport interface CharacterPresetBindings {\n Stats?: PresetBinding;\n Levels?: PresetBinding;\n Equipment?: PresetBinding;\n}\n\nexport interface CharacterDefinition {\n CharacterID?: string;\n Presets?: CharacterPresetBindings;\n Equipment?: { Slots?: Record<string, EquipmentSlotRule> };\n Stats?: Record<string, StatDefinition>;\n Classification?: { ClassID?: string; RarityID?: string };\n Identity?: { DisplayName?: string; Description?: string; SortOrder?: number };\n Unlock?: { UnlockedByDefault?: boolean };\n /**\n * Skins (alternative looks). Each skin IS a catalog item; wearing it binds a copy to the\n * reserved equipment key \"@skin\" (`SKIN_SLOT` in @idosgames/core). Full shape: CharacterSkins.\n */\n Skins?: {\n DefaultSkinID?: string;\n Definitions?: Record<\n string,\n {\n SkinID?: string;\n Identity?: {\n DisplayName?: string;\n Description?: string;\n SortOrder?: number;\n AssetPaths?: Record<string, string>;\n };\n Item?: { CatalogID?: string; ItemID?: string };\n Requirements?: { MinCharacterLevel?: number };\n }\n >;\n };\n}\n\nexport interface CharacterPresetRegistry {\n Stats?: Record<string, StatsPreset>;\n Equipment?: Record<string, EquipmentPreset>;\n}\n\nexport interface CharacterConfig {\n Definitions?: Record<string, CharacterDefinition>;\n Presets?: CharacterPresetRegistry;\n}\n\nexport function readCharacterConfig(client: IDosGamesClient): CharacterConfig {\n return client.data.config.getSection<CharacterConfig>(\"Character\") ?? {};\n}\n\nexport interface SlotInfo {\n slotID: string;\n minCharacterLevel: number;\n}\n\n/**\n * Equipment slots for a character: preset (via Presets.Equipment) merged with inline\n * Equipment.Slots by SlotID (preset base + inline override/add, Remove drops keys).\n */\nexport function resolveSlots(\n config: CharacterConfig,\n def: CharacterDefinition | undefined,\n): SlotInfo[] {\n if (!def) return [];\n const binding = def.Presets?.Equipment;\n const presetSlots = binding?.PresetID\n ? config.Presets?.Equipment?.[binding.PresetID]?.Equipment?.Slots\n : undefined;\n const slots = mergeByKey(presetSlots, def.Equipment?.Slots, binding?.Remove);\n return (\n Object.entries(slots)\n // \"@…\" keys are engine-reserved (the worn skin lives under \"@skin\"); the server refuses\n // gear in them, so a slot declared that way by mistake must not render as a gear slot.\n .filter(([slotID]) => !slotID.startsWith(\"@\"))\n .map(([slotID, rule]) => ({\n slotID,\n minCharacterLevel: rule.MinCharacterLevel ?? 0,\n }))\n );\n}\n\nexport interface StatInfo {\n statID: string;\n displayName: string;\n maxLevel?: number;\n baseValue: number;\n valueCurve?: ScalarCurveSpec;\n costCurve?: ScalarCurveSpec;\n costAmount: number;\n costCurrency?: string;\n}\n\n/**\n * Upgradable stats for a character: preset (via Presets.Stats) merged with inline Stats by\n * StatID (preset base + inline override/add, Remove drops keys).\n */\nexport function resolveStats(\n config: CharacterConfig,\n def: CharacterDefinition | undefined,\n): StatInfo[] {\n if (!def) return [];\n const binding = def.Presets?.Stats;\n const presetStats = binding?.PresetID\n ? config.Presets?.Stats?.[binding.PresetID]?.Stats\n : undefined;\n const stats = mergeByKey(presetStats, def.Stats, binding?.Remove);\n return Object.entries(stats).map(([statID, rule]) => {\n // The first option's first entry, mirroring the server's \"default cost\" for shop-like\n // displays (`PriceOptionSelector.DefaultCost`): the player has not chosen a way to pay yet.\n const option = Object.values(rule.PriceOptions ?? {})[0];\n const entry = option?.Cost?.Standard?.Entries?.[0];\n return {\n statID,\n displayName: rule.DisplayName ?? statID,\n maxLevel: rule.MaxLevel,\n baseValue: rule.BaseStatValue ?? 0,\n valueCurve: rule.ValueCurve,\n costCurve: rule.CostCurve,\n costAmount: entry?.Amount ?? 0,\n costCurrency: entry?.CurrencyID,\n };\n });\n}\n\n/** Preset base + inline override/add by key, Remove drops keys. No preset -> inline as-is. */\nfunction mergeByKey<T>(\n preset: Record<string, T> | undefined,\n inline: Record<string, T> | undefined,\n remove: string[] | undefined,\n): Record<string, T> {\n if (!preset) return inline ?? {};\n const result: Record<string, T> = { ...preset, ...inline };\n for (const key of remove ?? []) delete result[key];\n return result;\n}\n\n/**\n * Effective stat value at a given level — the shared curve, evaluated exactly as the\n * server does it in `PvPBattleEngine.CalculateStats`.\n *\n * The step is the stat level with **firstStep = 0**: a stat the player never upgraded is\n * level 0 and is worth the plain base. This used to be a third hand-written copy of the\n * formula here, and it was wrong in two ways at once — it assumed the multiplicative\n * shape (the server's is whatever the publisher configured) and counted from level 1, so\n * every stat was displayed one step behind what the battle actually used.\n */\nexport function computeStatValue(stat: StatInfo, level: number): number {\n return evaluateCurve(stat.valueCurve, stat.baseValue, level, 0);\n}\n\n/**\n * Cost to upgrade a stat TO `nextLevel`. The step is the TARGET level, numbered from 1,\n * and the total is rounded UP once — the platform's single rounding convention. The old\n * `Math.round` here disagreed with the server by one unit on half the levels.\n */\nexport function computeUpgradeCost(stat: StatInfo, nextLevel: number): number {\n if (stat.costAmount <= 0) return 0;\n return roundAmount(\n stat.costAmount * curveMultiplier(stat.costCurve, nextLevel, 1),\n );\n}\n\nexport type Availability = \"owned\" | \"available\" | \"locked\";\n\n/** owned = server has a CharacterModel; available = unlocked-by-default but not yet instantiated\n * (first level-up creates the model); locked = must be unlocked. */\nexport function resolveAvailability(\n def: CharacterDefinition | undefined,\n hasModel: boolean,\n): Availability {\n if (hasModel) return \"owned\";\n if (def?.Unlock?.UnlockedByDefault) return \"available\";\n return \"locked\";\n}\n"
82
99
  },
83
100
  {
84
101
  "path": "env.ts",
85
102
  "content": "// Runtime config the module reads from the page it runs in: the __IDOS_ENV__ global the host's\n// bundler defines (see the host template's vite.config). A module copied into a project runs in\n// that same page, so it is available; when absent (e.g. the classic preview bundler), the default\n// applies.\n//\n// The TITLE is deliberately NOT here. It comes from the host — `client.titleID`, or `ctx.titleId`\n// in a module's setup(). A module that re-derived the title from the URL could disagree with its\n// host, which is how a build ends up reading one title's data and writing another's.\n\ntype IdosEnv = {\n WALLETCONNECT_PROJECT_ID?: string;\n};\n\ndeclare const __IDOS_ENV__: IdosEnv | undefined;\n\nconst env: IdosEnv =\n typeof __IDOS_ENV__ !== \"undefined\" && __IDOS_ENV__ ? __IDOS_ENV__ : {};\n\nexport const ENV_WALLETCONNECT_PROJECT_ID = env.WALLETCONNECT_PROJECT_ID ?? \"\";\n"
86
103
  },
104
+ {
105
+ "path": "events.ts",
106
+ "content": "import { defineTopic, shape } from \"@idosgames/module-sdk\";\n\n// Topics this module EMITS. Declared in module.meta.json (`events.emits`) with the same descriptor,\n// so other modules can copy it from the catalog — they never import this file.\n\n/** A hero's level went up (after a successful `client.character.upgradeCharacterLevel`). */\nexport const characterUpgraded = defineTopic(\n \"idle-rpg:character-upgraded@1\",\n shape({ characterId: \"string\", level: \"number\" }),\n);\n"
107
+ },
87
108
  {
88
109
  "path": "game/IdleRpgController.ts",
89
110
  "content": "import Phaser from \"phaser\";\nimport { IdleRpgScene, type CharacterDisplay } from \"../phaser/IdleRpgScene\";\n\n/** Owns the Phaser game instance and bridges React → scene (selecting a character). */\nexport class IdleRpgController {\n private readonly game: Phaser.Game;\n private readonly scene: IdleRpgScene;\n\n constructor(parent: HTMLElement) {\n this.scene = new IdleRpgScene();\n this.game = new Phaser.Game({\n type: Phaser.AUTO,\n parent,\n width: 480,\n height: 640,\n backgroundColor: \"#063d99\",\n scene: this.scene,\n scale: {\n // Hug the left of the full-bleed host so the host's UI panel can dock on the right without\n // covering the character card.\n mode: Phaser.Scale.FIT,\n autoCenter: Phaser.Scale.CENTER_VERTICALLY,\n },\n });\n\n // Makes the game observable in the AI Coder's live preview, which reads this exact global.\n //\n // Phaser sets it itself — but only in its debug build (`Game.boot` guards it behind\n // WEBGL_DEBUG), and the release build shipped by npm has that branch stripped. So a Phaser game\n // is invisible to any outside observer unless it hands itself over, which is this one line.\n // Verified live: without it the preview reports no engine; with it, scenes, object counts,\n // camera and loop fps all come through.\n (globalThis as unknown as Record<string, unknown>).PHASER_GAME = this.game;\n }\n\n selectCharacter(display: CharacterDisplay): void {\n this.scene.setSelected(display);\n this.selected = display;\n }\n\n /** Последний выбранный персонаж — единственное, что сцена показывает и чего нет в DOM. */\n private selected: CharacterDisplay | null = null;\n\n /**\n * Что сейчас на канвасе. Читается поверхностью для агента (`module.ts`): у отрисованной сцены\n * нет DOM, и без этого агент не может сказать, дошёл ли выбор персонажа до Phaser.\n */\n describeScene(): { running: boolean; selected: CharacterDisplay | null } {\n return { running: this.game.loop.running, selected: this.selected };\n }\n\n /** Pause/resume the Phaser RAF loop. The host calls this so a suspended mode stops ticking\n * (the Mode Router invariant: only the active mode runs). */\n setRunning(running: boolean): void {\n if (running) this.game.loop.wake();\n else this.game.loop.sleep();\n }\n\n destroy(): void {\n this.game.destroy(true);\n // Drop the debug global with the game itself: the Mode Router destroys a suspended mode, and a\n // stale reference here would show an observer a game that no longer exists.\n const globals = globalThis as unknown as Record<string, unknown>;\n if (globals[\"PHASER_GAME\"] === this.game) delete globals[\"PHASER_GAME\"];\n }\n}\n"
90
111
  },
91
112
  {
92
113
  "path": "index.ts",
93
- "content": "// @idosgames/mod-idle-rpg — the Idle RPG feature module (Phaser + React panels) for the host shell.\n//\n// Primary export is the module manifest; the rest is exposed so a project can recompose the pieces\n// (swap the UI, reuse the roster, drive the scene differently) after copying this into src/modules/.\n\nexport { idleRpgModule } from \"./module\";\n\nexport { IdleRpgController } from \"./game/IdleRpgController\";\nexport { IdleRpgScene, type CharacterDisplay } from \"./phaser/IdleRpgScene\";\nexport { createIdleRpgScene } from \"./scene\";\nexport { makeIdleRpgRootPanel } from \"./RootPanel\";\nexport {\n IdleRpgControllerProvider,\n useIdleRpgController,\n} from \"./react/controller\";\n\nexport { CurrencyHud } from \"./components/CurrencyHud\";\nexport {\n CharacterRoster,\n useRoster,\n rosterStatusLine,\n type RosterEntry,\n} from \"./components/CharacterRoster\";\nexport { CharacterDetail } from \"./components/CharacterDetail\";\nexport { EquipmentPanel } from \"./components/EquipmentPanel\";\nexport { StatusBar } from \"./components/StatusBar\";\nexport { WalletPanel } from \"./components/WalletPanel\";\n\nexport {\n readCharacterConfig,\n resolveSlots,\n resolveStats,\n resolveAvailability,\n computeStatValue,\n computeUpgradeCost,\n type CharacterConfig,\n type CharacterDefinition,\n type Availability,\n} from \"./data/characterConfig\";\n"
114
+ "content": "// @idosgames/mod-idle-rpg — the Idle RPG feature module (Phaser + React panels) for the host shell.\n//\n// Primary export is the module manifest; the rest is exposed so a project can recompose the pieces\n// (swap the UI, reuse the roster, drive the scene differently) after copying this into src/modules/.\n\nexport { idleRpgModule } from \"./module\";\n\nexport { IdleRpgController } from \"./game/IdleRpgController\";\nexport { IdleRpgScene, type CharacterDisplay } from \"./phaser/IdleRpgScene\";\nexport { createIdleRpgScene } from \"./scene\";\nexport { makeIdleRpgRootPanel, type IdleRpgPanelOptions } from \"./RootPanel\";\nexport { FULL_CHROME, type IdleChrome } from \"./chrome\";\nexport { characterUpgraded } from \"./events\";\nexport {\n IdleRpgControllerProvider,\n useIdleRpgController,\n} from \"./react/controller\";\n\nexport { CurrencyHud } from \"./components/CurrencyHud\";\nexport {\n CharacterRoster,\n useRoster,\n rosterStatusLine,\n type RosterEntry,\n} from \"./components/CharacterRoster\";\nexport { CharacterDetail } from \"./components/CharacterDetail\";\nexport { EquipmentPanel } from \"./components/EquipmentPanel\";\nexport { StatusBar } from \"./components/StatusBar\";\nexport { WalletPanel } from \"./components/WalletPanel\";\n\nexport {\n readCharacterConfig,\n resolveSlots,\n resolveStats,\n resolveAvailability,\n computeStatValue,\n computeUpgradeCost,\n type CharacterConfig,\n type CharacterDefinition,\n type Availability,\n} from \"./data/characterConfig\";\n"
94
115
  },
95
116
  {
96
117
  "path": "module.ts",
97
- "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { IdleRpgController } from \"./game/IdleRpgController\";\nimport { createControllerBox } from \"./controller-box\";\nimport { createIdleRpgScene } from \"./scene\";\nimport { makeIdleRpgRootPanel } from \"./RootPanel\";\n\n// The Idle RPG feature module. `setup` is called once by the host: it wires a Phaser scene and the\n// module's UI panel to one shared controller, and registers a nav entry so the player can enter this\n// mode. No app shell, no client creation, no login — the host owns all of that.\nexport const idleRpgModule: Module = defineModule({\n id: \"idle-rpg\",\n meta: {\n name: \"Idle RPG\",\n type: \"game\",\n genre: \"idle-rpg\",\n engine: \"phaser\",\n },\n setup(ctx) {\n const box = createControllerBox<IdleRpgController>();\n ctx.registerScene(createIdleRpgScene(box));\n ctx.registerPanel({\n id: \"root\",\n slot: \"overlay\",\n component: makeIdleRpgRootPanel(box),\n });\n ctx.registerRoute({ id: \"idle-rpg\", label: \"Idle RPG\", icon: \"⚔️\" });\n\n // Тонкая поверхность для агента — по той же причине, что и в board-game: интерфейс модуля\n // живёт в DOM, агент читает и нажимает его сам. Из Phaser'а ему не видно только то, что\n // рисует сцена.\n ctx.exposeToAgent({\n state: () => {\n const controller = box.get();\n return controller\n ? { mounted: true, scene: controller.describeScene(), ui: \"dom\" }\n : { mounted: false };\n },\n describeActions: {},\n });\n },\n});\n"
118
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { IdleRpgController } from \"./game/IdleRpgController\";\nimport { createControllerBox } from \"./controller-box\";\nimport { createIdleRpgScene } from \"./scene\";\nimport { makeIdleRpgRootPanel } from \"./RootPanel\";\nimport type { IdleChrome } from \"./chrome\";\nimport { characterUpgraded } from \"./events\";\n\n// The Idle RPG feature module. `setup` is called once by the host: it wires a Phaser scene and the\n// module's UI panel to one shared controller, and registers a nav entry so the player can enter this\n// mode. No app shell, no client creation, no login — the host owns all of that.\nexport const idleRpgModule: Module = defineModule({\n id: \"idle-rpg\",\n meta: {\n name: \"Idle RPG\",\n type: \"game\",\n genre: \"idle-rpg\",\n engine: \"phaser\",\n },\n setup(ctx) {\n const box = createControllerBox<IdleRpgController>();\n\n // Shared chrome: draw our own copy only while no other module (e.g. game-hud) took the role.\n const chrome: IdleChrome = {\n wallet: ctx.sharedUi.shouldDraw(\"wallet\"),\n balances: ctx.sharedUi.shouldDraw(\"currency-bar\"),\n status: ctx.sharedUi.shouldDraw(\"status\"),\n };\n\n ctx.registerScene(createIdleRpgScene(box));\n ctx.registerPanel({\n id: \"root\",\n slot: \"overlay\",\n component: makeIdleRpgRootPanel(box, {\n chrome,\n // A signal for other modules (declared in module.meta.json → events.emits). The panel gets a\n // plain callback, so UI code never touches the bus directly.\n onCharacterUpgraded: (characterId, level) =>\n ctx.events.emit(characterUpgraded, { characterId, level }),\n }),\n });\n ctx.registerRoute({ id: \"idle-rpg\", label: \"Idle RPG\", icon: \"⚔️\" });\n\n // Тонкая поверхность для агента — по той же причине, что и в board-game: интерфейс модуля\n // живёт в DOM, агент читает и нажимает его сам. Из Phaser'а ему не видно только то, что\n // рисует сцена.\n ctx.exposeToAgent({\n state: () => {\n const controller = box.get();\n return controller\n ? {\n mounted: true,\n scene: controller.describeScene(),\n ui: \"dom\",\n chrome,\n }\n : { mounted: false, chrome };\n },\n describeActions: {},\n });\n },\n});\n"
98
119
  },
99
120
  {
100
121
  "path": "phaser/IdleRpgScene.ts",
@@ -106,11 +127,15 @@
106
127
  },
107
128
  {
108
129
  "path": "RootPanel.tsx",
109
- "content": "import {\n useState,\n useSyncExternalStore,\n type CSSProperties,\n type ComponentType,\n type ReactNode,\n} from \"react\";\nimport {\n IdleRpgControllerProvider,\n useIdleRpgController,\n} from \"./react/controller\";\nimport type { ControllerBox } from \"./controller-box\";\nimport type { IdleRpgController } from \"./game/IdleRpgController\";\nimport { CurrencyHud } from \"./components/CurrencyHud\";\nimport {\n CharacterRoster,\n rosterStatusLine,\n type RosterEntry,\n} from \"./components/CharacterRoster\";\nimport { CharacterDetail } from \"./components/CharacterDetail\";\nimport { EquipmentPanel } from \"./components/EquipmentPanel\";\nimport { StatusBar } from \"./components/StatusBar\";\nimport { WalletPanel } from \"./components/WalletPanel\";\n\n// The module's whole UI as a single overlay panel. The host already provides the client + status\n// contexts (mountHost wraps the tree); this panel adds the module's own controller context once the\n// Phaser scene has published its controller into the shared box. It docks to the right so the\n// full-bleed Phaser character card stays visible on the left.\n\n/** Builds the panel component bound to the controller published by this module's scene. */\nexport function makeIdleRpgRootPanel(\n box: ControllerBox<IdleRpgController>,\n): ComponentType {\n return function IdleRpgRootPanel(): ReactNode {\n const controller = useSyncExternalStore(box.subscribe, box.get);\n if (!controller) {\n return <div style={loadingStyle}>Loading RPG…</div>;\n }\n return (\n <IdleRpgControllerProvider controller={controller}>\n <Layout />\n </IdleRpgControllerProvider>\n );\n };\n}\n\nfunction Layout(): ReactNode {\n const controller = useIdleRpgController();\n const [selected, setSelected] = useState<string | null>(null);\n\n const onSelect = (entry: RosterEntry): void => {\n setSelected(entry.id);\n controller.selectCharacter({\n name: entry.name,\n rarity: entry.rarity,\n className: entry.className,\n statusLine: rosterStatusLine(entry),\n owned: entry.availability === \"owned\",\n });\n };\n\n return (\n <div style={dockStyle}>\n <CurrencyHud />\n <div style={columnsStyle}>\n <div style={{ flex: 1, minWidth: 0 }}>\n <CharacterRoster selectedID={selected} onSelect={onSelect} />\n </div>\n <div style={detailColumnStyle}>\n <CharacterDetail characterID={selected} />\n <EquipmentPanel characterID={selected} />\n <WalletPanel />\n </div>\n </div>\n <StatusBar />\n </div>\n );\n}\n\nconst dockStyle: CSSProperties = {\n position: \"absolute\",\n top: 0,\n right: 0,\n bottom: 0,\n width: \"min(560px, 62%)\",\n boxSizing: \"border-box\",\n padding: 16,\n overflow: \"auto\",\n color: \"#fff\",\n fontFamily: \"system-ui, sans-serif\",\n background: \"rgba(4,32,90,0.92)\",\n borderLeft: \"1px solid #1a4fb8\",\n};\nconst columnsStyle: CSSProperties = {\n display: \"flex\",\n gap: 16,\n alignItems: \"flex-start\",\n marginTop: 8,\n};\nconst detailColumnStyle: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 12,\n width: 280,\n flex: \"0 0 280px\",\n};\nconst loadingStyle: CSSProperties = {\n position: \"absolute\",\n top: 16,\n right: 16,\n opacity: 0.6,\n color: \"#fff\",\n fontFamily: \"system-ui, sans-serif\",\n};\n"
130
+ "content": "import {\n useState,\n useSyncExternalStore,\n type CSSProperties,\n type ComponentType,\n type ReactNode,\n} from \"react\";\nimport {\n IdleRpgControllerProvider,\n useIdleRpgController,\n} from \"./react/controller\";\nimport type { ControllerBox } from \"./controller-box\";\nimport type { IdleRpgController } from \"./game/IdleRpgController\";\nimport { CurrencyHud } from \"./components/CurrencyHud\";\nimport {\n CharacterRoster,\n rosterStatusLine,\n type RosterEntry,\n} from \"./components/CharacterRoster\";\nimport { CharacterDetail } from \"./components/CharacterDetail\";\nimport { EquipmentPanel } from \"./components/EquipmentPanel\";\nimport { StatusBar } from \"./components/StatusBar\";\nimport { WalletPanel } from \"./components/WalletPanel\";\nimport { FULL_CHROME, type IdleChrome } from \"./chrome\";\n\n// The module's whole UI as a single overlay panel. The host already provides the client + status\n// contexts (mountHost wraps the tree); this panel adds the module's own controller context once the\n// Phaser scene has published its controller into the shared box. It docks to the right so the\n// full-bleed Phaser character card stays visible on the left.\n\nexport interface IdleRpgPanelOptions {\n /** Which shared chrome to draw in the dock (the rest is drawn by a shared-UI provider). */\n chrome?: IdleChrome;\n /** Called after a successful level-up — `setup()` turns it into an event for other modules. */\n onCharacterUpgraded?: (characterId: string, level: number) => void;\n}\n\n/** Builds the panel component bound to the controller published by this module's scene. */\nexport function makeIdleRpgRootPanel(\n box: ControllerBox<IdleRpgController>,\n options: IdleRpgPanelOptions = {},\n): ComponentType {\n const chrome = options.chrome ?? FULL_CHROME;\n return function IdleRpgRootPanel(): ReactNode {\n const controller = useSyncExternalStore(box.subscribe, box.get);\n if (!controller) {\n return <div style={loadingStyle}>Loading RPG…</div>;\n }\n return (\n <IdleRpgControllerProvider controller={controller}>\n <Layout\n chrome={chrome}\n onCharacterUpgraded={options.onCharacterUpgraded}\n />\n </IdleRpgControllerProvider>\n );\n };\n}\n\nfunction Layout({\n chrome,\n onCharacterUpgraded,\n}: {\n chrome: IdleChrome;\n onCharacterUpgraded?: (characterId: string, level: number) => void;\n}): ReactNode {\n const controller = useIdleRpgController();\n const [selected, setSelected] = useState<string | null>(null);\n\n const onSelect = (entry: RosterEntry): void => {\n setSelected(entry.id);\n controller.selectCharacter({\n name: entry.name,\n rarity: entry.rarity,\n className: entry.className,\n statusLine: rosterStatusLine(entry),\n owned: entry.availability === \"owned\",\n });\n };\n\n return (\n <div style={dockStyle}>\n {chrome.balances && <CurrencyHud />}\n <div style={columnsStyle}>\n <div style={{ flex: 1, minWidth: 0 }}>\n <CharacterRoster selectedID={selected} onSelect={onSelect} />\n </div>\n <div style={detailColumnStyle}>\n <CharacterDetail\n characterID={selected}\n onUpgraded={onCharacterUpgraded}\n />\n <EquipmentPanel characterID={selected} />\n {chrome.wallet && <WalletPanel />}\n </div>\n </div>\n {chrome.status && <StatusBar />}\n </div>\n );\n}\n\nconst dockStyle: CSSProperties = {\n position: \"absolute\",\n top: 0,\n right: 0,\n bottom: 0,\n width: \"min(560px, 62%)\",\n boxSizing: \"border-box\",\n padding: 16,\n overflow: \"auto\",\n color: \"#fff\",\n fontFamily: \"system-ui, sans-serif\",\n background: \"rgba(4,32,90,0.92)\",\n borderLeft: \"1px solid #1a4fb8\",\n};\nconst columnsStyle: CSSProperties = {\n display: \"flex\",\n gap: 16,\n alignItems: \"flex-start\",\n marginTop: 8,\n};\nconst detailColumnStyle: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 12,\n width: 280,\n flex: \"0 0 280px\",\n};\nconst loadingStyle: CSSProperties = {\n position: \"absolute\",\n top: 16,\n right: 16,\n opacity: 0.6,\n color: \"#fff\",\n fontFamily: \"system-ui, sans-serif\",\n};\n"
110
131
  },
111
132
  {
112
133
  "path": "scene.ts",
113
134
  "content": "import type { EngineScene, SceneMountContext } from \"@idosgames/module-sdk\";\nimport { IdleRpgController } from \"./game/IdleRpgController\";\nimport type { ControllerBox } from \"./controller-box\";\n\n// Wraps the Phaser controller as a host-driven EngineScene. Created lazily on mount (Phaser needs a\n// parent element), published into the shared box for the React panel, and paused/resumed by the\n// Mode Router via activate/suspend so a hidden mode stops ticking.\nexport function createIdleRpgScene(\n box: ControllerBox<IdleRpgController>,\n): EngineScene {\n let controller: IdleRpgController | null = null;\n return {\n surface: \"fullbleed-canvas\",\n mount(ctx: SceneMountContext): void {\n controller = new IdleRpgController(ctx.host);\n box.set(controller);\n },\n activate(): void {\n controller?.setRunning(true);\n },\n suspend(): void {\n controller?.setRunning(false);\n },\n destroy(): void {\n box.set(null);\n controller?.destroy();\n controller = null;\n },\n };\n}\n"
135
+ },
136
+ {
137
+ "path": "module.meta.json",
138
+ "content": "{\n \"id\": \"idle-rpg\",\n \"type\": \"template\",\n \"summary\": \"Idle RPG: heroes auto-battle and earn resources over time, with upgrades and offline progress.\",\n \"description\": \"An incremental idle RPG built on Phaser. Heroes fight automatically, generate currency and loot over time, and keep progressing while the player is away. Includes an upgrade loop and offline-income accrual, so it fits any 'tap to grow / auto-battler / idle economy' request.\",\n \"provides\": [\n \"auto-battling heroes\",\n \"idle resource generation over time\",\n \"offline income accrual\",\n \"upgrade / progression loop\",\n \"2D sprite rendering (Phaser)\"\n ],\n \"tags\": [\"idle\", \"rpg\", \"incremental\", \"auto-battler\", \"phaser\", \"2d\"],\n \"media\": {\n \"image\": \"https://cloud.idosgames.com/drive/modules/idle-rpg/cover.png\",\n \"video\": \"https://cloud.idosgames.com/drive/modules/idle-rpg/demo.mp4\"\n },\n \"demoUrl\": \"https://cloud.idosgames.com/drive/modules/idle-rpg/demo/\",\n \"author\": { \"name\": \"iDos Games\", \"url\": \"https://idosgames.com\" },\n \"version\": \"0.1.0\",\n \"events\": {\n \"emits\": [\n {\n \"topic\": \"idle-rpg:character-upgraded@1\",\n \"when\": \"a hero's level went up (after a successful client.character.upgradeCharacterLevel)\",\n \"payload\": { \"characterId\": \"string\", \"level\": \"number\" }\n }\n ]\n }\n}\n"
114
139
  }
115
140
  ]
116
141
  }
@@ -37,9 +37,10 @@
37
37
  "version": "0.1.0"
38
38
  },
39
39
  "dependencies": {
40
- "@idosgames/module-sdk": "0.1.12",
40
+ "@idosgames/module-sdk": "0.2.0",
41
41
  "three": "0.185.1"
42
42
  },
43
+ "contentHash": "4af383a9c61787c8e20a3087c15d194ef743989f0e1583bbb0db6584d9c872d2",
43
44
  "files": [
44
45
  {
45
46
  "path": "agent.ts",
@@ -135,7 +136,7 @@
135
136
  },
136
137
  {
137
138
  "path": "style.css",
138
- "content": "/* VoxelCraft HUD/UI. Пиксель-арт: image-rendering: pixelated везде, где иконки. */\n* {\n margin: 0;\n padding: 0;\n box-sizing: border-box;\n}\nhtml,\nbody {\n width: 100%;\n height: 100%;\n overflow: hidden;\n background: #063d99;\n}\nbody {\n font-family: \"Segoe UI\", system-ui, sans-serif;\n user-select: none;\n}\n\n#game {\n position: fixed;\n inset: 0;\n width: 100%;\n height: 100%;\n display: block;\n}\n\n.hidden {\n display: none !important;\n}\n\n/* ---------- HUD ---------- */\n#crosshair {\n position: fixed;\n left: 50%;\n top: 50%;\n width: 18px;\n height: 18px;\n transform: translate(-50%, -50%);\n pointer-events: none;\n z-index: 5;\n}\n#crosshair::before,\n#crosshair::after {\n content: \"\";\n position: absolute;\n background: rgba(255, 255, 255, 0.85);\n mix-blend-mode: difference;\n}\n#crosshair::before {\n left: 8px;\n top: 0;\n width: 2px;\n height: 18px;\n}\n#crosshair::after {\n left: 0;\n top: 8px;\n width: 18px;\n height: 2px;\n}\n\n#fps {\n position: fixed;\n top: 8px;\n right: 10px;\n color: #7fff6a;\n z-index: 5;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n#debug {\n position: fixed;\n top: 8px;\n left: 10px;\n color: #ddd;\n z-index: 5;\n font: 11px monospace;\n text-shadow: 1px 1px 0 #000;\n opacity: 0.85;\n}\n#timeIndicator {\n position: fixed;\n top: 26px;\n right: 10px;\n color: #ffe9a0;\n z-index: 5;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n\n#hotbar {\n position: fixed;\n left: 50%;\n bottom: 8px;\n transform: translateX(-50%);\n display: flex;\n gap: 3px;\n z-index: 5;\n padding: 3px;\n background: rgba(0, 0, 0, 0.45);\n border: 2px solid #222;\n border-radius: 4px;\n}\n.slot {\n width: 44px;\n height: 44px;\n position: relative;\n background: rgba(120, 120, 120, 0.35);\n border: 2px solid #555;\n}\n.slot img {\n width: 100%;\n height: 100%;\n image-rendering: pixelated;\n pointer-events: none;\n}\n.slot .count {\n position: absolute;\n right: 2px;\n bottom: 0;\n color: #fff;\n pointer-events: none;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n#hotbar .slot.selected {\n border-color: #fff;\n background: rgba(200, 200, 200, 0.4);\n}\n\n/* сердечки — слева от центра, шкала голода — справа (как в Minecraft) */\n#hearts {\n position: fixed;\n right: calc(50% + 8px);\n bottom: 62px;\n white-space: nowrap;\n z-index: 5;\n font-size: 18px;\n letter-spacing: 2px;\n text-shadow: 1px 1px 0 #000;\n}\n.heart {\n color: #e03c3c;\n}\n.heart.half {\n color: #e03c3c;\n opacity: 0.55;\n}\n.heart.empty {\n color: #3a3a3a;\n}\n\n#hunger {\n position: fixed;\n left: calc(50% + 8px);\n bottom: 62px;\n white-space: nowrap;\n z-index: 5;\n font-size: 18px;\n letter-spacing: 2px;\n text-shadow: 1px 1px 0 #000;\n direction: rtl; /* пустеет справа налево, как в MC */\n}\n.drumstick {\n color: #c98a3c;\n}\n.drumstick.half {\n color: #c98a3c;\n opacity: 0.55;\n}\n.drumstick.empty {\n color: #3a3a3a;\n}\n\n#hurtFlash {\n position: fixed;\n inset: 0;\n pointer-events: none;\n z-index: 4;\n background: radial-gradient(\n ellipse at center,\n transparent 40%,\n rgba(255, 0, 0, 0.5)\n );\n opacity: 0;\n}\n#hurtFlash.show {\n animation: hurt 0.4s ease-out;\n}\n@keyframes hurt {\n 0% {\n opacity: 1;\n }\n 100% {\n opacity: 0;\n }\n}\n\n#message {\n position: fixed;\n left: 50%;\n top: 30%;\n transform: translateX(-50%);\n color: #fff;\n font: bold 18px monospace;\n text-shadow: 2px 2px 0 #000;\n z-index: 6;\n opacity: 0;\n transition: opacity 0.3s;\n pointer-events: none;\n}\n#message.show {\n opacity: 1;\n}\n\n/* ---------- инвентарь ---------- */\n#invScreen {\n position: fixed;\n inset: 0;\n z-index: 10;\n background: rgba(0, 0, 0, 0.5);\n display: flex;\n align-items: center;\n justify-content: center;\n}\n.invPanel {\n background: #c6c6c6;\n border: 3px solid #555;\n border-radius: 4px;\n padding: 14px 16px;\n box-shadow: 0 10px 40px rgba(0, 0, 0, 0.6);\n}\n.invPanel h3 {\n color: #3a3a3a;\n margin-bottom: 8px;\n font-size: 15px;\n}\n.invPanel .slot {\n background: #8b8b8b;\n border: 2px solid;\n border-color: #373737 #fff #fff #373737;\n}\n.craftRow {\n display: flex;\n align-items: center;\n gap: 12px;\n margin-bottom: 12px;\n}\n#craftGrid {\n display: grid;\n gap: 3px;\n}\n.arrow {\n font-size: 26px;\n color: #3a3a3a;\n}\n.slot.result {\n width: 50px;\n height: 50px;\n}\n#invGrid {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n margin-bottom: 10px;\n}\n#invHotbar {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n}\n\n#cursorStack {\n position: fixed;\n z-index: 20;\n width: 40px;\n height: 40px;\n pointer-events: none;\n transform: translate(-50%, -50%);\n}\n#cursorStack img {\n width: 100%;\n height: 100%;\n image-rendering: pixelated;\n}\n#cursorStack .count {\n position: absolute;\n right: 0;\n bottom: -2px;\n color: #fff;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n\n/* ---------- оверлей ---------- */\n#overlay {\n position: fixed;\n inset: 0;\n z-index: 30;\n cursor: pointer;\n background: rgba(4, 32, 90, 0.85);\n display: flex;\n align-items: center;\n justify-content: center;\n}\n#overlay .panel {\n text-align: center;\n color: #eee;\n max-width: 560px;\n padding: 20px;\n}\n#overlay h1 {\n font-size: 44px;\n letter-spacing: 2px;\n margin-bottom: 6px;\n color: #9adf6a;\n text-shadow: 3px 3px 0 #2a4d1a;\n}\n#overlayHint {\n font-size: 17px;\n color: #ffd76a;\n margin-bottom: 18px;\n}\n#overlay .controls {\n text-align: left;\n background: rgba(0, 0, 0, 0.35);\n border-radius: 6px;\n padding: 12px 16px;\n font-size: 14px;\n line-height: 1.8;\n margin-bottom: 16px;\n}\n#overlay .controls b {\n color: #9adf6a;\n}\n#overlay .buttons {\n display: flex;\n gap: 10px;\n justify-content: center;\n}\n#overlay button {\n cursor: pointer;\n font-size: 14px;\n padding: 8px 14px;\n border-radius: 4px;\n border: 2px solid #666;\n background: #3a3f4d;\n color: #eee;\n}\n#overlay button:hover {\n background: #4a5163;\n}\n"
139
+ "content": "/* VoxelCraft HUD/UI. Пиксель-арт: image-rendering: pixelated везде, где иконки. */\n* {\n margin: 0;\n padding: 0;\n box-sizing: border-box;\n}\nhtml,\nbody {\n width: 100%;\n height: 100%;\n overflow: hidden;\n background: #063d99;\n}\nbody {\n font-family: \"Segoe UI\", system-ui, sans-serif;\n user-select: none;\n}\n\n#game {\n position: fixed;\n inset: 0;\n width: 100%;\n height: 100%;\n display: block;\n}\n\n.hidden {\n display: none !important;\n}\n\n/* ---------- HUD ---------- */\n#crosshair {\n position: fixed;\n left: 50%;\n top: 50%;\n width: 18px;\n height: 18px;\n transform: translate(-50%, -50%);\n pointer-events: none;\n z-index: 5;\n}\n#crosshair::before,\n#crosshair::after {\n content: \"\";\n position: absolute;\n background: rgba(255, 255, 255, 0.85);\n mix-blend-mode: difference;\n}\n#crosshair::before {\n left: 8px;\n top: 0;\n width: 2px;\n height: 18px;\n}\n#crosshair::after {\n left: 0;\n top: 8px;\n width: 18px;\n height: 2px;\n}\n\n/* --idos-safe-top: высота общего HUD host'а (0 без него) — служебные строки не уходят под HUD. */\n#fps {\n position: fixed;\n top: calc(var(--idos-safe-top, 0px) + 8px);\n right: 10px;\n color: #7fff6a;\n z-index: 5;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n#debug {\n position: fixed;\n top: calc(var(--idos-safe-top, 0px) + 8px);\n left: 10px;\n color: #ddd;\n z-index: 5;\n font: 11px monospace;\n text-shadow: 1px 1px 0 #000;\n opacity: 0.85;\n}\n#timeIndicator {\n position: fixed;\n top: calc(var(--idos-safe-top, 0px) + 26px);\n right: 10px;\n color: #ffe9a0;\n z-index: 5;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n\n/* --idos-safe-bottom: высота нижней навигации host'а (0, когда режим один) — хотбар над ней. */\n#hotbar {\n position: fixed;\n left: 50%;\n bottom: calc(var(--idos-safe-bottom, 0px) + 8px);\n transform: translateX(-50%);\n display: flex;\n gap: 3px;\n z-index: 5;\n padding: 3px;\n background: rgba(0, 0, 0, 0.45);\n border: 2px solid #222;\n border-radius: 4px;\n}\n.slot {\n width: 44px;\n height: 44px;\n position: relative;\n background: rgba(120, 120, 120, 0.35);\n border: 2px solid #555;\n}\n.slot img {\n width: 100%;\n height: 100%;\n image-rendering: pixelated;\n pointer-events: none;\n}\n.slot .count {\n position: absolute;\n right: 2px;\n bottom: 0;\n color: #fff;\n pointer-events: none;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n#hotbar .slot.selected {\n border-color: #fff;\n background: rgba(200, 200, 200, 0.4);\n}\n\n/* сердечки — слева от центра, шкала голода — справа (как в Minecraft) */\n#hearts {\n position: fixed;\n right: calc(50% + 8px);\n bottom: calc(var(--idos-safe-bottom, 0px) + 62px);\n white-space: nowrap;\n z-index: 5;\n font-size: 18px;\n letter-spacing: 2px;\n text-shadow: 1px 1px 0 #000;\n}\n.heart {\n color: #e03c3c;\n}\n.heart.half {\n color: #e03c3c;\n opacity: 0.55;\n}\n.heart.empty {\n color: #3a3a3a;\n}\n\n#hunger {\n position: fixed;\n left: calc(50% + 8px);\n bottom: calc(var(--idos-safe-bottom, 0px) + 62px);\n white-space: nowrap;\n z-index: 5;\n font-size: 18px;\n letter-spacing: 2px;\n text-shadow: 1px 1px 0 #000;\n direction: rtl; /* пустеет справа налево, как в MC */\n}\n.drumstick {\n color: #c98a3c;\n}\n.drumstick.half {\n color: #c98a3c;\n opacity: 0.55;\n}\n.drumstick.empty {\n color: #3a3a3a;\n}\n\n#hurtFlash {\n position: fixed;\n inset: 0;\n pointer-events: none;\n z-index: 4;\n background: radial-gradient(\n ellipse at center,\n transparent 40%,\n rgba(255, 0, 0, 0.5)\n );\n opacity: 0;\n}\n#hurtFlash.show {\n animation: hurt 0.4s ease-out;\n}\n@keyframes hurt {\n 0% {\n opacity: 1;\n }\n 100% {\n opacity: 0;\n }\n}\n\n#message {\n position: fixed;\n left: 50%;\n top: 30%;\n transform: translateX(-50%);\n color: #fff;\n font: bold 18px monospace;\n text-shadow: 2px 2px 0 #000;\n z-index: 6;\n opacity: 0;\n transition: opacity 0.3s;\n pointer-events: none;\n}\n#message.show {\n opacity: 1;\n}\n\n/* ---------- инвентарь ---------- */\n#invScreen {\n position: fixed;\n inset: 0;\n z-index: 10;\n background: rgba(0, 0, 0, 0.5);\n display: flex;\n align-items: center;\n justify-content: center;\n}\n.invPanel {\n background: #c6c6c6;\n border: 3px solid #555;\n border-radius: 4px;\n padding: 14px 16px;\n box-shadow: 0 10px 40px rgba(0, 0, 0, 0.6);\n}\n.invPanel h3 {\n color: #3a3a3a;\n margin-bottom: 8px;\n font-size: 15px;\n}\n.invPanel .slot {\n background: #8b8b8b;\n border: 2px solid;\n border-color: #373737 #fff #fff #373737;\n}\n.craftRow {\n display: flex;\n align-items: center;\n gap: 12px;\n margin-bottom: 12px;\n}\n#craftGrid {\n display: grid;\n gap: 3px;\n}\n.arrow {\n font-size: 26px;\n color: #3a3a3a;\n}\n.slot.result {\n width: 50px;\n height: 50px;\n}\n#invGrid {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n margin-bottom: 10px;\n}\n#invHotbar {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n}\n\n#cursorStack {\n position: fixed;\n z-index: 20;\n width: 40px;\n height: 40px;\n pointer-events: none;\n transform: translate(-50%, -50%);\n}\n#cursorStack img {\n width: 100%;\n height: 100%;\n image-rendering: pixelated;\n}\n#cursorStack .count {\n position: absolute;\n right: 0;\n bottom: -2px;\n color: #fff;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n\n/* ---------- оверлей ---------- */\n#overlay {\n position: fixed;\n inset: 0;\n z-index: 30;\n cursor: pointer;\n background: rgba(4, 32, 90, 0.85);\n display: flex;\n align-items: center;\n justify-content: center;\n}\n#overlay .panel {\n text-align: center;\n color: #eee;\n max-width: 560px;\n padding: 20px;\n}\n#overlay h1 {\n font-size: 44px;\n letter-spacing: 2px;\n margin-bottom: 6px;\n color: #9adf6a;\n text-shadow: 3px 3px 0 #2a4d1a;\n}\n#overlayHint {\n font-size: 17px;\n color: #ffd76a;\n margin-bottom: 18px;\n}\n#overlay .controls {\n text-align: left;\n background: rgba(0, 0, 0, 0.35);\n border-radius: 6px;\n padding: 12px 16px;\n font-size: 14px;\n line-height: 1.8;\n margin-bottom: 16px;\n}\n#overlay .controls b {\n color: #9adf6a;\n}\n#overlay .buttons {\n display: flex;\n gap: 10px;\n justify-content: center;\n}\n#overlay button {\n cursor: pointer;\n font-size: 14px;\n padding: 8px 14px;\n border-radius: 4px;\n border: 2px solid #666;\n background: #3a3f4d;\n color: #eee;\n}\n#overlay button:hover {\n background: #4a5163;\n}\n"
139
140
  },
140
141
  {
141
142
  "path": "systems/Audio.ts",
@@ -192,6 +193,10 @@
192
193
  {
193
194
  "path": "world/TerrainGenerator.ts",
194
195
  "content": "// Генератор террейна: высотная карта на симплекс-fbm, слои пород, руды,\n// озёра, деревья и растения. Полностью детерминирован сидом:\n// один и тот же сид → бит-в-бит одинаковый мир (важно для сейва и этапа 2,\n// где клиент генерирует мир по сиду и накатывает диф).\nimport { CONFIG } from \"../config\";\nimport { Simplex2, mulberry32, hashString } from \"../core/Noise\";\nimport { B, packWater } from \"../registry/Blocks\";\nimport { CHUNK_SIZE, type Chunk } from \"./Chunk\";\n\nconst { SX, SY, SZ } = CHUNK_SIZE;\n\nexport class TerrainGenerator {\n seed: number;\n height: Simplex2;\n detail: Simplex2;\n\n constructor(seed: number) {\n this.seed = seed;\n this.height = new Simplex2(hashString(\"height:\" + seed));\n this.detail = new Simplex2(hashString(\"detail:\" + seed));\n }\n\n /** Высота поверхности в мировых координатах. */\n surfaceHeight(wx: number, wz: number): number {\n const w = CONFIG.world;\n const h =\n this.height.fbm(wx * 0.008, wz * 0.008, 4) * w.hillAmp +\n this.detail.noise(wx * 0.05, wz * 0.05) * 2;\n return Math.max(2, Math.min(SY - 8, Math.round(w.baseHeight + h)));\n }\n\n /** Заполняет данные чанка. Деревья сажаются с отступом от края (крона радиуса 2\n * не пересекает границу чанка — генерация чанков остаётся независимой). */\n generate(chunk: Chunk): void {\n const water = CONFIG.world.waterLevel;\n const rng = mulberry32(\n hashString(`chunk:${this.seed}:${chunk.cx},${chunk.cz}`),\n );\n const heights = new Int16Array(SX * SZ);\n\n for (let z = 0; z < SZ; z++) {\n for (let x = 0; x < SX; x++) {\n const wx = chunk.cx * SX + x,\n wz = chunk.cz * SZ + z;\n const h = this.surfaceHeight(wx, wz);\n heights[x + z * SX] = h;\n const beach = h <= water + 1; // у воды — песок вместо дёрна\n\n for (let y = 0; y <= h; y++) {\n let id: number;\n if (y === 0) id = B.bedrock!;\n else if (y === 1 && rng() < 0.5)\n id = B.bedrock!; // рваный второй слой\n else if (y >= h - (beach ? 2 : 0) && beach) id = B.sand!;\n else if (y === h) id = B.grass!;\n else if (y >= h - 3) id = B.dirt!;\n else id = B.stone!;\n chunk.setRaw(x, y, z, id);\n }\n // озёра: всё, что ниже уровня моря — source-вода\n for (let y = h + 1; y <= water; y++)\n chunk.setRaw(x, y, z, B.water!, packWater(8, true));\n }\n }\n\n this._ores(chunk, rng);\n this._trees(chunk, rng, heights, water);\n this._plants(chunk, rng, heights, water);\n chunk.generated = true;\n }\n\n _ores(chunk: Chunk, rng: () => number): void {\n // жилы: случайный «пьяный» шаг из точки, 4-8 блоков\n const vein = (id: number, count: number, maxY: number): void => {\n for (let v = 0; v < count; v++) {\n let x = (rng() * SX) | 0,\n y = (3 + rng() * (maxY - 3)) | 0,\n z = (rng() * SZ) | 0;\n const len = (4 + rng() * 5) | 0;\n for (let i = 0; i < len; i++) {\n if (\n x >= 0 &&\n x < SX &&\n y >= 2 &&\n y < SY &&\n z >= 0 &&\n z < SZ &&\n chunk.get(x, y, z) === B.stone\n )\n chunk.setRaw(x, y, z, id);\n x += ((rng() * 3) | 0) - 1;\n y += ((rng() * 3) | 0) - 1;\n z += ((rng() * 3) | 0) - 1;\n }\n }\n };\n vein(B.coal_ore!, 7, 40);\n vein(B.iron_ore!, 5, 28);\n }\n\n _trees(\n chunk: Chunk,\n rng: () => number,\n heights: Int16Array,\n _water: number,\n ): void {\n const attempts = 3;\n for (let t = 0; t < attempts; t++) {\n // отступ 2 от края — крона не выходит за чанк\n const x = 2 + ((rng() * (SX - 4)) | 0),\n z = 2 + ((rng() * (SZ - 4)) | 0);\n const h = heights[x + z * SX]!;\n if (rng() > 0.6) continue;\n if (chunk.get(x, h, z) !== B.grass || h + 8 >= SY) continue;\n const trunk = 4 + ((rng() * 2) | 0);\n for (let y = 1; y <= trunk; y++) chunk.setRaw(x, h + y, z, B.oak_log!);\n // крона: два слоя 5×5 + два слоя 3×3 сверху, углы прореживаются\n for (let dy = trunk - 1; dy <= trunk + 2; dy++) {\n const r = dy >= trunk + 1 ? 1 : 2;\n for (let dx = -r; dx <= r; dx++)\n for (let dz = -r; dz <= r; dz++) {\n if (dx === 0 && dz === 0 && dy <= trunk) continue;\n if (Math.abs(dx) === r && Math.abs(dz) === r && rng() < 0.5)\n continue;\n const y = h + dy + 1;\n if (y < SY && chunk.get(x + dx, y, z + dz) === B.air)\n chunk.setRaw(x + dx, y, z + dz, B.leaves!);\n }\n }\n }\n }\n\n _plants(\n chunk: Chunk,\n rng: () => number,\n heights: Int16Array,\n water: number,\n ): void {\n for (let z = 0; z < SZ; z++)\n for (let x = 0; x < SX; x++) {\n const h = heights[x + z * SX]!;\n if (h <= water || h + 1 >= SY) continue;\n if (chunk.get(x, h, z) !== B.grass || chunk.get(x, h + 1, z) !== B.air)\n continue;\n const r = rng();\n if (r < 0.08) chunk.setRaw(x, h + 1, z, B.tallgrass!);\n else if (r < 0.095)\n chunk.setRaw(\n x,\n h + 1,\n z,\n rng() < 0.5 ? B.flower_yellow! : B.flower_red!,\n );\n }\n }\n}\n"
196
+ },
197
+ {
198
+ "path": "module.meta.json",
199
+ "content": "{\n \"id\": \"voxelcraft\",\n \"type\": \"template\",\n \"summary\": \"Voxel sandbox (Minecraft-like): walk a first-person world, place and break blocks.\",\n \"description\": \"A first-person voxel sandbox rendered in Three.js. Players explore a procedurally chunked world, place and break blocks, and move with WASD + mouse-look. Covers any 'build / mine / explore a block world' or open-world sandbox request; the largest of the feature modules.\",\n \"provides\": [\n \"first-person voxel world exploration\",\n \"place and break blocks\",\n \"procedural chunked terrain\",\n \"WASD + mouse-look player controller\",\n \"3D block rendering (Three.js)\"\n ],\n \"tags\": [\"voxel\", \"sandbox\", \"minecraft\", \"3d\", \"first-person\", \"three\"],\n \"media\": {\n \"image\": \"https://cloud.idosgames.com/drive/modules/voxelcraft/cover.png\",\n \"video\": \"https://cloud.idosgames.com/drive/modules/voxelcraft/demo.mp4\"\n },\n \"demoUrl\": \"https://cloud.idosgames.com/drive/modules/voxelcraft/demo/\",\n \"author\": { \"name\": \"iDos Games\", \"url\": \"https://idosgames.com\" },\n \"version\": \"0.1.0\"\n}\n"
195
200
  }
196
201
  ]
197
202
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "character-system",
3
3
  "description": "Build a character / hero system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.character (CharacterService): load the hero roster and title definitions, unlock or purchase characters, upgrade character levels/ranks and per-character stats, equip and unequip gear into slots, and read the server-authoritative Power score. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants character screens, hero rosters, stat/level/rank upgrade UIs, equipment or loadout systems, or otherwise touches client.character, CharacterService, CharacterModel, CharacterDefinitions, StatLevels, or character Power — even if they don't name the module explicitly.",
4
- "content": "---\nname: character-system\ndescription: >-\n Build a character / hero system in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.character (CharacterService): load the hero\n roster and title definitions, unlock or purchase characters, upgrade\n character levels/ranks and per-character stats, equip and unequip gear into\n slots, and read the server-authoritative Power score. Use this whenever the\n user is working in the iDosGames TS SDK or its game templates (board-game,\n idle-rpg) and wants character screens, hero rosters, stat/level/rank upgrade\n UIs, equipment or loadout systems, or otherwise touches client.character,\n CharacterService, CharacterModel, CharacterDefinitions, StatLevels, or\n character Power — even if they don't name the module explicitly.\n---\n\n# Character system (iDosGames TS SDK)\n\nThe Character module lets a title ship a roster of heroes that players own, rank\nup, spec into stats, and dress in gear. Everything is **server-authoritative**:\nthe client asks the backend to unlock / upgrade / equip, the backend validates\ncost and rules, and the SDK mirrors the confirmed result into a local cache your\nUI reads. You never mutate character state yourself — you call a method, check\nthe result, and render from the cache.\n\nThis skill is for **using** the production `CharacterService`, not for porting or\nextending it. If a call is rejected, that's the backend enforcing a rule (cost,\ngate, lock) — surface the error, don't try to reproduce the check client-side.\n\n## The two data shapes\n\nKeep these straight; every recipe below is just moving between them.\n\n1. **Definitions** (config, same for every player) — the title's catalog of what\n characters _can_ exist: their IDs, unlock rules & prices, upgradable stats,\n level/rank tables, and equipment slots. Fetched with\n `getCharacterDefinitions()`.\n2. **Player characters** (state, per player) — what _this_ player actually has:\n each owned character's `Level`, `StatLevels`, `Equipment`, and `Power`.\n Fetched with `getUserCharacters()`.\n\nA character is identified by a string `CharacterID`. The reserved id `\"Main\"` is\nthe always-available primary hero. Render the roster by walking Definitions and\nlooking up each player character by id.\n\nTwo kinds of progression, don't conflate them:\n\n- **Character Level** (aka rank / stars) — one track per character, upgraded via\n `upgradeCharacterLevel`. Raising it can unlock slots and lift the stat cap.\n- **Stat Levels** — many upgradable stats _per character_ (e.g. `\"Attack\"`,\n `\"AttackSpeed\"`), each with its own level in `StatLevels`, upgraded via\n `upgradeStatLevel`. A stat's max level can depend on the character's rank.\n\n`Power` is a single combat score the backend computes from stats, rank, and\nequipped gear. **Treat it as read-only** — never compute it yourself; read it\nfrom the response or the cached `CharacterModel.Power`.\n\nFor the full field-by-field shape of Definitions and state (stat cost formulas,\nequipment gates, rank multipliers, presets), read\n[references/data-model.md](references/data-model.md). You do **not** need it to\ncall the methods — only to drive richer UI off the config.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst characters = client.character; // the CharacterService\n```\n\nEvery character method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that is\neither `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\n`result.ok` before touching `result.data`. `reason` is one of `\"client\"` (bad\nlocal args), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again\ninside the throttle window), `\"connection\"` (transient, offer Retry),\n`\"validation\"` (response/schema drift), or `\"server\"` (backend rejected it —\n`error` carries the human-readable reason, e.g. \"Character is locked\",\n\"Already at maximum level\", insufficient funds).\n\n| Method | Purpose | `data` on success |\n| ---------------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |\n| `getCharacterDefinitions()` | Load the title's character catalog (config). | `CharacterDefinitions` |\n| `getUserCharacters()` | Load this player's roster (state). | `{ Characters: Record<string, CharacterModel> }` |\n| `unlockCharacter(characterID, options?)` | Buy/unlock a locked character (charges the selected `Unlock.PriceOptions` option). | `UnlockCharacterResponse` |\n| `upgradeCharacterLevel(characterID, opts?)` | Raise the character's Level/rank (one step, or multi-level via `opts`). | `UpgradeCharacterLevelResponse` (`NewLevel`) |\n| `upgradeStatLevel(characterID, statID, opts?)` | Raise one stat (one step, or multi-level via `opts`). | `UpgradeStatLevelResponse` (`StatLevel`) |\n| `equipItems(characterID, pairs)` | Equip one or more items into slots. | `EquipItemsResponse` (`Equipment`, `ReplacedInstanceIDs`, `Power`, `Inventory`) |\n| `unequipItems(characterID, slotIDs)` | Clear specific slots. | `UnequipItemsResponse` (`ClearedSlotIDs`, `Power`, `Inventory`) |\n| `unequipAllCharacters()` | Strip gear off every character. | `UnequipAllCharactersResponse` (`Characters`, `Inventory`) |\n| `unlockCharactersBatch(characterIDs)` | Unlock many characters in one atomic call. | `BatchResponse<UnlockCharacterResponse>` |\n| `upgradeCharacterLevelsBatch(refs)` | Rank up many characters in one atomic call. | `BatchResponse<UpgradeCharacterLevelResponse>` |\n| `upgradeStatLevelsBatch(refs)` | Upgrade many stats (across characters) in one atomic call. | `BatchResponse<UpgradeStatLevelResponse>` |\n\n`opts` on the two single upgrades is `{ levels?, targetLevel? }`: raise `levels` steps at once (default 1), or pass an absolute `targetLevel` (wins over `levels`, clamped to the max). All levels in the range are charged and applied atomically — all-or-nothing.\n\n`equipItems` takes `EquipSlotPair[]`, each `{ SlotID, ItemInstanceID? , ItemID?,\nCatalogID? }`: give a `SlotID` plus **either** a specific `ItemInstanceID` **or**\nan `ItemID` (optionally `CatalogID`) to let the server auto-pick a matching\ninstance from inventory. In the response, read the equipped `ItemInstanceID`\nfrom `data.Equipment` — for stacked items the server splits off a fresh instance,\nso it can differ from what you sent. `ReplacedInstanceIDs` lists items knocked\nout of those slots (now back in inventory, unequipped).\n\nOn success, each method also **mirrors the confirmed change into the cache and\nemits an event** — you don't apply anything by hand. Consumed/granted resources\n(currencies, items) ride along in `data.Resources` and are already applied to\nthe cached balances, so read updated balances straight from the cache. For the\n**batch** methods the merged charge is at the wrapper's top-level `data.Resources`\n(per-item `Data.Resources` is null); it is applied once for you.\n\nEquip/unequip return an **`Inventory` delta** (`{ ChangedInstances, RemovedInstanceIDs }`)\nthat reconciles `InventoryV2.UnstackableItems`: equipped instances get their\n`EquippedSlot` set, evicted instances get it cleared, stack-splits add new\ninstances, and fully-consumed packs are removed. The SDK applies the delta to the\ncache for you — it is the authoritative source for unstackable-item changes.\n\n## Reading state and reacting to changes\n\nDrive the UI off the cache, not off one-off return values — that way every\nscreen stays consistent no matter which code path changed things.\n\n```ts\n// Current roster (only present after getUserCharacters()):\nconst roster = client.data.user.state?.Character?.Characters ?? {};\nconst hero = roster[\"Main\"];\nhero?.Level; // rank\nhero?.StatLevels; // { statID: level }\nhero?.Equipment; // { slotID: EquippedItem }\nhero?.Power; // server-computed combat score\n\n// Definitions (cached after getCharacterDefinitions()):\nimport type { CharacterDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<CharacterDefinitions>(\"Character\");\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `character:definitionsLoaded` → `CharacterDefinitions`\n- `character:userCharactersLoaded` → `Record<string, CharacterModel>`\n- `character:unlocked` → `UnlockCharacterResponse`\n- `character:levelUpgraded` → `UpgradeCharacterLevelResponse`\n- `character:statLevelUpgraded` → `UpgradeStatLevelResponse`\n- `character:itemsEquipped` → `EquipItemsResponse`\n- `character:itemsUnequipped` → `{ characterID, slotIDs, power? }`\n- `character:allUnequipped` → `UnequipAllCharactersResponse`\n- `character:charactersUnlocked` → `BatchResponse<UnlockCharacterResponse>`\n- `character:levelsUpgraded` → `BatchResponse<UpgradeCharacterLevelResponse>`\n- `character:statLevelsUpgraded` → `BatchResponse<UpgradeStatLevelResponse>`\n\nThe coarse `user:characterUpdated` (and `user:anyUpdated`) also fire on any\ncharacter cache write — handy for a \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"character:levelUpgraded\", (r) => {\n console.log(`${r.CharacterID} is now rank ${r.NewLevel}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Show the roster (owned, locked, and default heroes together)\n\n```ts\nawait client.character.getCharacterDefinitions();\nawait client.character.getUserCharacters();\n\nconst defs = client.data.config.getSection<CharacterDefinitions>(\"Character\");\nconst owned = client.data.user.state?.Character?.Characters ?? {};\n\nfor (const [characterID, def] of Object.entries(defs?.Definitions ?? {})) {\n const mine = owned[characterID];\n const isOwned = !!mine && (mine.Level ?? 0) > 0;\n // def carries Identity/Unlock/etc (see references/data-model.md).\n // Locked & purchasable → show its Unlock.PriceOptions and an Unlock button.\n}\n```\n\n`getUserCharacters()` already **overlays default characters** (`Unlock\n.UnlockedByDefault === true`, e.g. `\"Main\"`) as virtual `Level: 1` entries even\nbefore the player touches them, so the roster is complete. Treat any character\npresent with `Level >= 1` as owned/active; `Level === 0` or absent means not yet\nactivated.\n\n### Unlock a character\n\n```ts\n// Third argument picks the way to pay and carries a store receipt when the option needs one.\nconst res = await client.character.unlockCharacter(\"Knight\");\nif (!res.ok) return showError(res.error); // e.g. \"already unlocked\", can't afford\n// cache now has Knight; balances already debited. UI re-renders from cache.\n```\n\nOnly characters whose config has `Unlock.PriceOptions` are purchasable this way.\nDefault characters reject with \"unlocked by default\"; characters meant to drop\nfrom lootboxes/quests have no options and reject with \"must be granted by other\nsystems\" — for those, grant them through that other feature, not here.\n\nWhen the character has several ways to pay, render them with\n`client.checkout.availableOptions(def.Unlock.PriceOptions)` and pass the chosen one:\n\n```ts\nawait client.character.unlockCharacter(\"Knight\", {\n selectedOptionID: option.OptionID,\n // required only when this option is paid in a store (a `Purchase` entry in its Cost)\n payment: { Store: \"GooglePlay\", Receipt: receiptJson, Signature: signature },\n});\n```\n\n### Upgrade rank, then a stat\n\n```ts\nconst lvl = await client.character.upgradeCharacterLevel(\"Knight\");\nif (!lvl.ok) return showError(lvl.error);\n\nconst stat = await client.character.upgradeStatLevel(\"Knight\", \"Attack\");\nif (!stat.ok) return showError(stat.error);\n// stat.data.StatLevel is the new level.\n```\n\nA stat can hit its cap before you expect: the effective max is the stat's\n`MaxLevel` scaled by the character's current rank. When `upgradeStatLevel`\nreturns \"Already at maximum level\", the fix is `upgradeCharacterLevel` to raise\nthe cap — surface that to the player. Stats can also gate on other stats (a\n`Requirements` list); a \"required stat did not reach the desired level\" error\nmeans level the prerequisite first.\n\nTo move several levels in one call, pass `opts`:\n\n```ts\nawait client.character.upgradeCharacterLevel(\"Knight\", { targetLevel: 5 });\nawait client.character.upgradeStatLevel(\"Knight\", \"Attack\", { levels: 3 });\n```\n\nThis is atomic — either the whole range is charged and applied, or nothing is.\nIf the range runs past the configured cap it stops at the cap (the response\ncarries the level actually reached).\n\n### Equip and unequip\n\n```ts\nconst eq = await client.character.equipItems(\"Knight\", [\n { SlotID: \"Weapon\", ItemInstanceID: \"inst-123\" },\n { SlotID: \"Head\", ItemID: \"iron-helm\" }, // auto-pick an instance\n]);\nif (!eq.ok) return showError(eq.error);\neq.data.Power; // new score\neq.data.ReplacedInstanceIDs; // items bumped back to inventory\neq.data.Inventory; // UnstackableItems delta (already applied to the cache)\n\nconst un = await client.character.unequipItems(\"Knight\", [\"Weapon\"]);\nun.ok && un.data.ClearedSlotIDs; // slots actually cleared (empty ones aren't listed)\nun.ok && un.data.Power; // recomputed score (null if the request was a no-op)\n\nawait client.character.unequipAllCharacters(); // whole-account reset\n```\n\n`unequipItems` reports only the slots it **actually** cleared in `ClearedSlotIDs`\n(already-empty slots are skipped), the recomputed `Power` (null when the request\nwas empty), and an `Inventory` delta. `unequipAllCharacters` returns a\nper-character `Characters` map (`{ ClearedSlotIDs, Power }` each; characters with\nno gear are omitted) plus one `Inventory` delta for the whole sweep. Both apply\neverything to the cache for you.\n\nEquipping is validated on **both sides** and can be rejected for many reasons:\nthe slot isn't allowed on this character, the character's rank/stats don't meet\nthe slot's requirements, the item's rarity/tags/instance-level don't pass the\nslot filter, the item isn't equippable or isn't allowed on this character, the\nitem is already equipped elsewhere, or it has expired. Each is a\n`reason: \"server\"` with a specific `error` string — show it. The item↔slot rule\nmatrix lives in [references/data-model.md](references/data-model.md).\n\n### Batch operations\n\nWhen the player acts on several characters at once (a \"rank up all\", a starter\nbundle that unlocks a squad, a spec preset that bumps many stats), use the batch\nmethods: one atomic backend call, one merged charge, instead of N round-trips.\n\n```ts\nconst res = await client.character.upgradeStatLevelsBatch([\n { CharacterID: \"Knight\", StatID: \"Attack\", Levels: 2 },\n { CharacterID: \"Knight\", StatID: \"Defense\", TargetLevel: 5 },\n { CharacterID: \"Mage\", StatID: \"Attack\" }, // Levels defaults to 1\n]);\nif (!res.ok) return showError(res.error);\nfor (const item of res.data.Items) {\n if (item.Success)\n applyOk(item.Id); // e.g. \"Knight:Attack\"\n else showItemError(item.Id, item.Error); // this one was rejected\n}\n```\n\nEach batch resolves to a **`BatchResponse<T>` wrapper**: `data.Items` is the\nper-item list and `data.Resources` is the one merged charge for the whole batch\n(already applied to the cache). Batch results are **partial-aware**: the outer\n`res.ok` tells you the call ran; each element's `Success`/`Error` tells you\nwhether that item applied. But the resource charge is **all-or-nothing** — if the\nmerged cost can't be paid, every\nincluded item comes back `Success: false`. Items rejected on their own merits\n(already unlocked, unknown id, stat at cap) are filtered out _before_ the charge\nand simply report their reason. `unlockCharactersBatch(ids)` takes a string\narray; the two upgrade batches take `CharacterLevelRef[]` / `CharacterStatRef[]`\nwith the same `Levels`/`TargetLevel` options as the single calls; a ref without\na `CharacterID` targets `\"Main\"`. The server dedupes entries (by id /\n`CharacterID` / `CharacterID`+`StatID`) and processes at most **50 per call** —\nentries past 50 are silently dropped and don't appear in the results at all, so\nchunk larger sets into multiple calls yourself.\n\nOne caveat for stat batches: prerequisite checks use the levels _at the start of\nthe call_, so you can't chain \"raise A to 5, then raise B which requires A@5\" in\na single batch — split dependent steps across calls.\n\n## Gotchas\n\n- **Guard against double-submit.** Each call mints a fresh idempotency key, so\n two separate calls are two real operations — a double-clicked \"Upgrade\" can\n charge twice. Disable the control while a call is in flight. (Firing the same\n endpoint again within the throttle window, default 600 ms, is rejected with\n `reason: \"throttled\"` rather than duplicated, but don't rely on that for\n correctness.) The idempotency key only protects transport-level auto-retries\n inside a single call.\n- **Power is authoritative.** Read `CharacterModel.Power` / `response.Power`;\n never derive it. It's an integer combat score used for PvP ranking/matchmaking.\n **Every** mutating call now returns the recomputed `Power` (unlock, both level\n and stat upgrades, equip, unequip, and each batch item) and the SDK writes it to\n the cached character — so `CharacterModel.Power` is always current after a\n successful call. On `unequipItems` `Power` is nullable (null when the request\n was a no-op that never read the DB).\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth on\n `\"unauthorized\"`, toast the `error` on `\"server\"`).\n- **Lock before you upgrade.** Upgrading stats/levels or equipping on a\n not-yet-owned, non-default character fails with \"locked — unlock it first\".\n- **Equipment truth lives on the item instance.** The per-character `Equipment`\n map is a cache view; the source of truth is each item instance's\n `EquippedSlot`. The SDK keeps both in sync for you — just don't hand-edit.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and state\nfield, stat cost/scaling formulas, rank multipliers, the equip rule matrix, and\nshared stat/level/equipment presets. Read it when building config-driven UI\n(cost previews, upgrade math, slot filters) or when an error message points at a\nconfig rule you need to understand.\n",
4
+ "content": "---\nname: character-system\ndescription: >-\n Build a character / hero system in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.character (CharacterService): load the hero\n roster and title definitions, unlock or purchase characters, upgrade\n character levels/ranks and per-character stats, equip and unequip gear into\n slots, and read the server-authoritative Power score. Use this whenever the\n user is working in the iDosGames TS SDK or its game templates (board-game,\n idle-rpg) and wants character screens, hero rosters, stat/level/rank upgrade\n UIs, equipment or loadout systems, or otherwise touches client.character,\n CharacterService, CharacterModel, CharacterDefinitions, StatLevels, or\n character Power — even if they don't name the module explicitly.\n---\n\n# Character system (iDosGames TS SDK)\n\nThe Character module lets a title ship a roster of heroes that players own, rank\nup, spec into stats, and dress in gear. Everything is **server-authoritative**:\nthe client asks the backend to unlock / upgrade / equip, the backend validates\ncost and rules, and the SDK mirrors the confirmed result into a local cache your\nUI reads. You never mutate character state yourself — you call a method, check\nthe result, and render from the cache.\n\nThis skill is for **using** the production `CharacterService`, not for porting or\nextending it. If a call is rejected, that's the backend enforcing a rule (cost,\ngate, lock) — surface the error, don't try to reproduce the check client-side.\n\n## The two data shapes\n\nKeep these straight; every recipe below is just moving between them.\n\n1. **Definitions** (config, same for every player) — the title's catalog of what\n characters _can_ exist: their IDs, unlock rules & prices, upgradable stats,\n level/rank tables, and equipment slots. Fetched with\n `getCharacterDefinitions()`.\n2. **Player characters** (state, per player) — what _this_ player actually has:\n each owned character's `Level`, `StatLevels`, `Equipment`, and `Power`.\n Fetched with `getUserCharacters()`.\n\nA character is identified by a string `CharacterID`. The reserved id `\"Main\"` is\nthe always-available primary hero. Render the roster by walking Definitions and\nlooking up each player character by id.\n\nTwo kinds of progression, don't conflate them:\n\n- **Character Level** (aka rank / stars) — one track per character, upgraded via\n `upgradeCharacterLevel`. Raising it can unlock slots and lift the stat cap.\n- **Stat Levels** — many upgradable stats _per character_ (e.g. `\"Attack\"`,\n `\"AttackSpeed\"`), each with its own level in `StatLevels`, upgraded via\n `upgradeStatLevel`. A stat's max level can depend on the character's rank.\n\n`Power` is a single combat score the backend computes from stats, rank, and\nequipped gear. **Treat it as read-only** — never compute it yourself; read it\nfrom the response or the cached `CharacterModel.Power`.\n\nFor the full field-by-field shape of Definitions and state (stat cost formulas,\nequipment gates, rank multipliers, presets), read\n[references/data-model.md](references/data-model.md). You do **not** need it to\ncall the methods — only to drive richer UI off the config.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst characters = client.character; // the CharacterService\n```\n\nEvery character method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that is\neither `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\n`result.ok` before touching `result.data`. `reason` is one of `\"client\"` (bad\nlocal args), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again\ninside the throttle window), `\"connection\"` (transient, offer Retry),\n`\"validation\"` (response/schema drift), or `\"server\"` (backend rejected it —\n`error` carries the human-readable reason, e.g. \"Character is locked\",\n\"Already at maximum level\", insufficient funds).\n\n| Method | Purpose | `data` on success |\n| ---------------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |\n| `getCharacterDefinitions()` | Load the title's character catalog (config). | `CharacterDefinitions` |\n| `getUserCharacters()` | Load this player's roster (state). | `{ Characters: Record<string, CharacterModel> }` |\n| `unlockCharacter(characterID, options?)` | Buy/unlock a locked character (charges the selected `Unlock.PriceOptions` option). | `UnlockCharacterResponse` |\n| `upgradeCharacterLevel(characterID, opts?)` | Raise the character's Level/rank (one step, or multi-level via `opts`). | `UpgradeCharacterLevelResponse` (`NewLevel`) |\n| `upgradeStatLevel(characterID, statID, opts?)` | Raise one stat (one step, or multi-level via `opts`). | `UpgradeStatLevelResponse` (`StatLevel`) |\n| `equipItems(characterID, pairs)` | Equip one or more items into slots. | `EquipItemsResponse` (`Equipment`, `ReplacedInstanceIDs`, `Power`, `Inventory`) |\n| `unequipItems(characterID, slotIDs)` | Clear specific slots. | `UnequipItemsResponse` (`ClearedSlotIDs`, `Power`, `Inventory`) |\n| `unequipAllCharacters()` | Strip gear off every character. | `UnequipAllCharactersResponse` (`Characters`, `Inventory`) |\n| `unlockCharactersBatch(characterIDs)` | Unlock many characters in one atomic call. | `BatchResponse<UnlockCharacterResponse>` |\n| `upgradeCharacterLevelsBatch(refs)` | Rank up many characters in one atomic call. | `BatchResponse<UpgradeCharacterLevelResponse>` |\n| `upgradeStatLevelsBatch(refs)` | Upgrade many stats (across characters) in one atomic call. | `BatchResponse<UpgradeStatLevelResponse>` |\n\n`opts` on the two single upgrades is `{ levels?, targetLevel? }`: raise `levels` steps at once (default 1), or pass an absolute `targetLevel` (wins over `levels`, clamped to the max). All levels in the range are charged and applied atomically — all-or-nothing.\n\n`equipItems` takes `EquipSlotPair[]`, each `{ SlotID, ItemInstanceID? , ItemID?,\nCatalogID? }`: give a `SlotID` plus **either** a specific `ItemInstanceID` **or**\nan `ItemID` (optionally `CatalogID`) to let the server auto-pick a matching\ninstance from inventory. In the response, read the equipped `ItemInstanceID`\nfrom `data.Equipment` — for stacked items the server splits off a fresh instance,\nso it can differ from what you sent. `ReplacedInstanceIDs` lists items knocked\nout of those slots (now back in inventory, unequipped).\n\nOn success, each method also **mirrors the confirmed change into the cache and\nemits an event** — you don't apply anything by hand. Consumed/granted resources\n(currencies, items) ride along in `data.Resources` and are already applied to\nthe cached balances, so read updated balances straight from the cache. For the\n**batch** methods the merged charge is at the wrapper's top-level `data.Resources`\n(per-item `Data.Resources` is null); it is applied once for you.\n\nEquip/unequip return an **`Inventory` delta** (`{ ChangedInstances, RemovedInstanceIDs }`)\nthat reconciles `InventoryV2.UnstackableItems`: equipped instances get their\n`EquippedSlot` set, evicted instances get it cleared, stack-splits add new\ninstances, and fully-consumed packs are removed. The SDK applies the delta to the\ncache for you — it is the authoritative source for unstackable-item changes.\n\n## Reading state and reacting to changes\n\nDrive the UI off the cache, not off one-off return values — that way every\nscreen stays consistent no matter which code path changed things.\n\n```ts\n// Current roster (only present after getUserCharacters()):\nconst roster = client.data.user.state?.Character?.Characters ?? {};\nconst hero = roster[\"Main\"];\nhero?.Level; // rank\nhero?.StatLevels; // { statID: level }\nhero?.Equipment; // { slotID: EquippedItem }\nhero?.Power; // server-computed combat score\n\n// Definitions (cached after getCharacterDefinitions()):\nimport type { CharacterDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<CharacterDefinitions>(\"Character\");\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `character:definitionsLoaded` → `CharacterDefinitions`\n- `character:userCharactersLoaded` → `Record<string, CharacterModel>`\n- `character:unlocked` → `UnlockCharacterResponse`\n- `character:levelUpgraded` → `UpgradeCharacterLevelResponse`\n- `character:statLevelUpgraded` → `UpgradeStatLevelResponse`\n- `character:itemsEquipped` → `EquipItemsResponse`\n- `character:itemsUnequipped` → `{ characterID, slotIDs, power? }`\n- `character:allUnequipped` → `UnequipAllCharactersResponse`\n- `character:charactersUnlocked` → `BatchResponse<UnlockCharacterResponse>`\n- `character:levelsUpgraded` → `BatchResponse<UpgradeCharacterLevelResponse>`\n- `character:statLevelsUpgraded` → `BatchResponse<UpgradeStatLevelResponse>`\n\nThe coarse `user:characterUpdated` (and `user:anyUpdated`) also fire on any\ncharacter cache write — handy for a \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"character:levelUpgraded\", (r) => {\n console.log(`${r.CharacterID} is now rank ${r.NewLevel}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Show the roster (owned, locked, and default heroes together)\n\n```ts\nawait client.character.getCharacterDefinitions();\nawait client.character.getUserCharacters();\n\nconst defs = client.data.config.getSection<CharacterDefinitions>(\"Character\");\nconst owned = client.data.user.state?.Character?.Characters ?? {};\n\nfor (const [characterID, def] of Object.entries(defs?.Definitions ?? {})) {\n const mine = owned[characterID];\n const isOwned = !!mine && (mine.Level ?? 0) > 0;\n // def carries Identity/Unlock/etc (see references/data-model.md).\n // Locked & purchasable → show its Unlock.PriceOptions and an Unlock button.\n}\n```\n\n`getUserCharacters()` already **overlays default characters** (`Unlock\n.UnlockedByDefault === true`, e.g. `\"Main\"`) as virtual `Level: 1` entries even\nbefore the player touches them, so the roster is complete. Treat any character\npresent with `Level >= 1` as owned/active; `Level === 0` or absent means not yet\nactivated.\n\n### Unlock a character\n\n```ts\n// Third argument picks the way to pay and carries a store receipt when the option needs one.\nconst res = await client.character.unlockCharacter(\"Knight\");\nif (!res.ok) return showError(res.error); // e.g. \"already unlocked\", can't afford\n// cache now has Knight; balances already debited. UI re-renders from cache.\n```\n\nOnly characters whose config has `Unlock.PriceOptions` are purchasable this way.\nDefault characters reject with \"unlocked by default\"; characters meant to drop\nfrom lootboxes/quests have no options and reject with \"must be granted by other\nsystems\" — for those, grant them through that other feature, not here.\n\nWhen the character has several ways to pay, render them with\n`client.checkout.availableOptions(def.Unlock.PriceOptions)` and pass the chosen one:\n\n```ts\nawait client.character.unlockCharacter(\"Knight\", {\n selectedOptionID: option.OptionID,\n // required only when this option is paid in a store (a `Purchase` entry in its Cost)\n payment: { Store: \"GooglePlay\", Receipt: receiptJson, Signature: signature },\n});\n```\n\n### Upgrade rank, then a stat\n\n```ts\nconst lvl = await client.character.upgradeCharacterLevel(\"Knight\");\nif (!lvl.ok) return showError(lvl.error);\n\nconst stat = await client.character.upgradeStatLevel(\"Knight\", \"Attack\");\nif (!stat.ok) return showError(stat.error);\n// stat.data.StatLevel is the new level.\n```\n\nA stat can hit its cap before you expect: the effective max is the stat's\n`MaxLevel` scaled by the character's current rank. When `upgradeStatLevel`\nreturns \"Already at maximum level\", the fix is `upgradeCharacterLevel` to raise\nthe cap — surface that to the player. Stats can also gate on other stats (a\n`Requirements` list); a \"required stat did not reach the desired level\" error\nmeans level the prerequisite first.\n\nTo move several levels in one call, pass `opts`:\n\n```ts\nawait client.character.upgradeCharacterLevel(\"Knight\", { targetLevel: 5 });\nawait client.character.upgradeStatLevel(\"Knight\", \"Attack\", { levels: 3 });\n```\n\nThis is atomic — either the whole range is charged and applied, or nothing is.\nIf the range runs past the configured cap it stops at the cap (the response\ncarries the level actually reached).\n\n### Equip and unequip\n\n```ts\nconst eq = await client.character.equipItems(\"Knight\", [\n { SlotID: \"Weapon\", ItemInstanceID: \"inst-123\" },\n { SlotID: \"Head\", ItemID: \"iron-helm\" }, // auto-pick an instance\n]);\nif (!eq.ok) return showError(eq.error);\neq.data.Power; // new score\neq.data.ReplacedInstanceIDs; // items bumped back to inventory\neq.data.Inventory; // UnstackableItems delta (already applied to the cache)\n\nconst un = await client.character.unequipItems(\"Knight\", [\"Weapon\"]);\nun.ok && un.data.ClearedSlotIDs; // slots actually cleared (empty ones aren't listed)\nun.ok && un.data.Power; // recomputed score (null if the request was a no-op)\n\nawait client.character.unequipAllCharacters(); // whole-account reset\n```\n\n`unequipItems` reports only the slots it **actually** cleared in `ClearedSlotIDs`\n(already-empty slots are skipped), the recomputed `Power` (null when the request\nwas empty), and an `Inventory` delta. `unequipAllCharacters` returns a\nper-character `Characters` map (`{ ClearedSlotIDs, Power }` each; characters with\nno gear are omitted) plus one `Inventory` delta for the whole sweep. Both apply\neverything to the cache for you.\n\nEquipping is validated on **both sides** and can be rejected for many reasons:\nthe slot isn't allowed on this character, the character's rank/stats don't meet\nthe slot's requirements, the item's rarity/tags/instance-level don't pass the\nslot filter, the item isn't equippable or isn't allowed on this character, the\nitem is already equipped elsewhere, or it has expired. Each is a\n`reason: \"server\"` with a specific `error` string — show it. The item↔slot rule\nmatrix lives in [references/data-model.md](references/data-model.md).\n\n### Skins (alternative looks)\n\nA skin is an **item**. A character's `Skins.Definitions` names which catalog item\n_is_ each skin (non-stackable, no expiration). Owning a skin = owning a copy of\nthat item — so store offers, lootboxes, season tiers, quests and rewards grant\nskins with no extra wiring. Wearing binds that copy to the reserved equipment\nkey `SKIN_SLOT` (`\"@skin\"`); the worn skin is a regular `Equipment[SKIN_SLOT]`\nentry, and its item `Stats` (if any) count toward `Power` exactly like gear.\n\n```ts\nimport { SKIN_SLOT, SKIN_ALREADY_OWNED, isReservedSlot } from \"@idosgames/core\";\n\n// Buy from the character screen (only skins with Sale.PriceOptions are sold here)\nconst buy = await client.character.unlockSkin(\"Knight\", \"golden\", {\n autoEquip: true,\n});\nif (!buy.ok) {\n if (buy.error === SKIN_ALREADY_OWNED) hideBuyButton();\n else showError(buy.error);\n} else if (!buy.data.Equipped) {\n toast(buy.data.EquipError); // bought — but the wear requirements aren't met yet\n}\n\nawait client.character.equipSkin(\"Knight\", \"golden\"); // wear an owned skin\nawait client.character.equipSkin(\"Knight\", \"base\"); // the base look = no skin\nawait client.character.unequipSkin(\"Knight\"); // same, explicitly (idempotent)\n\n// Render: the worn skin (or none), gear slots without the reserved key\nconst worn = knight.Equipment?.[SKIN_SLOT]; // knight: the cached CharacterModel\nconst gear = Object.entries(knight.Equipment ?? {}).filter(\n ([slot]) => !isReservedSlot(slot),\n);\n```\n\n- **Which skin is worn** — match `worn.ItemID` against `Skins.Definitions[*].Item.ItemID`\n (the SkinID is not stored in state, so renaming a skin in config breaks nothing).\n- **Owned?** — `InventoryV2.Items[skin.Item.ItemID].TotalAmount > 0`.\n- **Sale.Schedule / Sale.Gate restrict buying only.** A skin the player owns can\n always be worn; `Requirements` (rank, stats) gate wearing.\n- Empty `Sale.PriceOptions` means **not sold directly** (granted by other systems\n only) — not \"free\", unlike most prices.\n- **What to show in the skin shop** — `client.character.getCharacterSkins(\"Knight\")`\n returns, per skin, `Owned` / `IsWorn` / `OnSale` / `GateOpen` /\n `MeetsRequirements` / `Purchasable` and the prices for this platform. Drive the\n Buy button from `Purchasable`: audience gates and sale windows can't be\n evaluated on the client.\n\n### Batch operations\n\nWhen the player acts on several characters at once (a \"rank up all\", a starter\nbundle that unlocks a squad, a spec preset that bumps many stats), use the batch\nmethods: one atomic backend call, one merged charge, instead of N round-trips.\n\n```ts\nconst res = await client.character.upgradeStatLevelsBatch([\n { CharacterID: \"Knight\", StatID: \"Attack\", Levels: 2 },\n { CharacterID: \"Knight\", StatID: \"Defense\", TargetLevel: 5 },\n { CharacterID: \"Mage\", StatID: \"Attack\" }, // Levels defaults to 1\n]);\nif (!res.ok) return showError(res.error);\nfor (const item of res.data.Items) {\n if (item.Success)\n applyOk(item.Id); // e.g. \"Knight:Attack\"\n else showItemError(item.Id, item.Error); // this one was rejected\n}\n```\n\nEach batch resolves to a **`BatchResponse<T>` wrapper**: `data.Items` is the\nper-item list and `data.Resources` is the one merged charge for the whole batch\n(already applied to the cache). Batch results are **partial-aware**: the outer\n`res.ok` tells you the call ran; each element's `Success`/`Error` tells you\nwhether that item applied. But the resource charge is **all-or-nothing** — if the\nmerged cost can't be paid, every\nincluded item comes back `Success: false`. Items rejected on their own merits\n(already unlocked, unknown id, stat at cap) are filtered out _before_ the charge\nand simply report their reason. `unlockCharactersBatch(ids)` takes a string\narray; the two upgrade batches take `CharacterLevelRef[]` / `CharacterStatRef[]`\nwith the same `Levels`/`TargetLevel` options as the single calls; a ref without\na `CharacterID` targets `\"Main\"`. The server dedupes entries (by id /\n`CharacterID` / `CharacterID`+`StatID`) and processes at most **50 per call** —\nentries past 50 are silently dropped and don't appear in the results at all, so\nchunk larger sets into multiple calls yourself.\n\nOne caveat for stat batches: prerequisite checks use the levels _at the start of\nthe call_, so you can't chain \"raise A to 5, then raise B which requires A@5\" in\na single batch — split dependent steps across calls.\n\n## Gotchas\n\n- **Guard against double-submit.** Each call mints a fresh idempotency key, so\n two separate calls are two real operations — a double-clicked \"Upgrade\" can\n charge twice. Disable the control while a call is in flight. (Firing the same\n endpoint again within the throttle window, default 600 ms, is rejected with\n `reason: \"throttled\"` rather than duplicated, but don't rely on that for\n correctness.) The idempotency key only protects transport-level auto-retries\n inside a single call.\n- **Power is authoritative.** Read `CharacterModel.Power` / `response.Power`;\n never derive it. It's an integer combat score used for PvP ranking/matchmaking.\n **Every** mutating call now returns the recomputed `Power` (unlock, both level\n and stat upgrades, equip, unequip, and each batch item) and the SDK writes it to\n the cached character — so `CharacterModel.Power` is always current after a\n successful call. On `unequipItems` `Power` is nullable (null when the request\n was a no-op that never read the DB).\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth on\n `\"unauthorized\"`, toast the `error` on `\"server\"`).\n- **Lock before you upgrade.** Upgrading stats/levels or equipping on a\n not-yet-owned, non-default character fails with \"locked — unlock it first\".\n- **Equipment truth lives on the item instance.** The per-character `Equipment`\n map is a cache view; the source of truth is each item instance's\n `EquippedSlot`. The SDK keeps both in sync for you — just don't hand-edit.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and state\nfield, stat cost/scaling formulas, rank multipliers, the equip rule matrix, and\nshared stat/level/equipment presets. Read it when building config-driven UI\n(cost previews, upgrade math, slot filters) or when an error message points at a\nconfig rule you need to understand.\n",
5
5
  "references": [
6
6
  {
7
7
  "path": "data-model.md",
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "craft-system",
3
3
  "description": "Build a crafting / trade-up system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.craft (CraftService): load craft recipe definitions and execute a craft that burns input item instances (trade-up by rarity or trade-up by collection) to produce a rolled output item. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants item-fusion / trade-up / salvage UIs, or otherwise touches client.craft, CraftService, CraftDefinitions, CraftDefinition, or CraftResponse — even if they don't name the module explicitly.",
4
- "content": "---\nname: craft-system\ndescription: >-\n Build a crafting / trade-up system in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.craft (CraftService): load craft recipe\n definitions and execute a craft that burns input item instances (trade-up\n by rarity or trade-up by collection) to produce a rolled output item. Use\n this whenever the user is working in the iDosGames TS SDK or its game\n templates (board-game, idle-rpg) and wants item-fusion / trade-up / salvage\n UIs, or otherwise touches client.craft, CraftService, CraftDefinitions,\n CraftDefinition, or CraftResponse — even if they don't name the module\n explicitly.\n---\n\n# Craft system (iDosGames TS SDK)\n\nThe Craft module lets a title define recipes that burn a fixed number of the\nplayer's owned item instances and produce one rolled output item — either\n**trade up by rarity** (N items of `InputRarityID` → one item of\n`OutputRarityID`, any collection) or **trade up by collection** (N items of\n`CollectionID` at `InputRarityID` → one item of the same `CollectionID` at\n`OutputRarityID`). It's **server-authoritative**: the client sends the recipe\nid and the specific item instances to burn, the backend validates\nownership/rarity/collection/cost and rolls the output with a\ncryptographically-secure RNG, and the SDK mirrors the resulting resource\nchanges into the local cache. You never resolve a craft yourself — you call\n`craft()`, check the result, and render from the response + cache.\n\nThis skill is for **using** the production `CraftService`, not for porting or\nextending it. If a craft is rejected, that's the backend enforcing a rule\n(wrong item count, item not in the allowed rarity/collection, insufficient\nprice), or a \"no valid input/output items configured\" state — surface the\nerror, don't try to reproduce the check client-side.\n\n## Key data entities\n\nOnly one config shape and no dedicated player-state shape:\n\n1. **`CraftDefinitions`** (config, same for every player) — the title's\n recipe catalog, keyed by `CraftID`. Fetched with `getDefinitions()`,\n cached under the `\"Craft\"` config section. Each `CraftDefinition` carries\n `Type` (`\"TradeUpRarity\"` | `\"TradeUpCollection\"`), the optional source\n `CatalogID`, `InputRarityID`/`OutputRarityID` (+ `CollectionID` for\n collection trade-ups), `RequiredItemCount`, and `PriceOptions`.\n2. **No player-state slot.** Unlike most other modules, Craft has **no**\n `client.data.user.state?.Craft` entry and **no** dedicated \"player craft\n state\" endpoint — a craft's outcome lives only in the `CraftResponse` and\n in the standard inventory/currency/event-token cache that\n `data.Resources` feeds into. There's nothing to \"load\" besides the recipe\n catalog.\n\nThe input items you burn are **item instances already in the player's\ninventory** — the recipe config only says how many and which\nrarity/collection they must belong to; it never lists specific instance ids.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst craft = client.craft; // the CraftService\n```\n\nEvery craft method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw.\n\n## Methods\n\nBoth methods return `Promise<OperationResult<T>>`: either `{ ok: true, data }`\nor `{ ok: false, reason, error }`. Always branch on `result.ok` before touching\n`result.data`. `reason` is one of `\"client\"` (bad local args, e.g. missing\n`CraftID`), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again\ninside the throttle window), `\"connection\"` (transient, offer Retry),\n`\"validation\"` (response/schema drift), or `\"server\"` (backend rejected it —\n`error` carries the human-readable reason, e.g. wrong input count, item not\nallowed, no outputs configured, insufficient funds).\n\n| Method | Purpose | `data` on success |\n| --------------------------------------------------------- | -------------------------------------------------------------------- | -------------------------- |\n| `getDefinitions()` | Load the title's craft recipe catalog (config). | `CraftDefinitionsResponse` |\n| `craft(craftID, inputItemIDs, count?, selectedOptionID?)` | Burn `inputItemIDs`, execute the recipe, apply the rolled output(s). | `CraftResponse` |\n\nNon-obvious parameter notes:\n\n- **`inputItemIDs` is a template, not a flat list.** Pass **exactly**\n `RequiredItemCount` item-instance ids — one craft's worth — regardless of\n `count`. The server repeats that same template `count` times internally and\n consumes `RequiredItemCount * count` total instances; sending\n `RequiredItemCount * count` ids yourself is rejected (\"InputItemIDs must\n contain exactly `{RequiredItemCount}` items\"). This means every iteration\n in a batched craft burns instances with the _same ids_ you passed — the\n server does not let you target `count` independent sets of instances in one\n call.\n- **`inputItemIDs` are item _instance_ ids, not catalog/definition ids.** The\n server checks each instance's underlying `ItemID` against the recipe's\n allowed-input set (by `InputRarityID`, and by `CollectionID` too for\n `TradeUpCollection`) and that the player actually holds enough of that item\n in total (equipped instances don't count — see Gotchas).\n- **`count`** (default `1`) is clamped server-side to **1–20** per call\n (`Math.Clamp(args.Count, 1, 20)`); passing 0, negative, or above 20 is\n silently clamped into range, not rejected.\n- **`selectedOptionID`** picks one entry of the recipe's `PriceOptions` map.\n Omit it to get the first option in the map (`PriceOptions.First()` —\n insertion order, not necessarily a \"default\" one you'd expect) when the\n recipe has more than one, or the sole option when it has just one. If\n `PriceOptions` is empty/absent, the craft is **free** (only the input items\n are burned). Passing an id that doesn't exist in the map fails with\n `\"Price option '{id}' not found.\"`.\n\nOn success, `craft()` applies `data.Resources` (consumed inputs + price,\ngranted output) to the cached currency/item/event-token balances via the\nshared resource-operation pipeline — read updated balances from the cache as\nusual.\n\n## Reading state and reacting to changes\n\nThere is no `Craft` cache slot to read — drive recipe-card UI off the config\nsection, and drive result UI directly off each `craft()` response plus the\nstandard inventory/currency cache:\n\n```ts\n// Config (cached after getDefinitions()):\nimport type { CraftDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<CraftDefinitions>(\"Craft\");\n\n// After craft(): read burned/rolled output straight off the response —\n// there's no \"last craft\" anywhere in client.data.user.state.\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `craft:definitionsLoaded` → `CraftDefinitions`\n- `craft:completed` → `CraftResponse`\n\nThere is **no** `user:craftUpdated` coarse event for this module (every other\nmodule with a state slot has one; Craft doesn't, because it has no state\nslot). `craft()` still triggers the generic resource-side events as a side\neffect of applying `data.Resources`: `user:inventoryUpdated` (items burned\nand/or granted), `user:virtualCurrencyUpdated` (if a `PriceOptions` entry\ncharges VC), `user:eventTokenUpdated` (if it charges event tokens), and the\numbrella `user:anyUpdated` — each only fires if that bucket actually changed.\n\n```ts\nconst off = client.on(\"craft:completed\", (r) => {\n console.log(`Crafted ${r.CraftID} x${r.CraftedCount}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Load the catalog and render a recipe card\n\n```ts\nawait client.craft.getDefinitions();\nconst defs = client.data.config.getSection<CraftDefinitions>(\"Craft\");\n\nfor (const [craftID, def] of Object.entries(defs?.Definitions ?? {})) {\n // def.Type: \"TradeUpRarity\" | \"TradeUpCollection\"\n // def.InputRarityID / def.OutputRarityID — always present for both types\n // def.CollectionID — only meaningful for \"TradeUpCollection\"\n // def.RequiredItemCount — how many input instances one craft consumes\n // def.PriceOptions: Record<OptionID, { OptionID, Name, Cost, AllowedPlatforms }>\n}\n```\n\n### Trade up by rarity (single craft)\n\n```ts\nconst res = await client.craft.craft(\"rarity-common-to-rare\", [\n \"inst-1\",\n \"inst-2\",\n \"inst-3\",\n]);\nif (!res.ok) return showError(res.error); // e.g. wrong count, wrong rarity\nres.data.Results?.[0]?.Output; // the rolled output entry ({ Type: \"Item\", ItemID, CatalogID, Amount: 1 })\nres.data.Results?.[0]?.BurnedItemIDs; // the instance ids actually consumed for this iteration\n```\n\n`RequiredItemCount` on the definition is the number of input items **per\ncraft** — pass exactly that many `inputItemIDs`, no matter what `count` you\nplan to pass.\n\n### Trade up by collection\n\n```ts\nconst res = await client.craft.craft(\"collection-set-a\", [\n \"inst-1\",\n \"inst-2\",\n \"inst-3\",\n \"inst-4\",\n \"inst-5\",\n]);\nif (!res.ok) return showError(res.error);\nres.data.Results?.[0]?.RolledCollectionID; // == the recipe's CollectionID\nres.data.Results?.[0]?.UsedCollections; // { [collectionID]: RequiredItemCount }\nres.data.Results?.[0]?.Output; // rolled output item, same collection, OutputRarityID\n```\n\n`TradeUpCollection` requires every input instance's `CollectionID` **and**\n`RarityID` to match the recipe's `CollectionID`/`InputRarityID`; the rolled\noutput is drawn only from items of that same `CollectionID` at\n`OutputRarityID` — cross-collection trade-ups use `TradeUpRarity` instead\n(which ignores `CollectionID` entirely, on both the input and the candidate\noutput pool).\n\n### Craft multiple times in one call\n\n```ts\nconst res = await client.craft.craft(\n \"collection-set-a\",\n templateInputInstanceIDs, // exactly RequiredItemCount ids — NOT multiplied by count\n 3, // count\n \"gems\", // selectedOptionID, if the recipe has more than one price option\n);\nif (!res.ok) return showError(res.error);\nres.data.CraftedCount; // how many iterations ran (clamped to 1-20, so may be < your request)\nres.data.Results; // one CraftSingleResult per iteration, each independently rolled\n```\n\nThis is atomic — either all `CraftedCount` iterations are charged and\napplied, or none are. Each iteration rolls its own output independently\n(same input template, `craftCount` separate weighted rolls); results are\nreported per-iteration in `res.data.Results`, indexed `0..CraftedCount-1`.\n\n### Preview cost before crafting\n\n```ts\nconst def = defs?.Definitions?.[\"rarity-common-to-rare\"];\nconst option =\n def?.PriceOptions?.[\"gems\"] ?? Object.values(def?.PriceOptions ?? {})[0];\n// option.Cost.Standard.Entries — cost of ONE craft; multiply by\n// your intended `count` yourself for a display estimate. The server does the\n// same multiplication and may apply PremiumDiscounts you can't predict\n// client-side, so treat any client-side total as an estimate, not a quote.\n```\n\n## Gotchas\n\n- **No cache slot, no coarse event.** Craft doesn't write a\n `client.data.user.state?.Craft` entry or emit a `user:craftUpdated` event —\n only `craft:definitionsLoaded`, `craft:completed`, and the resource-side\n events (`user:inventoryUpdated`, etc.) fire. There is no server-side \"craft\n history\" endpoint either; if you need a history UI, keep it client-side off\n `craft:completed`.\n- **`inputItemIDs` is a per-craft template, always length `RequiredItemCount`\n — never `RequiredItemCount * count`.** Sending more ids than\n `RequiredItemCount` fails with `\"InputItemIDs must contain exactly\n{RequiredItemCount} items (RequiredItemCount).\"` regardless of `count`.\n- **Equipped instances cannot be consumed.** The preflight check counts total\n owned quantity of each required `ItemID`; if it's short, the error\n explicitly says _\"Not enough '{itemID}' to craft. Need {n}, have {m}. Note:\n equipped instances cannot be consumed.\"_ — tell the player to unequip\n first, don't silently swap instances for them.\n- **A recipe can have zero valid outputs and still exist.** If the title's\n item catalog has no item at `OutputRarityID` (and, for collection\n trade-ups, `CollectionID`) with `Weight > 0`, every craft attempt on that\n recipe fails with `\"Trade-up impossible: ...\"` even though `GetDefinitions`\n happily returned the recipe. Don't assume a listed recipe is always\n craftable — surface the server error as-is.\n- **`count` is silently clamped to 1–20**, not validated/rejected — if you\n let players type an arbitrary batch size, clamp and reflect it in your own\n UI so the displayed cost/output count matches what the server will actually\n do (`res.data.CraftedCount` is the ground truth).\n- **Guard against double-submit.** Each call mints a fresh idempotency key\n (`RelatedEntityID`), so two separate calls are two real operations — a\n double-clicked \"Craft\" burns items twice. Disable the control while a call\n is in flight. (Firing the same endpoint again within the throttle window,\n default 600 ms, is rejected with `reason: \"throttled\"` rather than\n duplicated, but don't rely on that for correctness.)\n- **The RNG is server-side and cryptographically secure.** Never predict or\n precompute the rolled output client-side from `Weight`s in the config —\n it's for building an odds-preview UI only, not for guessing the result\n before the response arrives.\n- **Render from the response for this module.** Since there's no dedicated\n state cache, drive craft-result UI (burned items, rolled output, rolled\n collection) directly off `CraftResponse`, then let the standard\n inventory/currency/event-token cache update the rest of the screen.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — full `CraftDefinition`\n/ `CraftPriceOption` field shapes, the exact server-side input/output matching\nrules per `CraftType`, the weighted-roll algorithm, and the preflight\nvalidation order (with verbatim error strings).\n",
4
+ "content": "---\nname: craft-system\ndescription: >-\n Build a crafting / trade-up system in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.craft (CraftService): load craft recipe\n definitions and execute a craft that burns input item instances (trade-up\n by rarity or trade-up by collection) to produce a rolled output item. Use\n this whenever the user is working in the iDosGames TS SDK or its game\n templates (board-game, idle-rpg) and wants item-fusion / trade-up / salvage\n UIs, or otherwise touches client.craft, CraftService, CraftDefinitions,\n CraftDefinition, or CraftResponse — even if they don't name the module\n explicitly.\n---\n\n# Craft system (iDosGames TS SDK)\n\nThe Craft module lets a title define recipes that burn a fixed number of the\nplayer's owned item instances and produce one rolled output item — either\n**trade up by rarity** (N items of `InputRarityID` → one item of\n`OutputRarityID`, any collection) or **trade up by collection** (N items of\n`CollectionID` at `InputRarityID` → one item of the same `CollectionID` at\n`OutputRarityID`). It's **server-authoritative**: the client sends the recipe\nid and the specific item instances to burn, the backend validates\nownership/rarity/collection/cost and rolls the output with a\ncryptographically-secure RNG, and the SDK mirrors the resulting resource\nchanges into the local cache. You never resolve a craft yourself — you call\n`craft()`, check the result, and render from the response + cache.\n\nThis skill is for **using** the production `CraftService`, not for porting or\nextending it. If a craft is rejected, that's the backend enforcing a rule\n(wrong item count, item not in the allowed rarity/collection, insufficient\nprice), or a \"no valid input/output items configured\" state — surface the\nerror, don't try to reproduce the check client-side.\n\n## Key data entities\n\nOnly one config shape and no dedicated player-state shape:\n\n1. **`CraftDefinitions`** (config, same for every player) — the title's\n recipe catalog, keyed by `CraftID`. Fetched with `getDefinitions()`,\n cached under the `\"Craft\"` config section. Each `CraftDefinition` carries\n `Type` (`\"TradeUpRarity\"` | `\"TradeUpCollection\"`), the optional source\n `CatalogID`, `InputRarityID`/`OutputRarityID` (+ `CollectionID` for\n collection trade-ups), `RequiredItemCount`, and `PriceOptions`.\n2. **No player-state slot.** Unlike most other modules, Craft has **no**\n `client.data.user.state?.Craft` entry and **no** dedicated \"player craft\n state\" endpoint — a craft's outcome lives only in the `CraftResponse` and\n in the standard inventory/currency/event-token cache that\n `data.Resources` feeds into. There's nothing to \"load\" besides the recipe\n catalog.\n\nThe input items you burn are **item instances already in the player's\ninventory** — the recipe config only says how many and which\nrarity/collection they must belong to; it never lists specific instance ids.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst craft = client.craft; // the CraftService\n```\n\nEvery craft method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw.\n\n## Methods\n\nBoth methods return `Promise<OperationResult<T>>`: either `{ ok: true, data }`\nor `{ ok: false, reason, error }`. Always branch on `result.ok` before touching\n`result.data`. `reason` is one of `\"client\"` (bad local args, e.g. missing\n`CraftID`), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again\ninside the throttle window), `\"connection\"` (transient, offer Retry),\n`\"validation\"` (response/schema drift), or `\"server\"` (backend rejected it —\n`error` carries the human-readable reason, e.g. wrong input count, item not\nallowed, no outputs configured, insufficient funds).\n\n| Method | Purpose | `data` on success |\n| ---------------------------------------------------------------------------- | ---------------------------------------------------------------- | -------------------------- |\n| `getDefinitions()` | Load the title's craft recipe catalog (config). | `CraftDefinitionsResponse` |\n| `craft(craftID, inputItemIDs, count?, selectedOptionID?, inputInstanceIDs?)` | Burn the inputs, execute the recipe, apply the rolled output(s). | `CraftResponse` |\n\nNon-obvious parameter notes:\n\n- **`inputItemIDs` is a template, not a flat list.** Pass **exactly**\n `RequiredItemCount` item-instance ids — one craft's worth — regardless of\n `count`. The server repeats that same template `count` times internally and\n consumes `RequiredItemCount * count` total instances; sending\n `RequiredItemCount * count` ids yourself is rejected (\"InputItemIDs must\n contain exactly `{RequiredItemCount}` items\"). This means every iteration\n in a batched craft burns instances with the _same ids_ you passed — the\n server does not let you target `count` independent sets of instances in one\n call.\n- **`inputItemIDs` are catalog `ItemID`s, not instance ids.** The server checks\n each id against the recipe's allowed-input set (by `InputRarityID`, and by\n `CollectionID` too for `TradeUpCollection`) and that the player holds enough\n of that item in total (equipped instances don't count — see Gotchas). WHICH\n copies burn is decided by the recipe's `InputSelection` — see \"Output level\n from input levels\" below; to name specific instances use `inputInstanceIDs`.\n- **`inputInstanceIDs`** — only for a recipe with `InputSelection:\n\"ClientSelected\"`. One instance id per **unstackable** input of the\n _expanded_ list (template × `count`), in the same order; stackable inputs\n take no slot. A bundle's id may repeat once per unit it holds. Sending them to\n any other recipe is rejected (`\"InputInstanceIDs are accepted only by a craft\nwith InputSelection = ClientSelected.\"`); omitting them on a ClientSelected\n recipe falls back to the server picking non-upgraded copies only.\n- **`count`** (default `1`) is clamped server-side to **1–20** per call\n (`Math.Clamp(args.Count, 1, 20)`); passing 0, negative, or above 20 is\n silently clamped into range, not rejected.\n- **`selectedOptionID`** picks one entry of the recipe's `PriceOptions` map.\n Omit it to get the first option in the map (`PriceOptions.First()` —\n insertion order, not necessarily a \"default\" one you'd expect) when the\n recipe has more than one, or the sole option when it has just one. If\n `PriceOptions` is empty/absent, the craft is **free** (only the input items\n are burned). Passing an id that doesn't exist in the map fails with\n `\"Price option '{id}' not found.\"`.\n\nOn success, `craft()` applies `data.Resources` (consumed inputs + price,\ngranted output) to the cached currency/item/event-token balances via the\nshared resource-operation pipeline — read updated balances from the cache as\nusual.\n\n## Reading state and reacting to changes\n\nThere is no `Craft` cache slot to read — drive recipe-card UI off the config\nsection, and drive result UI directly off each `craft()` response plus the\nstandard inventory/currency cache:\n\n```ts\n// Config (cached after getDefinitions()):\nimport type { CraftDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<CraftDefinitions>(\"Craft\");\n\n// After craft(): read burned/rolled output straight off the response —\n// there's no \"last craft\" anywhere in client.data.user.state.\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `craft:definitionsLoaded` → `CraftDefinitions`\n- `craft:completed` → `CraftResponse`\n\nThere is **no** `user:craftUpdated` coarse event for this module (every other\nmodule with a state slot has one; Craft doesn't, because it has no state\nslot). `craft()` still triggers the generic resource-side events as a side\neffect of applying `data.Resources`: `user:inventoryUpdated` (items burned\nand/or granted), `user:virtualCurrencyUpdated` (if a `PriceOptions` entry\ncharges VC), `user:eventTokenUpdated` (if it charges event tokens), and the\numbrella `user:anyUpdated` — each only fires if that bucket actually changed.\n\n```ts\nconst off = client.on(\"craft:completed\", (r) => {\n console.log(`Crafted ${r.CraftID} x${r.CraftedCount}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Load the catalog and render a recipe card\n\n```ts\nawait client.craft.getDefinitions();\nconst defs = client.data.config.getSection<CraftDefinitions>(\"Craft\");\n\nfor (const [craftID, def] of Object.entries(defs?.Definitions ?? {})) {\n // def.Type: \"TradeUpRarity\" | \"TradeUpCollection\"\n // def.InputRarityID / def.OutputRarityID — always present for both types\n // def.CollectionID — only meaningful for \"TradeUpCollection\"\n // def.RequiredItemCount — how many input instances one craft consumes\n // def.PriceOptions: Record<OptionID, { OptionID, Name, Cost, AllowedPlatforms }>\n}\n```\n\n### Trade up by rarity (single craft)\n\n```ts\nconst res = await client.craft.craft(\"rarity-common-to-rare\", [\n \"inst-1\",\n \"inst-2\",\n \"inst-3\",\n]);\nif (!res.ok) return showError(res.error); // e.g. wrong count, wrong rarity\nres.data.Results?.[0]?.Output; // the rolled output entry ({ Type: \"Item\", ItemID, CatalogID, Amount: 1 })\nres.data.Results?.[0]?.BurnedItemIDs; // the catalog ItemIDs consumed in this iteration (your template)\n```\n\n`RequiredItemCount` on the definition is the number of input items **per\ncraft** — pass exactly that many `inputItemIDs`, no matter what `count` you\nplan to pass.\n\n### Trade up by collection\n\n```ts\nconst res = await client.craft.craft(\"collection-set-a\", [\n \"inst-1\",\n \"inst-2\",\n \"inst-3\",\n \"inst-4\",\n \"inst-5\",\n]);\nif (!res.ok) return showError(res.error);\nres.data.Results?.[0]?.RolledCollectionID; // == the recipe's CollectionID\nres.data.Results?.[0]?.UsedCollections; // { [collectionID]: RequiredItemCount }\nres.data.Results?.[0]?.Output; // rolled output item, same collection, OutputRarityID\n```\n\n`TradeUpCollection` requires every input instance's `CollectionID` **and**\n`RarityID` to match the recipe's `CollectionID`/`InputRarityID`; the rolled\noutput is drawn only from items of that same `CollectionID` at\n`OutputRarityID` — cross-collection trade-ups use `TradeUpRarity` instead\n(which ignores `CollectionID` entirely, on both the input and the candidate\noutput pool).\n\n### Craft multiple times in one call\n\n```ts\nconst res = await client.craft.craft(\n \"collection-set-a\",\n templateInputInstanceIDs, // exactly RequiredItemCount ids — NOT multiplied by count\n 3, // count\n \"gems\", // selectedOptionID, if the recipe has more than one price option\n);\nif (!res.ok) return showError(res.error);\nres.data.CraftedCount; // how many iterations ran (clamped to 1-20, so may be < your request)\nres.data.Results; // one CraftSingleResult per iteration, each independently rolled\n```\n\nThis is atomic — either all `CraftedCount` iterations are charged and\napplied, or none are. Each iteration rolls its own output independently\n(same input template, `craftCount` separate weighted rolls); results are\nreported per-iteration in `res.data.Results`, indexed `0..CraftedCount-1`.\n\n### Output level from input levels\n\nA recipe can make the output inherit the level of its inputs. Three config\nfields, all defaulting to the legacy behaviour:\n\n| Field | Values | Absent = |\n| --------------------- | ------------------------------------------------------------------------------ | --------------------------------- |\n| `OutputLevelMode` | `None` / `Min` / `Average` (rounded down) / `Max` — per craft | `None` (output is level 1) |\n| `InputSelection` | `ProtectLeveled` / `ClientSelected` / `LowestLevelFirst` / `HighestLevelFirst` | `ProtectLeveled` (only Level ≤ 1) |\n| `OutputLevelOverflow` | `Clamp` / `Reject` — when the level exceeds the output's `Upgrade.MaxLevel` | `Clamp` |\n\nStackable inputs and non-upgraded copies count as level 1; an output that is\nstackable or has no `Upgrade` is capped at level 1. With `ProtectLeveled`,\n`OutputLevelMode` has no effect — upgraded copies never enter the craft.\nEquipped and expired instances are never burned in any mode.\n\n```ts\n// Recipe: { OutputLevelMode: \"Average\", InputSelection: \"ClientSelected\", RequiredItemCount: 2 }\nconst res = await client.craft.craft(\n \"merge-swords\",\n [\"sword\", \"sword\"], // catalog ItemIDs — the template\n 1,\n undefined,\n [\"inst-lv4\", \"inst-lv7\"], // which copies to burn\n);\nif (!res.ok) return showError(res.error);\nres.data.Results?.[0]?.OutputLevel; // 5 — (4 + 7) / 2, rounded down\nres.data.Results?.[0]?.BurnedInstances; // [{ ItemInstanceID, ItemID, Level, Units }, …]\n```\n\n`Reject` is checked against the **lowest** `MaxLevel` of all possible outputs,\nbefore the roll, so the same request never passes on one try and fails on\nanother. A retry with the same idempotency key after the inputs were already\nburned returns the original result (`Results` empty) instead of \"not found\".\n\n### Preview cost before crafting\n\n```ts\nconst def = defs?.Definitions?.[\"rarity-common-to-rare\"];\nconst option =\n def?.PriceOptions?.[\"gems\"] ?? Object.values(def?.PriceOptions ?? {})[0];\n// option.Cost.Standard.Entries — cost of ONE craft; multiply by\n// your intended `count` yourself for a display estimate. The server does the\n// same multiplication and may apply PremiumDiscounts you can't predict\n// client-side, so treat any client-side total as an estimate, not a quote.\n```\n\n## Gotchas\n\n- **No cache slot, no coarse event.** Craft doesn't write a\n `client.data.user.state?.Craft` entry or emit a `user:craftUpdated` event —\n only `craft:definitionsLoaded`, `craft:completed`, and the resource-side\n events (`user:inventoryUpdated`, etc.) fire. There is no server-side \"craft\n history\" endpoint either; if you need a history UI, keep it client-side off\n `craft:completed`.\n- **`inputItemIDs` is a per-craft template, always length `RequiredItemCount`\n — never `RequiredItemCount * count`.** Sending more ids than\n `RequiredItemCount` fails with `\"InputItemIDs must contain exactly\n{RequiredItemCount} items (RequiredItemCount).\"` regardless of `count`.\n- **Equipped instances cannot be consumed.** The preflight check counts total\n owned quantity of each required `ItemID`; if it's short, the error\n explicitly says _\"Not enough '{itemID}' to craft. Need {n}, have {m}. Note:\n equipped instances cannot be consumed.\"_ — tell the player to unequip\n first, don't silently swap instances for them.\n- **A recipe can have zero valid outputs and still exist.** If the title's\n item catalog has no item at `OutputRarityID` (and, for collection\n trade-ups, `CollectionID`) with `Weight > 0`, every craft attempt on that\n recipe fails with `\"Trade-up impossible: ...\"` even though `GetDefinitions`\n happily returned the recipe. Don't assume a listed recipe is always\n craftable — surface the server error as-is.\n- **`count` is silently clamped to 1–20**, not validated/rejected — if you\n let players type an arbitrary batch size, clamp and reflect it in your own\n UI so the displayed cost/output count matches what the server will actually\n do (`res.data.CraftedCount` is the ground truth).\n- **Guard against double-submit.** Each call mints a fresh idempotency key\n (`RelatedEntityID`), so two separate calls are two real operations — a\n double-clicked \"Craft\" burns items twice. Disable the control while a call\n is in flight. (Firing the same endpoint again within the throttle window,\n default 600 ms, is rejected with `reason: \"throttled\"` rather than\n duplicated, but don't rely on that for correctness.)\n- **The RNG is server-side and cryptographically secure.** Never predict or\n precompute the rolled output client-side from `Weight`s in the config —\n it's for building an odds-preview UI only, not for guessing the result\n before the response arrives.\n- **Render from the response for this module.** Since there's no dedicated\n state cache, drive craft-result UI (burned items, rolled output, rolled\n collection) directly off `CraftResponse`, then let the standard\n inventory/currency/event-token cache update the rest of the screen.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — full `CraftDefinition`\n/ `CraftPriceOption` field shapes, the exact server-side input/output matching\nrules per `CraftType`, the weighted-roll algorithm, and the preflight\nvalidation order (with verbatim error strings).\n",
5
5
  "references": [
6
6
  {
7
7
  "path": "data-model.md",
8
- "content": "# Craft data model — reference\n\nFull shape of the config (`CraftDefinitions`), the server-side input/output\nmatching rules per `CraftType`, the weighted-roll algorithm, and the preflight\nvalidation order with verbatim error strings. All of these are **strictly\ntyped in the SDK** — `CraftDefinitions`, `CraftDefinition`, `CraftPriceOption`,\n`CraftResponse`, `CraftSingleResult` are exported from `@idosgames/core`, so\n`getDefinitions()` and `getSection<CraftDefinitions>(\"Craft\")` give you\nconcrete types, not `unknown`. The schemas keep `.passthrough()`, so a field\nthe backend adds later still round-trips. Field names are PascalCase (straight\nfrom the backend JSON).\n\n## Contents\n\n- [Config: CraftDefinitions](#config-craftdefinitions) — what `getDefinitions()` returns\n- [CraftDefinition](#craftdefinition)\n- [CraftPriceOption](#craftpriceoption)\n- [CraftType matching rules](#crafttype-matching-rules) — exactly what makes an input/output \"allowed\"\n- [The craft flow, in order](#the-craft-flow-in-order) — validation → preflight → roll → apply\n- [Weighted roll algorithm](#weighted-roll-algorithm)\n- [Response shapes](#response-shapes)\n\n---\n\n## Config: CraftDefinitions\n\nReturned by `getDefinitions()` as `CraftDefinitionsResponse`; cached via\n`client.data.config.getSection<CraftDefinitions>(\"Craft\")`.\n\n```ts\ninterface CraftDefinitions {\n Definitions?: Record<string, CraftDefinition> | null; // key = CraftID\n}\n```\n\n---\n\n## CraftDefinition\n\nOne recipe. Source: `IDosGamesSDK/API/Client/v2/Craft/Models/CraftDefinitions.cs`.\n\n```ts\ninterface CraftDefinition {\n CraftID?: string;\n Type?: \"TradeUpRarity\" | \"TradeUpCollection\"; // default on the backend is TradeUpRarity\n\n // Source item catalog (V2: ItemDefinitions.Catalogs[CatalogID]).\n // Empty/absent -> ItemDefinition lookup runs across ALL of the title's catalogs.\n CatalogID?: string;\n\n // Used only when Type === \"TradeUpCollection\". Both input AND output items'\n // Metadata.CollectionID must equal this. Ignored entirely for TradeUpRarity.\n CollectionID?: string;\n\n InputRarityID?: string; // required for both CraftTypes\n OutputRarityID?: string; // required for both CraftTypes\n\n RequiredItemCount?: number; // default 10 on the backend (\"usually 10, CS trade-up\")\n\n PriceOptions?: Record<string, CraftPriceOption>; // key = OptionID; empty/absent = free craft\n}\n```\n\nNote the backend default of `RequiredItemCount = 10` and `Type =\nTradeUpRarity` only apply when a title's config omits the field entirely —\nalways read the value the server actually returned rather than assuming 10.\n\n---\n\n## PriceOption\n\nOne payment option for a recipe — the platform-wide price shape, identical in\nevery module (see the `checkout-system` skill).\n\n```ts\ninterface PriceOption {\n OptionID?: string; // equals the dictionary key; the server substitutes it when empty\n Name?: string;\n Cost?: ResourceConsume; // cost of ONE craft; server multiplies by `count`\n AllowedPlatforms?: (\"Web\" | \"Android\" | \"Ios\")[]; // empty = every platform\n AssetPaths?: Record<string, string>;\n}\n```\n\n⚠ **A craft can never be paid in a store.** The price is per craft and multiplied\nby `count`, while a receipt pays for exactly one SKU — there is no \"one and a half\nreceipts\" for a batch of one and a half crafts. A `Purchase` entry here is rejected\nwith `\"Craft cannot be paid in a store.\"`\n\n`Cost` is the shared `ResourceConsume` shape (`Standard.Entries`\nfor VC/item costs, `Standard.EventTokens` for event-token costs,\n`PremiumDiscounts` for subscription-tier discounts). See the currency-system\nskill / `ResourceModels.ts` for the full shape — Craft doesn't add anything\ncraft-specific to it.\n\nSelection logic (`SelectPriceOption` in `Craft.cs`):\n\n- `PriceOptions` empty or absent → the craft is **free**: a virtual option with\n an empty cost is used, no input other than the burned items.\n- `selectedOptionID` omitted, but `PriceOptions` non-empty → the **first option\n available on the caller's platform**, ordered by `OptionID`. The order is\n explicit (not dictionary order) so the default is deterministic — but it is\n still \"first\", not \"cheapest\".\n- `selectedOptionID` provided but not found in the map → fails with\n `\"Price option '{selectedOptionID}' not found.\"`.\n\n---\n\n## CraftType matching rules\n\nBoth `CraftType`s run the same shape of validation; the difference is which\n`ItemDefinition.Metadata` fields the input/output item pools are filtered by.\nSource: `CraftTradeUpCollection` / `CraftTradeUpRarity` in `Craft.cs`.\n\n### TradeUpRarity\n\n| Pool | Filter |\n| ----------------- | ----------------------------------------------------------------------------------- |\n| Allowed inputs | `Metadata.RarityID == InputRarityID` (any `CollectionID`, cross-collection allowed) |\n| Candidate outputs | `Metadata.RarityID == OutputRarityID` **and** `Weight > 0` |\n\nEvery item scanned comes from `CatalogID` if set, else every catalog on the\ntitle (`EnumerateCatalogItems`).\n\n- No items match the input filter → `\"No INPUT items found for\nRarity='{InputRarityID}'.\"`\n- No items match the output filter → `\"Trade-up impossible: no outputs for\nrarity '{OutputRarityID}' with Weight > 0.\"`\n- A submitted input instance's `ItemID` isn't in the allowed-input set →\n `\"Item {itemID} is not allowed. Expected Rarity='{InputRarityID}'.\"`\n\n### TradeUpCollection\n\n| Pool | Filter |\n| ----------------- | ---------------------------------------------------------------------------------------------------------- |\n| Allowed inputs | `Metadata.CollectionID == CollectionID` **and** `Metadata.RarityID == InputRarityID` |\n| Candidate outputs | `Metadata.CollectionID == CollectionID` **and** `Metadata.RarityID == OutputRarityID` **and** `Weight > 0` |\n\n- `CollectionID` missing on the recipe config → `\"Craft config: CollectionID\nis required.\"`\n- No items match the input filter → `\"No INPUT items found for\nCollectionID='{CollectionID}' and Rarity='{InputRarityID}'.\"`\n- No items match the output filter → `\"Trade-up impossible: CollectionID='\n{CollectionID}' has no outputs for rarity '{OutputRarityID}' with Weight >\n0.\"`\n- A submitted input instance's `ItemID` isn't in the allowed-input set →\n `\"Item {itemID} is not allowed. Expected CollectionID='{CollectionID}',\nRarity='{InputRarityID}'.\"`\n\nIn both types, `Metadata` is the `ItemDefinition.Metadata` block\n(`RarityID`, `CollectionID`, `AuthorID`) — an item with no `Metadata` at all\nnever matches either pool.\n\n---\n\n## The craft flow, in order\n\n`Craft()` in `Craft.cs` runs these steps; the SDK's `craft()` is a thin pass\nthrough, so every one of these can surface as a `reason: \"server\"` error:\n\n1. **`CraftID` required** → `\"CraftID is required.\"`\n2. **Recipe must exist** in `titleConfig.Craft.Definitions` → `\"Craft config\nnot found.\"`\n3. **Title must have item definitions configured** → `\"Item definitions are\nnot configured for this title.\"`\n4. **`count` clamp** — `craftCount = Math.Clamp(args.Count, 1, 20)`. Values\n outside `[1, 20]` are silently clamped, never rejected.\n5. **Price option selection** (see above).\n6. **`RequiredItemCount` template check** —\n `requiredPerCraft = Math.Max(1, craftConfig.RequiredItemCount)`;\n `InputItemIDs.Count` must equal `requiredPerCraft` exactly, regardless of\n `craftCount` → `\"InputItemIDs must contain exactly {requiredPerCraft}\nitems (RequiredItemCount).\"` The server then builds the real burn list by\n repeating your template `craftCount` times\n (`Enumerable.Repeat(args.InputItemIDs, craftCount).SelectMany(x => x)`).\n7. **Build allowed-input / candidate-output pools** from the item catalog per\n `CraftType` (see above), fail fast if either is empty.\n8. **Validate every (repeated) input instance's `ItemID`** is in the\n allowed-input set (see per-type error strings above).\n9. **Preflight balance check** (`ValidatePreflightBalances`) — read-only,\n before any RNG roll, so a doomed craft never wastes a roll:\n - Input items: total owned quantity (`ItemTotals.TotalAmount`, i.e.\n **includes equipped instances in the count but excludes them from what's\n consumable** — see the Gotchas note in the main skill) must be `>=`\n the required quantity per `ItemID` → `\"Not enough '{itemID}' to craft.\nNeed {n}, have {m}. Note: equipped instances cannot be consumed.\"`\n - Price `Item` entries: combined with any input-item need for the same\n `ItemID` → `\"Not enough '{itemID}' (input + price). Need {combined}\n(input={a}, price={b}), have {have}.\"`\n - Price `VirtualCurrency` entries → `\"Not enough '{currencyID}'. Need\n{n}, have {m}.\"`\n - Price `EventTokens` entries → `\"Not enough event tokens. Need {n}, have\n{m}.\"`\n - Price entries of type `CryptoCurrency` skip this preflight (checked\n later, decimal-precise, inside the atomic apply).\n - Price entries of type `Purchase` are rejected outright → `\"Craft cannot be\npaid in a store.\"` (see the PriceOption section above)\n - **This preflight is intentionally conservative**: it checks the full\n undiscounted price. `PremiumDiscounts` are applied later, only inside\n `ResourceService`'s atomic apply — so a player with a discount may see\n the preflight \"pass\" at a higher number than what's actually charged,\n never the reverse.\n10. **Roll one output per iteration** (`craftCount` independent weighted\n rolls — see below) only after preflight passes, so RNG is never spent on\n a craft that was going to fail anyway.\n11. **Build the `ResourceOperation`** — `Consume.Standard.Entries` = grouped\n input items (by `ItemID`, summed count) + price `Item`/`VirtualCurrency`\n entries (each `Amount * craftCount`); `Consume.Standard.EventTokens` =\n price event-token entries (`Amount * craftCount`);\n `Consume.PremiumDiscounts` passed through from the price option;\n `Grant.Standard.Entries` = the rolled outputs (one `Item` entry per\n iteration, `Amount: 1` each).\n12. **Atomic apply** via `ResourceService.ApplyResourceOperationAtomicAsync`\n — OCC-guarded against `InventoryV2.Version` with retries, idempotent by\n `reason: \"Craft:{RelatedEntityID}\"` (the TS SDK always sends a fresh\n UUID-suffixed `RelatedEntityID`, so in practice every SDK-initiated call\n is a distinct operation — see the \"guard against double-submit\" gotcha in\n the main skill). Failure → `\"Craft failed: {error}\"`.\n\nAll of steps 6–12 run per-`CraftType` but are otherwise identical between\n`TradeUpCollection` and `TradeUpRarity`.\n\n---\n\n## Weighted roll algorithm\n\n`RollWeightedDef` in `Craft.cs`: a linear cumulative-weight scan over the\ncandidate-output pool (`(ItemDefinition, Weight)` pairs, `Weight` taken from\neach `ItemDefinition.Weight`), driven by `NextInt64`, a rejection-sampled\ndraw from `RandomNumberGenerator` (cryptographic RNG, not `System.Random`)\nthat removes modulo bias. One craft with `count = N` performs **N\nindependent rolls** against the same pool — there is no shared pity/duplicate\nprotection across iterations of one call, and no cross-call pity system\nanywhere in Craft.\n\nBecause the pool is rebuilt once per call (not once per iteration) from the\nsame `titleConfig` snapshot, all `N` iterations in one `craft()` call roll\nagainst an identical odds table.\n\n---\n\n## Response shapes\n\n```ts\ninterface CraftResponse {\n ServerTimeUtc: string; // ISO datetime\n Type?: \"TradeUpRarity\" | \"TradeUpCollection\";\n CraftID: string;\n CraftedCount?: number; // == the clamped craftCount that actually ran\n SelectedOptionID?: string; // the option actually charged (resolved default if you omitted it)\n InputRarity?: string; // echoes craftConfig.InputRarityID\n OutputRarity?: string; // echoes craftConfig.OutputRarityID\n Resources?: ResourceOperation; // Consume = burned inputs + price; Grant = rolled outputs\n Results?: CraftSingleResult[]; // one entry per iteration, index 0..CraftedCount-1\n}\n\ninterface CraftSingleResult {\n Index?: number;\n BurnedItemIDs?: string[]; // the instance ids consumed in this specific iteration\n RolledCollectionID?: string; // TradeUpCollection only — == the recipe's CollectionID\n UsedCollections?: Record<string, number>; // TradeUpCollection only — { [CollectionID]: RequiredItemCount }\n Output?: ResourceEntry; // the rolled item: { Type: \"Item\", ItemID, CatalogID, Amount: 1 }\n}\n```\n\n`RolledCollectionID` / `UsedCollections` are populated only when\n`collectionID` is non-empty when building the result (i.e. only for\n`TradeUpCollection` — `TradeUpRarity` always leaves both `undefined`, per the\n`BuildSingleResults` helper's `collectionID: null` argument on the rarity\npath).\n"
8
+ "content": "# Craft data model — reference\n\nFull shape of the config (`CraftDefinitions`), the server-side input/output\nmatching rules per `CraftType`, the weighted-roll algorithm, and the preflight\nvalidation order with verbatim error strings. All of these are **strictly\ntyped in the SDK** — `CraftDefinitions`, `CraftDefinition`, `CraftPriceOption`,\n`CraftResponse`, `CraftSingleResult` are exported from `@idosgames/core`, so\n`getDefinitions()` and `getSection<CraftDefinitions>(\"Craft\")` give you\nconcrete types, not `unknown`. The schemas keep `.passthrough()`, so a field\nthe backend adds later still round-trips. Field names are PascalCase (straight\nfrom the backend JSON).\n\n## Contents\n\n- [Config: CraftDefinitions](#config-craftdefinitions) — what `getDefinitions()` returns\n- [CraftDefinition](#craftdefinition)\n- [CraftPriceOption](#craftpriceoption)\n- [CraftType matching rules](#crafttype-matching-rules) — exactly what makes an input/output \"allowed\"\n- [The craft flow, in order](#the-craft-flow-in-order) — validation → preflight → roll → apply\n- [Weighted roll algorithm](#weighted-roll-algorithm)\n- [Response shapes](#response-shapes)\n\n---\n\n## Config: CraftDefinitions\n\nReturned by `getDefinitions()` as `CraftDefinitionsResponse`; cached via\n`client.data.config.getSection<CraftDefinitions>(\"Craft\")`.\n\n```ts\ninterface CraftDefinitions {\n Definitions?: Record<string, CraftDefinition> | null; // key = CraftID\n}\n```\n\n---\n\n## CraftDefinition\n\nOne recipe. Source: `IDosGamesSDK/API/Client/v2/Craft/Models/CraftDefinitions.cs`.\n\n```ts\ninterface CraftDefinition {\n CraftID?: string;\n Type?: \"TradeUpRarity\" | \"TradeUpCollection\"; // default on the backend is TradeUpRarity\n\n // Source item catalog (V2: ItemDefinitions.Catalogs[CatalogID]).\n // Empty/absent -> ItemDefinition lookup runs across ALL of the title's catalogs.\n CatalogID?: string;\n\n // Used only when Type === \"TradeUpCollection\". Both input AND output items'\n // Metadata.CollectionID must equal this. Ignored entirely for TradeUpRarity.\n CollectionID?: string;\n\n InputRarityID?: string; // required for both CraftTypes\n OutputRarityID?: string; // required for both CraftTypes\n\n RequiredItemCount?: number; // default 10 on the backend (\"usually 10, CS trade-up\")\n\n PriceOptions?: Record<string, CraftPriceOption>; // key = OptionID; empty/absent = free craft\n\n // Output level from input levels — all absent = legacy behaviour.\n OutputLevelMode?: \"None\" | \"Min\" | \"Average\" | \"Max\"; // per craft; Average rounds down; absent = None\n InputSelection?:\n | \"ProtectLeveled\"\n | \"ClientSelected\"\n | \"LowestLevelFirst\"\n | \"HighestLevelFirst\"; // absent = ProtectLeveled\n OutputLevelOverflow?: \"Clamp\" | \"Reject\"; // vs the output's Upgrade.MaxLevel; absent = Clamp\n}\n```\n\n- `ProtectLeveled` burns only non-upgraded copies (oldest first) — the only\n behaviour that existed before; with it `OutputLevelMode` has no effect.\n- `ClientSelected` burns the instances named in `InputInstanceIDs` (any\n level); without them it behaves like `ProtectLeveled`.\n- `LowestLevelFirst` / `HighestLevelFirst` — the server picks by level\n (then oldest first). Equipped and expired instances are never picked.\n- The output cap is `Upgrade.MaxLevel`; a stackable output or one without\n `Upgrade` is capped at 1. `Reject` compares against the lowest cap in the\n output pool, before the roll.\n\nNote the backend default of `RequiredItemCount = 10` and `Type =\nTradeUpRarity` only apply when a title's config omits the field entirely —\nalways read the value the server actually returned rather than assuming 10.\n\n---\n\n## PriceOption\n\nOne payment option for a recipe — the platform-wide price shape, identical in\nevery module (see the `checkout-system` skill).\n\n```ts\ninterface PriceOption {\n OptionID?: string; // equals the dictionary key; the server substitutes it when empty\n Name?: string;\n Cost?: ResourceConsume; // cost of ONE craft; server multiplies by `count`\n AllowedPlatforms?: (\"Web\" | \"Android\" | \"Ios\")[]; // empty = every platform\n AssetPaths?: Record<string, string>;\n}\n```\n\n⚠ **A craft can never be paid in a store.** The price is per craft and multiplied\nby `count`, while a receipt pays for exactly one SKU — there is no \"one and a half\nreceipts\" for a batch of one and a half crafts. A `Purchase` entry here is rejected\nwith `\"Craft cannot be paid in a store.\"`\n\n`Cost` is the shared `ResourceConsume` shape (`Standard.Entries`\nfor VC/item costs, `Standard.EventTokens` for event-token costs,\n`PremiumDiscounts` for subscription-tier discounts). See the currency-system\nskill / `ResourceModels.ts` for the full shape — Craft doesn't add anything\ncraft-specific to it.\n\nSelection logic (`SelectPriceOption` in `Craft.cs`):\n\n- `PriceOptions` empty or absent → the craft is **free**: a virtual option with\n an empty cost is used, no input other than the burned items.\n- `selectedOptionID` omitted, but `PriceOptions` non-empty → the **first option\n available on the caller's platform**, ordered by `OptionID`. The order is\n explicit (not dictionary order) so the default is deterministic — but it is\n still \"first\", not \"cheapest\".\n- `selectedOptionID` provided but not found in the map → fails with\n `\"Price option '{selectedOptionID}' not found.\"`.\n\n---\n\n## CraftType matching rules\n\nBoth `CraftType`s run the same shape of validation; the difference is which\n`ItemDefinition.Metadata` fields the input/output item pools are filtered by.\nSource: `CraftTradeUpCollection` / `CraftTradeUpRarity` in `Craft.cs`.\n\n### TradeUpRarity\n\n| Pool | Filter |\n| ----------------- | ----------------------------------------------------------------------------------- |\n| Allowed inputs | `Metadata.RarityID == InputRarityID` (any `CollectionID`, cross-collection allowed) |\n| Candidate outputs | `Metadata.RarityID == OutputRarityID` **and** `Weight > 0` |\n\nEvery item scanned comes from `CatalogID` if set, else every catalog on the\ntitle (`EnumerateCatalogItems`).\n\n- No items match the input filter → `\"No INPUT items found for\nRarity='{InputRarityID}'.\"`\n- No items match the output filter → `\"Trade-up impossible: no outputs for\nrarity '{OutputRarityID}' with Weight > 0.\"`\n- A submitted input instance's `ItemID` isn't in the allowed-input set →\n `\"Item {itemID} is not allowed. Expected Rarity='{InputRarityID}'.\"`\n\n### TradeUpCollection\n\n| Pool | Filter |\n| ----------------- | ---------------------------------------------------------------------------------------------------------- |\n| Allowed inputs | `Metadata.CollectionID == CollectionID` **and** `Metadata.RarityID == InputRarityID` |\n| Candidate outputs | `Metadata.CollectionID == CollectionID` **and** `Metadata.RarityID == OutputRarityID` **and** `Weight > 0` |\n\n- `CollectionID` missing on the recipe config → `\"Craft config: CollectionID\nis required.\"`\n- No items match the input filter → `\"No INPUT items found for\nCollectionID='{CollectionID}' and Rarity='{InputRarityID}'.\"`\n- No items match the output filter → `\"Trade-up impossible: CollectionID='\n{CollectionID}' has no outputs for rarity '{OutputRarityID}' with Weight >\n0.\"`\n- A submitted input instance's `ItemID` isn't in the allowed-input set →\n `\"Item {itemID} is not allowed. Expected CollectionID='{CollectionID}',\nRarity='{InputRarityID}'.\"`\n\nIn both types, `Metadata` is the `ItemDefinition.Metadata` block\n(`RarityID`, `CollectionID`, `AuthorID`) — an item with no `Metadata` at all\nnever matches either pool.\n\n---\n\n## The craft flow, in order\n\n`Craft()` in `Craft.cs` runs these steps; the SDK's `craft()` is a thin pass\nthrough, so every one of these can surface as a `reason: \"server\"` error:\n\n1. **`CraftID` required** → `\"CraftID is required.\"`\n2. **Recipe must exist** in `titleConfig.Craft.Definitions` → `\"Craft config\nnot found.\"`\n3. **Title must have item definitions configured** → `\"Item definitions are\nnot configured for this title.\"`\n4. **`count` clamp** — `craftCount = Math.Clamp(args.Count, 1, 20)`. Values\n outside `[1, 20]` are silently clamped, never rejected.\n5. **Price option selection** (see above).\n6. **`RequiredItemCount` template check** —\n `requiredPerCraft = Math.Max(1, craftConfig.RequiredItemCount)`;\n `InputItemIDs.Count` must equal `requiredPerCraft` exactly, regardless of\n `craftCount` → `\"InputItemIDs must contain exactly {requiredPerCraft}\nitems (RequiredItemCount).\"` The server then builds the real burn list by\n repeating your template `craftCount` times\n (`Enumerable.Repeat(args.InputItemIDs, craftCount).SelectMany(x => x)`).\n7. **Build allowed-input / candidate-output pools** from the item catalog per\n `CraftType` (see above), fail fast if either is empty.\n8. **Validate every (repeated) input instance's `ItemID`** is in the\n allowed-input set (see per-type error strings above).\n9. **Preflight balance check** (`ValidatePreflightBalances`) — read-only,\n before any RNG roll, so a doomed craft never wastes a roll:\n - Input items: total owned quantity (`ItemTotals.TotalAmount`, i.e.\n **includes equipped instances in the count but excludes them from what's\n consumable** — see the Gotchas note in the main skill) must be `>=`\n the required quantity per `ItemID` → `\"Not enough '{itemID}' to craft.\nNeed {n}, have {m}. Note: equipped instances cannot be consumed.\"`\n - Price `Item` entries: combined with any input-item need for the same\n `ItemID` → `\"Not enough '{itemID}' (input + price). Need {combined}\n(input={a}, price={b}), have {have}.\"`\n - Price `VirtualCurrency` entries → `\"Not enough '{currencyID}'. Need\n{n}, have {m}.\"`\n - Price `EventTokens` entries → `\"Not enough event tokens. Need {n}, have\n{m}.\"`\n - Price entries of type `CryptoCurrency` skip this preflight (checked\n later, decimal-precise, inside the atomic apply).\n - Price entries of type `Purchase` are rejected outright → `\"Craft cannot be\npaid in a store.\"` (see the PriceOption section above)\n - **This preflight is intentionally conservative**: it checks the full\n undiscounted price. `PremiumDiscounts` are applied later, only inside\n `ResourceService`'s atomic apply — so a player with a discount may see\n the preflight \"pass\" at a higher number than what's actually charged,\n never the reverse.\n10. **Roll one output per iteration** (`craftCount` independent weighted\n rolls — see below) only after preflight passes, so RNG is never spent on\n a craft that was going to fail anyway.\n11. **Build the `ResourceOperation`** — `Consume.Standard.Entries` = grouped\n input items (by `ItemID`, summed count) + price `Item`/`VirtualCurrency`\n entries (each `Amount * craftCount`); `Consume.Standard.EventTokens` =\n price event-token entries (`Amount * craftCount`);\n `Consume.PremiumDiscounts` passed through from the price option;\n `Grant.Standard.Entries` = the rolled outputs (one `Item` entry per\n iteration, `Amount: 1` each).\n12. **Atomic apply** via `ResourceService.ApplyResourceOperationAtomicAsync`\n — OCC-guarded against `InventoryV2.Version` with retries, idempotent by\n `reason: \"Craft:{RelatedEntityID}\"` (the TS SDK always sends a fresh\n UUID-suffixed `RelatedEntityID`, so in practice every SDK-initiated call\n is a distinct operation — see the \"guard against double-submit\" gotcha in\n the main skill). Failure → `\"Craft failed: {error}\"`.\n\nAll of steps 6–12 run per-`CraftType` but are otherwise identical between\n`TradeUpCollection` and `TradeUpRarity`.\n\n---\n\n## Weighted roll algorithm\n\n`RollWeightedDef` in `Craft.cs`: a linear cumulative-weight scan over the\ncandidate-output pool (`(ItemDefinition, Weight)` pairs, `Weight` taken from\neach `ItemDefinition.Weight`), driven by `NextInt64`, a rejection-sampled\ndraw from `RandomNumberGenerator` (cryptographic RNG, not `System.Random`)\nthat removes modulo bias. One craft with `count = N` performs **N\nindependent rolls** against the same pool — there is no shared pity/duplicate\nprotection across iterations of one call, and no cross-call pity system\nanywhere in Craft.\n\nBecause the pool is rebuilt once per call (not once per iteration) from the\nsame `titleConfig` snapshot, all `N` iterations in one `craft()` call roll\nagainst an identical odds table.\n\n---\n\n## Response shapes\n\n```ts\ninterface CraftResponse {\n ServerTimeUtc: string; // ISO datetime\n Type?: \"TradeUpRarity\" | \"TradeUpCollection\";\n CraftID: string;\n CraftedCount?: number; // == the clamped craftCount that actually ran\n SelectedOptionID?: string; // the option actually charged (resolved default if you omitted it)\n InputRarity?: string; // echoes craftConfig.InputRarityID\n OutputRarity?: string; // echoes craftConfig.OutputRarityID\n Resources?: ResourceOperation; // Consume = burned inputs + price; Grant = rolled outputs\n Results?: CraftSingleResult[]; // one entry per iteration, index 0..CraftedCount-1\n}\n\ninterface CraftSingleResult {\n Index?: number;\n BurnedItemIDs?: string[]; // the catalog ItemIDs consumed in this iteration (the template)\n RolledCollectionID?: string; // TradeUpCollection only — == the recipe's CollectionID\n UsedCollections?: Record<string, number>; // TradeUpCollection only — { [CollectionID]: RequiredItemCount }\n Output?: ResourceEntry; // the rolled item: { Type: \"Item\", ItemID, CatalogID, Amount: 1 }\n OutputLevel?: number; // only when OutputLevelMode !== \"None\"\n BurnedInstances?: { ItemInstanceID; ItemID; Level; Units }[]; // only when inputs were pinned to instances\n}\n```\n\nAn output above level 1 is granted as its own instance (a bundle is always\nlevel 1), and pinned inputs are burned by instance id — both are still listed in\n`Resources` (`Consume` / `Grant`) and in the `Inventory` delta, so\n`applyResourcesWithDelta` keeps the cache exact. On an idempotent replay\n`Resources` is the stored operation, which does not carry those two kinds of\nlines.\n\n`RolledCollectionID` / `UsedCollections` are populated only when\n`collectionID` is non-empty when building the result (i.e. only for\n`TradeUpCollection` — `TradeUpRarity` always leaves both `undefined`, per the\n`BuildSingleResults` helper's `collectionID: null` argument on the rarity\npath).\n"
9
9
  }
10
10
  ]
11
11
  }