gamekit777 0.1.3 → 0.1.6

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 (54) hide show
  1. package/README.md +2 -2
  2. package/client/http.ts +1 -1
  3. package/client/index.ts +26 -0
  4. package/create-game/scaffold.ts +9 -1
  5. package/create-game/template/CLAUDE.md.tmpl +38 -39
  6. package/create-game/template/src/index.ts.tmpl +5 -3
  7. package/create-game/template/src/view/mount.svelte.ts.tmpl +1 -22
  8. package/create-game/template/vitest.config.ts +1 -1
  9. package/dev-host/DevShell.svelte +23 -2
  10. package/dev-host/platform.svelte.ts +4 -1
  11. package/dev-host/theme.css +5 -3
  12. package/package.json +1 -1
  13. package/protocol/assets.ts +108 -0
  14. package/protocol/bundles.ts +74 -0
  15. package/protocol/errors.ts +6 -0
  16. package/protocol/games.ts +5 -0
  17. package/protocol/index.ts +2 -0
  18. package/publish/upload-core.ts +95 -15
  19. package/publish/upload.ts +17 -1
  20. package/runtime/assets.ts +19 -15
  21. package/runtime/fonts.ts +1 -0
  22. package/runtime/index.ts +2 -0
  23. package/runtime/preload.ts +38 -0
  24. package/runtime/registry.ts +74 -0
  25. package/runtime/types.ts +3 -25
  26. package/sdk/assets.ts +24 -0
  27. package/sdk/host.ts +0 -2
  28. package/sdk/index.ts +1 -0
  29. package/sdk/spec.ts +3 -0
  30. package/studio/app/App.svelte +43 -14
  31. package/studio/app/lib/api.ts +11 -3
  32. package/studio/app/lib/bus.svelte.ts +2 -0
  33. package/studio/app/lib/identity.svelte.ts +4 -1
  34. package/studio/app/panels/AssetsPanel.svelte +239 -0
  35. package/studio/app/panels/PublishPanel.svelte +44 -51
  36. package/studio/bin.ts +2 -0
  37. package/studio/cli.ts +7 -6
  38. package/studio/src/api.ts +20 -7
  39. package/studio/src/assets.ts +160 -0
  40. package/studio/src/bus.ts +2 -1
  41. package/studio/src/engine.ts +237 -18
  42. package/studio/src/game-vite.ts +11 -2
  43. package/studio/src/mcp.ts +73 -33
  44. package/studio/src/scaffold.ts +38 -15
  45. package/studio/src/session.ts +37 -0
  46. package/studio/src/studio-plugin.ts +16 -0
  47. package/studio/src/tasks.ts +4 -1
  48. package/vite-config/index.js +160 -35
  49. package/vite-config/index.ts +5 -1
  50. package/vite-config/manifest.ts +16 -2
  51. package/vite-config/namespace-css.ts +130 -43
  52. package/create-game/template/src/assets/manifest.ts +0 -30
  53. package/create-game/template/src/assets/registry.ts +0 -23
  54. package/studio/app/lib/upload.ts +0 -34
package/studio/src/api.ts CHANGED
@@ -93,6 +93,17 @@ export function createApi(engine: Engine): (req: IncomingMessage, res: ServerRes
93
93
  return undefined;
94
94
  });
95
95
 
96
+ /* 游戏包:dist/ 里的文件清单与字节,页面用浏览器身份传到平台 */
97
+ on('GET', '/bundle', () => engine.bundleFiles());
98
+ on('GET', '/bundle/file', (req, res) => {
99
+ const path = new URL(req.url!, 'http://x').searchParams.get('path') ?? '';
100
+ const bytes = engine.bundleFile(path);
101
+ if (!bytes) { json(res, 404, { error: `dist/ 里没有 ${path}` }); return undefined; }
102
+ res.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Access-Control-Allow-Origin': '*' });
103
+ res.end(bytes);
104
+ return undefined;
105
+ });
106
+
96
107
  /* 长任务 */
97
108
  on('GET', '/jobs', () => engine.jobs.list());
