@idosgames/mcp 0.1.44 → 0.1.46
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 +1597 -1542
- package/package.json +1 -1
- package/registry/host.json +3 -3
- package/registry/index.json +172 -125
- package/registry/modules/ai.json +5 -5
- package/registry/modules/board-game.json +6 -6
- package/registry/modules/boosts.json +5 -5
- package/registry/modules/character.json +9 -9
- package/registry/modules/chat.json +3 -3
- package/registry/modules/collections.json +5 -5
- package/registry/modules/coop-events.json +6 -6
- package/registry/modules/craft.json +5 -5
- package/registry/modules/deals.json +6 -6
- package/registry/modules/events.json +3 -3
- package/registry/modules/friends.json +3 -3
- package/registry/modules/hero-run.json +15 -234
- package/registry/modules/idle-rpg.json +8 -8
- package/registry/modules/inventory.json +3 -3
- package/registry/modules/invite.json +3 -3
- package/registry/modules/kit-game.json +171 -0
- package/registry/modules/leaderboard.json +3 -3
- package/registry/modules/lootboxes.json +5 -5
- package/registry/modules/mailbox.json +3 -3
- package/registry/modules/marketplace.json +3 -3
- package/registry/modules/mini-game.json +3 -3
- package/registry/modules/multiplayer.json +3 -3
- package/registry/modules/premium.json +5 -5
- package/registry/modules/profile.json +3 -3
- package/registry/modules/pvp.json +3 -3
- package/registry/modules/quests.json +3 -3
- package/registry/modules/rewards.json +3 -3
- package/registry/modules/season.json +3 -3
- package/registry/modules/store.json +6 -6
- package/registry/modules/tutorials.json +3 -3
- package/registry/modules/video-studio.json +9 -9
- package/registry/modules/voxelcraft.json +6 -6
- package/registry/modules/workshop.json +6 -6
- package/registry/skills/currency-system.json +1 -1
- package/registry/skills/idosgames-compose-modules.json +1 -1
- package/registry/skills/idosgames-getting-started.json +1 -1
- package/registry/skills/idosgames-ui-kit.json +1 -1
- package/registry/skills/store-system.json +1 -1
- package/registry/video.json +1 -1
|
@@ -75,14 +75,14 @@
|
|
|
75
75
|
}
|
|
76
76
|
},
|
|
77
77
|
"dependencies": {
|
|
78
|
-
"@idosgames/characters": "0.1.
|
|
79
|
-
"@idosgames/core": "0.
|
|
80
|
-
"@idosgames/module-sdk": "0.13.
|
|
81
|
-
"@idosgames/react": "0.
|
|
78
|
+
"@idosgames/characters": "0.1.3",
|
|
79
|
+
"@idosgames/core": "0.26.1",
|
|
80
|
+
"@idosgames/module-sdk": "0.13.3",
|
|
81
|
+
"@idosgames/react": "0.7.1",
|
|
82
82
|
"phaser": "4.2.1",
|
|
83
83
|
"react": "19.2.7"
|
|
84
84
|
},
|
|
85
|
-
"contentHash": "
|
|
85
|
+
"contentHash": "c829bf0a60c01c03a9370d167404e1cb6d5fce50e04f893157b7cdb33443d12f",
|
|
86
86
|
"files": [
|
|
87
87
|
{
|
|
88
88
|
"path": "character/index.ts",
|
|
@@ -134,7 +134,7 @@
|
|
|
134
134
|
},
|
|
135
135
|
{
|
|
136
136
|
"path": "game/heroStats.ts",
|
|
137
|
-
"content": "// The fighting hero's numbers, from the title's Character config and the player's server state.\n//\n// ⚠ A MIRROR of the server's `PvPBattleEngine.CalculateStats` (iDos_Games_Engine, Match/Services) —\n// the same three layers in the same order: (1) the stat's ValueCurve from level 0 and its RankCurve\n// from rank 1, (2) gear flat bonuses scaled by the instance level, (3) percent bonuses. Health and\n// damage also take the hero's RankStatCurve and \"AllMight\"; attack speed and chances do not. The\n// Heroes screen (`modules/character/src/model.ts`) shows the same numbers — modules never import each\n// other, so this is a copy kept to the server, not to that file.\n//\n// The server never sees this fight: stages are the game's own. What it does see is the hero's Power,\n// which is what the gold income is paid on — so a stronger hero here is a richer one there.\n\nimport {\n curveMultiplier,\n evaluateCurve,\n roundAmount,\n type ScalarCurveSpec,\n} from \"@idosgames/core\";\n\n// ── Config / state views (only the fields read here) ─────────────────────────────────────────\n\ninterface Binding {\n PresetID?: string | null;\n Remove?: string[] | null;\n}\n\ntype PriceOptions =\n | Record<\n string,\n {\n Cost?: {\n Standard?: {\n Entries?: Array<{\n Type?: string | null;\n CurrencyID?: string | null;\n Amount?: number | null;\n } | null> | null;\n } | null;\n } | null;\n } | null\n >\n | null\n | undefined;\n\nexport interface StatDefView {\n StatID?: string;\n DisplayName?: string | null;\n MaxLevel?: number | null;\n BaseStatValue?: number | null;\n ValueCurve?: ScalarCurveSpec | null;\n RankCurve?: ScalarCurveSpec | null;\n CostCurve?: ScalarCurveSpec | null;\n PriceOptions?: PriceOptions;\n Requirements?: Array<{\n RequiredStatID?: string;\n RequiredLevel?: number;\n } | null> | null;\n}\n\nexport interface CharacterDefView {\n CharacterID?: string;\n Identity?: {\n DisplayName?: string | null;\n SortOrder?: number | null;\n } | null;\n Classification?: { ClassID?: string | null; RarityID?: string | null } | null;\n Unlock?: { UnlockedByDefault?: boolean | null } | null;\n Stats?: Record<string, StatDefView | null> | null;\n RankStatCurve?: ScalarCurveSpec | null;\n StatMaxLevelCurve?: ScalarCurveSpec | null;\n Presets?: { Stats?: Binding | null } | null;\n}\n\nexport interface CharacterSection {\n Definitions?: Record<string, CharacterDefView | null> | null;\n Presets?: {\n Stats?: Record<\n string,\n { Stats?: Record<string, StatDefView | null> | null } | null\n > | null;\n } | null;\n}\n\nexport interface HeroModelView {\n CharacterID?: string;\n Level?: number | null;\n Power?: number | null;\n StatLevels?: Record<string, number> | null;\n Equipment?: Record<\n string,\n {\n ItemID?: string | null;\n CatalogID?: string | null;\n ItemInstanceID?: string | null;\n Level?: number | null;\n } | null\n > | null;\n}\n\nexport interface GearDefView {\n Stats?: {\n FlatBonuses?: Record<string, number> | null;\n PercentBonuses?: Record<string, number> | null;\n } | null;\n Upgrade?: {\n FlatBonusCurve?: ScalarCurveSpec | null;\n PercentBonusCurve?: ScalarCurveSpec | null;\n } | null;\n}\n\n// ── Combat roles ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * Which stat plays which part in the fight. The first ids are the engine's own (DefaultData — the\n * only ones the server's Power counts); the rest let a title with its own names still fight.\n */\nexport const ROLE_STATS = {\n health: [\"Health\", \"HP\", \"MaxHp\"],\n damage: [\"Damage\", \"Attack\", \"ATK\", \"Magic\"],\n armor: [\"Armor\", \"Defense\", \"DEF\"],\n attackSpeed: [\"AttackSpeed\"],\n critChance: [\"CritChance\", \"Crit\"],\n critDamage: [\"CritDamage\"],\n dodge: [\"Speed\", \"Dodge\"],\n regen: [\"HpRegen\", \"HealthRegen\", \"Regen\"],\n multiShot: [\"MultiShot\", \"TripleShot\"],\n allMight: [\"AllMight\"],\n} as const;\n\nexport type Role = keyof typeof ROLE_STATS;\n\n/** Chances and the crit multiplier are shown in percent. */\nexport const PERCENT_ROLES: ReadonlySet<Role> = new Set([\n \"critChance\",\n \"critDamage\",\n \"dodge\",\n \"multiShot\",\n]);\n\n/** The server's clamps (MatchCombatSettings defaults). */\nexport const MAX_CRIT_CHANCE = 0.6;\nexport const MAX_DODGE_CHANCE = 0.4;\nexport const DEFAULT_CRIT_MULTIPLIER = 1.5;\nexport const MAX_MULTI_SHOT = 1;\n\nexport interface FighterStats {\n maxHp: number;\n damage: number;\n armor: number;\n /** Attacks per second. */\n attackSpeed: number;\n critChance: number;\n critMultiplier: number;\n dodge: number;\n /** HP restored per second. */\n regen: number;\n /** Chance of one extra shot at the next enemy. */\n multiShot: number;\n}\n\n// ── Stats of a hero ──────────────────────────────────────────────────────────────────────────\n\nexport interface StatInfo {\n id: string;\n name: string;\n role: Role | null;\n base: number;\n maxLevel: number;\n valueCurve?: ScalarCurveSpec | null;\n rankCurve?: ScalarCurveSpec | null;\n costCurve?: ScalarCurveSpec | null;\n /** First way to pay (\"default cost\"), per level 1. */\n price: { currencyID: string; amount: number } | null;\n requirements: Array<{ statID: string; level: number }>;\n}\n\nexport function roleOf(statID: string): Role | null {\n for (const [role, ids] of Object.entries(ROLE_STATS))\n if ((ids as readonly string[]).includes(statID)) return role as Role;\n return null;\n}\n\n/** Preset stats (Presets.Stats) under the inline ones; Remove drops keys — the server's merge. */\nexport function resolveStats(\n section: CharacterSection | undefined,\n def: CharacterDefView | undefined,\n): StatInfo[] {\n if (!def) return [];\n const binding = def.Presets?.Stats;\n const preset = binding?.PresetID\n ? section?.Presets?.Stats?.[binding.PresetID]?.Stats\n : undefined;\n const merged: Record<string, StatDefView> = {};\n for (const [k, s] of Object.entries(preset ?? {})) if (s) merged[k] = s;\n for (const [k, s] of Object.entries(def.Stats ?? {})) if (s) merged[k] = s;\n for (const k of binding?.Remove ?? []) delete merged[k];\n return Object.entries(merged).map(([key, s]) => {\n const id = s.StatID ?? key;\n const first = Object.values(s.PriceOptions ?? {}).find((o) => o);\n const entry = first?.Cost?.Standard?.Entries?.find(\n (e) => e?.CurrencyID && Number(e.Amount ?? 0) > 0,\n );\n return {\n id,\n name: s.DisplayName || id,\n role: roleOf(id),\n base: Number(s.BaseStatValue ?? 0),\n maxLevel: Number(s.MaxLevel ?? 0),\n valueCurve: s.ValueCurve,\n rankCurve: s.RankCurve,\n costCurve: s.CostCurve,\n price: entry\n ? { currencyID: entry.CurrencyID!, amount: Number(entry.Amount) }\n : null,\n requirements: (s.Requirements ?? [])\n .filter((r) => r?.RequiredStatID)\n .map((r) => ({\n statID: r!.RequiredStatID!,\n level: Number(r!.RequiredLevel ?? 0),\n })),\n };\n });\n}\n\n/** Layer 1 of one stat: ValueCurve from level 0, then the stat's own RankCurve from rank 1. */\nexport function levelValue(\n stat: StatInfo,\n level: number,\n rank: number,\n): number {\n const own = evaluateCurve(stat.valueCurve, stat.base, Math.max(0, level), 0);\n return evaluateCurve(stat.rankCurve, own, Math.max(1, rank), 1);\n}\n\n/** The stat's cap at this rank: MaxLevel through the hero's StatMaxLevelCurve (0 = no cap). */\nexport function statCap(\n stat: StatInfo,\n rank: number,\n def: CharacterDefView | undefined,\n): number {\n if (stat.maxLevel <= 0) return 0;\n return Math.ceil(\n evaluateCurve(def?.StatMaxLevelCurve, stat.maxLevel, Math.max(1, rank), 1),\n );\n}\n\n/** What raising a stat TO `nextLevel` costs — price × CostCurve(target level), rounded up once. */\nexport function statCost(stat: StatInfo, nextLevel: number): number {\n if (!stat.price) return 0;\n return roundAmount(\n stat.price.amount *\n curveMultiplier(stat.costCurve, Math.max(1, nextLevel), 1),\n );\n}\n\n/**\n * How many levels from `level` the balance buys (at most `limit`, never past the cap), and what they\n * cost together — what \"×10\" and \"MAX\" send in one call.\n */\nexport function affordableLevels(\n stat: StatInfo,\n level: number,\n cap: number,\n balance: number,\n limit: number,\n): { levels: number; cost: number } {\n let levels = 0;\n let cost = 0;\n while (levels < limit && (cap <= 0 || level + levels < cap)) {\n const next = statCost(stat, level + levels + 1);\n if (next <= 0 || cost + next > balance) break;\n cost += next;\n levels++;\n }\n return { levels, cost };\n}\n\n// ── The fighter ──────────────────────────────────────────────────────────────────────────────\n\nexport interface HeroInput {\n section: CharacterSection | undefined;\n def: CharacterDefView | undefined;\n model: HeroModelView | undefined;\n /** Item definitions by item id. */\n items: Map<string, GearDefView>;\n /** Levels of item instances by instance id (the worn copy's own Level wins when present). */\n instanceLevels?: Record<string, number>;\n}\n\n/**\n * The rank the fight uses: the server's level, but a hero playable by default fights at rank 1 before\n * the server has materialized it (its first action does).\n */\nexport function heroRank(input: HeroInput): number {\n const level = Number(input.model?.Level ?? 0);\n if (level > 0) return level;\n return input.def?.Unlock?.UnlockedByDefault ? 1 : 0;\n}\n\nexport function fighterStats(input: HeroInput): FighterStats {\n const rank = Math.max(1, heroRank(input));\n const stats = resolveStats(input.section, input.def);\n const levels = input.model?.StatLevels ?? {};\n const rankMult = curveMultiplier(input.def?.RankStatCurve, rank, 1);\n\n const byRole = (role: Role): number => {\n for (const id of ROLE_STATS[role]) {\n const stat = stats.find((s) => s.id === id);\n if (stat) return levelValue(stat, Number(levels[stat.id] ?? 0), rank);\n }\n return 0;\n };\n\n const flat: Record<string, number> = {};\n const pct: Record<string, number> = {};\n for (const worn of Object.values(input.model?.Equipment ?? {})) {\n if (!worn?.ItemID) continue;\n const def = input.items.get(worn.ItemID);\n if (!def?.Stats) continue;\n const instLevel =\n worn.Level ??\n (worn.ItemInstanceID\n ? input.instanceLevels?.[worn.ItemInstanceID]\n : undefined);\n const level = Math.max(1, Number(instLevel ?? 1));\n const flatScale = curveMultiplier(def.Upgrade?.FlatBonusCurve, level, 1);\n const pctScale = curveMultiplier(def.Upgrade?.PercentBonusCurve, level, 1);\n for (const [k, n] of Object.entries(def.Stats.FlatBonuses ?? {}))\n flat[k] = (flat[k] ?? 0) + Number(n || 0) * flatScale;\n for (const [k, n] of Object.entries(def.Stats.PercentBonuses ?? {}))\n pct[k] = (pct[k] ?? 0) + Number(n || 0) * pctScale;\n }\n const gear = (table: Record<string, number>, role: Role): number =>\n ROLE_STATS[role].reduce((s, id) => s + (table[id] ?? 0), 0);\n\n const allMight = byRole(\"allMight\") + gear(pct, \"allMight\");\n const global = 1 + allMight;\n\n const critMultiplier =\n byRole(\"critDamage\") + gear(flat, \"critDamage\") + gear(pct, \"critDamage\");\n return {\n maxHp: Math.max(\n 1,\n (byRole(\"health\") * rankMult + gear(flat, \"health\")) * global,\n ),\n damage: (byRole(\"damage\") * rankMult + gear(flat, \"damage\")) * global,\n armor: byRole(\"armor\") * rankMult + gear(flat, \"armor\"),\n attackSpeed: Math.max(\n 0.1,\n (byRole(\"attackSpeed\") + gear(flat, \"attackSpeed\")) *\n (1 + gear(pct, \"attackSpeed\")),\n ),\n critChance: clamp(\n byRole(\"critChance\") + gear(flat, \"critChance\") + gear(pct, \"critChance\"),\n 0,\n MAX_CRIT_CHANCE,\n ),\n critMultiplier:\n critMultiplier > 0 ? critMultiplier : DEFAULT_CRIT_MULTIPLIER,\n dodge: clamp(\n byRole(\"dodge\") + gear(flat, \"dodge\") + gear(pct, \"dodge\"),\n 0,\n MAX_DODGE_CHANCE,\n ),\n regen: Math.max(0, byRole(\"regen\") * rankMult + gear(flat, \"regen\")),\n multiShot: clamp(\n byRole(\"multiShot\") + gear(flat, \"multiShot\") + gear(pct, \"multiShot\"),\n 0,\n MAX_MULTI_SHOT,\n ),\n };\n}\n\n/** The value a stat shows in the Enhance list at a given level (its role's final number). */\nexport function enhanceValue(\n input: HeroInput,\n statID: string,\n level: number,\n): number {\n const role = roleOf(statID);\n const model: HeroModelView = {\n ...input.model,\n StatLevels: { ...(input.model?.StatLevels ?? {}), [statID]: level },\n };\n const field = role ? ROLE_FIELD[role] : null;\n if (!field) {\n const stat = resolveStats(input.section, input.def).find(\n (s) => s.id === statID,\n );\n return stat ? levelValue(stat, level, Math.max(1, heroRank(input))) : 0;\n }\n return fighterStats({ ...input, model })[field];\n}\n\n/** Where a role ends up among the fighter's numbers (AllMight multiplies others — shown as its own value). */\nconst ROLE_FIELD: Record<Role, keyof FighterStats | null> = {\n health: \"maxHp\",\n damage: \"damage\",\n armor: \"armor\",\n attackSpeed: \"attackSpeed\",\n critChance: \"critChance\",\n critDamage: \"critMultiplier\",\n dodge: \"dodge\",\n regen: \"regen\",\n multiShot: \"multiShot\",\n allMight: null,\n};\n\nfunction clamp(n: number, min: number, max: number): number {\n return Math.min(max, Math.max(min, n));\n}\n"
|
|
137
|
+
"content": "// The fighting hero's numbers, from the title's Character config and the player's server state.\n//\n// ⚠ A MIRROR of the server's `PvPBattleEngine.CalculateStats` (iDos_Games_Engine, Match/Services) —\n// the same three layers in the same order: (1) the stat's ValueCurve from level 0 and its RankCurve\n// from rank 1, (2) gear flat bonuses scaled by the instance level, (3) percent bonuses. Health and\n// damage also take the hero's RankStatCurve and \"AllMight\"; attack speed and chances do not. The\n// Heroes screen (`modules/character/src/model.ts`) shows the same numbers — modules never import each\n// other, so this is a copy kept to the server, not to that file.\n//\n// The server never sees this fight: stages are the game's own. What it does see is the hero's Power,\n// which is what the gold income is paid on — so a stronger hero here is a richer one there.\n\nimport {\n curveMultiplier,\n evaluateCurve,\n roundAmount,\n type ScalarCurveSpec,\n} from \"@idosgames/core\";\n\n// ── Config / state views (only the fields read here) ─────────────────────────────────────────\n\ninterface Binding {\n PresetID?: string | null;\n Remove?: string[] | null;\n}\n\ntype PriceOptions =\n | Record<\n string,\n {\n Cost?: {\n Standard?: {\n Entries?: Array<{\n Type?: string | null;\n CurrencyID?: string | null;\n Amount?: number | null;\n } | null> | null;\n } | null;\n } | null;\n } | null\n >\n | null\n | undefined;\n\nexport interface StatDefView {\n StatID?: string;\n DisplayName?: string | null;\n MaxLevel?: number | null;\n BaseStatValue?: number | null;\n ValueCurve?: ScalarCurveSpec | null;\n RankCurve?: ScalarCurveSpec | null;\n CostCurve?: ScalarCurveSpec | null;\n PriceOptions?: PriceOptions;\n Requirements?: Array<{\n RequiredStatID?: string;\n RequiredLevel?: number;\n } | null> | null;\n}\n\nexport interface CharacterDefView {\n CharacterID?: string;\n Identity?: {\n DisplayName?: string | null;\n SortOrder?: number | null;\n } | null;\n Classification?: { ClassID?: string | null; RarityID?: string | null } | null;\n Unlock?: { UnlockedByDefault?: boolean | null } | null;\n Stats?: Record<string, StatDefView | null> | null;\n RankStatCurve?: ScalarCurveSpec | null;\n StatMaxLevelCurve?: ScalarCurveSpec | null;\n Presets?: { Stats?: Binding | null } | null;\n}\n\nexport interface CharacterSection {\n Definitions?: Record<string, CharacterDefView | null> | null;\n Presets?: {\n Stats?: Record<\n string,\n { Stats?: Record<string, StatDefView | null> | null } | null\n > | null;\n } | null;\n}\n\nexport interface HeroModelView {\n CharacterID?: string;\n Level?: number | null;\n Power?: number | null;\n StatLevels?: Record<string, number> | null;\n Equipment?: Record<\n string,\n {\n ItemID?: string | null;\n CatalogID?: string | null;\n ItemInstanceID?: string | null;\n Level?: number | null;\n } | null\n > | null;\n}\n\nexport interface GearDefView {\n Stats?: {\n FlatBonuses?: Record<string, number> | null;\n PercentBonuses?: Record<string, number> | null;\n } | null;\n Upgrade?: {\n FlatBonusCurve?: ScalarCurveSpec | null;\n PercentBonusCurve?: ScalarCurveSpec | null;\n } | null;\n}\n\n// ── Combat roles ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * Which stat plays which part in the fight. The first ids are the engine's own (DefaultData — the\n * only ones the server's Power counts); the rest let a title with its own names still fight.\n */\nexport const ROLE_STATS = {\n health: [\"Health\", \"HP\", \"MaxHp\"],\n damage: [\"Damage\", \"Attack\", \"ATK\", \"Magic\"],\n armor: [\"Armor\", \"Defense\", \"DEF\"],\n attackSpeed: [\"AttackSpeed\"],\n critChance: [\"CritChance\", \"Crit\"],\n critDamage: [\"CritDamage\"],\n dodge: [\"Speed\", \"Dodge\"],\n regen: [\"HpRegen\", \"HealthRegen\", \"Regen\"],\n multiShot: [\"MultiShot\", \"TripleShot\"],\n allMight: [\"AllMight\"],\n} as const;\n\nexport type Role = keyof typeof ROLE_STATS;\n\n/** Chances and the crit multiplier are shown in percent. */\nexport const PERCENT_ROLES: ReadonlySet<Role> = new Set([\n \"critChance\",\n \"critDamage\",\n \"dodge\",\n \"multiShot\",\n]);\n\n/** The server's clamps (MatchCombatSettings defaults). */\nexport const MAX_CRIT_CHANCE = 0.6;\nexport const MAX_DODGE_CHANCE = 0.4;\nexport const DEFAULT_CRIT_MULTIPLIER = 1.5;\nexport const MAX_MULTI_SHOT = 1;\n\nexport interface FighterStats {\n maxHp: number;\n damage: number;\n armor: number;\n /** Attacks per second. */\n attackSpeed: number;\n critChance: number;\n critMultiplier: number;\n dodge: number;\n /** HP restored per second. */\n regen: number;\n /** Chance of one extra shot at the next enemy. */\n multiShot: number;\n}\n\n// ── Stats of a hero ──────────────────────────────────────────────────────────────────────────\n\nexport interface StatInfo {\n id: string;\n name: string;\n role: Role | null;\n base: number;\n maxLevel: number;\n valueCurve?: ScalarCurveSpec | null;\n rankCurve?: ScalarCurveSpec | null;\n costCurve?: ScalarCurveSpec | null;\n /**\n * First way to pay (\"default cost\"), per level 1. `currencyID` is \"PlatformCredits\" for a price in Aspis: the\n * player confirms it on idosgames.com, the balance is the server's check.\n */\n price: { currencyID: string; amount: number } | null;\n requirements: Array<{ statID: string; level: number }>;\n}\n\n/** Aspis, the platform currency (`ResourceEntryType.PlatformCredits`) — a price only, never a reward. */\nexport const CREDITS = \"PlatformCredits\";\n\nexport function roleOf(statID: string): Role | null {\n for (const [role, ids] of Object.entries(ROLE_STATS))\n if ((ids as readonly string[]).includes(statID)) return role as Role;\n return null;\n}\n\n/** Preset stats (Presets.Stats) under the inline ones; Remove drops keys — the server's merge. */\nexport function resolveStats(\n section: CharacterSection | undefined,\n def: CharacterDefView | undefined,\n): StatInfo[] {\n if (!def) return [];\n const binding = def.Presets?.Stats;\n const preset = binding?.PresetID\n ? section?.Presets?.Stats?.[binding.PresetID]?.Stats\n : undefined;\n const merged: Record<string, StatDefView> = {};\n for (const [k, s] of Object.entries(preset ?? {})) if (s) merged[k] = s;\n for (const [k, s] of Object.entries(def.Stats ?? {})) if (s) merged[k] = s;\n for (const k of binding?.Remove ?? []) delete merged[k];\n return Object.entries(merged).map(([key, s]) => {\n const id = s.StatID ?? key;\n const first = Object.values(s.PriceOptions ?? {}).find((o) => o);\n const entry = first?.Cost?.Standard?.Entries?.find(\n (e) =>\n (e?.CurrencyID || e?.Type === CREDITS) && Number(e?.Amount ?? 0) > 0,\n );\n return {\n id,\n name: s.DisplayName || id,\n role: roleOf(id),\n base: Number(s.BaseStatValue ?? 0),\n maxLevel: Number(s.MaxLevel ?? 0),\n valueCurve: s.ValueCurve,\n rankCurve: s.RankCurve,\n costCurve: s.CostCurve,\n price: entry\n ? {\n currencyID: entry.Type === CREDITS ? CREDITS : entry.CurrencyID!,\n amount: Number(entry.Amount),\n }\n : null,\n requirements: (s.Requirements ?? [])\n .filter((r) => r?.RequiredStatID)\n .map((r) => ({\n statID: r!.RequiredStatID!,\n level: Number(r!.RequiredLevel ?? 0),\n })),\n };\n });\n}\n\n/** Layer 1 of one stat: ValueCurve from level 0, then the stat's own RankCurve from rank 1. */\nexport function levelValue(\n stat: StatInfo,\n level: number,\n rank: number,\n): number {\n const own = evaluateCurve(stat.valueCurve, stat.base, Math.max(0, level), 0);\n return evaluateCurve(stat.rankCurve, own, Math.max(1, rank), 1);\n}\n\n/** The stat's cap at this rank: MaxLevel through the hero's StatMaxLevelCurve (0 = no cap). */\nexport function statCap(\n stat: StatInfo,\n rank: number,\n def: CharacterDefView | undefined,\n): number {\n if (stat.maxLevel <= 0) return 0;\n return Math.ceil(\n evaluateCurve(def?.StatMaxLevelCurve, stat.maxLevel, Math.max(1, rank), 1),\n );\n}\n\n/** What raising a stat TO `nextLevel` costs — price × CostCurve(target level), rounded up once. */\nexport function statCost(stat: StatInfo, nextLevel: number): number {\n if (!stat.price) return 0;\n return roundAmount(\n stat.price.amount *\n curveMultiplier(stat.costCurve, Math.max(1, nextLevel), 1),\n );\n}\n\n/**\n * How many levels from `level` the balance buys (at most `limit`, never past the cap), and what they\n * cost together — what \"×10\" and \"MAX\" send in one call.\n */\nexport function affordableLevels(\n stat: StatInfo,\n level: number,\n cap: number,\n balance: number,\n limit: number,\n): { levels: number; cost: number } {\n let levels = 0;\n let cost = 0;\n while (levels < limit && (cap <= 0 || level + levels < cap)) {\n const next = statCost(stat, level + levels + 1);\n if (next <= 0 || cost + next > balance) break;\n cost += next;\n levels++;\n }\n return { levels, cost };\n}\n\n// ── The fighter ──────────────────────────────────────────────────────────────────────────────\n\nexport interface HeroInput {\n section: CharacterSection | undefined;\n def: CharacterDefView | undefined;\n model: HeroModelView | undefined;\n /** Item definitions by item id. */\n items: Map<string, GearDefView>;\n /** Levels of item instances by instance id (the worn copy's own Level wins when present). */\n instanceLevels?: Record<string, number>;\n}\n\n/**\n * The rank the fight uses: the server's level, but a hero playable by default fights at rank 1 before\n * the server has materialized it (its first action does).\n */\nexport function heroRank(input: HeroInput): number {\n const level = Number(input.model?.Level ?? 0);\n if (level > 0) return level;\n return input.def?.Unlock?.UnlockedByDefault ? 1 : 0;\n}\n\nexport function fighterStats(input: HeroInput): FighterStats {\n const rank = Math.max(1, heroRank(input));\n const stats = resolveStats(input.section, input.def);\n const levels = input.model?.StatLevels ?? {};\n const rankMult = curveMultiplier(input.def?.RankStatCurve, rank, 1);\n\n const byRole = (role: Role): number => {\n for (const id of ROLE_STATS[role]) {\n const stat = stats.find((s) => s.id === id);\n if (stat) return levelValue(stat, Number(levels[stat.id] ?? 0), rank);\n }\n return 0;\n };\n\n const flat: Record<string, number> = {};\n const pct: Record<string, number> = {};\n for (const worn of Object.values(input.model?.Equipment ?? {})) {\n if (!worn?.ItemID) continue;\n const def = input.items.get(worn.ItemID);\n if (!def?.Stats) continue;\n const instLevel =\n worn.Level ??\n (worn.ItemInstanceID\n ? input.instanceLevels?.[worn.ItemInstanceID]\n : undefined);\n const level = Math.max(1, Number(instLevel ?? 1));\n const flatScale = curveMultiplier(def.Upgrade?.FlatBonusCurve, level, 1);\n const pctScale = curveMultiplier(def.Upgrade?.PercentBonusCurve, level, 1);\n for (const [k, n] of Object.entries(def.Stats.FlatBonuses ?? {}))\n flat[k] = (flat[k] ?? 0) + Number(n || 0) * flatScale;\n for (const [k, n] of Object.entries(def.Stats.PercentBonuses ?? {}))\n pct[k] = (pct[k] ?? 0) + Number(n || 0) * pctScale;\n }\n const gear = (table: Record<string, number>, role: Role): number =>\n ROLE_STATS[role].reduce((s, id) => s + (table[id] ?? 0), 0);\n\n const allMight = byRole(\"allMight\") + gear(pct, \"allMight\");\n const global = 1 + allMight;\n\n const critMultiplier =\n byRole(\"critDamage\") + gear(flat, \"critDamage\") + gear(pct, \"critDamage\");\n return {\n maxHp: Math.max(\n 1,\n (byRole(\"health\") * rankMult + gear(flat, \"health\")) * global,\n ),\n damage: (byRole(\"damage\") * rankMult + gear(flat, \"damage\")) * global,\n armor: byRole(\"armor\") * rankMult + gear(flat, \"armor\"),\n attackSpeed: Math.max(\n 0.1,\n (byRole(\"attackSpeed\") + gear(flat, \"attackSpeed\")) *\n (1 + gear(pct, \"attackSpeed\")),\n ),\n critChance: clamp(\n byRole(\"critChance\") + gear(flat, \"critChance\") + gear(pct, \"critChance\"),\n 0,\n MAX_CRIT_CHANCE,\n ),\n critMultiplier:\n critMultiplier > 0 ? critMultiplier : DEFAULT_CRIT_MULTIPLIER,\n dodge: clamp(\n byRole(\"dodge\") + gear(flat, \"dodge\") + gear(pct, \"dodge\"),\n 0,\n MAX_DODGE_CHANCE,\n ),\n regen: Math.max(0, byRole(\"regen\") * rankMult + gear(flat, \"regen\")),\n multiShot: clamp(\n byRole(\"multiShot\") + gear(flat, \"multiShot\") + gear(pct, \"multiShot\"),\n 0,\n MAX_MULTI_SHOT,\n ),\n };\n}\n\n/** The value a stat shows in the Enhance list at a given level (its role's final number). */\nexport function enhanceValue(\n input: HeroInput,\n statID: string,\n level: number,\n): number {\n const role = roleOf(statID);\n const model: HeroModelView = {\n ...input.model,\n StatLevels: { ...(input.model?.StatLevels ?? {}), [statID]: level },\n };\n const field = role ? ROLE_FIELD[role] : null;\n if (!field) {\n const stat = resolveStats(input.section, input.def).find(\n (s) => s.id === statID,\n );\n return stat ? levelValue(stat, level, Math.max(1, heroRank(input))) : 0;\n }\n return fighterStats({ ...input, model })[field];\n}\n\n/** Where a role ends up among the fighter's numbers (AllMight multiplies others — shown as its own value). */\nconst ROLE_FIELD: Record<Role, keyof FighterStats | null> = {\n health: \"maxHp\",\n damage: \"damage\",\n armor: \"armor\",\n attackSpeed: \"attackSpeed\",\n critChance: \"critChance\",\n critDamage: \"critMultiplier\",\n dodge: \"dodge\",\n regen: \"regen\",\n multiShot: \"multiShot\",\n allMight: null,\n};\n\nfunction clamp(n: number, min: number, max: number): number {\n return Math.min(max, Math.max(min, n));\n}\n"
|
|
138
138
|
},
|
|
139
139
|
{
|
|
140
140
|
"path": "game/IdleRpgController.ts",
|
|
@@ -142,7 +142,7 @@
|
|
|
142
142
|
},
|
|
143
143
|
{
|
|
144
144
|
"path": "game/session.ts",
|
|
145
|
-
"content": "// The game's state for the whole session — created once in setup(), outliving the Phaser scene\n// (the Mode Router destroys a hidden mode; the fight, the progress and the queues must not reset).\n//\n// It owns: which hero fights (the Heroes screen tells us — `character:hero-selected@1`), that hero's\n// numbers (heroStats.ts, from the server state), the fight (battle.ts), the stage progress (saved in\n// the player's private custom data), the gold income (the server's idle accrual — economy.ts) and\n// the Enhance queue (one request in flight per stat, taps in between are added up).\n\nimport type { IDosGamesClient } from \"@idosgames/core\";\nimport {\n configSection,\n itemDefinitions,\n type ItemSection,\n} from \"@idosgames/react/ui\";\nimport { Battle, type BattleEvent } from \"./battle\";\nimport {\n METRIC_STAGE_CLEARED,\n METRIC_STAT_ENHANCED,\n incomePerSecond,\n pickIncome,\n questMetric,\n type Income,\n type QuestSection,\n type RewardSection,\n} from \"./economy\";\nimport {\n affordableLevels,\n fighterStats,\n heroRank,\n resolveStats,\n statCap,\n type CharacterDefView,\n type CharacterSection,\n type FighterStats,\n type GearDefView,\n type HeroInput,\n type HeroModelView,\n type StatInfo,\n} from \"./heroStats\";\nimport { stageInfo } from \"./stages\";\n\n/** The private custom-data key of the stage progress (\".\" and \"$\" are not allowed in keys). */\nexport const PROGRESS_KEY = \"idle_rpg_progress\";\nconst SAVE_DELAY_MS = 4000;\n/** Below this, a collect on entry is just \"the counter ticked\", not \"while you were away\". */\nconst AWAY_MIN_SECONDS = 60;\nconst COLLECT_EVERY_MS = 15_000;\n/** How often the HUD redraws while the fight runs. */\nconst HUD_HZ = 8;\nconst TURN_EVENTS = new Set<BattleEvent[\"type\"]>([\n \"stageCleared\",\n \"bossStart\",\n \"bossFailed\",\n \"heroDown\",\n \"heroUp\",\n \"skill\",\n]);\n\nexport interface HeroView {\n id: string;\n name: string;\n classID: string;\n rarity: string;\n rank: number;\n power: number;\n def: CharacterDefView;\n input: HeroInput;\n stats: StatInfo[];\n fighter: FighterStats;\n}\n\nexport interface AwayReport {\n amount: number;\n seconds: number;\n currencyID: string;\n}\n\nexport type Notice =\n { kind: \"error\"; text: string } | { kind: \"notEnough\"; currencyID: string };\n\nexport interface SessionCallbacks {\n /** A stage was cleared — the module turns it into its event for other modules. */\n onStageCleared?: (chapter: number, stage: number) => void;\n /** A hero's rank went up (anywhere — the Heroes screen too). */\n onRankUp?: (characterId: string, level: number) => void;\n}\n\ntype Listener = () => void;\n\nexport class IdleSession {\n readonly battle = new Battle();\n /** The hero the Heroes screen selected, or null until it says (then the fallback fights). */\n selectedID: string | null = null;\n hero: HeroView | null = null;\n income: Income | null = null;\n away: AwayReport | null = null;\n /** Gold the last collect brought (for the \"+X\" float), and when. */\n lastCollect: { amount: number; at: number } | null = null;\n notice: (Notice & { at: number }) | null = null;\n loaded = false;\n\n private version = 0;\n private listeners = new Set<Listener>();\n private hudClock = 0;\n private collectTimer: ReturnType<typeof setInterval> | null = null;\n private saveTimer: ReturnType<typeof setTimeout> | null = null;\n private collecting = false;\n private firstCollect = true;\n private enhanceQueue = new Map<string, { pending: number; busy: boolean }>();\n private metricQueue = new Map<string, { pending: number; busy: boolean }>();\n private offClient: Array<() => void> = [];\n\n constructor(\n private readonly client: IDosGamesClient,\n private readonly callbacks: SessionCallbacks = {},\n ) {\n // Removed in destroy(): setup() runs again on every login, and a client subscription that\n // outlived its session would keep a dead game recomputing — and emitting twice.\n this.offClient.push(\n client.on(\"user:anyUpdated\", () => this.refreshHero()),\n client.on(\"character:levelUpgraded\", (data) =>\n callbacks.onRankUp?.(data.CharacterID, data.NewLevel),\n ),\n );\n this.refreshHero();\n void this.load();\n }\n\n // ── React bridge ───────────────────────────────────────────────────────────────────────────\n\n subscribe = (listener: Listener): (() => void) => {\n this.listeners.add(listener);\n return () => this.listeners.delete(listener);\n };\n\n getVersion = (): number => this.version;\n\n private changed(): void {\n this.version++;\n for (const l of [...this.listeners]) l();\n }\n\n // ── The hero ───────────────────────────────────────────────────────────────────────────────\n\n selectHero(id: string): void {\n if (id === this.selectedID) return;\n this.selectedID = id;\n this.refreshHero();\n }\n\n /** Re-reads the hero from the cache: an upgrade, new gear or another hero changes the fighter. */\n refreshHero(): void {\n const section = configSection<CharacterSection>(this.client, \"Character\");\n const owned = (this.client.data.user.state?.Character?.Characters ??\n {}) as Record<string, HeroModelView | undefined>;\n const id = pickHero(section, owned, this.selectedID);\n this.income = pickIncome(\n configSection<RewardSection>(this.client, \"Reward\"),\n );\n if (!id) {\n this.hero = null;\n return this.changed();\n }\n const def = section?.Definitions?.[id] ?? {};\n const items = itemDefinitions(\n configSection<ItemSection>(this.client, \"Item\"),\n ) as unknown as Map<string, GearDefView>;\n const instances = (\n this.client.data.user.state?.InventoryV2 as\n | {\n UnstackableItems?: Record<string, { Level?: number | null } | null>;\n }\n | undefined\n )?.UnstackableItems;\n const instanceLevels: Record<string, number> = {};\n for (const [k, inst] of Object.entries(instances ?? {}))\n if (inst?.Level) instanceLevels[k] = inst.Level;\n const input: HeroInput = {\n section,\n def,\n model: owned[id],\n items,\n instanceLevels,\n };\n const fighter = fighterStats(input);\n this.hero = {\n id,\n name: def.Identity?.DisplayName ?? id,\n classID: def.Classification?.ClassID ?? \"\",\n rarity: def.Classification?.RarityID ?? \"\",\n rank: heroRank(input),\n power: Number(owned[id]?.Power ?? 0),\n def,\n input,\n stats: resolveStats(section, def),\n fighter,\n };\n this.battle.setStats(fighter);\n this.changed();\n }\n\n /** Gold per second the server pays now (display). */\n ratePerSecond(): number {\n if (!this.income) return 0;\n const owned = this.client.data.user.state?.Character?.Characters ?? {};\n return incomePerSecond(\n this.income,\n Object.values(owned).map((c) => (c as HeroModelView | undefined)?.Power),\n );\n }\n\n balance(currencyID: string): number {\n const vc = this.client.data.user.state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number } | undefined> | undefined;\n return Number(vc?.[currencyID]?.Amount ?? 0);\n }\n\n // ── The clock ──────────────────────────────────────────────────────────────────────────────\n\n /** One frame of the fight (called by the scene). Returns what happened, for the drawing. */\n tick(dt: number): BattleEvent[] {\n const events = this.battle.tick(dt);\n for (const e of events) {\n if (e.type === \"stageCleared\") this.stageCleared(e.stage);\n else if (e.type === \"bossFailed\") this.scheduleSave();\n }\n // The HUD follows at a few frames a second; a turn of the stage shows at once.\n this.hudClock += dt;\n const turn = events.some((e) => TURN_EVENTS.has(e.type));\n if (turn || this.hudClock >= 1 / HUD_HZ) {\n this.hudClock = 0;\n this.changed();\n }\n return events;\n }\n\n /** The mode is on screen: collect what the hero earned meanwhile, then keep collecting. */\n activate(): void {\n void this.collect();\n this.collectTimer ??= setInterval(\n () => void this.collect(),\n COLLECT_EVERY_MS,\n );\n }\n\n suspend(): void {\n if (this.collectTimer) clearInterval(this.collectTimer);\n this.collectTimer = null;\n this.flushSave();\n }\n\n destroy(): void {\n this.suspend();\n for (const off of this.offClient) off();\n this.offClient = [];\n this.listeners.clear();\n }\n\n // ── Income ─────────────────────────────────────────────────────────────────────────────────\n\n async collect(): Promise<void> {\n const income = this.income;\n if (!income || this.collecting) return;\n this.collecting = true;\n const first = this.firstCollect;\n this.firstCollect = false;\n const res = await this.client.reward.collectIdleAccrual(income.accrualID);\n this.collecting = false;\n // \"Too soon\" / \"Nothing to collect yet\" are the normal answers between collects — not errors.\n if (!res.ok) return;\n const amount = Number(res.data.IdleState?.LastClaimedAmount ?? 0);\n const seconds = Number(res.data.AccruedSeconds ?? 0);\n if (amount <= 0) return;\n this.lastCollect = { amount, at: Date.now() };\n if (first && seconds >= AWAY_MIN_SECONDS)\n this.away = { amount, seconds, currencyID: income.currencyID };\n this.changed();\n }\n\n dismissAway(): void {\n this.away = null;\n this.changed();\n }\n\n // ── Enhance ────────────────────────────────────────────────────────────────────────────────\n\n /**\n * Add `levels` to a stat. Taps while a request is out are added up and sent as ONE call when it\n * returns — so holding the button never floods the server (and the per-player rate limit).\n */\n enhance(statID: string, levels: number): void {\n const q = this.enhanceQueue.get(statID) ?? { pending: 0, busy: false };\n q.pending += Math.max(1, Math.floor(levels));\n this.enhanceQueue.set(statID, q);\n void this.pumpEnhance(statID);\n }\n\n /** What `levels` more would really buy now: limited by the cap and the balance. */\n plan(statID: string, levels: number): { levels: number; cost: number } {\n const hero = this.hero;\n const stat = hero?.stats.find((s) => s.id === statID);\n if (!hero || !stat?.price) return { levels: 0, cost: 0 };\n const level = Number(hero.input.model?.StatLevels?.[statID] ?? 0);\n return affordableLevels(\n stat,\n level,\n statCap(stat, Math.max(1, hero.rank), hero.def),\n this.balance(stat.price.currencyID),\n levels,\n );\n }\n\n private async pumpEnhance(statID: string): Promise<void> {\n const q = this.enhanceQueue.get(statID);\n const hero = this.hero;\n if (!q || q.busy || q.pending <= 0 || !hero) return;\n const { levels } = this.plan(statID, q.pending);\n if (levels <= 0) {\n q.pending = 0;\n const stat = hero.stats.find((s) => s.id === statID);\n if (stat?.price)\n this.notify({ kind: \"notEnough\", currencyID: stat.price.currencyID });\n return;\n }\n q.busy = true;\n const res = await this.client.character.upgradeStatLevel(hero.id, statID, {\n levels,\n });\n q.busy = false;\n if (!res.ok) {\n q.pending = 0;\n this.notify({ kind: \"error\", text: String(res.error ?? \"\") });\n return;\n }\n q.pending = Math.max(0, q.pending - levels);\n this.report(METRIC_STAT_ENHANCED, levels);\n void this.pumpEnhance(statID);\n }\n\n private notify(notice: Notice): void {\n this.notice = { ...notice, at: Date.now() };\n this.changed();\n }\n\n // ── Progress ───────────────────────────────────────────────────────────────────────────────\n\n private async load(): Promise<void> {\n let raw = this.readProgress();\n if (raw === undefined) {\n const res = await this.client.userCustomData.getMyUserCustomData();\n raw = res.ok ? (res.data.Private?.[PROGRESS_KEY]?.Value ?? null) : null;\n }\n const saved = parseProgress(raw ?? null);\n if (saved) this.battle.setStage(saved.stage, saved.best);\n this.loaded = true;\n this.changed();\n }\n\n /** `undefined` — the login state did not bring the custom data at all (ask once). */\n private readProgress(): string | null | undefined {\n const cd = this.client.data.user.state?.CustomData as\n | { Private?: Record<string, { Value?: string | null } | undefined> }\n | null\n | undefined;\n if (!cd?.Private) return undefined;\n return cd.Private[PROGRESS_KEY]?.Value ?? null;\n }\n\n private stageCleared(stage: number): void {\n const info = stageInfo(stage);\n this.callbacks.onStageCleared?.(info.chapter, info.stage);\n this.report(METRIC_STAGE_CLEARED, 1);\n this.scheduleSave();\n }\n\n private scheduleSave(): void {\n if (this.saveTimer) clearTimeout(this.saveTimer);\n this.saveTimer = setTimeout(() => this.flushSave(), SAVE_DELAY_MS);\n }\n\n private flushSave(): void {\n if (this.saveTimer) clearTimeout(this.saveTimer);\n this.saveTimer = null;\n if (!this.loaded) return;\n const value = serializeProgress({\n stage: this.battle.stage,\n best: this.battle.best,\n });\n if (value === this.readProgress()) return;\n void this.client.userCustomData.setPrivateData(PROGRESS_KEY, value);\n }\n\n // ── Quest metrics ──────────────────────────────────────────────────────────────────────────\n\n /** Report a metric to the quests that count it — one call at a time, never above their cap. */\n private report(metricID: string, amount: number): void {\n if (!questMetric(this.questSection(), metricID)) return;\n const q = this.metricQueue.get(metricID) ?? { pending: 0, busy: false };\n q.pending += amount;\n this.metricQueue.set(metricID, q);\n void this.pumpMetric(metricID);\n }\n\n private async pumpMetric(metricID: string): Promise<void> {\n const q = this.metricQueue.get(metricID);\n const metric = questMetric(this.questSection(), metricID);\n if (!q || q.busy || q.pending <= 0 || !metric) return;\n const chunk =\n metric.maxPerCall > 0\n ? Math.min(q.pending, metric.maxPerCall)\n : q.pending;\n q.busy = true;\n q.pending -= chunk;\n await this.client.quest.addQuestProgress(metricID, chunk);\n q.busy = false;\n void this.pumpMetric(metricID);\n }\n\n private questSection(): QuestSection | undefined {\n return configSection<QuestSection>(this.client, \"Quest\");\n }\n}\n\n// ── Pure helpers ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * The fighter: the selected hero while it is playable; else the strongest owned hero; else the\n * first hero playable by default (roster order).\n */\nexport function pickHero(\n section: CharacterSection | undefined,\n owned: Record<string, HeroModelView | undefined>,\n selectedID: string | null,\n): string | null {\n const defs = section?.Definitions ?? {};\n const playable = (id: string) =>\n Number(owned[id]?.Level ?? 0) > 0 || !!defs[id]?.Unlock?.UnlockedByDefault;\n if (selectedID && defs[selectedID] && playable(selectedID)) return selectedID;\n const strongest = Object.entries(owned)\n .filter(([id, m]) => defs[id] && Number(m?.Level ?? 0) > 0)\n .sort(([, a], [, b]) => Number(b?.Power ?? 0) - Number(a?.Power ?? 0))[0];\n if (strongest) return strongest[0];\n const byOrder = Object.entries(defs)\n .filter(([id, d]) => d && playable(id))\n .sort(\n ([a, x], [b, y]) =>\n Number(x?.Identity?.SortOrder ?? 0) -\n Number(y?.Identity?.SortOrder ?? 0) || a.localeCompare(b),\n )[0];\n return byOrder?.[0] ?? null;\n}\n\nexport function parseProgress(\n raw: string | null,\n): { stage: number; best: number } | null {\n if (!raw) return null;\n try {\n const v = JSON.parse(raw) as { stage?: unknown; best?: unknown };\n const stage = Math.floor(Number(v.stage));\n if (!Number.isFinite(stage) || stage < 1) return null;\n const best = Math.max(stage, Math.floor(Number(v.best)) || stage);\n return { stage, best };\n } catch {\n return null;\n }\n}\n\nexport function serializeProgress(p: { stage: number; best: number }): string {\n return JSON.stringify({ stage: p.stage, best: p.best });\n}\n"
|
|
145
|
+
"content": "// The game's state for the whole session — created once in setup(), outliving the Phaser scene\n// (the Mode Router destroys a hidden mode; the fight, the progress and the queues must not reset).\n//\n// It owns: which hero fights (the Heroes screen tells us — `character:hero-selected@1`), that hero's\n// numbers (heroStats.ts, from the server state), the fight (battle.ts), the stage progress (saved in\n// the player's private custom data), the gold income (the server's idle accrual — economy.ts) and\n// the Enhance queue (one request in flight per stat, taps in between are added up).\n\nimport type { IDosGamesClient } from \"@idosgames/core\";\nimport {\n configSection,\n itemDefinitions,\n payWithCredits,\n type ItemSection,\n} from \"@idosgames/react/ui\";\nimport { Battle, type BattleEvent } from \"./battle\";\nimport {\n METRIC_STAGE_CLEARED,\n METRIC_STAT_ENHANCED,\n incomePerSecond,\n pickIncome,\n questMetric,\n type Income,\n type QuestSection,\n type RewardSection,\n} from \"./economy\";\nimport {\n CREDITS,\n affordableLevels,\n fighterStats,\n heroRank,\n resolveStats,\n statCap,\n type CharacterDefView,\n type CharacterSection,\n type FighterStats,\n type GearDefView,\n type HeroInput,\n type HeroModelView,\n type StatInfo,\n} from \"./heroStats\";\nimport { stageInfo } from \"./stages\";\n\n/** The private custom-data key of the stage progress (\".\" and \"$\" are not allowed in keys). */\nexport const PROGRESS_KEY = \"idle_rpg_progress\";\nconst SAVE_DELAY_MS = 4000;\n/** Below this, a collect on entry is just \"the counter ticked\", not \"while you were away\". */\nconst AWAY_MIN_SECONDS = 60;\nconst COLLECT_EVERY_MS = 15_000;\n/** How often the HUD redraws while the fight runs. */\nconst HUD_HZ = 8;\nconst TURN_EVENTS = new Set<BattleEvent[\"type\"]>([\n \"stageCleared\",\n \"bossStart\",\n \"bossFailed\",\n \"heroDown\",\n \"heroUp\",\n \"skill\",\n]);\n\nexport interface HeroView {\n id: string;\n name: string;\n classID: string;\n rarity: string;\n rank: number;\n power: number;\n def: CharacterDefView;\n input: HeroInput;\n stats: StatInfo[];\n fighter: FighterStats;\n}\n\nexport interface AwayReport {\n amount: number;\n seconds: number;\n currencyID: string;\n}\n\nexport type Notice =\n { kind: \"error\"; text: string } | { kind: \"notEnough\"; currencyID: string };\n\nexport interface SessionCallbacks {\n /** A stage was cleared — the module turns it into its event for other modules. */\n onStageCleared?: (chapter: number, stage: number) => void;\n /** A hero's rank went up (anywhere — the Heroes screen too). */\n onRankUp?: (characterId: string, level: number) => void;\n}\n\ntype Listener = () => void;\n\nexport class IdleSession {\n readonly battle = new Battle();\n /** The hero the Heroes screen selected, or null until it says (then the fallback fights). */\n selectedID: string | null = null;\n hero: HeroView | null = null;\n income: Income | null = null;\n away: AwayReport | null = null;\n /** Gold the last collect brought (for the \"+X\" float), and when. */\n lastCollect: { amount: number; at: number } | null = null;\n notice: (Notice & { at: number }) | null = null;\n loaded = false;\n\n private version = 0;\n private listeners = new Set<Listener>();\n private hudClock = 0;\n private collectTimer: ReturnType<typeof setInterval> | null = null;\n private saveTimer: ReturnType<typeof setTimeout> | null = null;\n private collecting = false;\n private firstCollect = true;\n private enhanceQueue = new Map<string, { pending: number; busy: boolean }>();\n private metricQueue = new Map<string, { pending: number; busy: boolean }>();\n private offClient: Array<() => void> = [];\n\n constructor(\n private readonly client: IDosGamesClient,\n private readonly callbacks: SessionCallbacks = {},\n ) {\n // Removed in destroy(): setup() runs again on every login, and a client subscription that\n // outlived its session would keep a dead game recomputing — and emitting twice.\n this.offClient.push(\n client.on(\"user:anyUpdated\", () => this.refreshHero()),\n client.on(\"character:levelUpgraded\", (data) =>\n callbacks.onRankUp?.(data.CharacterID, data.NewLevel),\n ),\n );\n this.refreshHero();\n void this.load();\n }\n\n // ── React bridge ───────────────────────────────────────────────────────────────────────────\n\n subscribe = (listener: Listener): (() => void) => {\n this.listeners.add(listener);\n return () => this.listeners.delete(listener);\n };\n\n getVersion = (): number => this.version;\n\n private changed(): void {\n this.version++;\n for (const l of [...this.listeners]) l();\n }\n\n // ── The hero ───────────────────────────────────────────────────────────────────────────────\n\n selectHero(id: string): void {\n if (id === this.selectedID) return;\n this.selectedID = id;\n this.refreshHero();\n }\n\n /** Re-reads the hero from the cache: an upgrade, new gear or another hero changes the fighter. */\n refreshHero(): void {\n const section = configSection<CharacterSection>(this.client, \"Character\");\n const owned = (this.client.data.user.state?.Character?.Characters ??\n {}) as Record<string, HeroModelView | undefined>;\n const id = pickHero(section, owned, this.selectedID);\n this.income = pickIncome(\n configSection<RewardSection>(this.client, \"Reward\"),\n );\n if (!id) {\n this.hero = null;\n return this.changed();\n }\n const def = section?.Definitions?.[id] ?? {};\n const items = itemDefinitions(\n configSection<ItemSection>(this.client, \"Item\"),\n ) as unknown as Map<string, GearDefView>;\n const instances = (\n this.client.data.user.state?.InventoryV2 as\n | {\n UnstackableItems?: Record<string, { Level?: number | null } | null>;\n }\n | undefined\n )?.UnstackableItems;\n const instanceLevels: Record<string, number> = {};\n for (const [k, inst] of Object.entries(instances ?? {}))\n if (inst?.Level) instanceLevels[k] = inst.Level;\n const input: HeroInput = {\n section,\n def,\n model: owned[id],\n items,\n instanceLevels,\n };\n const fighter = fighterStats(input);\n this.hero = {\n id,\n name: def.Identity?.DisplayName ?? id,\n classID: def.Classification?.ClassID ?? \"\",\n rarity: def.Classification?.RarityID ?? \"\",\n rank: heroRank(input),\n power: Number(owned[id]?.Power ?? 0),\n def,\n input,\n stats: resolveStats(section, def),\n fighter,\n };\n this.battle.setStats(fighter);\n this.changed();\n }\n\n /** Gold per second the server pays now (display). */\n ratePerSecond(): number {\n if (!this.income) return 0;\n const owned = this.client.data.user.state?.Character?.Characters ?? {};\n return incomePerSecond(\n this.income,\n Object.values(owned).map((c) => (c as HeroModelView | undefined)?.Power),\n );\n }\n\n balance(currencyID: string): number {\n // Aspis are not in the game's state: the player confirms the price on idosgames.com, the server checks it.\n if (currencyID === CREDITS) return Number.POSITIVE_INFINITY;\n const vc = this.client.data.user.state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number } | undefined> | undefined;\n return Number(vc?.[currencyID]?.Amount ?? 0);\n }\n\n // ── The clock ──────────────────────────────────────────────────────────────────────────────\n\n /** One frame of the fight (called by the scene). Returns what happened, for the drawing. */\n tick(dt: number): BattleEvent[] {\n const events = this.battle.tick(dt);\n for (const e of events) {\n if (e.type === \"stageCleared\") this.stageCleared(e.stage);\n else if (e.type === \"bossFailed\") this.scheduleSave();\n }\n // The HUD follows at a few frames a second; a turn of the stage shows at once.\n this.hudClock += dt;\n const turn = events.some((e) => TURN_EVENTS.has(e.type));\n if (turn || this.hudClock >= 1 / HUD_HZ) {\n this.hudClock = 0;\n this.changed();\n }\n return events;\n }\n\n /** The mode is on screen: collect what the hero earned meanwhile, then keep collecting. */\n activate(): void {\n void this.collect();\n this.collectTimer ??= setInterval(\n () => void this.collect(),\n COLLECT_EVERY_MS,\n );\n }\n\n suspend(): void {\n if (this.collectTimer) clearInterval(this.collectTimer);\n this.collectTimer = null;\n this.flushSave();\n }\n\n destroy(): void {\n this.suspend();\n for (const off of this.offClient) off();\n this.offClient = [];\n this.listeners.clear();\n }\n\n // ── Income ─────────────────────────────────────────────────────────────────────────────────\n\n async collect(): Promise<void> {\n const income = this.income;\n if (!income || this.collecting) return;\n this.collecting = true;\n const first = this.firstCollect;\n this.firstCollect = false;\n const res = await this.client.reward.collectIdleAccrual(income.accrualID);\n this.collecting = false;\n // \"Too soon\" / \"Nothing to collect yet\" are the normal answers between collects — not errors.\n if (!res.ok) return;\n const amount = Number(res.data.IdleState?.LastClaimedAmount ?? 0);\n const seconds = Number(res.data.AccruedSeconds ?? 0);\n if (amount <= 0) return;\n this.lastCollect = { amount, at: Date.now() };\n if (first && seconds >= AWAY_MIN_SECONDS)\n this.away = { amount, seconds, currencyID: income.currencyID };\n this.changed();\n }\n\n dismissAway(): void {\n this.away = null;\n this.changed();\n }\n\n // ── Enhance ────────────────────────────────────────────────────────────────────────────────\n\n /**\n * Add `levels` to a stat. Taps while a request is out are added up and sent as ONE call when it\n * returns — so holding the button never floods the server (and the per-player rate limit).\n */\n enhance(statID: string, levels: number): void {\n const q = this.enhanceQueue.get(statID) ?? { pending: 0, busy: false };\n q.pending += Math.max(1, Math.floor(levels));\n this.enhanceQueue.set(statID, q);\n void this.pumpEnhance(statID);\n }\n\n /** What `levels` more would really buy now: limited by the cap and the balance. */\n plan(statID: string, levels: number): { levels: number; cost: number } {\n const hero = this.hero;\n const stat = hero?.stats.find((s) => s.id === statID);\n if (!hero || !stat?.price) return { levels: 0, cost: 0 };\n const level = Number(hero.input.model?.StatLevels?.[statID] ?? 0);\n return affordableLevels(\n stat,\n level,\n statCap(stat, Math.max(1, hero.rank), hero.def),\n this.balance(stat.price.currencyID),\n levels,\n );\n }\n\n private async pumpEnhance(statID: string): Promise<void> {\n const q = this.enhanceQueue.get(statID);\n const hero = this.hero;\n if (!q || q.busy || q.pending <= 0 || !hero) return;\n const { levels } = this.plan(statID, q.pending);\n if (levels <= 0) {\n q.pending = 0;\n const stat = hero.stats.find((s) => s.id === statID);\n if (stat?.price)\n this.notify({ kind: \"notEnough\", currencyID: stat.price.currencyID });\n return;\n }\n q.busy = true;\n // A price in Aspis: the first call asks the player to confirm the levels on idosgames.com (the kit shows it).\n const res = await payWithCredits(this.client, (creditsConfirmationID) =>\n this.client.character.upgradeStatLevel(hero.id, statID, {\n levels,\n creditsConfirmationID,\n }),\n );\n q.busy = false;\n if (!res.ok) {\n q.pending = 0;\n this.notify({ kind: \"error\", text: String(res.error ?? \"\") });\n return;\n }\n q.pending = Math.max(0, q.pending - levels);\n this.report(METRIC_STAT_ENHANCED, levels);\n void this.pumpEnhance(statID);\n }\n\n private notify(notice: Notice): void {\n this.notice = { ...notice, at: Date.now() };\n this.changed();\n }\n\n // ── Progress ───────────────────────────────────────────────────────────────────────────────\n\n private async load(): Promise<void> {\n let raw = this.readProgress();\n if (raw === undefined) {\n const res = await this.client.userCustomData.getMyUserCustomData();\n raw = res.ok ? (res.data.Private?.[PROGRESS_KEY]?.Value ?? null) : null;\n }\n const saved = parseProgress(raw ?? null);\n if (saved) this.battle.setStage(saved.stage, saved.best);\n this.loaded = true;\n this.changed();\n }\n\n /** `undefined` — the login state did not bring the custom data at all (ask once). */\n private readProgress(): string | null | undefined {\n const cd = this.client.data.user.state?.CustomData as\n | { Private?: Record<string, { Value?: string | null } | undefined> }\n | null\n | undefined;\n if (!cd?.Private) return undefined;\n return cd.Private[PROGRESS_KEY]?.Value ?? null;\n }\n\n private stageCleared(stage: number): void {\n const info = stageInfo(stage);\n this.callbacks.onStageCleared?.(info.chapter, info.stage);\n this.report(METRIC_STAGE_CLEARED, 1);\n this.scheduleSave();\n }\n\n private scheduleSave(): void {\n if (this.saveTimer) clearTimeout(this.saveTimer);\n this.saveTimer = setTimeout(() => this.flushSave(), SAVE_DELAY_MS);\n }\n\n private flushSave(): void {\n if (this.saveTimer) clearTimeout(this.saveTimer);\n this.saveTimer = null;\n if (!this.loaded) return;\n const value = serializeProgress({\n stage: this.battle.stage,\n best: this.battle.best,\n });\n if (value === this.readProgress()) return;\n void this.client.userCustomData.setPrivateData(PROGRESS_KEY, value);\n }\n\n // ── Quest metrics ──────────────────────────────────────────────────────────────────────────\n\n /** Report a metric to the quests that count it — one call at a time, never above their cap. */\n private report(metricID: string, amount: number): void {\n if (!questMetric(this.questSection(), metricID)) return;\n const q = this.metricQueue.get(metricID) ?? { pending: 0, busy: false };\n q.pending += amount;\n this.metricQueue.set(metricID, q);\n void this.pumpMetric(metricID);\n }\n\n private async pumpMetric(metricID: string): Promise<void> {\n const q = this.metricQueue.get(metricID);\n const metric = questMetric(this.questSection(), metricID);\n if (!q || q.busy || q.pending <= 0 || !metric) return;\n const chunk =\n metric.maxPerCall > 0\n ? Math.min(q.pending, metric.maxPerCall)\n : q.pending;\n q.busy = true;\n q.pending -= chunk;\n await this.client.quest.addQuestProgress(metricID, chunk);\n q.busy = false;\n void this.pumpMetric(metricID);\n }\n\n private questSection(): QuestSection | undefined {\n return configSection<QuestSection>(this.client, \"Quest\");\n }\n}\n\n// ── Pure helpers ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * The fighter: the selected hero while it is playable; else the strongest owned hero; else the\n * first hero playable by default (roster order).\n */\nexport function pickHero(\n section: CharacterSection | undefined,\n owned: Record<string, HeroModelView | undefined>,\n selectedID: string | null,\n): string | null {\n const defs = section?.Definitions ?? {};\n const playable = (id: string) =>\n Number(owned[id]?.Level ?? 0) > 0 || !!defs[id]?.Unlock?.UnlockedByDefault;\n if (selectedID && defs[selectedID] && playable(selectedID)) return selectedID;\n const strongest = Object.entries(owned)\n .filter(([id, m]) => defs[id] && Number(m?.Level ?? 0) > 0)\n .sort(([, a], [, b]) => Number(b?.Power ?? 0) - Number(a?.Power ?? 0))[0];\n if (strongest) return strongest[0];\n const byOrder = Object.entries(defs)\n .filter(([id, d]) => d && playable(id))\n .sort(\n ([a, x], [b, y]) =>\n Number(x?.Identity?.SortOrder ?? 0) -\n Number(y?.Identity?.SortOrder ?? 0) || a.localeCompare(b),\n )[0];\n return byOrder?.[0] ?? null;\n}\n\nexport function parseProgress(\n raw: string | null,\n): { stage: number; best: number } | null {\n if (!raw) return null;\n try {\n const v = JSON.parse(raw) as { stage?: unknown; best?: unknown };\n const stage = Math.floor(Number(v.stage));\n if (!Number.isFinite(stage) || stage < 1) return null;\n const best = Math.max(stage, Math.floor(Number(v.best)) || stage);\n return { stage, best };\n } catch {\n return null;\n }\n}\n\nexport function serializeProgress(p: { stage: number; best: number }): string {\n return JSON.stringify({ stage: p.stage, best: p.best });\n}\n"
|
|
146
146
|
},
|
|
147
147
|
{
|
|
148
148
|
"path": "game/stages.ts",
|
|
@@ -214,7 +214,7 @@
|
|
|
214
214
|
},
|
|
215
215
|
{
|
|
216
216
|
"path": "ui/EnhancePanel.tsx",
|
|
217
|
-
"content": "import { memo, useEffect, useRef, useState, type ReactNode } from \"react\";\nimport {\n Columns,\n Icon,\n ResourceCounter,\n buttonStyle,\n outlined,\n useCatalog,\n useUiKit,\n v,\n type Glyph,\n} from \"@idosgames/react/ui\";\nimport type { HeroView, IdleSession } from \"../game/session\";\nimport {\n PERCENT_ROLES,\n enhanceValue,\n statCap,\n statCost,\n type Role,\n type StatInfo,\n} from \"../game/heroStats\";\nimport { formatBig, formatStat } from \"../game/format\";\nimport { t } from \"../i18n\";\n\n// \"Enhance\" — the stats of the fighting hero, bought with gold level by level (Legend Slime's main\n// list). ×1 / ×10 / MAX sets how many levels one tap asks for; holding the button keeps buying. The\n// session sends one request per stat at a time and adds the taps in between up — see IdleSession.enhance.\n\ntype Mult = 1 | 10 | \"max\";\nconst MAX_LEVELS = 1000;\nconst HOLD_START_MS = 380;\nconst HOLD_EVERY_MS = 140;\n\nconst ROLE_GLYPH: Record<Role, Glyph> = {\n damage: \"sword\",\n health: \"heart\",\n regen: \"potion\",\n armor: \"shield\",\n attackSpeed: \"energy\",\n critChance: \"star\",\n critDamage: \"flame\",\n dodge: \"bulb\",\n multiShot: \"swords\",\n allMight: \"crown\",\n};\n\nexport function EnhancePanel({\n session,\n hero,\n}: {\n session: IdleSession;\n hero: HeroView;\n}): ReactNode {\n const [mult, setMult] = useState<Mult>(1);\n const catalog = useCatalog();\n const currency =\n session.income?.currencyID ??\n hero.stats.find((s) => s.price)?.price?.currencyID ??\n \"\";\n const rate = session.ratePerSecond();\n\n return (\n <div style={{ display: \"grid\", gap: 8, minHeight: 0 }}>\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n justifyContent: \"space-between\",\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 8 }}>\n {currency ? (\n <ResourceCounter\n currencyID={currency}\n amount={session.balance(currency)}\n size={26}\n />\n ) : null}\n {rate > 0 ? (\n <span style={{ ...outlined, fontSize: 12, color: v.green }}>\n +{formatBig(Math.round(rate * 10) / 10)}\n {t(\"perSecond\")}\n </span>\n ) : null}\n </div>\n <div style={{ display: \"flex\", gap: 4 }}>\n {([1, 10, \"max\"] as Mult[]).map((m) => (\n <button\n key={String(m)}\n type=\"button\"\n className=\"idos-press\"\n onClick={() => setMult(m)}\n data-tutorial-anchor={`idle:mult:${m}`}\n style={{\n ...buttonStyle(mult === m ? \"gold\" : \"grey\", false, \"sm\"),\n padding: \"4px 10px\",\n minWidth: 44,\n }}\n >\n {m === \"max\" ? t(\"max\") : `×${m}`}\n </button>\n ))}\n </div>\n </div>\n <div\n style={{\n display: \"flex\",\n alignItems: \"baseline\",\n gap: 8,\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n }}\n >\n <span style={{ color: v.text, fontSize: 15 }}>\n {catalog.localize(hero.name)}\n </span>\n <span>\n {t(\"rank\")} {hero.rank}\n </span>\n <span>\n {t(\"power\")} {formatBig(hero.power)}\n </span>\n </div>\n <Rows session={session} hero={hero} mult={mult} />\n </div>\n );\n}\n\n// The rows redraw when the hero changes (an upgrade, gear, the balance) — not with the fight.\nconst Rows = memo(function Rows({\n session,\n hero,\n mult,\n}: {\n session: IdleSession;\n hero: HeroView;\n mult: Mult;\n}): ReactNode {\n const stats = hero.stats.filter((s) => s.price);\n return (\n <Columns gap={6} min={340}>\n {stats.map((stat) => (\n <StatRow\n key={stat.id}\n session={session}\n hero={hero}\n stat={stat}\n mult={mult}\n />\n ))}\n </Columns>\n );\n});\n\nfunction StatRow({\n session,\n hero,\n stat,\n mult,\n}: {\n session: IdleSession;\n hero: HeroView;\n stat: StatInfo;\n mult: Mult;\n}): ReactNode {\n const catalog = useCatalog();\n const level = Number(hero.input.model?.StatLevels?.[stat.id] ?? 0);\n const cap = statCap(stat, Math.max(1, hero.rank), hero.def);\n const capped = cap > 0 && level >= cap;\n const want = mult === \"max\" ? MAX_LEVELS : mult;\n const plan = session.plan(stat.id, want);\n const levels = plan.levels;\n const percent = stat.role ? PERCENT_ROLES.has(stat.role) : false;\n const value = enhanceValue(hero.input, stat.id, level);\n const next = enhanceValue(hero.input, stat.id, level + Math.max(1, levels));\n const missing = stat.requirements.filter(\n (r) => Number(hero.input.model?.StatLevels?.[r.statID] ?? 0) < r.level,\n );\n const blocked = capped || missing.length > 0;\n const can = !blocked && levels > 0;\n const cost = levels > 0 ? plan.cost : statCost(stat, level + 1);\n\n return (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n padding: \"6px 8px\",\n borderRadius: 12,\n background: v.panel,\n border: `2px solid ${v.panelEdge}`,\n boxShadow: v.panelShadow,\n }}\n >\n <div\n style={{\n width: 46,\n height: 46,\n flex: \"0 0 46px\",\n borderRadius: 12,\n display: \"grid\",\n placeItems: \"center\",\n background: v.wellSoft,\n border: `2px solid ${v.panelEdge}`,\n position: \"relative\",\n }}\n >\n <Icon glyph={stat.role ? ROLE_GLYPH[stat.role] : \"star\"} size={28} />\n <span\n style={{\n ...outlined,\n position: \"absolute\",\n bottom: -6,\n fontSize: 10,\n padding: \"0 4px\",\n borderRadius: 6,\n background: v.panelDeep,\n }}\n >\n Lv {formatBig(level)}\n </span>\n </div>\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {catalog.localize(stat.name)}\n </div>\n <div style={{ ...outlined, fontSize: 15, color: v.gold }}>\n {formatStat(value, percent)}\n {!blocked && levels > 0 ? (\n <span style={{ fontSize: 11, color: v.green }}>\n {\" \"}\n → {formatStat(next, percent)}\n </span>\n ) : null}\n </div>\n {cap > 0 ? (\n <div style={{ ...outlined, fontSize: 10, color: v.textDim }}>\n {level}/{cap}\n {capped ? ` · ${t(\"rankUpToRaise\")}` : \"\"}\n {missing.length > 0\n ? ` · ${t(\"needs\")}: ${missing.map((m) => `${m.statID} ${m.level}`).join(\", \")}`\n : \"\"}\n </div>\n ) : null}\n </div>\n <HoldButton\n disabled={!can}\n repeat={mult !== \"max\"}\n onPress={() => session.enhance(stat.id, levels > 0 ? levels : 1)}\n anchor={`idle:enhance:${stat.id}`}\n >\n {capped ? (\n t(\"maxed\")\n ) : (\n <span\n style={{ display: \"grid\", justifyItems: \"center\", lineHeight: 1.1 }}\n >\n <span style={{ fontSize: 13 }}>\n {t(\"enhance\")}\n {levels > 1 ? ` ×${levels}` : \"\"}\n </span>\n {stat.price ? (\n <span\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 3,\n fontSize: 12,\n }}\n >\n <Icon\n glyph={catalog.iconOf({\n kind: \"currency\",\n id: stat.price.currencyID,\n amount: cost,\n })}\n size={14}\n />\n {formatBig(cost)}\n </span>\n ) : null}\n </span>\n )}\n </HoldButton>\n </div>\n );\n}\n\n/** A button that fires on press and keeps firing while held (after a short pause). */\nfunction HoldButton({\n children,\n disabled,\n repeat,\n onPress,\n anchor,\n}: {\n children: ReactNode;\n disabled: boolean;\n repeat: boolean;\n onPress: () => void;\n anchor: string;\n}): ReactNode {\n const kit = useUiKit();\n const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\n const press = useRef(onPress);\n press.current = onPress;\n const live = useRef(!disabled);\n live.current = !disabled;\n\n const stop = () => {\n if (timer.current) clearTimeout(timer.current);\n timer.current = null;\n };\n useEffect(() => stop, []);\n useEffect(() => {\n if (disabled) stop();\n }, [disabled]);\n\n const loop = () => {\n timer.current = setTimeout(() => {\n if (!live.current) return stop();\n press.current();\n loop();\n }, HOLD_EVERY_MS);\n };\n\n return (\n <button\n type=\"button\"\n className=\"idos-press\"\n disabled={disabled}\n data-tutorial-anchor={anchor}\n onPointerDown={(e) => {\n if (disabled || e.button !== 0) return;\n kit.play(\"click\");\n press.current();\n if (!repeat) return;\n stop();\n timer.current = setTimeout(loop, HOLD_START_MS);\n }}\n onPointerUp={stop}\n onPointerLeave={stop}\n onPointerCancel={stop}\n onContextMenu={(e) => e.preventDefault()}\n style={{\n ...buttonStyle(\"green\", disabled, \"sm\"),\n minWidth: 104,\n minHeight: 46,\n touchAction: \"none\",\n userSelect: \"none\",\n }}\n >\n {children}\n </button>\n );\n}\n"
|
|
217
|
+
"content": "import { memo, useEffect, useRef, useState, type ReactNode } from \"react\";\nimport {\n Columns,\n Icon,\n ResourceCounter,\n buttonStyle,\n outlined,\n useCatalog,\n useUiKit,\n v,\n type Glyph,\n} from \"@idosgames/react/ui\";\nimport type { HeroView, IdleSession } from \"../game/session\";\nimport {\n CREDITS,\n PERCENT_ROLES,\n enhanceValue,\n statCap,\n statCost,\n type Role,\n type StatInfo,\n} from \"../game/heroStats\";\nimport { formatBig, formatStat } from \"../game/format\";\nimport { t } from \"../i18n\";\n\n// \"Enhance\" — the stats of the fighting hero, bought with gold level by level (Legend Slime's main\n// list). ×1 / ×10 / MAX sets how many levels one tap asks for; holding the button keeps buying. The\n// session sends one request per stat at a time and adds the taps in between up — see IdleSession.enhance.\n\ntype Mult = 1 | 10 | \"max\";\nconst MAX_LEVELS = 1000;\nconst HOLD_START_MS = 380;\nconst HOLD_EVERY_MS = 140;\n\nconst ROLE_GLYPH: Record<Role, Glyph> = {\n damage: \"sword\",\n health: \"heart\",\n regen: \"potion\",\n armor: \"shield\",\n attackSpeed: \"energy\",\n critChance: \"star\",\n critDamage: \"flame\",\n dodge: \"bulb\",\n multiShot: \"swords\",\n allMight: \"crown\",\n};\n\nexport function EnhancePanel({\n session,\n hero,\n}: {\n session: IdleSession;\n hero: HeroView;\n}): ReactNode {\n const [mult, setMult] = useState<Mult>(1);\n const catalog = useCatalog();\n const currency =\n session.income?.currencyID ??\n hero.stats.find((s) => s.price && s.price.currencyID !== CREDITS)?.price\n ?.currencyID ??\n \"\";\n const rate = session.ratePerSecond();\n\n return (\n <div style={{ display: \"grid\", gap: 8, minHeight: 0 }}>\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n justifyContent: \"space-between\",\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 8 }}>\n {currency ? (\n <ResourceCounter\n currencyID={currency}\n amount={session.balance(currency)}\n size={26}\n />\n ) : null}\n {rate > 0 ? (\n <span style={{ ...outlined, fontSize: 12, color: v.green }}>\n +{formatBig(Math.round(rate * 10) / 10)}\n {t(\"perSecond\")}\n </span>\n ) : null}\n </div>\n <div style={{ display: \"flex\", gap: 4 }}>\n {([1, 10, \"max\"] as Mult[]).map((m) => (\n <button\n key={String(m)}\n type=\"button\"\n className=\"idos-press\"\n onClick={() => setMult(m)}\n data-tutorial-anchor={`idle:mult:${m}`}\n style={{\n ...buttonStyle(mult === m ? \"gold\" : \"grey\", false, \"sm\"),\n padding: \"4px 10px\",\n minWidth: 44,\n }}\n >\n {m === \"max\" ? t(\"max\") : `×${m}`}\n </button>\n ))}\n </div>\n </div>\n <div\n style={{\n display: \"flex\",\n alignItems: \"baseline\",\n gap: 8,\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n }}\n >\n <span style={{ color: v.text, fontSize: 15 }}>\n {catalog.localize(hero.name)}\n </span>\n <span>\n {t(\"rank\")} {hero.rank}\n </span>\n <span>\n {t(\"power\")} {formatBig(hero.power)}\n </span>\n </div>\n <Rows session={session} hero={hero} mult={mult} />\n </div>\n );\n}\n\n// The rows redraw when the hero changes (an upgrade, gear, the balance) — not with the fight.\nconst Rows = memo(function Rows({\n session,\n hero,\n mult,\n}: {\n session: IdleSession;\n hero: HeroView;\n mult: Mult;\n}): ReactNode {\n const stats = hero.stats.filter((s) => s.price);\n return (\n <Columns gap={6} min={340}>\n {stats.map((stat) => (\n <StatRow\n key={stat.id}\n session={session}\n hero={hero}\n stat={stat}\n mult={mult}\n />\n ))}\n </Columns>\n );\n});\n\nfunction StatRow({\n session,\n hero,\n stat,\n mult,\n}: {\n session: IdleSession;\n hero: HeroView;\n stat: StatInfo;\n mult: Mult;\n}): ReactNode {\n const catalog = useCatalog();\n const level = Number(hero.input.model?.StatLevels?.[stat.id] ?? 0);\n const cap = statCap(stat, Math.max(1, hero.rank), hero.def);\n const capped = cap > 0 && level >= cap;\n const want = mult === \"max\" ? MAX_LEVELS : mult;\n const plan = session.plan(stat.id, want);\n const levels = plan.levels;\n const percent = stat.role ? PERCENT_ROLES.has(stat.role) : false;\n const value = enhanceValue(hero.input, stat.id, level);\n const next = enhanceValue(hero.input, stat.id, level + Math.max(1, levels));\n const missing = stat.requirements.filter(\n (r) => Number(hero.input.model?.StatLevels?.[r.statID] ?? 0) < r.level,\n );\n const blocked = capped || missing.length > 0;\n const can = !blocked && levels > 0;\n const cost = levels > 0 ? plan.cost : statCost(stat, level + 1);\n\n return (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n padding: \"6px 8px\",\n borderRadius: 12,\n background: v.panel,\n border: `2px solid ${v.panelEdge}`,\n boxShadow: v.panelShadow,\n }}\n >\n <div\n style={{\n width: 46,\n height: 46,\n flex: \"0 0 46px\",\n borderRadius: 12,\n display: \"grid\",\n placeItems: \"center\",\n background: v.wellSoft,\n border: `2px solid ${v.panelEdge}`,\n position: \"relative\",\n }}\n >\n <Icon glyph={stat.role ? ROLE_GLYPH[stat.role] : \"star\"} size={28} />\n <span\n style={{\n ...outlined,\n position: \"absolute\",\n bottom: -6,\n fontSize: 10,\n padding: \"0 4px\",\n borderRadius: 6,\n background: v.panelDeep,\n }}\n >\n Lv {formatBig(level)}\n </span>\n </div>\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {catalog.localize(stat.name)}\n </div>\n <div style={{ ...outlined, fontSize: 15, color: v.gold }}>\n {formatStat(value, percent)}\n {!blocked && levels > 0 ? (\n <span style={{ fontSize: 11, color: v.green }}>\n {\" \"}\n → {formatStat(next, percent)}\n </span>\n ) : null}\n </div>\n {cap > 0 ? (\n <div style={{ ...outlined, fontSize: 10, color: v.textDim }}>\n {level}/{cap}\n {capped ? ` · ${t(\"rankUpToRaise\")}` : \"\"}\n {missing.length > 0\n ? ` · ${t(\"needs\")}: ${missing.map((m) => `${m.statID} ${m.level}`).join(\", \")}`\n : \"\"}\n </div>\n ) : null}\n </div>\n <HoldButton\n disabled={!can}\n repeat={mult !== \"max\"}\n onPress={() => session.enhance(stat.id, levels > 0 ? levels : 1)}\n anchor={`idle:enhance:${stat.id}`}\n >\n {capped ? (\n t(\"maxed\")\n ) : (\n <span\n style={{ display: \"grid\", justifyItems: \"center\", lineHeight: 1.1 }}\n >\n <span style={{ fontSize: 13 }}>\n {t(\"enhance\")}\n {levels > 1 ? ` ×${levels}` : \"\"}\n </span>\n {stat.price ? (\n <span\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 3,\n fontSize: 12,\n }}\n >\n <Icon\n glyph={catalog.iconOf({\n kind:\n stat.price.currencyID === CREDITS ? \"credits\" : \"currency\",\n id: stat.price.currencyID,\n amount: cost,\n })}\n size={14}\n />\n {formatBig(cost)}\n </span>\n ) : null}\n </span>\n )}\n </HoldButton>\n </div>\n );\n}\n\n/** A button that fires on press and keeps firing while held (after a short pause). */\nfunction HoldButton({\n children,\n disabled,\n repeat,\n onPress,\n anchor,\n}: {\n children: ReactNode;\n disabled: boolean;\n repeat: boolean;\n onPress: () => void;\n anchor: string;\n}): ReactNode {\n const kit = useUiKit();\n const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\n const press = useRef(onPress);\n press.current = onPress;\n const live = useRef(!disabled);\n live.current = !disabled;\n\n const stop = () => {\n if (timer.current) clearTimeout(timer.current);\n timer.current = null;\n };\n useEffect(() => stop, []);\n useEffect(() => {\n if (disabled) stop();\n }, [disabled]);\n\n const loop = () => {\n timer.current = setTimeout(() => {\n if (!live.current) return stop();\n press.current();\n loop();\n }, HOLD_EVERY_MS);\n };\n\n return (\n <button\n type=\"button\"\n className=\"idos-press\"\n disabled={disabled}\n data-tutorial-anchor={anchor}\n onPointerDown={(e) => {\n if (disabled || e.button !== 0) return;\n kit.play(\"click\");\n press.current();\n if (!repeat) return;\n stop();\n timer.current = setTimeout(loop, HOLD_START_MS);\n }}\n onPointerUp={stop}\n onPointerLeave={stop}\n onPointerCancel={stop}\n onContextMenu={(e) => e.preventDefault()}\n style={{\n ...buttonStyle(\"green\", disabled, \"sm\"),\n minWidth: 104,\n minHeight: 46,\n touchAction: \"none\",\n userSelect: \"none\",\n }}\n >\n {children}\n </button>\n );\n}\n"
|
|
218
218
|
},
|
|
219
219
|
{
|
|
220
220
|
"path": "ui/GamePanel.tsx",
|
|
@@ -32,9 +32,9 @@
|
|
|
32
32
|
"version": "0.1.1"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@idosgames/core": "0.
|
|
36
|
-
"@idosgames/module-sdk": "0.13.
|
|
37
|
-
"@idosgames/react": "0.
|
|
35
|
+
"@idosgames/core": "0.26.1",
|
|
36
|
+
"@idosgames/module-sdk": "0.13.3",
|
|
37
|
+
"@idosgames/react": "0.7.1",
|
|
38
38
|
"react": "19.2.7"
|
|
39
39
|
},
|
|
40
40
|
"contentHash": "0b02bc09a1a29ecb6d08fa281dfe316b01f8f341350ba27f386a05572827700e",
|
|
@@ -35,9 +35,9 @@
|
|
|
35
35
|
"version": "0.1.3"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@idosgames/core": "0.
|
|
39
|
-
"@idosgames/module-sdk": "0.13.
|
|
40
|
-
"@idosgames/react": "0.
|
|
38
|
+
"@idosgames/core": "0.26.1",
|
|
39
|
+
"@idosgames/module-sdk": "0.13.3",
|
|
40
|
+
"@idosgames/react": "0.7.1",
|
|
41
41
|
"react": "19.2.7"
|
|
42
42
|
},
|
|
43
43
|
"contentHash": "0e2ffb620e408e8a97d16de8ab88b36c09e7295d3e84d4f36f746e9beb08666b",
|