@forgeax/game 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +29 -0
  2. package/dist/main.js +473 -32
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -86,6 +86,8 @@ one-time actions on every turn.
86
86
  | `forgeax://status` | Resource | Preferred read-only project, service, and next-action status |
87
87
  | `forgeax_status_lite` | Tool | Status fallback for clients without MCP resource support |
88
88
  | `forgeax_run_current_game` | Tool | Build or reuse the active game's static preview, return its URL and health identity, and identify the runtime log file |
89
+ | `forgeax_generate_image` | Tool | Text-to-image, or image-to-image with a local `image`; saves a PNG/JPG into the active game's `assets/` and returns its path |
90
+ | `forgeax_generate_3d` | Tool | Text-to-3D (`prompt`) or image-to-3D (public https `image` URL); runs the async job to completion and saves a `.glb` into `assets/` |
89
91
 
90
92
  When this plugin cold-starts the managed Runtime, runtime output is written to:
91
93
 
@@ -111,6 +113,33 @@ The game SDK is available at `.forgeax/engine-sdk/`. Use its declaration files a
111
113
  `templates/game-default` or `templates/game-empty` before writing imports. If an API
112
114
  is absent, inspect `source/<package>/src/` instead of guessing.
113
115
 
116
+ ### Asset generation (LiteLLM)
117
+
118
+ `forgeax_generate_image` and `forgeax_generate_3d` produce art and 3D assets through a
119
+ LiteLLM gateway and save them into the active game's `assets/` directory, returning the
120
+ project-relative path to reference from game code. Configure via environment:
121
+
122
+ | Variable | Required | Default |
123
+ |:--|:-:|:--|
124
+ | `FORGEAX_LITELLM_API_KEY` | yes | — (secret; never commit it) |
125
+ | `FORGEAX_LITELLM_BASE_URL` | no | the shared ForgeaX gateway |
126
+ | `FORGEAX_GEN_IMAGE_MODEL` | no | `gemini-3-pro-image` |
127
+ | `FORGEAX_GEN_3D_TEXT_MODEL` | no | `tripo-3d-text` |
128
+ | `FORGEAX_GEN_3D_IMAGE_MODEL` | no | `tripo-3d-image` |
129
+ | `FORGEAX_COS_BUCKET` / `FORGEAX_COS_REGION` | for local image-to-3D | — |
130
+ | `FORGEAX_COS_SECRET_ID` / `FORGEAX_COS_SECRET_KEY` | for local image-to-3D | — (secret; never commit) |
131
+
132
+ - **Text-to-image / image-to-image**: `forgeax_generate_image({ prompt, image? })`. A
133
+ local `image` path switches to editing that image with the prompt.
134
+ - **Text-to-3D**: `forgeax_generate_3d({ prompt })` — submits, polls to completion
135
+ (~1–2 min), and downloads the `.glb`.
136
+ - **Image-to-3D**: `forgeax_generate_3d({ image })`. `image` is a **public https URL**,
137
+ or a **local file path** when COS is configured — the file is uploaded to the COS
138
+ bucket and passed to the backend as a short-lived presigned URL (the private bucket
139
+ stays private; the URL expires within the hour). This makes the "generate a concept
140
+ image, then turn it into a mesh" flow work end to end. Without COS, only a public URL
141
+ is accepted, because the 3D endpoint rejects local paths and inline base64.
142
+
114
143
  ## Supported clients
115
144
 
116
145
  | Client ID | Config path | Scope |
package/dist/main.js CHANGED
@@ -248,7 +248,7 @@ function runStdioServer(spec) {
248
248
  }
249
249
 
250
250
  // src/mcp/forgeax-server.ts
251
- import { readFileSync as readFileSync6 } from "node:fs";
251
+ import { readFileSync as readFileSync7 } from "node:fs";
252
252
 
253
253
  // src/status/collect.ts
254
254
  import { readFileSync as readFileSync4 } from "node:fs";
@@ -664,6 +664,14 @@ reasoning and game-code edits; the plugin owns ForgeaX Runtime lifecycle and fee
664
664
  - Reading runtime errors or engine logs: read the file path returned in
