gamekit777 0.1.5 → 0.1.7

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 (50) hide show
  1. package/README.md +1 -1
  2. package/create-game/scaffold.ts +81 -16
  3. package/create-game/template/base/game.meta.json +9 -0
  4. package/create-game/template/{game.ts.tmpl → base/game.ts} +15 -11
  5. package/create-game/template/{package.json.tmpl → base/package.json} +7 -3
  6. package/create-game/template/{src/components/App.svelte.tmpl → base/src/components/App.svelte} +8 -3
  7. package/create-game/template/{src/context.svelte.ts.tmpl → base/src/context.svelte.ts} +5 -5
  8. package/create-game/template/{src → base/src}/rules/const.ts +2 -1
  9. package/create-game/template/{src/rules/restore.ts.tmpl → base/src/rules/restore.ts} +7 -5
  10. package/create-game/template/{src/rules/schema.ts.tmpl → base/src/rules/schema.ts} +2 -2
  11. package/create-game/template/{src/rules/simulate.ts.tmpl → base/src/rules/simulate.ts} +2 -2
  12. package/create-game/template/{src/rules/types.ts.tmpl → base/src/rules/types.ts} +2 -2
  13. package/create-game/template/{src/state/game.svelte.ts.tmpl → base/src/state/game.svelte.ts} +14 -7
  14. package/create-game/template/{src/view/mount.svelte.ts.tmpl → base/src/view/mount.svelte.ts} +6 -4
  15. package/create-game/template/{src/view/present.ts.tmpl → base/src/view/present.ts} +2 -2
  16. package/create-game/template/{test/e2e.test.ts.tmpl → base/test/e2e.test.ts} +4 -1
  17. package/create-game/template/base/tsconfig.json +8 -0
  18. package/create-game/template/{CLAUDE.md.tmpl → overlay/CLAUDE.md.tmpl} +13 -3
  19. package/dev-host/DevShell.svelte +237 -73
  20. package/dev-host/index.ts +1 -0
  21. package/dev-host/layout.ts +80 -0
  22. package/dev-host/platform.svelte.ts +23 -4
  23. package/dev-host/run.ts +8 -1
  24. package/dev-host/theme.css +142 -10
  25. package/package.json +1 -1
  26. package/runtime/index.ts +1 -0
  27. package/runtime/meter.ts +72 -0
  28. package/runtime/registry.ts +5 -1
  29. package/sdk/host.ts +8 -0
  30. package/sdk/stage.ts +97 -11
  31. package/studio/app/App.svelte +92 -10
  32. package/studio/app/panels/PreviewPanel.svelte +4 -0
  33. package/studio/cli.ts +7 -6
  34. package/studio/preview/entry.ts +10 -1
  35. package/studio/src/engine.ts +5 -1
  36. package/studio/src/mcp.ts +9 -7
  37. package/studio/src/scaffold.ts +38 -15
  38. package/studio/src/studio-plugin.ts +29 -13
  39. package/create-game/template/game.meta.json.tmpl +0 -8
  40. package/create-game/template/tsconfig.json +0 -22
  41. /package/create-game/template/{scripts/verify.ts.tmpl → base/scripts/verify.ts} +0 -0
  42. /package/create-game/template/{src → base/src}/assets/critical/.gitkeep +0 -0
  43. /package/create-game/template/{src → base/src}/assets/lazy/.gitkeep +0 -0
  44. /package/create-game/template/{src/index.ts.tmpl → base/src/index.ts} +0 -0
  45. /package/create-game/template/{src → base/src}/styles/animations.css +0 -0
  46. /package/create-game/template/{src → base/src}/styles/global.css +0 -0
  47. /package/create-game/template/{src → base/src}/view/bridge.svelte.ts +0 -0
  48. /package/create-game/template/{vite.config.ts → base/vite.config.ts} +0 -0
  49. /package/create-game/template/{vitest.config.ts → base/vitest.config.ts} +0 -0
  50. /package/create-game/template/{_gitignore → overlay/_gitignore} +0 -0
package/README.md CHANGED
@@ -14,7 +14,7 @@
14
14
  ## 开始
15
15
 
16
16
  ```sh
17
- bunx gamekit777 new # 一个能跑的抛硬币(my-game)+ bun install;名字改 game.meta.json
17
+ bunx gamekit777 new [名字] # 一个能跑的抛硬币 + bun install:不给名字就地用当前空目录,给名字生成到子目录
18
18
  cd my-game
19
19
  bun run dev # 起 studio:http://127.0.0.1:4301/ ,预览 / 调参 / 建表 / Case / 发布
20
20
  ```
@@ -1,21 +1,37 @@
1
1
  /* 新游戏脚手架的库函数面。
2
2
  *
3
- * 模板不是空壳,是一个跑得通的最小游戏(抛硬币):装完就能 bun run dev、
4
- * bun run verify、bun run test、bun run build 全绿。
5
- * 从一个能跑的东西开始改,比对着一堆 TODO 填空可靠得多。
3
+ * 模板不是空壳,而是仓库里那个跑得通的最小游戏 games/coin-flip:脚手架把它整个复制出去、
4
+ * 换掉名字,装完就能 bun run dev、verify、test、build 全绿。从一个能跑的东西开始改,
5
+ * 比对着一堆 TODO 填空可靠得多;只维护一份也就不会再出现「模板和 coin-flip 漂开了」。
6
+ *
7
+ * 复制时剔掉仓库专属的东西(remote-e2e、平台分配的 gameId、缓存),再叠上只有新项目才需要的
8
+ * overlay/(给创作者 AI 的 CLAUDE.md、.gitignore)。
6
9
  *
7
10
  * 抽成库函数是因为有三个调用方:CLI、studio 的 scaffold 工具、以及
8
11
  * npm 打包脚本的产物自验——最后一个尤其要紧,它生成的项目就是创作者的真实起点,
9
12
  * 用别的东西代替就验不到「模板能不能脱离仓库跑」。 */
