gamekit777 0.1.2 → 0.1.5

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 (55) hide show
  1. package/README.md +1 -1
  2. package/client/http.ts +1 -1
  3. package/client/index.ts +26 -0
  4. package/create-game/template/CLAUDE.md.tmpl +38 -39
  5. package/create-game/template/src/components/App.svelte.tmpl +25 -7
  6. package/create-game/template/src/index.ts.tmpl +5 -3
  7. package/create-game/template/src/state/game.svelte.ts.tmpl +2 -0
  8. package/create-game/template/src/styles/animations.css +10 -1
  9. package/create-game/template/src/view/mount.svelte.ts.tmpl +1 -22
  10. package/create-game/template/src/view/present.ts.tmpl +9 -0
  11. package/create-game/template/vitest.config.ts +1 -1
  12. package/dev-host/DevShell.svelte +23 -2
  13. package/dev-host/platform.svelte.ts +4 -1
  14. package/dev-host/theme.css +5 -3
  15. package/package.json +1 -1
  16. package/protocol/assets.ts +108 -0
  17. package/protocol/bundles.ts +74 -0
  18. package/protocol/errors.ts +6 -0
  19. package/protocol/games.ts +5 -0
  20. package/protocol/index.ts +2 -0
  21. package/publish/upload-core.ts +95 -15
  22. package/publish/upload.ts +17 -1
  23. package/runtime/assets.ts +19 -15
  24. package/runtime/fonts.ts +1 -0
  25. package/runtime/index.ts +2 -0
  26. package/runtime/preload.ts +38 -0
  27. package/runtime/registry.ts +74 -0
  28. package/runtime/types.ts +3 -25
  29. package/sdk/assets.ts +24 -0
  30. package/sdk/host.ts +0 -2
  31. package/sdk/index.ts +1 -0
  32. package/sdk/spec.ts +3 -0
  33. package/studio/app/App.svelte +43 -14
  34. package/studio/app/lib/api.ts +11 -3
  35. package/studio/app/lib/bus.svelte.ts +2 -0
  36. package/studio/app/lib/identity.svelte.ts +4 -1
  37. package/studio/app/panels/AssetsPanel.svelte +239 -0
  38. package/studio/app/panels/PublishPanel.svelte +44 -51
  39. package/studio/bin.ts +2 -0
  40. package/studio/src/api.ts +20 -7
  41. package/studio/src/assets.ts +160 -0
  42. package/studio/src/bus.ts +2 -1
  43. package/studio/src/engine.ts +237 -18
  44. package/studio/src/game-vite.ts +11 -2
  45. package/studio/src/mcp.ts +68 -28
  46. package/studio/src/session.ts +37 -0
  47. package/studio/src/studio-plugin.ts +16 -0
  48. package/studio/src/tasks.ts +4 -1
  49. package/vite-config/index.js +160 -35
  50. package/vite-config/index.ts +5 -1
  51. package/vite-config/manifest.ts +16 -2
  52. package/vite-config/namespace-css.ts +130 -43
  53. package/create-game/template/src/assets/manifest.ts +0 -30
  54. package/create-game/template/src/assets/registry.ts +0 -23
  55. package/studio/app/lib/upload.ts +0 -34
@@ -2,7 +2,9 @@
2
2
  *
3
3
  * 状态只有两处:长任务(Jobs)和总线(Bus)。游戏本身的状态在 Game 里按指纹缓存,
4
4
  * 预览的状态在浏览器里,node 侧只留一份最近上报的快照给 AI 读。 */
5
- import { join } from 'node:path';
5
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
6
+ import { join, relative } from 'node:path';
7
+ import { Sha256 } from 'gamekit777/sdk/hash';
6
8
  import type { Segment } from 'gamekit777/weights';
7
9
  import { Bus } from './bus.ts';
8
10
  import { buildMode, groupsSummary, listArtifacts, readArtifactBytes, sampleMode, type Artifact, type BuildModeOptions } from './build.ts';
