gamekit777 0.1.0 → 0.1.1

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/README.md CHANGED
@@ -14,7 +14,7 @@
14
14
  ## 开始
15
15
 
16
16
  ```sh
17
- bunx gamekit777 new my-game "我的游戏" # 一个能跑的抛硬币 + bun install
17
+ bunx gamekit777 new # 一个能跑的抛硬币(my-game)+ bun install;名字改 game.meta.json
18
18
  cd my-game
19
19
  bun run dev # 参考平台:本地表模型,就地建表、抽行、restore 还原
20
20
  ```
package/client/index.ts CHANGED
@@ -15,6 +15,9 @@ import {
15
15
  GKLT1_HEADER_BYTES, GKLT1_ROW_BYTES,
16
16
  GameKitError,
17
17
  type ListGamesQuery, type ListRoundsQuery,
18
+ PAIR_CLAIM_PATH, PAIR_START_PATH,
19
+ type PairClaimResult as TPairClaimResult, PairClaimResult,
20
+ type PairStartResult as TPairStartResult, PairStartResult,
18
21
  MAX_BOOKS_PER_CHUNK,
19
22
  type PlaceBet, type PublishGame,
20
23
  type RotateResult as TRotateResult, RotateResult,
@@ -153,6 +156,14 @@ export function createClient(opts: ClientOptions) {
153
156
  http.request({ method: 'GET', path: '/v1/me', schema: SessionView }),
154
157
  },
155
158
 
159
+ /** studio 配对:站点签 code,studio 拿 code 换一个同一用户的会话(cookie 落在 studio 的 origin) */
160
+ pair: {
161
+ start: (): Promise<TPairStartResult> =>
162
+ http.request({ method: 'POST', path: PAIR_START_PATH, json: {}, schema: PairStartResult }),
163
+ claim: (code: string): Promise<TPairClaimResult> =>
164
+ http.request({ method: 'POST', path: PAIR_CLAIM_PATH, json: { code }, schema: PairClaimResult }),
165
+ },
166
+
156
167
  games: {
157
168
  create: (body: CreateGame): Promise<TGame> =>
158
169
  http.request({ method: 'POST', path: '/v1/games', json: body, schema: Game }),
@@ -24,6 +24,9 @@ studio/cases.jsonl case 库(进版本控制),studio 维护
24
24
 
25
25
  **没有** `publish.ts`、`src/rules/index.ts`、`meta`、版本号。看到教程里有这些就是旧结构,忽略。
26
26
 
27
+ **改名**:只改 `game.meta.json` 的 `id`(小写短横线,同时是平台上的 slug、产物目录名、CSS 命名空间)和 `title`,
28
+ `game.ts` 从那里读。`package.json` 的 `name` 随意。已经发布过的 slug 不能改——那是另一个游戏。
29
+
27
30
  ## 清单:`game.ts`
28
31
 
29
32
  ```ts
@@ -12,6 +12,7 @@
12
12
  逻辑侧三个必填:schema 说有哪些参数,round 说结果怎么算,paytable 说赔多少。
13
13
  normalize / validate / modeOf 不用写——它们由 schema 和 modes 推导。 */
14
14
  import { createRng, defineGame, normalizeBySchema, type RoundInput, type RoundResult } from 'gamekit777/sdk';
15
+ import meta from './game.meta.json' with { type: 'json' };
15
16
  import { HOUSE_EDGE, MULT } from './src/rules/const.ts';
16
17
  import { restore } from './src/rules/restore.ts';
17
18
  import { schema } from './src/rules/schema.ts';
@@ -44,8 +45,9 @@ export function round(input: RoundInput<__PASCAL__Config>): RoundResult<__PASCAL
44
45
  }
45
46
 
46
47
  export default defineGame<__PASCAL__Config, __PASCAL__Outcome>({
47
- slug: '__ID__',
48
- title: '__TITLE__',
48
+ // 名字只在 game.meta.json 里改:slug 同时是大厅里的 id、产物目录名和 CSS 命名空间
49
+ slug: meta.id,
50
+ title: meta.title,
49
51
  description: '押正面还是反面。',
50
52
  rtp: HOUSE_EDGE,
51
53
  sims: 100_000,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gamekit777",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "gamekit777": "./studio/cli.ts"
package/protocol/index.ts CHANGED
@@ -7,3 +7,4 @@ export * from './rounds.ts';
7
7
  export * from './seeds.ts';
8
8
  export * from './session.ts';
9
9
  export * from './verify.ts';
10
+ export * from './pair.ts';
@@ -0,0 +1,32 @@
1
+ /* studio 配对:站点把当前身份借给本机 studio。
2
+ 接口挂在 better-auth 下(/api/auth/pair/*),因为建会话、写 cookie 是它的事。 */
3
+ import { z } from 'zod';
4
+
5
+ export const PairStartResult = z.object({
6
+ /** 一次性、60 秒有效 */
7
+ code: z.string().min(32).max(128),
8
+ expiresAt: z.int(),
9
+ });
10
+ export type PairStartResult = z.infer<typeof PairStartResult>;
11
+
12
+ export const PairClaim = z.object({ code: z.string().min(32).max(128) });
13
+ export type PairClaim = z.infer<typeof PairClaim>;
14
+
15
+ export const PairClaimResult = z.object({
16
+ user: z.object({ id: z.string(), name: z.string().nullable(), isAnonymous: z.boolean() }),
17
+ });
18
+ export type PairClaimResult = z.infer<typeof PairClaimResult>;
19
+
20
+ export const PAIR_START_PATH = '/api/auth/pair/start';
21
+ export const PAIR_CLAIM_PATH = '/api/auth/pair/claim';
22
+
23
+ /**
24
+ * 授权只能回到回环地址:studio 是本机进程。站点和 studio 两边都用这一个判断——
25
+ * 任何网页都能把人带到 #/connect,能拿走身份的只有本机。
26
+ */
27
+ export function isLoopbackUrl(raw: string): boolean {
28
+ try {
29
+ const u = new URL(raw);
30
+ return u.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(u.hostname);
31
+ } catch { return false; }
32
+ }
@@ -7,6 +7,7 @@
7
7
  import { ensureHostFonts } from 'gamekit777/dev-host';
8
8
  import { api, type Info } from './lib/api.ts';
9
9
  import { bus } from './lib/bus.svelte.ts';
10
+ import { identity } from './lib/identity.svelte.ts';
10
11
  import PreviewPanel from './panels/PreviewPanel.svelte';
11
12
  import TuningPanel from './panels/TuningPanel.svelte';
12
13
  import TablePanel from './panels/TablePanel.svelte';
@@ -26,7 +27,11 @@
26
27
  let err = $state<string | null>(null);
27
28
  const refresh = async () => { try { info = await api.info(); err = null; } catch (e) { err = e instanceof Error ? e.message : String(e); } };
28
29
 
29
- onMount(() => { ensureHostFonts(); bus.connect(); void refresh(); });
30
+ /* 先拿 info(里面有站点地址),再验身份:没有 cookie 就整页跳去站点借,带 code 回来时 boot 会先换会话 */
31
+ onMount(() => {
32
+ ensureHostFonts(); bus.connect();
33
+ void refresh().then(() => { if (info) void identity.boot(info.urls.site); });
34
+ });
30
35
  $effect(() => { void bus.fingerprint; void bus.buildVersion; void refresh(); });
31
36
 
32
37
  const name = $derived(info ? info.snapshot.spec.title : '…');
@@ -44,6 +49,7 @@
44
49
  <span class="chip" class:on={bus.preview?.connected}>预览 {bus.preview?.connected ? '已连' : '未连'}</span>
45
50
  <span class="chip" class:on={bus.connected}>总线 {bus.connected ? '已连' : '断开'}</span>
46
51
  {#if running}<span class="chip busy">{running} 个任务运行中</span>{/if}
52
+ {#if identity.me}<span class="chip on" title={identity.me.user.id}>{identity.me.user.isAnonymous ? '游客' : identity.me.user.name ?? '玩家'} {identity.me.user.id.slice(0, 6)}…</span>{/if}
47
53
  {/if}
48
54
  {#if err}<span class="chip bad">{err}</span>{/if}
49
55
  <div class="grow"></div>
@@ -64,7 +70,15 @@
64
70
 
65
71
  <!-- 右:编辑区,拉满剩余宽度 -->
66
72
  <section class="editor">
67
- {#if info}
73
+ {#if identity.phase === 'redirecting' || identity.phase === 'claiming'}
74
+ <p class="dim">{identity.phase === 'claiming' ? '正在用站点签发的授权码建立会话…' : `没有身份,正在跳到 ${info?.urls.site ?? '站点'} 借一个…`}</p>
75
+ {:else if identity.phase === 'error'}
76
+ <div class="card">
77
+ <h2>身份没接上</h2>
78
+ <p class="bad">{identity.error}</p>
79
+ <div><button class="pri" onclick={() => info && identity.redirect(info.urls.site)}>再去站点授权一次</button></div>
80
+ </div>
81
+ {:else if info}
68
82
  {#if tab === 'preview'}<PreviewPanel {info} />{/if}
69
83
  {#if tab === 'tuning'}<TuningPanel {info} />{/if}
70
84
  {#if tab === 'table'}<TablePanel {info} />{/if}
@@ -0,0 +1,55 @@
1
+ /* studio 页面的身份:始终是**站点上的那个人**,不在这里另建。
2
+ *
3
+ * 页面对 /v1 的请求同源,cookie 落在 127.0.0.1 下;第一次没有 cookie 时把整个窗口
4
+ * 跳到站点的 #/connect 借身份,站点签一个一次性 code 带回 /__studio/connect,这里拿 code
5
+ * 经反代换一个同一用户的新会话。之后发布、下注都用这份 cookie。 */
6
+ import type { SessionView } from 'gamekit777/protocol';
7
+ import { client } from './upload.ts';
8
+
9
+ export type IdentityPhase = 'checking' | 'ok' | 'redirecting' | 'claiming' | 'error';
10
+
11
+ class Identity {
12
+ phase = $state<IdentityPhase>('checking');
13
+ me = $state<SessionView | null>(null);
14
+ error = $state<string | null>(null);
15
+
16
+ connectUrl(site: string): string {
17
+ const back = `${location.origin}/__studio/connect`;
18
+ return `${site.replace(/\/+$/, '')}/#/connect?return=${encodeURIComponent(back)}`;
19
+ }
20
+
21
+ /** 页面启动:/__studio/connect 带 code 就先换会话,然后一律 GET /v1/me */
22
+ async boot(site: string): Promise<void> {
23
+ const code = new URLSearchParams(location.search).get('code');
24
+ if (location.pathname.endsWith('/connect') && code) {
25
+ this.phase = 'claiming';
26
+ try {
27
+ await client.pair.claim(code);
28
+ // 去掉地址栏里的 code:刷新不该再换一次(code 已作废,会报错)
29
+ history.replaceState(null, '', '/__studio/');
30
+ } catch (e) {
31
+ this.phase = 'error';
32
+ this.error = `换会话失败:${e instanceof Error ? e.message : String(e)}`;
33
+ return;
34
+ }
35
+ }
36
+ try {
37
+ this.me = await client.session.me();
38
+ this.phase = 'ok';
39
+ } catch {
40
+ this.redirect(site);
41
+ }
42
+ }
43
+
44
+ redirect(site: string): void {
45
+ this.phase = 'redirecting';
46
+ location.href = this.connectUrl(site);
47
+ }
48
+
49
+ async refresh(): Promise<void> {
50
+ try { this.me = await client.session.me(); this.phase = 'ok'; }
51
+ catch (e) { this.me = null; this.error = e instanceof Error ? e.message : String(e); }
52
+ }
53
+ }
54
+
55
+ export const identity = new Identity();
@@ -1,18 +1,17 @@
1
1
  <script lang="ts">