10
13
  import { cpSync, existsSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
11
- import { dirname, join } from 'node:path';
14
+ import { basename, dirname, join, relative, sep } from 'node:path';
12
15
  import { fileURLToPath } from 'node:url';
13
16
 
14
17
  const HERE = dirname(fileURLToPath(import.meta.url));
15
- /* 仓库内是 packages/create-game/{src,template},压平进 npm 包后是 create-game/{scaffold.ts,template}——
16
- 两种布局都要找得到 */
17
- export const TEMPLATE_DIR = [join(HERE, '..', 'template'), join(HERE, 'template')].find(existsSync)
18
- ?? join(HERE, '..', 'template');
18
+ /* 仓库内:底是 games/coin-flip,叠层是 packages/create-game/overlay;
19
+ 压平进 npm 包后:create-game/template/{base,overlay}(build-npm 把两处一起搬过去) */
20
+ export const BASE_DIR = [join(HERE, '..', '..', '..', 'games', 'coin-flip'), join(HERE, 'template', 'base')].find(existsSync)
21
+ ?? join(HERE, 'template', 'base');
22
+ export const OVERLAY_DIR = [join(HERE, '..', 'overlay'), join(HERE, 'template', 'overlay')].find(existsSync)
23
+ ?? join(HERE, 'template', 'overlay');
24
+
25
+ /** 底里不该带进新项目的:装出来的、算出来的、只对仓库有意义的 */
26
+ export const BASE_SKIP = ['node_modules', 'dist', '.studio', 'studio', 'scripts/remote-e2e.ts'];
27
+ export const isSkipped = (rel: string): boolean => {
28
+ const posix = rel.split(sep).join('/');
29
+ return posix === '' ? false : BASE_SKIP.some((s) => posix === s || posix.startsWith(`${s}/`)) || posix.endsWith('.log');
30
+ };
31
+
32
+ /** 底那个游戏的名字,脚手架把它们换成新游戏的 */
33
+ const BASE_ID = 'coin-flip';
34
+ const BASE_TITLE = '抛硬币';
19
35
 
20
36
  export const ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
21
37
 
@@ -40,29 +56,78 @@ const walk = (dir: string): string[] =>
40
56
  });
41
57
 
42
58
  /** 驼峰化:coin-flip → CoinFlip,用作类型名前缀 */
43
- const pascal = (id: string): string =>
59
+ export const pascal = (id: string): string =>
44
60
  id.split('-').map((w) => w[0]!.toUpperCase() + w.slice(1)).join('');
45
61
 
