@idosgames/mcp 0.1.44 → 0.1.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +1597 -1542
- package/package.json +1 -1
- package/registry/host.json +3 -3
- package/registry/index.json +172 -125
- package/registry/modules/ai.json +5 -5
- package/registry/modules/board-game.json +6 -6
- package/registry/modules/boosts.json +5 -5
- package/registry/modules/character.json +9 -9
- package/registry/modules/chat.json +3 -3
- package/registry/modules/collections.json +5 -5
- package/registry/modules/coop-events.json +6 -6
- package/registry/modules/craft.json +5 -5
- package/registry/modules/deals.json +6 -6
- package/registry/modules/events.json +3 -3
- package/registry/modules/friends.json +3 -3
- package/registry/modules/hero-run.json +15 -234
- package/registry/modules/idle-rpg.json +8 -8
- package/registry/modules/inventory.json +3 -3
- package/registry/modules/invite.json +3 -3
- package/registry/modules/kit-game.json +171 -0
- package/registry/modules/leaderboard.json +3 -3
- package/registry/modules/lootboxes.json +5 -5
- package/registry/modules/mailbox.json +3 -3
- package/registry/modules/marketplace.json +3 -3
- package/registry/modules/mini-game.json +3 -3
- package/registry/modules/multiplayer.json +3 -3
- package/registry/modules/premium.json +5 -5
- package/registry/modules/profile.json +3 -3
- package/registry/modules/pvp.json +3 -3
- package/registry/modules/quests.json +3 -3
- package/registry/modules/rewards.json +3 -3
- package/registry/modules/season.json +3 -3
- package/registry/modules/store.json +6 -6
- package/registry/modules/tutorials.json +3 -3
- package/registry/modules/video-studio.json +9 -9
- package/registry/modules/voxelcraft.json +6 -6
- package/registry/modules/workshop.json +6 -6
- package/registry/skills/currency-system.json +1 -1
- package/registry/skills/idosgames-compose-modules.json +1 -1
- package/registry/skills/idosgames-getting-started.json +1 -1
- package/registry/skills/idosgames-ui-kit.json +1 -1
- package/registry/skills/store-system.json +1 -1
- package/registry/video.json +1 -1
|
@@ -32,12 +32,12 @@
|
|
|
32
32
|
"version": "0.1.4"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@idosgames/core": "0.
|
|
36
|
-
"@idosgames/module-sdk": "0.13.
|
|
37
|
-
"@idosgames/react": "0.
|
|
35
|
+
"@idosgames/core": "0.26.1",
|
|
36
|
+
"@idosgames/module-sdk": "0.13.3",
|
|
37
|
+
"@idosgames/react": "0.7.1",
|
|
38
38
|
"react": "19.2.7"
|
|
39
39
|
},
|
|
40
|
-
"contentHash": "
|
|
40
|
+
"contentHash": "55544f5bae173482aaa22e92612cade99a356547ee80b68fc096dde8fb700b47",
|
|
41
41
|
"files": [
|
|
42
42
|
{
|
|
43
43
|
"path": "i18n.ts",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
},
|
|
50
50
|
{
|
|
51
51
|
"path": "LootboxesScreen.tsx",
|
|
52
|
-
"content": "import type { ModuleContext } from \"@idosgames/module-sdk\";\nimport { useEffect, useMemo, useRef, useState, type ReactNode } from \"react\";\nimport type { FeatureScreenProps } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n EmptyState,\n Icon,\n KitPortal,\n ResourceList,\n UCloseCircle,\n URays,\n URibbon,\n UStyles,\n configSection,\n errorText,\n grantedBy,\n outlined,\n uText,\n useCatalog,\n useCelebrate,\n useSound,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n BULK,\n canOpenBulk,\n canPay,\n lootboxName,\n lootboxOptions,\n lootboxesOpenedBy,\n openableWithItems,\n type InventoryView,\n type LootboxOption,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Chests (Unity LootboxOpeningPopUp): every lootbox of the title with every way to open it — once,\n// or ten at a time where the ceiling and the wallet allow. The box shakes while the server rolls it,\n// bursts, and the rewards fly out. The inventory hands a chest or key item over here\n// (`features.open(\"lootboxes\", { itemID })`): the screen then shows what that item opens.\n\nexport function LootboxesScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const stagger = useStagger();\n const section = configSection(client, \"Lootbox\");\n const all = useMemo(() => lootboxOptions(section), [section]);\n const itemID = typeof args?.itemID === \"string\" ? args.itemID : null;\n const options = itemID ? lootboxesOpenedBy(itemID, all) : all;\n const [opening, setOpening] = useState<{\n option: LootboxOption;\n count: number;\n } | null>(null);\n\n // Handed an item that opens exactly one chest — open its popup right away.\n useEffect(() => {\n if (itemID && options.length === 1)\n setOpening({ option: options[0]!, count: 1 });\n }, [itemID]);\n\n const inventory = state?.InventoryV2 as InventoryView | undefined;\n if (options.length === 0)\n return <EmptyState glyph=\"chest\" text={t(\"noChests\")} />;\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(170px, 1fr))\",\n gap: 12,\n }}\n >\n {options.map((o, i) => {\n const affordable = canPay(o, inventory);\n const bulk = canOpenBulk(o, inventory);\n return (\n <Card\n key={`${o.lootboxID}:${o.optionID}`}\n style={{\n display: \"grid\",\n justifyItems: \"center\",\n gap: 8,\n ...stagger(i),\n }}\n >\n <div style={{ ...outlined, fontSize: 15, textAlign: \"center\" }}>\n {lootboxName(o.lootboxID, catalog.localize)}\n </div>\n <div className=\"idos-bounce\">\n <Icon glyph={o.icon} size={64} fallback=\"chest\" />\n </div>\n {o.name ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {catalog.localize(o.name)}\n </div>\n ) : null}\n <Button\n tone=\"gold\"\n attract={affordable}\n disabled={!affordable}\n onClick={() => setOpening({ option: o, count: 1 })}\n style={{ width: \"100%\" }}\n data-tutorial-anchor={`lootbox:${o.lootboxID}`}\n >\n {o.price.length === 0 ? (\n t(\"open\")\n ) : (\n <ResourceList lines={o.price} size={20} />\n )}\n </Button>\n {bulk ? (\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() => setOpening({ option: o, count: BULK })}\n style={{ width: \"100%\" }}\n >\n {t(\"openTimes\")} ×{BULK}\n </Button>\n ) : null}\n </Card>\n );\n })}\n {opening ? (\n <LootboxOpening\n option={opening.option}\n count={opening.count}\n onClose={() => setOpening(null)}\n />\n ) : null}\n </div>\n );\n}\n\n/**\n * Unity LootboxOpeningPopUp, full screen: the violet glow with the pattern, the orange CHEST ribbon,\n * the box over turning cyan rays and sparkles, the red count badge, \"Tap it now!\". A tap shakes the\n * box while the server rolls it, a white flash bursts it open, and the rewards come in the kit's\n * reward window.\n */\nfunction LootboxOpening({\n option,\n count,\n onClose,\n}: {\n option: LootboxOption;\n count: number;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const play = useSound();\n const kit = useUiKit();\n const boxRef = useRef<HTMLButtonElement>(null);\n const [phase, setPhase] = useState<\"idle\" | \"shake\" | \"burst\">(\"idle\");\n const name = lootboxName(option.lootboxID, catalog.localize);\n\n useEffect(() => {\n play(\"open\");\n }, []);\n\n const openBox = async () => {\n if (phase !== \"idle\") return;\n setPhase(\"shake\");\n const started = Date.now();\n const res = await client.lootbox.open(\n option.lootboxID,\n count,\n option.optionID,\n );\n // Let the shake play for a moment even on a fast server — the anticipation is the point.\n const wait = Math.max(\n 0,\n (kit.motion.on ? 900 : 0) - (Date.now() - started),\n );\n await new Promise((r) => setTimeout(r, wait));\n if (!res.ok) {\n setPhase(\"idle\");\n toast(errorText(res.error), \"error\");\n return;\n }\n play(\"lootbox\");\n kit.fx.burst(boxRef.current);\n setPhase(\"burst\");\n await new Promise((r) => setTimeout(r, kit.motion.on ? 450 : 0));\n onClose();\n // `Resources` is the whole operation — every box of a bulk opening already summed by the server\n // (`Results` is the same per box; adding both would count everything twice).\n celebrate(grantedBy(res.data), { sound: \"levelUp\" });\n };\n\n return (\n <KitPortal>\n <div\n style={{\n position: \"fixed\",\n inset: 0,\n zIndex: 40,\n overflow: \"hidden\",\n // The kit layer lets clicks through; a full-screen window takes them itself.\n pointerEvents: \"auto\",\n display: \"grid\",\n gridTemplateRows: \"auto 1fr auto\",\n justifyItems: \"center\",\n padding:\n \"max(28px, env(safe-area-inset-top)) 16px max(24px, env(safe-area-inset-bottom))\",\n // The sheet's \"Tap it now!\": a blue burst — light from the middle, faint rays around it.\n backgroundColor: v.blueDeep,\n backgroundImage: `repeating-conic-gradient(from 0deg at 50% 45%, rgba(255,255,255,.07) 0 9deg, transparent 9deg 18deg), radial-gradient(ellipse at 50% 45%, color-mix(in srgb, ${v.blue} 65%, #fff) 0%, ${v.blue} 35%, ${v.blueDeep} 80%)`,\n animation: \"idos-u-pop .3s ease-out both\",\n fontFamily: \"inherit\",\n }}\n >\n <UStyles />\n <div style={{ width: \"100%\", maxWidth: 360, marginTop: 12 }}>\n <URibbon tone=\"orange\" width={340}>\n {count > 1 ? `${name.toUpperCase()} ×${count}` : name.toUpperCase()}\n </URibbon>\n </div>\n\n <div\n style={{\n position: \"relative\",\n width: 300,\n height: 300,\n alignSelf: \"center\",\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <span style={{ position: \"absolute\", inset: -40, opacity: 0.9 }}>\n <URays size={380} color=\"rgba(255,255,255,.35)\" />\n </span>\n {SPARKS.map(([x, y, s, d], i) => (\n <span\n key={i}\n style={{\n position: \"absolute\",\n left: x,\n top: y,\n animation: `idos-u-float ${d}s ease-in-out infinite`,\n opacity: 0.85,\n }}\n >\n <Icon glyph=\"star\" size={s} />\n </span>\n ))}\n <button\n type=\"button\"\n ref={boxRef}\n onClick={() => void openBox()}\n aria-label={t(\"open\")}\n style={{\n position: \"relative\",\n border: \"none\",\n background: \"none\",\n padding: 0,\n cursor: phase === \"idle\" ? \"pointer\" : \"default\",\n animation:\n phase === \"shake\"\n ? \"idos-u-shake .18s linear infinite\"\n : phase === \"burst\"\n ? \"idos-loot-burst .45s ease-in forwards\"\n : \"idos-u-float 1.8s ease-in-out infinite\",\n }}\n >\n <Icon glyph={option.icon} size={210} fallback=\"chest\" />\n {count > 1 ? (\n <span\n style={{\n position: \"absolute\",\n left: 6,\n top: 6,\n minWidth: 50,\n height: 50,\n borderRadius: 25,\n background: `radial-gradient(circle at 50% 35%, color-mix(in srgb, ${v.red} 70%, #fff) 0%, ${v.red} 70%)`,\n border: `3px solid ${v.ink}`,\n display: \"grid\",\n placeItems: \"center\",\n ...uText(28),\n }}\n >\n {count}\n </span>\n ) : null}\n </button>\n </div>\n\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 14 }}>\n {option.price.length > 0 ? (\n <ResourceList\n lines={option.price.map((l) => ({\n ...l,\n amount: l.amount * count,\n }))}\n size={22}\n />\n ) : null}\n <div\n style={{\n ...uText(30),\n animation:\n phase === \"idle\"\n ? \"idos-u-attract 1s ease-in-out infinite\"\n : undefined,\n visibility: phase === \"idle\" ? \"visible\" : \"hidden\",\n }}\n >\n {t(\"tapNow\")}\n </div>\n <UCloseCircle\n onClick={() => (phase === \"idle\" ? onClose() : undefined)}\n />\n </div>\n\n {/* Flash */}\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n background: \"#fff\",\n pointerEvents: \"none\",\n opacity: 0,\n animation:\n phase === \"burst\"\n ? \"idos-loot-flash .45s ease-out both\"\n : undefined,\n }}\n />\n <style>{LOOT_CSS}</style>\n </div>\n </KitPortal>\n );\n}\n\n/** Image_Effect_Star / Square sparkles around the box: left, top, size, float period. */\nconst SPARKS: Array<[number, number, number, number]> = [\n [18, 30, 34, 2.2],\n [244, 64, 42, 2.8],\n [30, 220, 28, 2.5],\n [250, 236, 30, 3.1],\n];\n\nconst LOOT_CSS = `\n@keyframes idos-loot-flash { 0% { opacity: 0 } 25% { opacity: 1 } 100% { opacity: 0 } }\n@keyframes idos-loot-burst { 0% { transform: scale(1) } 40% { transform: scale(1.25) } 100% { transform: scale(1.6); opacity: 0 } }\n`;\n\n/** Badge: chests the player can open right now with the items they hold (a chest item or a key). */\nexport function watchLootboxes(ctx: ModuleContext): void {\n const { client, features } = ctx;\n ctx.effect(() => {\n const update = () =>\n features.setBadge(\n \"lootboxes\",\n openableWithItems(\n lootboxOptions(configSection(client, \"Lootbox\")),\n client.data.user.state?.InventoryV2 as InventoryView | undefined,\n ),\n );\n update();\n return client.on(\"user:anyUpdated\", update);\n });\n}\n"
|
|
52
|
+
"content": "import type { ModuleContext } from \"@idosgames/module-sdk\";\nimport { useEffect, useMemo, useRef, useState, type ReactNode } from \"react\";\nimport type { FeatureScreenProps } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n EmptyState,\n Icon,\n KitPortal,\n ResourceList,\n UCloseCircle,\n URays,\n URibbon,\n UStyles,\n configSection,\n errorText,\n grantedBy,\n payWithCredits,\n outlined,\n uText,\n useCatalog,\n useCelebrate,\n useSound,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n BULK,\n canOpenBulk,\n canPay,\n lootboxName,\n lootboxOptions,\n lootboxesOpenedBy,\n openableWithItems,\n type InventoryView,\n type LootboxOption,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Chests (Unity LootboxOpeningPopUp): every lootbox of the title with every way to open it — once,\n// or ten at a time where the ceiling and the wallet allow. The box shakes while the server rolls it,\n// bursts, and the rewards fly out. The inventory hands a chest or key item over here\n// (`features.open(\"lootboxes\", { itemID })`): the screen then shows what that item opens.\n\nexport function LootboxesScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const stagger = useStagger();\n const section = configSection(client, \"Lootbox\");\n const all = useMemo(() => lootboxOptions(section), [section]);\n const itemID = typeof args?.itemID === \"string\" ? args.itemID : null;\n const options = itemID ? lootboxesOpenedBy(itemID, all) : all;\n const [opening, setOpening] = useState<{\n option: LootboxOption;\n count: number;\n } | null>(null);\n\n // Handed an item that opens exactly one chest — open its popup right away.\n useEffect(() => {\n if (itemID && options.length === 1)\n setOpening({ option: options[0]!, count: 1 });\n }, [itemID]);\n\n const inventory = state?.InventoryV2 as InventoryView | undefined;\n if (options.length === 0)\n return <EmptyState glyph=\"chest\" text={t(\"noChests\")} />;\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(170px, 1fr))\",\n gap: 12,\n }}\n >\n {options.map((o, i) => {\n const affordable = canPay(o, inventory);\n const bulk = canOpenBulk(o, inventory);\n return (\n <Card\n key={`${o.lootboxID}:${o.optionID}`}\n style={{\n display: \"grid\",\n justifyItems: \"center\",\n gap: 8,\n ...stagger(i),\n }}\n >\n <div style={{ ...outlined, fontSize: 15, textAlign: \"center\" }}>\n {lootboxName(o.lootboxID, catalog.localize)}\n </div>\n <div className=\"idos-bounce\">\n <Icon glyph={o.icon} size={64} fallback=\"chest\" />\n </div>\n {o.name ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {catalog.localize(o.name)}\n </div>\n ) : null}\n <Button\n tone=\"gold\"\n attract={affordable}\n disabled={!affordable}\n onClick={() => setOpening({ option: o, count: 1 })}\n style={{ width: \"100%\" }}\n data-tutorial-anchor={`lootbox:${o.lootboxID}`}\n >\n {o.price.length === 0 ? (\n t(\"open\")\n ) : (\n <ResourceList lines={o.price} size={20} />\n )}\n </Button>\n {bulk ? (\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() => setOpening({ option: o, count: BULK })}\n style={{ width: \"100%\" }}\n >\n {t(\"openTimes\")} ×{BULK}\n </Button>\n ) : null}\n </Card>\n );\n })}\n {opening ? (\n <LootboxOpening\n option={opening.option}\n count={opening.count}\n onClose={() => setOpening(null)}\n />\n ) : null}\n </div>\n );\n}\n\n/**\n * Unity LootboxOpeningPopUp, full screen: the violet glow with the pattern, the orange CHEST ribbon,\n * the box over turning cyan rays and sparkles, the red count badge, \"Tap it now!\". A tap shakes the\n * box while the server rolls it, a white flash bursts it open, and the rewards come in the kit's\n * reward window.\n */\nfunction LootboxOpening({\n option,\n count,\n onClose,\n}: {\n option: LootboxOption;\n count: number;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const play = useSound();\n const kit = useUiKit();\n const boxRef = useRef<HTMLButtonElement>(null);\n const [phase, setPhase] = useState<\"idle\" | \"shake\" | \"burst\">(\"idle\");\n const name = lootboxName(option.lootboxID, catalog.localize);\n\n useEffect(() => {\n play(\"open\");\n }, []);\n\n const openBox = async () => {\n if (phase !== \"idle\") return;\n setPhase(\"shake\");\n const started = Date.now();\n // A price in Aspis: the first call only asks the player to confirm on idosgames.com (the kit shows it).\n const res = await payWithCredits(client, (creditsConfirmationID) =>\n client.lootbox.open(\n option.lootboxID,\n count,\n option.optionID,\n undefined,\n creditsConfirmationID,\n ),\n );\n // Let the shake play for a moment even on a fast server — the anticipation is the point.\n const wait = Math.max(\n 0,\n (kit.motion.on ? 900 : 0) - (Date.now() - started),\n );\n await new Promise((r) => setTimeout(r, wait));\n if (!res.ok) {\n setPhase(\"idle\");\n toast(errorText(res.error), \"error\");\n return;\n }\n play(\"lootbox\");\n kit.fx.burst(boxRef.current);\n setPhase(\"burst\");\n await new Promise((r) => setTimeout(r, kit.motion.on ? 450 : 0));\n onClose();\n // `Resources` is the whole operation — every box of a bulk opening already summed by the server\n // (`Results` is the same per box; adding both would count everything twice).\n celebrate(grantedBy(res.data), { sound: \"levelUp\" });\n };\n\n return (\n <KitPortal>\n <div\n style={{\n position: \"fixed\",\n inset: 0,\n zIndex: 40,\n overflow: \"hidden\",\n // The kit layer lets clicks through; a full-screen window takes them itself.\n pointerEvents: \"auto\",\n display: \"grid\",\n gridTemplateRows: \"auto 1fr auto\",\n justifyItems: \"center\",\n padding:\n \"max(28px, env(safe-area-inset-top)) 16px max(24px, env(safe-area-inset-bottom))\",\n // The sheet's \"Tap it now!\": a blue burst — light from the middle, faint rays around it.\n backgroundColor: v.blueDeep,\n backgroundImage: `repeating-conic-gradient(from 0deg at 50% 45%, rgba(255,255,255,.07) 0 9deg, transparent 9deg 18deg), radial-gradient(ellipse at 50% 45%, color-mix(in srgb, ${v.blue} 65%, #fff) 0%, ${v.blue} 35%, ${v.blueDeep} 80%)`,\n animation: \"idos-u-pop .3s ease-out both\",\n fontFamily: \"inherit\",\n }}\n >\n <UStyles />\n <div style={{ width: \"100%\", maxWidth: 360, marginTop: 12 }}>\n <URibbon tone=\"orange\" width={340}>\n {count > 1 ? `${name.toUpperCase()} ×${count}` : name.toUpperCase()}\n </URibbon>\n </div>\n\n <div\n style={{\n position: \"relative\",\n width: 300,\n height: 300,\n alignSelf: \"center\",\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <span style={{ position: \"absolute\", inset: -40, opacity: 0.9 }}>\n <URays size={380} color=\"rgba(255,255,255,.35)\" />\n </span>\n {SPARKS.map(([x, y, s, d], i) => (\n <span\n key={i}\n style={{\n position: \"absolute\",\n left: x,\n top: y,\n animation: `idos-u-float ${d}s ease-in-out infinite`,\n opacity: 0.85,\n }}\n >\n <Icon glyph=\"star\" size={s} />\n </span>\n ))}\n <button\n type=\"button\"\n ref={boxRef}\n onClick={() => void openBox()}\n aria-label={t(\"open\")}\n style={{\n position: \"relative\",\n border: \"none\",\n background: \"none\",\n padding: 0,\n cursor: phase === \"idle\" ? \"pointer\" : \"default\",\n animation:\n phase === \"shake\"\n ? \"idos-u-shake .18s linear infinite\"\n : phase === \"burst\"\n ? \"idos-loot-burst .45s ease-in forwards\"\n : \"idos-u-float 1.8s ease-in-out infinite\",\n }}\n >\n <Icon glyph={option.icon} size={210} fallback=\"chest\" />\n {count > 1 ? (\n <span\n style={{\n position: \"absolute\",\n left: 6,\n top: 6,\n minWidth: 50,\n height: 50,\n borderRadius: 25,\n background: `radial-gradient(circle at 50% 35%, color-mix(in srgb, ${v.red} 70%, #fff) 0%, ${v.red} 70%)`,\n border: `3px solid ${v.ink}`,\n display: \"grid\",\n placeItems: \"center\",\n ...uText(28),\n }}\n >\n {count}\n </span>\n ) : null}\n </button>\n </div>\n\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 14 }}>\n {option.price.length > 0 ? (\n <ResourceList\n lines={option.price.map((l) => ({\n ...l,\n amount: l.amount * count,\n }))}\n size={22}\n />\n ) : null}\n <div\n style={{\n ...uText(30),\n animation:\n phase === \"idle\"\n ? \"idos-u-attract 1s ease-in-out infinite\"\n : undefined,\n visibility: phase === \"idle\" ? \"visible\" : \"hidden\",\n }}\n >\n {t(\"tapNow\")}\n </div>\n <UCloseCircle\n onClick={() => (phase === \"idle\" ? onClose() : undefined)}\n />\n </div>\n\n {/* Flash */}\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n background: \"#fff\",\n pointerEvents: \"none\",\n opacity: 0,\n animation:\n phase === \"burst\"\n ? \"idos-loot-flash .45s ease-out both\"\n : undefined,\n }}\n />\n <style>{LOOT_CSS}</style>\n </div>\n </KitPortal>\n );\n}\n\n/** Image_Effect_Star / Square sparkles around the box: left, top, size, float period. */\nconst SPARKS: Array<[number, number, number, number]> = [\n [18, 30, 34, 2.2],\n [244, 64, 42, 2.8],\n [30, 220, 28, 2.5],\n [250, 236, 30, 3.1],\n];\n\nconst LOOT_CSS = `\n@keyframes idos-loot-flash { 0% { opacity: 0 } 25% { opacity: 1 } 100% { opacity: 0 } }\n@keyframes idos-loot-burst { 0% { transform: scale(1) } 40% { transform: scale(1.25) } 100% { transform: scale(1.6); opacity: 0 } }\n`;\n\n/** Badge: chests the player can open right now with the items they hold (a chest item or a key). */\nexport function watchLootboxes(ctx: ModuleContext): void {\n const { client, features } = ctx;\n ctx.effect(() => {\n const update = () =>\n features.setBadge(\n \"lootboxes\",\n openableWithItems(\n lootboxOptions(configSection(client, \"Lootbox\")),\n client.data.user.state?.InventoryV2 as InventoryView | undefined,\n ),\n );\n update();\n return client.on(\"user:anyUpdated\", update);\n });\n}\n"
|
|
53
53
|
},
|
|
54
54
|
{
|
|
55
55
|
"path": "model.ts",
|
|
@@ -36,9 +36,9 @@
|
|
|
36
36
|
"version": "0.1.3"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@idosgames/core": "0.
|
|
40
|
-
"@idosgames/module-sdk": "0.13.
|
|
41
|
-
"@idosgames/react": "0.
|
|
39
|
+
"@idosgames/core": "0.26.1",
|
|
40
|
+
"@idosgames/module-sdk": "0.13.3",
|
|
41
|
+
"@idosgames/react": "0.7.1",
|
|
42
42
|
"react": "19.2.7"
|
|
43
43
|
},
|
|
44
44
|
"contentHash": "12cf31156c913e7275c83bbff9b555821f7eb8dff6241ceb569da20ed87de1eb",
|
|
@@ -36,9 +36,9 @@
|
|
|
36
36
|
"version": "0.1.3"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@idosgames/core": "0.
|
|
40
|
-
"@idosgames/module-sdk": "0.13.
|
|
41
|
-
"@idosgames/react": "0.
|
|
39
|
+
"@idosgames/core": "0.26.1",
|
|
40
|
+
"@idosgames/module-sdk": "0.13.3",
|
|
41
|
+
"@idosgames/react": "0.7.1",
|
|
42
42
|
"react": "19.2.7"
|
|
43
43
|
},
|
|
44
44
|
"contentHash": "158824605f43164105e68883333c347fd5110a14b1ccb43c68036db930bbfe5f",
|
|
@@ -30,9 +30,9 @@
|
|
|
30
30
|
"version": "0.1.4"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@idosgames/core": "0.
|
|
34
|
-
"@idosgames/module-sdk": "0.13.
|
|
35
|
-
"@idosgames/react": "0.
|
|
33
|
+
"@idosgames/core": "0.26.1",
|
|
34
|
+
"@idosgames/module-sdk": "0.13.3",
|
|
35
|
+
"@idosgames/react": "0.7.1",
|
|
36
36
|
"react": "19.2.7"
|
|
37
37
|
},
|
|
38
38
|
"contentHash": "4e855049c6c583010b8e9b39d552de30cd8544278690fe903fcc8d2a7e451382",
|
|
@@ -39,9 +39,9 @@
|
|
|
39
39
|
"version": "0.2.1"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@idosgames/core": "0.
|
|
43
|
-
"@idosgames/module-sdk": "0.13.
|
|
44
|
-
"@idosgames/react": "0.
|
|
42
|
+
"@idosgames/core": "0.26.1",
|
|
43
|
+
"@idosgames/module-sdk": "0.13.3",
|
|
44
|
+
"@idosgames/react": "0.7.1",
|
|
45
45
|
"react": "19.2.7"
|
|
46
46
|
},
|
|
47
47
|
"contentHash": "03c011db581a59f3e98ea8696f113e02021db798e9a871be151d3e65726db2f8",
|
|
@@ -32,12 +32,12 @@
|
|
|
32
32
|
"version": "0.1.3"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@idosgames/core": "0.
|
|
36
|
-
"@idosgames/module-sdk": "0.13.
|
|
37
|
-
"@idosgames/react": "0.
|
|
35
|
+
"@idosgames/core": "0.26.1",
|
|
36
|
+
"@idosgames/module-sdk": "0.13.3",
|
|
37
|
+
"@idosgames/react": "0.7.1",
|
|
38
38
|
"react": "19.2.7"
|
|
39
39
|
},
|
|
40
|
-
"contentHash": "
|
|
40
|
+
"contentHash": "d835f2923e281c625bd1888202c6e2df9005bd05ba54ed5793fbac2e03aab4dc",
|
|
41
41
|
"files": [
|
|
42
42
|
{
|
|
43
43
|
"path": "i18n.ts",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
},
|
|
58
58
|
{
|
|
59
59
|
"path": "PremiumScreen.tsx",
|
|
60
|
-
"content": "import type { ModuleContext } from \"@idosgames/module-sdk\";\nimport {\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { FeatureScreenProps } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Columns,\n Button,\n canAfford,\n Card,\n configSection,\n EmptyState,\n errorText,\n Icon,\n outlined,\n panel,\n ResourceList,\n Timer,\n toneSurface,\n useCatalog,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n passRows,\n premiumBadge,\n type PassRow,\n type PremiumStateView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// VIP (Unity PremiumPopUp): every premium pass of the title, lowest tier first — what it gives, how\n// long it lasts, until when the player holds it, a free trial once, and buying it with in-game\n// currency. A pass paid in a store (real money) is the platform's checkout, not sold here. Other\n// systems (events, season) send the player here for their VIP prizes:\n// `features.open(\"premium\", { premiumID })` — that pass is marked.\n\nexport function PremiumScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const now = useNow();\n const stagger = useStagger();\n const wanted = typeof args?.premiumID === \"string\" ? args.premiumID : null;\n const rows = passRows(\n configSection(client, \"Premium\"),\n state?.Premium as PremiumStateView | undefined,\n now,\n );\n // Passes run out by their end date, which the rows check against `now` — the state itself is\n // fresh by its revision, so the central cache asks the server only when it changed.\n useEffect(() => {\n void client.cache.ensureState([\"Premium\"]);\n }, [client]);\n\n if (rows.length === 0)\n return <EmptyState glyph=\"crown\" text={t(\"noPasses\")} />;\n return (\n <Columns gap={12}>\n {rows.map((row, i) => (\n <PassCard\n key={row.premiumID}\n row={row}\n marked={row.premiumID === wanted}\n style={stagger(i)}\n />\n ))}\n </Columns>\n );\n}\n\nfunction PassCard({\n row,\n marked,\n style,\n}: {\n row: PassRow;\n marked: boolean;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const now = useNow();\n const toast = useToast();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n\n useEffect(() => {\n if (marked) ref.current?.scrollIntoView({ block: \"center\" });\n }, [marked]);\n\n const done = (res: { ok: true } | { ok: false; error: string }) => {\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"purchase\");\n kit.fx.burst(ref.current);\n toast(t(\"active\"), \"success\");\n };\n const trial = async () => {\n setBusy(\"trial\");\n const res = await client.premium.activateTrial(\n row.premiumID,\n crypto.randomUUID(),\n );\n done(res);\n };\n const buy = async (optionID: string) => {\n setBusy(optionID);\n const res = await client.premium.purchaseItemOrCurrency(\n
|
|
60
|
+
"content": "import type { ModuleContext } from \"@idosgames/module-sdk\";\nimport {\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { FeatureScreenProps } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Columns,\n Button,\n canAfford,\n Card,\n configSection,\n EmptyState,\n errorText,\n Icon,\n outlined,\n panel,\n ResourceList,\n Timer,\n toneSurface,\n useCatalog,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\n payWithCredits,\n} from \"@idosgames/react/ui\";\nimport {\n passRows,\n premiumBadge,\n type PassRow,\n type PremiumStateView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// VIP (Unity PremiumPopUp): every premium pass of the title, lowest tier first — what it gives, how\n// long it lasts, until when the player holds it, a free trial once, and buying it with in-game\n// currency. A pass paid in a store (real money) is the platform's checkout, not sold here. Other\n// systems (events, season) send the player here for their VIP prizes:\n// `features.open(\"premium\", { premiumID })` — that pass is marked.\n\nexport function PremiumScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const now = useNow();\n const stagger = useStagger();\n const wanted = typeof args?.premiumID === \"string\" ? args.premiumID : null;\n const rows = passRows(\n configSection(client, \"Premium\"),\n state?.Premium as PremiumStateView | undefined,\n now,\n );\n // Passes run out by their end date, which the rows check against `now` — the state itself is\n // fresh by its revision, so the central cache asks the server only when it changed.\n useEffect(() => {\n void client.cache.ensureState([\"Premium\"]);\n }, [client]);\n\n if (rows.length === 0)\n return <EmptyState glyph=\"crown\" text={t(\"noPasses\")} />;\n return (\n <Columns gap={12}>\n {rows.map((row, i) => (\n <PassCard\n key={row.premiumID}\n row={row}\n marked={row.premiumID === wanted}\n style={stagger(i)}\n />\n ))}\n </Columns>\n );\n}\n\nfunction PassCard({\n row,\n marked,\n style,\n}: {\n row: PassRow;\n marked: boolean;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const now = useNow();\n const toast = useToast();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n\n useEffect(() => {\n if (marked) ref.current?.scrollIntoView({ block: \"center\" });\n }, [marked]);\n\n const done = (res: { ok: true } | { ok: false; error: string }) => {\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"purchase\");\n kit.fx.burst(ref.current);\n toast(t(\"active\"), \"success\");\n };\n const trial = async () => {\n setBusy(\"trial\");\n const res = await client.premium.activateTrial(\n row.premiumID,\n crypto.randomUUID(),\n );\n done(res);\n };\n const buy = async (optionID: string) => {\n setBusy(optionID);\n // One transaction id for both calls of an Aspis purchase (request, then the confirmed one).\n const transactionID = crypto.randomUUID();\n // A price in Aspis: the first call asks the player to confirm on idosgames.com (the kit shows it).\n const res = await payWithCredits(client, (creditsConfirmationID) =>\n client.premium.purchaseItemOrCurrency(\n row.premiumID,\n transactionID,\n optionID,\n 1,\n creditsConfirmationID,\n ),\n );\n done(res);\n };\n\n return (\n <div ref={ref}>\n <Card\n highlight={marked}\n style={{\n display: \"grid\",\n gap: 10,\n ...toneSurface(\"gold\"),\n ...style,\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 12 }}>\n <div className=\"idos-bounce\">\n <Icon glyph=\"crown\" size={52} />\n </div>\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 17 }}>\n {catalog.localize(row.name)}\n </div>\n {row.activeUntil ? (\n <Timer at={row.activeUntil} now={now} label={t(\"activeFor\")} />\n ) : row.durationDays > 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {row.durationDays} {t(\"days\")}\n </div>\n ) : null}\n </div>\n {row.activeUntil ? <Icon glyph=\"check\" size={30} /> : null}\n </div>\n {row.benefits.length > 0 ? (\n <div style={{ display: \"grid\", gap: 4 }}>\n {row.benefits.map((b, i) => (\n <div\n key={i}\n style={{\n ...panel,\n padding: 8,\n display: \"flex\",\n gap: 8,\n alignItems: \"center\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <Icon glyph=\"check\" size={18} /> {catalog.localize(b)}\n </div>\n ))}\n </div>\n ) : null}\n {row.trialDays > 0 ? (\n <Button\n tone=\"green\"\n attract\n busy={busy === \"trial\"}\n onClick={() => void trial()}\n data-tutorial-anchor={`premium:trial:${row.premiumID}`}\n >\n {t(\"trial\")} · {row.trialDays} {t(\"days\")}\n </Button>\n ) : null}\n {row.options.map((o) => {\n const affordable = canAfford(o.price, balances);\n return (\n <Button\n key={o.id}\n tone=\"gold\"\n attract={affordable && !row.activeUntil}\n disabled={!affordable}\n busy={busy === o.id}\n onClick={() => void buy(o.id)}\n data-tutorial-anchor={`premium:buy:${row.premiumID}`}\n >\n {row.activeUntil ? t(\"extend\") : t(\"buy\")}{\" \"}\n <ResourceList\n lines={o.price}\n size={18}\n dimIf={() => !affordable}\n />\n </Button>\n );\n })}\n </Card>\n </div>\n );\n}\n\n/** Badge: a free trial waiting — from the config and the cached state, no request of its own. */\nexport function watchPremium(ctx: ModuleContext): void {\n const { client, features } = ctx;\n ctx.effect(() => {\n // Subscribed directly, not keyed on the state object: the cache may patch it in place, and an\n // effect that does not re-run leaves the badge lit after the trial is taken.\n const update = () =>\n features.setBadge(\n \"premium\",\n premiumBadge(\n passRows(\n configSection(client, \"Premium\"),\n client.data.user.state?.Premium as PremiumStateView | undefined,\n Date.now(),\n ),\n ),\n );\n // A pass running out brings its trial back into view — no event for that, so look now and then.\n ctx.every(60_000, update);\n return client.on(\"user:anyUpdated\", update);\n });\n}\n"
|
|
61
61
|
},
|
|
62
62
|
{
|
|
63
63
|
"path": "module.meta.json",
|
|
@@ -34,9 +34,9 @@
|
|
|
34
34
|
"version": "0.1.5"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@idosgames/core": "0.
|
|
38
|
-
"@idosgames/module-sdk": "0.13.
|
|
39
|
-
"@idosgames/react": "0.
|
|
37
|
+
"@idosgames/core": "0.26.1",
|
|
38
|
+
"@idosgames/module-sdk": "0.13.3",
|
|
39
|
+
"@idosgames/react": "0.7.1",
|
|
40
40
|
"react": "19.2.7"
|
|
41
41
|
},
|
|
42
42
|
"contentHash": "2448dc72d4408919336d2f4011a8dd8903a913811c8ef00db2b64c21b77f5b52",
|
|
@@ -33,9 +33,9 @@
|
|
|
33
33
|
"version": "0.1.2"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@idosgames/core": "0.
|
|
37
|
-
"@idosgames/module-sdk": "0.13.
|
|
38
|
-
"@idosgames/react": "0.
|
|
36
|
+
"@idosgames/core": "0.26.1",
|
|
37
|
+
"@idosgames/module-sdk": "0.13.3",
|
|
38
|
+
"@idosgames/react": "0.7.1",
|
|
39
39
|
"react": "19.2.7"
|
|
40
40
|
},
|
|
41
41
|
"contentHash": "c374268f2fc7cfb1fb4a6073cceca7cb0666f85bc2f1c37bc4ba818233207b61",
|
|
@@ -34,9 +34,9 @@
|
|
|
34
34
|
"version": "0.1.3"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@idosgames/core": "0.
|
|
38
|
-
"@idosgames/module-sdk": "0.13.
|
|
39
|
-
"@idosgames/react": "0.
|
|
37
|
+
"@idosgames/core": "0.26.1",
|
|
38
|
+
"@idosgames/module-sdk": "0.13.3",
|
|
39
|
+
"@idosgames/react": "0.7.1",
|
|
40
40
|
"react": "19.2.7"
|
|
41
41
|
},
|
|
42
42
|
"contentHash": "f30292cdadf0595a434aaae91598c279add865f03c5ae9767d5b4227e9d479ba",
|
|
@@ -31,9 +31,9 @@
|
|
|
31
31
|
"version": "0.1.3"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@idosgames/core": "0.
|
|
35
|
-
"@idosgames/module-sdk": "0.13.
|
|
36
|
-
"@idosgames/react": "0.
|
|
34
|
+
"@idosgames/core": "0.26.1",
|
|
35
|
+
"@idosgames/module-sdk": "0.13.3",
|
|
36
|
+
"@idosgames/react": "0.7.1",
|
|
37
37
|
"react": "19.2.7"
|
|
38
38
|
},
|
|
39
39
|
"contentHash": "ab99622e4ba739d6e189e21c2e41c38125b2b2d140c2c63f670dd2205dca3efc",
|
|
@@ -36,9 +36,9 @@
|
|
|
36
36
|
"version": "0.1.3"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@idosgames/core": "0.
|
|
40
|
-
"@idosgames/module-sdk": "0.13.
|
|
41
|
-
"@idosgames/react": "0.
|
|
39
|
+
"@idosgames/core": "0.26.1",
|
|
40
|
+
"@idosgames/module-sdk": "0.13.3",
|
|
41
|
+
"@idosgames/react": "0.7.1",
|
|
42
42
|
"react": "19.2.7"
|
|
43
43
|
},
|
|
44
44
|
"contentHash": "e7d48360d17873bd96f35c06a96a69320d2802f688a71e47392cc7dbf2c1d893",
|
|
@@ -36,12 +36,12 @@
|
|
|
36
36
|
"version": "0.1.4"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@idosgames/core": "0.
|
|
40
|
-
"@idosgames/module-sdk": "0.13.
|
|
41
|
-
"@idosgames/react": "0.
|
|
39
|
+
"@idosgames/core": "0.26.1",
|
|
40
|
+
"@idosgames/module-sdk": "0.13.3",
|
|
41
|
+
"@idosgames/react": "0.7.1",
|
|
42
42
|
"react": "19.2.7"
|
|
43
43
|
},
|
|
44
|
-
"contentHash": "
|
|
44
|
+
"contentHash": "e702aa4d3c44024f0c964d4b3c791599a8724bece9020895df2de3286ad204ff",
|
|
45
45
|
"files": [
|
|
46
46
|
{
|
|
47
47
|
"path": "i18n.ts",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
},
|
|
54
54
|
{
|
|
55
55
|
"path": "model.ts",
|
|
56
|
-
"content": "// Pure helpers of the shop screen — covered by model.test.ts.\n\nimport type {\n GetStorefrontResponse,\n StorefrontOfferView,\n StorefrontSectionView,\n StorefrontSlotView,\n} from \"@idosgames/core\";\nimport { costLines } from \"@idosgames/react/ui\";\n\nexport function sortedSections(\n store: { Sections?: StorefrontSectionView[] | null } | null | undefined,\n): StorefrontSectionView[] {\n return (store?.Sections ?? [])\n .slice()\n .sort((a, b) => a.SortOrder - b.SortOrder);\n}\n\nexport function slotsOf(section: StorefrontSectionView): StorefrontSlotView[] {\n return (section.Slots ?? [])\n .slice()\n .sort((a, b) => a.SortOrder - b.SortOrder);\n}\n\n/** When the first card of a section refreshes — the section's countdown. */\nexport function earliestRefresh(section: StorefrontSectionView): string | null {\n const times = slotsOf(section)\n .map((s) => s.RefreshesAtUtc)\n .filter((x): x is string => typeof x === \"string\")\n .sort();\n return times[0] ?? null;\n}\n\n/**\n * Price in Credits (platform currency) of the first option, or null when the offer is not sold for Credits. A Credits price\n * is not a game resource: the card shows it as text, and the purchase goes through the player's confirmation on\n * idosgames.com (`client.store.purchaseWithCredits`).\n */\nexport function creditsPriceOf(offer: StorefrontOfferView): number | null {\n const option = offer.PriceOptions?.[0];\n return option?.IsCreditsPaid ? Number(option.CreditsPrice ?? 0) : null;\n}\n\n/** Price lines in game resources — without the Credits entry (it is drawn by `creditsPriceOf`). */\nexport function resourcePrice(offer: StorefrontOfferView) {\n return costLines(offer.PriceOptions?.[0]?.Cost).filter(\n (line) =>
|
|
56
|
+
"content": "// Pure helpers of the shop screen — covered by model.test.ts.\n\nimport type {\n GetStorefrontResponse,\n StorefrontOfferView,\n StorefrontSectionView,\n StorefrontSlotView,\n} from \"@idosgames/core\";\nimport { costLines } from \"@idosgames/react/ui\";\n\nexport function sortedSections(\n store: { Sections?: StorefrontSectionView[] | null } | null | undefined,\n): StorefrontSectionView[] {\n return (store?.Sections ?? [])\n .slice()\n .sort((a, b) => a.SortOrder - b.SortOrder);\n}\n\nexport function slotsOf(section: StorefrontSectionView): StorefrontSlotView[] {\n return (section.Slots ?? [])\n .slice()\n .sort((a, b) => a.SortOrder - b.SortOrder);\n}\n\n/** When the first card of a section refreshes — the section's countdown. */\nexport function earliestRefresh(section: StorefrontSectionView): string | null {\n const times = slotsOf(section)\n .map((s) => s.RefreshesAtUtc)\n .filter((x): x is string => typeof x === \"string\")\n .sort();\n return times[0] ?? null;\n}\n\n/**\n * Price in Credits (platform currency) of the first option, or null when the offer is not sold for Credits. A Credits price\n * is not a game resource: the card shows it as text, and the purchase goes through the player's confirmation on\n * idosgames.com (`client.store.purchaseWithCredits`).\n */\nexport function creditsPriceOf(offer: StorefrontOfferView): number | null {\n const option = offer.PriceOptions?.[0];\n return option?.IsCreditsPaid ? Number(option.CreditsPrice ?? 0) : null;\n}\n\n/** Price lines in game resources — without the Credits entry (it is drawn by `creditsPriceOf`). */\nexport function resourcePrice(offer: StorefrontOfferView) {\n return costLines(offer.PriceOptions?.[0]?.Cost).filter(\n (line) => line.kind !== \"credits\",\n );\n}\n\nexport function isFree(offer: StorefrontOfferView): boolean {\n if (creditsPriceOf(offer) != null) return false;\n const option = offer.PriceOptions?.[0];\n return option?.IsFree === true || costLines(option?.Cost).length === 0;\n}\n\n/** Offers the player can take right now (not sold out, not waiting for their window). */\nexport function isAvailable(offer: StorefrontOfferView): boolean {\n return offer.State?.SoldOut !== true && !offer.State?.AvailableAtUtc;\n}\n\n/** The badge of the shop: free offers waiting to be taken (the daily gift). */\nexport function freeOffersWaiting(\n front: GetStorefrontResponse | null | undefined,\n): number {\n let n = 0;\n for (const store of front?.Stores ?? [])\n for (const section of store.Sections ?? [])\n for (const slot of section.Slots ?? [])\n if (slot.Offer && isFree(slot.Offer) && isAvailable(slot.Offer)) n++;\n return n;\n}\n\n/** Stores by their order; the first is the default tab. */\nexport function sortedStores(\n front: GetStorefrontResponse | null | undefined,\n): NonNullable<GetStorefrontResponse[\"Stores\"]> {\n return (front?.Stores ?? [])\n .slice()\n .sort((a, b) => a.SortOrder - b.SortOrder);\n}\n\n/** Whether the storefront has any offer — until it does, the lobby hides the Shop tab. */\nexport function hasOffers(\n front: GetStorefrontResponse | null | undefined,\n): boolean {\n return (front?.Stores ?? []).some((store) =>\n (store.Sections ?? []).some((section) =>\n (section.Slots ?? []).some((slot) => slot.Offer != null),\n ),\n );\n}\n"
|
|
57
57
|
},
|
|
58
58
|
{
|
|
59
59
|
"path": "module.ts",
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
},
|
|
62
62
|
{
|
|
63
63
|
"path": "PlatformCurrencyIcon.tsx",
|
|
64
|
-
"content": "
|
|
64
|
+
"content": "// The Aspis icon and \"[icon] 100 Aspis\" now live in the UI kit (`@idosgames/react/ui`): every system that sells for\n// Aspis draws the same one. Kept as a re-export so imports of this file keep working.\nexport { PlatformCurrencyIcon, CreditsPrice } from \"@idosgames/react/ui\";\n"
|
|
65
65
|
},
|
|
66
66
|
{
|
|
67
67
|
"path": "StoreScreen.tsx",
|
|
@@ -32,9 +32,9 @@
|
|
|
32
32
|
"version": "0.1.4"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@idosgames/core": "0.
|
|
36
|
-
"@idosgames/module-sdk": "0.13.
|
|
37
|
-
"@idosgames/react": "0.
|
|
35
|
+
"@idosgames/core": "0.26.1",
|
|
36
|
+
"@idosgames/module-sdk": "0.13.3",
|
|
37
|
+
"@idosgames/react": "0.7.1",
|
|
38
38
|
"react": "19.2.7"
|
|
39
39
|
},
|
|
40
40
|
"contentHash": "da4dbd714014f6a0f2a2d761ac737dc2e70f5c466ec05f9cc51194d3cd87f40f",
|
|
@@ -54,23 +54,23 @@
|
|
|
54
54
|
"version": "0.1.1"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@idosgames/characters": "0.1.
|
|
58
|
-
"@idosgames/core": "0.
|
|
59
|
-
"@idosgames/module-sdk": "0.13.
|
|
60
|
-
"@idosgames/react": "0.
|
|
57
|
+
"@idosgames/characters": "0.1.3",
|
|
58
|
+
"@idosgames/core": "0.26.1",
|
|
59
|
+
"@idosgames/module-sdk": "0.13.3",
|
|
60
|
+
"@idosgames/react": "0.7.1",
|
|
61
61
|
"@idosgames/timeline": "0.1.0",
|
|
62
62
|
"react": "19.2.7",
|
|
63
63
|
"three": "0.185.1"
|
|
64
64
|
},
|
|
65
|
-
"contentHash": "
|
|
65
|
+
"contentHash": "f21269891c1f334358e49c1926f8fd343a3f32dae1ef373b15d7b4c193cf4b9b",
|
|
66
66
|
"files": [
|
|
67
67
|
{
|
|
68
68
|
"path": "ai/assets.ts",
|
|
69
|
-
"content": "// ИИ-ассеты по плану режиссёра (ступень ai-asset). Каждый вид — своя скрытая фича тайтла со своей\n// ценой; результат становится обычным ассетом «Моих ассетов» (own:…) по тому же контракту, что и\n// библиотека: воксельный герой — рецепт CharacterSpec, 2D-герой — рецепт рига (детали, цвета, свои\n// SVG), существо или предмет — картинка-вырезка, фон — сцена с картинкой, музыка — петля.\n// Стиль студии (style lock) уходит в фичи картинок переменной, игрок его не пишет.\nimport type {\n IDosGamesClient,\n AIGenerationView,\n OperationResult,\n} from \"@idosgames/core\";\nimport type { StyleFamily } from \"@idosgames/characters\";\nimport { parseCharacterSpec } from \"@idosgames/characters/voxel\";\nimport { heroRig, parseRig2dRecipe } from \"@idosgames/characters/rig2d\";\nimport type {\n ActorAsset,\n AIAssetKind,\n Asset,\n MusicAsset,\n PropAsset,\n SetAsset,\n} from \"@idosgames/timeline\";\nimport { FEATURES } from \"../ids\";\nimport { newId } from \"../model/store\";\nimport { STYLE_PROMPTS } from \"./prompts\";\nimport { ASSET_GENERATORS as G } from \"./generators\";\n\nexport type AssetResult =\n { ok: true; asset: Asset } | { ok: false; error: string; refunded?: boolean };\n\nexport interface AssetOptions {\n style: StyleFamily;\n /** Подпись ассета в «Моих» (имя героя, «Фон: кухня»). */\n title?: string;\n optionID?: string;\n signal?: AbortSignal;\n onProgress?: (u: number) => void;\n /** Сцена-основа для фона: её якоря и планы камеры, свой только фон. */\n baseSet?: SetAsset;\n /** Длительность музыки по адресу (по умолчанию — декодировать). */\n measure?: (url: string) => Promise<{ duration: number } | null>;\n}\n\nasync function finish(\n client: IDosGamesClient,\n started: OperationResult<AIGenerationView>,\n opts: AssetOptions,\n timeoutMs: number,\n): Promise<\n | { ok: true; view: AIGenerationView }\n | { ok: false; error: string; refunded?: boolean }\n> {\n if (!started.ok) return { ok: false, error: started.error };\n const done = await client.ai.waitForGeneration(started.data, {\n timeoutMs,\n ...(opts.signal ? { signal: opts.signal } : {}),\n onUpdate: (v) => opts.onProgress?.(Number(v.Progress ?? 0)),\n });\n if (!done.ok) return { ok: false, error: done.error };\n if (done.data.Status !== \"completed\")\n return {\n ok: false,\n error: done.data.Error?.Message || \"generation failed\",\n refunded: !!done.data.PlayerRefunded,\n };\n return { ok: true, view: done.data };\n}\n\nconst common = (opts: AssetOptions) => ({\n relatedEntityID: crypto.randomUUID(),\n ...(opts.optionID ? { selectedOptionID: opts.optionID } : {}),\n});\n\nconst meta = (\n kind: Asset[\"kind\"],\n opts: AssetOptions,\n prompt: string,\n tags: string[],\n) => ({\n ref: `own:${newId()}`,\n kind,\n style: opts.style,\n title: (opts.title || prompt).slice(0, 60),\n description: prompt.slice(0, 300),\n tags: [\"ai\", ...tags],\n tier: \"creator\" as const,\n license: \"creator\" as const,\n});\n\nconst firstUrl = (v: AIGenerationView) =>\n v.Assets?.find((a) => a.Url)?.Url ?? null;\n\nasync function measureByDecode(\n url: string,\n): Promise<{ duration: number } | null> {\n try {\n const buf = await (await fetch(url)).arrayBuffer();\n const audio = await new OfflineAudioContext(1, 1, 44100).decodeAudioData(\n buf,\n );\n return { duration: audio.duration };\n } catch {\n return null;\n }\n}\n\n/** Сгенерировать ассет вида `kind` по описанию. */\nexport async function generateAsset(\n client: IDosGamesClient,\n kind: AIAssetKind,\n prompt: string,\n opts: AssetOptions,\n): Promise<AssetResult> {\n const style = STYLE_PROMPTS[opts.style] ?? STYLE_PROMPTS[\"flat-cartoon\"]!;\n switch (kind) {\n case \"voxel-character\": {\n const r = await finish(\n client,\n await client.ai.generateText(\n FEATURES.voxelCharacter,\n { Prompt: prompt },\n common(opts),\n ),\n opts,\n 5 * 60_000,\n );\n if (!r.ok) return r;\n const spec = parseCharacterSpec(\n r.view.OutputJson ?? r.view.OutputText ?? \"\",\n );\n if (!spec.ok)\n return { ok: false, error: \"the AI answer is not a character recipe\" };\n const asset: ActorAsset = {\n ...meta(\n \"actor\",\n {\n ...opts,\n style: G[\"voxel-character\"].style!,\n title: opts.title || spec.spec.name || prompt,\n },\n prompt,\n G[\"voxel-character\"].asset.tags,\n ),\n kind: \"actor\",\n affordances: G[\"voxel-character\"].asset[\"affordances\"] as string[],\n body: { type: \"voxel\", spec: spec.spec },\n };\n return { ok: true, asset };\n }\n case \"rig2d-character\": {\n const r = await finish(\n client,\n await client.ai.generateText(\n FEATURES.rig2dCharacter,\n { Prompt: prompt },\n common(opts),\n ),\n opts,\n 5 * 60_000,\n );\n if (!r.ok) return r;\n const recipe = parseRig2dRecipe(\n r.view.OutputJson ?? r.view.OutputText ?? \"\",\n heroRig,\n );\n if (!recipe.ok) return { ok: false, error: recipe.error };\n const asset: ActorAsset = {\n ...meta(\n \"actor\",\n {\n ...opts,\n style: G[\"rig2d-character\"].style!,\n title: opts.title || recipe.recipe.name || prompt,\n },\n recipe.recipe.description || prompt,\n G[\"rig2d-character\"].asset.tags,\n ),\n kind: \"actor\",\n affordances: G[\"rig2d-character\"].asset[\"affordances\"] as string[],\n height: G[\"rig2d-character\"].asset[\"height\"] as number,\n body: {\n type: \"rig2d\",\n look: recipe.recipe.look,\n ...(Object.keys(recipe.recipe.art).length\n ? { art: recipe.recipe.art }\n : {}),\n },\n };\n return { ok: true, asset };\n }\n case \"image-actor\":\n case \"prop\": {\n const r = await finish(\n client,\n await client.ai.generateImage(\n FEATURES.image,\n { Background: \"transparent\", Format: \"png\" },\n {\n ...common(opts),\n variables: { subject: prompt.slice(0, 400), style },\n },\n ),\n opts,\n 5 * 60_000,\n );\n if (!r.ok) return r;\n const url = firstUrl(r.view);\n if (!url) return { ok: false, error: \"the AI returned no picture\" };\n if (kind === \"prop\") {\n const g = G.prop.asset;\n const asset: PropAsset = {\n ...meta(\"prop\", opts, prompt, g.tags),\n kind: \"prop\",\n image: url,\n height: g[\"height\"] as number,\n grip: g[\"grip\"] as { x: number; y: number },\n };\n return { ok: true, asset };\n }\n const asset: ActorAsset = {\n ...meta(\"actor\", opts, prompt, G[\"image-actor\"].asset.tags),\n kind: \"actor\",\n affordances: G[\"image-actor\"].asset[\"affordances\"] as string[],\n height: G[\"image-actor\"].asset[\"height\"] as number,\n body: { type: \"cutout\", image: url, outline: true },\n };\n return { ok: true, asset };\n }\n case \"background\": {\n if (!opts.baseSet)\n return { ok: false, error: \"no scene to put the background into\" };\n const r = await finish(\n client,\n await client.ai.generateImage(\n FEATURES.background,\n { Size: \"1024x1536\", Format: \"jpeg\" },\n {\n ...common(opts),\n variables: { subject: prompt.slice(0, 400), style },\n },\n ),\n opts,\n 5 * 60_000,\n );\n if (!r.ok) return r;\n const url = firstUrl(r.view);\n if (!url) return { ok: false, error: \"the AI returned no picture\" };\n const base = opts.baseSet.set;\n const asset: SetAsset = {\n ...meta(\"set\", opts, prompt, G.background.asset.tags),\n kind: \"set\",\n set: {\n ...base,\n background: {\n image: url,\n gradient: base.background.gradient ?? [\"#000000\", \"#000000\"],\n },\n props: [],\n },\n };\n return { ok: true, asset };\n }\n case \"music\": {\n const r = await finish(\n client,\n await client.ai.generateMusic(\n FEATURES.music,\n { Format: \"mp3\" },\n { ...common(opts), variables: { mood: prompt.slice(0, 300) } },\n ),\n opts,\n 8 * 60_000,\n );\n if (!r.ok) return r;\n const url = firstUrl(r.view);\n if (!url) return { ok: false, error: \"the AI returned no music\" };\n const m = await (opts.measure ?? measureByDecode)(url);\n const asset: MusicAsset = {\n ...meta(\"music\", opts, prompt, G.music.asset.tags),\n kind: \"music\",\n url,\n duration: m?.duration ?? (G.music.asset[\"duration\"] as number),\n loop: true,\n mood: prompt.slice(0, 60),\n };\n return { ok: true, asset };\n }\n case \"three-d\":\n return { ok: false, error: \"3D generation is not open on this title\" };\n }\n}\n"
|
|
69
|
+
"content": "// ИИ-ассеты по плану режиссёра (ступень ai-asset). Каждый вид — своя скрытая фича тайтла со своей\n// ценой; результат становится обычным ассетом «Моих ассетов» (own:…) по тому же контракту, что и\n// библиотека: воксельный герой — рецепт CharacterSpec, 2D-герой — рецепт рига (детали, цвета, свои\n// SVG), существо или предмет — картинка-вырезка, фон — сцена с картинкой, музыка — петля.\n// Стиль студии (style lock) уходит в фичи картинок переменной, игрок его не пишет.\nimport type {\n IDosGamesClient,\n AIGenerationView,\n OperationResult,\n} from \"@idosgames/core\";\nimport type { StyleFamily } from \"@idosgames/characters\";\nimport { parseCharacterSpec } from \"@idosgames/characters/voxel\";\nimport { heroRig, parseRig2dRecipe } from \"@idosgames/characters/rig2d\";\nimport type {\n ActorAsset,\n AIAssetKind,\n Asset,\n MusicAsset,\n PropAsset,\n SetAsset,\n} from \"@idosgames/timeline\";\nimport { payWithCredits } from \"@idosgames/react/ui\";\nimport { FEATURES } from \"../ids\";\nimport { newId } from \"../model/store\";\nimport { STYLE_PROMPTS } from \"./prompts\";\nimport { ASSET_GENERATORS as G } from \"./generators\";\n\nexport type AssetResult =\n { ok: true; asset: Asset } | { ok: false; error: string; refunded?: boolean };\n\nexport interface AssetOptions {\n style: StyleFamily;\n /** Подпись ассета в «Моих» (имя героя, «Фон: кухня»). */\n title?: string;\n optionID?: string;\n signal?: AbortSignal;\n onProgress?: (u: number) => void;\n /** Сцена-основа для фона: её якоря и планы камеры, свой только фон. */\n baseSet?: SetAsset;\n /** Длительность музыки по адресу (по умолчанию — декодировать). */\n measure?: (url: string) => Promise<{ duration: number } | null>;\n}\n\nasync function finish(\n client: IDosGamesClient,\n started: OperationResult<AIGenerationView>,\n opts: AssetOptions,\n timeoutMs: number,\n): Promise<\n | { ok: true; view: AIGenerationView }\n | { ok: false; error: string; refunded?: boolean }\n> {\n if (!started.ok) return { ok: false, error: started.error };\n const done = await client.ai.waitForGeneration(started.data, {\n timeoutMs,\n ...(opts.signal ? { signal: opts.signal } : {}),\n onUpdate: (v) => opts.onProgress?.(Number(v.Progress ?? 0)),\n });\n if (!done.ok) return { ok: false, error: done.error };\n if (done.data.Status !== \"completed\")\n return {\n ok: false,\n error: done.data.Error?.Message || \"generation failed\",\n refunded: !!done.data.PlayerRefunded,\n };\n return { ok: true, view: done.data };\n}\n\nconst common = (opts: AssetOptions) => ({\n relatedEntityID: crypto.randomUUID(),\n ...(opts.optionID ? { selectedOptionID: opts.optionID } : {}),\n});\n\n/**\n * Запуск генерации с ценой, которая может быть в Aspis: первый вызов только просит игрока подтвердить на idosgames.com\n * (окно — кит), Aspis списываются только за готовую генерацию. Ключ один на оба вызова.\n */\nfunction start(\n client: IDosGamesClient,\n opts: AssetOptions,\n call: (\n o: ReturnType<typeof common> & { creditsConfirmationID?: string },\n ) => Promise<OperationResult<AIGenerationView>>,\n): Promise<OperationResult<AIGenerationView>> {\n const base = common(opts);\n return payWithCredits(\n client,\n (creditsConfirmationID) => call({ ...base, creditsConfirmationID }),\n opts.signal ? { signal: opts.signal } : {},\n );\n}\n\nconst meta = (\n kind: Asset[\"kind\"],\n opts: AssetOptions,\n prompt: string,\n tags: string[],\n) => ({\n ref: `own:${newId()}`,\n kind,\n style: opts.style,\n title: (opts.title || prompt).slice(0, 60),\n description: prompt.slice(0, 300),\n tags: [\"ai\", ...tags],\n tier: \"creator\" as const,\n license: \"creator\" as const,\n});\n\nconst firstUrl = (v: AIGenerationView) =>\n v.Assets?.find((a) => a.Url)?.Url ?? null;\n\nasync function measureByDecode(\n url: string,\n): Promise<{ duration: number } | null> {\n try {\n const buf = await (await fetch(url)).arrayBuffer();\n const audio = await new OfflineAudioContext(1, 1, 44100).decodeAudioData(\n buf,\n );\n return { duration: audio.duration };\n } catch {\n return null;\n }\n}\n\n/** Сгенерировать ассет вида `kind` по описанию. */\nexport async function generateAsset(\n client: IDosGamesClient,\n kind: AIAssetKind,\n prompt: string,\n opts: AssetOptions,\n): Promise<AssetResult> {\n const style = STYLE_PROMPTS[opts.style] ?? STYLE_PROMPTS[\"flat-cartoon\"]!;\n switch (kind) {\n case \"voxel-character\": {\n const r = await finish(\n client,\n await start(client, opts, (o) =>\n client.ai.generateText(\n FEATURES.voxelCharacter,\n { Prompt: prompt },\n o,\n ),\n ),\n opts,\n 5 * 60_000,\n );\n if (!r.ok) return r;\n const spec = parseCharacterSpec(\n r.view.OutputJson ?? r.view.OutputText ?? \"\",\n );\n if (!spec.ok)\n return { ok: false, error: \"the AI answer is not a character recipe\" };\n const asset: ActorAsset = {\n ...meta(\n \"actor\",\n {\n ...opts,\n style: G[\"voxel-character\"].style!,\n title: opts.title || spec.spec.name || prompt,\n },\n prompt,\n G[\"voxel-character\"].asset.tags,\n ),\n kind: \"actor\",\n affordances: G[\"voxel-character\"].asset[\"affordances\"] as string[],\n body: { type: \"voxel\", spec: spec.spec },\n };\n return { ok: true, asset };\n }\n case \"rig2d-character\": {\n const r = await finish(\n client,\n await start(client, opts, (o) =>\n client.ai.generateText(\n FEATURES.rig2dCharacter,\n { Prompt: prompt },\n o,\n ),\n ),\n opts,\n 5 * 60_000,\n );\n if (!r.ok) return r;\n const recipe = parseRig2dRecipe(\n r.view.OutputJson ?? r.view.OutputText ?? \"\",\n heroRig,\n );\n if (!recipe.ok) return { ok: false, error: recipe.error };\n const asset: ActorAsset = {\n ...meta(\n \"actor\",\n {\n ...opts,\n style: G[\"rig2d-character\"].style!,\n title: opts.title || recipe.recipe.name || prompt,\n },\n recipe.recipe.description || prompt,\n G[\"rig2d-character\"].asset.tags,\n ),\n kind: \"actor\",\n affordances: G[\"rig2d-character\"].asset[\"affordances\"] as string[],\n height: G[\"rig2d-character\"].asset[\"height\"] as number,\n body: {\n type: \"rig2d\",\n look: recipe.recipe.look,\n ...(Object.keys(recipe.recipe.art).length\n ? { art: recipe.recipe.art }\n : {}),\n },\n };\n return { ok: true, asset };\n }\n case \"image-actor\":\n case \"prop\": {\n const r = await finish(\n client,\n await start(client, opts, (o) =>\n client.ai.generateImage(\n FEATURES.image,\n { Background: \"transparent\", Format: \"png\" },\n {\n ...o,\n variables: { subject: prompt.slice(0, 400), style },\n },\n ),\n ),\n opts,\n 5 * 60_000,\n );\n if (!r.ok) return r;\n const url = firstUrl(r.view);\n if (!url) return { ok: false, error: \"the AI returned no picture\" };\n if (kind === \"prop\") {\n const g = G.prop.asset;\n const asset: PropAsset = {\n ...meta(\"prop\", opts, prompt, g.tags),\n kind: \"prop\",\n image: url,\n height: g[\"height\"] as number,\n grip: g[\"grip\"] as { x: number; y: number },\n };\n return { ok: true, asset };\n }\n const asset: ActorAsset = {\n ...meta(\"actor\", opts, prompt, G[\"image-actor\"].asset.tags),\n kind: \"actor\",\n affordances: G[\"image-actor\"].asset[\"affordances\"] as string[],\n height: G[\"image-actor\"].asset[\"height\"] as number,\n body: { type: \"cutout\", image: url, outline: true },\n };\n return { ok: true, asset };\n }\n case \"background\": {\n if (!opts.baseSet)\n return { ok: false, error: \"no scene to put the background into\" };\n const r = await finish(\n client,\n await start(client, opts, (o) =>\n client.ai.generateImage(\n FEATURES.background,\n { Size: \"1024x1536\", Format: \"jpeg\" },\n {\n ...o,\n variables: { subject: prompt.slice(0, 400), style },\n },\n ),\n ),\n opts,\n 5 * 60_000,\n );\n if (!r.ok) return r;\n const url = firstUrl(r.view);\n if (!url) return { ok: false, error: \"the AI returned no picture\" };\n const base = opts.baseSet.set;\n const asset: SetAsset = {\n ...meta(\"set\", opts, prompt, G.background.asset.tags),\n kind: \"set\",\n set: {\n ...base,\n background: {\n image: url,\n gradient: base.background.gradient ?? [\"#000000\", \"#000000\"],\n },\n props: [],\n },\n };\n return { ok: true, asset };\n }\n case \"music\": {\n const r = await finish(\n client,\n await start(client, opts, (o) =>\n client.ai.generateMusic(\n FEATURES.music,\n { Format: \"mp3\" },\n { ...o, variables: { mood: prompt.slice(0, 300) } },\n ),\n ),\n opts,\n 8 * 60_000,\n );\n if (!r.ok) return r;\n const url = firstUrl(r.view);\n if (!url) return { ok: false, error: \"the AI returned no music\" };\n const m = await (opts.measure ?? measureByDecode)(url);\n const asset: MusicAsset = {\n ...meta(\"music\", opts, prompt, G.music.asset.tags),\n kind: \"music\",\n url,\n duration: m?.duration ?? (G.music.asset[\"duration\"] as number),\n loop: true,\n mood: prompt.slice(0, 60),\n };\n return { ok: true, asset };\n }\n case \"three-d\":\n return { ok: false, error: \"3D generation is not open on this title\" };\n }\n}\n"
|
|
70
70
|
},
|
|
71
71
|
{
|
|
72
72
|
"path": "ai/director.ts",
|
|
73
|
-
"content": "// AI-режиссёр: идея игрока → VideoSpec + план по ступеням лестницы. Это скрытая текстовая фича\n// тайтла \"video-director\": системный промпт, схема ответа и цена — в настройках тайтла; игрок\n// заполняет только переменные шаблона (свободного промпта у фичи нет). Ответ не по схеме движок\n// сам возвращает игроку деньгами. Ссылку не из каталога студия чинит САМА — ставит ближайший\n// ассет того же вида: повторный (платный) запрос ради опечатки модели игроку не нужен.\nimport type { IDosGamesClient } from \"@idosgames/core\";\nimport type { StyleFamily } from \"@idosgames/characters\";\nimport {\n CAPTION_STYLES,\n getFormat,\n parseDirectorAnswer,\n type PlanItem,\n type VideoSpec,\n} from \"@idosgames/timeline\";\nimport { FEATURES } from \"../ids\";\nimport type { Access } from \"../model/access\";\nimport { closestAsset, type StudioCatalog } from \"../model/catalog\";\nimport { canPick } from \"../model/access\";\n\nexport type StudioMode = \"quick\" | \"unique\";\n\nexport interface DirectRequest {\n idea: string;\n format: string;\n style: StyleFamily;\n /** Язык реплик словами («Russian», «English»). */\n language: string;\n /** Сколько персонажей хочет игрок; пусто — решит формат. */\n cast?: number;\n /** Свои медиа игрока, ссылками каталога (`own:…`) с подписью. */\n media?: string;\n /** quick — только библиотека (ИИ-ассеты плана отбрасываются), unique — по плану. */\n mode: StudioMode;\n}\n\nexport interface Directed {\n spec: VideoSpec;\n plan: PlanItem[];\n /** Элементы, где ссылку модели пришлось заменить ближайшим из каталога. */\n repaired: string[];\n warnings: string[];\n}\n\nexport type DirectResult =\n | { ok: true; value: Directed }\n | { ok: false; error: string; refunded?: boolean };\n\nexport interface DirectOptions {\n signal?: AbortSignal;\n optionID?: string;\n onProgress?: (u: number) => void;\n}\n\nconst DEFAULT_CAPTIONS = \"karaoke-bold\";\n\nexport async function direct(\n client: IDosGamesClient,\n catalog: StudioCatalog,\n access: Access,\n req: DirectRequest,\n opts: DirectOptions = {},\n): Promise<DirectResult> {\n const variables: Record<string, string> = {\n idea: req.idea.trim().slice(0, 600),\n format: req.format,\n style: req.style,\n language: req.language,\n catalog: catalog.brief(access, req.style),\n };\n if (req.cast) variables[\"cast\"] = String(req.cast);\n if (req.media) variables[\"media\"] = req.media.slice(0, 600);\n\n const started = await client.ai.generateText(\n
|
|
73
|
+
"content": "// AI-режиссёр: идея игрока → VideoSpec + план по ступеням лестницы. Это скрытая текстовая фича\n// тайтла \"video-director\": системный промпт, схема ответа и цена — в настройках тайтла; игрок\n// заполняет только переменные шаблона (свободного промпта у фичи нет). Ответ не по схеме движок\n// сам возвращает игроку деньгами. Ссылку не из каталога студия чинит САМА — ставит ближайший\n// ассет того же вида: повторный (платный) запрос ради опечатки модели игроку не нужен.\nimport type { IDosGamesClient } from \"@idosgames/core\";\nimport type { StyleFamily } from \"@idosgames/characters\";\nimport {\n CAPTION_STYLES,\n getFormat,\n parseDirectorAnswer,\n type PlanItem,\n type VideoSpec,\n} from \"@idosgames/timeline\";\nimport { payWithCredits } from \"@idosgames/react/ui\";\nimport { FEATURES } from \"../ids\";\nimport type { Access } from \"../model/access\";\nimport { closestAsset, type StudioCatalog } from \"../model/catalog\";\nimport { canPick } from \"../model/access\";\n\nexport type StudioMode = \"quick\" | \"unique\";\n\nexport interface DirectRequest {\n idea: string;\n format: string;\n style: StyleFamily;\n /** Язык реплик словами («Russian», «English»). */\n language: string;\n /** Сколько персонажей хочет игрок; пусто — решит формат. */\n cast?: number;\n /** Свои медиа игрока, ссылками каталога (`own:…`) с подписью. */\n media?: string;\n /** quick — только библиотека (ИИ-ассеты плана отбрасываются), unique — по плану. */\n mode: StudioMode;\n}\n\nexport interface Directed {\n spec: VideoSpec;\n plan: PlanItem[];\n /** Элементы, где ссылку модели пришлось заменить ближайшим из каталога. */\n repaired: string[];\n warnings: string[];\n}\n\nexport type DirectResult =\n | { ok: true; value: Directed }\n | { ok: false; error: string; refunded?: boolean };\n\nexport interface DirectOptions {\n signal?: AbortSignal;\n optionID?: string;\n onProgress?: (u: number) => void;\n}\n\nconst DEFAULT_CAPTIONS = \"karaoke-bold\";\n\nexport async function direct(\n client: IDosGamesClient,\n catalog: StudioCatalog,\n access: Access,\n req: DirectRequest,\n opts: DirectOptions = {},\n): Promise<DirectResult> {\n const variables: Record<string, string> = {\n idea: req.idea.trim().slice(0, 600),\n format: req.format,\n style: req.style,\n language: req.language,\n catalog: catalog.brief(access, req.style),\n };\n if (req.cast) variables[\"cast\"] = String(req.cast);\n if (req.media) variables[\"media\"] = req.media.slice(0, 600);\n\n // Цена в Aspis: первый вызов только просит игрока подтвердить на idosgames.com (окно — кит); ключ один на оба.\n const relatedEntityID = crypto.randomUUID();\n const started = await payWithCredits(\n client,\n (creditsConfirmationID) =>\n client.ai.generateText(\n FEATURES.director,\n {},\n {\n variables,\n relatedEntityID,\n creditsConfirmationID,\n ...(opts.optionID ? { selectedOptionID: opts.optionID } : {}),\n },\n ),\n opts.signal ? { signal: opts.signal } : {},\n );\n if (!started.ok) return { ok: false, error: started.error };\n const done = await client.ai.waitForGeneration(started.data, {\n timeoutMs: 4 * 60_000,\n ...(opts.signal ? { signal: opts.signal } : {}),\n onUpdate: (v) => opts.onProgress?.(Number(v.Progress ?? 0)),\n });\n if (!done.ok) return { ok: false, error: done.error };\n if (done.data.Status !== \"completed\")\n return {\n ok: false,\n error: done.data.Error?.Message || \"director failed\",\n refunded: !!done.data.PlayerRefunded,\n };\n\n const parsed = parseDirectorAnswer(\n done.data.OutputJson ?? done.data.OutputText ?? \"\",\n );\n if (!parsed.ok)\n return {\n ok: false,\n error: parsed.issues.map((i) => `${i.path} ${i.message}`).join(\"; \"),\n };\n return {\n ok: true,\n value: settle(\n parsed.answer.spec,\n parsed.answer.plan,\n catalog,\n access,\n req,\n parsed.issues.map((i) => `${i.path} ${i.message}`),\n ),\n };\n}\n\n/**\n * Довести ответ модели до ролика, который точно соберётся: формат и стиль — как просил игрок,\n * ссылки — только из каталога и открытые игроку, субтитры — известным стилем, сид — новый.\n * Режим quick выбрасывает из плана ИИ-шаги (в спеке уже стоит ближайшее из каталога).\n */\nexport function settle(\n spec: VideoSpec,\n plan: PlanItem[],\n catalog: StudioCatalog,\n access: Access,\n req: Pick<DirectRequest, \"format\" | \"style\" | \"mode\">,\n warnings: string[] = [],\n): Directed {\n const repaired: string[] = [];\n const out: VideoSpec = {\n ...spec,\n format: getFormat(spec.format) ? spec.format : req.format,\n style: req.style,\n aspect: \"9:16\",\n fps: 30,\n seed: Math.floor(Math.random() * 2 ** 31),\n captions: CAPTION_STYLES[spec.captions] ? spec.captions : DEFAULT_CAPTIONS,\n overrides: [],\n assets: {},\n };\n\n const fix = (\n slot: string,\n ref: string | null | undefined,\n kind: \"actor\" | \"set\" | \"music\",\n ): string | undefined => {\n const a = ref ? catalog.get(ref) : undefined;\n if (a && a.kind === kind && canPick(a, access)) return a.ref;\n const near = closestAsset(catalog, access, kind, ref ?? \"\", req.style);\n if (near) repaired.push(slot);\n return near?.ref;\n };\n out.set = fix(\"set\", spec.set, \"set\") ?? spec.set;\n out.cast = spec.cast.map((c, i) => ({\n ...c,\n asset: fix(`cast[${i}]`, c.asset, \"actor\") ?? c.asset,\n }));\n if (spec.music) {\n const m = fix(\"music\", spec.music, \"music\");\n if (m) out.music = m;\n else delete out.music;\n }\n\n // формат решает, сколько актёров: лишних — долой, недостающих — из каталога\n const format = getFormat(out.format);\n if (format) {\n if (out.cast.length > format.maxCast)\n out.cast = out.cast.slice(0, format.maxCast);\n while (out.cast.length < format.minCast) {\n const used = new Set(out.cast.map((c) => c.asset));\n const extra = catalog\n .pickable(access, { kind: \"actor\", style: req.style })\n .find((a) => !used.has(a.ref));\n if (!extra) break;\n out.cast.push({ asset: extra.ref });\n repaired.push(`cast[${out.cast.length - 1}]`);\n }\n out.lines = out.lines.map((l) =>\n l.who >= out.cast.length ? { ...l, who: format.narrator ? -1 : 0 } : l,\n );\n }\n\n const kept =\n req.mode === \"quick\"\n ? plan.filter((p) => p.step !== \"ai-asset\" && p.step !== \"ai-clip\")\n : plan;\n return { spec: out, plan: kept, repaired, warnings };\n}\n"
|
|
74
74
|
},
|
|
75
75
|
{
|
|
76
76
|
"path": "ai/generators.ts",
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
},
|
|
83
83
|
{
|
|
84
84
|
"path": "ai/voice.ts",
|
|
85
|
-
"content": "// Озвучка реплик. Два голоса у каждого пресета: черновой (превью, дешёвый уровень, только английский)\n// и финальный (в MP4, понимает русский). Русский черновик читает сам браузер (Web Speech) —\n// бесплатно, только вживую, в файл не попадает. Повтор — бесплатно дважды:\n// • свой повтор: ключ генерации = голос + текст, движок отдаёт уже оплаченную запись;\n// • чужой повтор: у фич озвучки включено ShareIdenticalResults — та же реплика тем же голосом,\n// озвученная любым игроком тайтла, отдаётся из кеша тайтла за 0 (ремикс, тренд-шаблон).\n// Ремикс берёт озвучку родителя из VideoSpec: переозвучиваются только изменённые реплики.\nimport type { IDosGamesClient } from \"@idosgames/core\";\nimport {\n linesNeedingVoice,\n voiceKey,\n type Catalog,\n type Line,\n type VideoSpec,\n type VoiceAsset,\n} from \"@idosgames/timeline\";\nimport { FEATURES } from \"../ids\";\nimport { isCyrillic, NARRATOR_VOICE, voicePreset } from \"../library/voices\";\n\n/** Пресет голоса реплики: свой у реплики → у актёра в ролике → у ассета актёра → рассказчик. */\nexport function voiceOf(spec: VideoSpec, catalog: Catalog, line: Line): string {\n if (line.voice) return line.voice;\n if (line.who < 0) return NARRATOR_VOICE;\n const member = spec.cast[line.who];\n if (member?.voice) return member.voice;\n const asset = member ? catalog.get(member.asset) : undefined;\n return (asset?.kind === \"actor\" ? asset.voice : undefined) ?? NARRATOR_VOICE;\n}\n\n/** Реплики, которые надо озвучить для этого шага (черновик или финал). */\nexport function pendingLines(\n spec: VideoSpec,\n catalog: Catalog,\n final: boolean,\n): number[] {\n const all = linesNeedingVoice(spec, (l) => voiceOf(spec, catalog, l), final);\n // русский черновик читает браузер — серверу его не заказываем\n return final\n ? all\n : all.filter((i) => !isCyrillic(spec.lines[i]?.text ?? \"\"));\n}\n\n/** Реплики, которые в превью прочитает браузер (русский без серверной озвучки). */\nexport function browserLines(spec: VideoSpec): number[] {\n const out: number[] = [];\n spec.lines.forEach((l, i) => {\n if (!spec.assets.voice?.[String(i)] && isCyrillic(l.text)) out.push(i);\n });\n return out;\n}\n\nexport interface VoiceProgress {\n done: number;\n total: number;\n}\n\nexport interface VoiceOptions {\n final: boolean;\n optionID?: string;\n signal?: AbortSignal;\n onProgress?: (p: VoiceProgress) => void;\n /** Сколько реплик озвучивать одновременно. */\n parallel?: number;\n /** Длительность файла по адресу (по умолчанию — декодировать звук). */\n measure?: (url: string) => Promise<{ duration: number } | null>;\n}\n\nexport interface VoiceResult {\n spec: VideoSpec;\n /** Реплики, которые озвучить не вышло (остались с оценкой по длине). */\n failed: number[];\n /** Сколько реплик пришло бесплатно из кеша тайтла. */\n reused: number;\n error?: string;\n}\n\n/** Ключ генерации: один и тот же для того же голоса и текста — повтор не списывается дважды. */\nexport function voiceEntityID(\n final: boolean,\n voice: string,\n text: string,\n): string {\n return `${final ? \"f\" : \"d\"}${voiceKey(voice, text).slice(1)}`;\n}\n\nlet decoder: BaseAudioContext | null = null;\nasync function measureByDecode(\n url: string,\n): Promise<{ duration: number } | null> {\n try {\n const res = await fetch(url);\n if (!res.ok) return null;\n const buf = await res.arrayBuffer();\n decoder ??= new OfflineAudioContext(1, 1, 44100);\n const audio = await decoder.decodeAudioData(buf);\n return { duration: audio.duration };\n } catch {\n return null;\n }\n}\n\n/** Озвучить недостающие реплики. Возвращает НОВЫЙ VideoSpec с адресами и длительностями. */\nexport async function voiceLines(\n client: IDosGamesClient,\n spec: VideoSpec,\n catalog: Catalog,\n opts: VoiceOptions,\n): Promise<VoiceResult> {\n const todo = pendingLines(spec, catalog, opts.final);\n const voice: Record<string, VoiceAsset> = { ...(spec.assets.voice ?? {}) };\n const failed: number[] = [];\n let reused = 0;\n let done = 0;\n let error: string | undefined;\n const measure = opts.measure ?? measureByDecode;\n opts.onProgress?.({ done, total: todo.length });\n\n const one = async (i: number) => {\n const line = spec.lines[i];\n if (!line || opts.signal?.aborted) return;\n const presetId = voiceOf(spec, catalog, line);\n const preset = voicePreset(presetId);\n const name = opts.final ? preset.final : preset.draft;\n const started = await client.ai.generateAudio(\n
|
|
85
|
+
"content": "// Озвучка реплик. Два голоса у каждого пресета: черновой (превью, дешёвый уровень, только английский)\n// и финальный (в MP4, понимает русский). Русский черновик читает сам браузер (Web Speech) —\n// бесплатно, только вживую, в файл не попадает. Повтор — бесплатно дважды:\n// • свой повтор: ключ генерации = голос + текст, движок отдаёт уже оплаченную запись;\n// • чужой повтор: у фич озвучки включено ShareIdenticalResults — та же реплика тем же голосом,\n// озвученная любым игроком тайтла, отдаётся из кеша тайтла за 0 (ремикс, тренд-шаблон).\n// Ремикс берёт озвучку родителя из VideoSpec: переозвучиваются только изменённые реплики.\nimport type { IDosGamesClient } from \"@idosgames/core\";\nimport {\n linesNeedingVoice,\n voiceKey,\n type Catalog,\n type Line,\n type VideoSpec,\n type VoiceAsset,\n} from \"@idosgames/timeline\";\nimport { payWithCredits } from \"@idosgames/react/ui\";\nimport { FEATURES } from \"../ids\";\nimport { isCyrillic, NARRATOR_VOICE, voicePreset } from \"../library/voices\";\n\n/** Пресет голоса реплики: свой у реплики → у актёра в ролике → у ассета актёра → рассказчик. */\nexport function voiceOf(spec: VideoSpec, catalog: Catalog, line: Line): string {\n if (line.voice) return line.voice;\n if (line.who < 0) return NARRATOR_VOICE;\n const member = spec.cast[line.who];\n if (member?.voice) return member.voice;\n const asset = member ? catalog.get(member.asset) : undefined;\n return (asset?.kind === \"actor\" ? asset.voice : undefined) ?? NARRATOR_VOICE;\n}\n\n/** Реплики, которые надо озвучить для этого шага (черновик или финал). */\nexport function pendingLines(\n spec: VideoSpec,\n catalog: Catalog,\n final: boolean,\n): number[] {\n const all = linesNeedingVoice(spec, (l) => voiceOf(spec, catalog, l), final);\n // русский черновик читает браузер — серверу его не заказываем\n return final\n ? all\n : all.filter((i) => !isCyrillic(spec.lines[i]?.text ?? \"\"));\n}\n\n/** Реплики, которые в превью прочитает браузер (русский без серверной озвучки). */\nexport function browserLines(spec: VideoSpec): number[] {\n const out: number[] = [];\n spec.lines.forEach((l, i) => {\n if (!spec.assets.voice?.[String(i)] && isCyrillic(l.text)) out.push(i);\n });\n return out;\n}\n\nexport interface VoiceProgress {\n done: number;\n total: number;\n}\n\nexport interface VoiceOptions {\n final: boolean;\n optionID?: string;\n signal?: AbortSignal;\n onProgress?: (p: VoiceProgress) => void;\n /** Сколько реплик озвучивать одновременно. */\n parallel?: number;\n /** Длительность файла по адресу (по умолчанию — декодировать звук). */\n measure?: (url: string) => Promise<{ duration: number } | null>;\n}\n\nexport interface VoiceResult {\n spec: VideoSpec;\n /** Реплики, которые озвучить не вышло (остались с оценкой по длине). */\n failed: number[];\n /** Сколько реплик пришло бесплатно из кеша тайтла. */\n reused: number;\n error?: string;\n}\n\n/** Ключ генерации: один и тот же для того же голоса и текста — повтор не списывается дважды. */\nexport function voiceEntityID(\n final: boolean,\n voice: string,\n text: string,\n): string {\n return `${final ? \"f\" : \"d\"}${voiceKey(voice, text).slice(1)}`;\n}\n\nlet decoder: BaseAudioContext | null = null;\nasync function measureByDecode(\n url: string,\n): Promise<{ duration: number } | null> {\n try {\n const res = await fetch(url);\n if (!res.ok) return null;\n const buf = await res.arrayBuffer();\n decoder ??= new OfflineAudioContext(1, 1, 44100);\n const audio = await decoder.decodeAudioData(buf);\n return { duration: audio.duration };\n } catch {\n return null;\n }\n}\n\n/** Озвучить недостающие реплики. Возвращает НОВЫЙ VideoSpec с адресами и длительностями. */\nexport async function voiceLines(\n client: IDosGamesClient,\n spec: VideoSpec,\n catalog: Catalog,\n opts: VoiceOptions,\n): Promise<VoiceResult> {\n const todo = pendingLines(spec, catalog, opts.final);\n const voice: Record<string, VoiceAsset> = { ...(spec.assets.voice ?? {}) };\n const failed: number[] = [];\n let reused = 0;\n let done = 0;\n let error: string | undefined;\n const measure = opts.measure ?? measureByDecode;\n opts.onProgress?.({ done, total: todo.length });\n\n const one = async (i: number) => {\n const line = spec.lines[i];\n if (!line || opts.signal?.aborted) return;\n const presetId = voiceOf(spec, catalog, line);\n const preset = voicePreset(presetId);\n const name = opts.final ? preset.final : preset.draft;\n // Цена в Aspis: первый вызов только просит игрока подтвердить на idosgames.com (окно — кит).\n const started = await payWithCredits(\n client,\n (creditsConfirmationID) =>\n client.ai.generateAudio(\n opts.final ? FEATURES.voice : FEATURES.voiceDraft,\n { Text: line.text, Voice: name, Format: \"mp3\" },\n {\n relatedEntityID: voiceEntityID(opts.final, name, line.text),\n creditsConfirmationID,\n ...(opts.optionID && opts.final\n ? { selectedOptionID: opts.optionID }\n : {}),\n context: { line: i },\n },\n ),\n opts.signal ? { signal: opts.signal } : {},\n );\n const finished = started.ok\n ? await client.ai.waitForGeneration(started.data, {\n timeoutMs: 3 * 60_000,\n ...(opts.signal ? { signal: opts.signal } : {}),\n })\n : started;\n const url =\n finished.ok && finished.data.Status === \"completed\"\n ? finished.data.Assets?.find((a) => a.Url)?.Url\n : null;\n if (!url) {\n failed.push(i);\n if (!finished.ok) error ??= finished.error;\n else error ??= finished.data.Error?.Message ?? undefined;\n } else {\n if (finished.ok && finished.data.Reused) reused++;\n const m = await measure(url);\n voice[String(i)] = {\n url,\n duration: m?.duration ?? 0,\n key: voiceKey(presetId, line.text),\n ...(opts.final ? {} : { draft: true }),\n };\n }\n done++;\n opts.onProgress?.({ done, total: todo.length });\n };\n\n const queue = [...todo];\n const workers = Array.from(\n { length: Math.min(opts.parallel ?? 3, queue.length) },\n async () => {\n for (let i = queue.shift(); i !== undefined; i = queue.shift())\n await one(i);\n },\n );\n await Promise.all(workers);\n return {\n spec: { ...spec, assets: { ...spec.assets, voice } },\n failed: failed.sort((a, b) => a - b),\n reused,\n ...(error ? { error } : {}),\n };\n}\n\n/** Есть ли у ролика финальный голос на каждой реплике (без этого MP4 не собирается). */\nexport function hasFinalVoice(spec: VideoSpec, catalog: Catalog): boolean {\n return (\n linesNeedingVoice(spec, (l) => voiceOf(spec, catalog, l), true).length === 0\n );\n}\n"
|
|
86
86
|
},
|
|
87
87
|
{
|
|
88
88
|
"path": "content.ts",
|
|
@@ -162,7 +162,7 @@
|
|
|
162
162
|
},
|
|
163
163
|
{
|
|
164
164
|
"path": "studio.ts",
|
|
165
|
-
"content": "// Студия целиком: каталог, хранилище, открытый ролик и всё, что с ним делают через платформу —\n// публикация и ремикс (Мастерская), квесты и челлендж (метрики, лидерборд), аналитика промахов\n// библиотеки. Экраны только рисуют её состояние и зовут её методы; агент ИИ-кодера зовёт те же.\nimport type { IDosGamesClient, WorkshopAccessOption } from \"@idosgames/core\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport {\n decodeVideoPackage,\n remixOf,\n type Asset,\n type PlanItem,\n type VideoSpec,\n} from \"@idosgames/timeline\";\nimport { CHALLENGE_BOARD, CONTENT, METRICS } from \"./ids\";\nimport { createLibrary } from \"./library\";\nimport { accessOf, type Access, type PlayerStateView } from \"./model/access\";\nimport { StudioCatalog } from \"./model/catalog\";\nimport type { AIFeaturesView } from \"./model/pricing\";\nimport {\n newId,\n StudioStore,\n type SavedAsset,\n type SavedVideo,\n} from \"./model/store\";\nimport { captureFrame } from \"./render\";\nimport {\n ownAssetsOf,\n packAssets,\n packVideo,\n unpackAssets,\n unpackVideo,\n} from \"./content\";\n\nexport type StudioView =\n | { screen: \"home\" }\n | { screen: \"create\"; format?: string }\n | { screen: \"editor\"; videoId: string; plan?: PlanItem[] }\n | { screen: \"watch\"; contentID: string };\n\nexport type Result<T = void> =\n { ok: true; value: T } | { ok: false; error: string };\n\nexport class Studio {\n readonly catalog = new StudioCatalog(createLibrary());\n readonly store: StudioStore;\n private viewNow: StudioView = { screen: \"home\" };\n private readonly listeners = new Set<() => void>();\n private loaded: Promise<void> | null = null;\n\n constructor(\n readonly client: IDosGamesClient,\n readonly titleId: string,\n store?: StudioStore,\n ) {\n this.store =\n store ?? new StudioStore(titleId, client.auth.context?.userID ?? \"guest\");\n this.store.subscribe(() => this.emit());\n }\n\n // ── состояние экранов ──────────────────────────────────────────────────────────────────────\n get view(): StudioView {\n return this.viewNow;\n }\n\n go(view: StudioView): void {\n this.viewNow = view;\n this.emit();\n }\n\n subscribe(listener: () => void): () => void {\n this.listeners.add(listener);\n return () => this.listeners.delete(listener);\n }\n\n private emit(): void {\n for (const l of [...this.listeners]) l();\n }\n\n /** «Мои ассеты» в каталог (один раз за сессию). */\n load(): Promise<void> {\n this.loaded ??= (async () => {\n for (const a of await this.store.assets()) this.catalog.mine.add(a.asset);\n if (!configSection(this.client, \"AI\"))\n await this.client.ai.getDefinitions();\n this.emit();\n })();\n return this.loaded;\n }\n\n access(nowMs = Date.now()): Access {\n return accessOf(\n this.client.data.user.state as PlayerStateView | null,\n nowMs,\n );\n }\n\n ai(): AIFeaturesView | undefined {\n return configSection<AIFeaturesView>(this.client, \"AI\");\n }\n\n // ── ролики ─────────────────────────────────────────────────────────────────────────────────\n async createVideo(spec: VideoSpec, plan?: PlanItem[]): Promise<SavedVideo> {\n const saved: SavedVideo = { id: newId(), spec, updatedAt: Date.now() };\n await this.store.saveVideo(saved);\n this.go({\n screen: \"editor\",\n videoId: saved.id,\n ...(plan?.length ? { plan } : {}),\n });\n void this.refreshThumb(saved.id);\n return saved;\n }\n\n /** Обложка ролика для «Моих роликов» — кадр трети ролика, маленький. */\n async refreshThumb(id: string): Promise<void> {\n const v = await this.store.video(id);\n if (!v) return;\n const blob = await captureFrame(v.spec, this.catalog, 0.33, {\n width: 180,\n height: 320,\n });\n if (!blob) return;\n const thumb = await new Promise<string>((resolve) => {\n const r = new FileReader();\n r.onload = () => resolve(String(r.result));\n r.readAsDataURL(blob);\n });\n const now = await this.store.video(id);\n if (now) await this.store.saveVideo({ ...now, thumb });\n }\n\n async updateVideo(id: string, spec: VideoSpec): Promise<void> {\n const v = await this.store.video(id);\n if (!v) return;\n await this.store.saveVideo({ ...v, spec, updatedAt: Date.now() });\n }\n\n async addAsset(asset: Asset, origin: SavedAsset[\"origin\"]): Promise<void> {\n this.catalog.mine.add(asset);\n await this.store.saveAsset({ asset, origin, updatedAt: Date.now() });\n }\n\n async removeAsset(ref: string): Promise<void> {\n this.catalog.mine.remove(ref);\n await this.store.removeAsset(ref);\n }\n\n /** Публикация ролика (ремикс — с родителем: дерево ремиксов и счётчик у родителя). */\n async publish(\n id: string,\n card: { title: string; description?: string; tags?: string[] },\n ): Promise<Result<string>> {\n const v = await this.store.video(id);\n if (!v) return { ok: false, error: \"no such video\" };\n const assets = ownAssetsOf(v.spec, (r) => this.catalog.get(r));\n const thumb = await captureFrame(v.spec, this.catalog);\n if (!thumb) return { ok: false, error: \"could not draw the cover\" };\n const res = await this.client.workshop.publish({\n contentType: CONTENT.video,\n ...(v.contentID ? { contentID: v.contentID } : {}),\n ...(v.spec.parent && !v.contentID\n ? { parentContentID: v.spec.parent }\n : {}),\n files: packVideo({ spec: v.spec, assets }).map((f) => ({\n role: f.role,\n contentType: f.contentType,\n data: f.data,\n })),\n thumbnail: { contentType: \"image/jpeg\", data: thumb },\n title: card.title.slice(0, 80),\n ...(card.description ? { description: card.description } : {}),\n tags: card.tags ?? [],\n metadata: {\n format: v.spec.format,\n style: v.spec.style,\n ...(v.spec.parent ? { parent: v.spec.parent } : {}),\n },\n visibility: \"Public\",\n access: [{ Mode: \"Free\" }],\n });\n if (!res.ok) return { ok: false, error: res.error };\n const contentID = res.data.Content?.ContentID ?? v.contentID ?? \"\";\n const first = !v.contentID;\n await this.store.saveVideo({ ...v, contentID, publishedAt: Date.now() });\n if (first) {\n // квесты и недельный челлендж: очко за опубликованный ролик, отдельная метрика за ремикс\n void this.client.quest.addQuestProgress(METRICS.published, 1);\n if (v.spec.parent)\n void this.client.quest.addQuestProgress(METRICS.remixed, 1);\n void this.client.leaderboard.submitScore(CHALLENGE_BOARD, 1);\n this.client.analytics.logEvent(\"video_published\", {\n format: v.spec.format,\n style: v.spec.style,\n remix: v.spec.parent ? 1 : 0,\n });\n }\n return { ok: true, value: contentID };\n }\n\n /** Скачать опубликованный ролик: VideoSpec + его ассеты (в слой каталога «этого ролика»). */\n async fetchPublished(contentID: string): Promise<Result<VideoSpec>> {\n const got = await this.client.workshop.downloadFiles(contentID);\n if (!got.ok) {\n // бесплатный ролик сначала «берут» (лицензия Free), потом скачивают\n const acq = await this.client.workshop.acquire(contentID);\n if (!acq.ok) return { ok: false, error: got.error };\n const again = await this.client.workshop.downloadFiles(contentID);\n if (!again.ok) return { ok: false, error: again.error };\n return this.unpack(again.data.files);\n }\n return this.unpack(got.data.files);\n }\n\n private unpack(\n files: Array<{ role: string; contentType: string; data: ArrayBuffer }>,\n ): Result<VideoSpec> {\n const u = unpackVideo(files);\n if (!u.ok) return u;\n this.catalog.setVideoAssets(u.value.assets);\n return { ok: true, value: u.value.spec };\n }\n\n /** Ремикс опубликованного ролика: копия с родителем; озвучка неизменённых реплик остаётся. */\n async remix(contentID: string): Promise<Result<SavedVideo>> {\n const got = await this.fetchPublished(contentID);\n if (!got.ok) return got;\n // ассеты чужого ролика становятся «моими» — иначе ремикс не откроется после перезагрузки\n for (const a of this.catalog.video.list())\n if (!this.catalog.mine.get(a.ref)) await this.addAsset(a, \"workshop\");\n const spec = remixOf(got.value, contentID);\n this.client.analytics.logEvent(\"video_remix_started\", {\n format: spec.format,\n });\n return { ok: true, value: await this.createVideo(spec) };\n }\n\n /**\n * Ролик ССЫЛКОЙ (`#videodata=…` — ролик агента из MCP или с другого устройства): проверка тем же кодом,\n * что у Мастерской, ассеты — в «Мои», сам ролик — новым в редактор. Сервер не нужен.\n */\n async openPackage(text: string): Promise<Result<SavedVideo>> {\n await this.load();\n const got = await decodeVideoPackage(text);\n if (!got.ok) return { ok: false, error: got.error };\n for (const a of got.value.assets)\n if (!this.catalog.mine.get(a.ref)) await this.addAsset(a, \"link\");\n this.client.analytics.logEvent(\"video_link_opened\", {\n format: got.value.spec.format,\n });\n return { ok: true, value: await this.createVideo(got.value.spec) };\n }\n\n /** Живая ссылка на ролик: приложение откроет его сразу на просмотр. */\n liveLink(contentID: string): string {\n const loc = globalThis.location;\n return loc\n ? `${loc.origin}${loc.pathname}#video=${encodeURIComponent(contentID)}`\n : `#video=${contentID}`;\n }\n\n /** Ассет или набор «Моих» — в Мастерскую (бесплатно или по цене, решает игрок). */\n async publishAssets(\n title: string,\n assets: Asset[],\n price?: { currency: string; amount: number },\n ): Promise<Result<string>> {\n if (!assets.length) return { ok: false, error: \"nothing to publish\" };\n const preview = assets.find((a) => a.kind === \"actor\" || a.kind === \"set\");\n const thumb = preview ? await assetThumb(preview, this.catalog) : null;\n if (!thumb) return { ok: false, error: \"could not draw the cover\" };\n const res = await this.client.workshop.publish({\n contentType: assets.length === 1 ? CONTENT.asset : CONTENT.pack,\n files: packAssets(title, assets).map((f) => ({\n role: f.role,\n contentType: f.contentType,\n data: f.data,\n })),\n thumbnail: { contentType: \"image/jpeg\", data: thumb },\n title: title.slice(0, 80),\n tags: [...new Set(assets.flatMap((a) => a.tags))].slice(0, 10),\n metadata: {\n kinds: [...new Set(assets.map((a) => a.kind))].join(\",\"),\n styles: [...new Set(assets.map((a) => a.style))].join(\",\"),\n },\n visibility: \"Public\",\n access: price\n ? [\n {\n Mode: \"Price\",\n Price: {\n Entries: [\n {\n Type: \"VirtualCurrency\",\n CurrencyID: price.currency,\n Amount: price.amount,\n },\n ],\n },\n },\n ]\n : [{ Mode: \"Free\" }],\n });\n if (!res.ok) return { ok: false, error: res.error };\n return { ok: true, value: res.data.Content?.ContentID ?? \"\" };\n }\n\n /** Взять ассет или пакет из Мастерской в «Мои ассеты». */\n async takeAssets(\n contentID: string,\n option?: WorkshopAccessOption | null,\n ): Promise<Result<Asset[]>> {\n let got = await this.client.workshop.downloadFiles(contentID);\n if (!got.ok) {\n // платный — по цене, которую игрок видел на карточке (сервер сверит и не возьмёт иную)\n const acq = await this.client.workshop.acquire(contentID, option);\n if (!acq.ok) return { ok: false, error: acq.error };\n got = await this.client.workshop.downloadFiles(contentID);\n if (!got.ok) return { ok: false, error: got.error };\n }\n const u = unpackAssets(got.data.files, contentID);\n if (!u.ok) return u;\n for (const a of u.value) await this.addAsset(a, \"workshop\");\n return { ok: true, value: u.value };\n }\n\n /** Промах библиотеки (ИИ-шаг плана): что нарисовать в следующий пакет. */\n logMiss(item: PlanItem): void {\n if (item.step !== \"ai-asset\" && item.step !== \"ai-clip\") return;\n this.client.analytics.logEvent(\"library_miss\", {\n kind: item.generate?.kind ?? \"clip\",\n slot: item.slot.slice(0, 40),\n prompt: (item.generate?.prompt ?? \"\").slice(0, 100),\n });\n }\n}\n\n/** Обложка ассета: сцена с первым актёром её стиля, или актёр в первой сцене своего стиля. */\nasync function assetThumb(\n asset: Asset,\n catalog: StudioCatalog,\n): Promise<Blob | null> {\n const first = (kind: \"actor\" | \"set\") =>\n catalog.library.list({ kind, style: asset.style })[0] ??\n catalog.library.list({ kind })[0];\n const set = asset.kind === \"set\" ? asset : first(\"set\");\n const actor = asset.kind === \"actor\" ? asset : first(\"actor\");\n if (!set || !actor) return null;\n return captureFrame(demoSpec(set.ref, actor.ref, asset.style), catalog, 0.5);\n}\n\nfunction demoSpec(\n set: string,\n actor: string,\n style: Asset[\"style\"],\n): VideoSpec {\n return {\n v: 1,\n format: \"story@1\",\n style,\n aspect: \"9:16\",\n fps: 30,\n seed: 7,\n set,\n cast: [{ asset: actor }],\n lines: [{ who: 0, text: \"Hi!\", emotion: \"happy\" }],\n captions: \"clean\",\n overrides: [],\n assets: {},\n };\n}\n"
|
|
165
|
+
"content": "// Студия целиком: каталог, хранилище, открытый ролик и всё, что с ним делают через платформу —\n// публикация и ремикс (Мастерская), квесты и челлендж (метрики, лидерборд), аналитика промахов\n// библиотеки. Экраны только рисуют её состояние и зовут её методы; агент ИИ-кодера зовёт те же.\nimport type { IDosGamesClient, WorkshopAccessOption } from \"@idosgames/core\";\nimport { configSection, kitCreditsPrompt } from \"@idosgames/react/ui\";\nimport {\n decodeVideoPackage,\n remixOf,\n type Asset,\n type PlanItem,\n type VideoSpec,\n} from \"@idosgames/timeline\";\nimport { CHALLENGE_BOARD, CONTENT, METRICS } from \"./ids\";\nimport { createLibrary } from \"./library\";\nimport { accessOf, type Access, type PlayerStateView } from \"./model/access\";\nimport { StudioCatalog } from \"./model/catalog\";\nimport type { AIFeaturesView } from \"./model/pricing\";\nimport {\n newId,\n StudioStore,\n type SavedAsset,\n type SavedVideo,\n} from \"./model/store\";\nimport { captureFrame } from \"./render\";\nimport {\n ownAssetsOf,\n packAssets,\n packVideo,\n unpackAssets,\n unpackVideo,\n} from \"./content\";\n\nexport type StudioView =\n | { screen: \"home\" }\n | { screen: \"create\"; format?: string }\n | { screen: \"editor\"; videoId: string; plan?: PlanItem[] }\n | { screen: \"watch\"; contentID: string };\n\nexport type Result<T = void> =\n { ok: true; value: T } | { ok: false; error: string };\n\nexport class Studio {\n readonly catalog = new StudioCatalog(createLibrary());\n readonly store: StudioStore;\n private viewNow: StudioView = { screen: \"home\" };\n private readonly listeners = new Set<() => void>();\n private loaded: Promise<void> | null = null;\n\n constructor(\n readonly client: IDosGamesClient,\n readonly titleId: string,\n store?: StudioStore,\n ) {\n this.store =\n store ?? new StudioStore(titleId, client.auth.context?.userID ?? \"guest\");\n this.store.subscribe(() => this.emit());\n }\n\n // ── состояние экранов ──────────────────────────────────────────────────────────────────────\n get view(): StudioView {\n return this.viewNow;\n }\n\n go(view: StudioView): void {\n this.viewNow = view;\n this.emit();\n }\n\n subscribe(listener: () => void): () => void {\n this.listeners.add(listener);\n return () => this.listeners.delete(listener);\n }\n\n private emit(): void {\n for (const l of [...this.listeners]) l();\n }\n\n /** «Мои ассеты» в каталог (один раз за сессию). */\n load(): Promise<void> {\n this.loaded ??= (async () => {\n for (const a of await this.store.assets()) this.catalog.mine.add(a.asset);\n if (!configSection(this.client, \"AI\"))\n await this.client.ai.getDefinitions();\n this.emit();\n })();\n return this.loaded;\n }\n\n access(nowMs = Date.now()): Access {\n return accessOf(\n this.client.data.user.state as PlayerStateView | null,\n nowMs,\n );\n }\n\n ai(): AIFeaturesView | undefined {\n return configSection<AIFeaturesView>(this.client, \"AI\");\n }\n\n // ── ролики ─────────────────────────────────────────────────────────────────────────────────\n async createVideo(spec: VideoSpec, plan?: PlanItem[]): Promise<SavedVideo> {\n const saved: SavedVideo = { id: newId(), spec, updatedAt: Date.now() };\n await this.store.saveVideo(saved);\n this.go({\n screen: \"editor\",\n videoId: saved.id,\n ...(plan?.length ? { plan } : {}),\n });\n void this.refreshThumb(saved.id);\n return saved;\n }\n\n /** Обложка ролика для «Моих роликов» — кадр трети ролика, маленький. */\n async refreshThumb(id: string): Promise<void> {\n const v = await this.store.video(id);\n if (!v) return;\n const blob = await captureFrame(v.spec, this.catalog, 0.33, {\n width: 180,\n height: 320,\n });\n if (!blob) return;\n const thumb = await new Promise<string>((resolve) => {\n const r = new FileReader();\n r.onload = () => resolve(String(r.result));\n r.readAsDataURL(blob);\n });\n const now = await this.store.video(id);\n if (now) await this.store.saveVideo({ ...now, thumb });\n }\n\n async updateVideo(id: string, spec: VideoSpec): Promise<void> {\n const v = await this.store.video(id);\n if (!v) return;\n await this.store.saveVideo({ ...v, spec, updatedAt: Date.now() });\n }\n\n async addAsset(asset: Asset, origin: SavedAsset[\"origin\"]): Promise<void> {\n this.catalog.mine.add(asset);\n await this.store.saveAsset({ asset, origin, updatedAt: Date.now() });\n }\n\n async removeAsset(ref: string): Promise<void> {\n this.catalog.mine.remove(ref);\n await this.store.removeAsset(ref);\n }\n\n /** Публикация ролика (ремикс — с родителем: дерево ремиксов и счётчик у родителя). */\n async publish(\n id: string,\n card: { title: string; description?: string; tags?: string[] },\n ): Promise<Result<string>> {\n const v = await this.store.video(id);\n if (!v) return { ok: false, error: \"no such video\" };\n const assets = ownAssetsOf(v.spec, (r) => this.catalog.get(r));\n const thumb = await captureFrame(v.spec, this.catalog);\n if (!thumb) return { ok: false, error: \"could not draw the cover\" };\n // A publish fee in Aspis: the player confirms it on idosgames.com (the kit shows it); the upload is done once.\n const credits = kitCreditsPrompt(this.client);\n const res = await this.client.workshop.publish({\n contentType: CONTENT.video,\n ...(v.contentID ? { contentID: v.contentID } : {}),\n ...(v.spec.parent && !v.contentID\n ? { parentContentID: v.spec.parent }\n : {}),\n files: packVideo({ spec: v.spec, assets }).map((f) => ({\n role: f.role,\n contentType: f.contentType,\n data: f.data,\n })),\n thumbnail: { contentType: \"image/jpeg\", data: thumb },\n title: card.title.slice(0, 80),\n ...(card.description ? { description: card.description } : {}),\n tags: card.tags ?? [],\n metadata: {\n format: v.spec.format,\n style: v.spec.style,\n ...(v.spec.parent ? { parent: v.spec.parent } : {}),\n },\n visibility: \"Public\",\n access: [{ Mode: \"Free\" }],\n credits: credits.options,\n });\n await credits.done(res);\n if (!res.ok) return { ok: false, error: res.error };\n const contentID = res.data.Content?.ContentID ?? v.contentID ?? \"\";\n const first = !v.contentID;\n await this.store.saveVideo({ ...v, contentID, publishedAt: Date.now() });\n if (first) {\n // квесты и недельный челлендж: очко за опубликованный ролик, отдельная метрика за ремикс\n void this.client.quest.addQuestProgress(METRICS.published, 1);\n if (v.spec.parent)\n void this.client.quest.addQuestProgress(METRICS.remixed, 1);\n void this.client.leaderboard.submitScore(CHALLENGE_BOARD, 1);\n this.client.analytics.logEvent(\"video_published\", {\n format: v.spec.format,\n style: v.spec.style,\n remix: v.spec.parent ? 1 : 0,\n });\n }\n return { ok: true, value: contentID };\n }\n\n /** Скачать опубликованный ролик: VideoSpec + его ассеты (в слой каталога «этого ролика»). */\n async fetchPublished(contentID: string): Promise<Result<VideoSpec>> {\n const got = await this.client.workshop.downloadFiles(contentID);\n if (!got.ok) {\n // бесплатный ролик сначала «берут» (лицензия Free), потом скачивают\n const acq = await this.client.workshop.acquire(contentID);\n if (!acq.ok) return { ok: false, error: got.error };\n const again = await this.client.workshop.downloadFiles(contentID);\n if (!again.ok) return { ok: false, error: again.error };\n return this.unpack(again.data.files);\n }\n return this.unpack(got.data.files);\n }\n\n private unpack(\n files: Array<{ role: string; contentType: string; data: ArrayBuffer }>,\n ): Result<VideoSpec> {\n const u = unpackVideo(files);\n if (!u.ok) return u;\n this.catalog.setVideoAssets(u.value.assets);\n return { ok: true, value: u.value.spec };\n }\n\n /** Ремикс опубликованного ролика: копия с родителем; озвучка неизменённых реплик остаётся. */\n async remix(contentID: string): Promise<Result<SavedVideo>> {\n const got = await this.fetchPublished(contentID);\n if (!got.ok) return got;\n // ассеты чужого ролика становятся «моими» — иначе ремикс не откроется после перезагрузки\n for (const a of this.catalog.video.list())\n if (!this.catalog.mine.get(a.ref)) await this.addAsset(a, \"workshop\");\n const spec = remixOf(got.value, contentID);\n this.client.analytics.logEvent(\"video_remix_started\", {\n format: spec.format,\n });\n return { ok: true, value: await this.createVideo(spec) };\n }\n\n /**\n * Ролик ССЫЛКОЙ (`#videodata=…` — ролик агента из MCP или с другого устройства): проверка тем же кодом,\n * что у Мастерской, ассеты — в «Мои», сам ролик — новым в редактор. Сервер не нужен.\n */\n async openPackage(text: string): Promise<Result<SavedVideo>> {\n await this.load();\n const got = await decodeVideoPackage(text);\n if (!got.ok) return { ok: false, error: got.error };\n for (const a of got.value.assets)\n if (!this.catalog.mine.get(a.ref)) await this.addAsset(a, \"link\");\n this.client.analytics.logEvent(\"video_link_opened\", {\n format: got.value.spec.format,\n });\n return { ok: true, value: await this.createVideo(got.value.spec) };\n }\n\n /** Живая ссылка на ролик: приложение откроет его сразу на просмотр. */\n liveLink(contentID: string): string {\n const loc = globalThis.location;\n return loc\n ? `${loc.origin}${loc.pathname}#video=${encodeURIComponent(contentID)}`\n : `#video=${contentID}`;\n }\n\n /** Ассет или набор «Моих» — в Мастерскую (бесплатно или по цене, решает игрок). */\n async publishAssets(\n title: string,\n assets: Asset[],\n price?: { currency: string; amount: number },\n ): Promise<Result<string>> {\n if (!assets.length) return { ok: false, error: \"nothing to publish\" };\n const preview = assets.find((a) => a.kind === \"actor\" || a.kind === \"set\");\n const thumb = preview ? await assetThumb(preview, this.catalog) : null;\n if (!thumb) return { ok: false, error: \"could not draw the cover\" };\n // A publish fee in Aspis: the player confirms it on idosgames.com (the kit shows it); the upload is done once.\n const credits = kitCreditsPrompt(this.client);\n const res = await this.client.workshop.publish({\n contentType: assets.length === 1 ? CONTENT.asset : CONTENT.pack,\n files: packAssets(title, assets).map((f) => ({\n role: f.role,\n contentType: f.contentType,\n data: f.data,\n })),\n thumbnail: { contentType: \"image/jpeg\", data: thumb },\n title: title.slice(0, 80),\n tags: [...new Set(assets.flatMap((a) => a.tags))].slice(0, 10),\n metadata: {\n kinds: [...new Set(assets.map((a) => a.kind))].join(\",\"),\n styles: [...new Set(assets.map((a) => a.style))].join(\",\"),\n },\n visibility: \"Public\",\n access: price\n ? [\n {\n Mode: \"Price\",\n Price: {\n Entries: [\n {\n Type: \"VirtualCurrency\",\n CurrencyID: price.currency,\n Amount: price.amount,\n },\n ],\n },\n },\n ]\n : [{ Mode: \"Free\" }],\n credits: credits.options,\n });\n await credits.done(res);\n if (!res.ok) return { ok: false, error: res.error };\n return { ok: true, value: res.data.Content?.ContentID ?? \"\" };\n }\n\n /** Взять ассет или пакет из Мастерской в «Мои ассеты». */\n async takeAssets(\n contentID: string,\n option?: WorkshopAccessOption | null,\n ): Promise<Result<Asset[]>> {\n let got = await this.client.workshop.downloadFiles(contentID);\n if (!got.ok) {\n // платный — по цене, которую игрок видел на карточке (сервер сверит и не возьмёт иную)\n const acq = await this.client.workshop.acquire(contentID, option);\n if (!acq.ok) return { ok: false, error: acq.error };\n got = await this.client.workshop.downloadFiles(contentID);\n if (!got.ok) return { ok: false, error: got.error };\n }\n const u = unpackAssets(got.data.files, contentID);\n if (!u.ok) return u;\n for (const a of u.value) await this.addAsset(a, \"workshop\");\n return { ok: true, value: u.value };\n }\n\n /** Промах библиотеки (ИИ-шаг плана): что нарисовать в следующий пакет. */\n logMiss(item: PlanItem): void {\n if (item.step !== \"ai-asset\" && item.step !== \"ai-clip\") return;\n this.client.analytics.logEvent(\"library_miss\", {\n kind: item.generate?.kind ?? \"clip\",\n slot: item.slot.slice(0, 40),\n prompt: (item.generate?.prompt ?? \"\").slice(0, 100),\n });\n }\n}\n\n/** Обложка ассета: сцена с первым актёром её стиля, или актёр в первой сцене своего стиля. */\nasync function assetThumb(\n asset: Asset,\n catalog: StudioCatalog,\n): Promise<Blob | null> {\n const first = (kind: \"actor\" | \"set\") =>\n catalog.library.list({ kind, style: asset.style })[0] ??\n catalog.library.list({ kind })[0];\n const set = asset.kind === \"set\" ? asset : first(\"set\");\n const actor = asset.kind === \"actor\" ? asset : first(\"actor\");\n if (!set || !actor) return null;\n return captureFrame(demoSpec(set.ref, actor.ref, asset.style), catalog, 0.5);\n}\n\nfunction demoSpec(\n set: string,\n actor: string,\n style: Asset[\"style\"],\n): VideoSpec {\n return {\n v: 1,\n format: \"story@1\",\n style,\n aspect: \"9:16\",\n fps: 30,\n seed: 7,\n set,\n cast: [{ asset: actor }],\n lines: [{ who: 0, text: \"Hi!\", emotion: \"happy\" }],\n captions: \"clean\",\n overrides: [],\n assets: {},\n };\n}\n"
|
|
166
166
|
},
|
|
167
167
|
{
|
|
168
168
|
"path": "ui/AssetPicker.tsx",
|