98
109
  on('GET', '/jobs/:id', (_r, res, p) => {
@@ -111,16 +122,18 @@ export function createApi(engine: Engine): (req: IncomingMessage, res: ServerRes
111
122
  on('POST', '/cases/bless', (_r, _s, _p, body) => ({ blessed: engine.cases.bless((body as { updates: never[] }).updates) }));
112
123
  on('DELETE', '/cases/:id', (_r, _s, p) => ({ removed: engine.cases.remove(p.id!) }));
113
124
 
114
- /* 发布 */
125
+ /* 动画库:列表 / 详情(源码 + NOTES + 预览用的绝对路径)/ 复制进游戏 / 手动同步 */
126
+ on('GET', '/assets', () => ({ entries: engine.assets.list(), syncedAt: engine.assets.syncedAt }));
127
+ on('GET', '/assets/:name', (_r, _s, p) => engine.assets.show(p.name!));
128
+ on('POST', '/assets/:name/use', (_r, _s, p, body) => engine.assets.use(p.name!, { force: !!(body as { force?: boolean } | null)?.force }));
129
+ on('POST', '/assets/sync', async () => ({ result: await engine.assets.sync(), entries: engine.assets.list() }));
130
+
131
+ /* 发布:node 侧一键跑完(身份是反代抄下来的站点 cookie),页面只发起和看进度 */
115
132
  on('GET', '/publish', () => engine.publishRequests());
133
+ on('GET', '/publish/preflight', () => engine.publishPreflight(null));
116
134
  on('POST', '/publish/request', (_r, _s, _p, body) => {
117
135
  const b = (body ?? {}) as { modes?: unknown; note?: string };
118
- return engine.requestPublish(list(b.modes), b.note ?? null, 'page');
119
- });
120
- on('POST', '/publish/:id', (_r, res, p, body) => {
121
- const r = engine.updatePublish(p.id!, body as never);
122
- if (!r) { json(res, 404, { error: `没有发布请求 ${p.id}` }); return undefined; }
123
- return r;
136
+ return engine.publish(list(b.modes), { note: b.note ?? null, from: 'page' });
124
137
  });
125
138
 
126
139
  return (req, res, next) => {
@@ -0,0 +1,160 @@
1
+ /* 资源库(动画 + 套路)在创作者机器上的那一半:后台拉、按哈希存、给页面预览、给 AI 查看、复制进游戏。
2
+ *
3
+ * 内容按内容寻址存在 ~/.gamekit/assets/<hash>/,跨项目共用、永不失效;index.json 的本地副本是目录。
4
+ * 拉下来的每个文件都按目录里的 sha256 核一遍,对不上就不落盘——目录走 TLS 从平台来,文件的完整性挂在它上面。
5
+ * 游戏用它是**复制**不是依赖:库里删一条,已经复制进游戏的副本照常构建。
6
+ * 两种条目只在 use() 上分叉:动画是一个文件,套路是一个目录(Demo 和范例不进游戏)。 */
7
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
8
+ import { homedir } from 'node:os';
9
+ import { dirname, join } from 'node:path';
10
+ import { Sha256 } from 'gamekit777/sdk/hash';
11
+ import {
12
+ ASSET_INDEX_PATH, AssetIndex, assetCopyFiles, assetFilePath, assetPreviewFile, assetTargetDir,
13
+ type AssetIndexEntry, type AssetKind,
14
+ } from 'gamekit777/protocol';
15
+
16
+ export const assetsDir = (): string => process.env.GAMEKIT_ASSETS_DIR ?? join(homedir(), '.gamekit', 'assets');
17
+
18
+ export interface AssetSyncResult { added: string[]; removed: string[]; total: number; at: number }
19
+
20
+ export interface AssetShown {
21
+ entry: AssetIndexEntry;
22
+ notes: string;
23
+ /** 除 asset.json / NOTES.md 之外的全部文件正文,按目录顺序。动画就一个,套路是机制 + Demo + 范例 */
24
+ files: { path: string; text: string; copied: boolean }[];
25
+ /** 预览要挂的组件(动画本身 / 套路的 Demo)的绝对路径,页面用 /@fs 让 vite 现场编译 */
26
+ previewPath: string;
27
+ /** 复制进游戏后落在哪(相对游戏目录) */
28
+ target: string;
29
+ }
30
+
31
+ export interface UseResult { path: string; written: boolean; reason?: string; files: string[] }
32
+
33
+ export class AssetStore {
34
+ readonly dir: string;
35
+ #index: AssetIndex | null = null;
36
+ #syncedAt = 0;
37
+ #syncing: Promise<AssetSyncResult | null> | null = null;
38
+
39
+ constructor(readonly backendUrl: string, readonly gameDir: string, private readonly onChange: (r: AssetSyncResult) => void, dir = assetsDir()) {
40
+ this.dir = dir;
41
+ mkdirSync(dir, { recursive: true });
42
+ this.#index = this.#readLocal();
43
+ }
44
+
45
+ get syncedAt(): number { return this.#syncedAt; }
46
+
47
+ #readLocal(): AssetIndex | null {
48
+ try { return AssetIndex.parse(JSON.parse(readFileSync(join(this.dir, 'index.json'), 'utf8'))); }
49
+ catch { return null; }
50
+ }
51
+
52
+ list(kind?: AssetKind): AssetIndexEntry[] {
53
+ const all = this.#index?.entries ?? [];
54
+ return kind ? all.filter((e) => e.kind === kind) : all;
55
+ }
56
+
57
+ get(name: string): AssetIndexEntry | null { return this.list().find((e) => e.name === name) ?? null; }
58
+
59
+ filePath(entry: AssetIndexEntry, path: string): string { return join(this.dir, entry.hash, path); }
60
+
61
+ /** 后台拉一次。并发调用共用同一次;失败(离线、平台没发过库)返回 null,本地目录照旧可用 */
62
+ sync(): Promise<AssetSyncResult | null> {
63
+ if (this.#syncing) return this.#syncing;
64
+ this.#syncing = this.#sync().finally(() => { this.#syncing = null; });
65
+ return this.#syncing;
66
+ }
67
+
68
+ async #sync(): Promise<AssetSyncResult | null> {
69
+ let remote: AssetIndex;
70
+ try {
71
+ const r = await fetch(`${this.backendUrl}${ASSET_INDEX_PATH}`);
72
+ if (!r.ok) return null;
73
+ remote = AssetIndex.parse(await r.json());
74
+ } catch { return null; }
75
+
76
+ const before = new Map(this.list().map((e) => [e.name, e.hash]));
77
+ const added: string[] = [];
78
+ for (const e of remote.entries) {
79
+ if (this.#complete(e)) { if (before.get(e.name) !== e.hash) added.push(e.name); continue; }
80
+ const tmp = join(this.dir, `.${e.hash}.part`);
81
+ rmSync(tmp, { recursive: true, force: true });
82
+ for (const f of e.files) {
83
+ const r = await fetch(`${this.backendUrl}${assetFilePath(e.hash, f.path)}`);
84
+ if (!r.ok) throw new Error(`拉 ${e.name}/${f.path} 失败:${r.status}`);
85
+ const bytes = new Uint8Array(await r.arrayBuffer());
86
+ const got = new Sha256().update(bytes).hex();
87
+ if (got !== f.sha256) throw new Error(`${e.name}/${f.path} 的 sha256 对不上目录(${got.slice(0, 8)} ≠ ${f.sha256.slice(0, 8)}),不落盘`);
88
+ mkdirSync(dirname(join(tmp, f.path)), { recursive: true });
89
+ writeFileSync(join(tmp, f.path), bytes);
90
+ }
91
+ rmSync(join(this.dir, e.hash), { recursive: true, force: true });
92
+ // 整条文件齐了再改名到位:vite 看到的目录要么完整要么没有
93
+ renameDir(tmp, join(this.dir, e.hash));
94
+ added.push(e.name);
95
+ }
96
+ const names = new Set(remote.entries.map((e) => e.name));
97
+ const removed = [...before.keys()].filter((n) => !names.has(n));
98
+
99
+ writeFileSync(join(this.dir, 'index.json'), `${JSON.stringify(remote, null, 2)}\n`);
100
+ this.#index = remote;
101
+ this.#syncedAt = Date.now();
102
+ const result = { added, removed, total: remote.entries.length, at: this.#syncedAt };
103
+ if (added.length || removed.length) this.onChange(result);
104
+ return result;
105
+ }
106
+
107
+ #complete(e: AssetIndexEntry): boolean {
108
+ const d = join(this.dir, e.hash);
109
+ if (!existsSync(d)) return false;
110
+ const have = new Set(readdirSync(d));
111
+ return e.files.every((f) => have.has(f.path));
112
+ }
113
+
114
+ show(name: string): AssetShown {
115
+ const entry = this.get(name);
116
+ if (!entry) throw new Error(`资源库里没有 ${name}。有的是:${this.list().map((e) => `${e.name}(${e.kind})`).join('、') || '(空,先等后台同步或检查后端)'}`);
117
+ const read = (p: string): string => readFileSync(this.filePath(entry, p), 'utf8');
118
+ const paths = entry.files.map((f) => f.path);
119
+ const copied = new Set(assetCopyFiles(entry, paths));
120
+ return {
121
+ entry, notes: read('NOTES.md'),
122
+ files: paths.filter((p) => p !== 'asset.json' && p !== 'NOTES.md').map((p) => ({ path: p, text: read(p), copied: copied.has(p) })),
123
+ previewPath: this.filePath(entry, assetPreviewFile(entry)),
124
+ target: entry.kind === 'animation' ? `${assetTargetDir('animation')}/${entry.component}` : `${assetTargetDir('pattern')}/${entry.name}/`,
125
+ };
126
+ }
127
+
128
+ /**
129
+ * 复制进游戏,每个文件头注来源与哈希。
130
+ * 动画 → src/animations/<Component>.svelte;套路 → src/patterns/<name>/ 下的机制文件(Demo、范例不进)。
131
+ * 目标已有且内容不同时拒绝,除非 force——那是创作者改过的副本,不能静默覆盖
132
+ */
133
+ use(name: string, o: { force?: boolean } = {}): UseResult {
134
+ const s = this.show(name);
135
+ const e = s.entry;
136
+ const tag = `来自 GameKit 资源库 ${e.kind === 'animation' ? 'animations' : 'patterns'}/${e.name}@${e.hash.slice(0, 12)}。这是你的副本,随意改;库更新不会动它`;
137
+ const header = (p: string): string => p.endsWith('.svelte') ? `<!-- ${tag} -->\n` : p.endsWith('.ts') ? `// ${tag}\n` : '';
138
+ const destDir = e.kind === 'animation' ? join(this.gameDir, assetTargetDir('animation')) : join(this.gameDir, assetTargetDir('pattern'), e.name);
139
+ const plan = s.files.filter((f) => f.copied).map((f) => ({ path: f.path, dest: join(destDir, f.path), content: header(f.path) + f.text }));
140
+ const shownPath = e.kind === 'animation' ? plan[0]!.dest : destDir;
141
+
142
+ const existing = plan.filter((f) => existsSync(f.dest));
143
+ if (existing.length === plan.length && existing.every((f) => readFileSync(f.dest, 'utf8') === f.content)) {
144
+ return { path: shownPath, written: false, reason: '已有同一份', files: plan.map((f) => f.path) };
145
+ }
146
+ const changed = existing.filter((f) => readFileSync(f.dest, 'utf8') !== f.content);
147
+ if (changed.length && !o.force) {
148
+ return { path: shownPath, written: false, reason: `已有同名文件且内容不同(你改过?):${changed.map((f) => f.path).join('、')}。确定要覆盖就 force`, files: plan.map((f) => f.path) };
149
+ }
150
+ mkdirSync(destDir, { recursive: true });
151
+ for (const f of plan) writeFileSync(f.dest, f.content);
152
+ return { path: shownPath, written: true, files: plan.map((f) => f.path) };
153
+ }
154
+ }
155
+
156
+ function renameDir(from: string, to: string): void {
157
+ // 同一目录下 rename 不会跨设备;万一失败就复制再删
158
+ try { renameSync(from, to); }
159
+ catch { cpSync(from, to, { recursive: true }); rmSync(from, { recursive: true, force: true }); }
160
+ }
package/studio/src/bus.ts CHANGED
@@ -14,7 +14,8 @@ export type BusEvent =
14
14
  | { t: 'cmd'; id: string; cmd: unknown }
15
15
  | { t: 'cases'; changed: true }
16
16
  | { t: 'build'; artifacts: unknown }
17
- | { t: 'publish'; request: unknown };
17
+ | { t: 'publish'; request: unknown }
18
+ | { t: 'assets'; added: string[]; removed: string[]; total: number };
18
19
 
19
20
  interface Waiter { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }
20
21
 
@@ -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
  },