2
- /* 发布:身份是这个页面上登录的人(/v1 同源反代,cookie 由浏览器带)。
2
+ /* 发布:身份是从站点借来的那个人(见 lib/identity.svelte.ts;/v1 同源反代,cookie 由浏览器带)。
3
3
  node 侧只排队和记录;MCP 发起的请求也落在这里,作者点确认才真传。发布不可逆。 */
4
4
  import { onMount } from 'svelte';
5
5
  import type { GameModule } from 'gamekit777/sdk';
6
- import type { SessionView } from 'gamekit777/protocol';
7
6
  import game from 'virtual:studio/game';
8
7
  import { api, type Info } from '../lib/api.ts';
9
8
  import { bus } from '../lib/bus.svelte.ts';
10
- import { client, planFromArtifacts, runUpload } from '../lib/upload.ts';
9
+ import { identity } from '../lib/identity.svelte.ts';
10
+ import { planFromArtifacts, runUpload } from '../lib/upload.ts';
11
11
 
12
12
  let { info }: { info: Info } = $props();
13
13
  const spec = $derived(info.snapshot.spec);
14
- let me = $state<SessionView | null>(null);
15
- let meErr = $state<string | null>(null);
14
+ const me = $derived(identity.me);
16
15
  let note = $state('');
17
16
  let running = $state<string | null>(null);
