@idosgames/mcp 0.1.44 → 0.1.45

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.
Files changed (38) hide show
  1. package/dist/cli.js +1 -1
  2. package/package.json +1 -1
  3. package/registry/host.json +1 -1
  4. package/registry/index.json +119 -119
  5. package/registry/modules/ai.json +5 -5
  6. package/registry/modules/board-game.json +6 -6
  7. package/registry/modules/boosts.json +5 -5
  8. package/registry/modules/character.json +9 -9
  9. package/registry/modules/chat.json +3 -3
  10. package/registry/modules/collections.json +5 -5
  11. package/registry/modules/coop-events.json +6 -6
  12. package/registry/modules/craft.json +5 -5
  13. package/registry/modules/deals.json +6 -6
  14. package/registry/modules/events.json +3 -3
  15. package/registry/modules/friends.json +3 -3
  16. package/registry/modules/hero-run.json +5 -5
  17. package/registry/modules/idle-rpg.json +7 -7
  18. package/registry/modules/inventory.json +3 -3
  19. package/registry/modules/invite.json +3 -3
  20. package/registry/modules/leaderboard.json +3 -3
  21. package/registry/modules/lootboxes.json +5 -5
  22. package/registry/modules/mailbox.json +3 -3
  23. package/registry/modules/marketplace.json +3 -3
  24. package/registry/modules/mini-game.json +3 -3
  25. package/registry/modules/multiplayer.json +3 -3
  26. package/registry/modules/premium.json +5 -5
  27. package/registry/modules/profile.json +3 -3
  28. package/registry/modules/pvp.json +3 -3
  29. package/registry/modules/quests.json +3 -3
  30. package/registry/modules/rewards.json +3 -3
  31. package/registry/modules/season.json +3 -3
  32. package/registry/modules/store.json +6 -6
  33. package/registry/modules/tutorials.json +3 -3
  34. package/registry/modules/video-studio.json +8 -8
  35. package/registry/modules/voxelcraft.json +5 -5
  36. package/registry/modules/workshop.json +6 -6
  37. package/registry/skills/idosgames-ui-kit.json +1 -1
  38. package/registry/skills/store-system.json +1 -1
@@ -76,13 +76,13 @@
76
76
  },
77
77
  "dependencies": {
78
78
  "@idosgames/characters": "0.1.2",
79
- "@idosgames/core": "0.25.0",
80
- "@idosgames/module-sdk": "0.13.2",
81
- "@idosgames/react": "0.6.3",
79
+ "@idosgames/core": "0.26.0",
80
+ "@idosgames/module-sdk": "0.13.3",
81
+ "@idosgames/react": "0.7.0",
82
82
  "phaser": "4.2.1",
83
83
  "react": "19.2.7"
84
84
  },
85
- "contentHash": "25599b5c55af0ebca3eef6909ef435c0e054063829da89985da8b72f61c9dafc",
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.25.0",
36
- "@idosgames/module-sdk": "0.13.2",
37
- "@idosgames/react": "0.6.3",
35
+ "@idosgames/core": "0.26.0",
36
+ "@idosgames/module-sdk": "0.13.3",
37
+ "@idosgames/react": "0.7.0",
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.25.0",
39
- "@idosgames/module-sdk": "0.13.2",
40
- "@idosgames/react": "0.6.3",
38
+ "@idosgames/core": "0.26.0",
39
+ "@idosgames/module-sdk": "0.13.3",
40
+ "@idosgames/react": "0.7.0",
41
41
  "react": "19.2.7"
42
42
  },
43
43
  "contentHash": "0e2ffb620e408e8a97d16de8ab88b36c09e7295d3e84d4f36f746e9beb08666b",
@@ -36,9 +36,9 @@
36
36
  "version": "0.1.5"
37
37
  },
38
38
  "dependencies": {
39
- "@idosgames/core": "0.25.0",
40
- "@idosgames/module-sdk": "0.13.2",
41
- "@idosgames/react": "0.6.3",
39
+ "@idosgames/core": "0.26.0",
40
+ "@idosgames/module-sdk": "0.13.3",
41
+ "@idosgames/react": "0.7.0",
42
42
  "react": "19.2.7"
43
43
  },
44
44
  "contentHash": "8aa99b6b9980f06e2a66adf38ff0c1a20d5e2eefc197a5a81b75c1140faf0b66",
@@ -32,12 +32,12 @@
32
32
  "version": "0.1.4"
33
33
  },
34
34
  "dependencies": {
35
- "@idosgames/core": "0.25.0",
36
- "@idosgames/module-sdk": "0.13.2",
37
- "@idosgames/react": "0.6.3",
35
+ "@idosgames/core": "0.26.0",
36
+ "@idosgames/module-sdk": "0.13.3",
37
+ "@idosgames/react": "0.7.0",
38
38
  "react": "19.2.7"
39
39
  },
