@idosgames/mcp 0.1.45 → 0.1.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/cli.js +1597 -1542
  2. package/package.json +1 -1
  3. package/registry/host.json +3 -3
  4. package/registry/index.json +124 -77
  5. package/registry/modules/ai.json +2 -2
  6. package/registry/modules/board-game.json +3 -3
  7. package/registry/modules/boosts.json +2 -2
  8. package/registry/modules/character.json +2 -2
  9. package/registry/modules/chat.json +2 -2
  10. package/registry/modules/collections.json +2 -2
  11. package/registry/modules/coop-events.json +2 -2
  12. package/registry/modules/craft.json +2 -2
  13. package/registry/modules/deals.json +2 -2
  14. package/registry/modules/events.json +2 -2
  15. package/registry/modules/friends.json +2 -2
  16. package/registry/modules/hero-run.json +13 -232
  17. package/registry/modules/idle-rpg.json +3 -3
  18. package/registry/modules/inventory.json +2 -2
  19. package/registry/modules/invite.json +2 -2
  20. package/registry/modules/kit-game.json +171 -0
  21. package/registry/modules/leaderboard.json +2 -2
  22. package/registry/modules/lootboxes.json +2 -2
  23. package/registry/modules/mailbox.json +2 -2
  24. package/registry/modules/marketplace.json +2 -2
  25. package/registry/modules/mini-game.json +2 -2
  26. package/registry/modules/multiplayer.json +2 -2
  27. package/registry/modules/premium.json +2 -2
  28. package/registry/modules/profile.json +2 -2
  29. package/registry/modules/pvp.json +2 -2
  30. package/registry/modules/quests.json +2 -2
  31. package/registry/modules/rewards.json +2 -2
  32. package/registry/modules/season.json +2 -2
  33. package/registry/modules/store.json +2 -2
  34. package/registry/modules/tutorials.json +2 -2
  35. package/registry/modules/video-studio.json +3 -3
  36. package/registry/modules/voxelcraft.json +3 -3
  37. package/registry/modules/workshop.json +2 -2
  38. package/registry/skills/currency-system.json +1 -1
  39. package/registry/skills/idosgames-compose-modules.json +1 -1
  40. package/registry/skills/idosgames-getting-started.json +1 -1
  41. package/registry/video.json +1 -1
@@ -75,10 +75,10 @@
75
75
  }
76
76
  },
77
77
  "dependencies": {
78
- "@idosgames/characters": "0.1.2",
79
- "@idosgames/core": "0.26.0",
78
+ "@idosgames/characters": "0.1.3",
79
+ "@idosgames/core": "0.26.1",
80
80
  "@idosgames/module-sdk": "0.13.3",
81
- "@idosgames/react": "0.7.0",
81
+ "@idosgames/react": "0.7.1",
82
82
  "phaser": "4.2.1",
83
83
  "react": "19.2.7"
84
84
  },
@@ -32,9 +32,9 @@
32
32
  "version": "0.1.1"
33
33
  },
34
34
  "dependencies": {
35
- "@idosgames/core": "0.26.0",
35
+ "@idosgames/core": "0.26.1",
36
36
  "@idosgames/module-sdk": "0.13.3",
37
- "@idosgames/react": "0.7.0",
37
+ "@idosgames/react": "0.7.1",
38
38
  "react": "19.2.7"
39
39
  },
40
40
  "contentHash": "0b02bc09a1a29ecb6d08fa281dfe316b01f8f341350ba27f386a05572827700e",
@@ -35,9 +35,9 @@
35
35
  "version": "0.1.3"
36
36
  },
37
37
  "dependencies": {
38
- "@idosgames/core": "0.26.0",
38
+ "@idosgames/core": "0.26.1",
39
39
  "@idosgames/module-sdk": "0.13.3",
40
- "@idosgames/react": "0.7.0",
40
+ "@idosgames/react": "0.7.1",
41
41
  "react": "19.2.7"
42
42
  },
43
43
  "contentHash": "0e2ffb620e408e8a97d16de8ab88b36c09e7295d3e84d4f36f746e9beb08666b",