18
17
  let log = $state<string[]>([]);
@@ -20,15 +19,7 @@
20
19
  const reqs = $derived(Object.values(bus.publish).sort((a, b) => b.createdAt - a.createdAt));
21
20
  const pct = (ppm: number) => `${(ppm / 1e4).toFixed(4)}%`;
22
21
 
23
- async function whoami() {
24
- try { me = await client.session.me(); meErr = null; }
25
- catch (e) { me = null; meErr = e instanceof Error ? e.message : String(e); }
26
- }
27
- async function anon() {
28
- try { me = await client.session.bootstrap(); meErr = null; }
29
- catch (e) { meErr = e instanceof Error ? e.message : String(e); }
30
- }
31
- onMount(() => { void whoami(); void api.publishList().then((l) => { for (const r of l) bus.publish = { ...bus.publish, [r.id]: r }; }); });
22
+ onMount(() => { void identity.refresh(); void api.publishList().then((l) => { for (const r of l) bus.publish = { ...bus.publish, [r.id]: r }; }); });
32
23
 
33
24
  const lowest = $derived(info.artifacts.reduce<number | null>((m, a) => (m === null || a.computedRtpPpm < m ? a.computedRtpPpm : m), null));
34
25
  const localIssues = $derived.by(() => {
@@ -68,11 +59,12 @@
68
59
  <div class="card">
69
60
  <h2>身份</h2>
70
61
  {#if me}
71
- <p>{me.user.isAnonymous ? '匿名用户' : me.user.id} <span class="mono hint">{me.user.id.slice(0, 8)}…</span> · 余额 {me.wallet.balanceCents}</p>
72
- <p class="hint">发布出来的游戏归这个身份。cookie 不分端口——它和 apps/web 在 localhost 上共享。</p>
62
+ <p>{me.user.isAnonymous ? '游客' : me.user.name ?? '玩家'} <span class="mono hint">{me.user.id.slice(0, 8)}…</span> · 余额 {me.wallet.balanceCents}</p>
63
+ <p class="hint">这就是你在 {info.urls.site} 上的身份,发布出来的游戏归它。{#if me.user.isAnonymous}匿名身份换浏览器就丢了——要长期用,先在站点上登录,再回来重新连接。{/if}</p>
64
+ <button class="sec" onclick={() => identity.redirect(info.urls.site)}>换个身份 / 重新连接</button>
73
65
  {:else}
74
- <p class="hint">{meErr ?? '未登录'}</p>
75
- <button class="sec" onclick={anon}>创建匿名会话</button>
66
+ <p class="hint">{identity.error ?? '没有身份'}</p>
67
+ <button class="sec" onclick={() => identity.redirect(info.urls.site)}>去站点授权</button>
76
68
  {/if}
77
69
  </div>
78
70
 
package/studio/bin.ts CHANGED
@@ -32,8 +32,8 @@ try {
32
32
  process.exit(1);
33
33
  }
34
34
 
35
- const backendUrl = process.env.GAMEKIT_BASE_URL ?? 'http://127.0.0.1:8787';
36
- const placeholder = { studio: '', preview: '' };
35
+ const backendUrl = cfg.backendUrl;
36
+ const placeholder = { studio: '', preview: '', backend: cfg.backendUrl, site: cfg.siteUrl };
37
37
  const engine = new Engine(cfg.gameDir, placeholder);
38
38
 
39
39
  const server = await startGameServer({
@@ -54,6 +54,7 @@ GameKit Studio${mcpMode ? '(MCP over stdio)' : ''}
54
54
  页面 ${placeholder.studio}
55
55
  预览 ${placeholder.preview}
56
56
  后端 ${backendUrl} → /v1
57
+ 站点 ${cfg.siteUrl} (没有身份时去这里借)
57
58
  ${mcpMode ? '' : `
58
59
  接 AI:claude mcp add gamekit -- bunx gamekit777 studio
59
60
  `}`);
package/studio/cli.ts CHANGED
@@ -1,16 +1,16 @@
1
1
  #!/usr/bin/env bun
2
2
  /* `gamekit777` 这个命令的入口:按子命令分派。
3
3
  *
4
- * gamekit777 studio [dir] [--mcp|--no-mcp] [--game-port N] 起工作台(MCP 走 stdio)
5
- * gamekit777 new <id> [标题] [--dest 目录] 脚手架一个新游戏并装依赖
4
+ * gamekit777 studio [dir] [--mcp|--no-mcp] [--game-port N] [--backend URL] 起工作台(MCP 走 stdio)
5
+ * gamekit777 new 在当前目录脚手架一个新游戏(my-game)并装依赖;改名改 game.meta.json
6
6
  *
7
7
  * 只是薄薄一层分派,逻辑各在 bin.ts 和 src/scaffold.ts。 */
8
8
  const [sub, ...rest] = process.argv.slice(2);
9
9
 
10
10
  const usage = (): never => {
11
11
  process.stderr.write(`用法:
12
- gamekit777 studio [dir] [--mcp|--no-mcp] [--game-port N]
13
- gamekit777 new <id> [标题] [--dest 目录]
12
+ gamekit777 studio [dir] [--mcp|--no-mcp] [--game-port N] [--backend URL]
13
+ gamekit777 new 在当前目录生成 my-game(占了加序号)并装依赖;名字之后改 game.meta.json
14
14
 
15
15
  接 Claude Code:在游戏目录里
16
16
  claude mcp add gamekit -- bunx gamekit777 studio
@@ -25,13 +25,11 @@ switch (sub) {
25
25
  break;
26
26
  }
27
27
  case 'new': {
28
+ // 刻意不收参数:名字、标题都在生成后的 game.meta.json 里改,少一层「命令行参数 vs 配置文件」的分叉
29
+ if (rest.length > 0) usage();
28
30
  const { scaffold } = await import('./src/scaffold.ts');
29
- const destAt = rest.indexOf('--dest');
30
- const dest = destAt >= 0 ? rest.splice(destAt, 2)[1] : undefined;
31
- const [id, title] = rest;
32
- if (!id) usage();
33
31
  try {
34
- const r = await scaffold({ id: id!, title, dest }, process.cwd());
32
+ const r = await scaffold({}, process.cwd());
35
33
  process.stdout.write(`✓ ${r.dest}\n\n${r.next.map((n) => ` ${n}`).join('\n')}\n\n cd ${r.dest} && bunx gamekit777 studio\n`);
36
34
  } catch (e) {
37
35
  process.stderr.write(`✗ ${e instanceof Error ? e.message : String(e)}\n`);
@@ -13,18 +13,32 @@ export interface StudioConfig {
13
13
  studioRoot: string;
14
14
  /** 游戏 vite dev server 的端口 */
15
15
  gamePort: number;
16
+ /** 平台后端。/v1 /api 反代到这里,发布用的身份也落在这条链路上 */
17
+ backendUrl: string;
18
+ /** 平台站点。没有身份时跳去这里的 #/connect 借身份;线上和后端是同一个域名,仓库内是 5180 */
19
+ siteUrl: string;
16
20
  }
17
21
 
22
+ /**
23
+ * 创作者装的是 npm 包,机器上没有本地后端,默认就该指到平台本身。
24
+ * 仓库内开发用 GAMEKIT_BASE_URL=http://127.0.0.1:8787(根 package.json 的 studio 脚本已带上)。
25
+ */
26
+ export const DEFAULT_BACKEND_URL = 'https://rtp.citdot.com';
27
+
18
28
  const STUDIO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
19
29
 
20
30
  export function loadConfig(argv: readonly string[]): StudioConfig {
21
31
  const rest = [...argv];
22
32
  let dir: string | undefined;
23
33
  let gamePort = 4301;
34
+ let backendUrl = process.env.GAMEKIT_BASE_URL ?? DEFAULT_BACKEND_URL;
35
+ let siteUrl = process.env.GAMEKIT_SITE_URL ?? '';
24
36
 
25
37
  while (rest.length > 0) {
26
38
  const k = rest.shift()!;
27
39
  if (k === '--game-port') gamePort = Number(rest.shift());
40
+ else if (k === '--backend') backendUrl = rest.shift() ?? backendUrl;
41
+ else if (k === '--site') siteUrl = rest.shift() ?? siteUrl;
28
42
  else if (k.startsWith('-')) throw new Error(`不认识的参数 ${k}`);
29
43
  else dir = k;
30
44
  }
@@ -35,5 +49,6 @@ export function loadConfig(argv: readonly string[]): StudioConfig {
35
49
  throw new Error(`${gameDir} 下没有 ${need}——这不像一个游戏目录。在游戏目录里启动,或把目录作为参数传进来`);
36
50
  }
37
51
  }
38
- return { gameDir, studioRoot: STUDIO_ROOT, gamePort };
52
+ backendUrl = backendUrl.replace(/\/+$/, '');
53
+ return { gameDir, studioRoot: STUDIO_ROOT, gamePort, backendUrl, siteUrl: (siteUrl || backendUrl).replace(/\/+$/, '') };
39
54
  }
@@ -43,7 +43,7 @@ export class Engine {
43
43
  readonly bus = new Bus();
44
44
  readonly jobs: Jobs;
45
45
  readonly cases: CaseStore;
46
- readonly urls: { studio: string; preview: string };
46
+ readonly urls: { studio: string; preview: string; backend: string; site: string };
47
47
  preview: PreviewState = {
48
48
  connected: false, config: null, mode: null, bet: null, balance: null,
49
49
  status: null, lastError: null, recent: [], consoleErrors: [], updatedAt: 0,
@@ -51,7 +51,7 @@ export class Engine {
51
51
  #publish = new Map<string, PublishRequest>();
52
52
  #lastFingerprint: string;
53
53
 
54
- constructor(gameDir: string, urls: { studio: string; preview: string }) {
54
+ constructor(gameDir: string, urls: Engine['urls']) {
55
55
  this.game = new Game(gameDir);
56
56
  this.urls = urls;
57
57
  this.#lastFingerprint = this.game.fingerprint;
@@ -5,7 +5,7 @@
5
5
  * 不存在「studio 里演得好好的,真 build 出来不一样」的分叉空间。 */
6
6
  import { existsSync, readFileSync, realpathSync } from 'node:fs';
7
7
  import { join, resolve } from 'node:path';
8
- import { createLogger, createServer, searchForWorkspaceRoot, type Logger, type ViteDevServer } from 'vite';
8
+ import { createLogger, createServer, searchForWorkspaceRoot, type Logger, type ProxyOptions, type ViteDevServer } from 'vite';
9
9
  import type { Engine } from './engine.ts';
10
10
  import { studioPlugin } from './studio-plugin.ts';
11
11
 
@@ -54,6 +54,27 @@ function stderrLogger(): Logger {
54
54
  return { ...base, info: w, warn: w, warnOnce: w, error: w, clearScreen: () => {} };
55
55
  }
56
56
 
57
+ /**
58
+ * 反代到平台后端。两个头都要改写:
59
+ * Host → 平台域名。后端在 Cloudflare 后面按 Host 路由,带着 127.0.0.1:4301 过去会被拒
60
+ * Origin → 平台自己的 origin。better-auth 对建会话的 POST 做 Origin 校验,
61
+ * 创作者机器上的 127.0.0.1:随机端口不可能预先写进 TRUSTED_ORIGINS
62
+ * 这么做不削弱 CSRF 防护:能打到这个反代的只有同源的 studio 页面本身。
63
+ */
64
+ function proxyRule(backendUrl: string): ProxyOptions {
65
+ const origin = new URL(backendUrl).origin;
66
+ return {
67
+ target: backendUrl,
68
+ changeOrigin: true,
69
+ configure(proxy) {
70
+ proxy.on('proxyReq', (req) => {
71
+ if (req.getHeader('origin')) req.setHeader('origin', origin);
72
+ req.removeHeader('referer');
73
+ });
74
+ },
75
+ };
76
+ }
77
+
57
78
  export async function startGameServer(o: GameServerOptions): Promise<ViteDevServer> {
58
79
  const server = await createServer({
59
80
  root: o.gameDir,
@@ -75,8 +96,8 @@ export async function startGameServer(o: GameServerOptions): Promise<ViteDevServ
75
96
  fs: { allow: allowList(o) },
76
97
  /* 同源代理到平台后端:cookie 才带得上。发布时用的就是这份身份 */
77
98
  proxy: {
78
- '/v1': { target: o.backendUrl, changeOrigin: false },
79
- '/api': { target: o.backendUrl, changeOrigin: false },
99
+ '/v1': proxyRule(o.backendUrl),
100
+ '/api': proxyRule(o.backendUrl),
80
101
  },
81
102
  },
82
103
  /* 预览壳和游戏各自 import svelte,物理上可能是两份(studio 的和游戏的 node_modules)。
package/studio/src/mcp.ts CHANGED
@@ -142,15 +142,11 @@ export function createMcpServer(engine: Engine): McpServer {
142
142
  });
143
143
 
144
144
  s.registerTool('scaffold', {
145
- description: '在当前目录下新建一个游戏(一个能跑的抛硬币),写好 package.json 并 bun install。之后请在那个目录里重启 studio。',
146
- inputSchema: {
147
- id: z.string().describe('小写短横线形式,同时是包名、目录名和 CSS 命名空间'),
148
- title: z.string().optional(),
149
- dest: z.string().optional().describe('目标目录,默认 <cwd>/<id>'),
150
- },
151
- }, async (a) => {
145
+ description: '在当前目录下新建一个游戏(my-game,占了加序号;一个能跑的抛硬币),写好 package.json 并 bun install。名字之后改 game.meta.json。请在那个目录里重启 studio。',
146
+ inputSchema: {},
147
+ }, async () => {
152
148
  try {
153
- const r = await scaffold({ id: a.id, title: a.title, dest: a.dest }, process.cwd());
149
+ const r = await scaffold({}, process.cwd());
154
150
  return out(`## ✅ 已生成 ${r.dest}\n\n${r.files} 个文件,依赖${r.installed ? '已装' : '未装'}。\n\n${r.next.map((n) => `- ${n}`).join('\n')}`, r);
155
151
  } catch (e) { return fail(e instanceof Error ? e.message : String(e)); }
156
152
  });
@@ -7,8 +7,18 @@ import { existsSync, readFileSync } from 'node:fs';
7
7
  import { join } from 'node:path';
8
8
  import { scaffoldGame, ID_PATTERN } from 'gamekit777/create-game';
9
9
 
10
+ export const DEFAULT_ID = 'my-game';
11
+ export const DEFAULT_TITLE = '我的游戏';
12
+
13
+ /** 不给 id 就叫 my-game;占了就 my-game-2、my-game-3……创作者改名只需要动 slug 和目录名 */
14
+ export function defaultId(cwd: string): string {
15
+ if (!existsSync(join(cwd, DEFAULT_ID))) return DEFAULT_ID;
16
+ for (let n = 2; ; n++) if (!existsSync(join(cwd, `${DEFAULT_ID}-${n}`))) return `${DEFAULT_ID}-${n}`;
17
+ }
18
+
10
19
  export interface ScaffoldRequest {
11
- id: string;
20
+ /** 不给就是 my-game(占了就加序号) */
21
+ id?: string;
12
22
  title?: string;
13
23
  /** 目标目录。默认 <cwd>/<id> */
14
24
  dest?: string;
@@ -61,9 +71,10 @@ function defaultSpec(dest: string): string {
61
71
  }
62
72
 
63
73
  export async function scaffold(req: ScaffoldRequest, cwd: string): Promise<ScaffoldResult> {
64
- if (!ID_PATTERN.test(req.id)) throw new Error(`id "${req.id}" 要是小写短横线形式`);
65
- const dest = req.dest ?? join(cwd, req.id);
66
- const r = scaffoldGame({ id: req.id, title: req.title, dest, gamekitSpec: req.gamekitSpec ?? defaultSpec(dest) });
74
+ const id = req.id ?? defaultId(cwd);
75
+ if (!ID_PATTERN.test(id)) throw new Error(`id "${id}" 要是小写短横线形式`);
76
+ const dest = req.dest ?? join(cwd, id);
77
+ const r = scaffoldGame({ id, title: req.title ?? DEFAULT_TITLE, dest, gamekitSpec: req.gamekitSpec ?? defaultSpec(dest) });
67
78
 
68
79
  let installed = false, installOutput = '';
69
80
  if (!req.skipInstall) {
@@ -75,7 +86,7 @@ export async function scaffold(req: ScaffoldRequest, cwd: string): Promise<Scaff
75
86
  return {
76
87
  dest, files: r.files.length, installed, installOutput,
77
88
  next: [
78
- `游戏在 ${dest},是一个能跑的抛硬币。从能跑的东西开始改。`,
89
+ `游戏在 ${dest},是一个能跑的抛硬币。从能跑的东西开始改;名字和标题在 game.meta.json 里改。`,
79
90
  `先读 ${join(dest, 'CLAUDE.md')}:清单字段、round() 的硬约束、modes/book/restore 怎么选、演出侧约定。`,
80
91
  '改 src/rules/ 下的纯逻辑(零 DOM、零自取随机),改 src/components/ 下的演出。',
81
92
  '已发布过的 mode 解码逻辑不能改,要改就换 slug;结果漂移由 studio/cases.jsonl 兜。',
@@ -20,6 +20,8 @@ export interface StudioPluginOptions {
20
20
  export const VIRTUAL_GAME = 'virtual:studio/game';
21
21
  export const PREVIEW_PATH = '/__studio/preview.html';
22
22
  export const PAGE_PATH = '/__studio/';
23
+ /** 站点授权完带 code 回到这里,页面拿 code 换会话 */
24
+ export const CONNECT_PATH = '/__studio/connect';
23
25
 
24
26
  const page = (title: string, entry: string): string => `<!doctype html>
25
27
  <html lang="zh-CN">
@@ -52,7 +54,7 @@ export function studioPlugin(o: StudioPluginOptions): Plugin {
52
54
  server.middlewares.use((req, res, next) => {
53
55
  const path = req.url?.split('?')[0];
54
56
  const html = path === PREVIEW_PATH ? previewHtml
55
- : path === PAGE_PATH || path === '/__studio' || path === '/__studio/index.html' ? appHtml
57
+ : path === PAGE_PATH || path === '/__studio' || path === '/__studio/index.html' || path === CONNECT_PATH ? appHtml
56
58
  : null;
57
59
  if (!html) return next();
58
60
  void server.transformIndexHtml(req.url!, html).then((out) => {