40
- "contentHash": "7942c63623452b8e5f162adebd6123c2effacfe8adfe2e9a1cba739482e8b6ee",
40
+ "contentHash": "55544f5bae173482aaa22e92612cade99a356547ee80b68fc096dde8fb700b47",
41
41
  "files": [
42
42
  {
43
43
  "path": "i18n.ts",
@@ -49,7 +49,7 @@
49
49
  },
50
50
  {
51
51
  "path": "LootboxesScreen.tsx",
52
- "content": "import type { ModuleContext } from \"@idosgames/module-sdk\";\nimport { useEffect, useMemo, useRef, useState, type ReactNode } from \"react\";\nimport type { FeatureScreenProps } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n EmptyState,\n Icon,\n KitPortal,\n ResourceList,\n UCloseCircle,\n URays,\n URibbon,\n UStyles,\n configSection,\n errorText,\n grantedBy,\n outlined,\n uText,\n useCatalog,\n useCelebrate,\n useSound,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n BULK,\n canOpenBulk,\n canPay,\n lootboxName,\n lootboxOptions,\n lootboxesOpenedBy,\n openableWithItems,\n type InventoryView,\n type LootboxOption,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Chests (Unity LootboxOpeningPopUp): every lootbox of the title with every way to open it — once,\n// or ten at a time where the ceiling and the wallet allow. The box shakes while the server rolls it,\n// bursts, and the rewards fly out. The inventory hands a chest or key item over here\n// (`features.open(\"lootboxes\", { itemID })`): the screen then shows what that item opens.\n\nexport function LootboxesScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const stagger = useStagger();\n const section = configSection(client, \"Lootbox\");\n const all = useMemo(() => lootboxOptions(section), [section]);\n const itemID = typeof args?.itemID === \"string\" ? args.itemID : null;\n const options = itemID ? lootboxesOpenedBy(itemID, all) : all;\n const [opening, setOpening] = useState<{\n option: LootboxOption;\n count: number;\n } | null>(null);\n\n // Handed an item that opens exactly one chest — open its popup right away.\n useEffect(() => {\n if (itemID && options.length === 1)\n setOpening({ option: options[0]!, count: 1 });\n }, [itemID]);\n\n const inventory = state?.InventoryV2 as InventoryView | undefined;\n if (options.length === 0)\n return <EmptyState glyph=\"chest\" text={t(\"noChests\")} />;\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(170px, 1fr))\",\n gap: 12,\n }}\n >\n {options.map((o, i) => {\n const affordable = canPay(o, inventory);\n const bulk = canOpenBulk(o, inventory);\n return (\n <Card\n key={`${o.lootboxID}:${o.optionID}`}\n style={{\n display: \"grid\",\n justifyItems: \"center\",\n gap: 8,\n ...stagger(i),\n }}\n >\n <div style={{ ...outlined, fontSize: 15, textAlign: \"center\" }}>\n {lootboxName(o.lootboxID, catalog.localize)}\n </div>\n <div className=\"idos-bounce\">\n <Icon glyph={o.icon} size={64} fallback=\"chest\" />\n </div>\n {o.name ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {catalog.localize(o.name)}\n </div>\n ) : null}\n <Button\n tone=\"gold\"\n attract={affordable}\n disabled={!affordable}\n onClick={() => setOpening({ option: o, count: 1 })}\n style={{ width: \"100%\" }}\n data-tutorial-anchor={`lootbox:${o.lootboxID}`}\n >\n {o.price.length === 0 ? (\n t(\"open\")\n ) : (\n <ResourceList lines={o.price} size={20} />\n )}\n </Button>\n {bulk ? (\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() => setOpening({ option: o, count: BULK })}\n style={{ width: \"100%\" }}\n >\n {t(\"openTimes\")} ×{BULK}\n </Button>\n ) : null}\n </Card>\n );\n })}\n {opening ? (\n <LootboxOpening\n option={opening.option}\n count={opening.count}\n onClose={() => setOpening(null)}\n />\n ) : null}\n </div>\n );\n}\n\n/**\n * Unity LootboxOpeningPopUp, full screen: the violet glow with the pattern, the orange CHEST ribbon,\n * the box over turning cyan rays and sparkles, the red count badge, \"Tap it now!\". A tap shakes the\n * box while the server rolls it, a white flash bursts it open, and the rewards come in the kit's\n * reward window.\n */\nfunction LootboxOpening({\n option,\n count,\n onClose,\n}: {\n option: LootboxOption;\n count: number;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const play = useSound();\n const kit = useUiKit();\n const boxRef = useRef<HTMLButtonElement>(null);\n const [phase, setPhase] = useState<\"idle\" | \"shake\" | \"burst\">(\"idle\");\n const name = lootboxName(option.lootboxID, catalog.localize);\n\n useEffect(() => {\n play(\"open\");\n }, []);\n\n const openBox = async () => {\n if (phase !== \"idle\") return;\n setPhase(\"shake\");\n const started = Date.now();\n const res = await client.lootbox.open(\n option.lootboxID,\n count,\n option.optionID,\n );\n // Let the shake play for a moment even on a fast server — the anticipation is the point.\n const wait = Math.max(\n 0,\n (kit.motion.on ? 900 : 0) - (Date.now() - started),\n );\n await new Promise((r) => setTimeout(r, wait));\n if (!res.ok) {\n setPhase(\"idle\");\n toast(errorText(res.error), \"error\");\n return;\n }\n play(\"lootbox\");\n kit.fx.burst(boxRef.current);\n setPhase(\"burst\");\n await new Promise((r) => setTimeout(r, kit.motion.on ? 450 : 0));\n onClose();\n // `Resources` is the whole operation — every box of a bulk opening already summed by the server\n // (`Results` is the same per box; adding both would count everything twice).\n celebrate(grantedBy(res.data), { sound: \"levelUp\" });\n };\n\n return (\n <KitPortal>\n <div\n style={{\n position: \"fixed\",\n inset: 0,\n zIndex: 40,\n overflow: \"hidden\",\n // The kit layer lets clicks through; a full-screen window takes them itself.\n pointerEvents: \"auto\",\n display: \"grid\",\n gridTemplateRows: \"auto 1fr auto\",\n justifyItems: \"center\",\n padding:\n \"max(28px, env(safe-area-inset-top)) 16px max(24px, env(safe-area-inset-bottom))\",\n // The sheet's \"Tap it now!\": a blue burst — light from the middle, faint rays around it.\n backgroundColor: v.blueDeep,\n backgroundImage: `repeating-conic-gradient(from 0deg at 50% 45%, rgba(255,255,255,.07) 0 9deg, transparent 9deg 18deg), radial-gradient(ellipse at 50% 45%, color-mix(in srgb, ${v.blue} 65%, #fff) 0%, ${v.blue} 35%, ${v.blueDeep} 80%)`,\n animation: \"idos-u-pop .3s ease-out both\",\n fontFamily: \"inherit\",\n }}\n >\n <UStyles />\n <div style={{ width: \"100%\", maxWidth: 360, marginTop: 12 }}>\n <URibbon tone=\"orange\" width={340}>\n {count > 1 ? `${name.toUpperCase()} ×${count}` : name.toUpperCase()}\n </URibbon>\n </div>\n\n <div\n style={{\n position: \"relative\",\n width: 300,\n height: 300,\n alignSelf: \"center\",\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <span style={{ position: \"absolute\", inset: -40, opacity: 0.9 }}>\n <URays size={380} color=\"rgba(255,255,255,.35)\" />\n </span>\n {SPARKS.map(([x, y, s, d], i) => (\n <span\n key={i}\n style={{\n position: \"absolute\",\n left: x,\n top: y,\n animation: `idos-u-float ${d}s ease-in-out infinite`,\n opacity: 0.85,\n }}\n >\n <Icon glyph=\"star\" size={s} />\n </span>\n ))}\n <button\n type=\"button\"\n ref={boxRef}\n onClick={() => void openBox()}\n aria-label={t(\"open\")}\n style={{\n position: \"relative\",\n border: \"none\",\n background: \"none\",\n padding: 0,\n cursor: phase === \"idle\" ? \"pointer\" : \"default\",\n animation:\n phase === \"shake\"\n ? \"idos-u-shake .18s linear infinite\"\n : phase === \"burst\"\n ? \"idos-loot-burst .45s ease-in forwards\"\n : \"idos-u-float 1.8s ease-in-out infinite\",\n }}\n >\n <Icon glyph={option.icon} size={210} fallback=\"chest\" />\n {count > 1 ? (\n <span\n style={{\n position: \"absolute\",\n left: 6,\n top: 6,\n minWidth: 50,\n height: 50,\n borderRadius: 25,\n background: `radial-gradient(circle at 50% 35%, color-mix(in srgb, ${v.red} 70%, #fff) 0%, ${v.red} 70%)`,\n border: `3px solid ${v.ink}`,\n display: \"grid\",\n placeItems: \"center\",\n ...uText(28),\n }}\n >\n {count}\n </span>\n ) : null}\n </button>\n </div>\n\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 14 }}>\n {option.price.length > 0 ? (\n <ResourceList\n lines={option.price.map((l) => ({\n ...l,\n amount: l.amount * count,\n }))}\n size={22}\n />\n ) : null}\n <div\n style={{\n ...uText(30),\n animation:\n phase === \"idle\"\n ? \"idos-u-attract 1s ease-in-out infinite\"\n : undefined,\n visibility: phase === \"idle\" ? \"visible\" : \"hidden\",\n }}\n >\n {t(\"tapNow\")}\n </div>\n <UCloseCircle\n onClick={() => (phase === \"idle\" ? onClose() : undefined)}\n />\n </div>\n\n {/* Flash */}\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n background: \"#fff\",\n pointerEvents: \"none\",\n opacity: 0,\n animation:\n phase === \"burst\"\n ? \"idos-loot-flash .45s ease-out both\"\n : undefined,\n }}\n />\n <style>{LOOT_CSS}</style>\n </div>\n </KitPortal>\n );\n}\n\n/** Image_Effect_Star / Square sparkles around the box: left, top, size, float period. */\nconst SPARKS: Array<[number, number, number, number]> = [\n [18, 30, 34, 2.2],\n [244, 64, 42, 2.8],\n [30, 220, 28, 2.5],\n [250, 236, 30, 3.1],\n];\n\nconst LOOT_CSS = `\n@keyframes idos-loot-flash { 0% { opacity: 0 } 25% { opacity: 1 } 100% { opacity: 0 } }\n@keyframes idos-loot-burst { 0% { transform: scale(1) } 40% { transform: scale(1.25) } 100% { transform: scale(1.6); opacity: 0 } }\n`;\n\n/** Badge: chests the player can open right now with the items they hold (a chest item or a key). */\nexport function watchLootboxes(ctx: ModuleContext): void {\n const { client, features } = ctx;\n ctx.effect(() => {\n const update = () =>\n features.setBadge(\n \"lootboxes\",\n openableWithItems(\n lootboxOptions(configSection(client, \"Lootbox\")),\n client.data.user.state?.InventoryV2 as InventoryView | undefined,\n ),\n );\n update();\n return client.on(\"user:anyUpdated\", update);\n });\n}\n"
52
+ "content": "import type { ModuleContext } from \"@idosgames/module-sdk\";\nimport { useEffect, useMemo, useRef, useState, type ReactNode } from \"react\";\nimport type { FeatureScreenProps } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n EmptyState,\n Icon,\n KitPortal,\n ResourceList,\n UCloseCircle,\n URays,\n URibbon,\n UStyles,\n configSection,\n errorText,\n grantedBy,\n payWithCredits,\n outlined,\n uText,\n useCatalog,\n useCelebrate,\n useSound,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n BULK,\n canOpenBulk,\n canPay,\n lootboxName,\n lootboxOptions,\n lootboxesOpenedBy,\n openableWithItems,\n type InventoryView,\n type LootboxOption,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Chests (Unity LootboxOpeningPopUp): every lootbox of the title with every way to open it — once,\n// or ten at a time where the ceiling and the wallet allow. The box shakes while the server rolls it,\n// bursts, and the rewards fly out. The inventory hands a chest or key item over here\n// (`features.open(\"lootboxes\", { itemID })`): the screen then shows what that item opens.\n\nexport function LootboxesScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const stagger = useStagger();\n const section = configSection(client, \"Lootbox\");\n const all = useMemo(() => lootboxOptions(section), [section]);\n const itemID = typeof args?.itemID === \"string\" ? args.itemID : null;\n const options = itemID ? lootboxesOpenedBy(itemID, all) : all;\n const [opening, setOpening] = useState<{\n option: LootboxOption;\n count: number;\n } | null>(null);\n\n // Handed an item that opens exactly one chest — open its popup right away.\n useEffect(() => {\n if (itemID && options.length === 1)\n setOpening({ option: options[0]!, count: 1 });\n }, [itemID]);\n\n const inventory = state?.InventoryV2 as InventoryView | undefined;\n if (options.length === 0)\n return <EmptyState glyph=\"chest\" text={t(\"noChests\")} />;\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(170px, 1fr))\",\n gap: 12,\n }}\n >\n {options.map((o, i) => {\n const affordable = canPay(o, inventory);\n const bulk = canOpenBulk(o, inventory);\n return (\n <Card\n key={`${o.lootboxID}:${o.optionID}`}\n style={{\n display: \"grid\",\n justifyItems: \"center\",\n gap: 8,\n ...stagger(i),\n }}\n >\n <div style={{ ...outlined, fontSize: 15, textAlign: \"center\" }}>\n {lootboxName(o.lootboxID, catalog.localize)}\n </div>\n <div className=\"idos-bounce\">\n <Icon glyph={o.icon} size={64} fallback=\"chest\" />\n </div>\n {o.name ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {catalog.localize(o.name)}\n </div>\n ) : null}\n <Button\n tone=\"gold\"\n attract={affordable}\n disabled={!affordable}\n onClick={() => setOpening({ option: o, count: 1 })}\n style={{ width: \"100%\" }}\n data-tutorial-anchor={`lootbox:${o.lootboxID}`}\n >\n {o.price.length === 0 ? (\n t(\"open\")\n ) : (\n <ResourceList lines={o.price} size={20} />\n )}\n </Button>\n {bulk ? (\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() => setOpening({ option: o, count: BULK })}\n style={{ width: \"100%\" }}\n >\n {t(\"openTimes\")} ×{BULK}\n </Button>\n ) : null}\n </Card>\n );\n })}\n {opening ? (\n <LootboxOpening\n option={opening.option}\n count={opening.count}\n onClose={() => setOpening(null)}\n />\n ) : null}\n </div>\n );\n}\n\n/**\n * Unity LootboxOpeningPopUp, full screen: the violet glow with the pattern, the orange CHEST ribbon,\n * the box over turning cyan rays and sparkles, the red count badge, \"Tap it now!\". A tap shakes the\n * box while the server rolls it, a white flash bursts it open, and the rewards come in the kit's\n * reward window.\n */\nfunction LootboxOpening({\n option,\n count,\n onClose,\n}: {\n option: LootboxOption;\n count: number;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const play = useSound();\n const kit = useUiKit();\n const boxRef = useRef<HTMLButtonElement>(null);\n const [phase, setPhase] = useState<\"idle\" | \"shake\" | \"burst\">(\"idle\");\n const name = lootboxName(option.lootboxID, catalog.localize);\n\n useEffect(() => {\n play(\"open\");\n }, []);\n\n const openBox = async () => {\n if (phase !== \"idle\") return;\n setPhase(\"shake\");\n const started = Date.now();\n // A price in Aspis: the first call only asks the player to confirm on idosgames.com (the kit shows it).\n const res = await payWithCredits(client, (creditsConfirmationID) =>\n client.lootbox.open(\n option.lootboxID,\n count,\n option.optionID,\n undefined,\n creditsConfirmationID,\n ),\n );\n // Let the shake play for a moment even on a fast server — the anticipation is the point.\n const wait = Math.max(\n 0,\n (kit.motion.on ? 900 : 0) - (Date.now() - started),\n );\n await new Promise((r) => setTimeout(r, wait));\n if (!res.ok) {\n setPhase(\"idle\");\n toast(errorText(res.error), \"error\");\n return;\n }\n play(\"lootbox\");\n kit.fx.burst(boxRef.current);\n setPhase(\"burst\");\n await new Promise((r) => setTimeout(r, kit.motion.on ? 450 : 0));\n onClose();\n // `Resources` is the whole operation — every box of a bulk opening already summed by the server\n // (`Results` is the same per box; adding both would count everything twice).\n celebrate(grantedBy(res.data), { sound: \"levelUp\" });\n };\n\n return (\n <KitPortal>\n <div\n style={{\n position: \"fixed\",\n inset: 0,\n zIndex: 40,\n overflow: \"hidden\",\n // The kit layer lets clicks through; a full-screen window takes them itself.\n pointerEvents: \"auto\",\n display: \"grid\",\n gridTemplateRows: \"auto 1fr auto\",\n justifyItems: \"center\",\n padding:\n \"max(28px, env(safe-area-inset-top)) 16px max(24px, env(safe-area-inset-bottom))\",\n // The sheet's \"Tap it now!\": a blue burst — light from the middle, faint rays around it.\n backgroundColor: v.blueDeep,\n backgroundImage: `repeating-conic-gradient(from 0deg at 50% 45%, rgba(255,255,255,.07) 0 9deg, transparent 9deg 18deg), radial-gradient(ellipse at 50% 45%, color-mix(in srgb, ${v.blue} 65%, #fff) 0%, ${v.blue} 35%, ${v.blueDeep} 80%)`,\n animation: \"idos-u-pop .3s ease-out both\",\n fontFamily: \"inherit\",\n }}\n >\n <UStyles />\n <div style={{ width: \"100%\", maxWidth: 360, marginTop: 12 }}>\n <URibbon tone=\"orange\" width={340}>\n {count > 1 ? `${name.toUpperCase()} ×${count}` : name.toUpperCase()}\n </URibbon>\n </div>\n\n <div\n style={{\n position: \"relative\",\n width: 300,\n height: 300,\n alignSelf: \"center\",\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <span style={{ position: \"absolute\", inset: -40, opacity: 0.9 }}>\n <URays size={380} color=\"rgba(255,255,255,.35)\" />\n </span>\n {SPARKS.map(([x, y, s, d], i) => (\n <span\n key={i}\n style={{\n position: \"absolute\",\n left: x,\n top: y,\n animation: `idos-u-float ${d}s ease-in-out infinite`,\n opacity: 0.85,\n }}\n >\n <Icon glyph=\"star\" size={s} />\n </span>\n ))}\n <button\n type=\"button\"\n ref={boxRef}\n onClick={() => void openBox()}\n aria-label={t(\"open\")}\n style={{\n position: \"relative\",\n border: \"none\",\n background: \"none\",\n padding: 0,\n cursor: phase === \"idle\" ? \"pointer\" : \"default\",\n animation:\n phase === \"shake\"\n ? \"idos-u-shake .18s linear infinite\"\n : phase === \"burst\"\n ? \"idos-loot-burst .45s ease-in forwards\"\n : \"idos-u-float 1.8s ease-in-out infinite\",\n }}\n >\n <Icon glyph={option.icon} size={210} fallback=\"chest\" />\n {count > 1 ? (\n <span\n style={{\n position: \"absolute\",\n left: 6,\n top: 6,\n minWidth: 50,\n height: 50,\n borderRadius: 25,\n background: `radial-gradient(circle at 50% 35%, color-mix(in srgb, ${v.red} 70%, #fff) 0%, ${v.red} 70%)`,\n border: `3px solid ${v.ink}`,\n display: \"grid\",\n placeItems: \"center\",\n ...uText(28),\n }}\n >\n {count}\n </span>\n ) : null}\n </button>\n </div>\n\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 14 }}>\n {option.price.length > 0 ? (\n <ResourceList\n lines={option.price.map((l) => ({\n ...l,\n amount: l.amount * count,\n }))}\n size={22}\n />\n ) : null}\n <div\n style={{\n ...uText(30),\n animation:\n phase === \"idle\"\n ? \"idos-u-attract 1s ease-in-out infinite\"\n : undefined,\n visibility: phase === \"idle\" ? \"visible\" : \"hidden\",\n }}\n >\n {t(\"tapNow\")}\n </div>\n <UCloseCircle\n onClick={() => (phase === \"idle\" ? onClose() : undefined)}\n />\n </div>\n\n {/* Flash */}\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n background: \"#fff\",\n pointerEvents: \"none\",\n opacity: 0,\n animation:\n phase === \"burst\"\n ? \"idos-loot-flash .45s ease-out both\"\n : undefined,\n }}\n />\n <style>{LOOT_CSS}</style>\n </div>\n </KitPortal>\n );\n}\n\n/** Image_Effect_Star / Square sparkles around the box: left, top, size, float period. */\nconst SPARKS: Array<[number, number, number, number]> = [\n [18, 30, 34, 2.2],\n [244, 64, 42, 2.8],\n [30, 220, 28, 2.5],\n [250, 236, 30, 3.1],\n];\n\nconst LOOT_CSS = `\n@keyframes idos-loot-flash { 0% { opacity: 0 } 25% { opacity: 1 } 100% { opacity: 0 } }\n@keyframes idos-loot-burst { 0% { transform: scale(1) } 40% { transform: scale(1.25) } 100% { transform: scale(1.6); opacity: 0 } }\n`;\n\n/** Badge: chests the player can open right now with the items they hold (a chest item or a key). */\nexport function watchLootboxes(ctx: ModuleContext): void {\n const { client, features } = ctx;\n ctx.effect(() => {\n const update = () =>\n features.setBadge(\n \"lootboxes\",\n openableWithItems(\n lootboxOptions(configSection(client, \"Lootbox\")),\n client.data.user.state?.InventoryV2 as InventoryView | undefined,\n ),\n );\n update();\n return client.on(\"user:anyUpdated\", update);\n });\n}\n"
53
53
  },
54
54
  {
55
55
  "path": "model.ts",
@@ -36,9 +36,9 @@
36
36
  "version": "0.1.3"
37
37
  },
38
38
  "dependencies": {
39
- "@idosgames/core": "0.25.0",
40
- "@idosgames/module-sdk": "0.13.2",
41
- "@idosgames/react": "0.6.3",
39
+ "@idosgames/core": "0.26.0",
40
+ "@idosgames/module-sdk": "0.13.3",
41
+ "@idosgames/react": "0.7.0",
42
42
  "react": "19.2.7"
43
43
  },
44
44
  "contentHash": "12cf31156c913e7275c83bbff9b555821f7eb8dff6241ceb569da20ed87de1eb",
@@ -36,9 +36,9 @@
36
36
  "version": "0.1.3"
37
37
  },
