@idosgames/mcp 0.1.38 → 0.1.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +40 -9
- package/package.json +1 -1
- package/registry/host.json +2 -2
- package/registry/index.json +52 -53
- package/registry/modules/ai.json +1 -1
- package/registry/modules/board-game.json +1 -1
- package/registry/modules/boosts.json +1 -1
- package/registry/modules/character.json +1 -1
- package/registry/modules/chat.json +1 -1
- package/registry/modules/collections.json +1 -1
- package/registry/modules/coop-events.json +1 -1
- package/registry/modules/craft.json +1 -1
- package/registry/modules/deals.json +1 -1
- package/registry/modules/events.json +1 -1
- package/registry/modules/friends.json +1 -1
- package/registry/modules/hero-run.json +19 -20
- package/registry/modules/idle-rpg.json +6 -6
- package/registry/modules/inventory.json +1 -1
- package/registry/modules/invite.json +1 -1
- package/registry/modules/leaderboard.json +1 -1
- package/registry/modules/lootboxes.json +1 -1
- package/registry/modules/mailbox.json +1 -1
- package/registry/modules/marketplace.json +1 -1
- package/registry/modules/mini-game.json +1 -1
- package/registry/modules/multiplayer.json +1 -1
- package/registry/modules/premium.json +1 -1
- package/registry/modules/profile.json +1 -1
- package/registry/modules/pvp.json +1 -1
- package/registry/modules/quests.json +1 -1
- package/registry/modules/rewards.json +1 -1
- package/registry/modules/season.json +1 -1
- package/registry/modules/store.json +9 -5
- package/registry/modules/tutorials.json +1 -1
- package/registry/modules/video-studio.json +41 -41
- package/registry/modules/voxelcraft.json +5 -5
- package/registry/modules/workshop.json +1 -1
- package/registry/skills/hero-run.json +2 -2
- package/registry/skills/store-system.json +1 -1
- package/registry/skills/video-studio.json +1 -1
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"name": "iDos Games",
|
|
47
47
|
"url": "https://idosgames.com"
|
|
48
48
|
},
|
|
49
|
-
"version": "0.4.
|
|
49
|
+
"version": "0.4.2",
|
|
50
50
|
"events": {
|
|
51
51
|
"emits": [
|
|
52
52
|
{
|
|
@@ -75,14 +75,14 @@
|
|
|
75
75
|
}
|
|
76
76
|
},
|
|
77
77
|
"dependencies": {
|
|
78
|
-
"@idosgames/characters": "0.1.
|
|
79
|
-
"@idosgames/core": "0.
|
|
78
|
+
"@idosgames/characters": "0.1.1",
|
|
79
|
+
"@idosgames/core": "0.24.0",
|
|
80
80
|
"@idosgames/module-sdk": "0.13.0",
|
|
81
81
|
"@idosgames/react": "0.6.0",
|
|
82
82
|
"phaser": "4.2.1",
|
|
83
83
|
"react": "19.2.7"
|
|
84
84
|
},
|
|
85
|
-
"contentHash": "
|
|
85
|
+
"contentHash": "3bb2350ea232f151853a4e0b69db86ae95285851a91e03d04e2c46349f8524ba",
|
|
86
86
|
"files": [
|
|
87
87
|
{
|
|
88
88
|
"path": "character/index.ts",
|
|
@@ -138,7 +138,7 @@
|
|
|
138
138
|
},
|
|
139
139
|
{
|
|
140
140
|
"path": "game/IdleRpgController.ts",
|
|
141
|
-
"content": "import Phaser from \"phaser\";\nimport { BattleScene } from \"../phaser/BattleScene\";\nimport type { IdleSession } from \"./session\";\n\n/**\n * Owns the Phaser game that draws the session's fight. Created on mount (Phaser needs the host\n * element), destroyed with the mode — the session (and with it the fight and the progress) lives on.\n */\nexport class IdleRpgController {\n private readonly game: Phaser.Game;\n\n constructor(\n parent: HTMLElement,\n readonly session: IdleSession,\n ) {\n this.game = new Phaser.Game({\n type: Phaser.AUTO,\n parent,\n width: parent.clientWidth || 480,\n height: parent.clientHeight || 800,\n backgroundColor: \"#7ec8ff\",\n scene: new BattleScene(session),\n scale: {\n // The canvas fills the full-bleed host; the scene draws the field into its top part and the\n // module's panel covers the rest (layout.ts).\n mode: Phaser.Scale.RESIZE,\n },\n });\n\n // Makes the game observable in the AI Coder's live preview, which reads this exact global.\n //\n // Phaser sets it itself — but only in its debug build (`Game.boot` guards it behind\n // WEBGL_DEBUG), and the release build shipped by npm has that branch stripped. So a Phaser game\n // is invisible to any outside observer unless it hands itself over, which is this one line.\n // Verified live: without it the preview reports no engine; with it, scenes, object counts,\n // camera and loop fps all come through.\n (globalThis as unknown as Record<string, unknown>).PHASER_GAME = this.game;\n }\n\n /** Whether the Phaser loop is running (the agent's \"am I even running\" flag). */\n get running(): boolean {\n return this.game.loop.running;\n }\n\n /** Pause/resume the Phaser RAF loop. The host calls this so a suspended mode stops ticking\n * (the Mode Router invariant: only the active mode runs) — and the fight with it. */\n setRunning(running: boolean): void {\n if (running) this.game.loop.wake();\n else this.game.loop.sleep();\n }\n\n /**\n * Один кадр игры для платформенной кнопки «поделиться снимком».\n *\n * ⚠ Через `renderer.snapshot`, а НЕ `canvas.toBlob`. У WebGL-рендерера Phaser (как и у любого\n * другого) буфер отрисовки очищен к моменту, когда до канвы доберётся посторонний код, и чтение\n * снаружи отдаёт ЧЁРНЫЙ прямоугольник, ничем не пожаловавшись — проверено живьём на стенде,\n * ровно так этот модуль и снимался, пока снимал его общий запасной путь хоста. `snapshot`\n * встроен в Phaser именно для этого: он забирает пиксели сразу после следующей отрисовки.\n *\n * Поэтому же сначала `wake()`: у остановленного цикла (режим только что был скрыт) следующей\n * отрисовки не случится вовсе, и обещание висело бы до таймаута.\n */\n capture(): Promise<Blob | null> {\n return new Promise<Blob | null>((resolve) => {\n let settled = false;\n const finish = (value: Blob | null) => {\n if (settled) return;\n settled = true;\n resolve(value);\n };\n\n // Кадр не пришёл (контекст потерян, игра уничтожена между вызовом и отрисовкой) — отвечаем\n // «нет», а не висим: у вызывающего за нами стоит следующий способ снять игру.\n setTimeout(() => finish(null), 1500);\n\n try {\n this.game.loop.wake();\n this.game.renderer.snapshot((result) => {\n if (!(result instanceof HTMLImageElement)) return finish(null);\n const canvas = document.createElement(\"canvas\");\n canvas.width = result.naturalWidth || result.width;\n canvas.height = result.naturalHeight || result.height;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return finish(null);\n ctx.drawImage(result, 0, 0);\n canvas.toBlob(finish, \"image/png\");\n });\n } catch {\n finish(null);\n }\n });\n }\n\n destroy(): void {\n this.game.destroy(true);\n // Drop the debug global with the game itself: the Mode Router destroys a suspended mode, and a\n // stale reference here would show an observer a game that no longer exists.\n const globals = globalThis as unknown as Record<string, unknown>;\n if (globals[\"PHASER_GAME\"] === this.game) delete globals[\"PHASER_GAME\"];\n }\n}\n"
|
|
141
|
+
"content": "import Phaser from \"phaser\";\nimport { BattleScene } from \"../phaser/BattleScene\";\nimport type { IdleSession } from \"./session\";\n\n/**\n * Owns the Phaser game that draws the session's fight. Created on mount (Phaser needs the host\n * element), destroyed with the mode — the session (and with it the fight and the progress) lives on.\n */\nexport class IdleRpgController {\n private readonly game: Phaser.Game;\n private readonly sizeWatch: ResizeObserver | null;\n\n constructor(\n parent: HTMLElement,\n readonly session: IdleSession,\n ) {\n this.game = new Phaser.Game({\n type: Phaser.AUTO,\n parent,\n width: parent.clientWidth || 480,\n height: parent.clientHeight || 800,\n backgroundColor: \"#7ec8ff\",\n scene: new BattleScene(session),\n scale: {\n // The canvas fills the full-bleed host; the scene draws the field into its top part and the\n // module's panel covers the rest (layout.ts).\n mode: Phaser.Scale.RESIZE,\n },\n });\n\n // Makes the game observable in the AI Coder's live preview, which reads this exact global.\n //\n // Phaser sets it itself — but only in its debug build (`Game.boot` guards it behind\n // WEBGL_DEBUG), and the release build shipped by npm has that branch stripped. So a Phaser game\n // is invisible to any outside observer unless it hands itself over, which is this one line.\n // Verified live: without it the preview reports no engine; with it, scenes, object counts,\n // camera and loop fps all come through.\n (globalThis as unknown as Record<string, unknown>).PHASER_GAME = this.game;\n\n // The canvas follows its host, not only the window: the lobby lays its header and tabs around the\n // game after it mounts (and Phaser's own first measure can come before that), and a canvas left\n // at another size overflows the frame — which then scrolls, the field sliding up under the HUD.\n const fit = () => {\n const w = parent.clientWidth;\n const h = parent.clientHeight;\n if (\n w > 0 &&\n h > 0 &&\n (w !== this.game.scale.width || h !== this.game.scale.height)\n )\n this.game.scale.resize(w, h);\n };\n this.sizeWatch =\n typeof ResizeObserver === \"undefined\" ? null : new ResizeObserver(fit);\n this.sizeWatch?.observe(parent);\n this.game.scale.on(\"resize\", () => queueMicrotask(fit));\n this.game.events.once(\"ready\", fit);\n }\n\n /** Whether the Phaser loop is running (the agent's \"am I even running\" flag). */\n get running(): boolean {\n return this.game.loop.running;\n }\n\n /** Pause/resume the Phaser RAF loop. The host calls this so a suspended mode stops ticking\n * (the Mode Router invariant: only the active mode runs) — and the fight with it. */\n setRunning(running: boolean): void {\n if (running) this.game.loop.wake();\n else this.game.loop.sleep();\n }\n\n /**\n * Один кадр игры для платформенной кнопки «поделиться снимком».\n *\n * ⚠ Через `renderer.snapshot`, а НЕ `canvas.toBlob`. У WebGL-рендерера Phaser (как и у любого\n * другого) буфер отрисовки очищен к моменту, когда до канвы доберётся посторонний код, и чтение\n * снаружи отдаёт ЧЁРНЫЙ прямоугольник, ничем не пожаловавшись — проверено живьём на стенде,\n * ровно так этот модуль и снимался, пока снимал его общий запасной путь хоста. `snapshot`\n * встроен в Phaser именно для этого: он забирает пиксели сразу после следующей отрисовки.\n *\n * Поэтому же сначала `wake()`: у остановленного цикла (режим только что был скрыт) следующей\n * отрисовки не случится вовсе, и обещание висело бы до таймаута.\n */\n capture(): Promise<Blob | null> {\n return new Promise<Blob | null>((resolve) => {\n let settled = false;\n const finish = (value: Blob | null) => {\n if (settled) return;\n settled = true;\n resolve(value);\n };\n\n // Кадр не пришёл (контекст потерян, игра уничтожена между вызовом и отрисовкой) — отвечаем\n // «нет», а не висим: у вызывающего за нами стоит следующий способ снять игру.\n setTimeout(() => finish(null), 1500);\n\n try {\n this.game.loop.wake();\n this.game.renderer.snapshot((result) => {\n if (!(result instanceof HTMLImageElement)) return finish(null);\n const canvas = document.createElement(\"canvas\");\n canvas.width = result.naturalWidth || result.width;\n canvas.height = result.naturalHeight || result.height;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return finish(null);\n ctx.drawImage(result, 0, 0);\n canvas.toBlob(finish, \"image/png\");\n });\n } catch {\n finish(null);\n }\n });\n }\n\n destroy(): void {\n this.sizeWatch?.disconnect();\n this.game.destroy(true);\n // Drop the debug global with the game itself: the Mode Router destroys a suspended mode, and a\n // stale reference here would show an observer a game that no longer exists.\n const globals = globalThis as unknown as Record<string, unknown>;\n if (globals[\"PHASER_GAME\"] === this.game) delete globals[\"PHASER_GAME\"];\n }\n}\n"
|
|
142
142
|
},
|
|
143
143
|
{
|
|
144
144
|
"path": "game/session.ts",
|
|
@@ -226,7 +226,7 @@
|
|
|
226
226
|
},
|
|
227
227
|
{
|
|
228
228
|
"path": "module.meta.json",
|
|
229
|
-
"content": "{\n \"id\": \"idle-rpg\",\n \"type\": \"template\",\n \"uses\": [\"character\", \"lootboxes\", \"store\"],\n \"summary\": \"Idle RPG in the style of Legend Slime: the hero auto-battles waves and bosses, gold flows in online and offline, stats are enhanced for gold.\",\n \"description\": \"A Legend Slime-style idle auto-battler on Phaser with a React screen. The selected hero (the Heroes system — character:hero-selected@1) stands on the left — a layered 2D character (src/character: its own chibi skeleton with bending meshes and IK played by its own runtime, parts as skins with vector SVG art, colours, ten animations of its own plus ranged attacks; dressed from its class and worn gear by src/character/looks, the attack swing timed to the shot; the same hero is drawn on the Heroes screen through ctx.characterArt — src/heroArt.ts, a live WebGL canvas, a new weapon swung once) — and marches along the road (Obsidian Knight style: the hero runs, its feet in step with the road, the world scrolls past in painted parallax layers per chapter theme; shadows under everyone; a fallen hero leaves the enemies standing) to the enemies standing in line ahead — people on the hero's own rig dressed in the maker's looks; brawlers fight one at a time face to face, shooters fire once the hero is in their range; a melee weapon hits only at the enemy, a bow, gun or staff from afar: crits, multi-shot, HP regen, three skills with cooldowns and an Auto switch. Stages come in chapters of ten (Chapter-Stage, themes meadow → volcano, difficulty loops); each stage is four waves and a boss with a 30-second timer — a failed boss drops back to farming until the player taps \\\"Boss!\\\". The hero's fight numbers are the server's character stats (ValueCurve, RankCurve, RankStatCurve, gear flat/percent bonuses — a mirror of PvPBattleEngine.CalculateStats). Gold is the title's idle accrual (Reward → IdleAccruals, rate = base + PowerCoefficient × the strongest hero's Power), collected every 15 s while playing and on entry with a \\\"while you were away\\\" popup; coins from kills draw that stream. Enhance: the hero's stats with level, value, next value and price, ×1 / ×10 / MAX and hold-to-repeat (one request in flight per stat, taps added up). A quest strip shows the next guide quest with Claim. The bottom bar opens the Heroes hub, gear summons, quests and the shop through ctx.features. Stage progress is saved in the player's private custom data (idle_rpg_progress); cleared stages and enhanced levels are reported to quests that count stage_cleared / stat_enhanced. Ships title-seed.json: GOLD/GEMS, three heroes on the engine's standard stat ids, twelve pieces of gear, the idle_gold accrual, a gear summon, a shop and a guide quest chain.\",\n \"provides\": [\n \"auto-battling hero vs waves of monsters\",\n \"stages, chapters and timed bosses\",\n \"idle gold income online and offline (server accrual)\",\n \"stat enhance for gold with x1 / x10 / MAX and hold\",\n \"skills with cooldowns and auto-cast\",\n \"guide quest strip\",\n \"bottom bar to heroes, summons, quests and shop\",\n \"2D procedural art (Phaser)\",\n \"layered 2D character: skeleton, swappable parts (skins), colours, 10 animations + ranged attacks (own runtime, vector art)\"\n ],\n \"tags\": [\n \"idle\",\n \"rpg\",\n \"incremental\",\n \"auto-battler\",\n \"legend-slime\",\n \"phaser\",\n \"2d\",\n \"character-customization\",\n \"skeletal-animation\"\n ],\n \"media\": {\n \"image\": \"https://cloud.idosgames.com/drive/modules/idle-rpg/cover.png\",\n \"video\": \"https://cloud.idosgames.com/drive/modules/idle-rpg/demo.mp4\"\n },\n \"demoUrl\": \"https://cloud.idosgames.com/drive/modules/idle-rpg/demo/\",\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.4.
|
|
229
|
+
"content": "{\n \"id\": \"idle-rpg\",\n \"type\": \"template\",\n \"uses\": [\"character\", \"lootboxes\", \"store\"],\n \"summary\": \"Idle RPG in the style of Legend Slime: the hero auto-battles waves and bosses, gold flows in online and offline, stats are enhanced for gold.\",\n \"description\": \"A Legend Slime-style idle auto-battler on Phaser with a React screen. The selected hero (the Heroes system — character:hero-selected@1) stands on the left — a layered 2D character (src/character: its own chibi skeleton with bending meshes and IK played by its own runtime, parts as skins with vector SVG art, colours, ten animations of its own plus ranged attacks; dressed from its class and worn gear by src/character/looks, the attack swing timed to the shot; the same hero is drawn on the Heroes screen through ctx.characterArt — src/heroArt.ts, a live WebGL canvas, a new weapon swung once) — and marches along the road (Obsidian Knight style: the hero runs, its feet in step with the road, the world scrolls past in painted parallax layers per chapter theme; shadows under everyone; a fallen hero leaves the enemies standing) to the enemies standing in line ahead — people on the hero's own rig dressed in the maker's looks; brawlers fight one at a time face to face, shooters fire once the hero is in their range; a melee weapon hits only at the enemy, a bow, gun or staff from afar: crits, multi-shot, HP regen, three skills with cooldowns and an Auto switch. Stages come in chapters of ten (Chapter-Stage, themes meadow → volcano, difficulty loops); each stage is four waves and a boss with a 30-second timer — a failed boss drops back to farming until the player taps \\\"Boss!\\\". The hero's fight numbers are the server's character stats (ValueCurve, RankCurve, RankStatCurve, gear flat/percent bonuses — a mirror of PvPBattleEngine.CalculateStats). Gold is the title's idle accrual (Reward → IdleAccruals, rate = base + PowerCoefficient × the strongest hero's Power), collected every 15 s while playing and on entry with a \\\"while you were away\\\" popup; coins from kills draw that stream. Enhance: the hero's stats with level, value, next value and price, ×1 / ×10 / MAX and hold-to-repeat (one request in flight per stat, taps added up). A quest strip shows the next guide quest with Claim. The bottom bar opens the Heroes hub, gear summons, quests and the shop through ctx.features. Stage progress is saved in the player's private custom data (idle_rpg_progress); cleared stages and enhanced levels are reported to quests that count stage_cleared / stat_enhanced. Ships title-seed.json: GOLD/GEMS, three heroes on the engine's standard stat ids, twelve pieces of gear, the idle_gold accrual, a gear summon, a shop and a guide quest chain.\",\n \"provides\": [\n \"auto-battling hero vs waves of monsters\",\n \"stages, chapters and timed bosses\",\n \"idle gold income online and offline (server accrual)\",\n \"stat enhance for gold with x1 / x10 / MAX and hold\",\n \"skills with cooldowns and auto-cast\",\n \"guide quest strip\",\n \"bottom bar to heroes, summons, quests and shop\",\n \"2D procedural art (Phaser)\",\n \"layered 2D character: skeleton, swappable parts (skins), colours, 10 animations + ranged attacks (own runtime, vector art)\"\n ],\n \"tags\": [\n \"idle\",\n \"rpg\",\n \"incremental\",\n \"auto-battler\",\n \"legend-slime\",\n \"phaser\",\n \"2d\",\n \"character-customization\",\n \"skeletal-animation\"\n ],\n \"media\": {\n \"image\": \"https://cloud.idosgames.com/drive/modules/idle-rpg/cover.png\",\n \"video\": \"https://cloud.idosgames.com/drive/modules/idle-rpg/demo.mp4\"\n },\n \"demoUrl\": \"https://cloud.idosgames.com/drive/modules/idle-rpg/demo/\",\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.4.2\",\n \"events\": {\n \"emits\": [\n {\n \"topic\": \"idle-rpg:character-upgraded@1\",\n \"when\": \"a hero's rank went up (client.character.upgradeCharacterLevel, from any screen)\",\n \"payload\": {\n \"characterId\": \"string\",\n \"level\": \"number\"\n }\n },\n {\n \"topic\": \"idle-rpg:stage-cleared@1\",\n \"when\": \"the hero beat a stage's boss; chapter-stage is the stage just cleared\",\n \"payload\": {\n \"chapter\": \"number\",\n \"stage\": \"number\"\n }\n }\n ],\n \"listens\": [\n {\n \"topic\": \"character:hero-selected@1\",\n \"why\": \"fields the hero picked on the Heroes screen (optional — without it the strongest hero fights)\"\n }\n ]\n }\n}\n"
|
|
230
230
|
}
|
|
231
231
|
]
|
|
232
232
|
}
|
|
@@ -36,16 +36,16 @@
|
|
|
36
36
|
"version": "0.1.3"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@idosgames/core": "0.
|
|
39
|
+
"@idosgames/core": "0.24.0",
|
|
40
40
|
"@idosgames/module-sdk": "0.13.0",
|
|
41
41
|
"@idosgames/react": "0.6.0",
|
|
42
42
|
"react": "19.2.7"
|
|
43
43
|
},
|
|
44
|
-
"contentHash": "
|
|
44
|
+
"contentHash": "9ae53fef8405e8c52518d98d011d1cc217bd804ccc416e3eaa8a5587cfecfdc5",
|
|
45
45
|
"files": [
|
|
46
46
|
{
|
|
47
47
|
"path": "i18n.ts",
|
|
48
|
-
"content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Shop\",\n free: \"Free\",\n soldOut: \"Sold out\",\n refreshesIn: \"Refreshes in\",\n readyIn: \"Ready in\",\n confirmTitle: \"Confirm purchase\",\n confirmBuy: \"Buy for\",\n confirmFree: \"Take for free\",\n emptyShop: \"The shop is empty. Add offers in LiveOps → Store.\",\n left: \"left\",\n retry: \"Retry\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Магазин\",\n free: \"Бесплатно\",\n soldOut: \"Распродано\",\n refreshesIn: \"Обновится через\",\n readyIn: \"Доступно через\",\n confirmTitle: \"Подтвердите покупку\",\n confirmBuy: \"Купить за\",\n confirmFree: \"Забрать бесплатно\",\n emptyShop: \"В магазине пусто. Добавьте товары в LiveOps → Store.\",\n left: \"осталось\",\n retry: \"Повторить\",\n});\n"
|
|
48
|
+
"content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Shop\",\n free: \"Free\",\n soldOut: \"Sold out\",\n refreshesIn: \"Refreshes in\",\n readyIn: \"Ready in\",\n confirmTitle: \"Confirm purchase\",\n confirmBuy: \"Buy for\",\n confirmFree: \"Take for free\",\n emptyShop: \"The shop is empty. Add offers in LiveOps → Store.\",\n left: \"left\",\n retry: \"Retry\",\n currencyName: \"Aspis\",\n creditsConfirmHint: \"Confirm the purchase on idosgames.com — the game cannot spend your Aspis without you.\",\n creditsConfirmOpen: \"Confirm on idosgames.com\",\n creditsWaiting: \"Waiting for confirmation…\",\n creditsLogin: \"Sign in with your iDos Games account to pay with Aspis.\",\n creditsNotEnough: \"Not enough Aspis.\",\n creditsTopUp: \"Top up Aspis\",\n creditsDeclined: \"Purchase cancelled.\",\n creditsExpired: \"The request expired — try again.\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Магазин\",\n free: \"Бесплатно\",\n soldOut: \"Распродано\",\n refreshesIn: \"Обновится через\",\n readyIn: \"Доступно через\",\n confirmTitle: \"Подтвердите покупку\",\n confirmBuy: \"Купить за\",\n confirmFree: \"Забрать бесплатно\",\n emptyShop: \"В магазине пусто. Добавьте товары в LiveOps → Store.\",\n left: \"осталось\",\n retry: \"Повторить\",\n currencyName: \"Aspis\",\n creditsConfirmHint: \"Подтвердите покупку на idosgames.com — без вас игра не может тратить ваши Aspis.\",\n creditsConfirmOpen: \"Подтвердить на idosgames.com\",\n creditsWaiting: \"Ждём подтверждения…\",\n creditsLogin: \"Войдите через аккаунт iDos Games, чтобы платить Aspis.\",\n creditsNotEnough: \"Не хватает Aspis.\",\n creditsTopUp: \"Пополнить Aspis\",\n creditsDeclined: \"Покупка отменена.\",\n creditsExpired: \"Срок заявки истёк — попробуйте ещё раз.\",\n});\n"
|
|
49
49
|
},
|
|
50
50
|
{
|
|
51
51
|
"path": "index.ts",
|
|
@@ -53,15 +53,19 @@
|
|
|
53
53
|
},
|
|
54
54
|
{
|
|
55
55
|
"path": "model.ts",
|
|
56
|
-
"content": "// Pure helpers of the shop screen — covered by model.test.ts.\n\nimport type {\n GetStorefrontResponse,\n StorefrontOfferView,\n StorefrontSectionView,\n StorefrontSlotView,\n} from \"@idosgames/core\";\nimport { costLines } from \"@idosgames/react/ui\";\n\nexport function sortedSections(\n store: { Sections?: StorefrontSectionView[] | null } | null | undefined,\n): StorefrontSectionView[] {\n return (store?.Sections ?? [])\n .slice()\n .sort((a, b) => a.SortOrder - b.SortOrder);\n}\n\nexport function slotsOf(section: StorefrontSectionView): StorefrontSlotView[] {\n return (section.Slots ?? [])\n .slice()\n .sort((a, b) => a.SortOrder - b.SortOrder);\n}\n\n/** When the first card of a section refreshes — the section's countdown. */\nexport function earliestRefresh(section: StorefrontSectionView): string | null {\n const times = slotsOf(section)\n .map((s) => s.RefreshesAtUtc)\n .filter((x): x is string => typeof x === \"string\")\n .sort();\n return times[0] ?? null;\n}\n\nexport function isFree(offer: StorefrontOfferView): boolean {\n const option = offer.PriceOptions?.[0];\n return option?.IsFree === true || costLines(option?.Cost).length === 0;\n}\n\n/** Offers the player can take right now (not sold out, not waiting for their window). */\nexport function isAvailable(offer: StorefrontOfferView): boolean {\n return offer.State?.SoldOut !== true && !offer.State?.AvailableAtUtc;\n}\n\n/** The badge of the shop: free offers waiting to be taken (the daily gift). */\nexport function freeOffersWaiting(\n front: GetStorefrontResponse | null | undefined,\n): number {\n let n = 0;\n for (const store of front?.Stores ?? [])\n for (const section of store.Sections ?? [])\n for (const slot of section.Slots ?? [])\n if (slot.Offer && isFree(slot.Offer) && isAvailable(slot.Offer)) n++;\n return n;\n}\n\n/** Stores by their order; the first is the default tab. */\nexport function sortedStores(\n front: GetStorefrontResponse | null | undefined,\n): NonNullable<GetStorefrontResponse[\"Stores\"]> {\n return (front?.Stores ?? [])\n .slice()\n .sort((a, b) => a.SortOrder - b.SortOrder);\n}\n\n/** Whether the storefront has any offer — until it does, the lobby hides the Shop tab. */\nexport function hasOffers(\n front: GetStorefrontResponse | null | undefined,\n): boolean {\n return (front?.Stores ?? []).some((store) =>\n (store.Sections ?? []).some((section) =>\n (section.Slots ?? []).some((slot) => slot.Offer != null),\n ),\n );\n}\n"
|
|
56
|
+
"content": "// Pure helpers of the shop screen — covered by model.test.ts.\n\nimport type {\n GetStorefrontResponse,\n StorefrontOfferView,\n StorefrontSectionView,\n StorefrontSlotView,\n} from \"@idosgames/core\";\nimport { costLines } from \"@idosgames/react/ui\";\n\nexport function sortedSections(\n store: { Sections?: StorefrontSectionView[] | null } | null | undefined,\n): StorefrontSectionView[] {\n return (store?.Sections ?? [])\n .slice()\n .sort((a, b) => a.SortOrder - b.SortOrder);\n}\n\nexport function slotsOf(section: StorefrontSectionView): StorefrontSlotView[] {\n return (section.Slots ?? [])\n .slice()\n .sort((a, b) => a.SortOrder - b.SortOrder);\n}\n\n/** When the first card of a section refreshes — the section's countdown. */\nexport function earliestRefresh(section: StorefrontSectionView): string | null {\n const times = slotsOf(section)\n .map((s) => s.RefreshesAtUtc)\n .filter((x): x is string => typeof x === \"string\")\n .sort();\n return times[0] ?? null;\n}\n\n/**\n * Price in Credits (platform currency) of the first option, or null when the offer is not sold for Credits. A Credits price\n * is not a game resource: the card shows it as text, and the purchase goes through the player's confirmation on\n * idosgames.com (`client.store.purchaseWithCredits`).\n */\nexport function creditsPriceOf(offer: StorefrontOfferView): number | null {\n const option = offer.PriceOptions?.[0];\n return option?.IsCreditsPaid ? Number(option.CreditsPrice ?? 0) : null;\n}\n\n/** Price lines in game resources — without the Credits entry (it is drawn by `creditsPriceOf`). */\nexport function resourcePrice(offer: StorefrontOfferView) {\n return costLines(offer.PriceOptions?.[0]?.Cost).filter(\n (line) => !(line.kind === \"other\" && line.id === \"PlatformCredits\"),\n );\n}\n\nexport function isFree(offer: StorefrontOfferView): boolean {\n if (creditsPriceOf(offer) != null) return false;\n const option = offer.PriceOptions?.[0];\n return option?.IsFree === true || costLines(option?.Cost).length === 0;\n}\n\n/** Offers the player can take right now (not sold out, not waiting for their window). */\nexport function isAvailable(offer: StorefrontOfferView): boolean {\n return offer.State?.SoldOut !== true && !offer.State?.AvailableAtUtc;\n}\n\n/** The badge of the shop: free offers waiting to be taken (the daily gift). */\nexport function freeOffersWaiting(\n front: GetStorefrontResponse | null | undefined,\n): number {\n let n = 0;\n for (const store of front?.Stores ?? [])\n for (const section of store.Sections ?? [])\n for (const slot of section.Slots ?? [])\n if (slot.Offer && isFree(slot.Offer) && isAvailable(slot.Offer)) n++;\n return n;\n}\n\n/** Stores by their order; the first is the default tab. */\nexport function sortedStores(\n front: GetStorefrontResponse | null | undefined,\n): NonNullable<GetStorefrontResponse[\"Stores\"]> {\n return (front?.Stores ?? [])\n .slice()\n .sort((a, b) => a.SortOrder - b.SortOrder);\n}\n\n/** Whether the storefront has any offer — until it does, the lobby hides the Shop tab. */\nexport function hasOffers(\n front: GetStorefrontResponse | null | undefined,\n): boolean {\n return (front?.Stores ?? []).some((store) =>\n (store.Sections ?? []).some((section) =>\n (section.Slots ?? []).some((slot) => slot.Offer != null),\n ),\n );\n}\n"
|
|
57
57
|
},
|
|
58
58
|
{
|
|
59
59
|
"path": "module.ts",
|
|
60
60
|
"content": "import {\n defineModule,\n type Module,\n type ModuleContext,\n} from \"@idosgames/module-sdk\";\nimport { StoreScreen } from \"./StoreScreen\";\nimport { freeOffersWaiting, hasOffers } from \"./model\";\nimport { t } from \"./i18n\";\n\n// The shop as a feature of the game (ctx.features): the lobby shows it as a main tab once the\n// storefront has offers. Its badge counts free offers waiting to be taken (the daily gift), kept\n// fresh by an invisible always-on panel — the screen itself is mounted only while open.\nexport const storeModule: Module = defineModule({\n id: \"store\",\n meta: { name: \"Shop\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"store\",\n label: () => t(\"title\"),\n icon: \"shop\",\n group: \"economy\",\n order: 10,\n primary: true,\n available: false,\n Screen: StoreScreen,\n });\n watchBadge(ctx);\n ctx.exposeToAgent({\n state: () => ({\n ui: \"dom\",\n feature: \"store\",\n badge: ctx.features.get(\"store\")?.badge ?? 0,\n }),\n describeActions: {},\n });\n },\n});\n\nfunction watchBadge(ctx: ModuleContext): void {\n const { client, features } = ctx;\n ctx.effect(() => {\n const update = () => {\n const front = client.store.cachedStorefront;\n features.setAvailable(\n \"store\",\n hasOffers(front),\n \"the storefront has no offers (Store section)\",\n );\n features.setBadge(\"store\", freeOffersWaiting(front));\n };\n // A free gift comes back with the next day: look again every few minutes.\n ctx.every(5 * 60_000, () => void client.store.getStorefront().then(update));\n // The badge is counted from the STOREFRONT, and after a purchase the screen re-reads it: the\n // purchase's own state update comes before that, with the old storefront still cached — so the\n // gift just taken kept the badge lit until the next re-read here. Count again on every load.\n const offs = [\n client.on(\"user:anyUpdated\", update),\n client.on(\"store:storefrontLoaded\", update),\n ];\n return () => {\n for (const off of offs) off();\n };\n });\n}\n"
|
|
61
61
|
},
|
|
62
|
+
{
|
|
63
|
+
"path": "PlatformCurrencyIcon.tsx",
|
|
64
|
+
"content": "import { useId } from \"react\";\r\n\r\n/**\r\n * Aspis icon (Credits in the code) — the platform currency (owner's decision 26.10.2026: a currency has an icon). ⚠ A copy of the\r\n * dashboard's and the website's drawing (`platform.idosgames.com/components/core/billing/PlatformCurrencyIcon.tsx`,\r\n * `iDosGames_com/components/v2/credits/PlatformCurrencyIcon.tsx`): change all three, or one currency looks different in\r\n * the game and on the site where the player confirms the purchase.\r\n */\r\nexport function PlatformCurrencyIcon({ size = 18 }: { size?: number }) {\r\n // A unique gradient id per instance: two icons sharing one id share one gradient.\r\n const id = useId().replace(/:/g, \"\");\r\n return (\r\n <svg\r\n width={size}\r\n height={size}\r\n viewBox=\"0 0 24 24\"\r\n aria-hidden=\"true\"\r\n style={{ flexShrink: 0 }}\r\n >\r\n <defs>\r\n <linearGradient id={`cg${id}`} x1=\"0\" y1=\"0\" x2=\"1\" y2=\"1\">\r\n <stop offset=\"0\" stopColor=\"#FFE58A\" />\r\n <stop offset=\"1\" stopColor=\"#E08E00\" />\r\n </linearGradient>\r\n <clipPath id={`cc${id}`}>\r\n <circle cx=\"12\" cy=\"12\" r=\"9.3\" />\r\n </clipPath>\r\n </defs>\r\n <circle cx=\"12\" cy=\"12\" r=\"11\" fill={`url(#cg${id})`} />\r\n <circle cx=\"12\" cy=\"12\" r=\"10.1\" fill=\"none\" stroke=\"#9A5A00\" strokeWidth=\"1.7\" />\r\n <path\r\n d=\"M2.2 20 L12 10.6 L21.8 20\"\r\n clipPath={`url(#cc${id})`}\r\n fill=\"none\"\r\n stroke=\"#9B1C12\"\r\n strokeWidth=\"3.1\"\r\n strokeLinecap=\"butt\"\r\n strokeLinejoin=\"miter\"\r\n />\r\n </svg>\r\n );\r\n}\r\n\r\n/** \"[icon] 100 Aspis\" — the price of an offer sold for Aspis (Credits in the code). */\r\nexport function CreditsPrice({ amount, label, size = 18 }: { amount: number; label: string; size?: number }) {\r\n return (\r\n <span style={{ display: \"inline-flex\", alignItems: \"center\", gap: 6 }}>\r\n <PlatformCurrencyIcon size={size} />\r\n {amount} {label}\r\n </span>\r\n );\r\n}\r\n"
|
|
65
|
+
},
|
|
62
66
|
{
|
|
63
67
|
"path": "StoreScreen.tsx",
|
|
64
|
-
"content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport {\n OFFER_NOT_IN_ROTATION,\n type GetStorefrontResponse,\n type StorefrontOfferView,\n type StorefrontSectionView,\n type StorefrontSlotView,\n} from \"@idosgames/core\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Center,\n EmptyState,\n Icon,\n Popup,\n ResourceList,\n SectionTitle,\n SoldOutStamp,\n Spinner,\n Tabs,\n Timer,\n TONES,\n canAfford,\n costLines,\n countdown,\n errorText,\n grantLines,\n grantedBy,\n inkFrame,\n inked,\n outlined,\n panel,\n useCatalog,\n useCelebrate,\n useGameLayout,\n useNow,\n usePopupClose,\n useSound,\n useStagger,\n useToast,\n useUiKit,\n v,\n type ButtonTone,\n type ResourceLine,\n tokenGlyph,\n} from \"@idosgames/react/ui\";\nimport type { FeatureScreenProps } from \"@idosgames/module-sdk\";\nimport {\n earliestRefresh,\n isFree,\n slotsOf,\n sortedSections,\n sortedStores,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n/** The ribbon colour of each section in turn, as the sheet's shop (\"Daily Shop\", \"AD Shop\", \"Gem Pack\"…). */\nconst SECTION_TONES: ButtonTone[] = [\n \"red\",\n \"green\",\n \"purple\",\n \"gold\",\n \"blue\",\n \"orange\",\n];\n\n// The shop, drawn from the storefront RESOLVED for this player (client.store.getStorefront — never\n// from the raw config): rotation, limits, badges and timers are computed by the server. Layout of the\n// Unity shop window (Alikhan/Shop): several stores as tabs, the first section of a store is a\n// carousel, the rest are grids; a tap opens the confirmation popup; a purchase shows what was granted\n// and the rewards fly to their counters.\n\ninterface Picked {\n storeID: string;\n section: StorefrontSectionView;\n slot: StorefrontSlotView;\n offer: StorefrontOfferView;\n}\n\nexport function StoreScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const [front, setFront] = useState<GetStorefrontResponse | null>(\n client.store.cachedStorefront,\n );\n const [loading, setLoading] = useState(front === null);\n const [failed, setFailed] = useState<string | null>(null);\n const [storeID, setStoreID] = useState<string | null>(\n typeof args?.storeID === \"string\" ? args.storeID : null,\n );\n const [picked, setPicked] = useState<Picked | null>(null);\n // Server clock offset: timers count from the SERVER time, not the device clock.\n const [skewMs, setSkewMs] = useState(0);\n const now = useNow() + skewMs;\n\n const load = useCallback(async () => {\n const res = await client.store.getStorefront();\n setLoading(false);\n if (!res.ok) {\n // A title without a Store section answers \"not found\" — that is an empty shop, not a failure.\n setFailed(\n /not found|not configured/i.test(res.error ?? \"\")\n ? t(\"emptyShop\")\n : errorText(res.error),\n );\n return;\n }\n setFailed(null);\n setFront(res.data);\n setSkewMs(new Date(res.data.ServerTimeUtc).getTime() - Date.now());\n }, [client]);\n\n useEffect(() => {\n void load();\n }, [load]);\n\n // One reload when the nearest card refreshes, instead of polling.\n useEffect(() => {\n const next = client.store.nextRefreshAtUtc();\n if (!next) return;\n const ms = new Date(next).getTime() - (Date.now() + skewMs);\n if (ms <= 0 || ms > 24 * 3600_000) return;\n const id = setTimeout(() => void load(), ms + 500);\n return () => clearTimeout(id);\n }, [front, client, load, skewMs]);\n\n if (loading)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (failed)\n return (\n <EmptyState\n glyph=\"shop\"\n text={failed}\n action={<Button onClick={() => void load()}>{t(\"retry\")}</Button>}\n />\n );\n\n const stores = sortedStores(front);\n const store = stores.find((s) => s.StoreID === storeID) ?? stores[0];\n const sections = sortedSections(store);\n if (!store || sections.length === 0)\n return <EmptyState glyph=\"shop\" text={t(\"emptyShop\")} />;\n const [first, ...rest] = sections;\n\n return (\n <div style={{ display: \"grid\", gap: 18 }}>\n <Tabs\n tabs={stores.map((s) => ({\n id: s.StoreID,\n label: s.Identity?.DisplayName ?? s.StoreID,\n }))}\n value={store.StoreID}\n onChange={setStoreID}\n />\n {first ? (\n <div key={`${store.StoreID}:${first.SectionID}`}>\n <SectionTitle\n tone={SECTION_TONES[0]}\n right={\n <Timer\n at={earliestRefresh(first)}\n now={now}\n label={t(\"refreshesIn\")}\n />\n }\n >\n {first.Identity?.DisplayName ?? first.SectionID}\n </SectionTitle>\n <div\n className=\"idos-scroll\"\n style={{\n display: \"flex\",\n gap: 12,\n overflowX: \"auto\",\n padding: \"4px 4px 12px\",\n scrollSnapType: \"x mandatory\",\n }}\n >\n {slotsOf(first).map((slot, i) => (\n <OfferCard\n key={slot.SlotID}\n slot={slot}\n now={now}\n wide\n index={i}\n onPick={(offer) =>\n setPicked({\n storeID: store.StoreID,\n section: first,\n slot,\n offer,\n })\n }\n />\n ))}\n </div>\n </div>\n ) : null}\n {rest.map((section, i) => (\n <div key={`${store.StoreID}:${section.SectionID}`}>\n <SectionTitle\n tone={SECTION_TONES[(i + 1) % SECTION_TONES.length]}\n right={\n <Timer\n at={earliestRefresh(section)}\n now={now}\n label={t(\"refreshesIn\")}\n />\n }\n >\n {section.Identity?.DisplayName ?? section.SectionID}\n </SectionTitle>\n <OfferGrid>\n {slotsOf(section).map((slot, i) => (\n <OfferCard\n key={slot.SlotID}\n slot={slot}\n now={now}\n index={i}\n onPick={(offer) =>\n setPicked({ storeID: store.StoreID, section, slot, offer })\n }\n />\n ))}\n </OfferGrid>\n </div>\n ))}\n {picked ? (\n <ConfirmPurchase\n picked={picked}\n onReload={load}\n onClose={() => setPicked(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction OfferGrid({ children }: { children: ReactNode }): ReactNode {\n const layout = useGameLayout();\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: `repeat(auto-fill, minmax(${layout === \"phone\" ? 150 : 180}px, 1fr))`,\n gap: layout === \"phone\" ? 12 : 16,\n }}\n >\n {children}\n </div>\n );\n}\n\nfunction OfferCard({\n slot,\n now,\n wide = false,\n index,\n onPick,\n}: {\n slot: StorefrontSlotView;\n now: number;\n wide?: boolean;\n index: number;\n onPick: (offer: StorefrontOfferView) => void;\n}): ReactNode {\n const catalog = useCatalog();\n const layout = useGameLayout();\n const stagger = useStagger();\n const state = useUserState();\n const offer = slot.Offer;\n const width = wide ? (layout === \"phone\" ? 170 : 200) : undefined;\n\n // An EMPTY slot is still drawn — the shelf must not jump when a window closes.\n if (!offer)\n return (\n <div\n style={{\n ...panel,\n width,\n minHeight: 200,\n display: \"grid\",\n placeItems: \"center\",\n opacity: 0.55,\n scrollSnapAlign: \"start\",\n flex: \"none\",\n ...stagger(index),\n }}\n >\n <Icon glyph=\"lock\" size={40} />\n </div>\n );\n\n const rewards = grantLines(offer.Rewards);\n const main = rewards[0];\n const price = costLines(offer.PriceOptions?.[0]?.Cost);\n const soldOut = offer.State?.SoldOut === true;\n const readyIn = countdown(offer.State?.AvailableAtUtc ?? null, now);\n const free = isFree(offer);\n const disabled = soldOut || readyIn !== null;\n const affordable = canAfford(price, state?.InventoryV2?.VirtualCurrencies);\n const name = catalog.localize(offer.Identity?.DisplayName ?? offer.OfferID);\n const remaining = offer.State?.RemainingToday ?? offer.State?.RemainingTotal;\n\n // The sheet's shop card: a light-blue plate in the ink frame (green when free), the name on top,\n // the prize big in the middle, the price at the bottom; a sold-out card is dimmed under the stamp.\n const [top, deep] = TONES[free ? \"green\" : \"blue\"];\n return (\n <div\n className={free && !disabled ? \"idos-shine\" : undefined}\n style={{\n position: \"relative\",\n width,\n flex: wide ? \"none\" : undefined,\n scrollSnapAlign: \"start\",\n padding: \"10px 10px 12px\",\n display: \"grid\",\n gap: 8,\n justifyItems: \"center\",\n borderRadius: 12,\n border: inkFrame,\n background: `linear-gradient(180deg, color-mix(in srgb, ${top} 55%, #fff) 0%, ${top} 38%, ${deep} 100%)`,\n boxShadow: `inset 0 -4px 0 rgba(0,0,0,.2), inset 0 2px 0 rgba(255,255,255,.45), 0 4px 0 ${v.shadow}`,\n ...stagger(index),\n }}\n >\n <div\n style={{\n ...inked,\n fontSize: 16,\n textAlign: \"center\",\n minHeight: 36,\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n {name}\n </div>\n <div\n className={free && !disabled ? \"idos-bounce\" : undefined}\n style={{\n width: 84,\n height: 84,\n display: \"grid\",\n placeItems: \"center\",\n borderRadius: 20,\n background:\n \"radial-gradient(circle, rgba(255,255,255,.18), transparent 70%)\",\n }}\n >\n <Icon\n glyph={\n main\n ? main.kind === \"token\"\n ? tokenGlyph(main)\n : catalog.iconOf(main)\n : \"gift\"\n }\n size={62}\n />\n </div>\n <div style={{ minHeight: 22 }}>\n <ResourceList lines={rewards} />\n </div>\n {remaining != null ? (\n <div\n style={{\n ...outlined,\n fontSize: 11,\n color: v.textDim,\n fontWeight: 700,\n }}\n >\n {t(\"left\")}: {remaining}\n </div>\n ) : null}\n <Button\n tone=\"gold\"\n disabled={disabled}\n attract={free && !disabled}\n onClick={() => onPick(offer)}\n style={{ width: \"100%\", visibility: soldOut ? \"hidden\" : undefined }}\n >\n {soldOut ? (\n t(\"soldOut\")\n ) : readyIn ? (\n `${t(\"readyIn\")} ${readyIn}`\n ) : free ? (\n t(\"free\")\n ) : (\n <ResourceList lines={price} size={20} dimIf={() => !affordable} />\n )}\n </Button>\n {soldOut ? (\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n borderRadius: 9,\n background: `color-mix(in srgb, ${v.bgBottom} 55%, transparent)`,\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <SoldOutStamp />\n </div>\n ) : null}\n </div>\n );\n}\n\nfunction ConfirmPurchase({\n picked,\n onReload,\n onClose,\n}: {\n picked: Picked;\n onReload: () => Promise<void>;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const rewards = grantLines(picked.offer.Rewards);\n const name = catalog.localize(\n picked.offer.Identity?.DisplayName ?? picked.offer.OfferID,\n );\n return (\n <Popup title={t(\"confirmTitle\")} onClose={onClose}>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 10 }}>\n <div style={{ ...outlined, fontSize: 17, textAlign: \"center\" }}>\n {name}\n </div>\n <div\n style={{\n display: \"flex\",\n gap: 14,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n {rewards.map((line) => (\n <div\n key={`${line.kind}:${line.id}`}\n style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}\n >\n <Icon\n glyph={\n line.kind === \"token\"\n ? tokenGlyph(line)\n : catalog.iconOf(line)\n }\n size={54}\n />\n <span style={{ ...outlined, fontSize: 17 }}>×{line.amount}</span>\n </div>\n ))}\n </div>\n </div>\n <BuyButton picked={picked} onReload={onReload} />\n </Popup>\n );\n}\n\nfunction BuyButton({\n picked,\n onReload,\n}: {\n picked: Picked;\n onReload: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const close = usePopupClose();\n const celebrate = useCelebrate();\n const toast = useToast();\n const play = useSound();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const option = picked.offer.PriceOptions?.[0];\n const price: ResourceLine[] = costLines(option?.Cost);\n const free = isFree(picked.offer);\n\n const buy = async () => {\n setBusy(true);\n const res = await client.store.purchase(picked.offer.OfferID, 1, {\n selectedOptionID: option?.OptionID,\n slot: {\n storeID: picked.storeID,\n sectionID: picked.section.SectionID,\n slotID: picked.slot.SlotID,\n },\n });\n setBusy(false);\n if (!res.ok) {\n if (res.error === OFFER_NOT_IN_ROTATION) {\n close();\n await onReload(); // the shelf reshuffled — redraw silently\n return;\n }\n toast(errorText(res.error), \"error\");\n return;\n }\n play(\"purchase\");\n kit.fx.burst(ref.current);\n close();\n const granted = grantedBy(res.data);\n celebrate(granted.length > 0 ? granted : grantLines(picked.offer.Rewards));\n await onReload();\n };\n\n return (\n <div ref={ref}>\n <Button\n tone={free ? \"green\" : \"gold\"}\n busy={busy}\n attract\n onClick={() => void buy()}\n style={{ width: \"100%\" }}\n size=\"lg\"\n >\n {free ? (\n t(\"confirmFree\")\n ) : (\n <>\n {t(\"confirmBuy\")} <ResourceList lines={price} size={20} />\n </>\n )}\n </Button>\n </div>\n );\n}\n"
|
|
68
|
+
"content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport {\n CreditsError,\n OFFER_NOT_IN_ROTATION,\n type CreditsConfirmationView,\n type GetStorefrontResponse,\n type StorefrontOfferView,\n type StorefrontSectionView,\n type StorefrontSlotView,\n} from \"@idosgames/core\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport { CreditsPrice } from \"./PlatformCurrencyIcon\";\nimport {\n Button,\n Center,\n EmptyState,\n Icon,\n Popup,\n ResourceList,\n SectionTitle,\n SoldOutStamp,\n Spinner,\n Tabs,\n Timer,\n TONES,\n canAfford,\n countdown,\n errorText,\n grantLines,\n grantedBy,\n inkFrame,\n inked,\n outlined,\n panel,\n useCatalog,\n useCelebrate,\n useGameLayout,\n useNow,\n usePopupClose,\n useSound,\n useStagger,\n useToast,\n useUiKit,\n v,\n type ButtonTone,\n type ResourceLine,\n tokenGlyph,\n} from \"@idosgames/react/ui\";\nimport type { FeatureScreenProps } from \"@idosgames/module-sdk\";\nimport {\n creditsPriceOf,\n earliestRefresh,\n isFree,\n resourcePrice,\n slotsOf,\n sortedSections,\n sortedStores,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n/** The ribbon colour of each section in turn, as the sheet's shop (\"Daily Shop\", \"AD Shop\", \"Gem Pack\"…). */\nconst SECTION_TONES: ButtonTone[] = [\n \"red\",\n \"green\",\n \"purple\",\n \"gold\",\n \"blue\",\n \"orange\",\n];\n\n// The shop, drawn from the storefront RESOLVED for this player (client.store.getStorefront — never\n// from the raw config): rotation, limits, badges and timers are computed by the server. Layout of the\n// Unity shop window (Alikhan/Shop): several stores as tabs, the first section of a store is a\n// carousel, the rest are grids; a tap opens the confirmation popup; a purchase shows what was granted\n// and the rewards fly to their counters.\n\ninterface Picked {\n storeID: string;\n section: StorefrontSectionView;\n slot: StorefrontSlotView;\n offer: StorefrontOfferView;\n}\n\nexport function StoreScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const [front, setFront] = useState<GetStorefrontResponse | null>(\n client.store.cachedStorefront,\n );\n const [loading, setLoading] = useState(front === null);\n const [failed, setFailed] = useState<string | null>(null);\n const [storeID, setStoreID] = useState<string | null>(\n typeof args?.storeID === \"string\" ? args.storeID : null,\n );\n const [picked, setPicked] = useState<Picked | null>(null);\n // Server clock offset: timers count from the SERVER time, not the device clock.\n const [skewMs, setSkewMs] = useState(0);\n const now = useNow() + skewMs;\n\n const load = useCallback(async () => {\n const res = await client.store.getStorefront();\n setLoading(false);\n if (!res.ok) {\n // A title without a Store section answers \"not found\" — that is an empty shop, not a failure.\n setFailed(\n /not found|not configured/i.test(res.error ?? \"\")\n ? t(\"emptyShop\")\n : errorText(res.error),\n );\n return;\n }\n setFailed(null);\n setFront(res.data);\n setSkewMs(new Date(res.data.ServerTimeUtc).getTime() - Date.now());\n }, [client]);\n\n useEffect(() => {\n void load();\n }, [load]);\n\n // One reload when the nearest card refreshes, instead of polling.\n useEffect(() => {\n const next = client.store.nextRefreshAtUtc();\n if (!next) return;\n const ms = new Date(next).getTime() - (Date.now() + skewMs);\n if (ms <= 0 || ms > 24 * 3600_000) return;\n const id = setTimeout(() => void load(), ms + 500);\n return () => clearTimeout(id);\n }, [front, client, load, skewMs]);\n\n if (loading)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (failed)\n return (\n <EmptyState\n glyph=\"shop\"\n text={failed}\n action={<Button onClick={() => void load()}>{t(\"retry\")}</Button>}\n />\n );\n\n const stores = sortedStores(front);\n const store = stores.find((s) => s.StoreID === storeID) ?? stores[0];\n const sections = sortedSections(store);\n if (!store || sections.length === 0)\n return <EmptyState glyph=\"shop\" text={t(\"emptyShop\")} />;\n const [first, ...rest] = sections;\n\n return (\n <div style={{ display: \"grid\", gap: 18 }}>\n <Tabs\n tabs={stores.map((s) => ({\n id: s.StoreID,\n label: s.Identity?.DisplayName ?? s.StoreID,\n }))}\n value={store.StoreID}\n onChange={setStoreID}\n />\n {first ? (\n <div key={`${store.StoreID}:${first.SectionID}`}>\n <SectionTitle\n tone={SECTION_TONES[0]}\n right={\n <Timer\n at={earliestRefresh(first)}\n now={now}\n label={t(\"refreshesIn\")}\n />\n }\n >\n {first.Identity?.DisplayName ?? first.SectionID}\n </SectionTitle>\n <div\n className=\"idos-scroll\"\n style={{\n display: \"flex\",\n gap: 12,\n overflowX: \"auto\",\n padding: \"4px 4px 12px\",\n scrollSnapType: \"x mandatory\",\n }}\n >\n {slotsOf(first).map((slot, i) => (\n <OfferCard\n key={slot.SlotID}\n slot={slot}\n now={now}\n wide\n index={i}\n onPick={(offer) =>\n setPicked({\n storeID: store.StoreID,\n section: first,\n slot,\n offer,\n })\n }\n />\n ))}\n </div>\n </div>\n ) : null}\n {rest.map((section, i) => (\n <div key={`${store.StoreID}:${section.SectionID}`}>\n <SectionTitle\n tone={SECTION_TONES[(i + 1) % SECTION_TONES.length]}\n right={\n <Timer\n at={earliestRefresh(section)}\n now={now}\n label={t(\"refreshesIn\")}\n />\n }\n >\n {section.Identity?.DisplayName ?? section.SectionID}\n </SectionTitle>\n <OfferGrid>\n {slotsOf(section).map((slot, i) => (\n <OfferCard\n key={slot.SlotID}\n slot={slot}\n now={now}\n index={i}\n onPick={(offer) =>\n setPicked({ storeID: store.StoreID, section, slot, offer })\n }\n />\n ))}\n </OfferGrid>\n </div>\n ))}\n {picked ? (\n <ConfirmPurchase\n picked={picked}\n onReload={load}\n onClose={() => setPicked(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction OfferGrid({ children }: { children: ReactNode }): ReactNode {\n const layout = useGameLayout();\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: `repeat(auto-fill, minmax(${layout === \"phone\" ? 150 : 180}px, 1fr))`,\n gap: layout === \"phone\" ? 12 : 16,\n }}\n >\n {children}\n </div>\n );\n}\n\nfunction OfferCard({\n slot,\n now,\n wide = false,\n index,\n onPick,\n}: {\n slot: StorefrontSlotView;\n now: number;\n wide?: boolean;\n index: number;\n onPick: (offer: StorefrontOfferView) => void;\n}): ReactNode {\n const catalog = useCatalog();\n const layout = useGameLayout();\n const stagger = useStagger();\n const state = useUserState();\n const offer = slot.Offer;\n const width = wide ? (layout === \"phone\" ? 170 : 200) : undefined;\n\n // An EMPTY slot is still drawn — the shelf must not jump when a window closes.\n if (!offer)\n return (\n <div\n style={{\n ...panel,\n width,\n minHeight: 200,\n display: \"grid\",\n placeItems: \"center\",\n opacity: 0.55,\n scrollSnapAlign: \"start\",\n flex: \"none\",\n ...stagger(index),\n }}\n >\n <Icon glyph=\"lock\" size={40} />\n </div>\n );\n\n const rewards = grantLines(offer.Rewards);\n const main = rewards[0];\n const price = resourcePrice(offer);\n const creditsPrice = creditsPriceOf(offer);\n const soldOut = offer.State?.SoldOut === true;\n const readyIn = countdown(offer.State?.AvailableAtUtc ?? null, now);\n const free = isFree(offer);\n const disabled = soldOut || readyIn !== null;\n const affordable = canAfford(price, state?.InventoryV2?.VirtualCurrencies);\n const name = catalog.localize(offer.Identity?.DisplayName ?? offer.OfferID);\n const remaining = offer.State?.RemainingToday ?? offer.State?.RemainingTotal;\n\n // The sheet's shop card: a light-blue plate in the ink frame (green when free), the name on top,\n // the prize big in the middle, the price at the bottom; a sold-out card is dimmed under the stamp.\n const [top, deep] = TONES[free ? \"green\" : \"blue\"];\n return (\n <div\n className={free && !disabled ? \"idos-shine\" : undefined}\n style={{\n position: \"relative\",\n width,\n flex: wide ? \"none\" : undefined,\n scrollSnapAlign: \"start\",\n padding: \"10px 10px 12px\",\n display: \"grid\",\n gap: 8,\n justifyItems: \"center\",\n borderRadius: 12,\n border: inkFrame,\n background: `linear-gradient(180deg, color-mix(in srgb, ${top} 55%, #fff) 0%, ${top} 38%, ${deep} 100%)`,\n boxShadow: `inset 0 -4px 0 rgba(0,0,0,.2), inset 0 2px 0 rgba(255,255,255,.45), 0 4px 0 ${v.shadow}`,\n ...stagger(index),\n }}\n >\n <div\n style={{\n ...inked,\n fontSize: 16,\n textAlign: \"center\",\n minHeight: 36,\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n {name}\n </div>\n <div\n className={free && !disabled ? \"idos-bounce\" : undefined}\n style={{\n width: 84,\n height: 84,\n display: \"grid\",\n placeItems: \"center\",\n borderRadius: 20,\n background:\n \"radial-gradient(circle, rgba(255,255,255,.18), transparent 70%)\",\n }}\n >\n <Icon\n glyph={\n main\n ? main.kind === \"token\"\n ? tokenGlyph(main)\n : catalog.iconOf(main)\n : \"gift\"\n }\n size={62}\n />\n </div>\n <div style={{ minHeight: 22 }}>\n <ResourceList lines={rewards} />\n </div>\n {remaining != null ? (\n <div\n style={{\n ...outlined,\n fontSize: 11,\n color: v.textDim,\n fontWeight: 700,\n }}\n >\n {t(\"left\")}: {remaining}\n </div>\n ) : null}\n <Button\n tone=\"gold\"\n disabled={disabled}\n attract={free && !disabled}\n onClick={() => onPick(offer)}\n style={{ width: \"100%\", visibility: soldOut ? \"hidden\" : undefined }}\n >\n {soldOut ? (\n t(\"soldOut\")\n ) : readyIn ? (\n `${t(\"readyIn\")} ${readyIn}`\n ) : free ? (\n t(\"free\")\n ) : creditsPrice != null ? (\n <CreditsPrice amount={creditsPrice} label={t(\"currencyName\")} size={20} />\n ) : (\n <ResourceList lines={price} size={20} dimIf={() => !affordable} />\n )}\n </Button>\n {soldOut ? (\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n borderRadius: 9,\n background: `color-mix(in srgb, ${v.bgBottom} 55%, transparent)`,\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <SoldOutStamp />\n </div>\n ) : null}\n </div>\n );\n}\n\nfunction ConfirmPurchase({\n picked,\n onReload,\n onClose,\n}: {\n picked: Picked;\n onReload: () => Promise<void>;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const rewards = grantLines(picked.offer.Rewards);\n const name = catalog.localize(\n picked.offer.Identity?.DisplayName ?? picked.offer.OfferID,\n );\n return (\n <Popup title={t(\"confirmTitle\")} onClose={onClose}>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 10 }}>\n <div style={{ ...outlined, fontSize: 17, textAlign: \"center\" }}>\n {name}\n </div>\n <div\n style={{\n display: \"flex\",\n gap: 14,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n {rewards.map((line) => (\n <div\n key={`${line.kind}:${line.id}`}\n style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}\n >\n <Icon\n glyph={\n line.kind === \"token\"\n ? tokenGlyph(line)\n : catalog.iconOf(line)\n }\n size={54}\n />\n <span style={{ ...outlined, fontSize: 17 }}>×{line.amount}</span>\n </div>\n ))}\n </div>\n </div>\n <BuyButton picked={picked} onReload={onReload} />\n </Popup>\n );\n}\n\nfunction BuyButton({\n picked,\n onReload,\n}: {\n picked: Picked;\n onReload: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const close = usePopupClose();\n const celebrate = useCelebrate();\n const toast = useToast();\n const play = useSound();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const option = picked.offer.PriceOptions?.[0];\n const price: ResourceLine[] = resourcePrice(picked.offer);\n const creditsPrice = creditsPriceOf(picked.offer);\n const free = isFree(picked.offer);\n // Credits: the request waiting for the player on idosgames.com. Leaving the popup stops the wait.\n const [confirm, setConfirm] = useState<CreditsConfirmationView | null>(null);\n const abort = useRef<AbortController | null>(null);\n useEffect(() => () => abort.current?.abort(), []);\n\n const creditsError = (error: string | undefined): string => {\n if (error === CreditsError.LoginRequired) return t(\"creditsLogin\");\n if (error === CreditsError.NotEnough) return t(\"creditsNotEnough\");\n if (error === CreditsError.ConfirmationDeclined) return t(\"creditsDeclined\");\n if (error === CreditsError.ConfirmationExpired) return t(\"creditsExpired\");\n return errorText(error);\n };\n\n const buy = async () => {\n setBusy(true);\n const slot = {\n storeID: picked.storeID,\n sectionID: picked.section.SectionID,\n slotID: picked.slot.SlotID,\n };\n abort.current = new AbortController();\n const res =\n creditsPrice != null\n ? await client.store.purchaseWithCredits(picked.offer.OfferID, 1, {\n selectedOptionID: option?.OptionID,\n slot,\n onConfirmation: setConfirm,\n signal: abort.current.signal,\n })\n : await client.store.purchase(picked.offer.OfferID, 1, {\n selectedOptionID: option?.OptionID,\n slot,\n });\n setBusy(false);\n setConfirm(null);\n if (!res.ok) {\n if (res.error === OFFER_NOT_IN_ROTATION) {\n close();\n await onReload(); // the shelf reshuffled — redraw silently\n return;\n }\n if (res.error === \"CREDITS_CONFIRMATION_CANCELLED\") return;\n if (res.error === CreditsError.NotEnough) {\n const balance = await client.store.getCreditsBalance();\n const url = balance.ok ? balance.data.TopUpUrl : null;\n if (url) window.open(url, \"_blank\", \"noopener\");\n }\n toast(creditsPrice != null ? creditsError(res.error) : errorText(res.error), \"error\");\n return;\n }\n play(\"purchase\");\n kit.fx.burst(ref.current);\n close();\n const granted = grantedBy(res.data);\n celebrate(granted.length > 0 ? granted : grantLines(picked.offer.Rewards));\n await onReload();\n };\n\n if (confirm) {\n // A link, not window.open after an await: the browser blocks popups that are not a direct tap.\n return (\n <div ref={ref} style={{ display: \"grid\", gap: 10, textAlign: \"center\" }}>\n <span style={{ ...outlined, fontSize: 14 }}>{t(\"creditsConfirmHint\")}</span>\n <a\n href={confirm.Url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n style={{ textDecoration: \"none\" }}\n >\n <Button tone=\"gold\" size=\"lg\" style={{ width: \"100%\" }}>\n {t(\"creditsConfirmOpen\")}\n </Button>\n </a>\n <span style={{ ...outlined, fontSize: 13, opacity: 0.8 }}>{t(\"creditsWaiting\")}</span>\n </div>\n );\n }\n\n return (\n <div ref={ref}>\n <Button\n tone={free ? \"green\" : \"gold\"}\n busy={busy}\n attract\n onClick={() => void buy()}\n style={{ width: \"100%\" }}\n size=\"lg\"\n >\n {free ? (\n t(\"confirmFree\")\n ) : creditsPrice != null ? (\n <>\n {t(\"confirmBuy\")} <CreditsPrice amount={creditsPrice} label={t(\"currencyName\")} size={20} />\n </>\n ) : (\n <>\n {t(\"confirmBuy\")} <ResourceList lines={price} size={20} />\n </>\n )}\n </Button>\n </div>\n );\n}\n"
|
|
65
69
|
},
|
|
66
70
|
{
|
|
67
71
|
"path": "module.meta.json",
|