@idosgames/mcp 0.1.44 → 0.1.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +1 -1
- package/package.json +1 -1
- package/registry/host.json +1 -1
- package/registry/index.json +119 -119
- 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 +5 -5
- package/registry/modules/idle-rpg.json +7 -7
- package/registry/modules/inventory.json +3 -3
- package/registry/modules/invite.json +3 -3
- 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 +8 -8
- package/registry/modules/voxelcraft.json +5 -5
- package/registry/modules/workshop.json +6 -6
- package/registry/skills/idosgames-ui-kit.json +1 -1
- package/registry/skills/store-system.json +1 -1
|
@@ -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.0",
|
|
40
|
+
"@idosgames/module-sdk": "0.13.3",
|
|
41
|
+
"@idosgames/react": "0.7.0",
|
|
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.0",
|
|
36
|
+
"@idosgames/module-sdk": "0.13.3",
|
|
37
|
+
"@idosgames/react": "0.7.0",
|
|
38
38
|
"react": "19.2.7"
|
|
39
39
|
},
|
|
40
40
|
"contentHash": "da4dbd714014f6a0f2a2d761ac737dc2e70f5c466ec05f9cc51194d3cd87f40f",
|
|
@@ -55,22 +55,22 @@
|
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
57
|
"@idosgames/characters": "0.1.2",
|
|
58
|
-
"@idosgames/core": "0.
|
|
59
|
-
"@idosgames/module-sdk": "0.13.
|
|
60
|
-
"@idosgames/react": "0.
|
|
58
|
+
"@idosgames/core": "0.26.0",
|
|
59
|
+
"@idosgames/module-sdk": "0.13.3",
|
|
60
|
+
"@idosgames/react": "0.7.0",
|
|
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",
|
|
@@ -85,13 +85,13 @@
|
|
|
85
85
|
},
|
|
86
86
|
"dependencies": {
|
|
87
87
|
"@idosgames/characters": "0.1.2",
|
|
88
|
-
"@idosgames/core": "0.
|
|
89
|
-
"@idosgames/module-sdk": "0.13.
|
|
90
|
-
"@idosgames/react": "0.
|
|
88
|
+
"@idosgames/core": "0.26.0",
|
|
89
|
+
"@idosgames/module-sdk": "0.13.3",
|
|
90
|
+
"@idosgames/react": "0.7.0",
|
|
91
91
|
"react": "19.2.7",
|
|
92
92
|
"three": "0.185.1"
|
|
93
93
|
},
|
|
94
|
-
"contentHash": "
|
|
94
|
+
"contentHash": "5068fee9f43c38b2967a4771de445afa23699a0ed5bc16fe9ecd891757dd0730",
|
|
95
95
|
"files": [
|
|
96
96
|
{
|
|
97
97
|
"path": "agent.ts",
|
|
@@ -107,7 +107,7 @@
|
|
|
107
107
|
},
|
|
108
108
|
{
|
|
109
109
|
"path": "ai/specAi.ts",
|
|
110
|
-
"content": "// Общая ИИ-генерация РЕЦЕПТА поверх client.ai (@idosgames/core): мир, персонаж, облик предмета —\n// одна и та же механика, разные фича, промпт и разбор ответа. Правила — по скиллу\n// ai-generation-system:\n//\n// - клиент называет только фичу: модель, системный промпт и цена — в конфиге тайтла;\n// - отказ (ok: false, \"AI_CODE: сообщение\") и провал генерации (Status: \"failed\") — разные вещи;\n// - у generate нет авто-ретрая транспорта: при обрыве связи повторяем САМИ и с тем же\n// relatedEntityID — сервер вернёт ту же генерацию и второй раз не спишет;\n// - асинхронную задачу опрашиваем сами, пока игрок ждёт; закрыл экран — задача дорабатывает на\n// сервере, её GenerationID сохраняется, и resume() дождётся результата в следующий раз.\n// От @idosgames/core — только типы: сам клиент модулю отдаёт хост (ctx.client), runtime-код core\n// модулю не нужен. Поэтому коды отказов ниже — литералами, зеркалом AIErrorCode из core.\nimport type {\n AIGenerationView,\n AIPublicFeature,\n AITextInput,\n IDosGamesClient,\n OperationResult,\n} from \"@idosgames/core\";\nimport { tr, type Loc } from \"../i18n\";\nimport type {\n WorldAIFailure,\n WorldAIFeature,\n WorldAIProgress,\n WorldAIStatus,\n} from \"./types\";\n\ntype Failure = Extract<OperationResult<unknown>, { ok: false }>;\n\n/** Коды отказов AI-модуля (AIErrorCode в @idosgames/core; сервер — InAppAIErrors). */\nconst AIErrorCode = {\n Disabled: \"AI_DISABLED\",\n FeatureNotFound: \"AI_FEATURE_NOT_FOUND\",\n FeatureDisabled: \"AI_FEATURE_DISABLED\",\n NotAvailableForPlayer: \"AI_NOT_AVAILABLE_FOR_PLAYER\",\n FeatureClosed: \"AI_FEATURE_CLOSED\",\n LimitReached: \"AI_LIMIT_REACHED\",\n Cooldown: \"AI_COOLDOWN\",\n BadInput: \"AI_BAD_INPUT\",\n PromptRejected: \"AI_PROMPT_REJECTED\",\n Unavailable: \"AI_UNAVAILABLE\",\n GenerationNotFound: \"AI_GENERATION_NOT_FOUND\",\n} as const;\n\n/** Код из отказа \"AI_COOLDOWN: …\" (как parseAIErrorCode в core); null — отказ без кода. */\nexport function parseAIErrorCode(\n error: string | null | undefined,\n): string | null {\n if (!error) return null;\n return /\\b(AI_[A-Z_]+)\\s*:/.exec(error)?.[1] ?? null;\n}\n\nexport type SpecAIFeature = WorldAIFeature;\nexport type SpecAIStatus = WorldAIStatus;\nexport type SpecAIProgress = WorldAIProgress;\n\nexport type SpecAIResult<S> =\n | { ok: true; spec: S; warnings: string[] }\n | { ok: false; kind: WorldAIFailure; message: string; refunded?: boolean };\n\n/** Разбор текста ответа модели. */\nexport type SpecParse<S> =\n | { ok: true; spec: S; warnings: string[] }\n | { ok: false; truncated?: boolean };\n\nexport interface SpecAIImage {\n dataUrl: string;\n contentType: string;\n}\n\n/** Что отличает один вид генерации от другого. */\nexport interface SpecAIKind<Req, S, Extra = null> {\n featureKey: string;\n /** Суффикс ключа незаконченной задачи в localStorage. */\n pendingName: string;\n buildPrompt(req: Req, maxPromptChars: number | null): string;\n images(req: Req): SpecAIImage[];\n /** То, что надо помнить вместе с задачей до ответа (у мира — размер). */\n extra(req: Req): Extra;\n /** Ответ модели → рецепт (extra — то, что запомнили при запуске). */\n parse(text: string, extra: Extra): SpecParse<S>;\n /** Тексты для игрока. */\n texts: {\n /** «миры», «персонажей», «предметы» — «ИИ-генерация … не настроена». */\n what: Loc;\n /** «ИИ строит мир…» */\n working: Loc;\n /** «Войдите в игру, чтобы строить миры с ИИ.» */\n login: Loc;\n /** «ИИ не смог построить мир.» */\n failed: Loc;\n /** «ИИ ответил не рецептом мира.» */\n invalid: Loc;\n /** «Ответ ИИ оборван — мир получился слишком длинным.» */\n truncated: Loc;\n /** «Дожидаюсь мира с прошлого раза…» */\n resuming: Loc;\n /** «ИИ отвечает слишком долго. Откройте «Создать мир» позже — результат дождётся вас.» */\n slow: Loc;\n };\n}\n\nexport interface SpecAIOptions {\n featureKey?: string;\n pollMs?: number;\n timeoutMs?: number;\n /** localStorage по умолчанию; null — не сохранять задачу между запусками. */\n storage?: Pick<Storage, \"getItem\" | \"setItem\" | \"removeItem\"> | null;\n sleep?: (ms: number, signal: AbortSignal) => Promise<void>;\n}\n\nexport interface SpecAI<Req, S> {\n status(): Promise<SpecAIStatus>;\n generate(\n req: Req,\n opts: { signal: AbortSignal; onProgress: SpecAIProgress },\n ): Promise<SpecAIResult<S>>;\n /** Недождавшаяся задача с прошлого раза, если есть: дождаться её результата. */\n resume(opts: {\n signal: AbortSignal;\n onProgress: SpecAIProgress;\n }): Promise<SpecAIResult<S> | null>;\n}\n\nconst DEFAULT_TYPES = [\"image/png\", \"image/jpeg\", \"image/webp\"];\n\nfunction defaultStorage(): SpecAIOptions[\"storage\"] {\n try {\n return typeof localStorage === \"undefined\" ? null : localStorage;\n } catch {\n return null; // хранилище запрещено (фрейм, настройки браузера)\n }\n}\n\nfunction abortableSleep(ms: number, signal: AbortSignal): Promise<void> {\n return new Promise((resolve) => {\n const t = setTimeout(resolve, ms);\n signal.addEventListener(\"abort\", () => {\n clearTimeout(t);\n resolve();\n });\n });\n}\n\nfunction newKey(): string {\n return typeof crypto !== \"undefined\" && \"randomUUID\" in crypto\n ? crypto.randomUUID()\n : Date.now().toString(36) + \"-\" + Math.random().toString(36).slice(2, 12);\n}\n\n/** «10 GEM» из первой опции цены; null — бесплатно. */\nexport function priceLabel(\n options: AIPublicFeature[\"PriceOptions\"],\n): string | null {\n const first = options ? Object.values(options)[0] : undefined;\n if (!first) return null;\n if (first.Name) return first.Name;\n const parts = (first.Cost?.Standard?.Entries ?? [])\n .map((e) => `${e.Amount ?? \"\"} ${e.CurrencyID ?? e.ItemID ?? \"\"}`.trim())\n .filter(Boolean);\n return parts.length ? parts.join(\" + \") : null;\n}\n\nfunction toFeature(f: AIPublicFeature): SpecAIFeature {\n return {\n allowImages: f.AllowImages === true,\n maxImageBytes: f.MaxInputImageBytes || null,\n imageTypes: f.AllowedImageTypes?.length\n ? f.AllowedImageTypes\n : DEFAULT_TYPES,\n async: f.Async === true,\n priceLabel: priceLabel(f.PriceOptions),\n maxPromptChars: f.MaxPromptChars || null,\n // у фичи по умолчанию одна картинка; потолка платформы нет — сколько разрешил издатель\n maxImages: Math.max(1, f.MaxInputImages || 1),\n };\n}\n\n/** Безопасное сообщение из отказа \"AI_CODE: сообщение\". */\nfunction messageOf(error: string): string {\n const m = /\\bAI_[A-Z_]+\\s*:\\s*([\\s\\S]*)$/.exec(error);\n return (m?.[1] ?? error).trim();\n}\n\ntype Refusal = Extract<SpecAIResult<unknown>, { ok: false }>;\n\n/** Отказ сервера (ничего не сгенерировано и не списано) — в понятный игроку текст. */\nfunction refusal(res: Failure, login: Loc, what: Loc): Refusal {\n if (res.reason === \"connection\")\n return {\n ok: false,\n kind: \"connection\",\n message: tr({\n en: \"No connection to the server. Try again.\",\n ru: \"Нет связи с сервером. Попробуйте ещё раз.\",\n }),\n };\n if (res.reason === \"unauthorized\")\n return { ok: false, kind: \"refused\", message: tr(login) };\n if (res.reason === \"throttled\")\n return {\n ok: false,\n kind: \"refused\",\n message: tr({\n en: \"A generation is already running — wait for it.\",\n ru: \"Генерация уже идёт — дождитесь её.\",\n }),\n };\n const code = parseAIErrorCode(res.error);\n switch (code) {\n case AIErrorCode.LimitReached:\n case AIErrorCode.Cooldown:\n return {\n ok: false,\n kind: \"refused\",\n message: tr({\n en: \"The AI generation limit is used up for now. Come back later.\",\n ru: \"Лимит ИИ-генераций на сейчас исчерпан. Загляните позже.\",\n }),\n };\n case AIErrorCode.PromptRejected:\n return {\n ok: false,\n kind: \"refused\",\n message: tr({\n en: \"The description did not pass the check. Rephrase it.\",\n ru: \"Описание не прошло проверку. Переформулируйте его.\",\n }),\n };\n case AIErrorCode.BadInput:\n return { ok: false, kind: \"refused\", message: messageOf(res.error) };\n case AIErrorCode.FeatureClosed:\n return {\n ok: false,\n kind: \"refused\",\n message: tr(\n {\n en: \"AI generation of {what} is closed right now.\",\n ru: \"ИИ-генерация {what} сейчас закрыта.\",\n },\n { what: tr(what) },\n ),\n };\n case AIErrorCode.Unavailable:\n case AIErrorCode.Disabled:\n case AIErrorCode.FeatureNotFound:\n case AIErrorCode.FeatureDisabled:\n case AIErrorCode.NotAvailableForPlayer:\n return {\n ok: false,\n kind: \"refused\",\n message: tr({\n en: \"AI is unavailable right now.\",\n ru: \"ИИ сейчас недоступен.\",\n }),\n };\n default:\n return {\n ok: false,\n kind: \"refused\",\n message: code\n ? messageOf(res.error)\n : tr({\n en: \"Could not start the generation.\",\n ru: \"Не удалось запустить генерацию.\",\n }),\n };\n }\n}\n\n/** Недождавшаяся задача: сохраняется до результата, переживает перезагрузку. */\ninterface PendingJob<Extra> {\n id: string;\n extra: Extra;\n}\n\nexport function createSpecAI<Req, S, Extra>(\n client: IDosGamesClient,\n kind: SpecAIKind<Req, S, Extra>,\n opts: SpecAIOptions & {\n /** Старый формат сохранённой задачи (мир: { id, size }) — читать и его. */\n readPending?: (raw: Record<string, unknown>) => PendingJob<Extra> | null;\n writePending?: (job: PendingJob<Extra>) => unknown;\n } = {},\n): SpecAI<Req, S> {\n const featureKey = opts.featureKey ?? kind.featureKey;\n const pollMs = opts.pollMs ?? 4000;\n const timeoutMs = opts.timeoutMs ?? 10 * 60_000;\n const storage = opts.storage === undefined ? defaultStorage() : opts.storage;\n const sleep = opts.sleep ?? abortableSleep;\n const pendingKey = `voxelcraft:${kind.pendingName}:${client.titleID}`;\n const T = kind.texts;\n let feature: SpecAIFeature | null = null;\n\n const savePending = (job: PendingJob<Extra>) => {\n try {\n storage?.setItem(\n pendingKey,\n JSON.stringify(opts.writePending ? opts.writePending(job) : job),\n );\n } catch {\n /* без хранилища задача просто не переживёт перезагрузку */\n }\n };\n const loadPending = (): PendingJob<Extra> | null => {\n try {\n const raw = storage?.getItem(pendingKey);\n const job = raw ? (JSON.parse(raw) as Record<string, unknown>) : null;\n if (!job || typeof job.id !== \"string\") return null;\n if (opts.readPending) return opts.readPending(job);\n return \"extra\" in job ? (job as unknown as PendingJob<Extra>) : null;\n } catch {\n return null;\n }\n };\n const clearPending = () => {\n try {\n storage?.removeItem(pendingKey);\n } catch {\n /* нечего чистить */\n }\n };\n\n async function status(): Promise<SpecAIStatus> {\n const res = await client.ai.getDefinitions();\n if (!res.ok)\n return {\n available: false,\n reason:\n res.reason === \"unauthorized\"\n ? tr({\n en: \"sign in to build with AI\",\n ru: \"войдите, чтобы строить с ИИ\",\n })\n : tr({\n en: \"AI is unavailable right now\",\n ru: \"ИИ сейчас недоступен\",\n }),\n };\n if (res.data.Enabled === false)\n return {\n available: false,\n reason: tr({\n en: \"AI is turned off in this game\",\n ru: \"ИИ выключен в этой игре\",\n }),\n };\n const f = res.data.Features?.[featureKey];\n if (!f || (f.Modality && f.Modality !== \"Text\"))\n return {\n available: false,\n reason: tr(\n {\n en: \"AI generation of {what} is not set up\",\n ru: \"ИИ-генерация {what} не настроена\",\n },\n { what: tr(T.what) },\n ),\n };\n feature = toFeature(f);\n return { available: true, feature };\n }\n\n /** Опрос асинхронной задачи, пока игрок ждёт. */\n async function waitFor(\n first: AIGenerationView,\n signal: AbortSignal,\n onProgress: SpecAIProgress,\n ): Promise<AIGenerationView | Refusal> {\n const deadline = Date.now() + timeoutMs;\n let view = first;\n while (!client.ai.isTerminal(view.Status)) {\n if (signal.aborted) return { ok: false, kind: \"cancelled\", message: \"\" };\n if (Date.now() > deadline)\n return { ok: false, kind: \"connection\", message: tr(T.slow) };\n onProgress(view.Progress ?? null, tr(T.working));\n await sleep(pollMs, signal);\n if (signal.aborted) return { ok: false, kind: \"cancelled\", message: \"\" };\n const next = await client.ai.getText(view.GenerationID);\n if (!next.ok) {\n if (next.reason === \"throttled\" || next.reason === \"connection\")\n continue;\n if (parseAIErrorCode(next.error) === AIErrorCode.GenerationNotFound)\n clearPending();\n return refusal(next, T.login, T.what);\n }\n view = next.data;\n }\n return view;\n }\n\n /** Довести генерацию до рецепта: дождаться и разобрать. */\n async function settle(\n first: AIGenerationView,\n extra: Extra,\n signal: AbortSignal,\n onProgress: SpecAIProgress,\n ): Promise<SpecAIResult<S>> {\n let view = first;\n if (!client.ai.isTerminal(view.Status)) {\n savePending({ id: view.GenerationID, extra });\n const waited = await waitFor(view, signal, onProgress);\n if (\"ok\" in waited) return waited; // отменено / таймаут / отказ — задача остаётся сохранённой\n view = waited;\n }\n clearPending();\n if (view.Status !== \"completed\")\n return {\n ok: false,\n kind: \"failed\",\n message: view.Error?.Message || tr(T.failed),\n refunded: view.PlayerRefunded === true,\n };\n const parsed = kind.parse(view.OutputText ?? \"\", extra);\n if (!parsed.ok)\n return parsed.truncated\n ? { ok: false, kind: \"truncated\", message: tr(T.truncated) }\n : { ok: false, kind: \"invalid\", message: tr(T.invalid) };\n return parsed;\n }\n\n async function generate(\n req: Req,\n { signal, onProgress }: { signal: AbortSignal; onProgress: SpecAIProgress },\n ): Promise<SpecAIResult<S>> {\n if (!feature) {\n const s = await status();\n if (!s.available)\n return { ok: false, kind: \"refused\", message: s.reason };\n }\n const f = feature!; // status() выставил её или вернул отказ выше\n const input: AITextInput = {\n Prompt: kind.buildPrompt(req, f.maxPromptChars),\n };\n const images = kind.images(req);\n if (images.length && f.allowImages)\n input.Images = images\n .slice(0, f.maxImages)\n .map((im) => ({ Base64: im.dataUrl, ContentType: im.contentType }));\n\n // Один ключ на нажатие: повтор после обрыва связи — та же генерация, без второго списания.\n const key = newKey();\n onProgress(\n null,\n tr({ en: \"Sending the request to AI…\", ru: \"Отправляю запрос ИИ…\" }),\n );\n let res = await client.ai.generateText(featureKey, input, {\n relatedEntityID: key,\n });\n if (!res.ok && res.reason === \"connection\" && !signal.aborted) {\n onProgress(\n null,\n tr({\n en: \"Connection dropped, retrying…\",\n ru: \"Связь прервалась, повторяю…\",\n }),\n );\n res = await client.ai.generateText(featureKey, input, {\n relatedEntityID: key,\n });\n }\n if (!res.ok) {\n const code = parseAIErrorCode(res.error);\n if (\n code === AIErrorCode.FeatureNotFound ||\n code === AIErrorCode.FeatureDisabled\n )\n feature = null;\n return refusal(res, T.login, T.what);\n }\n return settle(res.data, kind.extra(req), signal, onProgress);\n }\n\n async function resume({\n signal,\n onProgress,\n }: {\n signal: AbortSignal;\n onProgress: SpecAIProgress;\n }): Promise<SpecAIResult<S> | null> {\n const job = loadPending();\n if (!job) return null;\n onProgress(null, tr(T.resuming));\n const res = await client.ai.getText(job.id);\n if (!res.ok) {\n if (res.reason === \"connection\" || res.reason === \"throttled\")\n return null; // попробуем в другой раз\n clearPending(); // задачи больше нет (или она чужая) — не спрашивать о ней снова\n return null;\n }\n return settle(res.data, job.extra, signal, onProgress);\n }\n\n return { status, generate, resume };\n}\n"
|
|
110
|
+
"content": "// Общая ИИ-генерация РЕЦЕПТА поверх client.ai (@idosgames/core): мир, персонаж, облик предмета —\n// одна и та же механика, разные фича, промпт и разбор ответа. Правила — по скиллу\n// ai-generation-system:\n//\n// - клиент называет только фичу: модель, системный промпт и цена — в конфиге тайтла;\n// - отказ (ok: false, \"AI_CODE: сообщение\") и провал генерации (Status: \"failed\") — разные вещи;\n// - у generate нет авто-ретрая транспорта: при обрыве связи повторяем САМИ и с тем же\n// relatedEntityID — сервер вернёт ту же генерацию и второй раз не спишет;\n// - асинхронную задачу опрашиваем сами, пока игрок ждёт; закрыл экран — задача дорабатывает на\n// сервере, её GenerationID сохраняется, и resume() дождётся результата в следующий раз.\n// От @idosgames/core — только типы: сам клиент модулю отдаёт хост (ctx.client), runtime-код core\n// модулю не нужен. Поэтому коды отказов ниже — литералами, зеркалом AIErrorCode из core.\nimport type {\n AIGenerationView,\n AIPublicFeature,\n AITextInput,\n IDosGamesClient,\n OperationResult,\n} from \"@idosgames/core\";\nimport { errorText, payWithCredits } from \"@idosgames/react/ui\";\nimport { tr, type Loc } from \"../i18n\";\nimport type {\n WorldAIFailure,\n WorldAIFeature,\n WorldAIProgress,\n WorldAIStatus,\n} from \"./types\";\n\ntype Failure = Extract<OperationResult<unknown>, { ok: false }>;\n\n/** Коды отказов AI-модуля (AIErrorCode в @idosgames/core; сервер — InAppAIErrors). */\nconst AIErrorCode = {\n Disabled: \"AI_DISABLED\",\n FeatureNotFound: \"AI_FEATURE_NOT_FOUND\",\n FeatureDisabled: \"AI_FEATURE_DISABLED\",\n NotAvailableForPlayer: \"AI_NOT_AVAILABLE_FOR_PLAYER\",\n FeatureClosed: \"AI_FEATURE_CLOSED\",\n LimitReached: \"AI_LIMIT_REACHED\",\n Cooldown: \"AI_COOLDOWN\",\n BadInput: \"AI_BAD_INPUT\",\n PromptRejected: \"AI_PROMPT_REJECTED\",\n Unavailable: \"AI_UNAVAILABLE\",\n GenerationNotFound: \"AI_GENERATION_NOT_FOUND\",\n} as const;\n\n/** Код из отказа \"AI_COOLDOWN: …\" (как parseAIErrorCode в core); null — отказ без кода. */\nexport function parseAIErrorCode(\n error: string | null | undefined,\n): string | null {\n if (!error) return null;\n return /\\b(AI_[A-Z_]+)\\s*:/.exec(error)?.[1] ?? null;\n}\n\nexport type SpecAIFeature = WorldAIFeature;\nexport type SpecAIStatus = WorldAIStatus;\nexport type SpecAIProgress = WorldAIProgress;\n\nexport type SpecAIResult<S> =\n | { ok: true; spec: S; warnings: string[] }\n | { ok: false; kind: WorldAIFailure; message: string; refunded?: boolean };\n\n/** Разбор текста ответа модели. */\nexport type SpecParse<S> =\n | { ok: true; spec: S; warnings: string[] }\n | { ok: false; truncated?: boolean };\n\nexport interface SpecAIImage {\n dataUrl: string;\n contentType: string;\n}\n\n/** Что отличает один вид генерации от другого. */\nexport interface SpecAIKind<Req, S, Extra = null> {\n featureKey: string;\n /** Суффикс ключа незаконченной задачи в localStorage. */\n pendingName: string;\n buildPrompt(req: Req, maxPromptChars: number | null): string;\n images(req: Req): SpecAIImage[];\n /** То, что надо помнить вместе с задачей до ответа (у мира — размер). */\n extra(req: Req): Extra;\n /** Ответ модели → рецепт (extra — то, что запомнили при запуске). */\n parse(text: string, extra: Extra): SpecParse<S>;\n /** Тексты для игрока. */\n texts: {\n /** «миры», «персонажей», «предметы» — «ИИ-генерация … не настроена». */\n what: Loc;\n /** «ИИ строит мир…» */\n working: Loc;\n /** «Войдите в игру, чтобы строить миры с ИИ.» */\n login: Loc;\n /** «ИИ не смог построить мир.» */\n failed: Loc;\n /** «ИИ ответил не рецептом мира.» */\n invalid: Loc;\n /** «Ответ ИИ оборван — мир получился слишком длинным.» */\n truncated: Loc;\n /** «Дожидаюсь мира с прошлого раза…» */\n resuming: Loc;\n /** «ИИ отвечает слишком долго. Откройте «Создать мир» позже — результат дождётся вас.» */\n slow: Loc;\n };\n}\n\nexport interface SpecAIOptions {\n featureKey?: string;\n pollMs?: number;\n timeoutMs?: number;\n /** localStorage по умолчанию; null — не сохранять задачу между запусками. */\n storage?: Pick<Storage, \"getItem\" | \"setItem\" | \"removeItem\"> | null;\n sleep?: (ms: number, signal: AbortSignal) => Promise<void>;\n}\n\nexport interface SpecAI<Req, S> {\n status(): Promise<SpecAIStatus>;\n generate(\n req: Req,\n opts: { signal: AbortSignal; onProgress: SpecAIProgress },\n ): Promise<SpecAIResult<S>>;\n /** Недождавшаяся задача с прошлого раза, если есть: дождаться её результата. */\n resume(opts: {\n signal: AbortSignal;\n onProgress: SpecAIProgress;\n }): Promise<SpecAIResult<S> | null>;\n}\n\nconst DEFAULT_TYPES = [\"image/png\", \"image/jpeg\", \"image/webp\"];\n\nfunction defaultStorage(): SpecAIOptions[\"storage\"] {\n try {\n return typeof localStorage === \"undefined\" ? null : localStorage;\n } catch {\n return null; // хранилище запрещено (фрейм, настройки браузера)\n }\n}\n\nfunction abortableSleep(ms: number, signal: AbortSignal): Promise<void> {\n return new Promise((resolve) => {\n const t = setTimeout(resolve, ms);\n signal.addEventListener(\"abort\", () => {\n clearTimeout(t);\n resolve();\n });\n });\n}\n\nfunction newKey(): string {\n return typeof crypto !== \"undefined\" && \"randomUUID\" in crypto\n ? crypto.randomUUID()\n : Date.now().toString(36) + \"-\" + Math.random().toString(36).slice(2, 12);\n}\n\n/** «10 GEM» из первой опции цены; null — бесплатно. */\nexport function priceLabel(\n options: AIPublicFeature[\"PriceOptions\"],\n): string | null {\n const first = options ? Object.values(options)[0] : undefined;\n if (!first) return null;\n if (first.Name) return first.Name;\n const parts = (first.Cost?.Standard?.Entries ?? [])\n .map((e) => `${e.Amount ?? \"\"} ${e.CurrencyID ?? e.ItemID ?? \"\"}`.trim())\n .filter(Boolean);\n return parts.length ? parts.join(\" + \") : null;\n}\n\nfunction toFeature(f: AIPublicFeature): SpecAIFeature {\n return {\n allowImages: f.AllowImages === true,\n maxImageBytes: f.MaxInputImageBytes || null,\n imageTypes: f.AllowedImageTypes?.length\n ? f.AllowedImageTypes\n : DEFAULT_TYPES,\n async: f.Async === true,\n priceLabel: priceLabel(f.PriceOptions),\n maxPromptChars: f.MaxPromptChars || null,\n // у фичи по умолчанию одна картинка; потолка платформы нет — сколько разрешил издатель\n maxImages: Math.max(1, f.MaxInputImages || 1),\n };\n}\n\n/** Безопасное сообщение из отказа \"AI_CODE: сообщение\". */\nfunction messageOf(error: string): string {\n const m = /\\bAI_[A-Z_]+\\s*:\\s*([\\s\\S]*)$/.exec(error);\n return (m?.[1] ?? error).trim();\n}\n\ntype Refusal = Extract<SpecAIResult<unknown>, { ok: false }>;\n\n/** Отказ сервера (ничего не сгенерировано и не списано) — в понятный игроку текст. */\nfunction refusal(res: Failure, login: Loc, what: Loc): Refusal {\n if (res.reason === \"connection\")\n return {\n ok: false,\n kind: \"connection\",\n message: tr({\n en: \"No connection to the server. Try again.\",\n ru: \"Нет связи с сервером. Попробуйте ещё раз.\",\n }),\n };\n if (res.reason === \"unauthorized\")\n return { ok: false, kind: \"refused\", message: tr(login) };\n if (res.reason === \"throttled\")\n return {\n ok: false,\n kind: \"refused\",\n message: tr({\n en: \"A generation is already running — wait for it.\",\n ru: \"Генерация уже идёт — дождитесь её.\",\n }),\n };\n // Отказы оплаты Aspis — словами кита, как во всех системах.\n if (/^CREDITS_|^NOT_ENOUGH_CREDITS$/.test(res.error))\n return { ok: false, kind: \"refused\", message: errorText(res.error) };\n const code = parseAIErrorCode(res.error);\n switch (code) {\n case AIErrorCode.LimitReached:\n case AIErrorCode.Cooldown:\n return {\n ok: false,\n kind: \"refused\",\n message: tr({\n en: \"The AI generation limit is used up for now. Come back later.\",\n ru: \"Лимит ИИ-генераций на сейчас исчерпан. Загляните позже.\",\n }),\n };\n case AIErrorCode.PromptRejected:\n return {\n ok: false,\n kind: \"refused\",\n message: tr({\n en: \"The description did not pass the check. Rephrase it.\",\n ru: \"Описание не прошло проверку. Переформулируйте его.\",\n }),\n };\n case AIErrorCode.BadInput:\n return { ok: false, kind: \"refused\", message: messageOf(res.error) };\n case AIErrorCode.FeatureClosed:\n return {\n ok: false,\n kind: \"refused\",\n message: tr(\n {\n en: \"AI generation of {what} is closed right now.\",\n ru: \"ИИ-генерация {what} сейчас закрыта.\",\n },\n { what: tr(what) },\n ),\n };\n case AIErrorCode.Unavailable:\n case AIErrorCode.Disabled:\n case AIErrorCode.FeatureNotFound:\n case AIErrorCode.FeatureDisabled:\n case AIErrorCode.NotAvailableForPlayer:\n return {\n ok: false,\n kind: \"refused\",\n message: tr({\n en: \"AI is unavailable right now.\",\n ru: \"ИИ сейчас недоступен.\",\n }),\n };\n default:\n return {\n ok: false,\n kind: \"refused\",\n message: code\n ? messageOf(res.error)\n : tr({\n en: \"Could not start the generation.\",\n ru: \"Не удалось запустить генерацию.\",\n }),\n };\n }\n}\n\n/** Недождавшаяся задача: сохраняется до результата, переживает перезагрузку. */\ninterface PendingJob<Extra> {\n id: string;\n extra: Extra;\n}\n\nexport function createSpecAI<Req, S, Extra>(\n client: IDosGamesClient,\n kind: SpecAIKind<Req, S, Extra>,\n opts: SpecAIOptions & {\n /** Старый формат сохранённой задачи (мир: { id, size }) — читать и его. */\n readPending?: (raw: Record<string, unknown>) => PendingJob<Extra> | null;\n writePending?: (job: PendingJob<Extra>) => unknown;\n } = {},\n): SpecAI<Req, S> {\n const featureKey = opts.featureKey ?? kind.featureKey;\n const pollMs = opts.pollMs ?? 4000;\n const timeoutMs = opts.timeoutMs ?? 10 * 60_000;\n const storage = opts.storage === undefined ? defaultStorage() : opts.storage;\n const sleep = opts.sleep ?? abortableSleep;\n const pendingKey = `voxelcraft:${kind.pendingName}:${client.titleID}`;\n const T = kind.texts;\n let feature: SpecAIFeature | null = null;\n\n const savePending = (job: PendingJob<Extra>) => {\n try {\n storage?.setItem(\n pendingKey,\n JSON.stringify(opts.writePending ? opts.writePending(job) : job),\n );\n } catch {\n /* без хранилища задача просто не переживёт перезагрузку */\n }\n };\n const loadPending = (): PendingJob<Extra> | null => {\n try {\n const raw = storage?.getItem(pendingKey);\n const job = raw ? (JSON.parse(raw) as Record<string, unknown>) : null;\n if (!job || typeof job.id !== \"string\") return null;\n if (opts.readPending) return opts.readPending(job);\n return \"extra\" in job ? (job as unknown as PendingJob<Extra>) : null;\n } catch {\n return null;\n }\n };\n const clearPending = () => {\n try {\n storage?.removeItem(pendingKey);\n } catch {\n /* нечего чистить */\n }\n };\n\n async function status(): Promise<SpecAIStatus> {\n const res = await client.ai.getDefinitions();\n if (!res.ok)\n return {\n available: false,\n reason:\n res.reason === \"unauthorized\"\n ? tr({\n en: \"sign in to build with AI\",\n ru: \"войдите, чтобы строить с ИИ\",\n })\n : tr({\n en: \"AI is unavailable right now\",\n ru: \"ИИ сейчас недоступен\",\n }),\n };\n if (res.data.Enabled === false)\n return {\n available: false,\n reason: tr({\n en: \"AI is turned off in this game\",\n ru: \"ИИ выключен в этой игре\",\n }),\n };\n const f = res.data.Features?.[featureKey];\n if (!f || (f.Modality && f.Modality !== \"Text\"))\n return {\n available: false,\n reason: tr(\n {\n en: \"AI generation of {what} is not set up\",\n ru: \"ИИ-генерация {what} не настроена\",\n },\n { what: tr(T.what) },\n ),\n };\n feature = toFeature(f);\n return { available: true, feature };\n }\n\n /** Опрос асинхронной задачи, пока игрок ждёт. */\n async function waitFor(\n first: AIGenerationView,\n signal: AbortSignal,\n onProgress: SpecAIProgress,\n ): Promise<AIGenerationView | Refusal> {\n const deadline = Date.now() + timeoutMs;\n let view = first;\n while (!client.ai.isTerminal(view.Status)) {\n if (signal.aborted) return { ok: false, kind: \"cancelled\", message: \"\" };\n if (Date.now() > deadline)\n return { ok: false, kind: \"connection\", message: tr(T.slow) };\n onProgress(view.Progress ?? null, tr(T.working));\n await sleep(pollMs, signal);\n if (signal.aborted) return { ok: false, kind: \"cancelled\", message: \"\" };\n const next = await client.ai.getText(view.GenerationID);\n if (!next.ok) {\n if (next.reason === \"throttled\" || next.reason === \"connection\")\n continue;\n if (parseAIErrorCode(next.error) === AIErrorCode.GenerationNotFound)\n clearPending();\n return refusal(next, T.login, T.what);\n }\n view = next.data;\n }\n return view;\n }\n\n /** Довести генерацию до рецепта: дождаться и разобрать. */\n async function settle(\n first: AIGenerationView,\n extra: Extra,\n signal: AbortSignal,\n onProgress: SpecAIProgress,\n ): Promise<SpecAIResult<S>> {\n let view = first;\n if (!client.ai.isTerminal(view.Status)) {\n savePending({ id: view.GenerationID, extra });\n const waited = await waitFor(view, signal, onProgress);\n if (\"ok\" in waited) return waited; // отменено / таймаут / отказ — задача остаётся сохранённой\n view = waited;\n }\n clearPending();\n if (view.Status !== \"completed\")\n return {\n ok: false,\n kind: \"failed\",\n message: view.Error?.Message || tr(T.failed),\n refunded: view.PlayerRefunded === true,\n };\n const parsed = kind.parse(view.OutputText ?? \"\", extra);\n if (!parsed.ok)\n return parsed.truncated\n ? { ok: false, kind: \"truncated\", message: tr(T.truncated) }\n : { ok: false, kind: \"invalid\", message: tr(T.invalid) };\n return parsed;\n }\n\n async function generate(\n req: Req,\n { signal, onProgress }: { signal: AbortSignal; onProgress: SpecAIProgress },\n ): Promise<SpecAIResult<S>> {\n if (!feature) {\n const s = await status();\n if (!s.available)\n return { ok: false, kind: \"refused\", message: s.reason };\n }\n const f = feature!; // status() выставил её или вернул отказ выше\n const input: AITextInput = {\n Prompt: kind.buildPrompt(req, f.maxPromptChars),\n };\n const images = kind.images(req);\n if (images.length && f.allowImages)\n input.Images = images\n .slice(0, f.maxImages)\n .map((im) => ({ Base64: im.dataUrl, ContentType: im.contentType }));\n\n // Один ключ на нажатие: повтор после обрыва связи — та же генерация, без второго списания.\n const key = newKey();\n onProgress(\n null,\n tr({ en: \"Sending the request to AI…\", ru: \"Отправляю запрос ИИ…\" }),\n );\n // Цена в Aspis: первый вызов только просит игрока подтвердить на idosgames.com (окно — кит), списание —\n // только за готовую генерацию. Ключ тот же у обоих вызовов.\n const start = () =>\n payWithCredits(\n client,\n (creditsConfirmationID) =>\n client.ai.generateText(featureKey, input, {\n relatedEntityID: key,\n creditsConfirmationID,\n }),\n { signal },\n );\n let res = await start();\n if (!res.ok && res.reason === \"connection\" && !signal.aborted) {\n onProgress(\n null,\n tr({\n en: \"Connection dropped, retrying…\",\n ru: \"Связь прервалась, повторяю…\",\n }),\n );\n res = await start();\n }\n if (!res.ok) {\n const code = parseAIErrorCode(res.error);\n if (\n code === AIErrorCode.FeatureNotFound ||\n code === AIErrorCode.FeatureDisabled\n )\n feature = null;\n return refusal(res, T.login, T.what);\n }\n return settle(res.data, kind.extra(req), signal, onProgress);\n }\n\n async function resume({\n signal,\n onProgress,\n }: {\n signal: AbortSignal;\n onProgress: SpecAIProgress;\n }): Promise<SpecAIResult<S> | null> {\n const job = loadPending();\n if (!job) return null;\n onProgress(null, tr(T.resuming));\n const res = await client.ai.getText(job.id);\n if (!res.ok) {\n if (res.reason === \"connection\" || res.reason === \"throttled\")\n return null; // попробуем в другой раз\n clearPending(); // задачи больше нет (или она чужая) — не спрашивать о ней снова\n return null;\n }\n return settle(res.data, job.extra, signal, onProgress);\n }\n\n return { status, generate, resume };\n}\n"
|
|
111
111
|
},
|
|
112
112
|
{
|
|
113
113
|
"path": "ai/types.ts",
|
|
@@ -39,12 +39,12 @@
|
|
|
39
39
|
"version": "0.1.3"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@idosgames/core": "0.
|
|
43
|
-
"@idosgames/module-sdk": "0.13.
|
|
44
|
-
"@idosgames/react": "0.
|
|
42
|
+
"@idosgames/core": "0.26.0",
|
|
43
|
+
"@idosgames/module-sdk": "0.13.3",
|
|
44
|
+
"@idosgames/react": "0.7.0",
|
|
45
45
|
"react": "19.2.7"
|
|
46
46
|
},
|
|
47
|
-
"contentHash": "
|
|
47
|
+
"contentHash": "c3a4b38be15260152a5244a06539639a1c7c08c207faf76d03baab43e639d148",
|
|
48
48
|
"files": [
|
|
49
49
|
{
|
|
50
50
|
"path": "components/Catalog.tsx",
|
|
@@ -68,11 +68,11 @@
|
|
|
68
68
|
},
|
|
69
69
|
{
|
|
70
70
|
"path": "components/PublishWizard.tsx",
|
|
71
|
-
"content": "import { useEffect, useState, type ReactNode } from \"react\";\nimport {\n type WorkshopAccessOptionInput,\n type WorkshopDefinitionsResponse,\n type WorkshopVisibility,\n} from \"@idosgames/core\";\nimport { errorText } from \"../errors\";\nimport { useContentTypes, useResourceOptions } from \"../hooks\";\nimport { bundleLabel } from \"../labels\";\nimport {\n checkLabel,\n chip,\n chipActive,\n chipsRow,\n errorBox,\n fieldLabel,\n filtersRow,\n formCol,\n input,\n muted,\n primaryBtn,\n secondaryBtn,\n} from \"../styles\";\nimport { type WorkshopHost } from \"../types\";\n\ninterface AccessDraft {\n mode: \"Free\" | \"Price\" | \"Holding\";\n resource: string; // \"vc:GOLD\" | \"cc:USDT\" | \"item:<catalog>/<item>\"\n amount: number;\n holdMode: \"WhileHeld\" | \"UnlockOnce\";\n}\n\nexport function PublishWizard({\n host,\n defs,\n onDone,\n}: {\n host: WorkshopHost;\n defs: WorkshopDefinitionsResponse;\n onDone(contentID: string): void;\n}): ReactNode {\n const handlers = useContentTypes(host.content).filter(\n (h) => h.listLocal && h.capture && defs.CanPublish?.[h.type] === true,\n );\n const [type, setType] = useState<string | null>(null);\n const [locals, setLocals] = useState<Array<{\n id: string;\n name: string;\n subtitle?: string;\n }> | null>(null);\n const [localId, setLocalId] = useState<string | null>(null);\n const [title, setTitle] = useState(\"\");\n const [description, setDescription] = useState(\"\");\n const [tags, setTags] = useState(\"\");\n const [visibility, setVisibility] = useState<WorkshopVisibility>(\"Public\");\n const [access, setAccess] = useState<AccessDraft[]>([\n { mode: \"Free\", resource: \"\", amount: 1, holdMode: \"UnlockOnce\" },\n ]);\n const [busy, setBusy] = useState(false);\n const [message, setMessage] = useState<string | null>(null);\n\n const handler = handlers.find((h) => h.type === type);\n const typeDef = type ? defs.Definitions?.ContentTypes?.[type] : undefined;\n const allowedModes = typeDef?.AllowedAccessModes?.length\n ? typeDef.AllowedAccessModes\n : [\"Free\", \"Price\", \"Holding\"];\n const resources = useResourceOptions(host.client);\n // Плата за публикацию берётся только ресурсами: вариант с оплатой в сторе сервер отвергнет.\n const fee = Object.entries(typeDef?.PublishFeeOptions ?? {}).find(\n ([, option]) =>\n !(option?.Cost?.Standard?.Entries ?? []).some(\n (e) => e?.Type === \"Purchase\",\n ),\n );\n\n useEffect(() => {\n setLocals(null);\n setLocalId(null);\n if (!handler?.listLocal) return;\n void handler\n .listLocal()\n .then(setLocals, (e: unknown) =>\n setMessage(e instanceof Error ? e.message : String(e)),\n );\n }, [handler]);\n\n if (handlers.length === 0)\n return (\n <div style={muted}>\n Nothing to publish from here: the games in this project either do not\n register their content, or you can't publish it yet.\n </div>\n );\n\n const publish = async (): Promise<void> => {\n if (!handler?.capture || !type || !localId) return;\n // Опция без ресурса молча выпала бы из списка, и публикация ушла бы не с теми способами получить.\n if (access.some((a) => a.mode !== \"Free\" && !a.resource)) {\n setMessage(\"Pick a resource for every paid or hold-to-unlock option.\");\n return;\n }\n setBusy(true);\n setMessage(null);\n try {\n const captured = await handler.capture(localId);\n const r = await host.client.workshop.publish({\n contentType: type,\n files: captured.files,\n thumbnail: captured.thumbnail,\n title: title.trim() || captured.suggestedTitle || \"Untitled\",\n description: description.trim() || captured.suggestedDescription,\n tags: tags\n .split(\",\")\n .map((t) => t.trim())\n .filter(Boolean)\n .concat(captured.tags ?? []),\n metadata: captured.metadata,\n visibility,\n access: access\n .map(toAccessInput)\n .filter((a): a is WorkshopAccessOptionInput => a !== null),\n selectedFeeOptionID: fee?.[0],\n });\n if (!r.ok) setMessage(errorText(r.error));\n else if (r.data.Content?.ContentID) onDone(r.data.Content.ContentID);\n } catch (e) {\n setMessage(e instanceof Error ? e.message : String(e));\n } finally {\n setBusy(false);\n }\n };\n\n return (\n <div style={formCol}>\n <label style={fieldLabel}>What</label>\n <div style={chipsRow}>\n {handlers.map((h) => (\n <button\n key={h.type}\n style={type === h.type ? chipActive : chip}\n onClick={() => setType(h.type)}\n >\n {h.icon} {h.label}\n </button>\n ))}\n </div>\n\n {handler && (\n <>\n <label style={fieldLabel}>Which one</label>\n {!locals ? (\n <div style={muted}>Loading…</div>\n ) : locals.length === 0 ? (\n <div style={muted}>You have nothing of this type yet.</div>\n ) : (\n <div style={formCol}>\n {locals.map((l) => (\n <label key={l.id} style={checkLabel}>\n <input\n type=\"radio\"\n name=\"wsLocal\"\n checked={localId === l.id}\n onChange={() => {\n setLocalId(l.id);\n if (!title) setTitle(l.name);\n }}\n />\n {l.name}{\" \"}\n {l.subtitle && <span style={muted}>· {l.subtitle}</span>}\n </label>\n ))}\n </div>\n )}\n </>\n )}\n\n {localId && (\n <>\n <label style={fieldLabel}>Title</label>\n <input\n style={input}\n value={title}\n maxLength={defs.Definitions?.Limits?.MaxTitleLength ?? 60}\n onChange={(e) => setTitle(e.target.value)}\n />\n <label style={fieldLabel}>Description</label>\n <textarea\n style={{ ...input, minHeight: 70 }}\n value={description}\n onChange={(e) => setDescription(e.target.value)}\n />\n <label style={fieldLabel}>\n Tags (comma separated\n {typeDef?.TagVocabulary?.length\n ? `: ${typeDef.TagVocabulary.join(\", \")}`\n : \"\"}\n )\n </label>\n <input\n style={input}\n value={tags}\n onChange={(e) => setTags(e.target.value)}\n />\n <label style={fieldLabel}>Who can see it</label>\n <select\n style={input}\n value={visibility}\n onChange={(e) =>\n setVisibility(e.target.value as WorkshopVisibility)\n }\n >\n <option value=\"Public\">Everyone (in the catalog)</option>\n <option value=\"Unlisted\">Anyone with the link</option>\n <option value=\"Friends\">Only my friends</option>\n </select>\n\n <label style={fieldLabel}>How others get it — any option works</label>\n {access.map((a, i) => (\n <div key={i} style={filtersRow}>\n <select\n style={input}\n value={a.mode}\n onChange={(e) =>\n setAccess(\n patch(access, i, {\n mode: e.target.value as AccessDraft[\"mode\"],\n }),\n )\n }\n >\n {allowedModes.map((m) => (\n <option key={m} value={m}>\n {m === \"Free\"\n ? \"Free\"\n : m === \"Price\"\n ? \"For resources\"\n : \"Hold to unlock\"}\n </option>\n ))}\n </select>\n {a.mode !== \"Free\" && (\n <>\n <select\n style={input}\n value={a.resource}\n onChange={(e) =>\n setAccess(patch(access, i, { resource: e.target.value }))\n }\n >\n <option value=\"\">— resource —</option>\n {resources\n .filter((r) =>\n // A price: an in-game currency or crypto (the author gets it minus the developer's trade\n // fee). Holding: currencies and items — crypto is not a \"hold to unlock\" resource.\n a.mode === \"Holding\"\n ? !r.startsWith(\"cc:\")\n : r.startsWith(\"vc:\") || r.startsWith(\"cc:\"),\n )\n .map((r) => (\n <option key={r} value={r}>\n {r\n .replace(/^vc:/, \"\")\n .replace(/^cc:/, \"crypto \")\n .replace(/^item:/, \"item \")}\n </option>\n ))}\n </select>\n <input\n style={{ ...input, width: 90 }}\n type=\"number\"\n min={1}\n value={a.amount}\n onChange={(e) =>\n setAccess(\n patch(access, i, {\n amount: Math.max(1, Number(e.target.value) || 1),\n }),\n )\n }\n />\n </>\n )}\n {a.mode === \"Holding\" && (\n <select\n style={input}\n value={a.holdMode}\n onChange={(e) =>\n setAccess(\n patch(access, i, {\n holdMode: e.target.value as AccessDraft[\"holdMode\"],\n }),\n )\n }\n >\n <option value=\"UnlockOnce\">Checked once, then forever</option>\n <option value=\"WhileHeld\">Only while they hold it</option>\n </select>\n )}\n {access.length > 1 && (\n <button\n style={secondaryBtn}\n onClick={() => setAccess(access.filter((_, j) => j !== i))}\n >\n ✕\n </button>\n )}\n </div>\n ))}\n <button\n style={secondaryBtn}\n onClick={() =>\n setAccess([\n ...access,\n {\n mode: allowedModes.includes(\"Price\") ? \"Price\" : \"Free\",\n resource: \"\",\n amount: 10,\n holdMode: \"UnlockOnce\",\n },\n ])\n }\n >\n + Add an option\n </button>\n\n {fee && (\n <div style={muted}>\n Publishing costs {bundleLabel(fee[1].Cost?.Standard)} (charged\n once).\n </div>\n )}\n <button\n style={primaryBtn}\n disabled={busy}\n onClick={() => void publish()}\n >\n {busy ? \"Publishing…\" : \"Publish\"}\n </button>\n </>\n )}\n {message && <div style={errorBox}>{message}</div>}\n </div>\n );\n}\n\nfunction patch<T>(list: T[], index: number, change: Partial<T>): T[] {\n return list.map((item, i) => (i === index ? { ...item, ...change } : item));\n}\n\nfunction toAccessInput(a: AccessDraft): WorkshopAccessOptionInput | null {\n if (a.mode === \"Free\") return { Mode: \"Free\" };\n if (!a.resource) return null;\n const [kind, id = \"\"] = a.resource.split(\":\", 2);\n if (a.mode === \"Price\") {\n return {\n Mode: \"Price\",\n Price: {\n Entries: [\n {\n Type: kind === \"cc\" ? \"CryptoCurrency\" : \"VirtualCurrency\",\n CurrencyID: id,\n Amount: a.amount,\n },\n ],\n },\n };\n }\n const [catalogID, itemID] = id.split(\"/\", 2);\n return {\n Mode: \"Holding\",\n Holding: {\n Mode: a.holdMode,\n Match: \"All\",\n Requirements: [\n kind === \"vc\"\n ? { Type: \"VirtualCurrency\", CurrencyID: id, Amount: a.amount }\n : {\n Type: \"Item\",\n CatalogID: catalogID,\n ItemID: itemID ?? \"\",\n Amount: a.amount,\n },\n ],\n },\n };\n}\n\n// ────────────────────────────── Lists ──────────────────────────────\n"
|
|
71
|
+
"content": "import { kitCreditsPrompt } from \"@idosgames/react/ui\";\nimport { useEffect, useState, type ReactNode } from \"react\";\nimport {\n type WorkshopAccessOptionInput,\n type WorkshopDefinitionsResponse,\n type WorkshopVisibility,\n} from \"@idosgames/core\";\nimport { errorText } from \"../errors\";\nimport { useContentTypes, useResourceOptions } from \"../hooks\";\nimport { bundleLabel } from \"../labels\";\nimport {\n checkLabel,\n chip,\n chipActive,\n chipsRow,\n errorBox,\n fieldLabel,\n filtersRow,\n formCol,\n input,\n muted,\n primaryBtn,\n secondaryBtn,\n} from \"../styles\";\nimport { type WorkshopHost } from \"../types\";\n\ninterface AccessDraft {\n mode: \"Free\" | \"Price\" | \"Holding\";\n resource: string; // \"vc:GOLD\" | \"cc:USDT\" | \"item:<catalog>/<item>\"\n amount: number;\n holdMode: \"WhileHeld\" | \"UnlockOnce\";\n}\n\nexport function PublishWizard({\n host,\n defs,\n onDone,\n}: {\n host: WorkshopHost;\n defs: WorkshopDefinitionsResponse;\n onDone(contentID: string): void;\n}): ReactNode {\n const handlers = useContentTypes(host.content).filter(\n (h) => h.listLocal && h.capture && defs.CanPublish?.[h.type] === true,\n );\n const [type, setType] = useState<string | null>(null);\n const [locals, setLocals] = useState<Array<{\n id: string;\n name: string;\n subtitle?: string;\n }> | null>(null);\n const [localId, setLocalId] = useState<string | null>(null);\n const [title, setTitle] = useState(\"\");\n const [description, setDescription] = useState(\"\");\n const [tags, setTags] = useState(\"\");\n const [visibility, setVisibility] = useState<WorkshopVisibility>(\"Public\");\n const [access, setAccess] = useState<AccessDraft[]>([\n { mode: \"Free\", resource: \"\", amount: 1, holdMode: \"UnlockOnce\" },\n ]);\n const [busy, setBusy] = useState(false);\n const [message, setMessage] = useState<string | null>(null);\n\n const handler = handlers.find((h) => h.type === type);\n const typeDef = type ? defs.Definitions?.ContentTypes?.[type] : undefined;\n const allowedModes = typeDef?.AllowedAccessModes?.length\n ? typeDef.AllowedAccessModes\n : [\"Free\", \"Price\", \"Holding\"];\n const resources = useResourceOptions(host.client);\n // Плата за публикацию берётся только ресурсами: вариант с оплатой в сторе сервер отвергнет.\n const fee = Object.entries(typeDef?.PublishFeeOptions ?? {}).find(\n ([, option]) =>\n !(option?.Cost?.Standard?.Entries ?? []).some(\n (e) => e?.Type === \"Purchase\",\n ),\n );\n\n useEffect(() => {\n setLocals(null);\n setLocalId(null);\n if (!handler?.listLocal) return;\n void handler\n .listLocal()\n .then(setLocals, (e: unknown) =>\n setMessage(e instanceof Error ? e.message : String(e)),\n );\n }, [handler]);\n\n if (handlers.length === 0)\n return (\n <div style={muted}>\n Nothing to publish from here: the games in this project either do not\n register their content, or you can't publish it yet.\n </div>\n );\n\n const publish = async (): Promise<void> => {\n if (!handler?.capture || !type || !localId) return;\n // Опция без ресурса молча выпала бы из списка, и публикация ушла бы не с теми способами получить.\n if (access.some((a) => a.mode !== \"Free\" && !a.resource)) {\n setMessage(\"Pick a resource for every paid or hold-to-unlock option.\");\n return;\n }\n setBusy(true);\n setMessage(null);\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(host.client);\n try {\n const captured = await handler.capture(localId);\n const r = await host.client.workshop.publish({\n contentType: type,\n files: captured.files,\n thumbnail: captured.thumbnail,\n title: title.trim() || captured.suggestedTitle || \"Untitled\",\n description: description.trim() || captured.suggestedDescription,\n tags: tags\n .split(\",\")\n .map((t) => t.trim())\n .filter(Boolean)\n .concat(captured.tags ?? []),\n metadata: captured.metadata,\n visibility,\n access: access\n .map(toAccessInput)\n .filter((a): a is WorkshopAccessOptionInput => a !== null),\n selectedFeeOptionID: fee?.[0],\n credits: credits.options,\n });\n await credits.done(r);\n if (!r.ok) setMessage(errorText(r.error));\n else if (r.data.Content?.ContentID) onDone(r.data.Content.ContentID);\n } catch (e) {\n await credits.done({ ok: true, data: null });\n setMessage(e instanceof Error ? e.message : String(e));\n } finally {\n setBusy(false);\n }\n };\n\n return (\n <div style={formCol}>\n <label style={fieldLabel}>What</label>\n <div style={chipsRow}>\n {handlers.map((h) => (\n <button\n key={h.type}\n style={type === h.type ? chipActive : chip}\n onClick={() => setType(h.type)}\n >\n {h.icon} {h.label}\n </button>\n ))}\n </div>\n\n {handler && (\n <>\n <label style={fieldLabel}>Which one</label>\n {!locals ? (\n <div style={muted}>Loading…</div>\n ) : locals.length === 0 ? (\n <div style={muted}>You have nothing of this type yet.</div>\n ) : (\n <div style={formCol}>\n {locals.map((l) => (\n <label key={l.id} style={checkLabel}>\n <input\n type=\"radio\"\n name=\"wsLocal\"\n checked={localId === l.id}\n onChange={() => {\n setLocalId(l.id);\n if (!title) setTitle(l.name);\n }}\n />\n {l.name}{\" \"}\n {l.subtitle && <span style={muted}>· {l.subtitle}</span>}\n </label>\n ))}\n </div>\n )}\n </>\n )}\n\n {localId && (\n <>\n <label style={fieldLabel}>Title</label>\n <input\n style={input}\n value={title}\n maxLength={defs.Definitions?.Limits?.MaxTitleLength ?? 60}\n onChange={(e) => setTitle(e.target.value)}\n />\n <label style={fieldLabel}>Description</label>\n <textarea\n style={{ ...input, minHeight: 70 }}\n value={description}\n onChange={(e) => setDescription(e.target.value)}\n />\n <label style={fieldLabel}>\n Tags (comma separated\n {typeDef?.TagVocabulary?.length\n ? `: ${typeDef.TagVocabulary.join(\", \")}`\n : \"\"}\n )\n </label>\n <input\n style={input}\n value={tags}\n onChange={(e) => setTags(e.target.value)}\n />\n <label style={fieldLabel}>Who can see it</label>\n <select\n style={input}\n value={visibility}\n onChange={(e) =>\n setVisibility(e.target.value as WorkshopVisibility)\n }\n >\n <option value=\"Public\">Everyone (in the catalog)</option>\n <option value=\"Unlisted\">Anyone with the link</option>\n <option value=\"Friends\">Only my friends</option>\n </select>\n\n <label style={fieldLabel}>How others get it — any option works</label>\n {access.map((a, i) => (\n <div key={i} style={filtersRow}>\n <select\n style={input}\n value={a.mode}\n onChange={(e) =>\n setAccess(\n patch(access, i, {\n mode: e.target.value as AccessDraft[\"mode\"],\n }),\n )\n }\n >\n {allowedModes.map((m) => (\n <option key={m} value={m}>\n {m === \"Free\"\n ? \"Free\"\n : m === \"Price\"\n ? \"For resources\"\n : \"Hold to unlock\"}\n </option>\n ))}\n </select>\n {a.mode !== \"Free\" && (\n <>\n <select\n style={input}\n value={a.resource}\n onChange={(e) =>\n setAccess(patch(access, i, { resource: e.target.value }))\n }\n >\n <option value=\"\">— resource —</option>\n {resources\n .filter((r) =>\n // A price: an in-game currency or crypto (the author gets it minus the developer's trade\n // fee). Holding: currencies and items — crypto is not a \"hold to unlock\" resource.\n a.mode === \"Holding\"\n ? !r.startsWith(\"cc:\")\n : r.startsWith(\"vc:\") || r.startsWith(\"cc:\"),\n )\n .map((r) => (\n <option key={r} value={r}>\n {r\n .replace(/^vc:/, \"\")\n .replace(/^cc:/, \"crypto \")\n .replace(/^item:/, \"item \")}\n </option>\n ))}\n </select>\n <input\n style={{ ...input, width: 90 }}\n type=\"number\"\n min={1}\n value={a.amount}\n onChange={(e) =>\n setAccess(\n patch(access, i, {\n amount: Math.max(1, Number(e.target.value) || 1),\n }),\n )\n }\n />\n </>\n )}\n {a.mode === \"Holding\" && (\n <select\n style={input}\n value={a.holdMode}\n onChange={(e) =>\n setAccess(\n patch(access, i, {\n holdMode: e.target.value as AccessDraft[\"holdMode\"],\n }),\n )\n }\n >\n <option value=\"UnlockOnce\">Checked once, then forever</option>\n <option value=\"WhileHeld\">Only while they hold it</option>\n </select>\n )}\n {access.length > 1 && (\n <button\n style={secondaryBtn}\n onClick={() => setAccess(access.filter((_, j) => j !== i))}\n >\n ✕\n </button>\n )}\n </div>\n ))}\n <button\n style={secondaryBtn}\n onClick={() =>\n setAccess([\n ...access,\n {\n mode: allowedModes.includes(\"Price\") ? \"Price\" : \"Free\",\n resource: \"\",\n amount: 10,\n holdMode: \"UnlockOnce\",\n },\n ])\n }\n >\n + Add an option\n </button>\n\n {fee && (\n <div style={muted}>\n Publishing costs {bundleLabel(fee[1].Cost?.Standard)} (charged\n once).\n </div>\n )}\n <button\n style={primaryBtn}\n disabled={busy}\n onClick={() => void publish()}\n >\n {busy ? \"Publishing…\" : \"Publish\"}\n </button>\n </>\n )}\n {message && <div style={errorBox}>{message}</div>}\n </div>\n );\n}\n\nfunction patch<T>(list: T[], index: number, change: Partial<T>): T[] {\n return list.map((item, i) => (i === index ? { ...item, ...change } : item));\n}\n\nfunction toAccessInput(a: AccessDraft): WorkshopAccessOptionInput | null {\n if (a.mode === \"Free\") return { Mode: \"Free\" };\n if (!a.resource) return null;\n const [kind, id = \"\"] = a.resource.split(\":\", 2);\n if (a.mode === \"Price\") {\n return {\n Mode: \"Price\",\n Price: {\n Entries: [\n {\n Type: kind === \"cc\" ? \"CryptoCurrency\" : \"VirtualCurrency\",\n CurrencyID: id,\n Amount: a.amount,\n },\n ],\n },\n };\n }\n const [catalogID, itemID] = id.split(\"/\", 2);\n return {\n Mode: \"Holding\",\n Holding: {\n Mode: a.holdMode,\n Match: \"All\",\n Requirements: [\n kind === \"vc\"\n ? { Type: \"VirtualCurrency\", CurrencyID: id, Amount: a.amount }\n : {\n Type: \"Item\",\n CatalogID: catalogID,\n ItemID: itemID ?? \"\",\n Amount: a.amount,\n },\n ],\n },\n };\n}\n\n// ────────────────────────────── Lists ──────────────────────────────\n"
|
|
72
72
|
},
|
|
73
73
|
{
|
|
74
74
|
"path": "errors.ts",
|
|
75
|
-
"content": "/** Server error codes → words. Anything else is shown as is (it is already a sentence). */\nconst ERRORS: Record<string, string> = {\n WORKSHOP_DISABLED: \"The Workshop is turned off.\",\n WORKSHOP_NOT_CONFIGURED: \"The Workshop is not set up in this game yet.\",\n WORKSHOP_NOT_AVAILABLE: \"The Workshop is not available to you.\",\n WORKSHOP_CLOSED: \"The Workshop is closed right now.\",\n PRICE_CHANGED: \"The author changed the price — look at it again.\",\n HOLDING_REQUIREMENTS_NOT_MET: \"You don't hold what this option requires.\",\n ACCESS_DENIED: \"You don't have access to this yet.\",\n CONTENT_NOT_FOUND: \"Not found (or not shared with you).\",\n CONTENT_NOT_AVAILABLE: \"This publication is not available now.\",\n CONTENT_TEXT_REJECTED:\n \"The title or description contains words that are not allowed.\",\n PUBLISH_LIMIT_REACHED:\n \"You have reached the limit of publications of this type.\",\n DAILY_PUBLISH_LIMIT_REACHED: \"You have reached today's publishing limit.\",\n PUBLISH_NOT_ALLOWED: \"You can't publish this type of content yet.\",\n CANNOT_LIKE_OWN_CONTENT: \"You can't like your own publication.\",\n WORKSHOP_STORAGE_NOT_CONFIGURED:\n \"File storage is not configured for this game.\",\n TOO_MANY_PENDING_UPLOADS:\n \"You have too many unfinished uploads — try again a bit later.\",\n ACCESS_REQUIRED: \"Add at least one way for others to get it.\",\n OPTION_REQUIRED: \"Choose how you want to get it.\",\n PRICE_NO_LONGER_ALLOWED: \"This price is no longer allowed in this game.\",\n TITLE_REQUIRED: \"Give it a title.\",\n TITLE_TOO_LONG: \"The title is too long.\",\n THUMBNAIL_REQUIRED: \"This type of content needs a preview image.\",\n FILE_TOO_LARGE: \"The file is too large for this type of content.\",\n FILE_TYPE_MISMATCH: \"The file does not match its declared format.\",\n CONTENT_TYPE_NOT_ALLOWED:\n \"This type of content can't be published in this game.\",\n CANNOT_REPORT_OWN_CONTENT: \"You can't report your own publication.\",\n CANNOT_FOLLOW_SELF: \"You can't follow yourself.\",\n};\n\nexport const errorText = (e: string): string
|
|
75
|
+
"content": "import { errorText as kitErrorText } from \"@idosgames/react/ui\";\n\n/** Server error codes → words. Anything else is shown as is (it is already a sentence). */\nconst ERRORS: Record<string, string> = {\n WORKSHOP_DISABLED: \"The Workshop is turned off.\",\n WORKSHOP_NOT_CONFIGURED: \"The Workshop is not set up in this game yet.\",\n WORKSHOP_NOT_AVAILABLE: \"The Workshop is not available to you.\",\n WORKSHOP_CLOSED: \"The Workshop is closed right now.\",\n PRICE_CHANGED: \"The author changed the price — look at it again.\",\n HOLDING_REQUIREMENTS_NOT_MET: \"You don't hold what this option requires.\",\n ACCESS_DENIED: \"You don't have access to this yet.\",\n CONTENT_NOT_FOUND: \"Not found (or not shared with you).\",\n CONTENT_NOT_AVAILABLE: \"This publication is not available now.\",\n CONTENT_TEXT_REJECTED:\n \"The title or description contains words that are not allowed.\",\n PUBLISH_LIMIT_REACHED:\n \"You have reached the limit of publications of this type.\",\n DAILY_PUBLISH_LIMIT_REACHED: \"You have reached today's publishing limit.\",\n PUBLISH_NOT_ALLOWED: \"You can't publish this type of content yet.\",\n CANNOT_LIKE_OWN_CONTENT: \"You can't like your own publication.\",\n WORKSHOP_STORAGE_NOT_CONFIGURED:\n \"File storage is not configured for this game.\",\n TOO_MANY_PENDING_UPLOADS:\n \"You have too many unfinished uploads — try again a bit later.\",\n ACCESS_REQUIRED: \"Add at least one way for others to get it.\",\n OPTION_REQUIRED: \"Choose how you want to get it.\",\n PRICE_NO_LONGER_ALLOWED: \"This price is no longer allowed in this game.\",\n TITLE_REQUIRED: \"Give it a title.\",\n TITLE_TOO_LONG: \"The title is too long.\",\n THUMBNAIL_REQUIRED: \"This type of content needs a preview image.\",\n FILE_TOO_LARGE: \"The file is too large for this type of content.\",\n FILE_TYPE_MISMATCH: \"The file does not match its declared format.\",\n CONTENT_TYPE_NOT_ALLOWED:\n \"This type of content can't be published in this game.\",\n CANNOT_REPORT_OWN_CONTENT: \"You can't report your own publication.\",\n CANNOT_FOLLOW_SELF: \"You can't follow yourself.\",\n};\n\n/** Aspis (publish fee) refusals are worded by the kit, like in every other system. */\nconst isCredits = (e: string): boolean =>\n e.startsWith(\"CREDITS_\") || e === \"NOT_ENOUGH_CREDITS\";\n\nexport const errorText = (e: string): string =>\n ERRORS[e] ?? (isCredits(e) ? kitErrorText(e) : e);\n"
|
|
76
76
|
},
|
|
77
77
|
{
|
|
78
78
|
"path": "hooks.ts",
|