38
38
  "dependencies": {
39
- "@idosgames/core": "0.25.0",
40
- "@idosgames/module-sdk": "0.13.2",
41
- "@idosgames/react": "0.6.3",
39
+ "@idosgames/core": "0.26.0",
40
+ "@idosgames/module-sdk": "0.13.3",
41
+ "@idosgames/react": "0.7.0",
42
42
  "react": "19.2.7"
43
43
  },
44
44
  "contentHash": "158824605f43164105e68883333c347fd5110a14b1ccb43c68036db930bbfe5f",
@@ -30,9 +30,9 @@
30
30
  "version": "0.1.4"
31
31
  },
32
32
  "dependencies": {
33
- "@idosgames/core": "0.25.0",
34
- "@idosgames/module-sdk": "0.13.2",
35
- "@idosgames/react": "0.6.3",
33
+ "@idosgames/core": "0.26.0",
34
+ "@idosgames/module-sdk": "0.13.3",
35
+ "@idosgames/react": "0.7.0",
36
36
  "react": "19.2.7"
37
37
  },
38
38
  "contentHash": "4e855049c6c583010b8e9b39d552de30cd8544278690fe903fcc8d2a7e451382",
@@ -39,9 +39,9 @@
39
39
  "version": "0.2.1"
40
40
  },