62
+ /** 存在也不算「非空」的东西:新建仓库时常见的那几样 */
63
+ const IGNORABLE = new Set(['README.md', 'LICENSE', 'CLAUDE.md']);
64
+
65
+ /* 仓库内的 games/<id> 和仓库外的创作者项目要的不一样:
66
+ 仓库内 tsconfig 继承根的 tsconfig.base.json、dev 脚本指到本地 8787/5180;
67
+ 仓库外没有这两样——tsconfig 写全、dev 脚本不带参数指平台 */
68
+ const inRepo = (dest: string): boolean =>
69
+ basename(dirname(dest)) === 'games' && existsSync(join(dest, '..', '..', 'tsconfig.base.json'));
70
+
71
+ /** 和根 tsconfig.base.json 同一组选项。仓库外没有那个文件可继承,只能抄一份进项目 */
72
+ const STANDALONE_TSCONFIG = {
73
+ extends: '@tsconfig/svelte/tsconfig.json',
74
+ compilerOptions: {
75
+ target: 'ES2022', lib: ['ES2022', 'DOM', 'DOM.Iterable'], module: 'ESNext', moduleResolution: 'bundler',
76
+ strict: true, noUnusedLocals: true, noUnusedParameters: true, noImplicitOverride: true, noFallthroughCasesInSwitch: true,
77
+ noEmit: true, isolatedModules: true, verbatimModuleSyntax: true, skipLibCheck: true, forceConsistentCasingInFileNames: true,
78
+ allowImportingTsExtensions: true, types: [],
79
+ },
80
+ include: ['game.ts', 'src', 'scripts', 'test'],
81
+ };
82
+
46
83
  export function scaffoldGame(o: ScaffoldOptions): ScaffoldResult {
47
84
  if (!ID_PATTERN.test(o.id)) {
48
85
  throw new Error(`id "${o.id}" 要是小写短横线形式——它同时是包名、输出目录名和 CSS 命名空间`);
49
86
  }
50
- if (existsSync(o.dest)) throw new Error(`${o.dest} 已经存在`);
87
+ if (!existsSync(BASE_DIR)) throw new Error(`脚手架的底 ${BASE_DIR} 不存在`);
88
+ /* 目标目录可以已存在,但必须是「空」的:只有 .git、README、编辑器配置这类不算内容。
89
+ 有 package.json / game.ts / src 就是别的项目,往里铺模板会把它搅成两个项目的混合物 */
90
+ if (existsSync(o.dest)) {
91
+ const left = readdirSync(o.dest).filter((f) => !IGNORABLE.has(f) && !f.startsWith('.'));
92
+ if (left.length) throw new Error(`${o.dest} 不是空目录(有 ${left.slice(0, 5).join('、')}${left.length > 5 ? '…' : ''})`);
93
+ }
51
94
 
52
- cpSync(TEMPLATE_DIR, o.dest, { recursive: true });
95
+ cpSync(BASE_DIR, o.dest, { recursive: true, filter: (src) => !isSkipped(relative(BASE_DIR, src)) });
96
+ if (existsSync(OVERLAY_DIR)) cpSync(OVERLAY_DIR, o.dest, { recursive: true });
53
97
 
98
+ const title = o.title ?? o.id;
99
+ const repo = inRepo(o.dest);
54
100
  const files: string[] = [];
55
101
  for (const file of walk(o.dest)) {
56
102
  if (/\.(png|jpe?g|gif|webp|ico|woff2?)$/i.test(file)) { files.push(file); continue; }
57
103
  let out = readFileSync(file, 'utf8')
104
+ // overlay 里的占位符
58
105
  .replaceAll('__ID__', o.id)
59
- .replaceAll('__TITLE__', o.title ?? o.id)
60
- .replaceAll('__PASCAL__', pascal(o.id));
61
- if (o.gamekitSpec && /package\.json(\.tmpl)?$/.test(file)) {
62
- out = out.replace(/("gamekit777"\s*:\s*)"[^"]*"/, `$1${JSON.stringify(o.gamekitSpec)}`);
106
+ .replaceAll('__TITLE__', title)
107
+ .replaceAll('__PASCAL__', pascal(o.id))
108
+ // 底里 coin-flip 自己的名字:包名、slug、类型前缀、标题
109
+ .replaceAll(pascal(BASE_ID), pascal(o.id))
110
+ .replaceAll(BASE_ID, o.id)
111
+ .replaceAll(BASE_TITLE, title);
112
+ const name = relative(o.dest, file).split(sep).join('/');
113
+ if (name === 'package.json') {
114
+ const pkg = JSON.parse(out) as { name: string; exports?: unknown; scripts: Record<string, string>; dependencies: Record<string, string> };
115
+ pkg.name = `@game/${o.id}`;
116
+ delete pkg.exports; // 只有仓库内的测试会 import 别的游戏
117
+ delete pkg.scripts.remote; // remote-e2e.ts 没复制
118
+ if (!repo) pkg.scripts.dev = 'gamekit777 studio --no-mcp';
119
+ if (o.gamekitSpec) pkg.dependencies.gamekit777 = o.gamekitSpec;
120
+ out = `${JSON.stringify(pkg, null, 2)}\n`;
121
+ } else if (name === 'game.meta.json') {
122
+ const meta = JSON.parse(out) as Record<string, unknown>;
123
+ delete meta.gameId; // 平台第一次发布时才分配,不是底的那个
124
+ meta.id = o.id; meta.title = title;
125
+ out = `${JSON.stringify(meta, null, 2)}\n`;
126
+ } else if (name === 'tsconfig.json' && !repo) {
127
+ out = `${JSON.stringify(STANDALONE_TSCONFIG, null, 2)}\n`;
63
128
  }
64
129
  writeFileSync(file, out);
65
- // 模板文件带 .tmpl 后缀,免得脚手架自己的 lint / 构建去处理它们;
130
+ // overlay 里带 .tmpl 后缀,免得脚手架自己的 lint / 构建去处理它们;
66
131
  // .gitignore 叫 _gitignore,因为 npm 打包会把包里的 .gitignore 改名或丢掉
67
132
  let final = file.endsWith('.tmpl') ? file.slice(0, -5) : file;
68
133
  if (final.endsWith('/_gitignore')) final = `${final.slice(0, -'_gitignore'.length)}.gitignore`;
@@ -0,0 +1,9 @@
1
+ {
2
+ "id": "coin-flip",
3
+ "title": "抛硬币",
4
+ "gameId": "95d28dcf-fe8f-45f0-a03b-f68523efd865",
5
+ "version": "1.0.0",
6
+ "aspect": "16:9",
7
+ "orientation": "any",
8
+ "fonts": []
9
+ }
@@ -1,23 +1,27 @@
1
- /* __TITLE__ 的清单。只依赖 gamekit777/sdk 和自己的 rules/,Node 和浏览器都能 import:
2
- studio 用它探测和建表、打包流水线用它采样、浏览器入口把它和 mount 拼在一起。
3
-
4
- 模板是抛硬币:两面对称,**只有一种赔付分布**,所以一张表就够。
5
- `side` 不改变分布,只改变某一局落在哪面——服务端不需要知道它,玩家运行时自由选。
6
- book 因此可以完全不传:payout > 0 就说明硬币落在你押的那面(见 rules/restore.ts)。
1
+ /* 抛硬币的清单。只依赖 gamekit777/sdk 和自己的 rules/,Node 和浏览器都能 import:
2
+ 打包流水线用它采样、studio 用它探测和建表、浏览器入口把它和 mount 拼在一起。
7
3
 
4
+ 这个游戏同时是脚手架的底:`gamekit777 new` 把整个目录复制出去再换名字。
8
5
  改成你自己的游戏时,先用 studio 的 probe_table 看看:
9
6
  哪些字段改变赔付分布 → 写进 modes(一张表 = 一种分布),只写那几个字段
10
7
  哪些字段只改变这一局 → 保持自由,用 book 编码演出(参考 coco 的 book.ts)
11
8
  赔付必须是 centi 精度、必须是注额的线性函数——这两条 probe 会替你查
12
9
  逻辑侧三个必填:schema 说有哪些参数,round 说结果怎么算,paytable 说赔多少。
13
- normalize / validate / modeOf 不用写——它们由 schema 和 modes 推导。 */
10
+ normalize / validate / modeOf 不用写——它们由 schema 和 modes 推导。
11
+
12
+ 两面对称,只有一种赔付分布,所以一张表就够。`side` 不改变分布,只改变某一局落在哪面——
13
+ 服务端不需要知道它,玩家运行时自由选。book 因此可以完全不传:payout > 0 就说明
14
+ 硬币落在你押的那面(见 rules/restore.ts)。
15
+
16
+ 纯度由 scripts/verify.ts 的运行时桩保证:把 Math.random 和 crypto 换成会抛的假实现
17
+ 再调 round(),查的是实际执行路径含全部传递依赖。 */
14
18
  import { createRng, defineGame, normalizeBySchema, type RoundInput, type RoundResult } from 'gamekit777/sdk';
15
19
  import meta from './game.meta.json' with { type: 'json' };
16
20
  import { HOUSE_EDGE, MULT } from './src/rules/const.ts';
17
21
  import { restore } from './src/rules/restore.ts';
18
22
  import { schema } from './src/rules/schema.ts';
19
23
  import { simulate } from './src/rules/simulate.ts';
20
- import type { __PASCAL__Config, __PASCAL__Outcome } from './src/rules/types.ts';
24
+ import type { CoinFlipConfig, CoinFlipOutcome } from './src/rules/types.ts';
21
25
 
22
26
  export { schema, simulate, restore };
23
27
  export * from './src/rules/types.ts';
@@ -28,7 +32,7 @@ export * from './src/rules/types.ts';
28
32
  * 随机数的抽取顺序是契约的一部分:flip 子流 below(2) 一次。
29
33
  * 已发布 mode 的这段逻辑不能改——历史局的 book 要靠它解;要改就换 slug。
30
34
  */
31
- export function round(input: RoundInput<__PASCAL__Config>): RoundResult<__PASCAL__Outcome> {
35
+ export function round(input: RoundInput<CoinFlipConfig>): RoundResult<CoinFlipOutcome> {
32
36
  const cfg = normalizeBySchema(schema, input.config);
33
37
  const flip = createRng(input.seed).fork('flip').below(2);
34
38
  const outcome = simulate(flip, cfg);
@@ -44,11 +48,11 @@ export function round(input: RoundInput<__PASCAL__Config>): RoundResult<__PASCAL
44
48
  };
45
49
  }
46
50
 
47
- export default defineGame<__PASCAL__Config, __PASCAL__Outcome>({
51
+ export default defineGame<CoinFlipConfig, CoinFlipOutcome>({
48
52
  // 名字只在 game.meta.json 里改:slug 同时是大厅里的 id、产物目录名和 CSS 命名空间
49
53
  slug: meta.id,
50
54
  title: meta.title,
51
- description: '押正面还是反面。',
55
+ description: '押正面还是反面。猜中赔 1.96 倍,RTP 98%。',
52
56
  rtp: HOUSE_EDGE,
53
57
  sims: 100_000,
54
58
  schema,
@@ -1,14 +1,18 @@
1
1
  {
2
- "name": "@game/__ID__",
2
+ "name": "@game/coin-flip",
3
3
  "private": true,
4
4
  "version": "1.0.0",
5
5
  "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts"
8
+ },
6
9
  "scripts": {
7
- "dev": "gamekit777 studio --no-mcp",
10
+ "dev": "GAMEKIT_BASE_URL=http://127.0.0.1:8787 GAMEKIT_SITE_URL=http://localhost:5180 gamekit777 studio --no-mcp",
8
11
  "build": "svelte-check --tsconfig ./tsconfig.json && vite build",
9
12
  "check": "svelte-check --tsconfig ./tsconfig.json",
10
13
  "test": "vitest run",
11
- "verify": "bun scripts/verify.ts"
14
+ "verify": "bun scripts/verify.ts",
15
+ "remote": "bun scripts/remote-e2e.ts"
12
16
  },
13
17
  "dependencies": {
14
18
  "gamekit777": "workspace:*"
@@ -31,6 +31,8 @@
31
31
  {game.last.win ? '猜中了' : '没猜中'}
32
32
  {:else if game.live}
33
33
  抛出中…
34
+ {:else if game.layout === 'mobile'}
35
+ 在参数里选一面,下注开始
34
36
  {:else}
35
37
  在左边选一面,下注开始
36
38
  {/if}
@@ -47,9 +49,12 @@
47
49
  </div>
48
50
 
49
51
  <style>
50
- /* 不写 border-radius:外框和裁切归平台容器,游戏只管自己那块内容 */
52
+ /* 不写 border-radius:外框和裁切归平台容器,游戏只管自己那块内容。
53
+ 上下内边距加上平台给的安全区(--gk-safe-*,容器上由平台写,手机 HUD 盖住的那几段):
54
+ 硬币和文案落在没被盖住的那块里,被盖住的边只剩底色 */
51
55
  .game{position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;
52
- height:100%;min-height:240px;padding:28px;
56
+ height:100%;min-height:240px;
57
+ padding:calc(28px + var(--gk-safe-top,0px)) calc(28px + var(--gk-safe-right,0px)) calc(28px + var(--gk-safe-bottom,0px)) calc(28px + var(--gk-safe-left,0px));
53
58
  background:var(--bg);color:var(--ink);font-family:var(--txt)}
54
59
  .head{margin:0;font-size:13px;color:var(--dim)}
55
60
  .head b{color:var(--ink);font-size:16px}
@@ -61,7 +66,7 @@
61
66
  .foot.win{color:var(--win);font-weight:700}
62
67
  .foot.lose{color:var(--lose)}
63
68
 
64
- .toast{position:absolute;left:50%;top:22%;transform:translate(-50%,0);pointer-events:none;
69
+ .toast{position:absolute;left:50%;top:calc(var(--gk-safe-top,0px) + 22%);transform:translate(-50%,0);pointer-events:none;
65
70
  display:flex;align-items:baseline;gap:10px;padding:10px 22px 12px;border-radius:18px;
66
71
  background:var(--panel);color:var(--ink);box-shadow:0 0 0 3px var(--win),0 10px 30px rgba(0,0,0,.45);
67
72
  animation:toast 1400ms ease-out forwards}
@@ -1,22 +1,22 @@
1
1
  import { getContext, setContext } from 'svelte';
2
2
  import type { Host } from 'gamekit777/sdk';
3
- import type { __PASCAL__Config } from './rules/types.ts';
3
+ import type { CoinFlipConfig } from './rules/types.ts';
4
4
  import { Game } from './state/game.svelte';
5
5
 
6
- const KEY = Symbol('__ID__');
6
+ const KEY = Symbol('coin-flip');
7
7
 
8
8
  /** 一个游戏实例的全部状态。模块级不留任何可变状态,同页挂两个实例是安全的 */
9
9
  export interface Instance {
10
10
  game: Game;
11
- host: Host<__PASCAL__Config>;
11
+ host: Host<CoinFlipConfig>;
12
12
  }
13
13
 
14
- export const createInstance = (host: Host<__PASCAL__Config>): Instance => ({ game: new Game(), host });
14
+ export const createInstance = (host: Host<CoinFlipConfig>): Instance => ({ game: new Game(), host });
15
15
 
16
16
  /* 用 context 而不是模块级单例:组件一律 getInstance(),不 import 任何状态对象 */
17
17
  export const setInstance = (i: Instance): void => { setContext(KEY, i); };
18
18
  export const getInstance = (): Instance => {
19
19
  const i = getContext<Instance>(KEY);
20
- if (!i) throw new Error('[__ID__] 组件不在 <App> 之下,拿不到游戏实例');
20
+ if (!i) throw new Error('[coin-flip] 组件不在 <App> 之下,拿不到游戏实例');
21
21
  return i;
22
22
  };
@@ -5,5 +5,6 @@ export const HOUSE_EDGE = 0.98;
5
5
  /** 中奖概率:抛硬币就是一半 */
6
6
  export const WIN_CHANCE = 0.5;
7
7
  export const MULT = HOUSE_EDGE / WIN_CHANCE;
8
- /** 倍率 ×100 = payoutCenti。平台只存整数 payoutCenti,restore 按它反推 */
8
+
9
+ /** 倍率的定点尺度。平台按 winCents = betCents/100 × payoutCenti 赔付,两边必须是同一个 100 */
9
10
  export const PAYOUT_SCALE = 100;
@@ -1,8 +1,10 @@
1
1
  /* 表模型下的还原:把平台抽中的那一行变回一局可演出的结果。
2
2
  *
3
- * 抛硬币是最省的那一档——**不需要任何演出数据**。
3
+ * coin-flip 是最省的那一档——**不需要任何演出数据**。
4
4
  * 猜中了硬币就落在你押的那面,猜错了就落在另一面,payout 已经把这件事说完了。
5
- * 所以这张表只有两行,book 一个字节都不传(game.ts 里 book: 'none')。
5
+ * 所以这张表只有两行,book 一个字节都不传。
6
+ *
7
+ * 押哪一面(side)因此可以保持玩家自由选:它不改变赔付分布,服务端也不需要知道。
6
8
  *
7
9
  * 改成你自己的游戏时:
8
10
  * 所有影响结果的字段都在 modes 里 → 删掉这个文件,game.ts 里不写 book 和 restore,
@@ -13,14 +15,14 @@
13
15
  import { normalizeBySchema, type Restore } from 'gamekit777/sdk';
14
16
  import { PAYOUT_SCALE } from './const.ts';
15
17
  import { schema } from './schema.ts';
16
- import type { __PASCAL__Config, __PASCAL__Outcome } from './types.ts';
18
+ import type { CoinFlipConfig, CoinFlipOutcome } from './types.ts';
17
19
 
18
- export const restore: Restore<__PASCAL__Config, __PASCAL__Outcome> = (input) => {
20
+ export const restore: Restore<CoinFlipConfig, CoinFlipOutcome> = (input) => {
19
21
  const { side } = normalizeBySchema(schema, input.config);
20
22
  const win = input.payoutCenti > 0;
21
23
  const mult = input.payoutCenti / PAYOUT_SCALE;
22
24
 
23
- const outcome: __PASCAL__Outcome = {
25
+ const outcome: CoinFlipOutcome = {
24
26
  side,
25
27
  landed: win ? side : side === 'heads' ? 'tails' : 'heads',
26
28
  win,
@@ -1,11 +1,11 @@
1
1
  import type { ConfigSchema } from 'gamekit777/sdk';
2
- import type { __PASCAL__Config } from './types.ts';
2
+ import type { CoinFlipConfig } from './types.ts';
3
3
 
4
4
  /* 平台按这份声明自动渲染设置控件,它不认识这个游戏。
5
5
  affectsOutcome 影响结果 → 下注时冻结、随 RoundInput 上送、进 proof
6
6
  lock 什么时候不许改
7
7
  ui 谁渲染:'host' 平台的表单,'stage' 游戏画布内(仍然受控) */
8
- export const schema: ConfigSchema<__PASCAL__Config> = {
8
+ export const schema: ConfigSchema<CoinFlipConfig> = {
9
9
  side: {
10
10
  kind: 'enum',
11
11
  options: [
@@ -1,8 +1,8 @@
1
1
  /* 判定。纯函数:不碰 DOM、不碰时间、不碰全局状态、不自取随机数。 */
2
2
  import { MULT } from './const.ts';
3
- import type { __PASCAL__Config, __PASCAL__Outcome } from './types.ts';
3
+ import type { CoinFlipConfig, CoinFlipOutcome } from './types.ts';
4
4
 
5
- export function simulate(flip: number, c: __PASCAL__Config): __PASCAL__Outcome {
5
+ export function simulate(flip: number, c: CoinFlipConfig): CoinFlipOutcome {
6
6
  const landed = flip === 0 ? 'heads' : 'tails';
7
7
  const win = landed === c.side;
8
8
  return {side: c.side, landed, win, mult: win ? MULT : 0};
@@ -1,11 +1,11 @@
1
1
  /** 影响结果的参数。平台在下注这一刻冻结一份传进 round() */
2
- export interface __PASCAL__Config {
2
+ export interface CoinFlipConfig {
3
3
  /** 押正面还是反面 */
4
4
  side: 'heads' | 'tails';
5
5
  }
6
6
 
7
7
  /** 一局的完整结果快照。演出层只读它,不做任何判定 */
8
- export interface __PASCAL__Outcome {
8
+ export interface CoinFlipOutcome {
9
9
  side: 'heads' | 'tails';
10
10
  landed: 'heads' | 'tails';
11
11
  win: boolean;
@@ -2,24 +2,25 @@
2
2
 
3
3
  设置来自平台(只读投影),结果来自 round(),这里只放「演到哪一步了」。
4
4
  做成可实例化的类而不是模块级单例:同页挂两个实例时各自一套状态。 */
5
- import type { ViewSettings } from 'gamekit777/sdk';
6
- import type { __PASCAL__Config, __PASCAL__Outcome } from '../rules/types.ts';
5
+ import { DESKTOP_STAGE, type SafeInsets, type StageInfo, type StageLayout, type ViewSettings } from 'gamekit777/sdk';
6
+ import type { CoinFlipConfig, CoinFlipOutcome } from '../rules/types.ts';
7
7
  import { schema } from '../rules/schema.ts';
8
8
 
9
9
  export type Phase = 'idle' | 'playing' | 'over';
10
10
 
11
- const DEFAULT_CONFIG: __PASCAL__Config = { side: schema.side.default };
11
+ const DEFAULT_CONFIG: CoinFlipConfig = { side: schema.side.default };
12
12
  const DEFAULT_VIEW: ViewSettings = {
13
13
  sound: true, volume: 1, turbo: false, reducedMotion: false,
14
14
  skin: 'default', locale: 'zh-CN', autoActive: false
15
15
  };
16
16
 
17
17
  export class Game {
18
- #config = $state<__PASCAL__Config>(DEFAULT_CONFIG);
18
+ #config = $state<CoinFlipConfig>(DEFAULT_CONFIG);
19
19
  #view = $state<ViewSettings>(DEFAULT_VIEW);
20
+ #stage = $state<StageInfo>(DESKTOP_STAGE);
20
21
 
21
22
  /* 影响结果的参数,来自 host.config */
22
- get side(): __PASCAL__Config['side'] { return this.#config.side; }
23
+ get side(): CoinFlipConfig['side'] { return this.#config.side; }
23
24
 
24
25
  /* 不影响结果的表现参数,来自 host.view */
25
26
  get turbo(): boolean { return this.#view.turbo; }
@@ -27,12 +28,17 @@ export class Game {
27
28
  get reducedMotion(): boolean { return this.#view.reducedMotion; }
28
29
  get autoActive(): boolean { return this.#view.autoActive; }
29
30
 
31
+ /* 所在的舞台,来自 host.stage:桌面还是手机、被平台 HUD 盖住的边。
32
+ CSS 侧同一份数据是容器上的 --gk-safe-*;这里给需要按布局换文案/换组件的地方 */
33
+ get layout(): StageLayout { return this.#stage.layout; }
34
+ get safe(): SafeInsets { return this.#stage.safe; }
35
+
30
36
  phase = $state<Phase>('idle');
31
37
  /** 作废令牌:开新局时 +1,旧的演出协程醒来发现对不上号就退出 */
32
38
  rid = $state(0);
33
39
  /** 演出过程中翻动的那一面 */
34
40
  face = $state<'heads' | 'tails'>('heads');
35
- last = $state<__PASCAL__Outcome | null>(null);
41
+ last = $state<CoinFlipOutcome | null>(null);
36
42
  lastPayout = $state(0);
37
43
  /** 赢面小弹窗。id 让连赢两局弹两次;ms 是它的存活时间,到点自己清空 */
38
44
  toast = $state<{ id: number; mult: number; payout: number; ms: number } | null>(null);
@@ -40,8 +46,9 @@ export class Game {
40
46
  live = $derived(this.phase === 'playing');
41
47
 
42
48
  /* 只有 view/mount 的订阅回调会调这两个 */
43
- applyConfig(c: __PASCAL__Config): void { this.#config = c; }
49
+ applyConfig(c: CoinFlipConfig): void { this.#config = c; }
44
50
  applyView(v: ViewSettings): void { this.#view = v; }
51
+ applyStage(s: StageInfo): void { this.#stage = s; }
45
52
 
46
53
  invalidate(): void { this.rid++; this.phase = 'idle'; }
47
54
  }
@@ -7,13 +7,13 @@ import type { GameInstance, GameStatus, Host, PresentOptions, Readable, RoundRes
7
7
  import App from '../components/App.svelte';
8
8
  import buildMeta from '../../game.meta.json' with { type: 'json' };
9
9
  import { createInstance } from '../context.svelte.ts';
10
- import type { __PASCAL__Config, __PASCAL__Outcome } from '../rules/types.ts';
10
+ import type { CoinFlipConfig, CoinFlipOutcome } from '../rules/types.ts';
11
11
  import { present } from './present.ts';
12
12
  import { toStore } from './bridge.svelte.ts';
13
13
 
14
14
  export async function mountGame(
15
- host: Host<__PASCAL__Config>, el: HTMLElement
16
- ): Promise<GameInstance<__PASCAL__Outcome>> {
15
+ host: Host<CoinFlipConfig>, el: HTMLElement
16
+ ): Promise<GameInstance<CoinFlipOutcome>> {
17
17
  /* 每次 mount 造一棵全新的状态树,同页挂两个实例是安全的 */
18
18
  const inst = createInstance(host);
19
19
  const { game } = inst;
@@ -25,6 +25,7 @@ export async function mountGame(
25
25
  const teardown: Array<() => void> = [];
26
26
  teardown.push(host.config.subscribe(c => game.applyConfig(c)));
27
27
  teardown.push(host.view.subscribe(v => game.applyView(v)));
28
+ teardown.push(host.stage.subscribe(s => game.applyStage(s)));
28
29
 
29
30
  const status = toStore<GameStatus>(() => ({
30
31
  phase: game.phase === 'playing' ? 'playing' : game.phase === 'over' ? 'settling' : 'idle',
@@ -40,7 +41,7 @@ export async function mountGame(
40
41
  const app = svelteMount(App, { target: el, props: { instance: inst } });
41
42
  host.emit({ type: 'ready' });
42
43
 
43
- const play = async (r: RoundResult<__PASCAL__Outcome>, o?: PresentOptions): Promise<void> => {
44
+ const play = async (r: RoundResult<CoinFlipOutcome>, o?: PresentOptions): Promise<void> => {
44
45
  host.emit({ type: 'present:start' });
45
46
  await present(game, r.outcome, r.payout, o?.rate ?? 1);
46
47
  host.emit({ type: 'present:end', payout: r.payout });
@@ -53,6 +54,7 @@ export async function mountGame(
53
54
  preview: play,
54
55
  skip() { if (game.live) game.rid++; },
55
56
  reset() { game.invalidate(); game.last = null; },
57
+ /* 舞台是 CSS 排的(flex + 容器上的 --gk-safe-*),换布局不需要额外处理 */
56
58
  resize() {},
57
59
  destroy() {
58
60
  game.invalidate();
@@ -3,12 +3,12 @@
3
3
  判定一行都不在这里——outcome 是传进来的。turbo / reducedMotion 只改这段的
4
4
  时长,改不了结果,因为它们根本不在 round() 的入参里。 */
5
5
  import type { Game } from '../state/game.svelte';
6
- import type { __PASCAL__Outcome } from '../rules/types.ts';
6
+ import type { CoinFlipOutcome } from '../rules/types.ts';
7
7
 
8
8
  const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
9
9
 
10
10
  export async function present(
11
- game: Game, o: __PASCAL__Outcome, payout: number, rate = 1
11
+ game: Game, o: CoinFlipOutcome, payout: number, rate = 1
12
12
  ): Promise<void> {
13
13
  const id = ++game.rid;
14
14
  game.phase = 'playing';
@@ -8,13 +8,14 @@ import { tick } from 'svelte';
8
8
  import { createPlatform, localServer } from 'gamekit777/dev-host';
9
9
  import { defineGame } from 'gamekit777/sdk';
10
10
  import game from '../src/index.ts';
11
- import type { __PASCAL__Config as C, __PASCAL__Outcome as O } from '../src/rules/types.ts';
11
+ import type { CoinFlipConfig as C, CoinFlipOutcome as O } from '../src/rules/types.ts';
12
12
 
13
13
  const live: { destroy(): void }[] = [];
14
14
  afterEach(() => { while (live.length) { try { live.pop()!.destroy(); } catch { /* 已经拆过了 */ } } });
15
15
 
16
16
  const boot = async () => {
17
17
  const p = createPlatform<C, O>(game);
18
+ // 和真实宿主一样先握手:余额、注额区间都在服务端那边
18
19
  await p.init();
19
20
  const el = document.createElement('div');
20
21
  document.body.appendChild(el);
@@ -53,7 +54,9 @@ test('表模型:还原出的赔付必须等于平台记账,差一分也要
53
54
  if (!r.ok) return;
54
55
  expect(r.round.result!.payout).toBe((1000 / 100) * r.round.row!.payoutCenti);
55
56
  expect(r.round.wallet.balance).toBe(before - 1000 + r.round.result!.payout);
57
+ expect(r.round.checks.every((c) => c.ok)).toBe(true);
56
58
 
59
+ /* 同一份清单,只把 restore 换成会多赔一分的:钱已经按表记了,演出不能演 */
57
60
  const tampered = defineGame<C, O>({ ...game, restore: (i) => { const x = game.restore(i); return { ...x, payout: x.payout + 1 }; } });
58
61
  const bad = localServer<C, O>({ spec: tampered, sims: 500 });
59
62
  await bad.init();
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": ["@tsconfig/svelte/tsconfig.json", "../../tsconfig.base.json"],
3
+ "compilerOptions": {
4
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
5
+ "types": ["vite/client"]
6
+ },
7
+ "include": ["src", "test", "scripts", "*.ts"]
8
+ }
@@ -3,7 +3,8 @@
3
3
  这是一个 GameKit 游戏目录。平台**不执行游戏代码**:打包时用你的 `round()` 采样出一张权重表,
4
4
  运行时平台按权重抽一行、查表赔付,浏览器用你的 `restore()` 把那一行还原成演出。
5
5
 
6
- 先读这份文件,再读 `game.ts`。模板是一个**能跑的抛硬币**——从能跑的东西开始改,不要从零写。
6
+ 先读这份文件,再读 `game.ts`。这个目录是平台参考游戏 **coin-flip(抛硬币)的完整拷贝**换了名字——它能跑、能建表、能发布;
7
+ 从能跑的东西开始改,不要从零写。源码里带「改成你自己的游戏时」的注释就是给你的路标。
7
8
 
8
9
  ## 一条原则:演出必须能回放
9
10
 
@@ -145,7 +146,15 @@ mount(host: Host<Config>, el: HTMLElement): Promise<GameInstance<Outcome>>
145
146
  - **模块级不留可变状态**。`createInstance(host)` 造一棵状态树经 Svelte context 下传,组件 `getInstance()`。同页会有两个实例(主实例 + 回放),`test/e2e.test.ts` 会验。
146
147
  - `turbo` / `reducedMotion` 只改演出时长,改不了结果——它们根本不在 `round()` 的入参里。
147
148
  - CSS 命名空间挂在容器 `el` 上(`gk-__ID__`),不写 `document.documentElement`。
148
- - 画布是固定 900×771 的逻辑画面,平台整体缩放;按这个尺寸排版。
149
+ - **两块舞台都要支持**:桌面 900×771(横版机台)和手机 450×H(竖屏全屏,H 在 800–1000 之间按 50 分档)。
150
+ 平台整体缩放,游戏里看到的宽度永远是这两个数之一;用 flex / 百分比排高度,不要写死 771。
151
+ 当前在哪块由三处同一份数据告诉你:`host.stage`(`Readable<StageInfo>`,`layout` / `width` / `height` / `safe`)、
152
+ 容器 `el` 上的 `data-layout="desktop|mobile"`、以及 CSS 变量 `--gk-safe-top/right/bottom/left`。
153
+ **安全区**:手机上平台的操作 UI(余额、注额、下注、参数)叠在画布上下两端,`safe` 就是被盖住的边(逻辑像素)。
154
+ 安全区外可以渲染(底色、氛围),但**不要放交互和关键信息**——它们会被挡住。最简单的做法是给根元素
155
+ `padding: var(--gk-safe-top) … var(--gk-safe-bottom)`。舞台变化时平台会调 `resize()`,CSS 排版的游戏留空实现即可。
156
+ 按尺寸分档用 `@container gk-stage (max-width: 500px)` 或 `[data-layout="mobile"]`,**不要用 `@media` / `vw`**:
157
+ iframe 里视口是逻辑分辨率,studio 预览里是整页,两处含义不同。
149
158
  - **演出先查资源库**:`asset_list` 看有哪些。**animation** 是自足的小组件(赢面弹窗、分档大奖、揭示翻动、上飘文字…),`asset_use <name>` 复制进 `src/animations/` 再按需改;
150
159
  **pattern** 是一套机制加接线清单(荷官说话…),`asset_show <name>` 先读 NOTES 里的接线表和台词写作指南,`asset_use <name>` 复制进 `src/patterns/<name>/` 后把调用按表穿进你的时间轴,
151
160
  文案按这个游戏的角色重写。库是参考实现,复制进来就是你的代码;不要从零写一个库里已有的演出。
@@ -157,6 +166,7 @@ mount(host: Host<Config>, el: HTMLElement): Promise<GameInstance<Outcome>>
157
166
  → run_task check / verify / test 类型、数学回归、端到端
158
167
  → probe_table 能不能打表、modes 该写哪些键、book 怎么选
159
168
  → preview_play { rounds, seed?, simId? } 在预览里演几局(本地表模型:就地建表 → 抽行 → restore)
169
+ → preview_set { layout: 'mobile' } 切到手机机台看一眼:安全区对不对、画面有没有被 HUD 盖住;preview_state 报回 stage.safe
160
170
  → case_add 把值得盯的局面存进 studio/cases.jsonl
161
171
  → build_table 采样 → 求解 → 编码 → 本地终验 → .studio/build/<指纹>/
162
172
  → publish_dry_run → publish 一键:vite build → 传包 → 传表 → 发布,不用人确认,不可逆。身份是作者在 studio 页面连过的站点身份;报「没有身份」就让作者打开页面连一次
@@ -166,4 +176,4 @@ mount(host: Host<Config>, el: HTMLElement): Promise<GameInstance<Outcome>>
166
176
  - 长任务返回 `jobId` 就用 `job_status` 轮询。
167
177
  - `case_run` 任何结果变化都是 `fail`。是有意改的规则且这个 mode 没发布过,`case_bless` 接受。**已发布过的 mode,发牌逻辑、book 编解码、取整方式都不能再改**——历史局靠它们回放,要改就换 slug。
168
178
  - `preview_set` 被否决会在 `rejected` 里说明——那是平台的否决权在起作用,不是 bug。
169
- - 手动:`bun run dev` 起 studio(页面 `http://127.0.0.1:4301/`,左边是预览机台,右边是状态 / 调参 / 建表 / Case / 发布 / 日志);`bun run verify`、`bun run test`、`bun run build`。
179
+ - 手动:`bun run dev` 起 studio(地址看终端输出,默认 `http://127.0.0.1:4301/`,被占会换;左边是预览机台,上方可切桌面 / 手机并选设备,右边是状态 / 调参 / 建表 / Case / 发布 / 日志);`bun run verify`、`bun run test`、`bun run build`。