@idosgames/mcp 0.1.11 → 0.1.13
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.
- package/dist/cli.js +2 -2
- package/package.json +1 -1
- package/registry/host.json +18 -6
- package/registry/index.json +130 -26
- package/registry/modules/board-game.json +31 -10
- package/registry/modules/game-hud.json +99 -0
- package/registry/modules/idle-rpg.json +35 -10
- package/registry/modules/voxelcraft.json +139 -34
- package/registry/skills/ai-generation-system.json +11 -0
- package/registry/skills/character-system.json +1 -1
- package/registry/skills/craft-system.json +2 -2
- package/registry/skills/idosgames-compose-modules.json +2 -2
- package/registry/skills/idosgames-getting-started.json +2 -2
- package/registry/skills/idosgames-module-contract.json +2 -2
- package/registry/skills/idosgames-project-structure.json +6 -0
- package/registry/skills/item-system.json +2 -2
- package/registry/skills/voxelcraft-worlds.json +6 -0
- package/registry/skills/workshop-system.json +6 -0
|
@@ -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.
|
|
41
|
-
"@idosgames/module-sdk": "0.
|
|
42
|
-
"@idosgames/react": "0.2.
|
|
43
|
-
"@idosgames/wallet": "0.2.
|
|
52
|
+
"@idosgames/core": "0.14.0",
|
|
53
|
+
"@idosgames/module-sdk": "0.3.0",
|
|
54
|
+
"@idosgames/react": "0.2.7",
|
|
55
|
+
"@idosgames/wallet": "0.2.7",
|
|
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\"
|
|
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
|
|
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 ? {
|
|
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
|
|
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
|
}
|