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
@@ -0,0 +1,108 @@
1
+ /* 资源库:历史演出经验的归档,两种条目共用一套索引、同步与发布。
2
+ *
3
+ * animation 一个自足的 .svelte 组件,契约只有一条:挂载即播放一次。复制进游戏的 src/animations/
4
+ * pattern 一套机制(若干 .ts / .svelte)+ 一个 Demo.svelte(可点的场景,预览用)+ 范例文件(*.example.*)。
5
+ * 复制进游戏的 src/patterns/<name>/,Demo 和范例不复制;NOTES 里有接线清单,接线要 AI 照着穿进游戏的时间轴
6
+ *
7
+ * 内容不进 npm 包:源码在仓库 packages/assets/src/{animations,patterns}/,`bun run assets:push` 按内容寻址传到 R2,
8
+ * `/a/index.json` 是目录、`/a/<hash>/<file>` 是文件(immutable)。studio 启动后在后台拉,AI 用 MCP 查、看、复制进游戏。 */
9
+ import { z } from 'zod';
10
+ import { BundleFile, BundlePath, bundleManifestText } from './bundles.ts';
11
+ import { Sha256Hex } from './money.ts';
12
+
13
+ export const AssetKind = z.enum(['animation', 'pattern']);
14
+ export type AssetKind = z.infer<typeof AssetKind>;
15
+
16
+ export const AssetName = z.string().min(2).max(48).regex(/^[a-z0-9][a-z0-9-]*$/);
17
+ const SvelteFile = z.string().regex(/^[A-Z][A-Za-z0-9]*\.svelte$/);
18
+
19
+ /** 预览页按它生成旋钮(animation 给组件,pattern 给 Demo);也是 AI 看「能调什么」的说明 */
20
+ export const AssetProp = z.object({
21
+ kind: z.enum(['number', 'boolean', 'string', 'enum']),
22
+ label: z.string().max(40).optional(),
23
+ default: z.union([z.number(), z.boolean(), z.string()]),
24
+ min: z.number().optional(),
25
+ max: z.number().optional(),
26
+ step: z.number().optional(),
27
+ /** kind = enum 时的取值 */
28
+ options: z.array(z.string().max(40)).max(24).optional(),
29
+ }).strict();
30
+ export type AssetProp = z.infer<typeof AssetProp>;
31
+
32
+ const base = {
33
+ name: AssetName,
34
+ title: z.string().min(1).max(60),
35
+ summary: z.string().min(1).max(200),
36
+ tags: z.array(z.string().min(1).max(24)).max(12),
37
+ props: z.record(z.string().regex(/^[a-z][A-Za-z0-9]*$/), AssetProp).default({}),
38
+ /** 预览舞台的逻辑尺寸,屏幕不够时整体缩放;不填就是 450×385 深色底 */
39
+ stage: z.object({
40
+ width: z.int().min(100).max(900).optional(),
41
+ height: z.int().min(100).max(771).optional(),
42
+ background: z.string().max(60).optional(),
43
+ }).strict().optional(),
44
+ };
45
+
46
+ export const AnimationMeta = z.object({
47
+ kind: z.literal('animation'),
48
+ ...base,
49
+ /** 组件文件名,PascalCase.svelte;复制进游戏时也用这个名 */
50
+ component: SvelteFile,
51
+ }).strict();
52
+ export type AnimationMeta = z.infer<typeof AnimationMeta>;
53
+
54
+ export const PatternMeta = z.object({
55
+ kind: z.literal('pattern'),
56
+ ...base,
57
+ /** 预览用的场景组件,只在 studio 里跑,不复制进游戏 */
58
+ demo: SvelteFile,
59
+ }).strict();
60
+ export type PatternMeta = z.infer<typeof PatternMeta>;
61
+
62
+ /** asset.json 的形状 */
63
+ export const AssetMeta = z.discriminatedUnion('kind', [AnimationMeta, PatternMeta]);
64
+ export type AssetMeta = z.infer<typeof AssetMeta>;
65
+
66
+ export const ASSET_REQUIRED = ['asset.json', 'NOTES.md'] as const;
67
+ export const MAX_ASSET_FILE_BYTES = 512 * 1024;
68
+
69
+ const indexed = { hash: Sha256Hex, files: z.array(BundleFile).min(3) };
70
+ export const AnimationIndexEntry = AnimationMeta.extend(indexed);
71
+ export const PatternIndexEntry = PatternMeta.extend(indexed);
72
+ export const AssetIndexEntry = z.discriminatedUnion('kind', [AnimationIndexEntry, PatternIndexEntry]);
73
+ export type AnimationIndexEntry = z.infer<typeof AnimationIndexEntry>;
74
+ export type PatternIndexEntry = z.infer<typeof PatternIndexEntry>;
75
+ export type AssetIndexEntry = z.infer<typeof AssetIndexEntry>;
76
+
77
+ export const AssetIndex = z.object({
78
+ version: z.literal(1),
79
+ entries: z.array(AssetIndexEntry),
80
+ });
81
+ export type AssetIndex = z.infer<typeof AssetIndex>;
82
+
83
+ /* /assets 让给了站点的静态产物(vite 的 dist/assets/*),库走短前缀 /a,和游戏包的 /g 同一副样子 */
84
+ export const ASSET_INDEX_PATH = '/a/index.json';
85
+ export const assetFilePath = (hash: string, path: string): string => `/a/${hash}/${path}`;
86
+ export const AssetFilePath = BundlePath;
87
+ /** 条目哈希 = 文件清单文本(path sha256 按 path 排序)的 sha256。内容一字不改哈希就不变 */
88
+ export const assetManifestText = bundleManifestText;
89
+
90
+ /** 预览时挂的那个组件:animation 是它自己,pattern 是 Demo */
91
+ export const assetPreviewFile = (m: AssetMeta): string => (m.kind === 'animation' ? m.component : m.demo);
92
+
93
+ /** 复制进游戏时落在哪:animation 一个文件,pattern 一个目录 */
94
+ export const assetTargetDir = (kind: AssetKind): string => (kind === 'animation' ? 'src/animations' : 'src/patterns');
95
+
96
+ /** 复制进游戏的文件。元数据、NOTES、Demo、范例(*.example.*)都是给人和 AI 看的,不进游戏 */
97
+ export function assetCopyFiles(m: AssetMeta, files: readonly string[]): string[] {
98
+ if (m.kind === 'animation') return [m.component];
99
+ return files.filter((f) => f !== 'asset.json' && f !== 'NOTES.md' && f !== m.demo && !/\.example\./.test(f));
100
+ }
101
+
102
+ export function assetContentType(path: string): string {
103
+ if (path.endsWith('.svelte') || path.endsWith('.ts')) return 'text/plain; charset=utf-8';
104
+ if (path.endsWith('.css')) return 'text/css; charset=utf-8';
105
+ if (path.endsWith('.md')) return 'text/markdown; charset=utf-8';
106
+ if (path.endsWith('.json')) return 'application/json; charset=utf-8';
107
+ return 'application/octet-stream';
108
+ }
@@ -0,0 +1,74 @@
1
+ /* 游戏包:vite build 出来的 index.js / manifest.json / meta.json / assets,按内容寻址存进 R2。
2
+ *
3
+ * 包不是审计对象——公平性的审计对象是表。包只是演出,所以已发布的游戏允许换包(修 UI、改演出),
4
+ * 表照旧不可变。「已发布 mode 的解码逻辑不能改」这条由 case 库和 restore 契约守,与包是否更新无关。
5
+ *
6
+ * 寻址:bundles/<gameId>/<bundleHash>/<path>。bundleHash 是文件清单(路径 + 各文件 sha256)的 sha256,
7
+ * 内容不变哈希就不变,所以对象永远不覆盖、可以 Cache-Control: immutable。 */
8
+ import { z } from 'zod';
9
+ import { Sha256Hex } from './money.ts';
10
+
11
+ /** 平台加载游戏时必须有的三个文件;其余(assets/…)随意 */
12
+ export const BUNDLE_REQUIRED = ['index.js', 'manifest.json', 'meta.json'] as const;
13
+ export const MAX_BUNDLE_FILE_BYTES = 20 * 1024 * 1024;
14
+ export const MAX_BUNDLE_FILES = 400;
15
+ export const MAX_BUNDLE_BYTES = 64 * 1024 * 1024;
16
+
17
+ /** 相对路径:每一段以字母数字开头(挡掉 . 和 ..),只允许字母数字 . _ - 和 / */
18
+ export const BundlePath = z.string().min(1).max(200)
19
+ .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*(\/[A-Za-z0-9][A-Za-z0-9._-]*)*$/, '路径只能是 a-z 0-9 . _ - 和 /,每段以字母数字开头');
20
+ export type BundlePath = z.infer<typeof BundlePath>;
21
+
22
+ export const BundleFile = z.object({
23
+ path: BundlePath,
24
+ sha256: Sha256Hex,
25
+ bytes: z.int().min(0).max(MAX_BUNDLE_FILE_BYTES),
26
+ });
27
+ export type BundleFile = z.infer<typeof BundleFile>;
28
+
29
+ export const BundleCommit = z.object({
30
+ files: z.array(BundleFile).min(1).max(MAX_BUNDLE_FILES),
31
+ });
32
+ export type BundleCommit = z.infer<typeof BundleCommit>;
33
+
34
+ /** 站点加载游戏要的全部信息:包哈希和入口 URL(同源相对路径,由平台流出) */
35
+ export const BundleRef = z.object({
36
+ hash: Sha256Hex,
37
+ /** game.meta.json 的 version,给人看的。缓存键是 hash:内容变了地址一定变,没变一定不变 */
38
+ version: z.string(),
39
+ entry: z.string(),
40
+ files: z.int(),
41
+ bytes: z.int(),
42
+ createdAt: z.int(),
43
+ });
44
+ export type BundleRef = z.infer<typeof BundleRef>;
45
+
46
+ export const BundleFileResult = z.object({ path: BundlePath, bytes: z.int(), sha256: Sha256Hex });
47
+ export type BundleFileResult = z.infer<typeof BundleFileResult>;
48
+
49
+ export const BundleCommitResult = z.object({ bundle: BundleRef });
50
+ export type BundleCommitResult = z.infer<typeof BundleCommitResult>;
51
+
52
+ /**
53
+ * 清单的规范文本:按路径排序,一行一个 `path sha256`。两端(上传方、服务端)都对它算 sha256 得到 bundleHash。
54
+ * 放在协议里而不是各写一份,是因为一个空格的差别就是两个哈希、永远对不上。
55
+ */
56
+ export function bundleManifestText(files: readonly { path: string; sha256: string }[]): string {
57
+ return [...files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
58
+ .map((f) => `${f.path} ${f.sha256}`).join('\n') + '\n';
59
+ }
60
+
61
+ export const bundleEntryPath = (gameId: string, hash: string): string => `/g/${gameId}/${hash}/index.js`;
62
+
63
+ /** R2 对象的 content-type 按扩展名给:模块脚本没有 JS 的 MIME 浏览器会拒绝 import */
64
+ export function bundleContentType(path: string): string {
65
+ const ext = path.split('.').pop()?.toLowerCase() ?? '';
66
+ return ({
67
+ js: 'text/javascript; charset=utf-8', mjs: 'text/javascript; charset=utf-8', json: 'application/json; charset=utf-8',
68
+ css: 'text/css; charset=utf-8', html: 'text/html; charset=utf-8', txt: 'text/plain; charset=utf-8', map: 'application/json',
69
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', avif: 'image/avif',
70
+ woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', otf: 'font/otf',
71
+ mp3: 'audio/mpeg', ogg: 'audio/ogg', wav: 'audio/wav', m4a: 'audio/mp4', webm: 'video/webm', mp4: 'video/mp4',
72
+ wasm: 'application/wasm',
73
+ } as Record<string, string>)[ext] ?? 'application/octet-stream';
74
+ }
@@ -19,6 +19,9 @@ export const ErrorCode = z.enum([
19
19
  'RTP_MISMATCH',
20
20
  'RTP_OUT_OF_BOUNDS',
21
21
  'PAYLOAD_TOO_LARGE',
22
+ 'BUNDLE_MISSING',
23
+ 'BUNDLE_INVALID',
24
+ 'SLUG_TAKEN',
22
25
  'RATE_LIMITED',
23
26
  'INTERNAL',
24
27
  ]);
@@ -53,6 +56,9 @@ export const HTTP_STATUS: Record<ErrorCode, number> = {
53
56
  RTP_MISMATCH: 422,
54
57
  RTP_OUT_OF_BOUNDS: 422,
55
58
  PAYLOAD_TOO_LARGE: 413,
59
+ BUNDLE_MISSING: 409,
60
+ BUNDLE_INVALID: 422,
61
+ SLUG_TAKEN: 409,
56
62
  RATE_LIMITED: 429,
57
63
  INTERNAL: 500,
58
64
  };
package/protocol/games.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { BundleRef } from './bundles.ts';
2
3
  import { Cents, MAX_BET_CENTS, PayoutCenti, Ppm, RtpPpm, Sha256Hex } from './money.ts';
3
4
 
4
5
  export const GameStatus = z.enum([
@@ -63,6 +64,8 @@ export const CreateGame = z.object({
63
64
  export type CreateGame = z.infer<typeof CreateGame>;
64
65
 
65
66
  export const UpdateGame = z.object({
67
+ /** 只有草稿能改:已发布的 slug 是站点 URL 和历史局的一部分 */
68
+ slug: CreateGame.shape.slug.optional(),
66
69
  title: z.string().min(1).max(80).optional(),
67
70
  description: z.string().max(2000).nullable().optional(),
68
71
  declaredRtpPpm: RtpPpm.optional(),
@@ -105,6 +108,8 @@ export const Game = z.object({
105
108
  configHash: Sha256Hex,
106
109
  gameHash: Sha256Hex.nullable(),
107
110
  activeTableId: z.uuid().nullable(),
111
+ /** 站点从这里加载游戏代码。null = 还没上传包,大厅不列、进不去 */
112
+ bundle: BundleRef.nullable(),
108
113
  publishedAt: z.int().nullable(),
109
114
  createdAt: z.int(),
110
115
  updatedAt: z.int(),
package/protocol/index.ts CHANGED
@@ -8,3 +8,5 @@ export * from './seeds.ts';
8
8
  export * from './session.ts';
9
9
  export * from './verify.ts';
10
10
  export * from './pair.ts';
11
+ export * from './bundles.ts';
12
+ export * from './assets.ts';
@@ -8,7 +8,7 @@ import {
8
8
  } from '../protocol/index.ts';
9
9
  import type { GameKitClient } from '../client/index.ts';
10
10
  import { HEADER_BYTES, ROW_BYTES, decodeHeader, readRow, type Verdict } from '../lut/index.ts';
11
- import type { GameDefinition } from '../sdk/index.ts';
11
+ import type { GameDefinition, Paytable } from '../sdk/index.ts';
12
12
 
13
13
  /** 上传只需要 BuiltTable 的这几个字段。studio 从产物文件里拼出来的也能过 */
14
14
  export interface UploadTable {
@@ -19,10 +19,33 @@ export interface UploadTable {
19
19
  betCostCenti: number;
20
20
  books: { simId: number; frames: string }[] | null;
21
21
  verdict: Pick<Verdict, 'computedRtpPpm'>;
22
+ /** 这张表对应配置的赔率行。不给就用 spec.paytableFor(spec.configOf(mode)) 算——studio 的主进程没有游戏代码,只能预先算好带进来 */
23
+ paytable?: Paytable;
22
24
  }
23
25
 
26
+ /**
27
+ * 上传只需要清单里的这几项。GameDefinition 直接满足;studio 主进程从 worker 拿回的纯数据快照也满足
28
+ * (它没有 paytableFor / configOf,所以每张表要自带 paytable)
29
+ */
30
+ export type UploadSpec<C = unknown, O = unknown> =
31
+ Pick<GameDefinition<C, O>, 'slug' | 'title' | 'description' | 'rtp' | 'autoplay'>
32
+ & Partial<Pick<GameDefinition<C, O>, 'paytableFor' | 'configOf'>>;
33
+
34
+ export interface BundleFileInput { path: string; bytes: Uint8Array }
35
+
24
36
  export interface UploadOptions {
25
37
  client: GameKitClient;
38
+ /**
39
+ * 平台分配的游戏 id(game.meta.json 的 gameId)。给了就只认它:slug 只是它的一个属性,草稿阶段可以改。
40
+ * 不给就新建一个游戏——**不会**按 slug 去找旧的:同一台机器上两个目录都叫 my-game 时,按名字找会把
41
+ * 这个项目的表传进那个项目的游戏里。返回的 game.id 由调用方写回 game.meta.json,下次就是同一个游戏
42
+ */
43
+ gameId?: string | null;
44
+ /**
45
+ * 游戏包(vite build 的产物)。发布前必须有——没有包的游戏站点加载不了,服务端会拒绝发布。
46
+ * 不给就假定这个游戏已经传过包(只换表 / 新增 mode)
47
+ */
48
+ bundle?: readonly BundleFileInput[];
26
49
  /** 只发布这些 mode。不给就全发 */
27
50
  publishModes?: readonly string[];
28
51
  chunkRows?: number;
@@ -59,9 +82,11 @@ function histogramOf(bytes: Uint8Array): { weights: Map<number, number>; total:
59
82
  * betModes 和表的 mode 不是一回事:前者是「花多少钱买一注」,后者是「用哪张表」。
60
83
  * coco 的 22 个 mode 成本完全相同,塞进 betModes 只会撑爆它的 8 个上限。
61
84
  */
62
- export function describeConfig<C, O>(spec: GameDefinition<C, O>, tables: readonly UploadTable[]): GameConfig {
85
+ export function describeConfig<C, O>(spec: UploadSpec<C, O>, tables: readonly UploadTable[]): GameConfig {
63
86
  const paytables = tables.map((t) => {
64
- const rows = spec.paytableFor(spec.configOf(t.mode));
87
+ const rows = t.paytable ?? (spec.paytableFor && spec.configOf
88
+ ? spec.paytableFor(spec.configOf(t.mode))
89
+ : (() => { throw new GameKitError('VALIDATION_FAILED', `表 ${t.mode} 没带赔率行,清单也没有 paytableFor`); })());
65
90
  const { weights, total } = histogramOf(t.bytes);
66
91
  return {
67
92
  mode: t.mode,
@@ -80,7 +105,7 @@ export function describeConfig<C, O>(spec: GameDefinition<C, O>, tables: readonl
80
105
  }
81
106
 
82
107
  /** 注额区间是运营侧的事,不在游戏清单里——后端按默认值建,之后由 games.update 调 */
83
- const gameFields = <C, O>(spec: GameDefinition<C, O>): Omit<CreateGame, 'slug' | 'config'> => ({
108
+ const gameFields = <C, O>(spec: UploadSpec<C, O>): Omit<CreateGame, 'slug' | 'config'> => ({
84
109
  title: spec.title,
85
110
  description: spec.description,
86
111
  declaredRtpPpm: Math.round(spec.rtp * 1e6),
@@ -99,28 +124,83 @@ async function findBySlug(api: GameKitClient, slug: string): Promise<Game | null
99
124
  }
100
125
  }
101
126
 
127
+ /** 按 gameId 取回自己的游戏,把三种「不是你想的那个」都翻成能照着做的话 */
128
+ async function resolveGame<C, O>(api: GameKitClient, spec: UploadSpec<C, O>, gameId: string, config: GameConfig, say: (m: string) => void): Promise<Game> {
129
+ const me = await api.session.me().catch(() => {
130
+ throw new GameKitError('UNAUTHENTICATED', '没有身份。先在 studio 页面连接一次站点身份');
131
+ });
132
+ let game: Game;
133
+ try {
134
+ game = await api.games.get(gameId);
135
+ } catch (e) {
136
+ if (e instanceof GameKitError && e.code === 'NOT_FOUND') {
137
+ throw new GameKitError('NOT_FOUND',
138
+ `game.meta.json 里的 gameId ${gameId} 在这个平台上不存在(换了平台或库?)。删掉 gameId 字段会新建一个游戏`);
139
+ }
140
+ throw e;
141
+ }
142
+ if (game.ownerId !== me.user.id) {
143
+ throw new GameKitError('FORBIDDEN',
144
+ `游戏 ${gameId}(${game.slug})归另一个账号。换回那个身份,或删掉 game.meta.json 的 gameId 用当前身份新建`);
145
+ }
146
+ if (game.status === 'published') {
147
+ if (game.slug !== spec.slug) {
148
+ throw new GameKitError('GAME_IMMUTABLE',
149
+ `游戏 ${gameId} 已发布为 "${game.slug}",slug 不能改成 "${spec.slug}"——那是站点 URL 和历史局的一部分。要改名就删掉 gameId 另建一个`);
150
+ }
151
+ say(`${spec.slug} 已发布(${game.id.slice(0, 8)}…):已生效的 mode 不动`);
152
+ return game;
153
+ }
154
+ game = await api.games.update(game.id, { slug: spec.slug, ...gameFields(spec), config });
155
+ say(`更新草稿 ${game.id.slice(0, 8)}…`);
156
+ return game;
157
+ }
158
+
102
159
  export async function uploadTables<C, O>(
103
- spec: GameDefinition<C, O>, tables: readonly UploadTable[], opts: UploadOptions,
160
+ spec: UploadSpec<C, O>, tables: readonly UploadTable[], opts: UploadOptions,
104
161
  ): Promise<UploadResult> {
105
162
  const api = opts.client;
106
163
  const say = opts.onProgress ?? (() => {});
107
164
  const config = describeConfig(spec, tables);
108
165
 
109
- let game = await findBySlug(api, spec.slug);
110
- if (game?.status === 'published') {
111
- throw new GameKitError('GAME_IMMUTABLE',
112
- `slug "${spec.slug}" 已经发布过了。已发布的游戏不能换表——改个 slug 发一个新版本`);
113
- }
114
- if (game) {
115
- game = await api.games.update(game.id, { ...gameFields(spec), config });
116
- say(`复用草稿 ${game.id.slice(0, 8)}…`);
166
+ let game: Game;
167
+ if (opts.gameId) {
168
+ game = await resolveGame(api, spec, opts.gameId, config, say);
117
169
  } else {
170
+ /* 不按 slug 复用。但撞了要说清楚是哪种情况:同一个项目忘了写 gameId,还是另一个项目起了同样的名字 */
171
+ const dup = await findBySlug(api, spec.slug);
172
+ if (dup) {
173
+ throw new GameKitError('VALIDATION_FAILED',
174
+ `你已经有一个 slug 为 "${spec.slug}" 的游戏(${dup.id},${dup.status})。` +
175
+ `如果就是这个项目,把 "gameId": "${dup.id}" 写进 game.meta.json;如果是另一个项目,改 game.meta.json 的 id`);
176
+ }
118
177
  game = await api.games.create({ slug: spec.slug, config, ...gameFields(spec) });
119
- say(`新建游戏 ${game.id.slice(0, 8)}…`);
178
+ say(`新建游戏 ${game.id}(写进 game.meta.json 的 gameId,之后都认它)`);
179
+ }
180
+
181
+ if (opts.bundle) {
182
+ say(`上传游戏包:${opts.bundle.length} 个文件`);
183
+ await api.bundles.upload(game.id, opts.bundle, say);
184
+ } else if (!game.bundle) {
185
+ throw new GameKitError('BUNDLE_MISSING', `游戏 ${spec.slug} 还没有客户端包,发布会被拒。先 vite build,把 dist/ 一起传`);
186
+ }
187
+
188
+ /* 表按 mode 不可变:已经生效的 mode 一律跳过,只传新的。
189
+ 于是同一条命令既是首发也是「加 mode」也是「只换包」,不用调用方自己分辨游戏处在哪个状态 */
190
+ const live = new Set((await api.games.get(game.id)).modes.map((m) => m.mode));
191
+ const fresh = tables.filter((t) => !live.has(t.mode));
192
+ for (const t of tables) if (live.has(t.mode)) say(`[${t.mode}] 已生效,跳过(表不可变;要改就换 slug)`);
193
+ if (fresh.length === 0) {
194
+ if (!opts.bundle) {
195
+ throw new GameKitError('GAME_IMMUTABLE',
196
+ `slug "${spec.slug}" 的 ${tables.length} 个 mode 都已发布,没有新表也没有新包,无事可做。改表就换 slug;只想换包就把 dist/ 一起传`);
197
+ }
198
+ say(`只更新了客户端包`);
199
+ return { game: await api.games.get(game.id), tables: [], published: true };
120
200
  }
121
201
 
122
202
  const uploaded: GameTable[] = [];
123
- for (const t of tables) {
203
+ for (const t of fresh) {
124
204
  const { table, resumed } = await api.tables.declare(game.id, {
125
205
  mode: t.mode, format: 'GKLT1', rowCount: t.rowCount,
126
206
  byteLength: t.bytes.length, contentHash: t.contentHash, betCostCenti: t.betCostCenti,
package/publish/upload.ts CHANGED
@@ -4,13 +4,29 @@
4
4
  * 而 games 的唯一约束是 (owner_id, slug)——同一个 slug 会在库里堆出很多份,
5
5
  * `findBySlug` 也永远找不到上次传的那个草稿。
6
6
  * 纯逻辑在 upload-core.ts,浏览器里的 studio 直接用那一份。 */
7
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
8
+ import { join, relative } from 'node:path';
7
9
  import { createClient } from '../client/index.ts';
8
10
  import { fileCookieStore } from '../client/node.ts';
9
11
  import type { BuiltTable } from './build.ts';
10
12
  import type { PublishSpec } from './spec.ts';
11
13
  import { uploadTables as uploadCore, type UploadOptions as CoreOptions, type UploadResult } from './upload-core.ts';
12
14
 
13
- export { describeConfig, type UploadResult, type UploadTable } from './upload-core.ts';
15
+ export { describeConfig, type BundleFileInput, type UploadResult, type UploadTable } from './upload-core.ts';
16
+
17
+ /** 把 vite build 出来的 dist/ 读成上传用的文件列表。路径用 /,和平台上的对象键一致 */
18
+ export function bundleFromDir(dir: string): { path: string; bytes: Uint8Array }[] {
19
+ const out: { path: string; bytes: Uint8Array }[] = [];
20
+ const walk = (d: string): void => {
21
+ for (const n of readdirSync(d).sort()) {
22
+ const p = join(d, n);
23
+ if (statSync(p).isDirectory()) walk(p);
24
+ else out.push({ path: relative(dir, p).split('\\').join('/'), bytes: new Uint8Array(readFileSync(p)) });
25
+ }
26
+ };
27
+ walk(dir);
28
+ return out;
29
+ }
14
30
 
15
31
  export interface UploadOptions extends Omit<CoreOptions, 'client'> {
16
32
  baseUrl?: string;
package/runtime/assets.ts CHANGED
@@ -5,15 +5,15 @@
5
5
  1. 用固定并发池,不用裸 Promise.all。五十张图同时 decode 会把主线程压死,
6
6
  进度条反而不动——看起来更慢。
7
7
  2. 进度按**字节**加权而不是按个数。按个数会出现「99% 卡住等那张 2MB 的图」。
8
+ 开发环境不知道字节数(bytes 为 null),退化成按个数。
8
9
  3. 所有图片都设 crossOrigin='anonymous'。游戏文件夹可能放在 CDN 上,
9
10
  而特效层迟早会把图画进 canvas;没有这一条,那时候画布会被污染,
10
11
  而且报错离原因很远。 */
11
- import type { AssetEntry, GameManifest, LoadProgress, LoadedAssets } from './types.ts';
12
+ import type { AssetBundle, AssetEntry } from '../sdk/index.ts';
13
+ import type { LoadProgress, LoadedAssets } from './types.ts';
12
14
 
13
15
  export interface LoadOptions {
14
- manifest: GameManifest;
15
- /** registry 给出的 key → 运行时 URL(已经过 import.meta.url 解析) */
16
- urls: Record<string, string>;
16
+ bundle: AssetBundle;
17
17
  onProgress?(p: LoadProgress): void;
18
18
  signal?: AbortSignal;
19
19
  audioContext?: AudioContext;
@@ -23,21 +23,24 @@ export interface LoadOptions {
23
23
  priority?: 'critical' | 'lazy';
24
24
  }
25
25
 
26
+ /* 有一个条目不知道大小,整组就按个数算:混着算会让那一个「零字节」瞬间完成,进度失真 */
27
+ const weightOf = (entries: AssetEntry[]): ((a: AssetEntry) => number) =>
28
+ entries.every(a => a.bytes !== null && a.bytes > 0) ? a => a.bytes! : () => 1;
29
+
26
30
  export async function loadAssets(opts: LoadOptions): Promise<LoadedAssets> {
27
- const { manifest, urls, onProgress, signal, concurrency = 6, priority = 'critical' } = opts;
28
- const entries = manifest.assets.filter(a => a.priority === priority);
31
+ const { bundle, onProgress, signal, concurrency = 6, priority = 'critical' } = opts;
32
+ const entries = bundle.entries.filter(a => a.priority === priority);
33
+ const weight = weightOf(entries);
29
34
 
30
35
  const images = new Map<string, HTMLImageElement>();
31
36
  const audios = new Map<string, AudioBuffer>();
32
37
  const failed: string[] = [];
33
38
 
34
- const total = entries.reduce((s, a) => s + a.bytes, 0) || 1;
39
+ const total = entries.reduce((s, a) => s + weight(a), 0) || 1;
35
40
  let loaded = 0, done = 0;
36
41
 
37
- const resolve = (a: AssetEntry): string => urls[a.key] ?? new URL(a.path, location.href).href;
38
-
39
42
  const bump = (a: AssetEntry): void => {
40
- loaded += a.bytes; done++;
43
+ loaded += weight(a); done++;
41
44
  onProgress?.({
42
45
  ratio: Math.min(1, loaded / total), loaded, total,
43
46
  done, count: entries.length, current: a.key
@@ -45,7 +48,7 @@ export async function loadAssets(opts: LoadOptions): Promise<LoadedAssets> {
45
48
  };
46
49
 
47
50
  async function one(a: AssetEntry): Promise<void> {
48
- const url = resolve(a);
51
+ const url = a.url;
49
52
  try {
50
53
  if (signal?.aborted) throw new DOMException('aborted', 'AbortError');
51
54
 
@@ -99,19 +102,20 @@ export async function loadAssets(opts: LoadOptions): Promise<LoadedAssets> {
99
102
  return {
100
103
  image: k => images.get(k) ?? null,
101
104
  audio: k => audios.get(k) ?? null,
102
- url: k => urls[k] ?? k,
105
+ url: k => bundle.entries.find(a => a.key === k)?.url ?? k,
103
106
  failed
104
107
  };
105
108
  }
106
109
 
107
110
  /** 后台预取 lazy 资源。不阻塞、不报错、不影响开场 */
108
- export function prefetchLazy(manifest: GameManifest, urls: Record<string, string>): void {
111
+ export function prefetchLazy(bundle: AssetBundle): void {
112
+ if (!bundle.entries.some(a => a.priority === 'lazy')) return;
109
113
  const run = (): void => {
110
- for (const a of manifest.assets) {
114
+ for (const a of bundle.entries) {
111
115
  if (a.priority !== 'lazy') continue;
112
116
  const l = document.createElement('link');
113
117
  l.rel = 'prefetch';
114
- l.href = urls[a.key] ?? a.path;
118
+ l.href = a.url;
115
119
  l.as = a.type === 'image' ? 'image' : a.type === 'audio' ? 'audio' : 'fetch';
116
120
  l.crossOrigin = 'anonymous';
117
121
  document.head.appendChild(l);
package/runtime/fonts.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  const injected = new Set<string>();
7
7
 
8
8
  export function ensureFonts(hrefs: readonly string[]): Promise<unknown> {
9
+ if (hrefs.length === 0) return Promise.resolve();
9
10
  for (const href of hrefs) {
10
11
  if (injected.has(href)) continue;
11
12
  injected.add(href);
package/runtime/index.ts CHANGED
@@ -1,3 +1,5 @@
1
1
  export * from './assets.ts';
2
2
  export * from './fonts.ts';
3
+ export * from './preload.ts';
4
+ export * from './registry.ts';
3
5
  export * from './types.ts';
@@ -0,0 +1,38 @@
1
+ /* 平台侧的预加载:拿到游戏模块之后、mount 之前调一次。
2
+
3
+ 玩家站点的 iframe、dev-host、studio 预览三处共用这一份——进度条的分母、字体的等待、
4
+ lazy 资源的预取时机,只在这里定义一次。游戏对此无感:它的 mount 假定 critical 资源已经到齐。 */
5
+ import type { AssetBundle } from '../sdk/index.ts';
6
+ import { loadAssets, prefetchLazy } from './assets.ts';
7
+ import { ensureFonts } from './fonts.ts';
8
+ import type { LoadProgress, LoadedAssets } from './types.ts';
9
+
10
+ export interface PreloadOptions {
11
+ onProgress?(p: LoadProgress): void;
12
+ signal?: AbortSignal;
13
+ }
14
+
15
+ export interface Preloaded {
16
+ assets: LoadedAssets;
17
+ /** mount 之后调:lazy 资源不该和开场抢带宽 */
18
+ prefetchLazy(): void;
19
+ }
20
+
21
+ const EMPTY: LoadedAssets = { image: () => null, audio: () => null, url: k => k, failed: [] };
22
+
23
+ export async function preloadGame(bundle: AssetBundle | undefined, opts: PreloadOptions = {}): Promise<Preloaded> {
24
+ // 老包或者没有资源的游戏:没有清单就没有可等的
25
+ if (!bundle) return { assets: EMPTY, prefetchLazy() {} };
26
+
27
+ const [assets] = await Promise.all([
28
+ loadAssets({ bundle, signal: opts.signal, onProgress: opts.onProgress }),
29
+ ensureFonts(bundle.fonts)
30
+ ]);
31
+ /* 字体和资源并行,谁后到都可能让最后一帧进度停在 <100%;
32
+ 收尾补一条满格,平台的条能走到底再切到画面 */
33
+ const count = bundle.entries.filter(a => a.priority === 'critical').length;
34
+ opts.onProgress?.({ ratio: 1, loaded: 1, total: 1, done: count, count });
35
+
36
+ // 图片句柄要活到游戏卸载:opaque origin 下 Chrome 不落盘缓存,内存里的这份就是唯一一份
37
+ return { assets, prefetchLazy: () => prefetchLazy(bundle) };
38
+ }
@@ -0,0 +1,74 @@
1
+ /* 资源注册表。目录即清单:
2
+ src/assets/critical/ 首屏必需,平台开场前加载完
3
+ src/assets/lazy/ 按需,挂载后后台预取
4
+
5
+ 游戏不写任何加载代码:组件用 assets.url(key) 取地址,index.ts 把 assets.bundle() 交给平台,
6
+ 平台在 mount 之前按清单预加载并画进度条。谁去加载、进度怎么算,游戏不知道也不该知道。
7
+
8
+ glob 写在这个库文件里而不是每个游戏一份:以 / 开头的模式按 vite 的 root 解析,
9
+ 而游戏的 vite root 就是游戏目录,所以从任何位置 import 这个模块看到的都是该游戏的 src/assets。
10
+ 代价是 root 不是游戏目录时(比如玩家站点自己 import 了 runtime)清单为空——那里也没人调 bundle()。
11
+
12
+ ?no-inline 不是可选项。Vite 的 lib 模式会**无条件**把资源 base64 内联进 JS
13
+ (见 vite/dist/node/chunks/config.js 的 shouldInline:build.lib 为真时直接
14
+ return true,assetsInlineLimit 完全失效),唯一的逃生口就是这个 query。
15
+ 漏掉的话构建照样成功,只是资源悄悄胖进 index.js、不在 manifest 里、
16
+ 平台预加载不到——所以 manifest 插件里还有一道 base64 告警兜底。 */
17
+ /// <reference types="vite/client" />
18
+ import type { AssetBundle, AssetEntry, AssetType } from '../sdk/index.ts';
19
+
20
+ const critical = import.meta.glob('/src/assets/critical/**/*', {
21
+ eager: true, query: '?no-inline', import: 'default'
22
+ }) as Record<string, string>;
23
+
24
+ const lazy = import.meta.glob('/src/assets/lazy/**/*', {
25
+ eager: true, query: '?no-inline', import: 'default'
26
+ }) as Record<string, string>;
27
+
28
+ /** key 形如 'critical/coco-classic.jpg',value 是带 hash 的运行时 URL */
29
+ const table: Record<string, string> = {};
30
+ for (const [k, v] of Object.entries({ ...critical, ...lazy })) {
31
+ table[k.replace(/^\/src\/assets\//, '')] = v;
32
+ }
33
+
34
+ /* 两个构建期才知道的值。
35
+ 字体列表由 vite-config 用 define 注入(dev 和 build 都有);
36
+ 字节数只有 generateBundle 之后才知道,manifest 插件把它替换进这个字符串字面量——
37
+ 开发环境没人替换,字面量原样留着,进度退化成按个数走 */
38
+ declare const __GAMEKIT_FONTS__: readonly string[] | undefined;
39
+ const BYTES_PLACEHOLDER = '__GAMEKIT_ASSET_BYTES__';
40
+ const bytesOf: Record<string, number> | null = BYTES_PLACEHOLDER.startsWith('{') ? JSON.parse(BYTES_PLACEHOLDER) as Record<string, number> : null;
41
+ const fonts: readonly string[] = typeof __GAMEKIT_FONTS__ !== 'undefined' ? __GAMEKIT_FONTS__ : [];
42
+
43
+ const TYPE_BY_EXT: Record<string, AssetType> = {
44
+ jpg: 'image', jpeg: 'image', png: 'image', webp: 'image', avif: 'image', svg: 'image', gif: 'image',
45
+ mp3: 'audio', ogg: 'audio', wav: 'audio', m4a: 'audio',
46
+ woff: 'font', woff2: 'font', ttf: 'font', otf: 'font',
47
+ json: 'json', mp4: 'video', webm: 'video'
48
+ };
49
+
50
+ const entryOf = (key: string): AssetEntry => ({
51
+ key,
52
+ url: table[key],
53
+ type: TYPE_BY_EXT[key.split('.').pop()!.toLowerCase()] ?? 'binary',
54
+ priority: key.startsWith('lazy/') ? 'lazy' : 'critical',
55
+ bytes: bytesOf?.[key] ?? null
56
+ });
57
+
58
+ export const assets = {
59
+ url(key: string): string {
60
+ const u = table[key];
61
+ if (!u) throw new Error(`[assets] 未知资源: ${key}(现有: ${Object.keys(table).join(', ')})`);
62
+ return u;
63
+ },
64
+ has: (key: string): boolean => key in table,
65
+ keys: Object.keys(table),
66
+ all: table as Readonly<Record<string, string>>,
67
+ /** 交给平台的清单。critical 在前,同优先级按名字排,和 manifest.json 一致 */
68
+ bundle(): AssetBundle {
69
+ const entries = Object.keys(table).map(entryOf);
70
+ entries.sort((x, y) =>
71
+ x.priority === y.priority ? x.key.localeCompare(y.key) : x.priority === 'critical' ? -1 : 1);
72
+ return { fonts: [...fonts], entries };
73
+ }
74
+ };