665
665
  \`runtime_logs.local_file\` with your own file-reading tool. Log tailing is
666
666
  deliberately not an MCP tool — the log is a file, so read it like one.
667
+ - Generating art or 3D assets ("make a sprite", "I need a texture", "generate a
668
+ model of…"): call \`forgeax_generate_image\` (text-to-image, or image-to-image
669
+ with a local \`image\`) or \`forgeax_generate_3d\` (text-to-3D via \`prompt\`,
670
+ image-to-3D via \`image\`). Both save into the active game's \`assets/\` directory
671
+ and return the project-relative path to reference from code. Image-to-3D accepts a
672
+ public https URL, or a local file path when COS is configured (it is uploaded and
673
+ passed as a short-lived presigned URL). They need \`FORGEAX_LITELLM_API_KEY\` (and
674
+ \`FORGEAX_COS_*\` for local-file image-to-3D) in the environment.
667
675
  - Creating a game, switching the active game, installing or upgrading the plugin:
668
676
  these are one-time operations and are CLI subcommands, not MCP tools. Run
669
677
  \`npx -y -p @forgeax/game forgeax-game <init|use|doctor|devkit|upgrade>\`.
@@ -1658,11 +1666,432 @@ function resolveSlug(root, requested) {
1658
1666
  return { slug, dir };
1659
1667
  }
1660
1668
 
1669
+ // src/gen/generate.ts
1670
+ import { existsSync as existsSync4, mkdirSync as mkdirSync7, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "node:fs";
1671
+ import { basename, extname, join as join7, relative as relative3 } from "node:path";
1672
+
1673
+ // src/gen/config.ts
1674
+ var DEFAULT_LITELLM_BASE_URL = "http://21.214.33.175:4000";
1675
+ var DEFAULT_MODELS = {
1676
+ textToImage: "gemini-3-pro-image",
1677
+ textTo3d: "tripo-3d-text",
1678
+ imageTo3d: "tripo-3d-image"
1679
+ };
1680
+ function env(name) {
1681
+ const value = process.env[name]?.trim();
1682
+ return value ? value : undefined;
1683
+ }
1684
+ function resolveLiteLlmConfig() {
1685
+ const baseUrl = (env("FORGEAX_LITELLM_BASE_URL") ?? DEFAULT_LITELLM_BASE_URL).replace(/\/+$/, "");
1686
+ const apiKey = env("FORGEAX_LITELLM_API_KEY");
1687
+ if (!apiKey) {
1688
+ throw new Error("FORGEAX_LITELLM_API_KEY is not set. Export the LiteLLM key so the asset tools can reach the gateway, e.g. `export FORGEAX_LITELLM_API_KEY=sk-...`.");
1689
+ }
1690
+ return {
1691
+ baseUrl,
1692
+ apiKey,
1693
+ models: {
1694
+ textToImage: env("FORGEAX_GEN_IMAGE_MODEL") ?? DEFAULT_MODELS.textToImage,
1695
+ textTo3d: env("FORGEAX_GEN_3D_TEXT_MODEL") ?? DEFAULT_MODELS.textTo3d,
1696
+ imageTo3d: env("FORGEAX_GEN_3D_IMAGE_MODEL") ?? DEFAULT_MODELS.imageTo3d
1697
+ }
1698
+ };
1699
+ }
1700
+
1701
+ // src/gen/cos.ts
1702
+ import { createHash as createHash2, createHmac } from "node:crypto";
1703
+ var DEFAULT_EXPIRES_SEC = 3600;
1704
+ var SKEW_SEC = 60;
1705
+ function env2(name) {
1706
+ const value = process.env[name]?.trim();
1707
+ return value ? value : undefined;
1708
+ }
1709
+ function resolveCosConfig() {
1710
+ const bucket = env2("FORGEAX_COS_BUCKET");
1711
+ const region = env2("FORGEAX_COS_REGION");
1712
+ const secretId = env2("FORGEAX_COS_SECRET_ID");
1713
+ const secretKey = env2("FORGEAX_COS_SECRET_KEY");
1714
+ if (!bucket || !region || !secretId || !secretKey)
1715
+ return;
1716
+ return { bucket, region, secretId, secretKey };
1717
+ }
1718
+ function cosHost(cfg) {
1719
+ return `${cfg.bucket}.cos.${cfg.region}.myqcloud.com`;
1720
+ }
1721
+ function rfc3986(value) {
1722
+ return encodeURIComponent(value).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
1723
+ }
1724
+ function formatKv(map) {
1725
+ const lowered = {};
1726
+ for (const [k, v] of Object.entries(map))
1727
+ lowered[k.toLowerCase()] = v;
1728
+ const keys = Object.keys(lowered).sort();
1729
+ return {
1730
+ serialized: keys.map((k) => `${rfc3986(k)}=${rfc3986(lowered[k])}`).join("&"),
1731
+ keyList: keys.map((k) => rfc3986(k)).join(";")
1732
+ };
1733
+ }
1734
+ function buildAuthorization(cfg, opts) {
1735
+ const start = opts.nowSec - SKEW_SEC;
1736
+ const end = opts.nowSec + (opts.expiresSec ?? DEFAULT_EXPIRES_SEC);
1737
+ const signTime = `${start};${end}`;
1738
+ const signKey = createHmac("sha1", cfg.secretKey).update(signTime).digest("hex");
1739
+ const { serialized: paramStr, keyList: paramList } = formatKv(opts.params ?? {});
1740
+ const { serialized: headerStr, keyList: headerList } = formatKv(opts.headers ?? {});
1741
+ const httpString = `${opts.method.toLowerCase()}
1742
+ ${opts.pathname}
1743
+ ${paramStr}
1744
+ ${headerStr}
1745
+ `;
1746
+ const httpStringSha1 = createHash2("sha1").update(httpString).digest("hex");
1747
+ const stringToSign = `sha1
1748
+ ${signTime}
1749
+ ${httpStringSha1}
1750
+ `;
1751
+ const signature = createHmac("sha1", signKey).update(stringToSign).digest("hex");
1752
+ return [
1753
+ "q-sign-algorithm=sha1",
1754
+ `q-ak=${cfg.secretId}`,
1755
+ `q-sign-time=${signTime}`,
1756
+ `q-key-time=${signTime}`,
1757
+ `q-header-list=${headerList}`,
1758
+ `q-url-param-list=${paramList}`,
1759
+ `q-signature=${signature}`
1760
+ ].join("&");
1761
+ }
1762
+ function presignGetUrl(cfg, key, expiresSec = DEFAULT_EXPIRES_SEC, nowSec = Math.floor(Date.now() / 1000)) {
1763
+ const pathname = key.startsWith("/") ? key : `/${key}`;
1764
+ const auth = buildAuthorization(cfg, { method: "get", pathname, nowSec, expiresSec });
1765
+ return `https://${cosHost(cfg)}${pathname}?${auth}`;
1766
+ }
1767
+ async function uploadObject(cfg, key, bytes, contentType) {
1768
+ const host = cosHost(cfg);
1769
+ const pathname = key.startsWith("/") ? key : `/${key}`;
1770
+ const auth = buildAuthorization(cfg, {
1771
+ method: "put",
1772
+ pathname,
1773
+ headers: { host },
1774
+ nowSec: Math.floor(Date.now() / 1000),
1775
+ expiresSec: 600
1776
+ });
1777
+ const ctrl = new AbortController;
1778
+ const timer = setTimeout(() => ctrl.abort(), 60000);
1779
+ try {
1780
+ const res = await fetch(`https://${host}${pathname}`, {
1781
+ method: "PUT",
1782
+ headers: { authorization: auth, "content-type": contentType },
1783
+ body: bytes,
1784
+ signal: ctrl.signal
1785
+ });
1786
+ if (!res.ok) {
1787
+ const body = await res.text().catch(() => "");
1788
+ throw new Error(`COS upload of ${key} failed: ${res.status} ${res.statusText}${body ? ` — ${body.slice(0, 400)}` : ""}`);
1789
+ }
1790
+ } finally {
1791
+ clearTimeout(timer);
1792
+ }
1793
+ }
1794
+ async function uploadAndPresign(cfg, key, bytes, contentType, expiresSec = DEFAULT_EXPIRES_SEC) {
1795
+ await uploadObject(cfg, key, bytes, contentType);
1796
+ return presignGetUrl(cfg, key, expiresSec);
1797
+ }
1798
+
1799
+ // src/gen/litellm.ts
1800
+ var TASK_TIMEOUT_MS = 420000;
1801
+ var POLL_INTERVAL_MS = 3000;
1802
+ var REQUEST_TIMEOUT_MS = 60000;
1803
+ function authHeaders(cfg) {
1804
+ return { authorization: `Bearer ${cfg.apiKey}` };
1805
+ }
1806
+ async function request(url, init, timeoutMs = REQUEST_TIMEOUT_MS) {
1807
+ const ctrl = new AbortController;
1808
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
1809
+ try {
1810
+ const res = await fetch(url, { ...init, signal: ctrl.signal });
1811
+ if (!res.ok) {
1812
+ const body = await res.text().catch(() => "");
1813
+ throw new Error(`LiteLLM ${init.method ?? "GET"} ${url} failed: ${res.status} ${res.statusText}${body ? ` — ${body.slice(0, 600)}` : ""}`);
1814
+ }
1815
+ return res;
1816
+ } catch (err) {
1817
+ if (err instanceof Error && err.name === "AbortError") {
1818
+ throw new Error(`LiteLLM request timed out after ${timeoutMs}ms: ${url}`);
1819
+ }
1820
+ throw err;
1821
+ } finally {
1822
+ clearTimeout(timer);
1823
+ }
1824
+ }
1825
+ function decodeImagePayload(item) {
1826
+ if (!item)
1827
+ return {};
1828
+ const b64 = typeof item.b64_json === "string" ? item.b64_json : undefined;
1829
+ const url = typeof item.url === "string" ? item.url : undefined;
1830
+ return { b64, url };
1831
+ }
1832
+ function sniffImageExt(bytes) {
1833
+ if (bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255)
1834
+ return "jpg";
1835
+ return "png";
1836
+ }
1837
+ async function materializeImage(payload) {
1838
+ if (payload.b64) {
1839
+ const bytes = new Uint8Array(Buffer.from(payload.b64, "base64"));
1840
+ return { bytes, ext: sniffImageExt(bytes) };
1841
+ }
1842
+ if (payload.url) {
1843
+ const res = await request(payload.url, { method: "GET" });
1844
+ const bytes = new Uint8Array(await res.arrayBuffer());
1845
+ return { bytes, ext: sniffImageExt(bytes) };
1846
+ }
1847
+ throw new Error("LiteLLM image response contained neither b64_json nor url.");
1848
+ }
1849
+ async function generateImage(cfg, opts) {
1850
+ const res = await request(`${cfg.baseUrl}/v1/images/generations`, {
1851
+ method: "POST",
1852
+ headers: { ...authHeaders(cfg), "content-type": "application/json" },
1853
+ body: JSON.stringify({ model: opts.model, prompt: opts.prompt, n: 1, ...opts.size ? { size: opts.size } : {} })
1854
+ });
1855
+ const json = await res.json();
1856
+ return materializeImage(decodeImagePayload(json.data?.[0]));
1857
+ }
1858
+ async function editImage(cfg, opts) {
1859
+ const form = new FormData;
1860
+ form.set("model", opts.model);
1861
+ form.set("prompt", opts.prompt);
1862
+ form.set("n", "1");
1863
+ form.set("image", new Blob([opts.image]), opts.filename);
1864
+ const res = await request(`${cfg.baseUrl}/v1/images/edits`, {
1865
+ method: "POST",
1866
+ headers: authHeaders(cfg),
1867
+ body: form
1868
+ });
1869
+ const json = await res.json();
1870
+ return materializeImage(decodeImagePayload(json.data?.[0]));
1871
+ }
1872
+ async function submit3dTask(cfg, body) {
1873
+ const res = await request(`${cfg.baseUrl}/v1/3d/generations`, {
1874
+ method: "POST",
1875
+ headers: { ...authHeaders(cfg), "content-type": "application/json" },
1876
+ body: JSON.stringify(body)
1877
+ });
1878
+ const json = await res.json();
1879
+ if (!json.id)
1880
+ throw new Error(`LiteLLM 3D submit returned no task id: ${JSON.stringify(json).slice(0, 400)}`);
1881
+ return json.id;
1882
+ }
1883
+ async function poll3dTask(cfg, id, onProgress) {
1884
+ const deadline = Date.now() + TASK_TIMEOUT_MS;
1885
+ for (;; ) {
1886
+ const res = await request(`${cfg.baseUrl}/v1/3d/tasks/${encodeURIComponent(id)}`, {
1887
+ method: "GET",
1888
+ headers: authHeaders(cfg)
1889
+ });
1890
+ const state = await res.json();
1891
+ if (typeof state.progress === "number")
1892
+ onProgress?.(state.progress);
1893
+ const status = state.status?.toLowerCase();
1894
+ if (status === "succeeded" || status === "success" || status === "completed")
1895
+ return state;
1896
+ if (status === "failed" || status === "error" || status === "cancelled") {
1897
+ throw new Error(`LiteLLM 3D task ${id} ${state.status}${state.error ? `: ${JSON.stringify(state.error).slice(0, 300)}` : ""}`);
1898
+ }
1899
+ if (Date.now() > deadline) {
1900
+ throw new Error(`LiteLLM 3D task ${id} did not finish within ${TASK_TIMEOUT_MS / 1000}s (last status: ${state.status}).`);
1901
+ }
1902
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
1903
+ }
1904
+ }
1905
+ async function downloadMesh(state) {
1906
+ const assets = (state.data ?? []).map((d) => ({
1907
+ url: typeof d.url === "string" ? d.url : undefined,
1908
+ type: typeof d.type === "string" ? d.type : "",
1909
+ format: typeof d.format === "string" ? d.format : ""
1910
+ }));
1911
+ const mesh = assets.find((a) => a.url && (a.type === "mesh" || /\.(glb|gltf|obj|fbx|usdz)/i.test(a.url ?? ""))) ?? assets.find((a) => a.url);
1912
+ if (!mesh?.url)
1913
+ throw new Error(`LiteLLM 3D task ${state.id} completed with no downloadable mesh asset.`);
1914
+ const res = await request(mesh.url, { method: "GET" });
1915
+ const bytes = new Uint8Array(await res.arrayBuffer());
1916
+ const ext = mesh.format || (mesh.url.match(/\.([a-z0-9]+)(?:\?|$)/i)?.[1] ?? "glb").toLowerCase();
1917
+ return { bytes, ext, assetType: mesh.type || "mesh" };
1918
+ }
1919
+ async function generate3dFromText(cfg, opts) {
1920
+ const id = await submit3dTask(cfg, { model: opts.model, prompt: opts.prompt });
1921
+ return downloadMesh(await poll3dTask(cfg, id, opts.onProgress));
1922
+ }
1923
+ async function generate3dFromImageUrl(cfg, opts) {
1924
+ const id = await submit3dTask(cfg, { model: opts.model, image_url: opts.imageUrl, ...opts.prompt ? { prompt: opts.prompt } : {} });
1925
+ return downloadMesh(await poll3dTask(cfg, id, opts.onProgress));
1926
+ }
1927
+
1928
+ // src/gen/generate.ts
1929
+ function assetsDirFor(cwd, explicitGame) {
1930
+ const binding = resolveProject(cwd);
1931
+ if (!binding.root) {
1932
+ throw new Error(`No ForgeaX project found from ${binding.searchedFrom}. Run \`forgeax-game init --game <slug>\` in the workspace first, or pass \`game\`/\`target_dir\`.`);
1933
+ }
1934
+ const slug = explicitGame?.trim() || activeGame(binding.root);
1935
+ if (!slug) {
1936
+ const games = listGames(binding.root);
1937
+ throw new Error(`No active game to save the asset into.${games.length ? ` Pass one of: ${games.join(", ")}` : " Create one with `forgeax-game init --game <slug>`."}`);
1938
+ }
1939
+ const dir = gameDir(binding.root, slug);
1940
+ if (!dir)
1941
+ throw new Error(`Game ${JSON.stringify(slug)} not found in this project.`);
1942
+ const assets = join7(dir, "assets");
1943
+ mkdirSync7(assets, { recursive: true });
1944
+ return { dir: assets, root: binding.root, slug };
1945
+ }
1946
+ function safeStem(preferred, fallback) {
1947
+ const source = (preferred ?? fallback).toLowerCase();
1948
+ const stem = source.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48);
1949
+ return stem || "asset";
1950
+ }
1951
+ function uniquePath(dir, stem, ext) {
1952
+ let candidate = join7(dir, `${stem}.${ext}`);
1953
+ for (let i = 1;existsSync4(candidate); i += 1)
1954
+ candidate = join7(dir, `${stem}-${i}.${ext}`);
1955
+ return candidate;
1956
+ }
1957
+ function logProgress(label) {
1958
+ let last = -1;
1959
+ return (pct) => {
1960
+ const step = Math.floor(pct / 10);
1961
+ if (step !== last) {
1962
+ last = step;
1963
+ process.stderr.write(`[forgeax] ${label}: ${pct}%
1964
+ `);
1965
+ }
1966
+ };
1967
+ }
1968
+ var GAME_PROPERTY = {
1969
+ game: {
1970
+ type: "string",
1971
+ description: "Game slug to save the asset into. Defaults to the active game."
1972
+ },
1973
+ target_dir: {
1974
+ type: "string",
1975
+ description: "Directory to resolve the ForgeaX project from. Defaults to the server working directory."
1976
+ },
1977
+ name: {
1978
+ type: "string",
1979
+ description: "Base file name for the saved asset (without extension). Defaults to a slug of the prompt."
1980
+ }
1981
+ };
1982
+ var GENERATE_IMAGE_SCHEMA = {
1983
+ type: "object",
1984
+ properties: {
1985
+ prompt: { type: "string", description: "What to draw. Required for both text-to-image and editing an input image." },
1986
+ image: {
1987
+ type: "string",
1988
+ description: "Optional local image path to edit (image-to-image). When set, the prompt describes the desired change."
1989
+ },
1990
+ model: { type: "string", description: "Override the image model. Defaults to the configured text-to-image model." },
1991
+ ...GAME_PROPERTY
1992
+ },
1993
+ required: ["prompt"],
1994
+ additionalProperties: false
1995
+ };
1996
+ var GENERATE_3D_SCHEMA = {
1997
+ type: "object",
1998
+ properties: {
1999
+ prompt: { type: "string", description: "Text description for text-to-3D. Provide this or `image`." },
2000
+ image: {
2001
+ type: "string",
2002
+ description: "Image for image-to-3D: a public https URL, or a local file path when COS is configured (FORGEAX_COS_*) — local files are uploaded to COS and passed as a short-lived presigned URL. Without COS, only a public URL works."
2003
+ },
2004
+ model: { type: "string", description: "Override the 3D model. Defaults to the configured text/image-to-3D model." },
2005
+ ...GAME_PROPERTY
2006
+ },
2007
+ additionalProperties: false
2008
+ };
2009
+ var HTTP_URL_RE = /^https?:\/\//i;
2010
+ async function generateImageTool(args, cwd) {
2011
+ const prompt = typeof args.prompt === "string" ? args.prompt.trim() : "";
2012
+ if (!prompt)
2013
+ throw new Error("`prompt` is required.");
2014
+ const cfg = resolveLiteLlmConfig();
2015
+ const targetDir = typeof args.target_dir === "string" ? args.target_dir : cwd;
2016
+ const { dir, root, slug } = assetsDirFor(targetDir, typeof args.game === "string" ? args.game : undefined);
2017
+ const model = typeof args.model === "string" && args.model.trim() ? args.model.trim() : cfg.models.textToImage;
2018
+ let result;
2019
+ let mode;
2020
+ const inputImage = typeof args.image === "string" ? args.image.trim() : "";
2021
+ if (inputImage) {
2022
+ if (HTTP_URL_RE.test(inputImage)) {
2023
+ throw new Error("Image-to-image expects a LOCAL image path, not a URL. Download it first, then pass the path.");
2024
+ }
2025
+ if (!existsSync4(inputImage))
2026
+ throw new Error(`Input image not found: ${inputImage}`);
2027
+ const bytes = new Uint8Array(readFileSync6(inputImage));
2028
+ result = await editImage(cfg, { model, prompt, image: bytes, filename: basename(inputImage) });
2029
+ mode = "image-to-image";
2030
+ } else {
2031
+ result = await generateImage(cfg, { model, prompt });
2032
+ mode = "text-to-image";
2033
+ }
2034
+ const stem = safeStem(typeof args.name === "string" ? args.name : undefined, prompt);
2035
+ const outPath = uniquePath(dir, stem, result.ext);
2036
+ writeFileSync4(outPath, result.bytes);
2037
+ const rel = relative3(root, outPath);
2038
+ return `Saved ${mode} asset to \`${rel}\` (game: ${slug}, model: ${model}, ${result.bytes.length} bytes). Reference it from game code by this path.`;
2039
+ }
2040
+ function imageContentType(path) {
2041
+ const ext = extname(path).toLowerCase();
2042
+ if (ext === ".jpg" || ext === ".jpeg")
2043
+ return "image/jpeg";
2044
+ if (ext === ".webp")
2045
+ return "image/webp";
2046
+ return "image/png";
2047
+ }
2048
+ async function resolveImageUrlFor3d(image, slug) {
2049
+ if (HTTP_URL_RE.test(image))
2050
+ return image;
2051
+ if (!existsSync4(image))
2052
+ throw new Error(`Input image not found: ${image}`);
2053
+ const cos = resolveCosConfig();
2054
+ if (!cos) {
2055
+ throw new Error("Image-to-3D from a local file needs COS configured (FORGEAX_COS_BUCKET/REGION/SECRET_ID/SECRET_KEY) so the image can be hosted for the backend to fetch. Alternatively pass a public https URL.");
2056
+ }
2057
+ const bytes = new Uint8Array(readFileSync6(image));
2058
+ const stem = safeStem(basename(image, extname(image)), "input");
2059
+ const ext = (extname(image).replace(".", "") || "png").toLowerCase();
2060
+ const key = `forgeax/${slug}/${stem}-${Date.now()}.${ext}`;
2061
+ return uploadAndPresign(cos, key, bytes, imageContentType(image));
2062
+ }
2063
+ async function generate3dTool(args, cwd) {
2064
+ const prompt = typeof args.prompt === "string" ? args.prompt.trim() : "";
2065
+ const image = typeof args.image === "string" ? args.image.trim() : "";
2066
+ if (!prompt && !image)
2067
+ throw new Error("Provide `prompt` (text-to-3D) or `image` (image-to-3D).");
2068
+ const cfg = resolveLiteLlmConfig();
2069
+ const targetDir = typeof args.target_dir === "string" ? args.target_dir : cwd;
2070
+ const { dir, root, slug } = assetsDirFor(targetDir, typeof args.game === "string" ? args.game : undefined);
2071
+ let result;
2072
+ let mode;
2073
+ if (image) {
2074
+ const imageUrl = await resolveImageUrlFor3d(image, slug);
2075
+ const model = typeof args.model === "string" && args.model.trim() ? args.model.trim() : cfg.models.imageTo3d;
2076
+ result = await generate3dFromImageUrl(cfg, { model, imageUrl, prompt: prompt || undefined, onProgress: logProgress("image-to-3D") });
2077
+ mode = "image-to-3D";
2078
+ } else {
2079
+ const model = typeof args.model === "string" && args.model.trim() ? args.model.trim() : cfg.models.textTo3d;
2080
+ result = await generate3dFromText(cfg, { model, prompt, onProgress: logProgress("text-to-3D") });
2081
+ mode = "text-to-3D";
2082
+ }
2083
+ const stem = safeStem(typeof args.name === "string" ? args.name : undefined, prompt || "model");
2084
+ const outPath = uniquePath(dir, stem, result.ext);
2085
+ writeFileSync4(outPath, result.bytes);
2086
+ const rel = relative3(root, outPath);
2087
+ return `Saved ${mode} ${result.assetType} to \`${rel}\` (game: ${slug}, ${result.bytes.length} bytes). Reference it from game code by this path.`;
2088
+ }
2089
+
1661
2090
  // src/mcp/forgeax-server.ts