@@ -0,0 +1,171 @@
1
+ {
2
+ "id": "kit-game",
3
+ "meta": {
4
+ "name": "Constructor game",
5
+ "type": "game",
6
+ "engine": "phaser",
7
+ "genre": "constructor"
8
+ },
9
+ "catalog": {
10
+ "type": "template",
11
+ "uses": [
12
+ "premium"
13
+ ],
14
+ "summary": "The game constructor's runtime: plays the title's GameSpec (a whole game as data) on Phaser; without one, a demo lane runner.",
15
+ "description": "One prebuilt runtime for every 2D game of the platform's constructor. The title's config section GameSpec describes the game as data: genre presets as modes, their parameters (difficulty, lanes, lives…), the palette, texts in every language, resources and how modes produce and spend them, rules (trigger → condition → effect) and reward multipliers. The module validates the spec against the platform's blocks and presets from @idosgames/game-kit (numbers out of range are clamped, unknown fields kept), runs the core mode in a deterministic world and draws it through the Phaser adapter. Genres so far: an endless lane runner (swipe, arrows or a tap on a side to change lanes; coins, obstacles, growing speed). A title without a GameSpec plays the built-in demo spec. Edit the game by editing the spec, not the code; a new genre is a new preset over the kit's blocks.",
16
+ "provides": [
17
+ "a game from a GameSpec: no code per game",
18
+ "endless lane runner (swipe / arrows / tap a side)",
19
+ "palette, texts and parameters from the spec",
20
+ "deterministic runs from the spec's seed",
21
+ "round flow: tap to start, result screen, play again",
22
+ "reward window after a round: claim, x3 for a rewarded ad (in the app), x5 for VIP (server-side multiplier)"
23
+ ],
24
+ "tags": [
25
+ "constructor",
26
+ "game-kit",
27
+ "runner",
28
+ "arcade",
29
+ "phaser",
30
+ "2d",
31
+ "data-driven"
32
+ ],
33
+ "author": {
34
+ "name": "iDos Games",
35
+ "url": "https://idosgames.com"
36
+ },
37
+ "version": "0.1.0"
38
+ },
39
+ "dependencies": {
40
+ "@idosgames/characters": "0.1.3",
41
+ "@idosgames/core": "0.26.1",
42
+ "@idosgames/game-kit": "0.1.0",
43
+ "@idosgames/module-sdk": "0.13.3",
44
+ "@idosgames/react": "0.7.1",
45
+ "phaser": "4.2.1",
46
+ "react": "19.2.7"
47
+ },
48
+ "contentHash": "0b4f174020db8317ee459734d650dc8d19becab0e08c2d87ce66fb10fc2d8986",
49
+ "files": [
50
+ {
51
+ "path": "GameMenu.tsx",
52
+ "content": "import { useEffect, useState, useSyncExternalStore, type ReactNode } from \"react\";\nimport type { FeatureRegistry } from \"@idosgames/module-sdk\";\nimport { FeatureGrid, Popup, Window } from \"@idosgames/react/ui\";\nimport { t } from \"./i18n\";\n\n// The game's ONE menu (owner, 07.11.2026): every system of the app (quests, inventory, friends,\n// profile…) behind the ☰ of the HUD, opened OVER the game — the field freezes underneath, closing\n// returns to the same moment. While the game is on screen it is also the presenter of\n// `features.open(id)` (\"not enough coins\" → the shop opens here, not in the lobby).\n\n/** The base's \"Play\" tab: the player is already in the game. */\nconst PLAY_FEATURE = \"play\";\n\nexport function GameMenu({\n features,\n open,\n setOpen,\n}: {\n features: FeatureRegistry;\n open: boolean;\n /** Stable (a state setter): the presenter is registered once. */\n setOpen: (open: boolean) => void;\n}): ReactNode {\n const all = useSyncExternalStore(features.subscribe, features.list, features.list);\n const list = all.filter((f) => f.available && f.id !== PLAY_FEATURE);\n const [screen, setScreen] = useState<{ id: string; args?: Record<string, unknown> } | null>(null);\n\n useEffect(\n () =>\n features.setPresenter((id, args) => {\n setOpen(true);\n setScreen({ id, args });\n return true;\n }),\n [features, setOpen],\n );\n\n const current = screen ? features.get(screen.id) : undefined;\n const Screen = current?.Screen;\n const closeAll = () => {\n setScreen(null);\n setOpen(false);\n };\n\n return (\n <>\n {open && !current ? (\n <Popup title={t(\"menu\")} onClose={closeAll} width={520}>\n <FeatureGrid items={list} onOpen={(id) => setScreen({ id })} />\n </Popup>\n ) : null}\n {current && Screen ? (\n <Window key={current.id} title={current.label} icon={current.icon} onClose={closeAll}>\n <Screen args={screen?.args} close={closeAll} />\n </Window>\n ) : null}\n </>\n );\n}\n"
53
+ },
54
+ {
55
+ "path": "i18n.ts",
56
+ "content": "import { makeT } from \"@idosgames/react/ui\";\n\n// Strings of the runtime itself. The game's own words (its title, hints, resource names) come from\n// the spec's texts.\n\nconst EN = {\n title: \"Game\",\n tapToPlay: \"Tap to play\",\n score: \"Score\",\n best: \"Best\",\n coins: \"Coins\",\n lives: \"Lives\",\n gameOver: \"Round over\",\n won: \"Victory!\",\n lost: \"Defeat\",\n playAgain: \"Play again\",\n continue: \"Continue\",\n home: \"Upgrades\",\n menu: \"Menu\",\n newBest: \"New best!\",\n specFallback: \"This title has no game spec yet: playing the demo.\",\n} as const;\n\nexport type StringKey = keyof typeof EN;\n\nexport const t = makeT<StringKey>(EN, {\n title: \"Игра\",\n tapToPlay: \"Нажмите, чтобы играть\",\n score: \"Очки\",\n best: \"Рекорд\",\n coins: \"Монеты\",\n lives: \"Жизни\",\n gameOver: \"Раунд окончен\",\n won: \"Победа!\",\n lost: \"Поражение\",\n playAgain: \"Ещё раз\",\n continue: \"Продолжить\",\n home: \"Прокачка\",\n menu: \"Меню\",\n newBest: \"Новый рекорд!\",\n specFallback: \"У этого тайтла ещё нет спеки игры: играет демо.\",\n});\n"
57
+ },
58
+ {
59
+ "path": "index.ts",
60
+ "content": "// @idosgames/mod-kit-game — the game constructor's Phaser runtime: plays the title's GameSpec.\nexport { kitGameModule } from \"./module\";\nexport { GAME_SPEC_SECTION, KitRuntime, loadSpec, type Hud, type LoadedSpec } from \"./runtime\";\nexport { createKitScene } from \"./scene\";\nexport { makeKitPanel, reviveTextOf } from \"./KitPanel\";\nexport { RoundResultView, outcomeOf, type RoundOutcome, type RoundResultViewProps } from \"./RoundResultView\";\nexport { META_GAMES } from \"./runtime\";\nexport { GameTopBar, chipStyle, type BarBalance } from \"./TopBar\";\nexport { GameMenu } from \"./GameMenu\";\nexport { RunOverlay, optionView } from \"./survivors/RunOverlay\";\nexport { ArtIcon, type ArtLookProps } from \"./meta/ArtIcon\";\nexport { SurvivorsLobby } from \"./survivors/Lobby\";\nexport { RunnerLobby, Stars } from \"./runner/Lobby\";\nexport { PlatformerLobby } from \"./platformer/Lobby\";\nexport { TowerDefenseLobby } from \"./towerDefense/Lobby\";\nexport { Match3Lobby } from \"./match3/Lobby\";\nexport { MergeLobby } from \"./merge/Lobby\";\nexport { TycoonLobby } from \"./tycoon/Lobby\";\nexport { TouchPad } from \"./TouchPad\";\nexport { GameLobby } from \"./meta/Lobby\";\nexport { ReviveDialog } from \"./meta/ReviveDialog\";\nexport { resultDetails } from \"./meta/details\";\nexport {\n applyMeta,\n learnedOf,\n progressAfter,\n parseProgress,\n runStatsOf,\n LocalGameMeta,\n demoWallet,\n ServerGameMeta,\n type GameMeta,\n type RunStats,\n type MetaSnapshot,\n type MetaStatView,\n type Progress,\n} from \"./meta/meta\";\nexport { LocalSurvivorsMeta, ServerSurvivorsMeta, type SurvivorsMeta } from \"./survivors/meta\";\nexport { st as kitText, st as survivorsText } from \"./meta/i18n\";\n"
61
+ },
62
+ {
63
+ "path": "KitPanel.tsx",
64
+ "content": "import { useEffect, useState, useSyncExternalStore, type ComponentType, type ReactNode } from \"react\";\nimport type { FeatureRegistry } from \"@idosgames/module-sdk\";\nimport { useUserState } from \"@idosgames/react\";\nimport { useLanguage, v } from \"@idosgames/react/ui\";\nimport type { KitRuntime } from \"./runtime\";\nimport { t } from \"./i18n\";\nimport { GameMenu } from \"./GameMenu\";\nimport { chipStyle, GameTopBar, type BarBalance } from \"./TopBar\";\nimport { RoundResult } from \"./RoundResult\";\nimport { RunOverlay } from \"./survivors/RunOverlay\";\nimport { SurvivorsLobby } from \"./survivors/Lobby\";\nimport { RunnerLobby } from \"./runner/Lobby\";\nimport { PlatformerLobby } from \"./platformer/Lobby\";\nimport { TowerDefenseLobby } from \"./towerDefense/Lobby\";\nimport { Match3Lobby } from \"./match3/Lobby\";\nimport { MergeLobby } from \"./merge/Lobby\";\nimport { TycoonLobby } from \"./tycoon/Lobby\";\nimport { TouchPad } from \"./TouchPad\";\nimport { ReviveDialog } from \"./meta/ReviveDialog\";\n\nexport interface KitPanelOptions {\n /** The app's systems: all of them are behind the one menu button, opened over the game. */\n readonly features: FeatureRegistry;\n}\n\n// The game's whole UI, drawn as part of the game (owner, 07.11.2026: minimal, native, no separate\n// bars): ONE menu button top-left (every system of the app, over the game: ./GameMenu.tsx), the title's currencies top-right in the same chips, the score in\n// the middle while playing; the title and \"tap to play\" before a round, the result after. The overlay\n// lets taps through to the canvas except on its buttons. It re-renders only when a HUD value or a\n// balance changed, never per frame.\n/** The words of a genre's revive offer. */\nexport const reviveTextOf = (presetId: string) => (presetId === \"survivors\" ? \"reviveText\" : presetId === \"runner\" ? \"reviveRun\" : presetId === \"tower-defense\" ? \"reviveTd\" : presetId === \"match3\" ? \"reviveM3\" : presetId === \"merge\" ? \"reviveMg\" : presetId === \"tycoon\" ? \"reviveTy\" : \"reviveLevel\");\n\nexport function makeKitPanel(runtime: KitRuntime, options: KitPanelOptions): ComponentType {\n return function KitPanel(): ReactNode {\n const hud = useSyncExternalStore(runtime.subscribe, runtime.snapshot, runtime.snapshot);\n const state = useUserState();\n const [menuOpen, setMenuOpen] = useState(false);\n const features = useSyncExternalStore(\n options.features.subscribe,\n options.features.list,\n options.features.list,\n );\n const badges = features.reduce((n, f) => n + (f.available && f.badge > 0 ? 1 : 0), 0);\n // The field freezes while the menu is over it.\n useEffect(() => {\n runtime.setPaused(menuOpen);\n return () => runtime.setPaused(false);\n }, [menuOpen]);\n const language = useLanguage();\n const title = runtime.text(\"title\", language, t(\"title\"));\n const hint = runtime.text(\"hint\", language, \"\");\n // The spec's currencies that are real title currencies (`ref`), in the spec's order.\n const currencies = (runtime.spec?.resolved.spec.resources ?? []).filter(\n (r) => r.kind === \"currency\" && r.ref,\n );\n\n // Balances of the bar: the spec's real currencies, and the gems of the meta (a revive, a hero) beside them.\n const hardID = runtime.meta?.snapshot().hardID;\n const ids = [...currencies.map((r) => r.ref!), ...(hardID && !currencies.some((r) => r.ref === hardID) ? [hardID] : [])];\n const balances: BarBalance[] = ids.map((id, i) => ({\n key: id,\n currencyID: id,\n amount: Number(state?.InventoryV2?.VirtualCurrencies?.[id]?.Amount ?? 0),\n ...(i === 0 && hud.state === \"playing\" ? { plus: hud.coins } : {}),\n }));\n // a game that draws its own hearts on the field (the meta's genres) does not repeat them here\n const center = hud.state === \"playing\" && (\n <>\n <div style={chipStyle}>\n <span style={{ fontSize: 20 }}>{hud.score}</span>\n </div>\n {hud.lives > 1 && !runtime.meta && <div style={chipStyle}>{`♥ ${hud.lives}`}</div>}\n </>\n );\n\n return (\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n pointerEvents: \"none\",\n fontFamily: v.font,\n color: \"#fff\",\n textShadow: \"0 1px 3px rgba(0,0,0,0.5)\",\n }}\n >\n <GameTopBar onMenu={() => setMenuOpen(true)} badges={badges} center={center} balances={balances} />\n\n {/* before the round: the title up top; the start is the big button on the field with a hand\n tapping it (the game draws it: no \"tap to play\" words needed) */}\n {/* a game with a meta: its home between rounds (level, upgrades, heroes), its cards and the revive */}\n {runtime.meta && hud.state === \"ready\" && runtime.presetId === \"survivors\" && (\n <SurvivorsLobby meta={runtime.meta} look={runtime.artLook()} baseSeconds={Number(runtime.world?.vars[\"survivors_base\"] ?? 300)} language={language} />\n )}\n {runtime.meta && hud.state === \"ready\" && runtime.presetId === \"runner\" && (\n <RunnerLobby meta={runtime.meta} look={runtime.artLook()} lengthScale={Number(runtime.world?.vars[\"runner_length_scale\"] ?? 1)} language={language} />\n )}\n {runtime.meta && hud.state === \"ready\" && runtime.presetId === \"platformer\" && <PlatformerLobby meta={runtime.meta} look={runtime.artLook()} language={language} />}\n {runtime.meta && hud.state === \"ready\" && runtime.presetId === \"tower-defense\" && <TowerDefenseLobby meta={runtime.meta} look={runtime.artLook()} language={language} />}\n {runtime.meta && hud.state === \"ready\" && runtime.presetId === \"match3\" && <Match3Lobby meta={runtime.meta} look={runtime.artLook()} language={language} />}\n {runtime.meta && hud.state === \"ready\" && runtime.presetId === \"merge\" && <MergeLobby meta={runtime.meta} look={runtime.artLook()} language={language} />}\n {runtime.meta && hud.state === \"ready\" && runtime.presetId === \"tycoon\" && <TycoonLobby meta={runtime.meta} look={runtime.artLook()} language={language} />}\n {runtime.presetId === \"platformer\" && hud.state === \"playing\" && runtime.world && (\n <TouchPad world={runtime.world} dash={String(runtime.world.vars[\"abilities\"] ?? \"\").includes(\"dash\")} />\n )}\n {runtime.meta && runtime.presetId === \"survivors\" && <RunOverlay world={runtime.world} meta={runtime.meta} look={runtime.artLook()} />}\n {runtime.meta && <ReviveDialog world={runtime.world} meta={runtime.meta} text={reviveTextOf(runtime.presetId)} />}\n\n {hud.state === \"ready\" && !runtime.meta && (\n <div style={{ ...centered, justifyContent: \"flex-start\", paddingTop: \"18%\" }}>\n <div style={{ fontSize: 36, fontWeight: 800 }}>{title}</div>\n {hint && <div style={{ opacity: 0.85, maxWidth: 320 }}>{hint}</div>}\n {hud.best > 0 && <div style={{ opacity: 0.8, fontWeight: 700 }}>{`${t(\"best\")}: ${hud.best}`}</div>}\n </div>\n )}\n\n {hud.state === \"ended\" && <RoundResult runtime={runtime} hud={hud} features={options.features} />}\n <div style={{ pointerEvents: \"auto\" }}>\n <GameMenu features={options.features} open={menuOpen} setOpen={setMenuOpen} />\n </div>\n </div>\n );\n };\n}\n\nconst centered = {\n position: \"absolute\",\n inset: 0,\n display: \"flex\",\n flexDirection: \"column\",\n alignItems: \"center\",\n justifyContent: \"center\",\n gap: 8,\n textAlign: \"center\",\n padding: 24,\n} as const;\n"
65
+ },
66
+ {
67
+ "path": "match3/Lobby.tsx",
68
+ "content": "import { useSyncExternalStore, type ReactNode } from \"react\";\r\nimport { M3_ELEMENTS, M3_HEROES, M3_LEVELS, M3_OBSTACLES, M3_PIECES, m3EnemyOf, m3LevelOf, m3SkillOf, m3StarsToEnter, type MetaHeroDef } from \"@idosgames/game-kit\";\r\nimport { Icon, v } from \"@idosgames/react/ui\";\r\nimport { st, stAny } from \"../meta/i18n\";\r\nimport { starsTotal, type GameMeta } from \"../meta/meta\";\r\nimport { ArtIcon, type ArtLookProps } from \"../meta/ArtIcon\";\r\nimport { GameLobby } from \"../meta/Lobby\";\r\nimport { Stars } from \"../runner/Lobby\";\r\n\r\n/**\r\n * Match-3's home between levels: the level (its world, its stars), what it is — a battle (the foes, the boss) or\r\n * goals (what to collect or clear, the moves) — the next world's gate, the first-clear reward; the hero's upgrades\r\n * and heroes below (each hero: an element and a skill).\r\n */\r\nexport function Match3Lobby({ meta, look, language }: { meta: GameMeta; look: ArtLookProps; language: string }): ReactNode {\r\n const p = useSyncExternalStore(meta.subscribe, meta.snapshot, meta.snapshot).progress;\r\n const level = m3LevelOf(p.chapter);\r\n const cleared = p.cleared.includes(p.chapter);\r\n const stars = p.stars[String(p.chapter)] ?? 0;\r\n const total = starsTotal(p);\r\n const gate = p.chapter === p.unlocked && cleared && p.chapter < M3_LEVELS ? m3StarsToEnter(p.chapter + 1) : 0;\r\n const def = level.def;\r\n const card = (\r\n <>\r\n <div style={{ display: \"flex\", gap: 10, alignItems: \"center\", fontWeight: 900, fontSize: 18 }}>\r\n <span>{`${st(\"world\")} ${level.world}-${level.index}`}</span>\r\n {def.kind === \"battle\" ? (\r\n <span style={{ display: \"inline-flex\", gap: 4, alignItems: \"center\", color: v.red }}>\r\n <Icon glyph=\"swords\" size={16} /> {level.boss ? st(\"boss\") : st(\"battle\")}\r\n </span>\r\n ) : (\r\n <span style={{ fontSize: 14, opacity: 0.9 }}>{`${def.moves} ${st(\"movesN\")}`}</span>\r\n )}\r\n <Stars n={stars} size={20} />\r\n </div>\r\n <div style={{ fontSize: 12, display: \"flex\", gap: 10, flexWrap: \"wrap\", justifyContent: \"center\", alignItems: \"center\" }}>\r\n {def.kind === \"battle\"\r\n ? def.enemies.map((id, i) => {\r\n const e = m3EnemyOf(id);\r\n const el = e ? M3_ELEMENTS[e.element] : undefined;\r\n return (\r\n <span key={`${id}${i}`} style={{ display: \"inline-flex\", gap: 4, alignItems: \"center\", fontWeight: 800 }}>\r\n {el && <span style={{ width: 10, height: 10, borderRadius: 5, background: look.palette[el.color] ?? \"#888\", display: \"inline-block\" }} />}\r\n {stAny(`m.${id}`)}\r\n </span>\r\n );\r\n })\r\n : def.goals.map((g, i) => {\r\n const art = g.kind === \"color\" ? M3_PIECES.find((x) => x.index === g.color)?.art : g.kind === \"clear\" ? M3_OBSTACLES[g.obstacle]?.art : g.kind === \"drops\" ? \"m3_crate\" : \"coin\";\r\n const color = g.kind === \"color\" ? M3_PIECES.find((x) => x.index === g.color)?.color : undefined;\r\n const label = g.kind === \"color\" ? `× ${g.count}` : g.kind === \"clear\" ? stAny(`g.${g.obstacle}`) : g.kind === \"drops\" ? `${stAny(\"g.drops\")} × ${g.count}` : String(g.value);\r\n return (\r\n <span key={i} style={{ display: \"inline-flex\", gap: 4, alignItems: \"center\", fontWeight: 800 }}>\r\n {art && <ArtIcon id={art} {...(color ? { color } : {})} size={20} look={look} />}\r\n {label}\r\n </span>\r\n );\r\n })}\r\n </div>\r\n <div style={{ fontSize: 12, opacity: 0.9, display: \"flex\", gap: 10, flexWrap: \"wrap\", justifyContent: \"center\" }}>\r\n <span>{`${st(\"starsAll\")}: ★ ${total}`}</span>\r\n {gate > total && <span style={{ color: v.gold, fontWeight: 800 }}>{`${st(\"world\")} ${level.world + 1}: ${st(\"needStars\")} ★ ${gate}`}</span>}\r\n {!cleared && (\r\n <span style={{ display: \"inline-flex\", gap: 4, alignItems: \"center\" }}>\r\n {st(\"firstClear\")}: <Icon glyph=\"coin\" size={13} /> {level.firstClearGold}\r\n </span>\r\n )}\r\n </div>\r\n </>\r\n );\r\n const heroInfo = (m: MetaHeroDef) => {\r\n const h = M3_HEROES.find((x) => x.id === m.id) ?? M3_HEROES[0]!;\r\n const sk = m3SkillOf(h.skill);\r\n return (\r\n <>\r\n <div style={{ fontSize: 12, opacity: 0.8 }}>{`${st(\"element\")}: ${stAny(`el.${h.element}`)}`}</div>\r\n <div style={{ fontSize: 13, fontWeight: 800, minHeight: 34, display: \"flex\", gap: 4, alignItems: \"center\", justifyContent: \"center\" }}>\r\n {sk && <ArtIcon id={sk.art} size={18} look={look} />}\r\n {stAny(`sk.${h.skill}`)}\r\n </div>\r\n </>\r\n );\r\n };\r\n return <GameLobby meta={meta} look={look} language={language} label=\"levelN\" card={card} heroInfo={heroInfo} />;\r\n}\r\n"
69
+ },
70
+ {
71
+ "path": "merge/Lobby.tsx",
72
+ "content": "import { useSyncExternalStore, type ReactNode } from \"react\";\nimport { MG_BOARDS, MG_HEROES, MG_LEVELS, MG_OBSTACLES, mgChainOf, mgLevelOf, mgSkillOf, mgStarsToEnter, type MetaHeroDef } from \"@idosgames/game-kit\";\nimport { Icon, v } from \"@idosgames/react/ui\";\nimport { st, stAny } from \"../meta/i18n\";\nimport { starsTotal, type GameMeta } from \"../meta/meta\";\nimport { ArtIcon, type ArtLookProps } from \"../meta/ArtIcon\";\nimport { GameLobby } from \"../meta/Lobby\";\nimport { Stars } from \"../runner/Lobby\";\n\n/** The obstacles a board holds (by its chars), in the order of the registry. */\nconst obstaclesOf = (board: string): string[] => {\n const rows = (MG_BOARDS[board] ?? []).join(\"\");\n const has: Record<string, boolean> = { box: /[xX]/.test(rows), fog: rows.includes(\"f\"), web: rows.includes(\"w\"), bubble: rows.includes(\"u\") };\n return Object.keys(MG_OBSTACLES).filter((id) => has[id]);\n};\n\n/**\n * Merge's home between levels: the level (its world, its stars, a VIP at the end of a world), what it is — the chains\n * whose generators stand on the board, the customers to serve, the energy, the obstacles — the next world's gate, the\n * first-clear reward; the upgrades and heroes below (each hero: a skill).\n */\nexport function MergeLobby({ meta, look, language }: { meta: GameMeta; look: ArtLookProps; language: string }): ReactNode {\n const p = useSyncExternalStore(meta.subscribe, meta.snapshot, meta.snapshot).progress;\n const level = mgLevelOf(p.chapter);\n const cleared = p.cleared.includes(p.chapter);\n const stars = p.stars[String(p.chapter)] ?? 0;\n const total = starsTotal(p);\n const gate = p.chapter === p.unlocked && cleared && p.chapter < MG_LEVELS ? mgStarsToEnter(p.chapter + 1) : 0;\n const def = level.def;\n const card = (\n <>\n <div style={{ display: \"flex\", gap: 10, alignItems: \"center\", fontWeight: 900, fontSize: 18 }}>\n <span>{`${st(\"world\")} ${level.world}-${level.index}`}</span>\n {level.boss && (\n <span style={{ display: \"inline-flex\", gap: 4, alignItems: \"center\", color: v.gold }}>\n <Icon glyph=\"crown\" size={16} /> {st(\"vip\")}\n </span>\n )}\n <Stars n={stars} size={20} />\n </div>\n <div style={{ fontSize: 12, display: \"flex\", gap: 10, flexWrap: \"wrap\", justifyContent: \"center\", alignItems: \"center\" }}>\n {def.chains.map((id) => {\n const ch = mgChainOf(id);\n return (\n <span key={id} style={{ display: \"inline-flex\", gap: 4, alignItems: \"center\", fontWeight: 800 }}>\n {ch && <ArtIcon id={ch.gen} size={22} look={look} />}\n {stAny(`ch.${id}`)}\n </span>\n );\n })}\n </div>\n <div style={{ fontSize: 12, display: \"flex\", gap: 10, flexWrap: \"wrap\", justifyContent: \"center\", alignItems: \"center\" }}>\n <span style={{ display: \"inline-flex\", gap: 4, alignItems: \"center\", fontWeight: 800 }}>\n <ArtIcon id=\"mg_energy\" size={18} look={look} /> {level.energy}\n </span>\n <span style={{ display: \"inline-flex\", gap: 4, alignItems: \"center\", fontWeight: 800 }}>\n <ArtIcon id=\"ticket\" size={18} look={look} /> {`${st(\"customers\")}: ${level.orders.length}`}\n </span>\n {obstaclesOf(def.board).map((id) => (\n <span key={id} style={{ display: \"inline-flex\", gap: 4, alignItems: \"center\", fontWeight: 800 }}>\n <ArtIcon id={MG_OBSTACLES[id]!.art[0]!} size={18} look={look} /> {stAny(`o.${id}`)}\n </span>\n ))}\n </div>\n <div style={{ fontSize: 12, opacity: 0.9, display: \"flex\", gap: 10, flexWrap: \"wrap\", justifyContent: \"center\" }}>\n <span>{`${st(\"starsAll\")}: ★ ${total}`}</span>\n {gate > total && <span style={{ color: v.gold, fontWeight: 800 }}>{`${st(\"world\")} ${level.world + 1}: ${st(\"needStars\")} ★ ${gate}`}</span>}\n {!cleared && (\n <span style={{ display: \"inline-flex\", gap: 4, alignItems: \"center\" }}>\n {st(\"firstClear\")}: <Icon glyph=\"coin\" size={13} /> {level.firstClearGold}\n </span>\n )}\n </div>\n </>\n );\n const heroInfo = (m: MetaHeroDef) => {\n const h = MG_HEROES.find((x) => x.id === m.id) ?? MG_HEROES[0]!;\n const sk = mgSkillOf(h.skill);\n return (\n <div style={{ fontSize: 13, fontWeight: 800, minHeight: 34, display: \"flex\", gap: 4, alignItems: \"center\", justifyContent: \"center\" }}>\n {sk && <ArtIcon id={sk.art} size={18} look={look} />}\n {stAny(`sk.${h.skill}`)}\n </div>\n );\n };\n return <GameLobby meta={meta} look={look} language={language} label=\"levelN\" card={card} heroInfo={heroInfo} />;\n}\n"
73
+ },
74
+ {
75
+ "path": "meta/ArtIcon.tsx",
76
+ "content": "import { useMemo, type ReactNode } from \"react\";\nimport { artDocument, type CastDef } from \"@idosgames/game-kit\";\n\n/** How the kit's art is drawn here: the pack's style and the palette of the game. */\nexport interface ArtLookProps {\n readonly style: string;\n readonly palette: Readonly<Record<string, string>>;\n /** Who plays the roles (the pack's cast): portraits of the heroes. */\n readonly cast?: Readonly<Record<string, CastDef>>;\n}\n\n/** A picture of the kit's art (the same as on the field) as an image. */\nexport function ArtIcon({ id, color, size, look }: { id: string; color?: string; size: number; look: ArtLookProps }): ReactNode {\n const src = useMemo(() => {\n const svg = artDocument(id, { style: look.style, palette: look.palette, ...(color ? { color } : {}), box: { w: size, h: size }, px: { w: size * 2, h: size * 2 } });\n return svg ? `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}` : \"\";\n }, [id, color, size, look.style, look.palette]);\n if (!src) return null;\n return <img src={src} width={size} height={size} alt=\"\" draggable={false} style={{ display: \"block\", flex: \"none\" }} />;\n}\n"
77
+ },
78
+ {
79
+ "path": "meta/details.ts",
80
+ "content": "import { shortCash } from \"@idosgames/game-kit\";\nimport { st } from \"./i18n\";\n\nconst clockOf = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, \"0\")}`;\n\n/** The lines a genre's result shows under the score (what the round counted). */\nexport function resultDetails(presetId: string, stats: Readonly<Record<string, unknown>>): Array<{ label: string; value: string }> {\n if (presetId === \"survivors\")\n return [\n { label: st(\"kills\"), value: String(stats[\"kills\"] ?? 0) },\n { label: st(\"time\"), value: clockOf(Number(stats[\"seconds\"] ?? 0)) },\n { label: st(\"bosses\"), value: String(stats[\"bosses\"] ?? 0) },\n ];\n if (presetId === \"platformer\") {\n const stars = Math.max(0, Math.min(3, Number(stats[\"stars\"] ?? 0)));\n return [\n { label: st(\"stars\"), value: \"★\".repeat(stars) + \"☆\".repeat(3 - stars) },\n { label: st(\"coinsTaken\"), value: String(stats[\"coins\"] ?? 0) },\n { label: st(\"time\"), value: clockOf(Number(stats[\"seconds\"] ?? 0)) },\n ];\n }\n if (presetId === \"match3\") {\n const stars = Math.max(0, Math.min(3, Number(stats[\"stars\"] ?? 0)));\n return [\n { label: st(\"stars\"), value: \"★\".repeat(stars) + \"☆\".repeat(3 - stars) },\n ...(stats[\"battle\"] ? [{ label: st(\"kills\"), value: String(stats[\"kills\"] ?? 0) }] : []),\n { label: st(\"time\"), value: clockOf(Number(stats[\"seconds\"] ?? 0)) },\n ];\n }\n if (presetId === \"merge\") {\n const stars = Math.max(0, Math.min(3, Number(stats[\"stars\"] ?? 0)));\n return [\n { label: st(\"stars\"), value: \"★\".repeat(stars) + \"☆\".repeat(3 - stars) },\n { label: st(\"orders\"), value: String(stats[\"orders\"] ?? 0) },\n { label: st(\"merges\"), value: String(stats[\"merges\"] ?? 0) },\n ];\n }\n if (presetId === \"tycoon\") {\n const stars = Math.max(0, Math.min(3, Number(stats[\"stars\"] ?? 0)));\n return [\n { label: st(\"stars\"), value: \"★\".repeat(stars) + \"☆\".repeat(3 - stars) },\n { label: st(\"earned\"), value: shortCash(Number(stats[\"earned\"] ?? 0)) },\n { label: st(\"managersN\"), value: String(stats[\"managers\"] ?? 0) },\n ];\n }\n if (presetId === \"tower-defense\") {\n const stars = Math.max(0, Math.min(3, Number(stats[\"stars\"] ?? 0)));\n return [\n { label: st(\"stars\"), value: \"★\".repeat(stars) + \"☆\".repeat(3 - stars) },\n { label: st(\"livesLeft\"), value: String(stats[\"lives\"] ?? 0) },\n { label: st(\"kills\"), value: String(stats[\"kills\"] ?? 0) },\n ];\n }\n if (presetId === \"runner\") {\n const stars = Math.max(0, Math.min(3, Number(stats[\"stars\"] ?? 0)));\n return [\n { label: st(\"stars\"), value: \"★\".repeat(stars) + \"☆\".repeat(3 - stars) },\n { label: st(\"meters\"), value: `${Number(stats[\"meters\"] ?? 0)} ${st(\"m\")}` },\n { label: st(\"coinsTaken\"), value: String(stats[\"coins\"] ?? 0) },\n ];\n }\n return [];\n}\n"
81
+ },
82
+ {
83
+ "path": "meta/HeroPortrait.tsx",
84
+ "content": "import { useEffect, useRef, type ReactNode } from \"react\";\nimport { CharacterCanvas, createCatalog, heroRig, type ArtCatalog } from \"@idosgames/characters/rig2d\";\nimport { castLook } from \"@idosgames/game-kit/phaser\";\nimport type { CastDef } from \"@idosgames/game-kit\";\n\nlet catalog: ArtCatalog | null = null;\n\n/** A hero of the pack's cast as the field draws it: one still frame on a 2D canvas (many fit on a page). */\nexport function HeroPortrait({ cast, def, size }: { cast: string; def: CastDef | undefined; size: number }): ReactNode {\n const host = useRef<HTMLDivElement>(null);\n useEffect(() => {\n if (!host.current) return;\n const view = new CharacterCanvas(host.current, { rig: heroRig, catalog: (catalog ??= createCatalog()), look: castLook(cast, def, 0), mode: \"still\" });\n return () => view.destroy();\n }, [cast, def]);\n return <div ref={host} style={{ position: \"relative\", width: size * 0.7, height: size, flex: \"none\" }} />;\n}\n"
85
+ },
86
+ {
87
+ "path": "meta/i18n.ts",
88
+ "content": "import { makeT } from \"@idosgames/react/ui\";\n\n// The words of the constructor's games: the survivors' weapons and passives, the runner's power-ups, the\n// meta's screens. The UI kit speaks English and Russian;\n// the game's own names (title, coins) come from the spec's texts.\n\nconst EN = {\n \"w.blaster\": \"Blaster\",\n \"w.blaster.d\": \"Shoots the nearest foe.\",\n \"w.blaster.5\": \"Three piercing shots.\",\n \"w.blades\": \"Blades\",\n \"w.blades.d\": \"Blades spin around you.\",\n \"w.blades.5\": \"Five big blades.\",\n \"w.aura\": \"Aura\",\n \"w.aura.d\": \"Hurts every foe close to you.\",\n \"w.aura.5\": \"A wide golden aura.\",\n \"w.boomerang\": \"Boomerang\",\n \"w.boomerang.d\": \"Flies out and comes back.\",\n \"w.boomerang.5\": \"Three big boomerangs.\",\n \"w.lightning\": \"Lightning\",\n \"w.lightning.d\": \"Jumps from foe to foe.\",\n \"w.lightning.5\": \"A chain of seven.\",\n \"w.drone\": \"Drone\",\n \"w.drone.d\": \"Flies with you and shoots.\",\n \"w.drone.5\": \"Three drones, piercing shots.\",\n \"p.might\": \"Might\",\n \"p.might.d\": \"+12% damage\",\n \"p.haste\": \"Haste\",\n \"p.haste.d\": \"-7% reload\",\n \"p.area\": \"Area\",\n \"p.area.d\": \"+10% reach\",\n \"p.swift\": \"Swift\",\n \"p.swift.d\": \"+8% speed\",\n \"p.vigor\": \"Vigor\",\n \"p.vigor.d\": \"+20 health, healing\",\n \"p.armor\": \"Armor\",\n \"p.armor.d\": \"-1.5 damage taken\",\n \"p.magnet\": \"Magnet\",\n \"p.magnet.d\": \"+30% pickup range\",\n \"p.luck\": \"Luck\",\n \"p.luck.d\": \"+20% coins and finds\",\n \"p.growth\": \"Growth\",\n \"p.growth.d\": \"+10% experience\",\n \"heal\": \"Heal\",\n \"heal.d\": \"Restore 30% health\",\n \"gold\": \"Gold\",\n \"gold.d\": \"+10 coins\",\n new: \"NEW\",\n level: \"Lv\",\n strong: \"STRONG FORM\",\n levelUp: \"Level up!\",\n chest: \"Chest!\",\n pick: \"Choose one\",\n reroll: \"Other cards\",\n revive: \"Revive?\",\n reviveText: \"Get back up with full health. Once a run.\",\n giveUp: \"Give up\",\n forAd: \"Watch\",\n notEnough: \"Not enough gems\",\n chapters: \"Chapters\",\n upgrades: \"Upgrades\",\n heroes: \"Heroes\",\n chapter: \"Chapter\",\n locked: \"Locked\",\n cleared: \"Cleared\",\n bestTime: \"Best\",\n firstClear: \"First clear\",\n minutes: \"min\",\n maxed: \"MAX\",\n unlock: \"Unlock\",\n selected: \"Selected\",\n select: \"Select\",\n startsWith: \"Starts with\",\n play: \"Play\",\n gemsForAd: \"Gems for an ad\",\n kills: \"Kills\",\n time: \"Time\",\n bosses: \"Bosses\",\n newChapter: \"New chapter open!\",\n \"s.Might\": \"Damage\",\n \"s.Vitality\": \"Health\",\n \"s.Armor\": \"Armor\",\n \"s.Swiftness\": \"Speed\",\n \"s.Magnet\": \"Magnet\",\n \"s.Luck\": \"Luck\",\n \"s.Growth\": \"Experience\",\n \"s.Recovery\": \"Recovery\",\n levelN: \"Level\",\n world: \"World\",\n boss: \"Boss\",\n starsAll: \"All stars\",\n needStars: \"Opens at\",\n nextAbility: \"Next\",\n \"a.double\": \"Double jump\",\n \"a.dash\": \"Dash\",\n \"a.wall\": \"Wall jump\",\n \"perk.jump\": \"Jumps 15% higher\",\n \"s.Hearts\": \"Hearts\",\n \"s.JumpPower\": \"Jump\",\n \"s.Grace\": \"Invulnerability\",\n \"k.wall\": \"Walls: take another lane\",\n \"k.barrier\": \"Barriers: jump them\",\n \"k.pit\": \"Pits: jump over\",\n \"k.mover\": \"Runners: they switch lanes\",\n \"k.train\": \"Wagons: up the ramp, run on the roof\",\n \"k.rock\": \"Rocks: watch the shadows\",\n sec: \"s\",\n levels: \"Levels\",\n stars: \"Stars\",\n meters: \"Distance\",\n m: \"m\",\n coinsTaken: \"Coins\",\n perk: \"Bonus\",\n \"perk.none\": \"No bonus\",\n \"perk.heart\": \"+1 heart\",\n \"perk.power\": \"Power-ups last 25% longer\",\n reviveRun: \"Run on with a shield. Once a level.\",\n reviveLevel: \"Back to the checkpoint with two hearts. Once a level.\",\n \"s.MagnetTime\": \"Magnet\",\n \"s.ShieldTime\": \"Shield\",\n \"s.DoubleTime\": \"Double coins\",\n \"s.DashTime\": \"Dash\",\n \"s.FlightTime\": \"Flight\",\n \"s.HeadStart\": \"Head start\",\n \"s.Greed\": \"Coin bonus\",\n \"s.StartGold\": \"Start gold\",\n \"s.Lives\": \"Lives\",\n \"s.Archer\": \"Archers\",\n \"s.Cannon\": \"Cannons\",\n \"s.Mage\": \"Mages\",\n \"s.Frost\": \"Frost\",\n \"s.Spells\": \"Spells\",\n \"perk.gold\": \"25% more gold at the start\",\n \"perk.spells\": \"Spells come back 30% sooner\",\n reviveTd: \"Five more lives, the foes stop a moment. Once a level.\",\n newHere: \"New here\",\n branchesOpen: \"Tower branches open\",\n livesLeft: \"Lives kept\",\n \"t.archer\": \"Archers\",\n \"t.cannon\": \"Cannon\",\n \"t.mage\": \"Mage\",\n \"t.frost\": \"Frost\",\n \"sp.meteor\": \"Meteors\",\n \"sp.reinforce\": \"Reinforcements\",\n \"e.runner\": \"Runners: fast\",\n \"e.brute\": \"Brutes: armour (mages)\",\n \"e.flyer\": \"Flyers: cannons miss them\",\n \"e.shaman\": \"Shamans: resist magic\",\n \"e.healer\": \"Healers: heal the foes around\",\n \"e.splitter\": \"Splitters: fall apart\",\n \"s.Attack\": \"Attack\",\n \"s.Health\": \"Health\",\n \"s.Defense\": \"Defense\",\n \"s.SkillPower\": \"Skill power\",\n \"s.ManaGain\": \"Mana\",\n \"s.Moves\": \"Moves\",\n reviveM3: \"Five more moves, or half the hero's health in a battle. Once a level.\",\n battle: \"Battle\",\n goals: \"Goals\",\n movesN: \"moves\",\n element: \"Element\",\n skill: \"Skill\",\n \"el.fire\": \"Fire\",\n \"el.water\": \"Water\",\n \"el.nature\": \"Nature\",\n \"el.light\": \"Light\",\n \"el.dark\": \"Dark\",\n \"sk.blast\": \"Blast: clears an area\",\n \"sk.cross\": \"Cross: clears a row and a column\",\n \"sk.storm\": \"Storm: lines fire across the board\",\n \"g.ice\": \"Ice\",\n \"g.slime\": \"Slime\",\n \"g.chain\": \"Chains\",\n \"g.stone\": \"Stones\",\n \"g.cage\": \"Cages\",\n \"g.drops\": \"Crates down\",\n \"m.goblin\": \"Goblin\",\n \"m.wolf\": \"Wolf\",\n \"m.orc\": \"Orc\",\n \"m.frost-imp\": \"Frost imp: ices the board\",\n \"m.spider\": \"Spider: chains pieces\",\n \"m.golem\": \"Golem: throws stones\",\n \"m.slime-beast\": \"Slime beast: slime\",\n \"m.shaman\": \"Shaman: heals itself\",\n \"m.troll\": \"Troll\",\n \"m.frost-queen\": \"Frost Queen\",\n \"m.spider-queen\": \"Spider Queen\",\n \"m.lich\": \"Lich\",\n \"s.Energy\": \"Energy\",\n \"s.GenLuck\": \"Generator luck\",\n \"s.Reward\": \"Order reward\",\n \"s.Charge\": \"Skill charge\",\n reviveMg: \"Fifteen more energy and a joker on the board. Once a level.\",\n vip: \"VIP\",\n customers: \"Customers\",\n energyN: \"energy\",\n \"sk.upgrade\": \"Upgrade: items a level up\",\n \"sk.free\": \"Free taps for the generators\",\n \"sk.joker\": \"Jokers on the board\",\n \"ch.garden\": \"Garden\",\n \"ch.bake\": \"Bakery\",\n \"ch.jewel\": \"Jewels\",\n \"ch.potion\": \"Potions\",\n \"o.box\": \"Boxes\",\n \"o.fog\": \"Fog\",\n \"o.web\": \"Webs\",\n \"o.bubble\": \"Bubbles\",\n orders: \"Orders\",\n merges: \"Merges\",\n \"s.StartCash\": \"Start cash\",\n \"s.CycleSpeed\": \"Business speed\",\n \"s.Income\": \"Income\",\n \"s.Discount\": \"Discount\",\n reviveTy: \"Thirty more seconds of the day. Once a city.\",\n cityN: \"City\",\n bigCity: \"Big city\",\n day: \"Day\",\n earned: \"Earned\",\n managersN: \"Managers\",\n \"g.cash\": \"Earn\",\n \"g.level\": \"to level\",\n \"g.managers\": \"Managers\",\n \"b.lemon\": \"Lemonade\",\n \"b.bakery\": \"Bakery\",\n \"b.cafe\": \"Cafe\",\n \"b.workshop\": \"Workshop\",\n \"b.cinema\": \"Cinema\",\n \"b.hotel\": \"Hotel\",\n \"b.bank\": \"Bank\",\n \"b.port\": \"Spaceport\",\n \"ev.rush\": \"Rushes\",\n \"ev.break\": \"Breakdowns\",\n \"ev.invest\": \"Investors\",\n \"ev.courier\": \"Couriers\",\n \"sk.rush\": \"Rush hour: everything pays ×5\",\n \"sk.instant\": \"Every business pays at once\",\n \"sk.sale\": \"Sale: prices halved\",\n} as const;\n\nexport type SurvivorsKey = keyof typeof EN;\n\nexport const st = makeT<SurvivorsKey>(EN, {\n \"w.blaster\": \"Бластер\",\n \"w.blaster.d\": \"Стреляет в ближайшего врага.\",\n \"w.blaster.5\": \"Три пробивающих выстрела.\",\n \"w.blades\": \"Клинки\",\n \"w.blades.d\": \"Клинки кружат вокруг героя.\",\n \"w.blades.5\": \"Пять больших клинков.\",\n \"w.aura\": \"Аура\",\n \"w.aura.d\": \"Ранит всех врагов рядом.\",\n \"w.aura.5\": \"Широкая золотая аура.\",\n \"w.boomerang\": \"Бумеранг\",\n \"w.boomerang.d\": \"Летит и возвращается.\",\n \"w.boomerang.5\": \"Три больших бумеранга.\",\n \"w.lightning\": \"Молния\",\n \"w.lightning.d\": \"Перескакивает с врага на врага.\",\n \"w.lightning.5\": \"Цепь из семи врагов.\",\n \"w.drone\": \"Дрон\",\n \"w.drone.d\": \"Летает рядом и стреляет.\",\n \"w.drone.5\": \"Три дрона, пробивающие выстрелы.\",\n \"p.might\": \"Сила\",\n \"p.might.d\": \"+12% урона\",\n \"p.haste\": \"Спешка\",\n \"p.haste.d\": \"-7% перезарядки\",\n \"p.area\": \"Размах\",\n \"p.area.d\": \"+10% дальности\",\n \"p.swift\": \"Ловкость\",\n \"p.swift.d\": \"+8% скорости\",\n \"p.vigor\": \"Здоровье\",\n \"p.vigor.d\": \"+20 здоровья, лечение\",\n \"p.armor\": \"Броня\",\n \"p.armor.d\": \"-1.5 получаемого урона\",\n \"p.magnet\": \"Магнит\",\n \"p.magnet.d\": \"+30% радиуса сбора\",\n \"p.luck\": \"Удача\",\n \"p.luck.d\": \"+20% монет и находок\",\n \"p.growth\": \"Рост\",\n \"p.growth.d\": \"+10% опыта\",\n heal: \"Лечение\",\n \"heal.d\": \"Вернуть 30% здоровья\",\n gold: \"Золото\",\n \"gold.d\": \"+10 монет\",\n new: \"НОВОЕ\",\n level: \"Ур.\",\n strong: \"СИЛЬНАЯ ФОРМА\",\n levelUp: \"Новый уровень!\",\n chest: \"Сундук!\",\n pick: \"Выберите одно\",\n reroll: \"Другие карты\",\n revive: \"Воскреснуть?\",\n reviveText: \"Встать с полным здоровьем. Один раз за забег.\",\n giveUp: \"Сдаться\",\n forAd: \"Смотреть\",\n notEnough: \"Не хватает кристаллов\",\n chapters: \"Главы\",\n upgrades: \"Прокачка\",\n heroes: \"Герои\",\n chapter: \"Глава\",\n locked: \"Закрыта\",\n cleared: \"Пройдена\",\n bestTime: \"Рекорд\",\n firstClear: \"За первое прохождение\",\n minutes: \"мин\",\n maxed: \"МАКС\",\n unlock: \"Открыть\",\n selected: \"Выбран\",\n select: \"Выбрать\",\n startsWith: \"Начинает с\",\n play: \"Играть\",\n gemsForAd: \"Кристаллы за рекламу\",\n kills: \"Убито\",\n time: \"Время\",\n bosses: \"Боссы\",\n newChapter: \"Открыта новая глава!\",\n \"s.Might\": \"Урон\",\n \"s.Vitality\": \"Здоровье\",\n \"s.Armor\": \"Броня\",\n \"s.Swiftness\": \"Скорость\",\n \"s.Magnet\": \"Магнит\",\n \"s.Luck\": \"Удача\",\n \"s.Growth\": \"Опыт\",\n \"s.Recovery\": \"Восстановление\",\n levelN: \"Уровень\",\n world: \"Мир\",\n boss: \"Босс\",\n starsAll: \"Всего звёзд\",\n needStars: \"Откроется за\",\n nextAbility: \"Дальше\",\n \"a.double\": \"Двойной прыжок\",\n \"a.dash\": \"Рывок\",\n \"a.wall\": \"Прыжок от стены\",\n \"perk.jump\": \"Прыжок выше на 15%\",\n \"s.Hearts\": \"Сердца\",\n \"s.JumpPower\": \"Прыжок\",\n \"s.Grace\": \"Неуязвимость\",\n \"k.wall\": \"Стены: уходи на другую полосу\",\n \"k.barrier\": \"Барьеры: перепрыгивай\",\n \"k.pit\": \"Ямы: прыгай через них\",\n \"k.mover\": \"Бегуны: меняют полосу\",\n \"k.train\": \"Вагоны: по рампе на крышу\",\n \"k.rock\": \"Камни: смотри на тени\",\n sec: \"с\",\n levels: \"Уровни\",\n stars: \"Звёзды\",\n meters: \"Дистанция\",\n m: \"м\",\n coinsTaken: \"Монеты\",\n perk: \"Бонус\",\n \"perk.none\": \"Без бонуса\",\n \"perk.heart\": \"+1 сердце\",\n \"perk.power\": \"Бонусы дольше на 25%\",\n reviveRun: \"Бежать дальше со щитом. Один раз за уровень.\",\n reviveLevel: \"Вернуться на флажок с двумя сердцами. Один раз за уровень.\",\n \"s.MagnetTime\": \"Магнит\",\n \"s.ShieldTime\": \"Щит\",\n \"s.DoubleTime\": \"Двойные монеты\",\n \"s.DashTime\": \"Рывок\",\n \"s.FlightTime\": \"Полёт\",\n \"s.HeadStart\": \"Рывок на старте\",\n \"s.Greed\": \"Бонус монет\",\n \"s.StartGold\": \"Золото на старте\",\n \"s.Lives\": \"Жизни\",\n \"s.Archer\": \"Лучники\",\n \"s.Cannon\": \"Пушки\",\n \"s.Mage\": \"Маги\",\n \"s.Frost\": \"Мороз\",\n \"s.Spells\": \"Заклинания\",\n \"perk.gold\": \"На 25% больше золота на старте\",\n \"perk.spells\": \"Заклинания восстанавливаются на 30% быстрее\",\n reviveTd: \"Ещё пять жизней, враги замрут на миг. Один раз за уровень.\",\n newHere: \"Новое\",\n branchesOpen: \"Открыты ветки башен\",\n livesLeft: \"Жизней осталось\",\n \"t.archer\": \"Лучники\",\n \"t.cannon\": \"Пушка\",\n \"t.mage\": \"Маг\",\n \"t.frost\": \"Мороз\",\n \"sp.meteor\": \"Метеоры\",\n \"sp.reinforce\": \"Подкрепление\",\n \"e.runner\": \"Бегуны: быстрые\",\n \"e.brute\": \"Громилы: броня (против них маги)\",\n \"e.flyer\": \"Летуны: пушки не достают\",\n \"e.shaman\": \"Шаманы: защита от магии\",\n \"e.healer\": \"Лекари: лечат соседей\",\n \"e.splitter\": \"Делители: распадаются на мелких\",\n \"s.Attack\": \"Атака\",\n \"s.Health\": \"Здоровье\",\n \"s.Defense\": \"Защита\",\n \"s.SkillPower\": \"Сила умения\",\n \"s.ManaGain\": \"Мана\",\n \"s.Moves\": \"Ходы\",\n reviveM3: \"Ещё пять ходов, а в бою половина здоровья героя. Один раз за уровень.\",\n battle: \"Бой\",\n goals: \"Цели\",\n movesN: \"ходов\",\n element: \"Стихия\",\n skill: \"Умение\",\n \"el.fire\": \"Огонь\",\n \"el.water\": \"Вода\",\n \"el.nature\": \"Природа\",\n \"el.light\": \"Свет\",\n \"el.dark\": \"Тьма\",\n \"sk.blast\": \"Взрыв: очищает область\",\n \"sk.cross\": \"Крест: очищает ряд и столбец\",\n \"sk.storm\": \"Буря: линии бьют по полю\",\n \"g.ice\": \"Лёд\",\n \"g.slime\": \"Слизь\",\n \"g.chain\": \"Цепи\",\n \"g.stone\": \"Камни\",\n \"g.cage\": \"Клетки\",\n \"g.drops\": \"Ящики вниз\",\n \"m.goblin\": \"Гоблин\",\n \"m.wolf\": \"Волк\",\n \"m.orc\": \"Орк\",\n \"m.frost-imp\": \"Ледяной бес: морозит поле\",\n \"m.spider\": \"Паук: опутывает цепями\",\n \"m.golem\": \"Голем: кидает камни\",\n \"m.slime-beast\": \"Слизень: слизь\",\n \"m.shaman\": \"Шаман: лечит себя\",\n \"m.troll\": \"Тролль\",\n \"m.frost-queen\": \"Снежная королева\",\n \"m.spider-queen\": \"Королева пауков\",\n \"m.lich\": \"Лич\",\n \"s.Energy\": \"Энергия\",\n \"s.GenLuck\": \"Удача генераторов\",\n \"s.Reward\": \"Награда за заказ\",\n \"s.Charge\": \"Заряд умения\",\n reviveMg: \"Ещё 15 энергии и джокер на поле. Один раз за уровень.\",\n vip: \"Важный гость\",\n customers: \"Заказчики\",\n energyN: \"энергии\",\n \"sk.upgrade\": \"Улучшение: предметы на уровень выше\",\n \"sk.free\": \"Генераторы даром\",\n \"sk.joker\": \"Джокеры на поле\",\n \"ch.garden\": \"Сад\",\n \"ch.bake\": \"Пекарня\",\n \"ch.jewel\": \"Самоцветы\",\n \"ch.potion\": \"Зелья\",\n \"o.box\": \"Ящики\",\n \"o.fog\": \"Туман\",\n \"o.web\": \"Паутина\",\n \"o.bubble\": \"Пузыри\",\n orders: \"Заказы\",\n merges: \"Слияния\",\n \"s.StartCash\": \"Деньги на старте\",\n \"s.CycleSpeed\": \"Скорость бизнесов\",\n \"s.Income\": \"Доход\",\n \"s.Discount\": \"Скидка\",\n reviveTy: \"Ещё 30 секунд дня. Один раз за город.\",\n cityN: \"Город\",\n bigCity: \"Большой город\",\n day: \"День\",\n earned: \"Заработано\",\n managersN: \"Менеджеры\",\n \"g.cash\": \"Заработать\",\n \"g.level\": \"до уровня\",\n \"g.managers\": \"Менеджеры\",\n \"b.lemon\": \"Лимонад\",\n \"b.bakery\": \"Пекарня\",\n \"b.cafe\": \"Кафе\",\n \"b.workshop\": \"Мастерская\",\n \"b.cinema\": \"Кинотеатр\",\n \"b.hotel\": \"Отель\",\n \"b.bank\": \"Банк\",\n \"b.port\": \"Космопорт\",\n \"ev.rush\": \"Наплыв клиентов\",\n \"ev.break\": \"Поломки\",\n \"ev.invest\": \"Инвесторы\",\n \"ev.courier\": \"Курьеры\",\n \"sk.rush\": \"Час пик: всё приносит ×5\",\n \"sk.instant\": \"Все бизнесы платят сразу\",\n \"sk.sale\": \"Распродажа: цены вдвое ниже\",\n});\n\n/** A text of the table by a key built at run time (a weapon's id): the key itself when there is none. */\nexport const stAny = (key: string): string => st(key as SurvivorsKey);\n"
89
+ },
90
+ {
91
+ "path": "meta/Lobby.tsx",
92
+ "content": "import { useState, useSyncExternalStore, type CSSProperties, type ReactNode } from \"react\";\nimport type { MetaHeroDef } from \"@idosgames/game-kit\";\nimport { Button, Icon, Popup, panel, v } from \"@idosgames/react/ui\";\nimport { st, stAny } from \"./i18n\";\nimport type { GameMeta, MetaStatView } from \"./meta\";\nimport { ArtIcon, type ArtLookProps } from \"./ArtIcon\";\nimport { HeroPortrait } from \"./HeroPortrait\";\n\nconst HERO_NAMES: Readonly<Record<string, { en: string; ru: string }>> = {\n hero: { en: \"Fighter\", ru: \"Боец\" },\n hero2: { en: \"Guardian\", ru: \"Страж\" },\n hero3: { en: \"Mystic\", ru: \"Мистик\" },\n};\n\nexport const heroName = (id: string, language: string) => {\n const n = HERO_NAMES[id];\n return n ? (language === \"ru\" ? n.ru : n.en) : id;\n};\n\nconst formatValue = (s: MetaStatView, level: number) => {\n const value = s.valueAt(level);\n const shown = Math.round(value * 10) / 10;\n return s.unit === \"%\" ? `+${shown}%` : s.unit === \"s\" ? `+${shown} ${st(\"sec\")}` : `+${shown}`;\n};\n\n/**\n * A game's home between rounds, around the big start button of the field: the level above it (arrows to the\n * open ones, what the game says of it), the upgrades and the heroes below it. The game brings the middle of the\n * level card and what a hero gives.\n */\nexport function GameLobby({\n meta,\n look,\n language,\n label,\n card,\n heroInfo,\n}: {\n meta: GameMeta;\n look: ArtLookProps;\n language: string;\n /** \"Chapter\" or \"Level\". */\n label: \"chapter\" | \"levelN\" | \"cityN\";\n /** The middle of the level card (its goal, its record, its reward). */\n card: ReactNode;\n /** What a hero brings (its start weapon, its bonus). */\n heroInfo: (h: MetaHeroDef) => ReactNode;\n}): ReactNode {\n const snap = useSyncExternalStore(meta.subscribe, meta.snapshot, meta.snapshot);\n const [sheet, setSheet] = useState<\"upgrades\" | \"heroes\" | null>(null);\n const p = snap.progress;\n\n return (\n <>\n <div style={{ ...floating, top: \"17%\" }}>\n <div style={{ ...panel, ...chapterCard }}>\n <button type=\"button\" aria-label=\"previous\" disabled={p.chapter <= 1} onClick={() => meta.selectChapter(p.chapter - 1)} style={arrow(p.chapter > 1)}>\n ‹\n </button>\n <div style={{ display: \"grid\", gap: 2, justifyItems: \"center\", minWidth: 0 }}>\n <div style={{ fontSize: 13, opacity: 0.8, fontWeight: 800 }}>{`${st(label)} ${p.chapter} / ${meta.def.levels}`}</div>\n {card}\n </div>\n <button type=\"button\" aria-label=\"next\" disabled={p.chapter >= p.unlocked} onClick={() => meta.selectChapter(p.chapter + 1)} style={arrow(p.chapter < p.unlocked)}>\n ›\n </button>\n </div>\n </div>\n\n <div style={{ ...floating, bottom: \"calc(var(--idos-safe-bottom, 0px) + 18px)\" }}>\n <div style={{ display: \"flex\", gap: 10, flexWrap: \"wrap\", justifyContent: \"center\", pointerEvents: \"auto\" }}>\n <Button tone=\"blue\" size=\"md\" onClick={() => setSheet(\"upgrades\")}>\n <span style={rowStyle}>\n <Icon glyph=\"energy\" size={20} /> {st(\"upgrades\")}\n </span>\n </Button>\n <Button tone=\"purple\" size=\"md\" onClick={() => setSheet(\"heroes\")}>\n <span style={rowStyle}>\n <Icon glyph=\"hero\" size={20} /> {st(\"heroes\")}\n </span>\n </Button>\n {snap.adAvailable && (\n <Button tone=\"orange\" size=\"md\" busy={snap.busy === \"gems:ad\"} onClick={() => void meta.gemsForAd()}>\n <span style={rowStyle}>\n {/* gems for a rewarded ad (only where an ad can be shown: the app) */}\n <Icon glyph=\"megaphone\" size={18} /> +5 <Icon glyph=\"gem\" size={18} />\n </span>\n </Button>\n )}\n </div>\n </div>\n\n {sheet === \"upgrades\" && (\n <Popup title={st(\"upgrades\")} onClose={() => setSheet(null)} width={520}>\n <Balance glyph=\"coin\" amount={snap.soft} />\n <div style={{ display: \"grid\", gap: 8 }}>\n {meta.stats().map((s) => {\n const level = snap.stats[s.id] ?? 0;\n const max = level >= s.maxLevel;\n const cost = s.costAt(level + 1);\n return (\n <div key={s.id} style={statRow}>\n <ArtIcon id={s.art} color={s.color} size={42} look={look} />\n <div style={{ flex: \"1 1 auto\", minWidth: 0 }}>\n <div style={{ fontWeight: 900 }}>{stAny(`s.${s.id}`)}</div>\n <div style={{ fontSize: 12, opacity: 0.85 }}>\n {`${st(\"level\")} ${level}/${s.maxLevel} · ${formatValue(s, level)}`}\n {!max && <span style={{ color: v.green }}>{` → ${formatValue(s, level + 1)}`}</span>}\n </div>\n </div>\n <Button tone={max ? \"grey\" : \"green\"} size=\"sm\" disabled={max || snap.soft < cost} busy={snap.busy === `stat:${s.id}`} onClick={() => void meta.upgradeStat(s.id)}>\n {max ? (\n st(\"maxed\")\n ) : (\n <span style={rowStyle}>\n <Icon glyph=\"coin\" size={16} /> {cost}\n </span>\n )}\n </Button>\n </div>\n );\n })}\n </div>\n {snap.error && <div style={{ color: v.red, fontSize: 13, marginTop: 8 }}>{snap.error}</div>}\n </Popup>\n )}\n\n {sheet === \"heroes\" && (\n <Popup title={st(\"heroes\")} onClose={() => setSheet(null)} width={560}>\n <Balance glyph=\"gem\" amount={snap.hard} />\n <div style={{ display: \"grid\", gridTemplateColumns: \"repeat(auto-fit, minmax(150px, 1fr))\", gap: 10 }}>\n {meta.def.heroes.map((h) => {\n const owned = snap.heroes.includes(h.id);\n const chosen = snap.progress.hero === h.id && owned;\n return (\n <div key={h.id} style={{ ...statRow, flexDirection: \"column\", alignItems: \"center\", textAlign: \"center\", gap: 6, ...(chosen ? { boxShadow: `0 0 0 3px ${v.gold}` } : {}) }}>\n <HeroPortrait cast={h.cast} def={look.cast?.[h.cast]} size={150} />\n <div style={{ fontWeight: 900, fontSize: 17 }}>{heroName(h.id, language)}</div>\n {heroInfo(h)}\n {owned ? (\n <Button tone={chosen ? \"gold\" : \"green\"} size=\"sm\" disabled={chosen} onClick={() => meta.selectHero(h.id)}>\n {chosen ? st(\"selected\") : st(\"select\")}\n </Button>\n ) : (\n <Button tone=\"purple\" size=\"sm\" disabled={snap.hard < h.gems} busy={snap.busy === `hero:${h.id}`} onClick={() => void meta.unlockHero(h.id)}>\n <span style={rowStyle}>\n <Icon glyph=\"gem\" size={16} /> {h.gems}\n </span>\n </Button>\n )}\n </div>\n );\n })}\n </div>\n {snap.error && <div style={{ color: v.red, fontSize: 13, marginTop: 8 }}>{snap.error}</div>}\n </Popup>\n )}\n </>\n );\n}\n\nfunction Balance({ glyph, amount }: { glyph: \"coin\" | \"gem\"; amount: number }): ReactNode {\n return (\n <div style={{ display: \"flex\", justifyContent: \"flex-end\", marginBottom: 8 }}>\n <span style={{ ...rowStyle, background: \"rgba(0,0,0,0.3)\", borderRadius: 10, padding: \"4px 10px\", fontWeight: 900 }}>\n <Icon glyph={glyph} size={18} /> {amount}\n </span>\n </div>\n );\n}\n\nconst floating: CSSProperties = {\n position: \"absolute\",\n left: 12,\n right: 12,\n display: \"flex\",\n flexDirection: \"column\",\n alignItems: \"center\",\n pointerEvents: \"none\",\n zIndex: 2,\n};\n\nconst chapterCard: CSSProperties = {\n pointerEvents: \"auto\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n padding: \"10px 12px\",\n color: \"#fff\",\n width: \"min(360px, 100%)\",\n justifyContent: \"space-between\",\n};\n\nconst arrow = (on: boolean): CSSProperties => ({\n width: 40,\n height: 40,\n borderRadius: 12,\n border: \"none\",\n background: on ? \"rgba(255,255,255,0.18)\" : \"rgba(255,255,255,0.05)\",\n color: on ? \"#fff\" : \"rgba(255,255,255,0.3)\",\n fontSize: 28,\n lineHeight: \"36px\",\n fontWeight: 900,\n cursor: on ? \"pointer\" : \"default\",\n flex: \"none\",\n});\n\nexport const statRow: CSSProperties = {\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n padding: 10,\n borderRadius: 12,\n background: \"rgba(0,0,0,0.22)\",\n};\n\nexport const rowStyle: CSSProperties = { display: \"inline-flex\", alignItems: \"center\", gap: 6 };\n"
93
+ },
94
+ {
95
+ "path": "meta/meta.ts",
96
+ "content": "// The meta of a game between its rounds, the same for every genre (owner, 07–08.11.2026). Three currencies:\r\n// hard (gems, GE) — a rewarded ad (only in the app) or a purchase for Aspis; spent on a revive, a reroll of\r\n// cards, a hero; always through the server (store offers and spends of the title);\r\n// soft (coins, CO) — the reward of a round (the reward window, x3 / x5); spent on the stats of the meta (the\r\n// server's Character system: the price, its growth, the value of a level);\r\n// round points — what lives in the round only (experience, coins on the field).\r\n// Levels (chapters), records, stars, the chosen hero and the learned hints are the player's progress\r\n// (UserCustomData, Private, the key is the game's Character id; a free key is allowed on every title).\r\n//\r\n// A genre brings its `MetaGameDef` (game-kit); one interface, two homes: `ServerGameMeta` (a title: the client)\r\n// and `LocalGameMeta` (the constructor's test page: demo balances in the page's memory).\r\n\r\nimport type { IDosGamesClient } from \"@idosgames/core\";\r\nimport { curveMultiplier, evaluateCurve, roundAmount, type ScalarCurveSpec } from \"@idosgames/core\";\r\nimport { configSection, payWithCredits } from \"@idosgames/react/ui\";\r\nimport { heroCharacterOf, REROLL_GEMS, REVIVE_GEMS, type MetaGameDef, type MetaStatDef, type World } from \"@idosgames/game-kit\";\r\n\r\n/** Store offers of the title (the seed of kit-game): gems for an ad, a pack of gems for Aspis. */\r\nexport const OFFERS = { gemsAd: \"kit_gems_ad\", gemsPack: \"kit_gems_pack\" } as const;\r\n/** Spends of a round (`Currency.Spends` of the seed): the server only charges the gems, the round does the action. */\r\nexport const SPENDS = { revive: \"kit_revive\", reroll: \"kit_reroll\" } as const;\r\n/**\r\n * Quest metrics every game reports (the seed's daily quests; `games_played` is the base's). The same for every\r\n * genre: the seed is one for all the games of the constructor, so a quest has to be doable in any of them.\r\n */\r\nexport const METRICS = { played: \"games_played\", coins: \"kit_coins\", seconds: \"kit_seconds\", levels: \"kit_levels\" } as const;\r\n/** Rewarded placements of the title (shown only in the app). */\r\nexport const PLACEMENTS = { revive: \"revive\", reroll: \"reroll\", gems: \"gems\" } as const;\r\n\r\n/** What a round counted (the block's stats of `round-over`, plus the coins it earned). */\r\nexport interface RunStats {\r\n readonly chapter: number;\r\n readonly seconds: number;\r\n readonly cleared: boolean;\r\n /** Stars of the level (a game that has them). */\r\n readonly stars?: number;\r\n /** Soft coins the round earned. */\r\n readonly coins?: number;\r\n}\r\n\r\ninterface QuestSectionView {\r\n Quests?: Record<string, { Objectives?: Record<string, { MetricID?: string | null; Source?: string | null; MaxProgressPerCall?: number | null } | null> | null } | null>;\r\n}\r\n\r\nexport interface Progress {\r\n /** The highest level open. */\r\n unlocked: number;\r\n /** The level the next round plays. */\r\n chapter: number;\r\n hero: string;\r\n /** Level → the best result (survivors: seconds survived). */\r\n best: Record<string, number>;\r\n /** Level → the most stars it gave. */\r\n stars: Record<string, number>;\r\n cleared: number[];\r\n /** Hints the player has done once (they do not come back). */\r\n learned: string[];\r\n}\r\n\r\nexport const EMPTY_PROGRESS: Progress = { unlocked: 1, chapter: 1, hero: \"hero\", best: {}, stars: {}, cleared: [], learned: [] };\r\n\r\nconst numbers = (o: unknown): Record<string, number> =>\r\n o && typeof o === \"object\" ? Object.fromEntries(Object.entries(o as Record<string, unknown>).filter(([, v]) => typeof v === \"number\")) as Record<string, number> : {};\r\n\r\nexport function parseProgress(raw: string | null | undefined, levels: number): Progress {\r\n if (!raw) return { ...EMPTY_PROGRESS };\r\n try {\r\n const p = JSON.parse(raw) as Partial<Progress>;\r\n const unlocked = Math.max(1, Math.min(levels, Math.floor(Number(p.unlocked ?? 1))));\r\n return {\r\n unlocked,\r\n chapter: Math.max(1, Math.min(unlocked, Math.floor(Number(p.chapter ?? 1)))),\r\n hero: typeof p.hero === \"string\" ? p.hero : \"hero\",\r\n best: numbers(p.best),\r\n stars: numbers(p.stars),\r\n cleared: Array.isArray(p.cleared) ? p.cleared.filter((c) => typeof c === \"number\") : [],\r\n learned: Array.isArray(p.learned) ? p.learned.filter((c) => typeof c === \"string\").slice(0, 40) : [],\r\n };\r\n } catch {\r\n return { ...EMPTY_PROGRESS };\r\n }\r\n}\r\n\r\n/** A stat as the meta screen shows it: the value at a level, the price of the next one. */\r\nexport interface MetaStatView extends MetaStatDef {\r\n valueAt(level: number): number;\r\n costAt(nextLevel: number): number;\r\n}\r\n\r\n/** The seed's numbers (and what a page without a server uses). */\r\nexport const defaultStat = (d: MetaStatDef): MetaStatView => ({\r\n ...d,\r\n valueAt: (l) => d.perLevel * l,\r\n costAt: (n) => roundAmount(d.price * Math.pow(1 + d.growth, Math.max(0, n - 1))),\r\n});\r\n\r\nexport interface MetaSnapshot {\r\n readonly soft: number;\r\n readonly hard: number;\r\n readonly softID: string;\r\n readonly hardID: string;\r\n /** Stat id → level. */\r\n readonly stats: Readonly<Record<string, number>>;\r\n /** Heroes the player has. */\r\n readonly heroes: readonly string[];\r\n readonly progress: Progress;\r\n /** A rewarded ad can be offered here (the app). */\r\n readonly adAvailable: boolean;\r\n /** A revive / a reroll can be bought for gems here (the title has the spends). */\r\n readonly gemsPay: boolean;\r\n /** Gems a revive / a reroll cost (the title's spends; the defaults on the test page). */\r\n readonly prices: { readonly revive: number; readonly reroll: number };\r\n /** What is being paid right now (a button shows a spinner), or null. */\r\n readonly busy: string | null;\r\n readonly error: string | null;\r\n}\r\n\r\nexport interface GameMeta {\r\n readonly def: MetaGameDef;\r\n subscribe(fn: () => void): () => void;\r\n snapshot(): MetaSnapshot;\r\n stats(): readonly MetaStatView[];\r\n upgradeStat(id: string): Promise<void>;\r\n unlockHero(id: string): Promise<void>;\r\n selectHero(id: string): void;\r\n selectChapter(chapter: number): void;\r\n /** Pays for a revive or a reroll with gems or an ad; true — paid. */\r\n pay(kind: \"revive\" | \"reroll\", via: \"gems\" | \"ad\"): Promise<boolean>;\r\n gemsForAd(): Promise<void>;\r\n /** A round ended: records, stars, the next level, learned hints (and the quests' metrics on a title). */\r\n roundEnded(stats: RunStats, learned: readonly string[]): void;\r\n}\r\n\r\n/** What the next round reads (meta:* vars), put into the world before its start. */\r\nexport function applyMeta(world: World, meta: GameMeta): void {\r\n const snap = meta.snapshot();\r\n for (const s of meta.stats()) {\r\n const v = s.valueAt(snap.stats[s.id] ?? 0);\r\n world.vars[s.var] = s.unit === \"%\" ? v / 100 : v;\r\n }\r\n const p = snap.progress;\r\n world.vars[\"meta:chapter\"] = p.chapter;\r\n world.vars[\"meta:hero\"] = snap.heroes.includes(p.hero) ? p.hero : \"hero\";\r\n world.vars[\"meta:first_clear\"] = !p.cleared.includes(p.chapter);\r\n world.vars[\"revive_ready\"] = true;\r\n world.vars[\"meta:stars_total\"] = starsTotal(p);\r\n for (const key of p.learned) world.vars[`learned:${key}`] = true;\r\n}\r\n\r\n/** All the stars the player has (the best of every level). */\r\nexport const starsTotal = (p: Progress): number => Object.values(p.stars).reduce((a, b) => a + b, 0);\r\n\r\n/** The hints the world has learned (vars learned:*), to keep them. */\r\nexport const learnedOf = (world: World): string[] =>\r\n Object.keys(world.vars)\r\n .filter((k) => k.startsWith(\"learned:\") && world.vars[k] === true)\r\n .map((k) => k.slice(\"learned:\".length));\r\n\r\n/**\r\n * The progress after a round. `starsToEnter` — a game whose worlds open by stars: the next level opens once it is\r\n * reached AND the player has its stars (stars only grow, so a level never closes again).\r\n */\r\nexport function progressAfter(p: Progress, stats: RunStats, learned: readonly string[], levels: number, starsToEnter?: (level: number) => number): Progress {\r\n const key = String(stats.chapter);\r\n const best = { ...p.best, [key]: Math.max(p.best[key] ?? 0, stats.seconds) };\r\n const stars = stats.stars !== undefined ? { ...p.stars, [key]: Math.max(p.stars[key] ?? 0, stats.stars) } : p.stars;\r\n const cleared = stats.cleared && !p.cleared.includes(stats.chapter) ? [...p.cleared, stats.chapter] : p.cleared;\r\n let reached = Math.min(levels, Math.max(0, ...cleared) + 1);\r\n if (starsToEnter) {\r\n const total = Object.values(stars).reduce((a, b) => a + b, 0);\r\n while (reached > 1 && starsToEnter(reached) > total) reached--;\r\n }\r\n const unlocked = Math.max(p.unlocked, reached);\r\n // a finished level moves the player on to the next one\r\n const chapter = stats.cleared && stats.chapter === p.chapter ? Math.min(unlocked, stats.chapter + 1) : p.chapter;\r\n const known = new Set([...p.learned, ...learned]);\r\n return { ...p, best, stars, cleared, unlocked, chapter, learned: [...known].slice(0, 40) };\r\n}\r\n\r\n/** The round's stats as the meta reads them (the `stats` of round-ended), with the coins the round earned. */\r\nexport function runStatsOf(stats: Readonly<Record<string, unknown>>, coins: number): RunStats {\r\n return {\r\n chapter: Number(stats[\"chapter\"] ?? 1),\r\n seconds: Number(stats[\"seconds\"] ?? 0),\r\n cleared: stats[\"cleared\"] === true,\r\n ...(typeof stats[\"stars\"] === \"number\" ? { stars: stats[\"stars\"] } : {}),\r\n coins,\r\n };\r\n}\r\n\r\nabstract class BaseMeta implements GameMeta {\r\n protected listeners = new Set<() => void>();\r\n protected snap: MetaSnapshot;\r\n\r\n constructor(\r\n readonly def: MetaGameDef,\r\n initial: MetaSnapshot,\r\n ) {\r\n this.snap = initial;\r\n }\r\n\r\n subscribe = (fn: () => void): (() => void) => {\r\n this.listeners.add(fn);\r\n return () => this.listeners.delete(fn);\r\n };\r\n snapshot = (): MetaSnapshot => this.snap;\r\n\r\n protected set(patch: Partial<MetaSnapshot>): void {\r\n this.snap = { ...this.snap, ...patch };\r\n for (const fn of this.listeners) fn();\r\n }\r\n\r\n abstract stats(): readonly MetaStatView[];\r\n abstract upgradeStat(id: string): Promise<void>;\r\n abstract unlockHero(id: string): Promise<void>;\r\n abstract pay(kind: \"revive\" | \"reroll\", via: \"gems\" | \"ad\"): Promise<boolean>;\r\n abstract gemsForAd(): Promise<void>;\r\n protected abstract saveProgress(p: Progress): void;\r\n\r\n selectHero(id: string): void {\r\n if (!this.snap.heroes.includes(id)) return;\r\n this.setProgress({ ...this.snap.progress, hero: id });\r\n }\r\n\r\n selectChapter(chapter: number): void {\r\n if (chapter < 1 || chapter > this.snap.progress.unlocked) return;\r\n this.setProgress({ ...this.snap.progress, chapter });\r\n }\r\n\r\n roundEnded(stats: RunStats, learned: readonly string[]): void {\r\n this.setProgress(progressAfter(this.snap.progress, stats, learned, this.def.levels, this.def.starsToEnter ? (n) => this.def.starsToEnter!(n) : undefined));\r\n }\r\n\r\n protected setProgress(p: Progress): void {\r\n this.set({ progress: p });\r\n this.saveProgress(p);\r\n }\r\n}\r\n\r\n// ── The constructor's test page: no server, demo balances ──────────────────────────────────────────\r\n\r\n/** Demo balances shared by every game of the test page (switching genres keeps them); the page's bar reads them. */\r\nexport const demoWallet = (() => {\r\n let value = { soft: 5000, hard: 300 };\r\n const listeners = new Set<() => void>();\r\n return {\r\n get: (): { readonly soft: number; readonly hard: number } => value,\r\n set(soft: number, hard: number): void {\r\n if (soft === value.soft && hard === value.hard) return;\r\n value = { soft, hard };\r\n for (const fn of listeners) fn();\r\n },\r\n subscribe(fn: () => void): () => void {\r\n listeners.add(fn);\r\n return () => listeners.delete(fn);\r\n },\r\n };\r\n})();\r\n\r\nexport class LocalGameMeta extends BaseMeta {\r\n private readonly defs: MetaStatView[];\r\n\r\n constructor(def: MetaGameDef) {\r\n let saved: string | null = null;\r\n try {\r\n saved = localStorage.getItem(`kit:${def.character}`);\r\n } catch {\r\n saved = null;\r\n }\r\n super(def, {\r\n soft: demoWallet.get().soft,\r\n hard: demoWallet.get().hard,\r\n softID: \"CO\",\r\n hardID: \"GE\",\r\n stats: {},\r\n heroes: [\"hero\"],\r\n progress: parseProgress(saved, def.levels),\r\n adAvailable: true,\r\n gemsPay: true,\r\n prices: { revive: REVIVE_GEMS, reroll: REROLL_GEMS },\r\n busy: null,\r\n error: null,\r\n });\r\n this.defs = def.stats.map(defaultStat);\r\n }\r\n\r\n protected override set(patch: Partial<MetaSnapshot>): void {\r\n super.set(patch);\r\n demoWallet.set(this.snap.soft, this.snap.hard);\r\n }\r\n\r\n /** The other games of the page may have spent the demo balances. */\r\n refresh(): void {\r\n const w = demoWallet.get();\r\n if (this.snap.soft !== w.soft || this.snap.hard !== w.hard) this.set({ soft: w.soft, hard: w.hard });\r\n }\r\n\r\n stats(): readonly MetaStatView[] {\r\n return this.defs;\r\n }\r\n\r\n async upgradeStat(id: string): Promise<void> {\r\n const d = this.defs.find((s) => s.id === id);\r\n if (!d) return;\r\n const level = this.snap.stats[id] ?? 0;\r\n if (level >= d.maxLevel) return;\r\n const cost = d.costAt(level + 1);\r\n if (this.snap.soft < cost) return;\r\n this.set({ soft: this.snap.soft - cost, stats: { ...this.snap.stats, [id]: level + 1 } });\r\n }\r\n\r\n async unlockHero(id: string): Promise<void> {\r\n const h = this.def.heroes.find((x) => x.id === id);\r\n if (!h || this.snap.heroes.includes(id) || this.snap.hard < h.gems) return;\r\n this.set({ hard: this.snap.hard - h.gems, heroes: [...this.snap.heroes, id] });\r\n this.selectHero(id);\r\n }\r\n\r\n async pay(kind: \"revive\" | \"reroll\", via: \"gems\" | \"ad\"): Promise<boolean> {\r\n if (via === \"ad\") return true;\r\n const price = this.snap.prices[kind];\r\n if (this.snap.hard < price) return false;\r\n this.set({ hard: this.snap.hard - price });\r\n return true;\r\n }\r\n\r\n async gemsForAd(): Promise<void> {\r\n this.set({ hard: this.snap.hard + 5 });\r\n }\r\n\r\n /** The test page's round coins go straight to the demo balance (the game claims them through the server). */\r\n addSoft(amount: number): void {\r\n this.set({ soft: this.snap.soft + amount });\r\n }\r\n\r\n protected saveProgress(p: Progress): void {\r\n try {\r\n localStorage.setItem(`kit:${this.def.character}`, JSON.stringify(p));\r\n } catch {\r\n // a private window: the progress lives for this page only\r\n }\r\n }\r\n}\r\n\r\n// ── A title: the server ────────────────────────────────────────────────────────────────────────────\r\n\r\ninterface StatConfig {\r\n StatID?: string;\r\n MaxLevel?: number | null;\r\n BaseStatValue?: number | null;\r\n ValueCurve?: ScalarCurveSpec | null;\r\n CostCurve?: ScalarCurveSpec | null;\r\n PriceOptions?: Record<string, { Cost?: { Standard?: { Entries?: Array<{ CurrencyID?: string | null; Amount?: number | null } | null> | null } | null } | null } | null> | null;\r\n}\r\n\r\ntype SpendView = { PriceOptions?: Record<string, { Cost?: { Standard?: { Entries?: ({ CurrencyID?: string | null; Amount?: number | null } | null)[] | null } | null } | null } | null> | null };\r\nconst spendsOf = (client: IDosGamesClient): Record<string, SpendView | null> | null | undefined =>\r\n configSection<{ Spends?: Record<string, SpendView | null> | null }>(client, \"Currency\")?.Spends;\r\n/** The gems of a spend's first price (the server takes the first option too: the client names no option). */\r\nfunction spendPrice(client: IDosGamesClient, id: string, fallback: number): number {\r\n const option = Object.values(spendsOf(client)?.[id]?.PriceOptions ?? {}).find((o) => o);\r\n const entry = option?.Cost?.Standard?.Entries?.find((e) => e?.CurrencyID && Number(e.Amount ?? 0) > 0);\r\n return entry ? Number(entry.Amount) : fallback;\r\n}\r\n\r\nconst SAVE_DELAY_MS = 1500;\r\n\r\nexport class ServerGameMeta extends BaseMeta {\r\n private readonly off: () => void;\r\n private saveTimer: ReturnType<typeof setTimeout> | null = null;\r\n private defs: MetaStatView[];\r\n\r\n constructor(\r\n private readonly client: IDosGamesClient,\r\n softID: string,\r\n def: MetaGameDef,\r\n ) {\r\n super(def, {\r\n soft: 0,\r\n hard: 0,\r\n softID,\r\n hardID: \"GE\",\r\n stats: {},\r\n heroes: [\"hero\"],\r\n progress: { ...EMPTY_PROGRESS },\r\n adAvailable: client.ads.available,\r\n // a title without the spends (an older seed, or the publisher removed them) shows no gems buttons\r\n gemsPay: !!spendsOf(client)?.[SPENDS.revive],\r\n prices: { revive: spendPrice(client, SPENDS.revive, REVIVE_GEMS), reroll: spendPrice(client, SPENDS.reroll, REROLL_GEMS) },\r\n busy: null,\r\n error: null,\r\n });\r\n this.defs = this.readDefs();\r\n this.off = client.on(\"user:anyUpdated\", () => this.readState());\r\n this.readState();\r\n void this.loadProgress();\r\n }\r\n\r\n dispose(): void {\r\n this.off();\r\n if (this.saveTimer) this.flush();\r\n }\r\n\r\n /** The stats from the title's Character config (the seed), the defaults where it has none. */\r\n private readDefs(): MetaStatView[] {\r\n const section = configSection<{ Definitions?: Record<string, { Stats?: Record<string, StatConfig | null> | null } | null> | null }>(this.client, \"Character\");\r\n const stats = section?.Definitions?.[this.def.character]?.Stats ?? {};\r\n return this.def.stats.map((d) => {\r\n const c = stats[d.id];\r\n if (!c) return defaultStat(d);\r\n const entry = Object.values(c.PriceOptions ?? {})\r\n .find((o) => o)\r\n ?.Cost?.Standard?.Entries?.find((e) => e?.CurrencyID && Number(e.Amount ?? 0) > 0);\r\n const price = Number(entry?.Amount ?? d.price);\r\n return {\r\n ...d,\r\n maxLevel: Number(c.MaxLevel ?? d.maxLevel),\r\n valueAt: (l: number) => evaluateCurve(c.ValueCurve, Number(c.BaseStatValue ?? 0), Math.max(0, l), 0),\r\n costAt: (n: number) => roundAmount(price * curveMultiplier(c.CostCurve, Math.max(1, n), 1)),\r\n };\r\n });\r\n }\r\n\r\n private readState(): void {\r\n const state = this.client.data.user.state;\r\n const cur = state?.InventoryV2?.VirtualCurrencies ?? {};\r\n const chars = state?.Character?.Characters ?? {};\r\n const levels = chars[this.def.character]?.StatLevels ?? {};\r\n const heroes = [\"hero\", ...this.def.heroes.filter((h) => h.gems > 0 && chars[heroCharacterOf(this.def, h.id)]).map((h) => h.id)];\r\n this.set({\r\n soft: Number(cur[this.snap.softID]?.Amount ?? 0),\r\n hard: Number(cur[this.snap.hardID]?.Amount ?? 0),\r\n stats: { ...levels },\r\n heroes,\r\n });\r\n }\r\n\r\n private async loadProgress(): Promise<void> {\r\n const key = this.def.character;\r\n const cd = this.client.data.user.state?.CustomData as { Private?: Record<string, { Value?: string | null } | undefined> } | null | undefined;\r\n let raw: string | null | undefined = cd?.Private ? (cd.Private[key]?.Value ?? null) : undefined;\r\n if (raw === undefined) {\r\n const res = await this.client.userCustomData.getMyUserCustomData();\r\n raw = res.ok ? (res.data.Private?.[key]?.Value ?? null) : null;\r\n }\r\n this.set({ progress: parseProgress(raw, this.def.levels) });\r\n }\r\n\r\n stats(): readonly MetaStatView[] {\r\n return this.defs;\r\n }\r\n\r\n private async run(what: string, call: () => Promise<{ ok: boolean; error?: unknown }>): Promise<boolean> {\r\n if (this.snap.busy) return false;\r\n this.set({ busy: what, error: null });\r\n try {\r\n const res = await call();\r\n this.set({ busy: null, error: res.ok ? null : String(res.error ?? \"error\") });\r\n return res.ok;\r\n } catch (e) {\r\n this.set({ busy: null, error: String(e) });\r\n return false;\r\n }\r\n }\r\n\r\n async upgradeStat(id: string): Promise<void> {\r\n await this.run(`stat:${id}`, () => this.client.character.upgradeStatLevel(this.def.character, id));\r\n }\r\n\r\n async unlockHero(id: string): Promise<void> {\r\n const ok = await this.run(`hero:${id}`, () => this.client.character.unlockCharacter(heroCharacterOf(this.def, id)));\r\n if (ok) {\r\n this.readState();\r\n this.selectHero(id);\r\n }\r\n }\r\n\r\n async pay(kind: \"revive\" | \"reroll\", via: \"gems\" | \"ad\"): Promise<boolean> {\r\n if (via === \"ad\") {\r\n // the server's confirmed view is the price (the placement's daily cap limits it)\r\n if (this.snap.busy) return false;\r\n this.set({ busy: `${kind}:ad`, error: null });\r\n const ad = await this.client.ads.showRewarded(kind === \"revive\" ? PLACEMENTS.revive : PLACEMENTS.reroll);\r\n this.set({ busy: null });\r\n return ad.status === \"granted\" || ad.status === \"pending\";\r\n }\r\n if (!this.snap.gemsPay) return false;\r\n return this.run(`${kind}:gems`, () => this.client.currency.spend(kind === \"revive\" ? SPENDS.revive : SPENDS.reroll));\r\n }\r\n\r\n /** A round ended: the progress, and the quests that count what the round counted (one call per metric, under its cap). */\r\n override roundEnded(stats: RunStats, learned: readonly string[]): void {\r\n super.roundEnded(stats, learned);\r\n const quests = configSection<QuestSectionView>(this.client, \"Quest\");\r\n const metric = (id: string): { cap: number } | null => {\r\n let found = false;\r\n let cap = 0;\r\n for (const q of Object.values(quests?.Quests ?? {}))\r\n for (const o of Object.values(q?.Objectives ?? {})) {\r\n if (!o || o.MetricID !== id || (o.Source ?? \"ClientApi\") !== \"ClientApi\") continue;\r\n found = true;\r\n const max = Number(o.MaxProgressPerCall ?? 0);\r\n if (max > 0) cap = cap === 0 ? max : Math.min(cap, max);\r\n }\r\n return found ? { cap } : null;\r\n };\r\n const report = (id: string, amount: number) => {\r\n const m = metric(id);\r\n if (!m || amount <= 0) return;\r\n void this.client.quest.addQuestProgress(id, m.cap > 0 ? Math.min(amount, m.cap) : amount);\r\n };\r\n report(METRICS.played, 1);\r\n report(METRICS.coins, Math.floor(stats.coins ?? 0));\r\n report(METRICS.seconds, Math.floor(stats.seconds));\r\n report(METRICS.levels, stats.cleared ? 1 : 0);\r\n }\r\n\r\n async gemsForAd(): Promise<void> {\r\n const ad = await this.client.ads.showRewarded(PLACEMENTS.gems);\r\n if (ad.status !== \"granted\") return;\r\n await this.run(\"gems:ad\", () => this.client.store.purchase(OFFERS.gemsAd, 1));\r\n }\r\n\r\n /** A pack of gems for Aspis (the platform asks the player to confirm the price). */\r\n async gemsPack(): Promise<void> {\r\n await this.run(\"gems:pack\", () =>\r\n payWithCredits(this.client, (creditsConfirmationID) => this.client.store.purchase(OFFERS.gemsPack, 1, { creditsConfirmationID })),\r\n );\r\n }\r\n\r\n protected saveProgress(): void {\r\n if (this.saveTimer) clearTimeout(this.saveTimer);\r\n this.saveTimer = setTimeout(() => this.flush(), SAVE_DELAY_MS);\r\n }\r\n\r\n private flush(): void {\r\n if (this.saveTimer) clearTimeout(this.saveTimer);\r\n this.saveTimer = null;\r\n void this.client.userCustomData.setPrivateData(this.def.character, JSON.stringify(this.snap.progress));\r\n }\r\n}\r\n"
97
+ },
98
+ {
99
+ "path": "meta/ReviveDialog.tsx",
100
+ "content": "import { useEffect, useState, useSyncExternalStore, type CSSProperties, type ReactNode } from \"react\";\nimport { GIVE_UP, REVIVE, REVIVE_OFFER, ROUND_ENDED, ROUND_STARTED, type World } from \"@idosgames/game-kit\";\nimport { Button, Icon, Ribbon, panel, v } from \"@idosgames/react/ui\";\nimport { st } from \"./i18n\";\nimport type { GameMeta } from \"./meta\";\n\n/**\n * The revive offer of any game of the constructor: the block's hero fell, the world waits; gems (the title's\n * spend) or an ad bring it back once, \"Give up\" ends the round. The answer goes back as an event.\n */\nexport function ReviveDialog({ world, meta, text = \"reviveText\" }: { world: World | null; meta: GameMeta; text?: \"reviveText\" | \"reviveRun\" | \"reviveLevel\" | \"reviveTd\" | \"reviveM3\" | \"reviveMg\" | \"reviveTy\" }): ReactNode {\n const snap = useSyncExternalStore(meta.subscribe, meta.snapshot, meta.snapshot);\n const [open, setOpen] = useState(false);\n\n useEffect(() => {\n setOpen(false);\n if (!world) return;\n // a remount while the offer is up: read it from the vars\n if (world.vars[\"reviving\"] === true) setOpen(true);\n const offs = [\n world.on(REVIVE_OFFER, () => setOpen(true)),\n world.on(REVIVE, () => setOpen(false)),\n world.on(GIVE_UP, () => setOpen(false)),\n world.on(ROUND_STARTED, () => setOpen(false)),\n world.on(ROUND_ENDED, () => setOpen(false)),\n ];\n return () => offs.forEach((off) => off());\n }, [world]);\n\n if (!world || !open) return null;\n\n const revive = async (via: \"gems\" | \"ad\") => {\n if (await meta.pay(\"revive\", via)) {\n setOpen(false);\n world.emit(REVIVE, {});\n }\n };\n const giveUp = () => {\n setOpen(false);\n world.emit(GIVE_UP, {});\n };\n\n return (\n <div style={backdrop}>\n <div style={{ ...panel, padding: \"26px 20px 20px\", width: \"min(340px, 100%)\", display: \"grid\", gap: 12, justifyItems: \"center\", textAlign: \"center\", animation: \"idos-pop-in .3s both\" }}>\n <Ribbon tone=\"red\" size={24} stars={false}>\n {st(\"revive\")}\n </Ribbon>\n <Icon glyph=\"heart\" size={56} />\n <div style={{ opacity: 0.9 }}>{st(text)}</div>\n {snap.gemsPay && (\n <Button tone=\"purple\" size=\"lg\" busy={snap.busy === \"revive:gems\"} disabled={snap.hard < snap.prices.revive} onClick={() => void revive(\"gems\")}>\n <span style={row}>\n <Icon glyph=\"gem\" size={20} /> {snap.prices.revive}\n </span>\n </Button>\n )}\n {snap.gemsPay && snap.hard < snap.prices.revive && <div style={{ fontSize: 13, color: v.red }}>{st(\"notEnough\")}</div>}\n {snap.adAvailable && (\n <Button tone=\"orange\" size=\"md\" busy={snap.busy === \"revive:ad\"} onClick={() => void revive(\"ad\")}>\n <span style={row}>\n <Icon glyph=\"megaphone\" size={18} /> {st(\"forAd\")}\n </span>\n </Button>\n )}\n <Button tone=\"grey\" size=\"sm\" onClick={giveUp}>\n {st(\"giveUp\")}\n </Button>\n </div>\n </div>\n );\n}\n\nconst backdrop: CSSProperties = {\n position: \"absolute\",\n inset: 0,\n display: \"flex\",\n flexDirection: \"column\",\n alignItems: \"center\",\n justifyContent: \"center\",\n gap: 10,\n padding: 14,\n background: \"rgba(8,6,16,0.6)\",\n pointerEvents: \"auto\",\n color: \"#fff\",\n animation: \"idos-fade-in .15s both\",\n zIndex: 6,\n};\n\nconst row: CSSProperties = { display: \"inline-flex\", alignItems: \"center\", gap: 6 };\n"
101
+ },
102
+ {
103
+ "path": "module.ts",
104
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { INPUT_TOPIC, Runner } from \"@idosgames/game-kit\";\nimport { KitRuntime } from \"./runtime\";\nimport { createKitScene } from \"./scene\";\nimport { makeKitPanel } from \"./KitPanel\";\nimport { t } from \"./i18n\";\n\n// The game constructor's runtime: the game is the title's GameSpec (config section GameSpec), drawn\n// by the kit's Phaser adapter. Editing the game means editing the spec; a new genre is a new preset\n// in @idosgames/game-kit, not code here. See docs/feature-history/game-constructor.md at the\n// platform root.\nexport const kitGameModule: Module = defineModule({\n id: \"kit-game\",\n meta: {\n name: \"Constructor game\",\n type: \"game\",\n genre: \"constructor\",\n engine: \"phaser\",\n },\n setup(ctx) {\n const runtime = new KitRuntime(ctx.client);\n\n ctx.registerScene(createKitScene(runtime));\n // The game owns the whole screen and its UI is its own HUD (owner, 07.11.2026): the balances in the\n // game's style and ONE menu button with every system of the app, opened over the game.\n // `ownExit` — the base draws nothing over it.\n ctx.registerPanel({\n id: \"hud\",\n slot: \"overlay\",\n component: makeKitPanel(runtime, { features: ctx.features }),\n });\n ctx.registerRoute({ id: \"kit-game\", label: t(\"title\"), icon: \"🎮\", ownExit: true });\n\n // The game is drawn on a canvas: the agent reads its state and plays it through these.\n ctx.exposeToAgent({\n state: () => {\n const w = runtime.world;\n const spec = runtime.spec;\n const [player] = w ? w.query(Runner) : [];\n return {\n mounted: w !== null,\n specFromTitle: spec ? !spec.fallback : false,\n specIssues: spec?.issues ?? [],\n modes: spec?.resolved.modes.map((m) => ({ id: m.id, preset: m.preset.id, role: m.role, params: m.params })) ?? [],\n vars: w ? { ...w.vars } : {},\n entities: w?.entityCount ?? 0,\n lane: w && player !== undefined ? w.need(player, Runner).lane : null,\n };\n },\n actions: {\n input: async (args) => {\n const w = runtime.world;\n if (!w) throw new Error(\"the game is not mounted\");\n w.emit(INPUT_TOPIC, (args ?? {}) as Record<string, unknown>);\n },\n playAgain: async () => runtime.playAgain(),\n },\n describeActions: {\n input:\n 'Send one input event to the game: { type: \"tap\", x, y } | { type: \"swipe\", dir: \"left\"|\"right\"|\"up\"|\"down\", x, y } | { type: \"key\", key: \"left\"|\"right\"|\"up\"|\"down\"|\"action\" }.',\n playAgain: \"Start a new round after the result screen.\",\n },\n });\n },\n});\n"
105
+ },
106
+ {
107
+ "path": "platformer/Lobby.tsx",
108
+ "content": "import { useSyncExternalStore, type ReactNode } from \"react\";\nimport { ABILITIES, abilitiesFor, PLATFORMER_HEROES, PLATFORMER_LEVELS, platformerLevelOf, starsToEnter, type MetaHeroDef } from \"@idosgames/game-kit\";\nimport { Icon, v } from \"@idosgames/react/ui\";\nimport { st, stAny } from \"../meta/i18n\";\nimport { starsTotal, type GameMeta } from \"../meta/meta\";\nimport type { ArtLookProps } from \"../meta/ArtIcon\";\nimport { GameLobby } from \"../meta/Lobby\";\nimport { Stars } from \"../runner/Lobby\";\n\n/**\n * The platformer's home between levels: the level (its world, its stars, a boss), what the stars have opened and\n * what opens next, the next world's gate; upgrades and heroes below.\n */\nexport function PlatformerLobby({ meta, look, language }: { meta: GameMeta; look: ArtLookProps; language: string }): ReactNode {\n const p = useSyncExternalStore(meta.subscribe, meta.snapshot, meta.snapshot).progress;\n const level = platformerLevelOf(p.chapter);\n const cleared = p.cleared.includes(p.chapter);\n const stars = p.stars[String(p.chapter)] ?? 0;\n const total = starsTotal(p);\n const owned = abilitiesFor(total);\n const next = ABILITIES.find((a) => total < a.stars);\n // the next world waits for its stars: say how many\n const gate = p.chapter === p.unlocked && cleared && p.chapter < PLATFORMER_LEVELS ? starsToEnter(p.chapter + 1) : 0;\n const card = (\n <>\n <div style={{ display: \"flex\", gap: 10, alignItems: \"center\", fontWeight: 900, fontSize: 18 }}>\n <span>{`${st(\"world\")} ${level.world}-${level.index}`}</span>\n {level.boss && (\n <span style={{ display: \"inline-flex\", gap: 4, alignItems: \"center\", color: v.red }}>\n <Icon glyph=\"swords\" size={16} /> {st(\"boss\")}\n </span>\n )}\n <Stars n={stars} size={20} />\n </div>\n <div style={{ fontSize: 12, opacity: 0.9, display: \"flex\", gap: 10, flexWrap: \"wrap\", justifyContent: \"center\" }}>\n <span>{`${st(\"starsAll\")}: ★ ${total}`}</span>\n {owned.map((a) => (\n <span key={a} style={{ color: v.green, fontWeight: 800 }}>{stAny(`a.${a}`)}</span>\n ))}\n {next && <span>{`${st(\"nextAbility\")}: ${stAny(`a.${next.id}`)} ★ ${next.stars}`}</span>}\n {gate > total && <span style={{ color: v.gold, fontWeight: 800 }}>{`${st(\"world\")} ${level.world + 1}: ${st(\"needStars\")} ★ ${gate}`}</span>}\n {!cleared && (\n <span style={{ display: \"inline-flex\", gap: 4, alignItems: \"center\" }}>\n {st(\"firstClear\")}: <Icon glyph=\"coin\" size={13} /> {level.firstClearGold}\n </span>\n )}\n </div>\n </>\n );\n const heroInfo = (m: MetaHeroDef) => {\n const h = PLATFORMER_HEROES.find((x) => x.id === m.id) ?? PLATFORMER_HEROES[0]!;\n return (\n <>\n <div style={{ fontSize: 12, opacity: 0.8 }}>{st(\"perk\")}</div>\n <div style={{ fontSize: 13, fontWeight: 800, minHeight: 34 }}>{stAny(`perk.${h.perk}`)}</div>\n </>\n );\n };\n return <GameLobby meta={meta} look={look} language={language} label=\"levelN\" card={card} heroInfo={heroInfo} />;\n}\n"
109
+ },
110
+ {
111
+ "path": "RoundResult.tsx",
112
+ "content": "import { useState, type ReactNode } from \"react\";\nimport type { FeatureRegistry } from \"@idosgames/module-sdk\";\nimport { useRewardWindow } from \"@idosgames/react\";\nimport { grantedBy, useCelebrate } from \"@idosgames/react/ui\";\nimport type { Hud, KitRuntime } from \"./runtime\";\nimport { outcomeOf, RoundResultView } from \"./RoundResultView\";\nimport { resultDetails } from \"./meta/details\";\n\n// The result screen of a round (owner, 07.11.2026): won or lost, the score, then the reward window — \"Claim N\",\n// \"x3 for an ad\" (only in the app), \"x5 VIP\" — and only after it \"Play again\", so the round's coins are never left\n// behind. The coins reach the balance only through the server (`client.reward.claimRewardWindow`): the round's\n// count is the game's word, the cap and the multiplier are the server's. A title without the window (its config\n// has no `Reward.Windows[spec.rewards.window]`) shows the result and \"Play again\" as before. The window itself is\n// RoundResultView (the constructor's test page draws the same one).\nexport function RoundResult({\n runtime,\n hud,\n features,\n}: {\n runtime: KitRuntime;\n hud: Hud;\n features: FeatureRegistry;\n}): ReactNode {\n const result = hud.result;\n const windowID = runtime.spec?.resolved.rewards.window ?? \"round\";\n const rw = useRewardWindow(windowID);\n const celebrate = useCelebrate();\n const [claimedRound, setClaimedRound] = useState<string | null>(null);\n\n const coins = result?.coins ?? hud.coins;\n const roundKey = result?.roundKey ?? \"\";\n const offerWindow = !!rw.window && coins > 0 && claimedRound !== roundKey;\n\n const claim = async (choice: \"None\" | \"Ad\" | \"Vip\") => {\n // One key per round whatever the choice: a retried claim (lost answer) pays once.\n const data = await rw.claim(coins, choice, roundKey || undefined);\n if (!data) return;\n setClaimedRound(roundKey);\n celebrate(grantedBy(data));\n };\n\n return (\n <RoundResultView\n outcome={outcomeOf(result?.reason)}\n score={result?.score ?? hud.score}\n best={result?.best ?? hud.best}\n newBest={!!result?.newBest}\n reward={\n offerWindow && rw.window\n ? {\n amount: Math.min(coins, rw.window.maxBaseAmount),\n currencyID: rw.window.currencyID,\n adMultiplier: rw.window.adMultiplier,\n adAvailable: rw.adAvailable,\n vipMultiplier: rw.window.vipMultiplier,\n isVip: rw.isVip,\n busy: rw.busy,\n message: rw.message,\n // Without VIP the button opens the VIP offer over the game (the premium system's screen).\n ...(features.get(\"premium\")?.available ? { onVipOffer: () => features.open(\"premium\") } : {}),\n }\n : null\n }\n onClaim={(choice) => void claim(choice)}\n onPlayAgain={() => runtime.playAgain()}\n {...(runtime.meta ? { onHome: () => runtime.home() } : {})}\n {...(result?.stats && runtime.meta ? { details: resultDetails(runtime.presetId, result.stats) } : {})}\n />\n );\n}\n"
113
+ },
114
+ {
115
+ "path": "RoundResultView.tsx",
116
+ "content": "import type { ReactNode } from \"react\";\nimport { Button, Icon, Ribbon, RewardWindowPanel, panel, v, type RewardWindowPanelProps } from \"@idosgames/react/ui\";\nimport { t } from \"./i18n\";\n\n/** How a round ended, by the reason its genre gave (`round-over` { reason }). */\nexport type RoundOutcome = \"won\" | \"lost\" | \"over\";\n\nconst WON = new Set([\"cleared\", \"survived\", \"defended\"]);\nconst LOST = new Set([\"crashed\", \"fell\", \"died\", \"overrun\", \"out-of-moves\", \"no-moves\", \"out-of-energy\", \"out-of-time\"]);\n\nexport const outcomeOf = (reason: string | undefined): RoundOutcome =>\n reason && WON.has(reason) ? \"won\" : reason && LOST.has(reason) ? \"lost\" : \"over\";\n\nexport interface RoundResultViewProps {\n outcome: RoundOutcome;\n score: number;\n best: number;\n newBest: boolean;\n /** The reward window of the title (null: none, or the round's coins are already claimed). */\n reward: Omit<RewardWindowPanelProps, \"onClaim\"> | null;\n onClaim: RewardWindowPanelProps[\"onClaim\"];\n onPlayAgain: () => void;\n /** Back to the game's home (its meta: upgrades, chapters): a second button next to \"Play again\". */\n onHome?: () => void;\n /** Numbers of the run under the score (kills, time…). */\n details?: ReadonlyArray<{ label: string; value: string }>;\n}\n\n// The round's result as a window of the game (owner, 07.11.2026): a ribbon says won or lost, the score with the\n// best, then the reward window (\"Claim\", \"x3 for an ad\" in the app, \"x5 VIP\") and only after it \"Play again\", so\n// the round's coins are never left behind. After a win the button says \"Continue\" (owner, 08.11.2026): the meta has\n// already moved to the next level. Pure view: the game (RoundResult) and the constructor's test page\n// (playground kit.html) draw the same window.\nexport function RoundResultView({ outcome, score, best, newBest, reward, onClaim, onPlayAgain, onHome, details }: RoundResultViewProps): ReactNode {\n const ribbon =\n outcome === \"won\" ? (\n <Ribbon tone=\"green\" size={26}>\n {t(\"won\")}\n </Ribbon>\n ) : outcome === \"lost\" ? (\n <Ribbon tone=\"red\" size={26} stars={false}>\n {t(\"lost\")}\n </Ribbon>\n ) : (\n <Ribbon tone=\"blue\" size={26} stars={false}>\n {t(\"gameOver\")}\n </Ribbon>\n );\n return (\n <div style={backdrop}>\n <div style={{ ...panel, ...card }}>\n <div style={{ marginTop: -46, display: \"flex\", justifyContent: \"center\" }}>{ribbon}</div>\n <div style={{ display: \"flex\", alignItems: \"center\", justifyContent: \"center\", gap: 10, marginTop: 10 }}>\n <Icon glyph=\"star\" size={34} />\n <span style={{ fontSize: 48, fontWeight: 900, lineHeight: 1 }}>{score}</span>\n </div>\n {newBest ? (\n <div className=\"idos-bounce\" style={{ color: v.gold, fontWeight: 800, fontSize: 18 }}>\n {t(\"newBest\")}\n </div>\n ) : (\n <div style={{ opacity: 0.8, fontWeight: 700 }}>{`${t(\"best\")}: ${best}`}</div>\n )}\n {details && details.length > 0 && (\n <div style={{ display: \"flex\", gap: 8, flexWrap: \"wrap\", justifyContent: \"center\", marginTop: 4 }}>\n {details.map((d) => (\n <div key={d.label} style={{ background: \"rgba(0,0,0,0.28)\", borderRadius: 10, padding: \"4px 10px\", fontSize: 13 }}>\n <span style={{ opacity: 0.75 }}>{d.label}</span> <b>{d.value}</b>\n </div>\n ))}\n </div>\n )}\n <div style={{ marginTop: 8, width: \"100%\", display: \"grid\", justifyItems: \"center\", gap: 12 }}>\n {reward ? (\n <RewardWindowPanel {...reward} onClaim={onClaim} />\n ) : (\n <div style={{ display: \"flex\", gap: 10, flexWrap: \"wrap\", justifyContent: \"center\" }}>\n {onHome && (\n <Button size=\"lg\" tone=\"blue\" onClick={onHome}>\n {t(\"home\")}\n </Button>\n )}\n <Button size=\"lg\" tone=\"green\" onClick={onPlayAgain}>\n {outcome === \"won\" ? t(\"continue\") : t(\"playAgain\")}\n </Button>\n </div>\n )}\n </div>\n </div>\n </div>\n );\n}\n\nconst backdrop = {\n position: \"absolute\",\n inset: 0,\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n padding: 20,\n background: \"rgba(8,6,16,0.55)\",\n pointerEvents: \"auto\",\n animation: \"idos-fade-in .2s both\",\n} as const;\n\nconst card = {\n position: \"relative\",\n width: \"min(360px, 100%)\",\n padding: \"34px 18px 20px\",\n display: \"flex\",\n flexDirection: \"column\",\n alignItems: \"center\",\n gap: 6,\n textAlign: \"center\",\n color: \"#fff\",\n animation: \"idos-pop-in .35s both\",\n} as const;\n"
117
+ },
118
+ {
119
+ "path": "runner/Lobby.tsx",
120
+ "content": "import { useSyncExternalStore, type ReactNode } from \"react\";\nimport { levelOf, metersOf, RUNNER_HEROES, type MetaHeroDef } from \"@idosgames/game-kit\";\nimport { Icon, v } from \"@idosgames/react/ui\";\nimport { st, stAny } from \"../meta/i18n\";\nimport type { GameMeta } from \"../meta/meta\";\nimport type { ArtLookProps } from \"../meta/ArtIcon\";\nimport { GameLobby } from \"../meta/Lobby\";\n\n/** Three stars, the ones a level gave lit. */\nexport function Stars({ n, size = 18 }: { n: number; size?: number }): ReactNode {\n return (\n <span style={{ display: \"inline-flex\", gap: 2 }}>\n {[0, 1, 2].map((i) => (\n <span key={i} style={{ fontSize: size, lineHeight: 1, color: i < n ? v.gold : \"rgba(255,255,255,0.25)\", textShadow: \"0 2px 0 rgba(0,0,0,0.35)\" }}>\n ★\n </span>\n ))}\n </span>\n );\n}\n\n/** The runner's home between levels: the level (its stars, its length, what it teaches), upgrades, heroes. */\nexport function RunnerLobby({ meta, look, lengthScale, language }: { meta: GameMeta; look: ArtLookProps; lengthScale: number; language: string }): ReactNode {\n const p = useSyncExternalStore(meta.subscribe, meta.snapshot, meta.snapshot).progress;\n const level = levelOf(p.chapter);\n const cleared = p.cleared.includes(p.chapter);\n const stars = p.stars[String(p.chapter)] ?? 0;\n const card = (\n <>\n <div style={{ display: \"flex\", gap: 10, alignItems: \"center\", fontWeight: 900, fontSize: 18 }}>\n <Stars n={stars} size={22} />\n <span>{`${metersOf(level.length * lengthScale)} ${st(\"m\")}`}</span>\n </div>\n <div style={{ fontSize: 12, opacity: 0.85, display: \"flex\", gap: 10, flexWrap: \"wrap\", justifyContent: \"center\" }}>\n {level.fresh !== null && !cleared && <span style={{ color: v.green, fontWeight: 800 }}>{`${st(\"new\")}: ${stAny(`k.${level.fresh}`)}`}</span>}\n {!cleared && (\n <span style={{ display: \"inline-flex\", gap: 4, alignItems: \"center\" }}>\n {st(\"firstClear\")}: <Icon glyph=\"coin\" size={13} /> {level.firstClearGold}\n </span>\n )}\n </div>\n </>\n );\n const heroInfo = (m: MetaHeroDef) => {\n const h = RUNNER_HEROES.find((x) => x.id === m.id) ?? RUNNER_HEROES[0]!;\n return (\n <>\n <div style={{ fontSize: 12, opacity: 0.8 }}>{st(\"perk\")}</div>\n <div style={{ fontSize: 13, fontWeight: 800, minHeight: 34 }}>{stAny(`perk.${h.perk}`)}</div>\n </>\n );\n };\n return <GameLobby meta={meta} look={look} language={language} label=\"levelN\" card={card} heroInfo={heroInfo} />;\n}\n"
121
+ },
122
+ {
123
+ "path": "runtime.ts",
124
+ "content": "// The constructor game's runtime state, shared by the scene (Phaser) and the panel (React): which\n// spec runs, the running world, and a tiny store of the HUD values the panel shows.\n//\n// The HUD store notifies React only when a watched value CHANGED (owner's rule: no per-frame DOM\n// writes while the game plays): the scene calls `afterStep` every stepped frame, and that compares\n// a handful of numbers, nothing more.\n\nimport type { IDosGamesClient } from \"@idosgames/core\";\nimport {\n createRegistry,\n DEMO_SPEC,\n DEFAULT_LOOK,\n mergePalette,\n type ArtLook,\n registerPlatformKit,\n RUNNER_META,\n PLATFORMER_META,\n TD_META,\n M3_META,\n MG_META,\n TY_META,\n ROUND_ENDED,\n SURVIVORS_META,\n type MetaGameDef,\n START_ROUND,\n SHOW_START,\n startMode,\n validateSpec,\n type Issue,\n type Registry,\n type ResolvedSpec,\n type RunningMode,\n type World,\n} from \"@idosgames/game-kit\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { applyMeta, learnedOf, runStatsOf, ServerGameMeta } from \"./meta/meta\";\nimport type { ArtLookProps } from \"./meta/ArtIcon\";\n\n/** Title config section that holds the game (written by the constructor on title creation). */\nexport const GAME_SPEC_SECTION = \"GameSpec\";\n\nexport interface Hud {\n readonly state: \"ready\" | \"playing\" | \"ended\";\n readonly score: number;\n readonly coins: number;\n readonly lives: number;\n readonly best: number;\n /** The last finished round (shown on the result screen). */\n readonly result: {\n score: number;\n coins: number;\n best: number;\n newBest: boolean;\n /** Why it ended (the genre's reason: \"cleared\", \"crashed\"…): won or lost on the result window. */\n reason: string;\n /** Stable id of the round (this mount + round number): a retried reward claim pays once. */\n roundKey: string;\n /** What the genre counted (survivors: kills, seconds, bosses, chapter, cleared). */\n stats: Readonly<Record<string, unknown>> | null;\n } | null;\n}\n\nconst EMPTY_HUD: Hud = { state: \"ready\", score: 0, coins: 0, lives: 0, best: 0, result: null };\n\nexport interface LoadedSpec {\n readonly resolved: ResolvedSpec;\n /** The title had no usable spec: the demo runs. */\n readonly fallback: boolean;\n readonly issues: readonly Issue[];\n}\n\n/** The title's spec, or the demo when there is none or it does not validate (the reasons go to `issues`). */\nexport function loadSpec(client: IDosGamesClient, registry: Registry): LoadedSpec {\n const raw = configSection<unknown>(client, GAME_SPEC_SECTION);\n // The engine delivers the section as JSON text (TitlePublicConfigurationModel.GameSpec is a string);\n // an object is accepted too.\n let parsed: unknown = raw;\n if (typeof raw === \"string\") {\n try {\n parsed = JSON.parse(raw);\n } catch (e) {\n const demo = validateSpec(DEMO_SPEC, registry);\n return {\n resolved: demo.resolved!,\n fallback: true,\n issues: [{ level: \"error\", path: \"\", code: \"SPEC_JSON\", message: `GameSpec is not valid JSON: ${String(e)}` }],\n };\n }\n }\n if (parsed !== undefined && parsed !== null) {\n const res = validateSpec(parsed, registry);\n if (res.ok && res.resolved) return { resolved: res.resolved, fallback: false, issues: res.issues };\n const demo = validateSpec(DEMO_SPEC, registry);\n return { resolved: demo.resolved!, fallback: true, issues: res.issues };\n }\n const demo = validateSpec(DEMO_SPEC, registry);\n return { resolved: demo.resolved!, fallback: true, issues: [] };\n}\n\n/** The genres with a meta between rounds (levels, upgrades, heroes), by preset id. */\nexport const META_GAMES: Readonly<Record<string, MetaGameDef>> = { survivors: SURVIVORS_META, runner: RUNNER_META, platformer: PLATFORMER_META, \"tower-defense\": TD_META, match3: M3_META, merge: MG_META, tycoon: TY_META };\n\nexport class KitRuntime {\n readonly registry: Registry;\n private loaded: LoadedSpec | null = null;\n private running: RunningMode | null = null;\n private hud: Hud = EMPTY_HUD;\n private readonly listeners = new Set<() => void>();\n /** The game's meta (levels, upgrades, heroes) when the core mode's genre has one (META_GAMES). */\n meta: ServerGameMeta | null = null;\n private offMeta: (() => void) | null = null;\n\n constructor(private readonly client: IDosGamesClient) {\n this.registry = createRegistry();\n registerPlatformKit(this.registry);\n }\n\n /** Load the spec (once per mount) and start its core mode. Returns the new world. */\n start(): World {\n this.stop();\n this.loaded = loadSpec(this.client, this.registry);\n if (this.loaded.issues.length > 0)\n console.warn(\"[kit-game] GameSpec issues\", this.loaded.issues);\n const core = this.loaded.resolved.modes.find((m) => m.role === \"core\") ?? this.loaded.resolved.modes[0]!;\n this.running = startMode(this.loaded.resolved, core.id, this.registry, {\n onRuleError: (rule, error) => console.warn(`[kit-game] rule \"${rule.id}\" failed`, error),\n });\n const world = this.running.world;\n const runId = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;\n const def = META_GAMES[core.preset.id];\n if (def) {\n const soft = this.loaded.resolved.spec.resources?.find((r) => r.kind === \"currency\" && r.ref)?.ref ?? \"CO\";\n if (this.meta?.def !== def) {\n this.meta?.dispose();\n this.meta = new ServerGameMeta(this.client, soft, def);\n }\n const meta = this.meta;\n // the next round reads the meta (stats, level, hero) from the world's vars\n applyMeta(world, meta);\n this.offMeta = meta.subscribe(() => applyMeta(world, meta));\n world.on(ROUND_ENDED, (p) => {\n const s = p[\"stats\"] as Record<string, unknown> | undefined;\n if (s) meta.roundEnded(runStatsOf(s, Number(p[\"coins\"] ?? 0)), learnedOf(world));\n });\n } else {\n this.meta?.dispose();\n this.meta = null;\n }\n world.on(ROUND_ENDED, (p) => {\n const score = Number(p[\"score\"] ?? 0);\n const best = Number(p[\"best\"] ?? score);\n this.setHud({\n ...this.read(world),\n result: {\n score,\n coins: Number(p[\"coins\"] ?? 0),\n best,\n newBest: score > 0 && score >= best && score > this.hud.best,\n reason: typeof p[\"reason\"] === \"string\" ? p[\"reason\"] : \"over\",\n roundKey: `kit:${runId}:${String(world.vars[\"round\"] ?? 0)}`,\n stats: (p[\"stats\"] as Record<string, unknown> | undefined) ?? null,\n },\n });\n });\n this.setHud({ ...this.read(world), result: null });\n return world;\n }\n\n stop(): void {\n this.offMeta?.();\n this.offMeta = null;\n this.running?.stop();\n this.running = null;\n }\n\n /** The id of the core mode's preset (\"survivors\"…). */\n get presetId(): string {\n const core = this.loaded?.resolved.modes.find((m) => m.role === \"core\") ?? this.loaded?.resolved.modes[0];\n return core?.preset.id ?? \"\";\n }\n\n /** How the kit's art is drawn in the UI (the cards, the meta): the pack's style and the palette. */\n artLook(): ArtLookProps {\n return { style: this.look().style, palette: this.palette(), cast: this.look().cast };\n }\n\n /** Back to the game's home after a result (the meta, the start button). */\n home(): void {\n const w = this.world;\n if (!w) return;\n this.setHud({ ...this.hud, result: null });\n w.emit(SHOW_START);\n }\n\n get spec(): LoadedSpec | null {\n return this.loaded;\n }\n\n get world(): World | null {\n return this.running?.world ?? null;\n }\n\n /** Every palette role: the defaults ← the spec's asset pack ← the spec's own colours. */\n palette(): Record<string, string> {\n return { ...(this.loaded?.resolved.palette ?? mergePalette(undefined)) };\n }\n\n /** The art style and the cast of the spec's asset pack (the kit's own without one). */\n look(): ArtLook {\n return this.loaded?.resolved.look ?? DEFAULT_LOOK;\n }\n\n /** A text of the spec (or its asset pack) in the language, falling back to English, then to `fallback`. */\n text(key: string, language: string, fallback: string): string {\n const byLang = this.loaded?.resolved.texts[key];\n return byLang?.[language] ?? byLang?.[\"en\"] ?? fallback;\n }\n\n /** The scene's hook: freeze / unfreeze the field (the game's menu is open over it). */\n pauseHook: ((paused: boolean) => void) | null = null;\n\n /** The game's menu opened or closed over the field. */\n setPaused(paused: boolean): void {\n this.pauseHook?.(paused);\n }\n\n playAgain(): void {\n const w = this.world;\n if (!w) return;\n this.setHud({ ...this.hud, result: null });\n w.emit(START_ROUND);\n }\n\n /** Called by the scene after each stepped frame. */\n afterStep(world: World): void {\n const next = this.read(world);\n const h = this.hud;\n if (next.state !== h.state || next.score !== h.score || next.coins !== h.coins || next.lives !== h.lives || next.best !== h.best)\n this.setHud({ ...next, result: next.state === \"playing\" ? null : h.result });\n }\n\n // useSyncExternalStore\n subscribe = (fn: () => void): (() => void) => {\n this.listeners.add(fn);\n return () => this.listeners.delete(fn);\n };\n snapshot = (): Hud => this.hud;\n\n private read(world: World): Omit<Hud, \"result\"> {\n const n = (k: string) => {\n const v = world.vars[k];\n return typeof v === \"number\" ? Math.floor(v) : 0;\n };\n const s = world.vars[\"state\"];\n return {\n state: s === \"playing\" || s === \"ended\" ? s : \"ready\",\n score: n(\"score\"),\n coins: n(\"coins\"),\n lives: n(\"lives\"),\n best: n(\"best\"),\n };\n }\n\n private setHud(hud: Hud): void {\n this.hud = hud;\n for (const fn of this.listeners) fn();\n }\n}\n"
125
+ },
126
+ {
127
+ "path": "scene.ts",
128
+ "content": "import type { EngineScene, SceneMountContext } from \"@idosgames/module-sdk\";\r\nimport { createPhaserView, type PhaserView } from \"@idosgames/game-kit/phaser\";\r\nimport type { KitRuntime } from \"./runtime\";\r\n\r\n// The constructor game as a host-driven scene: the spec is read and the world started on mount, the\r\n// Phaser adapter draws it, the Mode Router pauses it when the mode is hidden, the game's own menu\r\n// pauses it while it is open over the field.\r\nexport function createKitScene(runtime: KitRuntime): EngineScene {\r\n let view: PhaserView | null = null;\r\n let active = true;\r\n let menuOpen = false;\r\n const apply = () => view?.setRunning(active && !menuOpen);\r\n return {\r\n surface: \"fullbleed-canvas\",\r\n mount(ctx: SceneMountContext): void {\r\n const world = runtime.start();\r\n view = createPhaserView({\r\n parent: ctx.host,\r\n world,\r\n palette: runtime.palette(),\r\n look: runtime.look(),\r\n onStep: (w) => runtime.afterStep(w),\r\n });\r\n runtime.pauseHook = (paused) => {\r\n menuOpen = paused;\r\n apply();\r\n };\r\n },\r\n activate(): void {\r\n active = true;\r\n apply();\r\n },\r\n suspend(): void {\r\n active = false;\r\n apply();\r\n },\r\n capture(): Promise<Blob | null> {\r\n return view?.capture() ?? Promise.resolve(null);\r\n },\r\n destroy(): void {\r\n runtime.pauseHook = null;\r\n view?.destroy();\r\n view = null;\r\n runtime.stop();\r\n },\r\n };\r\n}\r\n"
129
+ },
130
+ {
131
+ "path": "survivors/HeroPortrait.tsx",
132
+ "content": "export { HeroPortrait } from \"../meta/HeroPortrait\";\n"
133
+ },
134
+ {
135
+ "path": "survivors/i18n.ts",
136
+ "content": "// The words moved to the common meta (../meta/i18n.ts): the runner speaks the same table.\nexport * from \"../meta/i18n\";\n"
137
+ },
138
+ {
139
+ "path": "survivors/Lobby.tsx",
140
+ "content": "import { useSyncExternalStore, type ReactNode } from \"react\";\r\nimport { chapterOf, HEROES, PASSIVES, WEAPONS, type MetaHeroDef } from \"@idosgames/game-kit\";\r\nimport { Icon } from \"@idosgames/react/ui\";\r\nimport { st, stAny } from \"../meta/i18n\";\r\nimport type { GameMeta } from \"../meta/meta\";\r\nimport { ArtIcon, type ArtLookProps } from \"../meta/ArtIcon\";\r\nimport { GameLobby } from \"../meta/Lobby\";\r\n\r\nconst clockOf = (seconds: number) => `${Math.floor(seconds / 60)}:${String(Math.floor(seconds % 60)).padStart(2, \"0\")}`;\r\n\r\n/** The survivors' home between runs: the chapter (its minutes, record, first-clear gold), upgrades, heroes. */\r\nexport function SurvivorsLobby({ meta, look, baseSeconds, language }: { meta: GameMeta; look: ArtLookProps; baseSeconds: number; language: string }): ReactNode {\r\n const p = useSyncExternalStore(meta.subscribe, meta.snapshot, meta.snapshot).progress;\r\n const c = chapterOf(p.chapter, baseSeconds);\r\n const cleared = p.cleared.includes(p.chapter);\r\n const best = p.best[String(p.chapter)] ?? 0;\r\n const card = (\r\n <>\r\n <div style={{ display: \"flex\", gap: 8, alignItems: \"center\", fontWeight: 900, fontSize: 18 }}>\r\n {cleared ? <Icon glyph=\"check\" size={18} /> : <Icon glyph=\"swords\" size={18} />}\r\n <span>{cleared ? st(\"cleared\") : `${Math.round(c.duration / 60)} ${st(\"minutes\")}`}</span>\r\n </div>\r\n <div style={{ fontSize: 12, opacity: 0.85, display: \"flex\", gap: 10, flexWrap: \"wrap\", justifyContent: \"center\" }}>\r\n {best > 0 && <span>{`${st(\"bestTime\")}: ${clockOf(best)}`}</span>}\r\n {!cleared && (\r\n <span style={{ display: \"inline-flex\", gap: 4, alignItems: \"center\" }}>\r\n {st(\"firstClear\")}: <Icon glyph=\"coin\" size={13} /> {c.firstClearGold}\r\n </span>\r\n )}\r\n </div>\r\n </>\r\n );\r\n const heroInfo = (m: MetaHeroDef) => {\r\n const h = HEROES.find((x) => x.id === m.id) ?? HEROES[0]!;\r\n const w = WEAPONS[h.weapon];\r\n return (\r\n <>\r\n <div style={{ fontSize: 12, opacity: 0.8 }}>{st(\"startsWith\")}</div>\r\n <div style={{ display: \"flex\", gap: 6, alignItems: \"center\" }}>\r\n <ArtIcon id={w.art} color={w.color} size={40} look={look} />\r\n {h.passive && <ArtIcon id={PASSIVES[h.passive].art} color={PASSIVES[h.passive].color} size={32} look={look} />}\r\n </div>\r\n <div style={{ fontSize: 12, fontWeight: 800 }}>\r\n {stAny(`w.${h.weapon}`)}\r\n {h.passive ? ` + ${stAny(`p.${h.passive}`)}` : \"\"}\r\n </div>\r\n </>\r\n );\r\n };\r\n return <GameLobby meta={meta} look={look} language={language} label=\"chapter\" card={card} heroInfo={heroInfo} />;\r\n}\r\n"
141
+ },
142
+ {
143
+ "path": "survivors/meta.ts",
144
+ "content": "// The survivors' meta: the common meta of the constructor's games (../meta/meta.ts) with the survivors' own\n// description (chapters, upgrades, heroes — SURVIVORS_META of game-kit).\n\nimport type { IDosGamesClient } from \"@idosgames/core\";\nimport { chapterOf, heroCharacterOf, SURVIVORS_META } from \"@idosgames/game-kit\";\nimport { LocalGameMeta, ServerGameMeta, type GameMeta } from \"../meta/meta\";\n\nexport * from \"../meta/meta\";\n\nexport type SurvivorsMeta = GameMeta;\n\n/** UserCustomData key of the survivors' progress. */\nexport const PROGRESS_KEY = SURVIVORS_META.character;\n/** The Character ids of the heroes you open (the first one is everyone's). */\nexport const heroCharacter = (heroID: string) => heroCharacterOf(SURVIVORS_META, heroID);\n\nexport class LocalSurvivorsMeta extends LocalGameMeta {\n constructor() {\n super(SURVIVORS_META);\n }\n}\n\nexport class ServerSurvivorsMeta extends ServerGameMeta {\n constructor(client: IDosGamesClient, softID: string) {\n super(client, softID, SURVIVORS_META);\n }\n}\n\n/** Seconds of a chapter (for its card), by the spec's base duration. */\nexport const chapterSeconds = (chapter: number, baseSeconds: number) => chapterOf(chapter, baseSeconds).duration;\n"
145
+ },
146
+ {
147
+ "path": "survivors/RunOverlay.tsx",
148
+ "content": "import { useEffect, useState, useSyncExternalStore, type CSSProperties, type ReactNode } from \"react\";\nimport { ArtIcon, type ArtLookProps } from \"../meta/ArtIcon\";\nimport {\n LEVEL_UP,\n MAX_LEVEL,\n offerFromVar,\n PASSIVES,\n REVIVE_OFFER,\n ROUND_ENDED,\n ROUND_STARTED,\n UPGRADE_PICK,\n UPGRADE_PICKED,\n UPGRADE_REROLL,\n WEAPONS,\n type PassiveId,\n type UpgradeOption,\n type WeaponId,\n type World,\n} from \"@idosgames/game-kit\";\nimport { Button, Icon, Ribbon, panel, v } from \"@idosgames/react/ui\";\nimport { st, stAny } from \"./i18n\";\nimport type { SurvivorsMeta } from \"./meta\";\n\nexport { ArtIcon, type ArtLookProps } from \"../meta/ArtIcon\";\n\n/** Name, picture and words of one card. */\nexport function optionView(o: UpgradeOption): { art: string; color: string; name: string; text: string } {\n if (o.kind === \"weapon\") {\n const d = WEAPONS[o.id as WeaponId];\n return { art: d.art, color: d.color, name: stAny(`w.${o.id}`), text: stAny(o.level >= MAX_LEVEL ? `w.${o.id}.5` : `w.${o.id}.d`) };\n }\n if (o.kind === \"passive\") {\n const d = PASSIVES[o.id as PassiveId];\n return { art: d.art, color: d.color, name: stAny(`p.${o.id}`), text: stAny(`p.${o.id}.d`) };\n }\n if (o.kind === \"heal\") return { art: \"heart\", color: \"danger\", name: st(\"heal\"), text: st(\"heal.d\") };\n return { art: \"coin\", color: \"coin\", name: st(\"gold\"), text: st(\"gold.d\") };\n}\n\n/**\n * Over the field while a run plays: the level-up / chest cards (one of three, or other cards for gems or\n * an ad). The world is frozen while they are up; the answer goes back as an event. The revive offer is the\n * common ReviveDialog.\n */\nexport function RunOverlay({ world, meta, look }: { world: World | null; meta: SurvivorsMeta; look: ArtLookProps }): ReactNode {\n const snap = useSyncExternalStore(meta.subscribe, meta.snapshot, meta.snapshot);\n const [offer, setOffer] = useState<{ options: UpgradeOption[]; source: string; level: number } | null>(null);\n\n useEffect(() => {\n setOffer(null);\n if (!world) return;\n // a remount while a choice is up: read it from the vars\n if (world.vars[\"choosing\"] === true) setOffer({ options: offerFromVar(world.vars[\"survivors_offer\"]), source: \"level\", level: Number(world.vars[\"xp_level\"] ?? 1) });\n const offs = [\n world.on(LEVEL_UP, (p) => setOffer({ options: (p[\"options\"] as UpgradeOption[]) ?? [], source: String(p[\"source\"] ?? \"level\"), level: Number(p[\"level\"] ?? 1) })),\n // a card picked by the keys (or a bot) closes the cards too\n world.on(UPGRADE_PICKED, () => setOffer(null)),\n // the revive dialog (meta/ReviveDialog) takes the screen\n world.on(REVIVE_OFFER, () => setOffer(null)),\n world.on(ROUND_STARTED, () => setOffer(null)),\n world.on(ROUND_ENDED, () => setOffer(null)),\n ];\n return () => offs.forEach((off) => off());\n }, [world]);\n\n if (!world) return null;\n\n const pick = (index: number) => {\n setOffer(null);\n world.emit(UPGRADE_PICK, { index });\n };\n const reroll = async (via: \"gems\" | \"ad\") => {\n if (await meta.pay(\"reroll\", via)) world.emit(UPGRADE_REROLL, {});\n };\n\n if (offer) {\n return (\n <div style={backdrop}>\n <Ribbon tone={offer.source === \"chest\" ? \"gold\" : \"green\"} size={24}>\n {offer.source === \"chest\" ? st(\"chest\") : st(\"levelUp\")}\n </Ribbon>\n <div style={{ fontWeight: 800, opacity: 0.9, marginTop: 4 }}>{st(\"pick\")}</div>\n <div style={cards}>\n {offer.options.map((o, i) => {\n const view = optionView(o);\n const strong = o.kind === \"weapon\" && o.level >= MAX_LEVEL;\n return (\n <button key={`${o.kind}:${o.id}:${i}`} type=\"button\" onClick={() => pick(i)} style={{ ...panel, ...card, ...(strong ? strongCard : {}), animationDelay: `${i * 70}ms` }}>\n <div style={tag(o.level === 1 && (o.kind === \"weapon\" || o.kind === \"passive\") ? v.green : strong ? v.gold : v.blue)}>\n {strong ? st(\"strong\") : o.level === 1 && (o.kind === \"weapon\" || o.kind === \"passive\") ? st(\"new\") : o.kind === \"heal\" || o.kind === \"gold\" ? \"+\" : `${st(\"level\")} ${o.level}`}\n </div>\n <ArtIcon id={view.art} color={view.color} size={64} look={look} />\n <div style={{ fontWeight: 900, fontSize: 17 }}>{view.name}</div>\n <div style={{ fontSize: 13, opacity: 0.85, lineHeight: 1.25 }}>{view.text}</div>\n {(o.kind === \"weapon\" || o.kind === \"passive\") && (\n <div style={{ display: \"flex\", gap: 3, marginTop: \"auto\" }}>\n {Array.from({ length: MAX_LEVEL }, (_, k) => (\n <span key={k} style={{ width: 10, height: 10, borderRadius: 3, background: k < o.level ? (k === o.level - 1 ? v.gold : v.green) : \"rgba(0,0,0,0.35)\" }} />\n ))}\n </div>\n )}\n </button>\n );\n })}\n </div>\n <div style={{ display: \"flex\", gap: 10, flexWrap: \"wrap\", justifyContent: \"center\" }}>\n {snap.gemsPay && (\n <Button tone=\"purple\" size=\"sm\" busy={snap.busy === \"reroll:gems\"} disabled={snap.hard < snap.prices.reroll} onClick={() => void reroll(\"gems\")}>\n <span style={row}>\n {st(\"reroll\")} <Icon glyph=\"gem\" size={16} /> {snap.prices.reroll}\n </span>\n </Button>\n )}\n {snap.adAvailable && (\n <Button tone=\"orange\" size=\"sm\" busy={snap.busy === \"reroll:ad\"} onClick={() => void reroll(\"ad\")}>\n <span style={row}>\n {st(\"reroll\")} <Icon glyph=\"megaphone\" size={16} />\n </span>\n </Button>\n )}\n </div>\n </div>\n );\n }\n\n return null;\n}\n\nconst backdrop: CSSProperties = {\n position: \"absolute\",\n inset: 0,\n display: \"flex\",\n flexDirection: \"column\",\n alignItems: \"center\",\n justifyContent: \"center\",\n gap: 10,\n padding: 14,\n background: \"rgba(8,6,16,0.6)\",\n pointerEvents: \"auto\",\n color: \"#fff\",\n animation: \"idos-fade-in .15s both\",\n zIndex: 6,\n};\n\nconst cards: CSSProperties = {\n display: \"flex\",\n gap: 10,\n width: \"min(560px, 100%)\",\n justifyContent: \"center\",\n alignItems: \"stretch\",\n};\n\nconst card: CSSProperties = {\n flex: \"1 1 0\",\n minWidth: 0,\n maxWidth: 180,\n minHeight: 220,\n padding: \"14px 8px 12px\",\n display: \"flex\",\n flexDirection: \"column\",\n alignItems: \"center\",\n gap: 6,\n textAlign: \"center\",\n color: \"#fff\",\n cursor: \"pointer\",\n font: \"inherit\",\n animation: \"idos-pop-in .3s both\",\n position: \"relative\",\n};\n\nconst strongCard: CSSProperties = {\n boxShadow: \"0 0 0 3px #ffd54f, 0 0 22px rgba(255,213,79,0.65)\",\n};\n\nconst tag = (bg: string): CSSProperties => ({\n background: bg,\n color: \"#fff\",\n fontWeight: 900,\n fontSize: 11,\n letterSpacing: 0.5,\n padding: \"2px 8px\",\n borderRadius: 8,\n marginTop: -24,\n marginBottom: 2,\n boxShadow: \"0 2px 0 rgba(0,0,0,0.35)\",\n whiteSpace: \"nowrap\",\n});\n\nconst row: CSSProperties = { display: \"inline-flex\", alignItems: \"center\", gap: 6 };\n"
149
+ },
150
+ {
151
+ "path": "TopBar.tsx",
152
+ "content": "import type { ReactNode } from \"react\";\nimport { Icon, currencyGlyph } from \"@idosgames/react/ui\";\nimport { t } from \"./i18n\";\n\n/** One balance chip of the bar: a currency's picture, its amount, what the round is adding to it. */\nexport interface BarBalance {\n readonly key: string;\n readonly currencyID: string;\n readonly amount: number;\n /** Earned in the round, not claimed yet (\"+12\"). */\n readonly plus?: number;\n}\n\n/**\n * The game's top bar (owner, 07.11.2026: minimal, native, no separate bars): ONE menu button top-left (every\n * system of the app, over the game), what the round shows in the middle, the balances top-right in the same\n * chips. The constructor's test page draws the same bar on demo balances.\n */\nexport function GameTopBar({ onMenu, badges, center, balances }: { onMenu: () => void; badges: number; center?: ReactNode; balances: readonly BarBalance[] }): ReactNode {\n return (\n <div\n style={{\n position: \"absolute\",\n top: \"calc(var(--idos-safe-top, 0px) + 10px)\",\n left: 10,\n right: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n zIndex: 1,\n }}\n >\n <button\n type=\"button\"\n aria-label={t(\"menu\")}\n onClick={onMenu}\n style={{ ...chipStyle, position: \"relative\", pointerEvents: \"auto\", cursor: \"pointer\", border: \"none\", padding: 8, color: \"inherit\" }}\n >\n <Icon glyph=\"menu\" size={22} color=\"#fff\" />\n {badges > 0 && <span style={{ position: \"absolute\", top: -3, right: -3, width: 10, height: 10, borderRadius: 5, background: \"#ff4d4f\" }} />}\n </button>\n <div style={{ flex: \"1 1 auto\", display: \"flex\", justifyContent: \"center\", gap: 8 }}>{center}</div>\n {balances.map((b) => (\n <div key={b.key} style={chipStyle}>\n <Icon glyph={currencyGlyph(b.currencyID)} size={20} />\n <span>{b.amount}</span>\n {b.plus !== undefined && b.plus > 0 && <span style={{ color: \"#ffd54f\" }}>{`+${b.plus}`}</span>}\n </div>\n ))}\n </div>\n );\n}\n\nexport const chipStyle = {\n background: \"rgba(0,0,0,0.38)\",\n borderRadius: 12,\n padding: \"6px 12px\",\n display: \"flex\",\n gap: 6,\n alignItems: \"center\",\n fontWeight: 700,\n fontSize: 16,\n} as const;\n"
153
+ },
154
+ {
155
+ "path": "TouchPad.tsx",
156
+ "content": "import { useEffect, type CSSProperties, type PointerEvent, type ReactNode } from \"react\";\r\nimport { INPUT_TOPIC, type World } from \"@idosgames/game-kit\";\r\nimport { Icon } from \"@idosgames/react/ui\";\r\n\r\ntype PadKey = \"left\" | \"right\" | \"up\" | \"down\" | \"strike\";\r\n\r\n/**\r\n * The platformer's touch buttons (owner, 08.11.2026: the player's own controls): ◀ ▶ bottom-left, the sword, jump\r\n * (and the dash once the stars open it) bottom-right. They are page buttons, so several fingers work at once; each sends the\r\n * same key / key-up the keyboard sends (the sword: the click on the field), the block cannot tell them apart.\r\n */\r\nexport function TouchPad({ world, dash }: { world: World; dash: boolean }): ReactNode {\r\n const send = (key: PadKey, down: boolean) => {\r\n // the sword is the mouse's click on the field (a tap), not a key\r\n if (key === \"strike\") {\r\n if (down) world.emit(INPUT_TOPIC, { type: \"tap\", x: 0, y: 0 });\r\n return;\r\n }\r\n world.emit(INPUT_TOPIC, { type: down ? \"key\" : \"key-up\", key });\r\n };\r\n // a pad that goes away (the round ended) lets every key go\r\n useEffect(() => () => ([\"left\", \"right\", \"up\", \"down\"] as const).forEach((k) => world.emit(INPUT_TOPIC, { type: \"key-up\", key: k })), [world]);\r\n\r\n const button = (key: PadKey, content: ReactNode, size: number) => {\r\n const press = (e: PointerEvent<HTMLButtonElement>) => {\r\n e.preventDefault();\r\n e.currentTarget.setPointerCapture(e.pointerId);\r\n send(key, true);\r\n };\r\n const release = (e: PointerEvent<HTMLButtonElement>) => {\r\n e.preventDefault();\r\n send(key, false);\r\n };\r\n return (\r\n <button\r\n type=\"button\"\r\n aria-label={key}\r\n onPointerDown={press}\r\n onPointerUp={release}\r\n onPointerCancel={release}\r\n onContextMenu={(e) => e.preventDefault()}\r\n style={{ ...pad, width: size, height: size }}\r\n >\r\n {content}\r\n </button>\r\n );\r\n };\r\n\r\n return (\r\n <div style={row}>\r\n <div style={{ display: \"flex\", gap: 12 }}>\r\n {button(\"left\", <span style={arrow}>◀</span>, 76)}\r\n {button(\"right\", <span style={arrow}>▶</span>, 76)}\r\n </div>\r\n <div style={{ display: \"flex\", gap: 12, alignItems: \"flex-end\" }}>\r\n {dash && button(\"down\", <Icon glyph=\"energy\" size={28} color=\"#fff\" />, 60)}\r\n {button(\"strike\", <Sword />, 76)}\r\n {button(\"up\", <span style={{ ...arrow, fontSize: 34 }}>▲</span>, 88)}\r\n </div>\r\n </div>\r\n );\r\n}\r\n\r\n/** A sword, blade up to the right: the strike button reads as \"hit\" at a glance. */\r\nfunction Sword(): ReactNode {\r\n return (\r\n <svg width=\"40\" height=\"40\" viewBox=\"0 0 40 40\" aria-hidden=\"true\">\r\n <path d=\"M33 5 L35 7 L17 25 L15 23 Z\" fill=\"#e9eef5\" stroke=\"#1b1d24\" strokeWidth=\"2\" strokeLinejoin=\"round\" />\r\n <path d=\"M11 21 L19 29\" stroke=\"#f2c14e\" strokeWidth=\"5\" strokeLinecap=\"round\" />\r\n <path d=\"M11 21 L19 29\" stroke=\"#1b1d24\" strokeWidth=\"1.5\" strokeLinecap=\"round\" opacity=\"0.5\" />\r\n <path d=\"M14 26 L7 33\" stroke=\"#8a5a34\" strokeWidth=\"5\" strokeLinecap=\"round\" />\r\n <circle cx=\"6\" cy=\"34\" r=\"3\" fill=\"#f2c14e\" stroke=\"#1b1d24\" strokeWidth=\"1.5\" />\r\n </svg>\r\n );\r\n}\r\n\r\nconst row: CSSProperties = {\r\n position: \"absolute\",\r\n left: 14,\r\n right: 14,\r\n bottom: \"calc(var(--idos-safe-bottom, 0px) + 16px)\",\r\n display: \"flex\",\r\n justifyContent: \"space-between\",\r\n alignItems: \"flex-end\",\r\n pointerEvents: \"none\",\r\n zIndex: 3,\r\n};\r\n\r\nconst pad: CSSProperties = {\r\n pointerEvents: \"auto\",\r\n touchAction: \"none\",\r\n userSelect: \"none\",\r\n WebkitUserSelect: \"none\",\r\n borderRadius: \"50%\",\r\n border: \"3px solid rgba(255,255,255,0.55)\",\r\n background: \"rgba(0,0,0,0.32)\",\r\n color: \"#fff\",\r\n display: \"flex\",\r\n alignItems: \"center\",\r\n justifyContent: \"center\",\r\n cursor: \"pointer\",\r\n padding: 0,\r\n};\r\n\r\nconst arrow: CSSProperties = { fontSize: 30, lineHeight: 1, fontWeight: 900, textShadow: \"0 2px 0 rgba(0,0,0,0.35)\" };\r\n"
157
+ },
158
+ {
159
+ "path": "towerDefense/Lobby.tsx",
160
+ "content": "import { useSyncExternalStore, type ReactNode } from \"react\";\r\nimport { BRANCH_LEVEL, SPELLS, TD_HEROES, TD_LEVELS, TOWERS, tdLevelOf, tdStarsToEnter, type MetaHeroDef } from \"@idosgames/game-kit\";\r\nimport { Icon, v } from \"@idosgames/react/ui\";\r\nimport { st, stAny } from \"../meta/i18n\";\r\nimport { starsTotal, type GameMeta } from \"../meta/meta\";\r\nimport { ArtIcon, type ArtLookProps } from \"../meta/ArtIcon\";\r\nimport { GameLobby } from \"../meta/Lobby\";\r\nimport { Stars } from \"../runner/Lobby\";\r\n\r\n/**\r\n * Tower defense's home between levels: the level (its world, its stars, a boss), what is new on it — a tower, a\r\n * spell, a foe, the branches — the next world's gate, the first-clear reward; upgrades and commanders below.\r\n */\r\nexport function TowerDefenseLobby({ meta, look, language }: { meta: GameMeta; look: ArtLookProps; language: string }): ReactNode {\r\n const p = useSyncExternalStore(meta.subscribe, meta.snapshot, meta.snapshot).progress;\r\n const level = tdLevelOf(p.chapter);\r\n const cleared = p.cleared.includes(p.chapter);\r\n const stars = p.stars[String(p.chapter)] ?? 0;\r\n const total = starsTotal(p);\r\n const gate = p.chapter === p.unlocked && cleared && p.chapter < TD_LEVELS ? tdStarsToEnter(p.chapter + 1) : 0;\r\n // what this level brings for the first time\r\n const fresh: Array<{ key: string; text: string; art?: string }> = [];\r\n for (const t of TOWERS) if (t.unlockLevel === level.level) fresh.push({ key: t.id, text: stAny(`t.${t.id}`), art: t.tiers[0]!.art });\r\n for (const s of SPELLS) if (s.unlockLevel === level.level) fresh.push({ key: s.id, text: stAny(`sp.${s.id}`), art: s.icon });\r\n if (level.level === BRANCH_LEVEL) fresh.push({ key: \"branches\", text: st(\"branchesOpen\"), art: \"td_archer_sniper\" });\r\n if (level.fresh && level.level > 1) fresh.push({ key: level.fresh, text: stAny(`e.${level.fresh}`) });\r\n const card = (\r\n <>\r\n <div style={{ display: \"flex\", gap: 10, alignItems: \"center\", fontWeight: 900, fontSize: 18 }}>\r\n <span>{`${st(\"world\")} ${level.world}-${level.index}`}</span>\r\n {level.boss && (\r\n <span style={{ display: \"inline-flex\", gap: 4, alignItems: \"center\", color: v.red }}>\r\n <Icon glyph=\"swords\" size={16} /> {st(\"boss\")}\r\n </span>\r\n )}\r\n <Stars n={stars} size={20} />\r\n </div>\r\n {fresh.length > 0 && (\r\n <div style={{ fontSize: 12, display: \"flex\", gap: 10, flexWrap: \"wrap\", justifyContent: \"center\", alignItems: \"center\" }}>\r\n <span style={{ color: v.green, fontWeight: 900 }}>{st(\"newHere\")}:</span>\r\n {fresh.map((f) => (\r\n <span key={f.key} style={{ display: \"inline-flex\", gap: 4, alignItems: \"center\", fontWeight: 800 }}>\r\n {f.art && <ArtIcon id={f.art} size={20} look={look} />}\r\n {f.text}\r\n </span>\r\n ))}\r\n </div>\r\n )}\r\n <div style={{ fontSize: 12, opacity: 0.9, display: \"flex\", gap: 10, flexWrap: \"wrap\", justifyContent: \"center\" }}>\r\n <span>{`${st(\"starsAll\")}: ★ ${total}`}</span>\r\n {gate > total && <span style={{ color: v.gold, fontWeight: 800 }}>{`${st(\"world\")} ${level.world + 1}: ${st(\"needStars\")} ★ ${gate}`}</span>}\r\n {!cleared && (\r\n <span style={{ display: \"inline-flex\", gap: 4, alignItems: \"center\" }}>\r\n {st(\"firstClear\")}: <Icon glyph=\"coin\" size={13} /> {level.firstClearGold}\r\n </span>\r\n )}\r\n </div>\r\n </>\r\n );\r\n const heroInfo = (m: MetaHeroDef) => {\r\n const h = TD_HEROES.find((x) => x.id === m.id) ?? TD_HEROES[0]!;\r\n return (\r\n <>\r\n <div style={{ fontSize: 12, opacity: 0.8 }}>{st(\"perk\")}</div>\r\n <div style={{ fontSize: 13, fontWeight: 800, minHeight: 34 }}>{stAny(`perk.${h.perk}`)}</div>\r\n </>\r\n );\r\n };\r\n return <GameLobby meta={meta} look={look} language={language} label=\"levelN\" card={card} heroInfo={heroInfo} />;\r\n}\r\n"
161
+ },
162
+ {
163
+ "path": "tycoon/Lobby.tsx",
164
+ "content": "import { useSyncExternalStore, type ReactNode } from \"react\";\nimport { TY_HEROES, TY_LEVELS, tyBusinessOf, tyEventOf, tyLevelOf, tySkillOf, tyStarsToEnter, shortCash, type MetaHeroDef, type TyGoalDef } from \"@idosgames/game-kit\";\nimport { Icon, v } from \"@idosgames/react/ui\";\nimport { st, stAny } from \"../meta/i18n\";\nimport { starsTotal, type GameMeta } from \"../meta/meta\";\nimport { ArtIcon, type ArtLookProps } from \"../meta/ArtIcon\";\nimport { GameLobby } from \"../meta/Lobby\";\nimport { Stars } from \"../runner/Lobby\";\n\nconst clockOf = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, \"0\")}`;\n\n/** A goal as words: what to earn, which business to which level, how many managers. */\nfunction GoalLine({ g, look }: { g: TyGoalDef; look: ArtLookProps }): ReactNode {\n const art = g.kind === \"cash\" ? \"coin\" : g.kind === \"managers\" ? \"ty_manager\" : (tyBusinessOf(g.biz ?? \"\")?.art ?? \"biz1\");\n const words = g.kind === \"cash\" ? `${st(\"g.cash\")} ${shortCash(g.n)}` : g.kind === \"managers\" ? `${st(\"g.managers\")}: ${g.n}` : `${stAny(`b.${g.biz}`)} ${st(\"g.level\")} ${g.n}`;\n return (\n <span style={{ display: \"inline-flex\", gap: 4, alignItems: \"center\", fontWeight: 800 }}>\n <ArtIcon id={art} size={20} look={look} /> {words}\n </span>\n );\n}\n\n/**\n * Tycoon's home between cities: the city (its world, its stars, a big city at the end of a world), what it is: its\n * businesses, its goals, the day's length, the events of the day: the next world's gate, the first-clear reward; the\n * upgrades and heroes below (each hero: a skill).\n */\nexport function TycoonLobby({ meta, look, language }: { meta: GameMeta; look: ArtLookProps; language: string }): ReactNode {\n const p = useSyncExternalStore(meta.subscribe, meta.snapshot, meta.snapshot).progress;\n const level = tyLevelOf(p.chapter);\n const cleared = p.cleared.includes(p.chapter);\n const stars = p.stars[String(p.chapter)] ?? 0;\n const total = starsTotal(p);\n const gate = p.chapter === p.unlocked && cleared && p.chapter < TY_LEVELS ? tyStarsToEnter(p.chapter + 1) : 0;\n const def = level.def;\n const card = (\n <>\n <div style={{ display: \"flex\", gap: 10, alignItems: \"center\", fontWeight: 900, fontSize: 18 }}>\n <span>{`${st(\"world\")} ${level.world}-${level.index}`}</span>\n {level.boss && (\n <span style={{ display: \"inline-flex\", gap: 4, alignItems: \"center\", color: v.gold }}>\n <Icon glyph=\"crown\" size={16} /> {st(\"bigCity\")}\n </span>\n )}\n <Stars n={stars} size={20} />\n </div>\n <div style={{ fontSize: 12, display: \"flex\", gap: 10, flexWrap: \"wrap\", justifyContent: \"center\", alignItems: \"center\" }}>\n {def.businesses.map((id) => {\n const b = tyBusinessOf(id);\n return (\n <span key={id} style={{ display: \"inline-flex\", gap: 4, alignItems: \"center\", fontWeight: 800 }}>\n {b && <ArtIcon id={b.art} size={24} look={look} />}\n {stAny(`b.${id}`)}\n </span>\n );\n })}\n </div>\n <div style={{ fontSize: 12, display: \"flex\", gap: 10, flexWrap: \"wrap\", justifyContent: \"center\", alignItems: \"center\" }}>\n <span style={{ fontWeight: 800, opacity: 0.85 }}>{`${st(\"goals\")}:`}</span>\n {def.goals.map((g, i) => (\n <GoalLine key={i} g={g} look={look} />\n ))}\n </div>\n <div style={{ fontSize: 12, display: \"flex\", gap: 10, flexWrap: \"wrap\", justifyContent: \"center\", alignItems: \"center\" }}>\n <span style={{ display: \"inline-flex\", gap: 4, alignItems: \"center\", fontWeight: 800 }}>\n <ArtIcon id=\"hourglass\" size={18} look={look} /> {`${st(\"day\")}: ${clockOf(level.day)}`}\n </span>\n {def.events.map((id) => {\n const e = tyEventOf(id);\n return (\n <span key={id} style={{ display: \"inline-flex\", gap: 4, alignItems: \"center\", fontWeight: 800 }}>\n {e && <ArtIcon id={e.art} size={18} look={look} />} {stAny(`ev.${id}`)}\n </span>\n );\n })}\n </div>\n <div style={{ fontSize: 12, opacity: 0.9, display: \"flex\", gap: 10, flexWrap: \"wrap\", justifyContent: \"center\" }}>\n <span>{`${st(\"starsAll\")}: ★ ${total}`}</span>\n {gate > total && <span style={{ color: v.gold, fontWeight: 800 }}>{`${st(\"world\")} ${level.world + 1}: ${st(\"needStars\")} ★ ${gate}`}</span>}\n {!cleared && (\n <span style={{ display: \"inline-flex\", gap: 4, alignItems: \"center\" }}>\n {st(\"firstClear\")}: <Icon glyph=\"coin\" size={13} /> {level.firstClearGold}\n </span>\n )}\n </div>\n </>\n );\n const heroInfo = (m: MetaHeroDef) => {\n const h = TY_HEROES.find((x) => x.id === m.id) ?? TY_HEROES[0]!;\n const sk = tySkillOf(h.skill);\n return (\n <div style={{ fontSize: 13, fontWeight: 800, minHeight: 34, display: \"flex\", gap: 4, alignItems: \"center\", justifyContent: \"center\" }}>\n {sk && <ArtIcon id={sk.art} size={18} look={look} />}\n {stAny(`sk.${h.skill}`)}\n </div>\n );\n };\n return <GameLobby meta={meta} look={look} language={language} label=\"cityN\" card={card} heroInfo={heroInfo} />;\n}\n"
165
+ },
166
+ {
167
+ "path": "module.meta.json",
168
+ "content": "{\n \"id\": \"kit-game\",\n \"type\": \"template\",\n \"uses\": [\"premium\"],\n \"summary\": \"The game constructor's runtime: plays the title's GameSpec (a whole game as data) on Phaser; without one, a demo lane runner.\",\n \"description\": \"One prebuilt runtime for every 2D game of the platform's constructor. The title's config section GameSpec describes the game as data: genre presets as modes, their parameters (difficulty, lanes, lives…), the palette, texts in every language, resources and how modes produce and spend them, rules (trigger → condition → effect) and reward multipliers. The module validates the spec against the platform's blocks and presets from @idosgames/game-kit (numbers out of range are clamped, unknown fields kept), runs the core mode in a deterministic world and draws it through the Phaser adapter. Genres so far: an endless lane runner (swipe, arrows or a tap on a side to change lanes; coins, obstacles, growing speed). A title without a GameSpec plays the built-in demo spec. Edit the game by editing the spec, not the code; a new genre is a new preset over the kit's blocks.\",\n \"provides\": [\n \"a game from a GameSpec: no code per game\",\n \"endless lane runner (swipe / arrows / tap a side)\",\n \"palette, texts and parameters from the spec\",\n \"deterministic runs from the spec's seed\",\n \"round flow: tap to start, result screen, play again\",\n \"reward window after a round: claim, x3 for a rewarded ad (in the app), x5 for VIP (server-side multiplier)\"\n ],\n \"tags\": [\"constructor\", \"game-kit\", \"runner\", \"arcade\", \"phaser\", \"2d\", \"data-driven\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
169
+ }
170
+ ]
171
+ }
@@ -36,9 +36,9 @@
36
36
  "version": "0.1.5"
37
37
  },
38
38
  "dependencies": {
39
- "@idosgames/core": "0.26.0",
39
+ "@idosgames/core": "0.26.1",
40
40
  "@idosgames/module-sdk": "0.13.3",
41
- "@idosgames/react": "0.7.0",
41
+ "@idosgames/react": "0.7.1",
42
42
  "react": "19.2.7"
43
43
  },
44
44
  "contentHash": "8aa99b6b9980f06e2a66adf38ff0c1a20d5e2eefc197a5a81b75c1140faf0b66",