41
41
  "dependencies": {
42
- "@idosgames/core": "0.25.0",
43
- "@idosgames/module-sdk": "0.13.2",
44
- "@idosgames/react": "0.6.3",
42
+ "@idosgames/core": "0.26.0",
43
+ "@idosgames/module-sdk": "0.13.3",
44
+ "@idosgames/react": "0.7.0",
45
45
  "react": "19.2.7"
46
46
  },
47
47
  "contentHash": "03c011db581a59f3e98ea8696f113e02021db798e9a871be151d3e65726db2f8",
@@ -32,12 +32,12 @@
32
32
  "version": "0.1.3"
33
33
  },
34
34
  "dependencies": {
35
- "@idosgames/core": "0.25.0",
36
- "@idosgames/module-sdk": "0.13.2",
37
- "@idosgames/react": "0.6.3",
35
+ "@idosgames/core": "0.26.0",
36
+ "@idosgames/module-sdk": "0.13.3",
37
+ "@idosgames/react": "0.7.0",
38
38
  "react": "19.2.7"
39
39
  },
40
- "contentHash": "0f604f01a3e78b950a1dd799e80391a40da4321bcc334d24ba5a77094f22ca6c",
40
+ "contentHash": "d835f2923e281c625bd1888202c6e2df9005bd05ba54ed5793fbac2e03aab4dc",
41
41
  "files": [
42
42
  {
43
43
  "path": "i18n.ts",
@@ -57,7 +57,7 @@
57
57
  },
58
58
  {
59
59
  "path": "PremiumScreen.tsx",
60
- "content": "import type { ModuleContext } from \"@idosgames/module-sdk\";\nimport {\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { FeatureScreenProps } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Columns,\n Button,\n canAfford,\n Card,\n configSection,\n EmptyState,\n errorText,\n Icon,\n outlined,\n panel,\n ResourceList,\n Timer,\n toneSurface,\n useCatalog,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n passRows,\n premiumBadge,\n type PassRow,\n type PremiumStateView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// VIP (Unity PremiumPopUp): every premium pass of the title, lowest tier first — what it gives, how\n// long it lasts, until when the player holds it, a free trial once, and buying it with in-game\n// currency. A pass paid in a store (real money) is the platform's checkout, not sold here. Other\n// systems (events, season) send the player here for their VIP prizes:\n// `features.open(\"premium\", { premiumID })` — that pass is marked.\n\nexport function PremiumScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const now = useNow();\n const stagger = useStagger();\n const wanted = typeof args?.premiumID === \"string\" ? args.premiumID : null;\n const rows = passRows(\n configSection(client, \"Premium\"),\n state?.Premium as PremiumStateView | undefined,\n now,\n );\n // Passes run out by their end date, which the rows check against `now` — the state itself is\n // fresh by its revision, so the central cache asks the server only when it changed.\n useEffect(() => {\n void client.cache.ensureState([\"Premium\"]);\n }, [client]);\n\n if (rows.length === 0)\n return <EmptyState glyph=\"crown\" text={t(\"noPasses\")} />;\n return (\n <Columns gap={12}>\n {rows.map((row, i) => (\n <PassCard\n key={row.premiumID}\n row={row}\n marked={row.premiumID === wanted}\n style={stagger(i)}\n />\n ))}\n </Columns>\n );\n}\n\nfunction PassCard({\n row,\n marked,\n style,\n}: {\n row: PassRow;\n marked: boolean;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const now = useNow();\n const toast = useToast();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n\n useEffect(() => {\n if (marked) ref.current?.scrollIntoView({ block: \"center\" });\n }, [marked]);\n\n const done = (res: { ok: true } | { ok: false; error: string }) => {\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"purchase\");\n kit.fx.burst(ref.current);\n toast(t(\"active\"), \"success\");\n };\n const trial = async () => {\n setBusy(\"trial\");\n const res = await client.premium.activateTrial(\n row.premiumID,\n crypto.randomUUID(),\n );\n done(res);\n };\n const buy = async (optionID: string) => {\n setBusy(optionID);\n const res = await client.premium.purchaseItemOrCurrency(\n row.premiumID,\n crypto.randomUUID(),\n optionID,\n );\n done(res);\n };\n\n return (\n <div ref={ref}>\n <Card\n highlight={marked}\n style={{\n display: \"grid\",\n gap: 10,\n ...toneSurface(\"gold\"),\n ...style,\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 12 }}>\n <div className=\"idos-bounce\">\n <Icon glyph=\"crown\" size={52} />\n </div>\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 17 }}>\n {catalog.localize(row.name)}\n </div>\n {row.activeUntil ? (\n <Timer at={row.activeUntil} now={now} label={t(\"activeFor\")} />\n ) : row.durationDays > 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {row.durationDays} {t(\"days\")}\n </div>\n ) : null}\n </div>\n {row.activeUntil ? <Icon glyph=\"check\" size={30} /> : null}\n </div>\n {row.benefits.length > 0 ? (\n <div style={{ display: \"grid\", gap: 4 }}>\n {row.benefits.map((b, i) => (\n <div\n key={i}\n style={{\n ...panel,\n padding: 8,\n display: \"flex\",\n gap: 8,\n alignItems: \"center\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <Icon glyph=\"check\" size={18} /> {catalog.localize(b)}\n </div>\n ))}\n </div>\n ) : null}\n {row.trialDays > 0 ? (\n <Button\n tone=\"green\"\n attract\n busy={busy === \"trial\"}\n onClick={() => void trial()}\n data-tutorial-anchor={`premium:trial:${row.premiumID}`}\n >\n {t(\"trial\")} · {row.trialDays} {t(\"days\")}\n </Button>\n ) : null}\n {row.options.map((o) => {\n const affordable = canAfford(o.price, balances);\n return (\n <Button\n key={o.id}\n tone=\"gold\"\n attract={affordable && !row.activeUntil}\n disabled={!affordable}\n busy={busy === o.id}\n onClick={() => void buy(o.id)}\n data-tutorial-anchor={`premium:buy:${row.premiumID}`}\n >\n {row.activeUntil ? t(\"extend\") : t(\"buy\")}{\" \"}\n <ResourceList\n lines={o.price}\n size={18}\n dimIf={() => !affordable}\n />\n </Button>\n );\n })}\n </Card>\n </div>\n );\n}\n\n/** Badge: a free trial waiting — from the config and the cached state, no request of its own. */\nexport function watchPremium(ctx: ModuleContext): void {\n const { client, features } = ctx;\n ctx.effect(() => {\n // Subscribed directly, not keyed on the state object: the cache may patch it in place, and an\n // effect that does not re-run leaves the badge lit after the trial is taken.\n const update = () =>\n features.setBadge(\n \"premium\",\n premiumBadge(\n passRows(\n configSection(client, \"Premium\"),\n client.data.user.state?.Premium as PremiumStateView | undefined,\n Date.now(),\n ),\n ),\n );\n // A pass running out brings its trial back into view — no event for that, so look now and then.\n ctx.every(60_000, update);\n return client.on(\"user:anyUpdated\", update);\n });\n}\n"
60
+ "content": "import type { ModuleContext } from \"@idosgames/module-sdk\";\nimport {\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { FeatureScreenProps } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Columns,\n Button,\n canAfford,\n Card,\n configSection,\n EmptyState,\n errorText,\n Icon,\n outlined,\n panel,\n ResourceList,\n Timer,\n toneSurface,\n useCatalog,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\n payWithCredits,\n} from \"@idosgames/react/ui\";\nimport {\n passRows,\n premiumBadge,\n type PassRow,\n type PremiumStateView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// VIP (Unity PremiumPopUp): every premium pass of the title, lowest tier first — what it gives, how\n// long it lasts, until when the player holds it, a free trial once, and buying it with in-game\n// currency. A pass paid in a store (real money) is the platform's checkout, not sold here. Other\n// systems (events, season) send the player here for their VIP prizes:\n// `features.open(\"premium\", { premiumID })` — that pass is marked.\n\nexport function PremiumScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const now = useNow();\n const stagger = useStagger();\n const wanted = typeof args?.premiumID === \"string\" ? args.premiumID : null;\n const rows = passRows(\n configSection(client, \"Premium\"),\n state?.Premium as PremiumStateView | undefined,\n now,\n );\n // Passes run out by their end date, which the rows check against `now` — the state itself is\n // fresh by its revision, so the central cache asks the server only when it changed.\n useEffect(() => {\n void client.cache.ensureState([\"Premium\"]);\n }, [client]);\n\n if (rows.length === 0)\n return <EmptyState glyph=\"crown\" text={t(\"noPasses\")} />;\n return (\n <Columns gap={12}>\n {rows.map((row, i) => (\n <PassCard\n key={row.premiumID}\n row={row}\n marked={row.premiumID === wanted}\n style={stagger(i)}\n />\n ))}\n </Columns>\n );\n}\n\nfunction PassCard({\n row,\n marked,\n style,\n}: {\n row: PassRow;\n marked: boolean;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const now = useNow();\n const toast = useToast();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n\n useEffect(() => {\n if (marked) ref.current?.scrollIntoView({ block: \"center\" });\n }, [marked]);\n\n const done = (res: { ok: true } | { ok: false; error: string }) => {\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"purchase\");\n kit.fx.burst(ref.current);\n toast(t(\"active\"), \"success\");\n };\n const trial = async () => {\n setBusy(\"trial\");\n const res = await client.premium.activateTrial(\n row.premiumID,\n crypto.randomUUID(),\n );\n done(res);\n };\n const buy = async (optionID: string) => {\n setBusy(optionID);\n // One transaction id for both calls of an Aspis purchase (request, then the confirmed one).\n const transactionID = crypto.randomUUID();\n // A price in Aspis: the first call asks the player to confirm on idosgames.com (the kit shows it).\n const res = await payWithCredits(client, (creditsConfirmationID) =>\n client.premium.purchaseItemOrCurrency(\n row.premiumID,\n transactionID,\n optionID,\n 1,\n creditsConfirmationID,\n ),\n );\n done(res);\n };\n\n return (\n <div ref={ref}>\n <Card\n highlight={marked}\n style={{\n display: \"grid\",\n gap: 10,\n ...toneSurface(\"gold\"),\n ...style,\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 12 }}>\n <div className=\"idos-bounce\">\n <Icon glyph=\"crown\" size={52} />\n </div>\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 17 }}>\n {catalog.localize(row.name)}\n </div>\n {row.activeUntil ? (\n <Timer at={row.activeUntil} now={now} label={t(\"activeFor\")} />\n ) : row.durationDays > 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {row.durationDays} {t(\"days\")}\n </div>\n ) : null}\n </div>\n {row.activeUntil ? <Icon glyph=\"check\" size={30} /> : null}\n </div>\n {row.benefits.length > 0 ? (\n <div style={{ display: \"grid\", gap: 4 }}>\n {row.benefits.map((b, i) => (\n <div\n key={i}\n style={{\n ...panel,\n padding: 8,\n display: \"flex\",\n gap: 8,\n alignItems: \"center\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <Icon glyph=\"check\" size={18} /> {catalog.localize(b)}\n </div>\n ))}\n </div>\n ) : null}\n {row.trialDays > 0 ? (\n <Button\n tone=\"green\"\n attract\n busy={busy === \"trial\"}\n onClick={() => void trial()}\n data-tutorial-anchor={`premium:trial:${row.premiumID}`}\n >\n {t(\"trial\")} · {row.trialDays} {t(\"days\")}\n </Button>\n ) : null}\n {row.options.map((o) => {\n const affordable = canAfford(o.price, balances);\n return (\n <Button\n key={o.id}\n tone=\"gold\"\n attract={affordable && !row.activeUntil}\n disabled={!affordable}\n busy={busy === o.id}\n onClick={() => void buy(o.id)}\n data-tutorial-anchor={`premium:buy:${row.premiumID}`}\n >\n {row.activeUntil ? t(\"extend\") : t(\"buy\")}{\" \"}\n <ResourceList\n lines={o.price}\n size={18}\n dimIf={() => !affordable}\n />\n </Button>\n );\n })}\n </Card>\n </div>\n );\n}\n\n/** Badge: a free trial waiting — from the config and the cached state, no request of its own. */\nexport function watchPremium(ctx: ModuleContext): void {\n const { client, features } = ctx;\n ctx.effect(() => {\n // Subscribed directly, not keyed on the state object: the cache may patch it in place, and an\n // effect that does not re-run leaves the badge lit after the trial is taken.\n const update = () =>\n features.setBadge(\n \"premium\",\n premiumBadge(\n passRows(\n configSection(client, \"Premium\"),\n client.data.user.state?.Premium as PremiumStateView | undefined,\n Date.now(),\n ),\n ),\n );\n // A pass running out brings its trial back into view — no event for that, so look now and then.\n ctx.every(60_000, update);\n return client.on(\"user:anyUpdated\", update);\n });\n}\n"
61
61
  },
62
62
  {
63
63
  "path": "module.meta.json",
@@ -34,9 +34,9 @@
34
34
  "version": "0.1.5"
35
35
  },
36
36
  "dependencies": {
37
- "@idosgames/core": "0.25.0",
38
- "@idosgames/module-sdk": "0.13.2",
39
- "@idosgames/react": "0.6.3",
37
+ "@idosgames/core": "0.26.0",
38
+ "@idosgames/module-sdk": "0.13.3",
39
+ "@idosgames/react": "0.7.0",
40
40
  "react": "19.2.7"
41
41
  },
42
42
  "contentHash": "2448dc72d4408919336d2f4011a8dd8903a913811c8ef00db2b64c21b77f5b52",
@@ -33,9 +33,9 @@
33
33
  "version": "0.1.2"
34
34
  },