1662
2091
  function packageVersion() {
1663
- for (const relative3 of ["../package.json", "../../package.json"]) {
2092
+ for (const relative4 of ["../package.json", "../../package.json"]) {
1664
2093
  try {
1665
- const version = JSON.parse(readFileSync6(new URL(relative3, import.meta.url), "utf8")).version;
2094
+ const version = JSON.parse(readFileSync7(new URL(relative4, import.meta.url), "utf8")).version;
1666
2095
  if (version)
1667
2096
  return version;
1668
2097
  } catch {}
@@ -1704,18 +2133,30 @@ function createForgeaxMcpServer() {
1704
2133
  description: 'Build, preview, reload, or verify the active game. One call covers what the user means by "run it", "let me see it", "reload", or "does it work": it installs the selected Runtime when needed, builds or reuses a static preview, returns a preview URL to open, and reports the Runtime log file. Read an available `runtime_logs.local_file` with your own file tool — log tailing is intentionally not a tool. Call this after a requested game change, not for ordinary edits the user has not asked to see.',
1705
2134
  inputSchema: RUN_TOOL_SCHEMA,
1706
2135
  run: async (args, ctx) => runCurrentGame(args, ctx.cwd)
2136
+ },
2137
+ {
2138
+ name: "forgeax_generate_image",
2139
+ description: "Generate a game image asset from a text prompt (text-to-image), or edit a local image when `image` is set (image-to-image). Saves the PNG/JPG into the active game's `assets/` directory and returns its project-relative path to reference from game code. Backed by the ForgeaX LiteLLM gateway; requires FORGEAX_LITELLM_API_KEY. Use when the user asks for a sprite, texture, icon, background, or concept art.",
2140
+ inputSchema: GENERATE_IMAGE_SCHEMA,
2141
+ run: async (args, ctx) => generateImageTool(args, ctx.cwd)
2142
+ },
2143
+ {
2144
+ name: "forgeax_generate_3d",
2145
+ description: "Generate a 3D model (.glb) for the game. Provide `prompt` for text-to-3D, or `image` for image-to-3D — a public https URL, or a local file path when COS is configured (the file is uploaded and passed as a short-lived presigned URL). Runs the async generation to completion (~1–2 min) and saves the mesh into the active game's `assets/` directory, returning its project-relative path. Backed by the ForgeaX LiteLLM gateway; requires FORGEAX_LITELLM_API_KEY (and FORGEAX_COS_* for local-file image-to-3D).",
2146
+ inputSchema: GENERATE_3D_SCHEMA,
2147
+ run: async (args, ctx) => generate3dTool(args, ctx.cwd)
1707
2148
  }
1708
2149
  ]
1709
2150
  };
1710
2151
  }
