@idosgames/mcp 0.1.10 → 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.10.1",
41
- "@idosgames/module-sdk": "0.1.11",
42
- "@idosgames/react": "0.2.3",
43
- "@idosgames/wallet": "0.2.3",
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.11",
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,6 +1,6 @@
1
1
  {
2
2
  "name": "acquisition-attribution",
3
3
  "description": "Understand how a game built on the iDosGames TypeScript SDK (@idosgames/core) knows where a player came from, and how playtime reaches the publisher's analytics. Covers automatic capture of utm_* / ad click ids / ?ref= invite codes / idos_click tokens / Telegram start_param at launch, delivery with the login request, the deferred install match, the universal idosgames.com/go/{titleID} link, and the playtime tracker behind DAU/MAU and retention. Use this whenever the user asks about attribution, UTM tags, ad campaigns, install tracking, \"where did this player come from\", invite links, deep links carrying a referral code, session counting, playtime, DAU or MAU in the iDosGames SDK — and BEFORE writing any code that reads the URL or localStorage for campaign or referral parameters.",
4
- "content": "---\nname: acquisition-attribution\ndescription: >-\n Understand how a game built on the iDosGames TypeScript SDK (@idosgames/core)\n knows where a player came from, and how playtime reaches the publisher's\n analytics. Covers automatic capture of utm_* / ad click ids / ?ref= invite\n codes / idos_click tokens / Telegram start_param at launch, delivery with\n the login request, the deferred install match, the universal\n idosgames.com/go/{titleID} link, and the playtime tracker behind DAU/MAU and\n retention. Use this whenever the user asks about attribution, UTM tags, ad\n campaigns, install tracking, \"where did this player come from\", invite\n links, deep links carrying a referral code, session counting, playtime, DAU\n or MAU in the iDosGames SDK — and BEFORE writing any code that reads the URL\n or localStorage for campaign or referral parameters.\n---\n\n# Acquisition and attribution (iDosGames TS SDK)\n\nTwo things run by themselves in every client created with\n`createIDosGamesClient`, and **the correct amount of code you write for either\nis zero**:\n\n1. **`AcquisitionCapture`** reads the launch URL (and Telegram launch\n parameters) the moment the client is created, keeps what it found, and\n attaches it to whichever sign-in the player eventually uses.\n2. **`PlaytimeTracker`** counts how long the player actually plays and reports\n it, starting at login.\n\nThis skill exists mostly so you do **not** re-implement either one. If you find\nyourself writing `new URLSearchParams(location.search).get(\"utm_source\")` or a\n`setInterval` that posts playtime, stop: the SDK already did it, and a second\nimplementation competes with the first.\n\n## Why it matters\n\nEvery acquisition number a publisher sees — which campaign brought which\nplayer, retention split by source, invite conversion, K-factor — is derived\nfrom a signal the client sends **once**, with the login. And every engagement\nnumber — DAU, WAU, MAU, stickiness, average session, the retention cohorts on\ntop of them — is derived from what the playtime tracker posts. Neither has a\nfallback: nothing else on the platform writes those records.\n\n## What gets captured\n\nAt client construction, from the launch URL and the platform adapter:\n\n| Source | Lands in |\n| ---------------------------------------------------- | ----------------------------------------------------------- |\n| `utm_source/medium/campaign/term/content` | the matching `Utm*` fields |\n| `gclid`, `fbclid`, `ttclid`, `msclkid`, `yclid` | `ClickID` (whichever appears; ad networks never mix theirs) |\n| `?ref=` / `?r=` (bare code, or with a `ref_` prefix) | `ReferralCode`, `ChannelHint: \"query\"` |\n| `idos_click` | `ClaimToken` — an exact click receipt we issued |\n| Telegram `start_param` | `ReferralCode`, `ChannelHint: \"telegram\"` |\n| Telegram `start_param` beginning `a1_` | a whole packed mark set — see below |\n| `document.referrer` | `Referrer` |\n\nPlus, on **every** login regardless of tags: `OsVersion`, and `AppVersion` if\nyou set one.\n\n`AppVersion` is the only piece the SDK cannot find on its own — the web has no\n`Application.version` — so pass it when you create the client:\n\n```ts\nconst client = createIDosGamesClient({\n titleID: \"MYTITLE\",\n appVersion: \"1.4.2\",\n});\n```\n\nLeave it out and the player is attributed without a build number, which makes\n\"did the 1.5 release change retention?\" unanswerable for that title.\n\n⚠ **Those device facts are not decoration and must not be stripped as\n\"empty signal\".** They are how the server matches an install back to a click\nthat happened in a browser before the app existed (see Deferred match). The\nserver deliberately does not treat them as a signal on their own — that is what\nlets the match run at all.\n\nThe captured signal is **persisted with a 30-day TTL**, because the login often\nhappens much later than the launch: after a redirect to a sign-in screen, after\nan e-mail confirmation, after a reload. Holding it in memory would lose it for\nexactly the players who arrived through a campaign or an invite. It is cleared\nafter a successful login — the next sign-in by the same person must not be\nre-attributed to a month-old campaign.\n\n## The methods you may actually call\n\n```ts\nimport { AcquisitionCapture } from \"@idosgames/core\";\n```\n\n| Call | When you need it |\n| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |\n| `AcquisitionCapture.parseReferralCode(input)` | A player pasted something into an \"enter a code\" box. Accepts a bare code, a code with separators, or the whole invite link. |\n| `AcquisitionCapture.detectDevicePlatform(ua)` | Only if you are building a landing page that registers clicks. See the warning below. |\n\nEverything else on the instance (`capture()`, `buildForLogin()`, `clear()`) is\ndriven by the client. Calling them yourself does not add data; `clear()` in\nparticular throws away a signal that has not been delivered.\n\n`client.referral.activateReferralCode` already runs `parseReferralCode` on its\ninput, so for the ordinary \"enter a friend's code\" field you do not even need\nthat — pass the raw text straight through.\n\n## Playtime\n\n`PlaytimeTracker` starts on `auth:loggedIn` and stops on `auth:loggedOut`.\nNothing to wire.\n\n- Flushes every **200 s**; a gap of **300 s** with the tab hidden ends the\n session. Both numbers are copied from the Unity SDK on purpose: different\n constants would make the same behaviour produce different session counts per\n platform, and a publisher comparing web against mobile would be comparing two\n different definitions of \"a session\".\n- Time with the tab in the background is **not** counted.\n- The buffer survives a reload, and a failed flush is put back rather than\n dropped — under-reported playtime is invisible downstream, so it is never\n discarded silently.\n\n## Deferred match — how a store install finds its click\n\nThere is no way to carry a parameter through an app store. So:\n\n1. The universal link `idosgames.com/go/{titleID}?ref=CODE` registers the click\n with the backend and gets a `ClaimToken`.\n2. Where we control the destination (the web build), the token travels in the\n URL and the match is **exact**.\n3. Where we do not (Google Play, App Store), the server matches the first\n launch to the click by a **fingerprint**: network, platform family, major OS\n version, country, plus a daily salt.\n\n⚠ **A fingerprint match is a guess and is reported as one.** Behind a mobile\ncarrier's NAT hundreds of people share it. Such touches are marked\n`Trust: \"Inferred\"` and shown on their own row in the publisher's report — do\nnot build UI that presents them as certain.\n\n⚠ **If you build a page that registers clicks, report the platform of the\nDEVICE (`\"Android\"` / `\"iOS\"`), never `\"Web\"`.** The fingerprint is compared\nbetween two different programs — your page in a browser and the installed game\n— and the game reports its own platform. Send `\"Web\"` from a phone and the keys\nnever line up, which turns the whole deferred match into dead code in exactly\nits main case. `AcquisitionCapture.detectDevicePlatform(navigator.userAgent)`\nreturns the right string.\n\n## Gotchas\n\n- **Do not read the URL yourself for campaign or referral parameters.** The SDK\n captured them at construction and the launch URL is frequently gone by the\n time your screen mounts. Read `client.data.user.state?.Referral` for the\n outcome instead.\n- **Do not build invite links from a template.** The server returns a\n ready-made one (`UserReferralStateResponse.InviteUrl`); a client-assembled\n link is an open redirect, and every title would word it differently. See the\n `referral-system` skill.\n- **The link always carries the title** (`/go/{titleID}?ref=...`) because a\n referral code is unique only inside a title. A link shaped like `/i/{code}`\n cannot exist.\n- **`/go/{titleID}` is deliberately not `/play/...`** — the platform publishes\n applications as well as games.\n- **A game embedded in an iframe does not inherit the page's query string.**\n If you host a build inside your own page, forward `ref` and `idos_click` into\n the iframe `src` yourself, or arrivals through an invite will look like\n ordinary launches — silently.\n- **Telegram `start_param` is the one channel the server actually trusts** (it\n arrives inside data signed by the publisher's bot). The server reads its own\n copy; a code you place in the request body is ignored on a Telegram sign-in,\n so do not try to override it.\n- **A `start_param` may carry a whole mark set, not just a code.** Telegram's\n `startapp` accepts only `A-Z a-z 0-9 _ -`, so `key=value&...` cannot travel\n there; the invite page base64url-encodes it behind an `a1_` prefix and the\n SDK unpacks it for you. Two things follow. Never show a raw `start_param` to\n the player as their invite code — decode it first, or a blob appears on\n screen. And if you generate Telegram links yourself, either use the same\n format or send a bare code; an invented one is read as a code verbatim.\n The prefix is versioned on purpose: a future `a2_` will ship alongside `a1_`,\n never in place of it, so links already sent out keep working.\n- **Android carries its marks through Google Play, not through us.** The invite\n page appends `&referrer=` to the store link; Play hands that string to the\n app on first launch. Build your own store links the same way, or installs\n from them fall back to a fingerprint guess that breaks whenever the player\n switches network between tapping and launching.\n- **Nothing here works before login.** The signal rides on the sign-in request;\n a title that never signs a player in reports nothing, and its DAU stays zero.\n",
4
+ "content": "---\nname: acquisition-attribution\ndescription: >-\n Understand how a game built on the iDosGames TypeScript SDK (@idosgames/core)\n knows where a player came from, and how playtime reaches the publisher's\n analytics. Covers automatic capture of utm_* / ad click ids / ?ref= invite\n codes / idos_click tokens / Telegram start_param at launch, delivery with\n the login request, the deferred install match, the universal\n idosgames.com/go/{titleID} link, and the playtime tracker behind DAU/MAU and\n retention. Use this whenever the user asks about attribution, UTM tags, ad\n campaigns, install tracking, \"where did this player come from\", invite\n links, deep links carrying a referral code, session counting, playtime, DAU\n or MAU in the iDosGames SDK — and BEFORE writing any code that reads the URL\n or localStorage for campaign or referral parameters.\n---\n\n# Acquisition and attribution (iDosGames TS SDK)\n\nTwo things run by themselves in every client created with\n`createIDosGamesClient`, and **the correct amount of code you write for either\nis zero**:\n\n1. **`AcquisitionCapture`** reads the launch URL (and Telegram launch\n parameters) the moment the client is created, keeps what it found, and\n attaches it to whichever sign-in the player eventually uses.\n2. **`PlaytimeTracker`** counts how long the player actually plays and reports\n it, starting at login.\n\nThis skill exists mostly so you do **not** re-implement either one. If you find\nyourself writing `new URLSearchParams(location.search).get(\"utm_source\")` or a\n`setInterval` that posts playtime, stop: the SDK already did it, and a second\nimplementation competes with the first.\n\n## Why it matters\n\nEvery acquisition number a publisher sees — which campaign brought which\nplayer, retention split by source, invite conversion, K-factor — is derived\nfrom a signal the client sends **once**, with the login. And every engagement\nnumber — DAU, WAU, MAU, stickiness, average session, the retention cohorts on\ntop of them — is derived from what the playtime tracker posts. Neither has a\nfallback: nothing else on the platform writes those records.\n\n## What gets captured\n\nAt client construction, from the launch URL and the platform adapter:\n\n| Source | Lands in |\n| ---------------------------------------------------- | ----------------------------------------------------------- |\n| `utm_source/medium/campaign/term/content` | the matching `Utm*` fields |\n| `gclid`, `fbclid`, `ttclid`, `msclkid`, `yclid` | `ClickID` (whichever appears; ad networks never mix theirs) |\n| `?ref=` / `?r=` (bare code, or with a `ref_` prefix) | `ReferralCode`, `ChannelHint: \"query\"` |\n| `idos_click` | `ClaimToken` — an exact click receipt we issued |\n| Telegram `start_param` | `ReferralCode`, `ChannelHint: \"telegram\"` |\n| Telegram `start_param` beginning `a1_` | a whole packed mark set — see below |\n| `document.referrer` | `Referrer` |\n\nPlus, on **every** login regardless of tags: `OsVersion`, and `AppVersion` if\nyou set one.\n\n`AppVersion` is the only piece the SDK cannot find on its own — the web has no\n`Application.version` — so pass it when you create the client:\n\n```ts\nconst client = createIDosGamesClient({\n titleID: \"MYTITLE\",\n appVersion: \"1.4.2\",\n});\n```\n\nLeave it out and the player is attributed without a build number, which makes\n\"did the 1.5 release change retention?\" unanswerable for that title.\n\n⚠ **Those device facts are not decoration and must not be stripped as\n\"empty signal\".** They are how the server matches an install back to a click\nthat happened in a browser before the app existed (see Deferred match). The\nserver deliberately does not treat them as a signal on their own — that is what\nlets the match run at all.\n\nThe captured signal is **persisted with a 30-day TTL**, because the login often\nhappens much later than the launch: after a redirect to a sign-in screen, after\nan e-mail confirmation, after a reload. Holding it in memory would lose it for\nexactly the players who arrived through a campaign or an invite. It is cleared\nafter a successful login — the next sign-in by the same person must not be\nre-attributed to a month-old campaign.\n\n## The methods you may actually call\n\n```ts\nimport { AcquisitionCapture } from \"@idosgames/core\";\n```\n\n| Call | When you need it |\n| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |\n| `AcquisitionCapture.parseReferralCode(input)` | A player pasted something into an \"enter a code\" box. Accepts a bare code, a code with separators, or the whole invite link. |\n| `AcquisitionCapture.detectDevicePlatform(ua)` | Only if you are building a landing page that registers clicks. See the warning below. |\n\nEverything else on the instance (`capture()`, `buildForLogin()`, `clear()`) is\ndriven by the client. Calling them yourself does not add data; `clear()` in\nparticular throws away a signal that has not been delivered.\n\n`client.referral.activateReferralCode` already runs `parseReferralCode` on its\ninput, so for the ordinary \"enter a friend's code\" field you do not even need\nthat — pass the raw text straight through.\n\n## Playtime\n\n`PlaytimeTracker` starts on `auth:loggedIn` and stops on `auth:loggedOut`.\nNothing to wire.\n\n- Flushes every **200 s**; a gap of **1800 s** with the tab hidden closes the\n session locally. Both numbers match the Unity SDK on purpose.\n- **The session count is the server's decision, not this tracker's.** The\n backend opens a new session when the player's last recorded activity is older\n than `Usage.SessionIdleTimeoutMinutes` (default **30**, the same rule\n Firebase/GA4 use). The `IsNewSession` flag still travels on the wire and is\n ignored for counting — trusting it under-reported real players (a client\n resuming on a cached token never raised it, so a day of play could land with\n zero sessions and drop the player out of DAU and retention entirely), and the\n per-platform thresholds made the same behaviour count differently on web and\n mobile. The local threshold above still matters for\n `SessionDurationSeconds`: measuring a session's length by one rule while\n counting sessions by another would describe two different events.\n- Time with the tab in the background is **not** counted.\n- The buffer survives a reload, and a failed flush is put back rather than\n dropped — under-reported playtime is invisible downstream, so it is never\n discarded silently.\n\n## Deferred match — how a store install finds its click\n\nThere is no way to carry a parameter through an app store. So:\n\n1. The universal link `idosgames.com/go/{titleID}?ref=CODE` registers the click\n with the backend and gets a `ClaimToken`.\n2. Where we control the destination (the web build), the token travels in the\n URL and the match is **exact**.\n3. Where we do not (Google Play, App Store), the server matches the first\n launch to the click by a **fingerprint**: network, platform family, major OS\n version, country, plus a daily salt.\n\n⚠ **A fingerprint match is a guess and is reported as one.** Behind a mobile\ncarrier's NAT hundreds of people share it. Such touches are marked\n`Trust: \"Inferred\"` and shown on their own row in the publisher's report — do\nnot build UI that presents them as certain.\n\n⚠ **If you build a page that registers clicks, report the platform of the\nDEVICE (`\"Android\"` / `\"iOS\"`), never `\"Web\"`.** The fingerprint is compared\nbetween two different programs — your page in a browser and the installed game\n— and the game reports its own platform. Send `\"Web\"` from a phone and the keys\nnever line up, which turns the whole deferred match into dead code in exactly\nits main case. `AcquisitionCapture.detectDevicePlatform(navigator.userAgent)`\nreturns the right string.\n\n## Gotchas\n\n- **Do not read the URL yourself for campaign or referral parameters.** The SDK\n captured them at construction and the launch URL is frequently gone by the\n time your screen mounts. Read `client.data.user.state?.Referral` for the\n outcome instead.\n- **Do not build invite links from a template.** The server returns a\n ready-made one (`UserReferralStateResponse.InviteUrl`); a client-assembled\n link is an open redirect, and every title would word it differently. See the\n `referral-system` skill.\n- **The link always carries the title** (`/go/{titleID}?ref=...`) because a\n referral code is unique only inside a title. A link shaped like `/i/{code}`\n cannot exist.\n- **`/go/{titleID}` is deliberately not `/play/...`** — the platform publishes\n applications as well as games.\n- **A game embedded in an iframe does not inherit the page's query string.**\n If you host a build inside your own page, forward `ref` and `idos_click` into\n the iframe `src` yourself, or arrivals through an invite will look like\n ordinary launches — silently.\n- **Telegram `start_param` is the one channel the server actually trusts** (it\n arrives inside data signed by the publisher's bot). The server reads its own\n copy; a code you place in the request body is ignored on a Telegram sign-in,\n so do not try to override it.\n- **A `start_param` may carry a whole mark set, not just a code.** Telegram's\n `startapp` accepts only `A-Z a-z 0-9 _ -`, so `key=value&...` cannot travel\n there; the invite page base64url-encodes it behind an `a1_` prefix and the\n SDK unpacks it for you. Two things follow. Never show a raw `start_param` to\n the player as their invite code — decode it first, or a blob appears on\n screen. And if you generate Telegram links yourself, either use the same\n format or send a bare code; an invented one is read as a code verbatim.\n The prefix is versioned on purpose: a future `a2_` will ship alongside `a1_`,\n never in place of it, so links already sent out keep working.\n- **Android carries its marks through Google Play, not through us.** The invite\n page appends `&referrer=` to the store link; Play hands that string to the\n app on first launch. Build your own store links the same way, or installs\n from them fall back to a fingerprint guess that breaks whenever the player\n switches network between tapping and launching.\n- **Nothing here works before login.** The signal rides on the sign-in request;\n a title that never signs a player in reports nothing, and its DAU stays zero.\n",
5
5
  "references": []
6
6
  }