35
35
  "dependencies": {
36
- "@idosgames/core": "0.25.0",
37
- "@idosgames/module-sdk": "0.13.2",
38
- "@idosgames/react": "0.6.3",
36
+ "@idosgames/core": "0.26.0",
37
+ "@idosgames/module-sdk": "0.13.3",
38
+ "@idosgames/react": "0.7.0",
39
39
  "react": "19.2.7"
40
40
  },
41
41
  "contentHash": "c374268f2fc7cfb1fb4a6073cceca7cb0666f85bc2f1c37bc4ba818233207b61",
@@ -34,9 +34,9 @@
34
34
  "version": "0.1.3"
35
35
  },
36
36
  "dependencies": {
37
- "@idosgames/core": "0.25.0",
38
- "@idosgames/module-sdk": "0.13.2",
39
- "@idosgames/react": "0.6.3",
37
+ "@idosgames/core": "0.26.0",
38
+ "@idosgames/module-sdk": "0.13.3",
39
+ "@idosgames/react": "0.7.0",
40
40
  "react": "19.2.7"
41
41
  },
42
42
  "contentHash": "f30292cdadf0595a434aaae91598c279add865f03c5ae9767d5b4227e9d479ba",
@@ -31,9 +31,9 @@
31
31
  "version": "0.1.3"
32
32
  },
33
33
  "dependencies": {
34
- "@idosgames/core": "0.25.0",
35
- "@idosgames/module-sdk": "0.13.2",
36
- "@idosgames/react": "0.6.3",
34
+ "@idosgames/core": "0.26.0",
35
+ "@idosgames/module-sdk": "0.13.3",
36
+ "@idosgames/react": "0.7.0",
37
37
  "react": "19.2.7"
38
38
  },
39
39
  "contentHash": "ab99622e4ba739d6e189e21c2e41c38125b2b2d140c2c63f670dd2205dca3efc",
@@ -36,9 +36,9 @@
36
36
  "version": "0.1.3"
37
37
  },
38
38
  "dependencies": {
39
- "@idosgames/core": "0.25.0",
40
- "@idosgames/module-sdk": "0.13.2",
41
- "@idosgames/react": "0.6.3",
39
+ "@idosgames/core": "0.26.0",
40
+ "@idosgames/module-sdk": "0.13.3",
41
+ "@idosgames/react": "0.7.0",
42
42
  "react": "19.2.7"
43
43
  },
44
44
  "contentHash": "e7d48360d17873bd96f35c06a96a69320d2802f688a71e47392cc7dbf2c1d893",