1711
2152
 
1712
2153
  // src/cli/dispatch.ts
1713
- import { existsSync as existsSync5, readFileSync as readFileSync8, rmSync as rmSync2, writeFileSync as writeFileSync5 } from "node:fs";
1714
- import { basename, join as join8 } from "node:path";
2154
+ import { existsSync as existsSync6, readFileSync as readFileSync9, rmSync as rmSync2, writeFileSync as writeFileSync6 } from "node:fs";
2155
+ import { basename as basename2, join as join9 } from "node:path";
1715
2156
 
1716
2157
  // src/install/clients.ts
1717
2158
  import { homedir as homedir3 } from "node:os";
1718
- import { join as join7, resolve as resolve4 } from "node:path";
2159
+ import { join as join8, resolve as resolve4 } from "node:path";
1719
2160
  var HOME = homedir3();
1720
2161
  var CLIENTS = [
1721
2162
  {
@@ -1723,7 +2164,7 @@ var CLIENTS = [
1723
2164
  label: "Codex CLI",
1724
2165
  format: "toml",
1725
2166
  scope: "user",
1726
- path: () => join7(HOME, ".codex", "config.toml"),
2167
+ path: () => join8(HOME, ".codex", "config.toml"),
1727
2168
  commandShape: "split",
1728
2169
  postInstallNote: "Restart Codex, then run /mcp to confirm the server is connected."
1729
2170
  },
@@ -1732,7 +2173,7 @@ var CLIENTS = [
1732
2173
  label: "Claude Code",
1733
2174
  format: "json",
1734
2175
  scope: "user",
1735
- path: () => join7(HOME, ".claude.json"),
2176
+ path: () => join8(HOME, ".claude.json"),
1736
2177
  serverMapKey: ["mcpServers"],
1737
2178
  commandShape: "split",
1738
2179
  postInstallNote: "Restart Claude Code, then run /mcp to confirm the server is connected."
@@ -1742,7 +2183,7 @@ var CLIENTS = [
1742
2183
  label: "Cursor",
1743
2184
  format: "json",
1744
2185
  scope: "user",
1745
- path: () => join7(HOME, ".cursor", "mcp.json"),
2186
+ path: () => join8(HOME, ".cursor", "mcp.json"),
1746
2187
  serverMapKey: ["mcpServers"],
1747
2188
  commandShape: "split",
1748
2189
  postInstallNote: "Reload Cursor, then check Settings > MCP."
@@ -1752,7 +2193,7 @@ var CLIENTS = [
1752
2193
  label: "Trae (project)",
1753
2194
  format: "json",
1754
2195
  scope: "project",
1755
- path: (projectRoot) => join7(projectRoot, ".trae", "mcp.json"),
2196
+ path: (projectRoot) => join8(projectRoot, ".trae", "mcp.json"),
1756
2197
  serverMapKey: ["mcpServers"],
1757
2198
  commandShape: "split",
1758
2199
  postInstallNote: "Reload Trae, then check the project MCP server list."
@@ -1763,7 +2204,7 @@ var CLIENTS = [
1763
2204
  label: "CodeBuddy / WorkBuddy",
1764
2205
  format: "json",
1765
2206
  scope: "user",
1766
- path: () => join7(HOME, ".codebuddy", ".mcp.json"),
2207
+ path: () => join8(HOME, ".codebuddy", ".mcp.json"),
1767
2208
  serverMapKey: ["mcpServers"],
1768
2209
  commandShape: "split",
1769
2210
  postInstallNote: "Restart CodeBuddy or WorkBuddy, then run /mcp to confirm the server is connected."
@@ -1773,7 +2214,7 @@ var CLIENTS = [
1773
2214
  label: "Windsurf",
1774
2215
  format: "json",
1775
2216
  scope: "user",
1776
- path: () => join7(HOME, ".codeium", "windsurf", "mcp_config.json"),
2217
+ path: () => join8(HOME, ".codeium", "windsurf", "mcp_config.json"),
1777
2218
  serverMapKey: ["mcpServers"],
1778
2219
  commandShape: "split",
1779
2220
  postInstallNote: "Reload Windsurf to pick up the new server."
@@ -1783,7 +2224,7 @@ var CLIENTS = [
1783
2224
  label: "VS Code (workspace)",
1784
2225
  format: "json",
1785
2226
  scope: "project",
1786
- path: (projectRoot) => join7(projectRoot, ".vscode", "mcp.json"),
2227
+ path: (projectRoot) => join8(projectRoot, ".vscode", "mcp.json"),
1787
2228
  serverMapKey: ["servers"],
1788
2229
  commandShape: "split",
1789
2230
  postInstallNote: 'Open .vscode/mcp.json and click Start, or run "MCP: List Servers".'
@@ -1793,7 +2234,7 @@ var CLIENTS = [
1793
2234
  label: "OpenCode",
1794
2235
  format: "json",
1795
2236
  scope: "user",
1796
- path: () => join7(HOME, ".config", "opencode", "opencode.json"),
2237
+ path: () => join8(HOME, ".config", "opencode", "opencode.json"),
1797
2238
  serverMapKey: ["mcp"],
1798
2239
  commandShape: "argv",
1799
2240
  extraEntryFields: { type: "local", enabled: true },
@@ -1817,7 +2258,7 @@ function launchSpec(mode) {
1817
2258
  }
1818
2259
 
1819
2260
  // src/install/write-config.ts
1820
- import { copyFileSync as copyFileSync2, existsSync as existsSync4, mkdirSync as mkdirSync7, readFileSync as readFileSync7, writeFileSync as writeFileSync4 } from "node:fs";
2261
+ import { copyFileSync as copyFileSync2, existsSync as existsSync5, mkdirSync as mkdirSync8, readFileSync as readFileSync8, writeFileSync as writeFileSync5 } from "node:fs";
1821
2262
  import { dirname as dirname4 } from "node:path";
1822
2263
 
1823
2264
  // src/install/toml-section.ts
@@ -2027,11 +2468,11 @@ function mergeTomlConfig(existing, entry) {
2027
2468
  }
2028
2469
  function inspectConfig(spec, projectRoot, launch) {
2029
2470
  const path = spec.path(projectRoot);
2030
- if (!existsSync4(path))
2471
+ if (!existsSync5(path))
2031
2472
  return { path, state: "missing" };
2032
2473
  let existing;
2033
2474
  try {
2034
- existing = readFileSync7(path, "utf8");
2475
+ existing = readFileSync8(path, "utf8");
2035
2476
  if (spec.format === "toml") {
2036
2477
  const header = `mcp_servers.${SERVER_KEY}`;
2037
2478
  if (!hasTomlTable(existing, header)) {
@@ -2076,25 +2517,25 @@ function inspectConfig(spec, projectRoot, launch) {
2076
2517
  }
2077
2518
  function applyConfig(spec, projectRoot, launch) {
2078
2519
  const path = spec.path(projectRoot);
2079
- const existing = existsSync4(path) ? readFileSync7(path, "utf8") : undefined;
2520
+ const existing = existsSync5(path) ? readFileSync8(path, "utf8") : undefined;
2080
2521
  const entry = buildEntry(spec, launch);
2081
2522
  const merged = spec.format === "toml" ? mergeTomlConfig(existing, entry) : mergeJsonConfig(existing, spec, entry);
2082
2523
  if (!merged.changed)
2083
2524
  return { path, changed: false };
2084
- mkdirSync7(dirname4(path), { recursive: true });
2525
+ mkdirSync8(dirname4(path), { recursive: true });
2085
2526
  let backup;
2086
2527
  if (existing !== undefined) {
2087
2528
  backup = `${path}.bak.latest`;
2088
2529
  copyFileSync2(path, backup);
2089
2530
  }
2090
- writeFileSync4(path, merged.content);
2531
+ writeFileSync5(path, merged.content);
2091
2532
  return { path, changed: true, ...backup ? { backup } : {} };
2092
2533
  }
2093
2534
  function removeConfig(spec, projectRoot) {
2094
2535
  const path = spec.path(projectRoot);
2095
- if (!existsSync4(path))
2536
+ if (!existsSync5(path))
2096
2537
  return { path, changed: false };
2097
- const existing = readFileSync7(path, "utf8");
2538
+ const existing = readFileSync8(path, "utf8");
2098
2539
  let content;
2099
2540
  if (spec.format === "toml") {
2100
2541
  content = removeTomlTable(existing, `mcp_servers.${SERVER_KEY}`);
@@ -2122,7 +2563,7 @@ function removeConfig(spec, projectRoot) {
2122
2563
  return { path, changed: false };
2123
2564
  const backup = `${path}.bak.latest`;
2124
2565
  copyFileSync2(path, backup);
2125
- writeFileSync4(path, content);
2566
+ writeFileSync5(path, content);
2126
2567
  return { path, changed: true, backup };
2127
2568
  }
2128
2569
 
@@ -2326,23 +2767,23 @@ function requireProject() {
2326
2767
  return project.root;
2327
2768
  }
2328
2769
  function updateAgentsFile(root) {
2329
- const path = join8(root, "AGENTS.md");
2330
- const existing = existsSync5(path) ? readFileSync8(path, "utf8") : undefined;
2770
+ const path = join9(root, "AGENTS.md");
2771
+ const existing = existsSync6(path) ? readFileSync9(path, "utf8") : undefined;
2331
2772
  const content = upsertBlock(existing, ROUTING_TEXT);
2332
2773
  if (content === existing)
2333
2774
  return { path, changed: false };
2334
- writeFileSync5(path, content);
2775
+ writeFileSync6(path, content);
2335
2776
  return { path, changed: true };
2336
2777
  }
2337
2778
  function removeAgentsBlock(root) {
2338
- const path = join8(root, "AGENTS.md");
2339
- if (!existsSync5(path))
2779
+ const path = join9(root, "AGENTS.md");
2780
+ if (!existsSync6(path))
2340
2781
  return { path, changed: false };
2341
- const existing = readFileSync8(path, "utf8");
2782
+ const existing = readFileSync9(path, "utf8");
2342
2783
  const content = removeBlock(existing);
2343
2784
  if (content === existing)
2344
2785
  return { path, changed: false };
2345
- writeFileSync5(path, content);
2786
+ writeFileSync6(path, content);
2346
2787
  return { path, changed: true };
2347
2788
  }
2348
2789
  async function apiPost(path, body) {
@@ -2421,7 +2862,7 @@ async function installCommand(args) {
2421
2862
  return failures === 0 ? 0 : 1;
2422
2863
  }
2423
2864
  function defaultSlug(root) {
2424
- const raw = basename(root).toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
2865
+ const raw = basename2(root).toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
2425
2866
  return SLUG_RE.test(raw) ? raw : "my-game";
2426
2867
  }
2427
2868
  var INIT_USAGE = "usage: forgeax-game init [--game <slug>] [--ide codex,claude,cursor,...]";
@@ -2577,7 +3018,7 @@ async function uninstallCommand(args) {
2577
3018
  const agents = removeAgentsBlock(root);
2578
3019
  process.stdout.write(`${agents.changed ? "REMOVED" : "ABSENT "} routing block: ${agents.path}
2579
3020
  `);
2580
- process.stdout.write(`KEPT your games and project metadata: ${join8(root, ".forgeax")}
3021
+ process.stdout.write(`KEPT your games and project metadata: ${join9(root, ".forgeax")}
2581
3022
  `);
2582
3023
  } else {
2583
3024
  process.stdout.write(`INFO no ForgeaX project bound; only client configuration was touched.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgeax/game",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "@forgeax/game \u2014 ForgeaX game development as a plugin for MCP-capable agent CLIs (Codex, Claude Code, Cursor, Trae, OpenCode, WorkBuddy). Single binary, dual mode: no args = stdio MCP server, subcommand = CLI.",