@@ -10,8 +12,14 @@ import { CaseStore, captureCase, runCases, type CaseRecord, type RunCasesOptions
10
12
  import { Game, type Config } from './game.ts';
11
13
  import { Jobs, type Job } from './jobs.ts';
12
14
  import { probe, type ProbeOptions, type ProbeResult } from './probe.ts';
15
+ import { AssetStore, type AssetSyncResult } from './assets.ts';
16
+ import { hasSession, saveSession, sessionFile } from './session.ts';
13
17
  import { runSolve, type Targets } from './solver.ts';
14
18
  import { runTask, type TaskName, type TaskResult } from './tasks.ts';
19
+ import { createClient, type GameKitClient } from 'gamekit777/client';
20
+ import { fileCookieStore } from 'gamekit777/client/node';
21
+ import { ASSET_INDEX_PATH, GameKitError } from 'gamekit777/protocol';
22
+ import { uploadTables, type UploadTable } from 'gamekit777/publish/upload-core';
15
23
 
16
24
  export interface PreviewState {
17
25
  connected: boolean;
@@ -26,24 +34,55 @@ export interface PreviewState {
26
34
  updatedAt: number;
27
35
  }
28
36
 
37
+ /** 一次发布的记录。页面的「发布」面板按它显示历史与进度 */
29
38
  export interface PublishRequest {
30
39
  id: string;
40
+ jobId: string;
31
41
  modes: string[] | null;
32
42
  note: string | null;
33
43
  from: 'mcp' | 'page';
34
44
  createdAt: number;
35
- status: 'awaiting-approval' | 'running' | 'published' | 'rejected' | 'failed';
45
+ status: 'running' | 'published' | 'failed';
36
46
  progress: string[];
37
- result: unknown;
47
+ result: PublishResult | null;
38
48
  error: string | null;
39
49
  }
40
50
 
51
+ export interface PublishResult {
52
+ gameId: string;
53
+ slug: string;
54
+ verifiedRtpPpm: number | null;
55
+ /** 这次新发布的 mode。已生效的被跳过,不在这里 */
56
+ modes: string[];
57
+ bundle: string | null;
58
+ playUrl: string;
59
+ }
60
+
61
+ export interface PublishPreflight {
62
+ ok: boolean;
63
+ issues: string[];
64
+ warnings: string[];
65
+ artifacts: { mode: string; computedRtpPpm: number; rows: number }[];
66
+ session: boolean;
67
+ remote: {
68
+ user: string | null;
69
+ gameId: string | null;
70
+ game: { id: string; slug: string; status: string; liveModes: string[] } | null;
71
+ /** 这次会真正发布的 mode(去掉已生效的) */
72
+ willPublish: string[];
73
+ } | null;
74
+ }
75
+
41
76
  export class Engine {
42
77
  readonly game: Game;
43
78
  readonly bus = new Bus();
44
79
  readonly jobs: Jobs;
45
80
  readonly cases: CaseStore;
46
81
  readonly urls: { studio: string; preview: string; backend: string; site: string };
82
+ /** node 侧记住的站点身份(反代从页面的 /v1/me 抄来的 cookie),MCP 发布用 */
83
+ readonly sessionPath: string;
84
+ /** 平台资源库(动画 + 套路)在本机的副本:后台拉、给页面预览、给 AI 查看、复制进游戏 */
85
+ readonly assets: AssetStore;
47
86
  preview: PreviewState = {
48
87
  connected: false, config: null, mode: null, bet: null, balance: null,
49
88
  status: null, lastError: null, recent: [], consoleErrors: [], updatedAt: 0,
@@ -54,6 +93,11 @@ export class Engine {
54
93
  constructor(gameDir: string, urls: Engine['urls']) {
55
94
  this.game = new Game(gameDir);
56
95
  this.urls = urls;
96
+ this.sessionPath = sessionFile(urls.backend);
97
+ this.assets = new AssetStore(urls.backend, gameDir, (r: AssetSyncResult) => {
98
+ this.bus.emit({ t: 'assets', added: r.added, removed: r.removed, total: r.total });
99
+ this.bus.log('info', `资源库同步:${r.total} 条${r.added.length ? `,新增/更新 ${r.added.join('、')}` : ''}${r.removed.length ? `,移除 ${r.removed.join('、')}` : ''}`);
100
+ });
57
101
  this.#lastFingerprint = this.game.fingerprint;
58
102
  this.jobs = new Jobs((job, msg) => this.bus.emit({ t: 'job', id: job.id, kind: job.kind, status: job.status, msg }));
59
103
  this.cases = new CaseStore(gameDir, () => this.bus.emit({ t: 'cases', changed: true }));
@@ -72,7 +116,8 @@ export class Engine {
72
116
  gameDir: string; fingerprint: string; packageName: string;
73
117
  snapshot: Awaited<ReturnType<Game['snapshot']>>;
74
118
  urls: Engine['urls']; previewConnected: boolean; artifacts: Artifact[]; cases: number;
75
- caseFile: string;
119
+ caseFile: string; gameId: string | null; session: boolean;
120
+ assets: { count: number; syncedAt: number; dir: string };
76
121
  }> {
77
122
  this.touch();
78
123
  return {
@@ -81,9 +126,18 @@ export class Engine {
81
126
  previewConnected: this.bus.previewConnected,
82
127
  artifacts: listArtifacts(this.game), cases: this.cases.list().length,
83
128
  caseFile: this.cases.path,
129
+ gameId: this.meta().gameId, session: this.sessionPresent(),
130
+ assets: { count: this.assets.list().length, syncedAt: this.assets.syncedAt, dir: this.assets.dir },
84
131
  };
85
132
  }
86
133
 
134
+ /** 启动后在后台拉一次资源库,不阻塞任何东西;失败只记日志 */
135
+ syncAssetsInBackground(): void {
136
+ void this.assets.sync().then((r) => {
137
+ if (r === null) this.bus.log('warn', `资源库拉不到(${this.urls.backend}${ASSET_INDEX_PATH})。离线或平台还没发过库;本地已有的照常可用`);
138
+ }).catch((e: unknown) => this.bus.log('error', `资源库同步失败:${e instanceof Error ? e.message : String(e)}`));
139
+ }
140
+
87
141
  /* ── 探测 / 采样 / 求解 / 建表 ── */
88
142
 
89
143
  probe(o: ProbeOptions = {}): Job<ProbeResult> {
@@ -175,6 +229,30 @@ export class Engine {
175
229
  return runCases(this.game, this.cases, o);
176
230
  }
177
231
 
232
+ /* ── 游戏包:vite build 的产物。页面拿它连表一起传 ── */
233
+
234
+ bundleFiles(): { path: string; bytes: number; sha256: string }[] {
235
+ const dist = join(this.game.dir, 'dist');
236
+ if (!existsSync(join(dist, 'index.js'))) return [];
237
+ const out: { path: string; bytes: number; sha256: string }[] = [];
238
+ const walk = (d: string): void => {
239
+ for (const n of readdirSync(d).sort()) {
240
+ const p = join(d, n);
241
+ if (statSync(p).isDirectory()) walk(p);
242
+ else { const b = readFileSync(p); out.push({ path: relative(dist, p).split('\\').join('/'), bytes: b.length, sha256: new Sha256().update(new Uint8Array(b)).hex() }); }
243
+ }
244
+ };
245
+ walk(dist);
246
+ return out;
247
+ }
248
+
249
+ bundleFile(path: string): Uint8Array | null {
250
+ const dist = join(this.game.dir, 'dist');
251
+ const full = join(dist, path);
252
+ if (!full.startsWith(dist) || !existsSync(full) || statSync(full).isDirectory()) return null;
253
+ return new Uint8Array(readFileSync(full));
254
+ }
255
+
178
256
  /* ── 预览 ── */
179
257
 
180
258
  reportPreview(state: Partial<PreviewState>): void {
@@ -187,24 +265,165 @@ export class Engine {
187
265
  return this.bus.command<T>(cmd, timeoutMs);
188
266
  }
189
267
 
190
- /* ── 发布:node 侧只管排队和记录,上传由页面用浏览器身份做 ── */
268
+ /* ── 发布:node 侧一键跑完,不等人点。身份是反代从页面抄下来的那份站点 cookie ── */
269
+
270
+ rememberSession(cookieHeader: string): void { saveSession(this.sessionPath, cookieHeader); }
271
+ sessionPresent(): boolean { return hasSession(this.sessionPath); }
272
+
273
+ /** game.meta.json:游戏在平台上的身份是里面的 gameId(第一次发布后由这里写回),不是名字 */
274
+ meta(): { path: string; data: Record<string, unknown> | null; gameId: string | null } {
275
+ const path = join(this.game.dir, 'game.meta.json');
276
+ try {
277
+ const data = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>;
278
+ return { path, data, gameId: typeof data.gameId === 'string' && data.gameId ? data.gameId : null };
279
+ } catch { return { path, data: null, gameId: null }; }
280
+ }
281
+
282
+ #client(): GameKitClient {
283
+ return createClient({
284
+ baseUrl: this.urls.backend, cookies: 'manual', cookieStore: fileCookieStore(this.sessionPath),
285
+ headers: { origin: new URL(this.urls.backend).origin },
286
+ });
287
+ }
288
+
289
+ #noSession(): Error {
290
+ return new Error(`node 侧还没有站点身份。在浏览器打开 ${this.urls.studio},连接一次身份(没身份会自动跳到站点授权),之后 MCP 就能直接发布`);
291
+ }
191
292
 
192
- requestPublish(modes: string[] | null, note: string | null, from: PublishRequest['from']): PublishRequest {
193
- const req: PublishRequest = {
194
- id: `p${Date.now().toString(36)}`, modes, note, from, createdAt: Date.now(),
195
- status: 'awaiting-approval', progress: [], result: null, error: null,
293
+ async publishPreflight(modes: string[] | null): Promise<PublishPreflight> {
294
+ this.touch();
295
+ const snap = await this.game.snapshot();
296
+ const all = listArtifacts(this.game);
297
+ const arts = modes ? all.filter((t) => modes.includes(t.mode)) : all;
298
+ const declared = Math.round(snap.spec.rtp * 1e6);
299
+ const issues: string[] = [];
300
+ const warnings: string[] = [];
301
+ if (arts.length === 0) issues.push('当前指纹下没有任何已建的表——先 build_table');
302
+ const missing = snap.spec.modes.filter((m) => !arts.some((t) => t.mode === m.name)).map((m) => m.name);
303
+ if (missing.length && !modes) issues.push(`这些 mode 还没建表:${missing.join('、')}`);
304
+ if (modes) for (const m of modes) if (!all.some((t) => t.mode === m)) issues.push(`mode ${m} 没有已建的表`);
305
+ const lowest = arts.reduce<number | null>((m, t) => (m === null || t.computedRtpPpm < m ? t.computedRtpPpm : m), null);
306
+ if (lowest !== null && lowest + 100 < declared) issues.push(`游戏声明 RTP ${declared} ppm,但最低的表只有 ${lowest} ppm——大厅里的声明不能高于任何一个 mode 实际能给的,发布会被拒`);
307
+
308
+ const meta = this.meta();
309
+ let remote: PublishPreflight['remote'] = null;
310
+ const session = this.sessionPresent();
311
+ if (!session) {
312
+ issues.push(this.#noSession().message);
313
+ } else {
314
+ const api = this.#client();
315
+ try {
316
+ const me = await api.session.me();
317
+ let game: NonNullable<PublishPreflight['remote']>['game'] = null;
318
+ let willPublish = arts.map((t) => t.mode);
319
+ if (meta.gameId) {
320
+ const g = await api.games.get(meta.gameId).catch((e: unknown) => {
321
+ issues.push(e instanceof GameKitError && e.code === 'NOT_FOUND'
322
+ ? `game.meta.json 的 gameId ${meta.gameId} 在平台上不存在——删掉这个字段会新建`
323
+ : `取游戏 ${meta.gameId} 失败:${e instanceof Error ? e.message : String(e)}`);
324
+ return null;
325
+ });
326
+ if (g) {
327
+ if (g.ownerId !== me.user.id) issues.push(`游戏 ${g.id}(${g.slug})归另一个账号`);
328
+ if (g.status === 'published' && g.slug !== snap.spec.slug) issues.push(`已发布为 "${g.slug}",slug 不能改成 "${snap.spec.slug}"`);
329
+ const live = g.modes.map((m) => m.mode);
330
+ willPublish = willPublish.filter((m) => !live.includes(m));
331
+ if (willPublish.length === 0) warnings.push('所有选中的 mode 都已生效:这次只会更新客户端包');
332
+ game = { id: g.id, slug: g.slug, status: g.status, liveModes: live };
333
+ }
334
+ } else {
335
+ const mine = await api.games.list({ status: 'all', owner: 'me', limit: 50 });
336
+ const dup = mine.items.find((g) => g.slug === snap.spec.slug);
337
+ if (dup) issues.push(`你已经有一个 slug 为 "${snap.spec.slug}" 的游戏(${dup.id})。同一个项目就把它写进 game.meta.json 的 gameId;另一个项目就改 id`);
338
+ }
339
+ const taken = (await api.games.list({ status: 'published', limit: 50 })).items.find((g) => g.slug === snap.spec.slug && g.id !== meta.gameId);
340
+ if (taken) issues.push(`slug "${snap.spec.slug}" 已有别的已发布游戏在用(${taken.id}),发布会被拒。改 game.meta.json 的 id`);
341
+ remote = { user: me.user.id, gameId: meta.gameId, game, willPublish };
342
+ } catch (e) {
343
+ issues.push(`记住的身份已失效(${e instanceof Error ? e.message : String(e)})。在 studio 页面重新连接一次`);
344
+ }
345
+ }
346
+ return {
347
+ ok: issues.length === 0, issues, warnings, session, remote,
348
+ artifacts: arts.map((t) => ({ mode: t.mode, computedRtpPpm: t.computedRtpPpm, rows: t.rowCount })),
196
349
  };
197
- this.#publish.set(req.id, req);
198
- this.bus.emit({ t: 'publish', request: req });
199
- return req;
200
350
  }
201
351
 
202
- updatePublish(id: string, patch: Partial<PublishRequest>): PublishRequest | null {
203
- const req = this.#publish.get(id);
204
- if (!req) return null;
205
- Object.assign(req, patch);
206
- this.bus.emit({ t: 'publish', request: req });
207
- return req;
352
+ publish(modes: string[] | null, o: { note?: string | null; from: PublishRequest['from'] }): Job<PublishResult> {
353
+ this.touch();
354
+ const rec: PublishRequest = {
355
+ id: `p${Date.now().toString(36)}`, jobId: '', modes, note: o.note ?? null, from: o.from, createdAt: Date.now(),
356
+ status: 'running', progress: [], result: null, error: null,
357
+ };
358
+ this.#publish.set(rec.id, rec);
359
+ const emit = (): void => this.bus.emit({ t: 'publish', request: rec });
360
+ const job = this.jobs.start('publish', async (say0) => {
361
+ const say = (m: string): void => { say0(m); rec.progress.push(m); emit(); };
362
+ try {
363
+ const r = await this.#runPublish(modes, say);
364
+ rec.status = 'published'; rec.result = r; emit();
365
+ return r;
366
+ } catch (e) {
367
+ rec.status = 'failed'; rec.error = e instanceof Error ? e.message : String(e); emit();
368
+ throw e;
369
+ }
370
+ });
371
+ rec.jobId = job.id;
372
+ emit();
373
+ return job;
374
+ }
375
+
376
+ async #runPublish(modes: string[] | null, say: (m: string) => void): Promise<PublishResult> {
377
+ if (!this.sessionPresent()) throw this.#noSession();
378
+ const api = this.#client();
379
+ const me = await api.session.me().catch(() => {
380
+ throw new Error('记住的身份已失效(会话过期或在站点上退出了)。在 studio 页面重新连接一次');
381
+ });
382
+ say(`身份 ${me.user.isAnonymous ? '游客' : me.user.name ?? '玩家'} ${me.user.id.slice(0, 8)}…`);
383
+
384
+ const snap = await this.game.snapshot();
385
+ const all = listArtifacts(this.game);
386
+ const arts = modes ? all.filter((t) => modes.includes(t.mode)) : all;
387
+ if (arts.length === 0) throw new Error('当前指纹下没有已建的表——先 build_table');
388
+ const missing = (modes ?? []).filter((m) => !arts.some((t) => t.mode === m));
389
+ if (missing.length) throw new Error(`这些 mode 没有已建的表:${missing.join('、')}`);
390
+
391
+ /* 包:vite build 的产物。表是数学,包是演出,站点两样都要 */
392
+ say('构建游戏包(bun run build)…');
393
+ const built = await runTask(this.game.dir, 'build');
394
+ if (!built.ok) throw new Error(`vite build 失败:${built.issues.join(';') || built.output.split('\n').slice(-15).join('\n')}`);
395
+ const files = this.bundleFiles();
396
+ if (!files.length) throw new Error('dist/ 里没有 index.js——构建没产出游戏包');
397
+ const bundle = files.map((f) => ({ path: f.path, bytes: this.bundleFile(f.path)! }));
398
+ say(`游戏包 ${bundle.length} 个文件,${(bundle.reduce((n, f) => n + f.bytes.length, 0) / 1024).toFixed(0)} KB`);
399
+
400
+ /* 表:主进程没有游戏代码,赔率行从 worker 拿好带进去 */
401
+ const tables: UploadTable[] = [];
402
+ for (const a of arts) {
403
+ const mode = snap.spec.modes.find((m) => m.name === a.mode);
404
+ if (!mode) throw new Error(`产物里有 mode ${a.mode},但当前清单里没有——重新 build_table`);
405
+ const { table, books } = readArtifactBytes(a);
406
+ tables.push({
407
+ mode: a.mode, bytes: table, contentHash: a.contentHash, rowCount: a.rowCount, betCostCenti: a.betCostCenti,
408
+ books, verdict: { computedRtpPpm: a.computedRtpPpm }, paytable: await this.game.paytable(mode.config),
409
+ });
410
+ }
411
+
412
+ const meta = this.meta();
413
+ const r = await uploadTables(snap.spec, tables, {
414
+ client: api, gameId: meta.gameId, bundle, publishModes: modes ?? undefined, onProgress: say,
415
+ });
416
+ if (meta.gameId !== r.game.id) {
417
+ writeFileSync(meta.path, `${JSON.stringify({ ...(meta.data ?? {}), gameId: r.game.id }, null, 2)}\n`);
418
+ say(`gameId ${r.game.id} 已写进 game.meta.json——之后发布都认它,改名只改 id/title`);
419
+ }
420
+ const out: PublishResult = {
421
+ gameId: r.game.id, slug: r.game.slug, verifiedRtpPpm: r.game.verifiedRtpPpm,
422
+ modes: r.tables.map((t) => t.mode), bundle: r.game.bundle?.hash ?? null,
423
+ playUrl: `${this.urls.site}/#/play/${r.game.slug}`,
424
+ };
425
+ say(`✅ 已发布 ${out.slug}:${out.playUrl}`);
426
+ return out;
208
427
  }
209
428
 
210
429
  publishRequests(): PublishRequest[] { return [...this.#publish.values()].sort((a, b) => b.createdAt - a.createdAt); }
@@ -17,6 +17,8 @@ export interface GameServerOptions {
17
17
  /** 平台后端,/v1 反代到它。页面同源登录之后,发布用的就是浏览器里的这份身份 */
18
18
  backendUrl: string;
19
19
  engine: Engine;
20
+ /** 页面的身份连上时(GET /v1/me 200)收到它带的 cookie 头。node 侧记下来,MCP 发布用 */
21
+ onSession?: (cookieHeader: string) => void;
20
22
  /** stdio 模式下 stdout 是 MCP 协议,vite 的一切输出都得走 stderr */
21
23
  quiet?: boolean;
22
24
  }
@@ -46,6 +48,8 @@ function allowList(o: GameServerOptions): string[] {
46
48
  o.studioRoot, searchForWorkspaceRoot(o.studioRoot),
47
49
  ]);
48
50
  try { list.add(realpathSync(join(o.studioRoot, 'node_modules', 'gamekit777'))); } catch { /* 没装就算了 */ }
51
+ // 资源库的本机副本:页面用 /@fs 让 vite 现场编译预览
52
+ list.add(o.engine.assets.dir);
49
53
  return [...list];
50
54
  }
51
55
 
@@ -62,7 +66,7 @@ function stderrLogger(): Logger {
62
66
  * 创作者机器上的 127.0.0.1:随机端口不可能预先写进 TRUSTED_ORIGINS
63
67
  * 这么做不削弱 CSRF 防护:能打到这个反代的只有同源的 studio 页面本身。
64
68
  */
65
- function proxyRule(backendUrl: string): ProxyOptions {
69
+ function proxyRule(backendUrl: string, onSession?: (cookieHeader: string) => void): ProxyOptions {
66
70
  const origin = new URL(backendUrl).origin;
67
71
  return {
68
72
  target: backendUrl,
@@ -72,6 +76,11 @@ function proxyRule(backendUrl: string): ProxyOptions {
72
76
  if (req.getHeader('origin')) req.setHeader('origin', origin);
73
77
  req.removeHeader('referer');
74
78
  });
79
+ /* 页面的身份连上了(GET /v1/me 200),把它带的 cookie 抄给 node 侧——MCP 发布用的就是这份 */
80
+ proxy.on('proxyRes', (res, req) => {
81
+ const cookie = req.headers.cookie;
82
+ if (onSession && cookie && res.statusCode === 200 && (req.url ?? '').split('?')[0] === '/v1/me') onSession(cookie);
83
+ });
75
84
  },
76
85
  };
77
86
  }
@@ -118,7 +127,7 @@ export async function startGameServer(o: GameServerOptions): Promise<ViteDevServ
118
127
  fs: { allow: allowList(o) },
119
128
  /* 同源代理到平台后端:cookie 才带得上。发布时用的就是这份身份 */
120
129
  proxy: {
121
- '/v1': proxyRule(o.backendUrl),
130
+ '/v1': proxyRule(o.backendUrl, o.onSession),
122
131
  '/api': proxyRule(o.backendUrl),
123
132
  },
124
133
  },
package/studio/src/mcp.ts CHANGED
@@ -99,7 +99,10 @@ const INSTRUCTIONS = [
99
99
  '没有这个文件就先调 game_info,再读 game.ts 和 src/rules/。',
100
100
  '',
101
101
  '工作流:改 src/rules/ 或 game.ts → run_task check/verify/test → probe_table(能不能打表、modes 写哪些键、book 怎么选)',
102
- '→ preview_play 在预览里演几局 → case_add 存值得盯的局面 → build_table 建表 → publish_dry_run → publish(作者在页面确认)。',
102
+ '→ preview_play 在预览里演几局 → case_add 存值得盯的局面 → build_table 建表 → publish_dry_run → publish(一键跑完:vite build + 传包 + 传表 + 发布,不用人确认)。',
103
+ '演出先查资源库:asset_list → asset_show <name>(源码 + NOTES:为什么这么做、坑、退化)→ asset_use <name> 复制进游戏再改。库是参考实现,复制进来就是游戏自己的代码。',
104
+ '资源库两种条目:animation 是一个自足组件(复制到 src/animations/,{#key 局号} 重挂重播);pattern 是一套机制 + 接线清单(复制到 src/patterns/<name>/,NOTES 里的接线表要你照着穿进游戏的时间轴,台词/文案要按这个游戏的角色重写,不要照抄范例)。',
105
+ '游戏在平台上的身份是 game.meta.json 的 gameId(第一次发布后自动写回),不是名字;改名只改 id/title。发布用的身份是作者在 studio 页面连过一次的站点身份,没有就先让作者打开页面。',
103
106
  '',
104
107
  '铁律:round() 是同步纯函数、只用注入的 rng;payout 是整数分且是注额的精确线性函数;restore 返回的 payout 必须等于 (bet/100)×payoutCenti,对不上就抛。',
105
108
  '不写 normalize/validate/modeOf,不加 meta 或版本号;已发布 mode 的解码逻辑不能改,要改就换 slug。',
@@ -206,39 +209,76 @@ export function createMcpServer(engine: Engine): McpServer {
206
209
  ].join('\n')));
207
210
 
208
211
  s.registerTool('publish_dry_run', {
209
- description: '发布前体检(本地部分):当前指纹下有哪些表、最低实算 RTP 是否低于声明。登录态和 slug 占用要在 studio 页面上看。',
212
+ description: '发布前体检:本地有哪些表、RTP 是否虚标;连平台查身份、gameId 绑定、slug 占用、哪些 mode 已生效会被跳过。不上传任何东西。',
210
213
  inputSchema: { modes: modesArg },
211
214
  }, async (a) => {
212
- const i = await engine.info();
213
- const arts = i.artifacts.filter((t) => !a.modes || a.modes.includes(t.mode));
214
- const spec = i.snapshot.spec;
215
- const declared = Math.round(spec.rtp * 1e6);
216
- const issues: string[] = [];
217
- if (arts.length === 0) issues.push('当前指纹下没有任何已建的表——先 build_table');
218
- const missing = spec.modes.filter((m) => !arts.some((t) => t.mode === m.name)).map((m) => m.name);
219
- if (missing.length && !a.modes) issues.push(`这些 mode 还没建表:${missing.join('、')}`);
220
- const lowest = arts.reduce<number | null>((m, t) => (m === null || t.computedRtpPpm < m ? t.computedRtpPpm : m), null);
221
- if (lowest !== null && lowest + 100 < declared) issues.push(`游戏声明 ${pct(declared)},但最低的表只有 ${pct(lowest)}——大厅里的声明不能高于任何一个 mode 实际能给的,发布会被拒`);
222
- const ok = issues.length === 0;
223
- return out([`## ${ok ? '✅ 本地体检通过' : '❌ 本地体检有问题'}`, '', ...(ok ? [`${arts.length} 张表就绪:${arts.map((t) => t.mode).join('、')}`] : issues.map((x) => `- ${x}`)),
224
- '', '登录态、slug 是否被占、哪些 mode 已经锁死——这些要连平台才知道,在 studio 页面的「发布」面板里看。'].join('\n'),
225
- { ok, issues, artifacts: arts.map((t) => ({ mode: t.mode, computedRtpPpm: t.computedRtpPpm, rows: t.rowCount })) });
215
+ const r = await engine.publishPreflight(a.modes ?? null);
216
+ const lines = [`## ${r.ok ? '✅ 体检通过' : '❌ 体检有问题'}`, ''];
217
+ if (r.artifacts.length) lines.push(`本地 ${r.artifacts.length} 张表:${r.artifacts.map((t) => `${t.mode}(${pct(t.computedRtpPpm)},${t.rows.toLocaleString()} 行)`).join('、')}`);
218
+ if (r.remote) {
219
+ lines.push(`身份 ${r.remote.user!.slice(0, 8)}…`);
220
+ lines.push(r.remote.game
221
+ ? `游戏 ${r.remote.game.id}(${r.remote.game.slug},${r.remote.game.status}),已生效 mode:${r.remote.game.liveModes.join('、') || '无'}`
222
+ : 'game.meta.json 里没有 gameId:这次会新建游戏并写回');
223
+ lines.push(`这次会发布:${r.remote.willPublish.join('、') || '(无新表)'}`);
224
+ }
225
+ if (r.issues.length) lines.push('', ...r.issues.map((x) => `- ❌ ${x}`));
226
+ if (r.warnings.length) lines.push('', ...r.warnings.map((x) => `- ⚠️ ${x}`));
227
+ return out(lines.join('\n'), r);
226
228
  });
227
229
 
228
230
  s.registerTool('publish', {
229
- description: '请求发布。上传要用作者在 studio 页面上登录的身份,所以这里只是把请求排进页面的待审批队列,作者点确认后才真正上传。发布不可逆。',
230
- inputSchema: { modes: modesArg, note: z.string().optional().describe('给作者看的说明') },
231
+ description: '一键发布:vite build → 传游戏包 → 传表 → 发布,不需要作者确认。身份是作者在 studio 页面连过的站点身份(node 侧记住了)。游戏按 game.meta.json 的 gameId 绑定,第一次发布会新建并写回。发布不可逆:已生效的 mode 会被跳过,要改就换 slug。',
232
+ inputSchema: { modes: modesArg, note: z.string().optional().describe('记在发布记录里的说明') },
233
+ }, async (a) => awaitJob(engine, engine.publish(a.modes ?? null, { note: a.note ?? null, from: 'mcp' }), (r) =>
234
+ [`## ✅ 已发布 ${r.slug}`, '', `- gameId ${r.gameId}`, `- 新发布的 mode:${r.modes.join('、') || '(无新表,只换了包)'}`,
235
+ `- 实测 RTP ${r.verifiedRtpPpm === null ? '-' : pct(r.verifiedRtpPpm)}`, `- 游戏包 ${r.bundle ? `${r.bundle.slice(0, 12)}…` : '-'}`, `- 试玩:${r.playUrl}`].join('\n')));
236
+
237
+ s.registerTool('asset_list', {
238
+ description: '平台资源库:历史演出经验的归档。animation = 自足的小组件(赢面弹窗、分档大奖、揭示翻动、上飘文字…);pattern = 一套机制 + 接线清单(荷官说话…),复杂度高,要照 NOTES 穿进游戏的时间轴。做演出前先看这里。',
239
+ inputSchema: {
240
+ kind: z.enum(['animation', 'pattern']).optional().describe('只列这一种'),
241
+ tag: z.string().optional().describe('按 tag 过滤'),
242
+ sync: z.boolean().optional().describe('先同平台同步一次再列(默认后台已同步过就不等)'),
243
+ },
231
244
  }, async (a) => {
232
- const req = engine.requestPublish(a.modes ?? null, a.note ?? null, 'mcp');
233
- const t0 = Date.now();
234
- while (Date.now() - t0 < 120_000) {
235
- const cur = engine.publishRequests().find((r) => r.id === req.id)!;
236
- if (cur.status !== 'awaiting-approval' && cur.status !== 'running') {
237
- return out(`## 发布请求 ${cur.id}:${cur.status}\n\n${cur.progress.map((p) => `- ${p}`).join('\n')}${cur.error ? `\n\n错误:${cur.error}` : ''}`, cur);
238
- }
239
- await new Promise((r) => setTimeout(r, 1000));
240
- }
241
- return out(`## 发布请求 ${req.id} 等待作者确认\n\n请作者打开 ${engine.urls.studio} 的「发布」面板点确认。之后用 job_status 不行,用 publish_dry_run 或 game_info 看状态。`, req);
245
+ if (a.sync || engine.assets.list().length === 0) await engine.assets.sync().catch(() => null);
246
+ const all = engine.assets.list(a.kind).filter((e) => !a.tag || e.tags.includes(a.tag));
247
+ if (!all.length) return out(`资源库为空${a.kind ? `(${a.kind})` : ''}${a.tag ? `(tag ${a.tag} 下没有)` : `。平台 ${engine.urls.backend}/a/index.json 拉不到或还没发过库`}`, { entries: [] });
248
+ const row = (e: typeof all[number]): string => `| ${e.kind === 'pattern' ? '⚙ pattern' : '✦ animation'} | \`${e.name}\` | ${e.title} | ${e.summary} | ${e.tags.join(' ')} |`;
249
+ return out([`## 资源库 ${all.length} 条`, '', '| 类型 | name | 标题 | 一句话 | tags |', '|---|---|---|---|---|', ...all.map(row),
250
+ '', `预览:${engine.urls.studio}#assets · 详情 asset_show <name> · 复制进游戏 asset_use <name>`].join('\n'),
251
+ { entries: all.map((e) => ({ kind: e.kind, name: e.name, title: e.title, summary: e.summary, tags: e.tags, props: e.props, hash: e.hash })) });
252
+ });
253
+
254
+ s.registerTool('asset_show', {
255
+ description: '看一条:NOTES.md(为什么这么做、踩过的坑、turbo/reducedMotion 下怎么退化;套路还有接线清单)+ 全部文件源码 + 旋钮说明。',
256
+ inputSchema: { name: z.string() },
257
+ }, async (a) => {
258
+ let r;
259
+ try { r = engine.assets.show(a.name); } catch (e) { return fail(e instanceof Error ? e.message : String(e)); }
260
+ const e = r.entry;
261
+ const props = Object.entries(e.props).map(([k, p]) => `- \`${k}\`:${p.kind}${p.options ? `(${p.options.join(' / ')})` : ''},默认 ${JSON.stringify(p.default)}${p.label ? ` — ${p.label}` : ''}`);
262
+ const lang = (p: string): string => (p.endsWith('.svelte') ? 'svelte' : p.endsWith('.ts') ? 'ts' : p.endsWith('.json') ? 'json' : '');
263
+ const files = r.files.flatMap((f) => [`### ${f.path}${f.copied ? '' : e.kind === 'pattern' && f.path === e.demo ? '(Demo,只在预览里跑,不复制)' : '(范例,不复制)'}`, `\`\`\`${lang(f.path)}`, f.text, '```', '']);
264
+ return out([`## ${e.title}(\`${e.name}\`,${e.kind})`, '', e.summary, '', `复制进游戏后落在 \`${r.target}\``, '', '### 旋钮', ...props, '', '### NOTES', r.notes, '', ...files,
265
+ `预览:${engine.urls.studio}#assets:${e.name} · 复制进游戏:asset_use ${e.name}`].join('\n'),
266
+ { entry: e, notes: r.notes, files: r.files, target: r.target });
267
+ });
268
+
269
+ s.registerTool('asset_use', {
270
+ description: '复制进游戏(文件头注来源哈希):动画 → src/animations/<Component>.svelte;套路 → src/patterns/<name>/ 的机制文件(Demo、范例不进)。是复制不是依赖:之后随意改,库更新不会动它。目标已有且内容不同时拒绝,force 才覆盖。',
271
+ inputSchema: { name: z.string(), force: z.boolean().optional() },
272
+ }, async (a) => {
273
+ try {
274
+ const r = engine.assets.use(a.name, { force: a.force });
275
+ const e = engine.assets.get(a.name)!;
276
+ if (!r.written) return out(`未写入:${r.reason}(${r.path})`, r);
277
+ const next = e.kind === 'animation'
278
+ ? `在组件里 \`import X from './animations/${e.component}'\`,用 {#key 局号} 重挂来重播。`
279
+ : `接下来按 asset_show ${e.name} 里 NOTES 的接线清单,把调用穿进游戏的时间轴;台词/文案按这个游戏的角色重写。复制了:${r.files.join('、')}`;
280
+ return out(`## ✅ 已复制到 ${r.path}\n\n${next}改它就像改自己的代码。`, r);
281
+ } catch (e) { return fail(e instanceof Error ? e.message : String(e)); }
242
282
  });
243
283
 
244
284
  s.registerTool('preview_state', {
@@ -0,0 +1,37 @@
1
+ /* node 侧记住的站点身份,让 MCP 的 publish 不用等人在页面上点。
2
+ *
3
+ * 身份本来只在浏览器里:页面经 /v1 反代请求平台,cookie 由浏览器带。反代看得见每个请求的 cookie 头,
4
+ * 于是在 GET /v1/me 返回 200 时把那份 cookie 抄一份落盘——页面一开、身份一连,node 侧就有了同一个人。
5
+ * 文件按平台 host 分,放用户目录而不是游戏目录:身份是这个人的,不是这个项目的,也不该进版本控制。
6
+ * 格式和 client/node 的 fileCookieStore 一样(名 → 值),发布时直接当 cookieStore 用。 */
7
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
8
+ import { homedir } from 'node:os';
9
+ import { join } from 'node:path';
10
+
11
+ export function sessionFile(backendUrl: string): string {
12
+ const host = new URL(backendUrl).host.replace(/[^a-z0-9.-]/gi, '_');
13
+ return process.env.GAMEKIT_STUDIO_SESSION_FILE ?? join(homedir(), '.gamekit', `session-${host}.json`);
14
+ }
15
+
16
+ export function parseCookieHeader(header: string): Record<string, string> {
17
+ const out: Record<string, string> = {};
18
+ for (const part of header.split(';')) {
19
+ const i = part.indexOf('=');
20
+ if (i <= 0) continue;
21
+ out[part.slice(0, i).trim()] = part.slice(i + 1).trim();
22
+ }
23
+ return out;
24
+ }
25
+
26
+ export function saveSession(path: string, cookieHeader: string): void {
27
+ const cookies = parseCookieHeader(cookieHeader);
28
+ if (Object.keys(cookies).length === 0) return;
29
+ mkdirSync(join(path, '..'), { recursive: true });
30
+ writeFileSync(path, `${JSON.stringify(cookies, null, 2)}\n`);
31
+ try { chmodSync(path, 0o600); } catch { /* Windows 没有权限位 */ }
32
+ }
33
+
34
+ export function hasSession(path: string): boolean {
35
+ try { return existsSync(path) && Object.keys(JSON.parse(readFileSync(path, 'utf8')) as object).length > 0; }
36
+ catch { return false; }
37
+ }
@@ -5,7 +5,9 @@
5
5
  *
6
6
  * 不往游戏目录写任何文件:两个 html 从内存吐,入口用 /@fs 指回 studio 包。
7
7
  * 不依赖游戏目录里有 index.html:脚手架出来的项目没有它,`bun run dev` 就是起 studio。 */
8
+ import { readFileSync } from 'node:fs';
8
9
  import type { Plugin } from 'vite';
10
+ import { compile } from 'svelte/compiler';
9
11
  import { API_PREFIX, createApi } from './api.ts';
10
12
  import type { Engine } from './engine.ts';
11
13
 
@@ -53,6 +55,20 @@ export function studioPlugin(o: StudioPluginOptions): Plugin {
53
55
  return null;
54
56
  },
55
57
 
58
+ /* 资源库组件的 <style> 兜底。vite-plugin-svelte 把组件 CSS 存在模块信息里,样式虚拟模块
59
+ (…svelte?svelte&type=style&lang.css)从那里取;组件还没编译过就先来要样式(重连、刷新、缓存未命中)时
60
+ 它拿不到,退回读文件——整份 .svelte 源码被当 CSS 交给下游,namespace-css 直接炸页。
61
+ 库组件在游戏根之外、内容自足,这里自己编一遍只取 CSS:scoped class 的哈希只由样式文本决定,和组件那次编译一致 */
62
+ load(id) {
63
+ const m = /^([^?]+\.svelte)\?(?:.*&)?svelte(?:&.*)?&type=style(?:&|$)/.exec(id) ?? /^([^?]+\.svelte)\?(?:.*&)?type=style(?:&|$)/.exec(id);
64
+ const file = m?.[1];
65
+ if (!file || !file.startsWith(o.engine.assets.dir)) return null;
66
+ try {
67
+ const src = readFileSync(file, 'utf8');
68
+ return { code: compile(src, { filename: file, generate: 'client', css: 'external' }).css?.code ?? '' };
69
+ } catch { return null; }
70
+ },
71
+
56
72
  configureServer(server) {
57
73
  server.middlewares.use(api);
58
74
  server.middlewares.use((req, res, next) => {
@@ -31,7 +31,10 @@ export function runTask(gameDir: string, task: TaskName, o: RunTaskOptions = {})
31
31
  const t0 = Date.now();
32
32
 
33
33
  return new Promise((resolve) => {
34
- const child = spawn('bun', ['run', task], { cwd: gameDir, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' } });
34
+ /* 不把 NODE_ENV 传下去:studio 自己跑着 vite dev server,它会把 NODE_ENV 设成 development,
35
+ 子进程的 vite build 继承后就产出开发版包(不压缩、带开发期检查),发到平台上的就是那份 */
36
+ const { NODE_ENV: _dev, ...env } = process.env;
37
+ const child = spawn('bun', ['run', task], { cwd: gameDir, stdio: ['ignore', 'pipe', 'pipe'], env: { ...env, FORCE_COLOR: '0', NO_COLOR: '1' } });
35
38
  let out = '';
36
39
  const push = (b: Buffer): void => { if (out.length < maxOutput * 2) out += b.toString(); };
37
40
  child.stdout.on('data', push);