gamekit777 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -0
- package/client/http.ts +253 -0
- package/client/index.ts +211 -0
- package/client/node.ts +61 -0
- package/create-game/scaffold.ts +73 -0
- package/create-game/template/CLAUDE.md.tmpl +167 -0
- package/create-game/template/_gitignore +4 -0
- package/create-game/template/game.meta.json.tmpl +8 -0
- package/create-game/template/game.ts.tmpl +57 -0
- package/create-game/template/index.html.tmpl +11 -0
- package/create-game/template/package.json.tmpl +26 -0
- package/create-game/template/scripts/verify.ts.tmpl +79 -0
- package/create-game/template/src/assets/critical/.gitkeep +0 -0
- package/create-game/template/src/assets/lazy/.gitkeep +0 -0
- package/create-game/template/src/assets/manifest.ts +30 -0
- package/create-game/template/src/assets/registry.ts +23 -0
- package/create-game/template/src/components/App.svelte.tmpl +54 -0
- package/create-game/template/src/context.svelte.ts.tmpl +22 -0
- package/create-game/template/src/dev.ts +8 -0
- package/create-game/template/src/index.ts.tmpl +17 -0
- package/create-game/template/src/rules/const.ts +9 -0
- package/create-game/template/src/rules/restore.ts.tmpl +39 -0
- package/create-game/template/src/rules/schema.ts.tmpl +19 -0
- package/create-game/template/src/rules/simulate.ts.tmpl +9 -0
- package/create-game/template/src/rules/types.ts.tmpl +13 -0
- package/create-game/template/src/state/game.svelte.ts.tmpl +45 -0
- package/create-game/template/src/styles/animations.css +4 -0
- package/create-game/template/src/styles/global.css +18 -0
- package/create-game/template/src/view/bridge.svelte.ts +33 -0
- package/create-game/template/src/view/mount.svelte.ts.tmpl +86 -0
- package/create-game/template/src/view/present.ts.tmpl +36 -0
- package/create-game/template/test/e2e.test.ts.tmpl +81 -0
- package/create-game/template/tsconfig.json +22 -0
- package/create-game/template/vite.config.ts +4 -0
- package/create-game/template/vitest.config.ts +10 -0
- package/dev-host/DevShell.svelte +348 -0
- package/dev-host/Field.svelte +25 -0
- package/dev-host/SchemaFields.svelte +14 -0
- package/dev-host/SchemaFieldsPure.svelte +98 -0
- package/dev-host/context.svelte.ts +9 -0
- package/dev-host/fonts.ts +16 -0
- package/dev-host/index.ts +8 -0
- package/dev-host/local.ts +69 -0
- package/dev-host/platform-server.ts +78 -0
- package/dev-host/platform.svelte.ts +277 -0
- package/dev-host/remote.ts +178 -0
- package/dev-host/run.ts +46 -0
- package/dev-host/table-server.ts +194 -0
- package/dev-host/theme.css +344 -0
- package/lut/book.ts +97 -0
- package/lut/csv.ts +37 -0
- package/lut/format.ts +106 -0
- package/lut/index.ts +4 -0
- package/lut/verify.ts +243 -0
- package/package.json +58 -0
- package/protocol/bets.ts +74 -0
- package/protocol/errors.ts +90 -0
- package/protocol/games.ts +158 -0
- package/protocol/index.ts +9 -0
- package/protocol/money.ts +51 -0
- package/protocol/rounds.ts +60 -0
- package/protocol/seeds.ts +36 -0
- package/protocol/session.ts +18 -0
- package/protocol/tables.ts +120 -0
- package/protocol/verify.ts +33 -0
- package/publish/build.ts +168 -0
- package/publish/index.ts +6 -0
- package/publish/parallel.ts +56 -0
- package/publish/sample-worker.ts +31 -0
- package/publish/sample.ts +131 -0
- package/publish/spec.ts +8 -0
- package/publish/table.ts +244 -0
- package/publish/upload-core.ts +176 -0
- package/publish/upload.ts +30 -0
- package/runtime/assets.ts +122 -0
- package/runtime/fonts.ts +21 -0
- package/runtime/index.ts +3 -0
- package/runtime/types.ts +42 -0
- package/sdk/contract.ts +51 -0
- package/sdk/hash.ts +184 -0
- package/sdk/host.ts +96 -0
- package/sdk/index.ts +9 -0
- package/sdk/rng.ts +109 -0
- package/sdk/sample.ts +68 -0
- package/sdk/schema.ts +90 -0
- package/sdk/spec.ts +255 -0
- package/sdk/stage.ts +20 -0
- package/sdk/store.ts +67 -0
- package/studio/app/App.svelte +113 -0
- package/studio/app/lib/api.ts +59 -0
- package/studio/app/lib/bus.svelte.ts +44 -0
- package/studio/app/lib/upload.ts +34 -0
- package/studio/app/main.ts +5 -0
- package/studio/app/panels/CasePanel.svelte +83 -0
- package/studio/app/panels/LogPanel.svelte +19 -0
- package/studio/app/panels/PreviewPanel.svelte +33 -0
- package/studio/app/panels/PublishPanel.svelte +111 -0
- package/studio/app/panels/TablePanel.svelte +64 -0
- package/studio/app/panels/TuningPanel.svelte +140 -0
- package/studio/app/virtual.d.ts +6 -0
- package/studio/bin.ts +68 -0
- package/studio/cli.ts +43 -0
- package/studio/preview/bridge.ts +63 -0
- package/studio/preview/entry.ts +89 -0
- package/studio/preview/virtual.d.ts +6 -0
- package/studio/src/api.ts +157 -0
- package/studio/src/build.ts +166 -0
- package/studio/src/bus.ts +98 -0
- package/studio/src/canon.ts +18 -0
- package/studio/src/cases.ts +255 -0
- package/studio/src/config.ts +39 -0
- package/studio/src/engine.ts +218 -0
- package/studio/src/fingerprint.ts +39 -0
- package/studio/src/game-vite.ts +91 -0
- package/studio/src/game.ts +173 -0
- package/studio/src/jobs.ts +59 -0
- package/studio/src/mcp.ts +357 -0
- package/studio/src/probe.ts +359 -0
- package/studio/src/scaffold.ts +85 -0
- package/studio/src/solver.ts +139 -0
- package/studio/src/stats.ts +134 -0
- package/studio/src/studio-plugin.ts +76 -0
- package/studio/src/tasks.ts +52 -0
- package/studio/src/worker/pool.ts +84 -0
- package/studio/src/worker/rpc.ts +178 -0
- package/vite-config/index.js +207 -0
- package/vite-config/index.ts +87 -0
- package/vite-config/manifest.ts +144 -0
- package/vite-config/meta.ts +41 -0
- package/vite-config/namespace-css.ts +74 -0
- package/weights/index.ts +11 -0
- package/weights/linalg.ts +118 -0
- package/weights/report.ts +78 -0
- package/weights/solve.ts +236 -0
- package/weights/types.ts +73 -0
- package/weights/volatility.ts +40 -0
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/* MCP 工具面。AI 通过它创作、校验、调参、建表、驱动预览、触发发布。
|
|
2
|
+
*
|
|
3
|
+
* 不提供文件读写——Claude Code 自带的更好用。这里只有它做不到的事。
|
|
4
|
+
* 每个工具都返回 structuredContent(给程序)+ 一段中文 markdown(给模型),
|
|
5
|
+
* 长任务默认等到结束(上限几分钟),等不完就把 jobId 交出去让它 job_status 轮询。 */
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
9
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
import type { Engine } from './engine.ts';
|
|
12
|
+
import type { Job } from './jobs.ts';
|
|
13
|
+
import type { ProbeResult } from './probe.ts';
|
|
14
|
+
import { scaffold } from './scaffold.ts';
|
|
15
|
+
import type { SolveReport } from './solver.ts';
|
|
16
|
+
import type { TaskResult } from './tasks.ts';
|
|
17
|
+
|
|
18
|
+
type ToolOut = { content: { type: 'text'; text: string }[]; structuredContent?: Record<string, unknown>; isError?: boolean };
|
|
19
|
+
|
|
20
|
+
const out = (text: string, structured?: unknown): ToolOut => ({
|
|
21
|
+
content: [{ type: 'text', text }],
|
|
22
|
+
...(structured !== undefined ? { structuredContent: JSON.parse(JSON.stringify(structured, (_k, v: unknown) => (typeof v === 'bigint' ? v.toString() : v))) as Record<string, unknown> } : {}),
|
|
23
|
+
});
|
|
24
|
+
const fail = (text: string): ToolOut => ({ content: [{ type: 'text', text: `✗ ${text}` }], isError: true });
|
|
25
|
+
|
|
26
|
+
const pct = (ppm: number): string => `${(ppm / 1e4).toFixed(2)}%`;
|
|
27
|
+
const x = (centi: number): string => `${(centi / 100).toFixed(2)}x`;
|
|
28
|
+
|
|
29
|
+
const JOB_WAIT_MS = 240_000;
|
|
30
|
+
|
|
31
|
+
async function awaitJob<T>(engine: Engine, job: Job<T>, render: (r: T) => string, extra?: (r: T) => unknown): Promise<ToolOut> {
|
|
32
|
+
const j = await engine.jobs.wait<T>(job.id, JOB_WAIT_MS).catch(() => job);
|
|
33
|
+
if (j.status === 'running') {
|
|
34
|
+
return out(`任务 ${j.id} 还在跑(${j.progress.at(-1) ?? ''})。用 job_status 轮询。`, { jobId: j.id, status: 'running' });
|
|
35
|
+
}
|
|
36
|
+
if (j.status !== 'done' || j.result === null) return fail(`${j.kind} 失败:${j.error ?? '未知错误'}\n\n进度:\n${j.progress.map((p) => ` · ${p}`).join('\n')}`);
|
|
37
|
+
return out(render(j.result), extra ? extra(j.result) : j.result);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* ── 渲染 ── */
|
|
41
|
+
|
|
42
|
+
function renderProbe(r: ProbeResult): string {
|
|
43
|
+
const v = { 'single-table': '✅ 一张表', 'multi-table': '✅ 多张表', infeasible: '❌ 打不成表' }[r.verdict];
|
|
44
|
+
const lines = [
|
|
45
|
+
`## 打表可行性:${v}`, '', r.verdictReason, '',
|
|
46
|
+
`- config 组合:${Number(r.configCombinations).toLocaleString()}`,
|
|
47
|
+
`- 不同赔付分布:${r.distinctDistributions ?? '?'}${r.distinctIsUpperBound ? '(上界)' : ''}`,
|
|
48
|
+
`- 影响分布的字段:${r.distributionFields.join('、') || '(无)'}`,
|
|
49
|
+
`- 影响这一局但不影响分布的字段:${r.freeFields.join('、') || '(无)'}`,
|
|
50
|
+
`- modes:${r.hasModes ? `已声明(${r.modeKeys.join('、')})` : '未声明'}`,
|
|
51
|
+
'', '### 硬门槛',
|
|
52
|
+
...r.gates.map((g) => `- ${g.ok ? '✓' : '✗'} ${g.name}:${g.detail}`),
|
|
53
|
+
];
|
|
54
|
+
if (r.modeIssues.length) lines.push('', '### modes 声明问题', ...r.modeIssues.map((i) => `- ${i.level === 'error' ? '❌' : '⚠️'} ${i.message}`));
|
|
55
|
+
lines.push('', '### 逐字段', '| 字段 | 取值数 | 影响分布 | 逐局变化率 | 最大σ | 备注 |', '|---|---|---|---|---|---|');
|
|
56
|
+
for (const f of r.fields) {
|
|
57
|
+
lines.push(`| ${f.key} | ${f.cardinality} | ${f.affectsDistribution === null ? '—' : f.affectsDistribution ? '是' : '否'} | ${f.perSeedChangeRate === null ? '—' : `${(f.perSeedChangeRate * 100).toFixed(0)}%`} | ${f.maxSigma?.toFixed(1) ?? '—'} | ${f.note ?? ''} |`);
|
|
58
|
+
}
|
|
59
|
+
if (r.baseline) {
|
|
60
|
+
lines.push('', '### 默认配置的分布',
|
|
61
|
+
`- ${r.baseline.distinctPayouts} 种赔付,命中率 ${pct(r.baseline.hitRatePpm)},最大 ${x(r.baseline.maxPayoutCenti)}`,
|
|
62
|
+
`- 最稀有档位 ${x(r.baseline.rarest.payoutCenti)},概率 ${(r.baseline.rarest.probability * 100).toFixed(4)}%——要采到 30 个样本约需 ${r.baseline.simsForRarest.toLocaleString()} 局`);
|
|
63
|
+
}
|
|
64
|
+
lines.push('', `### book 建议:\`${r.book.suggestion}\``, r.book.reason, '', `_${(r.millis / 1000).toFixed(1)}s_`);
|
|
65
|
+
return lines.join('\n');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function renderSolve(r: SolveReport & { mode: string; rtpRangePpm: [number, number] }): string {
|
|
69
|
+
if (r.failure) return `## ❌ 求解失败(${r.failure.kind})\n\n${r.failure.detail}\n\n可达 RTP 区间:[${pct(r.rtpRangePpm[0])}, ${pct(r.rtpRangePpm[1])}]`;
|
|
70
|
+
const lines = [
|
|
71
|
+
`## ${r.converged ? '✅ 收敛' : '⚠️ 未收敛'}(mode ${r.mode})`, '',
|
|
72
|
+
`- 残差 ${r.residual.toExponential(2)},${r.iterations} 次迭代,${r.millis.toFixed(1)} ms`,
|
|
73
|
+
`- 实算 RTP ${pct(Math.round(r.achieved!.rtp * 1e6))}`,
|
|
74
|
+
];
|
|
75
|
+
if (r.hardest) lines.push(`- 最吃力的约束:${r.hardest.label}(θ=${r.hardest.theta.toFixed(3)})`);
|
|
76
|
+
if (r.dropped.length) lines.push(`- ⚠️ 被判冗余丢弃的约束:${r.dropped.join('、')}——这些目标没有生效`);
|
|
77
|
+
if (r.achieved!.segments.length) {
|
|
78
|
+
lines.push('', '| 段 | 命中 | RTP 份额 | 平均赢 | 中位数 | 均值/中位 |', '|---|---|---|---|---|---|');
|
|
79
|
+
for (const s of r.achieved!.segments) lines.push(`| ${s.name} | 每 ${s.hitRate.toFixed(1)} 局 | ${pct(Math.round(s.rtp * 1e6))} | ${x(s.avgWin)} | ${x(s.median)} | ${s.meanToMedian.toFixed(2)} |`);
|
|
80
|
+
}
|
|
81
|
+
if (r.thin.length) lines.push('', `⚠️ ${r.thin.length} 个档位样本不足(<30 局):${r.thin.slice(0, 5).map((t) => `${x(t.payoutCenti)}×${t.samples}局`).join('、')}`);
|
|
82
|
+
if (r.distorted.length) lines.push(`⚠️ ${r.distorted.length} 个档位被大幅改权:${r.distorted.slice(0, 5).map((t) => `${x(t.payoutCenti)} ×${t.distortion.toFixed(1)}`).join('、')}`);
|
|
83
|
+
return lines.join('\n');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function renderTask(r: TaskResult): string {
|
|
87
|
+
const head = `## ${r.ok ? '✅' : '❌'} bun run ${r.task}(${(r.durationMs / 1000).toFixed(1)}s${r.timedOut ? ',超时' : ''})`;
|
|
88
|
+
if (r.ok) return `${head}\n\n${r.output.split('\n').slice(-12).join('\n')}`;
|
|
89
|
+
return `${head}\n\n### 抽出的问题\n${r.issues.map((i) => `- ${i}`).join('\n') || '(没抽出来,看完整输出)'}\n\n### 完整输出\n\`\`\`\n${r.output}\n\`\`\``;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* 客户端在 initialize 时拿到的说明。写游戏的知识不在工具描述里——工具是手,这段和游戏目录里的
|
|
93
|
+
CLAUDE.md 才是脑子。Claude Code 会自动读 cwd 的 CLAUDE.md,别的 MCP 客户端只有这一段 */
|
|
94
|
+
const INSTRUCTIONS = [
|
|
95
|
+
'GameKit studio:这个 MCP 服务对应 cwd 里的一个 GameKit 游戏目录。',
|
|
96
|
+
'平台不执行游戏代码:打包时用 game.ts 里的 round() 采样出权重表,运行时按权重抽一行、查表赔付,浏览器用 restore() 还原演出。',
|
|
97
|
+
'',
|
|
98
|
+
'开始前先读游戏目录里的 CLAUDE.md(清单字段、round() 的硬约束、schema、modes/book/restore 怎么选、演出侧约定)。',
|
|
99
|
+
'没有这个文件就先调 game_info,再读 game.ts 和 src/rules/。',
|
|
100
|
+
'',
|
|
101
|
+
'工作流:改 src/rules/ 或 game.ts → run_task check/verify/test → probe_table(能不能打表、modes 写哪些键、book 怎么选)',
|
|
102
|
+
'→ preview_play 在预览里演几局 → case_add 存值得盯的局面 → build_table 建表 → publish_dry_run → publish(作者在页面确认)。',
|
|
103
|
+
'',
|
|
104
|
+
'铁律:round() 是同步纯函数、只用注入的 rng;payout 是整数分且是注额的精确线性函数;restore 返回的 payout 必须等于 (bet/100)×payoutCenti,对不上就抛。',
|
|
105
|
+
'不写 normalize/validate/modeOf,不加 meta 或版本号;已发布 mode 的解码逻辑不能改,要改就换 slug。',
|
|
106
|
+
].join('\n');
|
|
107
|
+
|
|
108
|
+
/* ── 注册 ── */
|
|
109
|
+
|
|
110
|
+
export function createMcpServer(engine: Engine): McpServer {
|
|
111
|
+
const s = new McpServer({ name: 'gamekit-studio', version: '0.1.0' }, { instructions: INSTRUCTIONS });
|
|
112
|
+
const modesArg = z.array(z.string()).optional().describe('只处理这些 mode;不给就是全部');
|
|
113
|
+
|
|
114
|
+
s.registerTool('game_info', {
|
|
115
|
+
description: '当前游戏目录的全貌:清单(slug / rtp / sims / modes / book)、schema、规则指纹、已建的表、预览连接状态、studio 页面地址。AI 的第一跳。',
|
|
116
|
+
inputSchema: {},
|
|
117
|
+
}, async () => {
|
|
118
|
+
const i = await engine.info();
|
|
119
|
+
const sn = i.snapshot;
|
|
120
|
+
const sp = sn.spec;
|
|
121
|
+
const lines = [
|
|
122
|
+
`## ${sp.title}(${sp.slug})`, '',
|
|
123
|
+
`- 目录:${i.gameDir}`, `- 规则指纹 ${i.fingerprint}`,
|
|
124
|
+
`- 目标 RTP ${(sp.rtp * 100).toFixed(2)}%,每表采样 ${sp.sims} 局,book ${sp.book},autoplay ${sp.autoplay ? '开' : '关'}`,
|
|
125
|
+
`- modes:${sp.modeKeys.length ? `${sp.modes.length} 个,由 ${sp.modeKeys.join('、')} 决定` : '单表 base'}`,
|
|
126
|
+
`- 已建的表(当前指纹):${i.artifacts.length ? i.artifacts.map((a) => `${a.mode}(${pct(a.computedRtpPpm)})`).join('、') : '无'}`,
|
|
127
|
+
`- case:${i.cases} 条(${i.caseFile})`,
|
|
128
|
+
`- 预览:${i.previewConnected ? '已连接' : '未连接'}——页面 ${i.urls.studio}`,
|
|
129
|
+
'', '### schema', '| 字段 | 类型 | 默认 | 影响结果 | 渲染 | devOnly |', '|---|---|---|---|---|---|',
|
|
130
|
+
];
|
|
131
|
+
const guide = existsSync(join(i.gameDir, 'CLAUDE.md'));
|
|
132
|
+
const tail = ['', '### 下一步',
|
|
133
|
+
guide ? `- 先读 ${join(i.gameDir, 'CLAUDE.md')}:清单字段、round() 的硬约束、modes/book/restore 怎么选、演出侧约定`
|
|
134
|
+
: '- 这个目录没有 CLAUDE.md(写给 AI 的开发指南)。读 game.ts 和 src/rules/ 之前先记住:round() 纯函数只用注入 rng,payout 整数分,restore 的 payout 必须等于 (bet/100)×payoutCenti',
|
|
135
|
+
'- 改了规则先 run_task verify,再 probe_table 看 modes / book 该怎么写,然后 preview_play'];
|
|
136
|
+
for (const [k, f] of Object.entries(sn.schema)) {
|
|
137
|
+
const range = f.kind === 'int' || f.kind === 'number' ? `${f.min}..${f.max}` : f.kind === 'enum' ? f.options.map((o) => o.value).join('/') : f.kind === 'pick' ? `从 ${f.of.length} 选 ${f.count}` : '';
|
|
138
|
+
lines.push(`| ${k} | ${f.kind} ${range} | ${JSON.stringify(f.default)} | ${f.affectsOutcome ? '是' : '否'} | ${f.ui} | ${f.devOnly ? '是' : ''} |`);
|
|
139
|
+
}
|
|
140
|
+
lines.push(...tail);
|
|
141
|
+
return out(lines.join('\n'), { ...i, guide: guide ? join(i.gameDir, 'CLAUDE.md') : null });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
s.registerTool('scaffold', {
|
|
145
|
+
description: '在当前目录下新建一个游戏(一个能跑的抛硬币),写好 package.json 并 bun install。之后请在那个目录里重启 studio。',
|
|
146
|
+
inputSchema: {
|
|
147
|
+
id: z.string().describe('小写短横线形式,同时是包名、目录名和 CSS 命名空间'),
|
|
148
|
+
title: z.string().optional(),
|
|
149
|
+
dest: z.string().optional().describe('目标目录,默认 <cwd>/<id>'),
|
|
150
|
+
},
|
|
151
|
+
}, async (a) => {
|
|
152
|
+
try {
|
|
153
|
+
const r = await scaffold({ id: a.id, title: a.title, dest: a.dest }, process.cwd());
|
|
154
|
+
return out(`## ✅ 已生成 ${r.dest}\n\n${r.files} 个文件,依赖${r.installed ? '已装' : '未装'}。\n\n${r.next.map((n) => `- ${n}`).join('\n')}`, r);
|
|
155
|
+
} catch (e) { return fail(e instanceof Error ? e.message : String(e)); }
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
s.registerTool('run_task', {
|
|
159
|
+
description: '在游戏目录里跑 bun run check / test / verify / build,返回结构化的失败信息。',
|
|
160
|
+
inputSchema: { task: z.enum(['check', 'test', 'verify', 'build']) },
|
|
161
|
+
}, async (a) => awaitJob(engine, engine.task(a.task), renderTask));
|
|
162
|
+
|
|
163
|
+
s.registerTool('probe_table', {
|
|
164
|
+
description: '打表可行性探测:数 config 组合、逐字段判是否影响赔付分布、数不同分布有几种、校验 modes 声明、给 book 编码建议。改了 rules/ 之后都该跑一遍。',
|
|
165
|
+
inputSchema: {
|
|
166
|
+
sims: z.number().int().min(1000).max(200_000).optional().describe('每个取值采多少局,默认 20000'),
|
|
167
|
+
maxValuesPerField: z.number().int().min(2).max(8).optional(),
|
|
168
|
+
},
|
|
169
|
+
}, async (a) => awaitJob(engine, engine.probe(a), renderProbe));
|
|
170
|
+
|
|
171
|
+
s.registerTool('sample', {
|
|
172
|
+
description: '按清单的 modes 采样并缓存(按规则指纹)。建表和调参都会自动采,这个工具只是预热。',
|
|
173
|
+
inputSchema: { modes: modesArg, sims: z.number().int().optional() },
|
|
174
|
+
}, async (a) => awaitJob(engine, engine.sample(a.modes ?? null, a.sims ?? null), (r) => `已采样 ${r.modes.join('、')},每个 ${r.sims} 局`));
|
|
175
|
+
|
|
176
|
+
s.registerTool('solve_params', {
|
|
177
|
+
description: '调参:给 RTP 和分段目标(命中率 / RTP 份额 / 波动率),毫秒级返回权重与实算报告。不建表。分段必须按赔付区间划分。',
|
|
178
|
+
inputSchema: {
|
|
179
|
+
mode: z.string().optional().describe('不给就是第一个 mode'),
|
|
180
|
+
sims: z.number().int().optional(),
|
|
181
|
+
totalRtpPpm: z.number().int().describe('目标总 RTP,ppm,如 970000'),
|
|
182
|
+
segments: z.array(z.object({
|
|
183
|
+
name: z.string(),
|
|
184
|
+
payoutRange: z.tuple([z.number(), z.number()]).describe('赔付区间 [lo, hi],centi'),
|
|
185
|
+
oneIn: z.number().optional().describe('每 N 局中一次'),
|
|
186
|
+
rtpPpm: z.number().int().optional().describe('该段的 RTP 份额,ppm'),
|
|
187
|
+
meanToMedian: z.tuple([z.number(), z.number()]).optional().describe('波动率:均值/中位数 区间'),
|
|
188
|
+
})).default([]),
|
|
189
|
+
},
|
|
190
|
+
}, async (a) => {
|
|
191
|
+
try {
|
|
192
|
+
const r = await engine.solve(a.mode ?? null, a.sims ?? null, { totalRtpPpm: a.totalRtpPpm, segments: a.segments });
|
|
193
|
+
return out(renderSolve(r), r);
|
|
194
|
+
} catch (e) { return fail(e instanceof Error ? e.message : String(e)); }
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
s.registerTool('build_table', {
|
|
198
|
+
description: '建表:采样(缓存)→ 求解 → 量化 → 编码 → 本地终验 → 落盘到 .studio/build/<指纹>/。预览的表模型和发布上传吃同一份产物。',
|
|
199
|
+
inputSchema: {
|
|
200
|
+
modes: modesArg,
|
|
201
|
+
sims: z.number().int().optional(),
|
|
202
|
+
rtpPpm: z.number().int().optional().describe('覆盖目标 RTP'),
|
|
203
|
+
segments: z.array(z.object({ name: z.string(), members: z.array(z.number().int()), mass: z.number().optional(), rtp: z.number().optional(), median: z.number().optional() })).optional()
|
|
204
|
+
.describe('solve_params 返回的 segments,原样带过来'),
|
|
205
|
+
},
|
|
206
|
+
}, async (a) => awaitJob(engine, engine.build(a.modes ?? null, { sims: a.sims, declaredRtpPpm: a.rtpPpm, segments: a.segments }), (arts) =>
|
|
207
|
+
[`## ✅ 建好 ${arts.length} 张表`, '', '| mode | 行数 | 声明 RTP | 实算 RTP | 最大赔付 | 命中率 | sha256 |', '|---|---|---|---|---|---|---|',
|
|
208
|
+
...arts.map((t) => `| ${t.mode} | ${t.rowCount.toLocaleString()} | ${pct(t.declaredRtpPpm)} | ${pct(t.computedRtpPpm)} | ${x(t.maxPayoutCenti)} | ${pct(t.hitRatePpm)} | ${t.contentHash.slice(0, 12)}… |`),
|
|
209
|
+
...(arts.some((t) => t.stats.thin.length) ? ['', `⚠️ 有档位样本不足:${arts.flatMap((t) => t.stats.thin.map((p) => `${t.mode} ${x(p.payoutCenti)}×${p.samples}`)).slice(0, 8).join('、')}`] : []),
|
|
210
|
+
].join('\n')));
|
|
211
|
+
|
|
212
|
+
s.registerTool('publish_dry_run', {
|
|
213
|
+
description: '发布前体检(本地部分):当前指纹下有哪些表、最低实算 RTP 是否低于声明。登录态和 slug 占用要在 studio 页面上看。',
|
|
214
|
+
inputSchema: { modes: modesArg },
|
|
215
|
+
}, async (a) => {
|
|
216
|
+
const i = await engine.info();
|
|
217
|
+
const arts = i.artifacts.filter((t) => !a.modes || a.modes.includes(t.mode));
|
|
218
|
+
const spec = i.snapshot.spec;
|
|
219
|
+
const declared = Math.round(spec.rtp * 1e6);
|
|
220
|
+
const issues: string[] = [];
|
|
221
|
+
if (arts.length === 0) issues.push('当前指纹下没有任何已建的表——先 build_table');
|
|
222
|
+
const missing = spec.modes.filter((m) => !arts.some((t) => t.mode === m.name)).map((m) => m.name);
|
|
223
|
+
if (missing.length && !a.modes) issues.push(`这些 mode 还没建表:${missing.join('、')}`);
|
|
224
|
+
const lowest = arts.reduce<number | null>((m, t) => (m === null || t.computedRtpPpm < m ? t.computedRtpPpm : m), null);
|
|
225
|
+
if (lowest !== null && lowest + 100 < declared) issues.push(`游戏声明 ${pct(declared)},但最低的表只有 ${pct(lowest)}——大厅里的声明不能高于任何一个 mode 实际能给的,发布会被拒`);
|
|
226
|
+
const ok = issues.length === 0;
|
|
227
|
+
return out([`## ${ok ? '✅ 本地体检通过' : '❌ 本地体检有问题'}`, '', ...(ok ? [`${arts.length} 张表就绪:${arts.map((t) => t.mode).join('、')}`] : issues.map((x) => `- ${x}`)),
|
|
228
|
+
'', '登录态、slug 是否被占、哪些 mode 已经锁死——这些要连平台才知道,在 studio 页面的「发布」面板里看。'].join('\n'),
|
|
229
|
+
{ ok, issues, artifacts: arts.map((t) => ({ mode: t.mode, computedRtpPpm: t.computedRtpPpm, rows: t.rowCount })) });
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
s.registerTool('publish', {
|
|
233
|
+
description: '请求发布。上传要用作者在 studio 页面上登录的身份,所以这里只是把请求排进页面的待审批队列,作者点确认后才真正上传。发布不可逆。',
|
|
234
|
+
inputSchema: { modes: modesArg, note: z.string().optional().describe('给作者看的说明') },
|
|
235
|
+
}, async (a) => {
|
|
236
|
+
const req = engine.requestPublish(a.modes ?? null, a.note ?? null, 'mcp');
|
|
237
|
+
const t0 = Date.now();
|
|
238
|
+
while (Date.now() - t0 < 120_000) {
|
|
239
|
+
const cur = engine.publishRequests().find((r) => r.id === req.id)!;
|
|
240
|
+
if (cur.status !== 'awaiting-approval' && cur.status !== 'running') {
|
|
241
|
+
return out(`## 发布请求 ${cur.id}:${cur.status}\n\n${cur.progress.map((p) => `- ${p}`).join('\n')}${cur.error ? `\n\n错误:${cur.error}` : ''}`, cur);
|
|
242
|
+
}
|
|
243
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
244
|
+
}
|
|
245
|
+
return out(`## 发布请求 ${req.id} 等待作者确认\n\n请作者打开 ${engine.urls.studio} 的「发布」面板点确认。之后用 job_status 不行,用 publish_dry_run 或 game_info 看状态。`, req);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
s.registerTool('preview_state', {
|
|
249
|
+
description: '预览页的当前状态:连没连、当前 config / mode / 注额 / 余额、已加载的表、最近几局、控制台报错。',
|
|
250
|
+
inputSchema: {},
|
|
251
|
+
}, async () => {
|
|
252
|
+
engine.touch();
|
|
253
|
+
const p = engine.preview;
|
|
254
|
+
if (!engine.bus.previewConnected) return out(`预览页没连着。请在浏览器打开 ${engine.urls.studio}`, { connected: false });
|
|
255
|
+
return out([`## 预览(本地表模型)`, `- mode ${p.mode},注额 ${p.bet},余额 ${p.balance}`, `- config ${JSON.stringify(p.config)}`,
|
|
256
|
+
`- 最近 ${p.recent.length} 局`, ...(p.lastError ? [`- ⚠️ 最近错误:${p.lastError}`] : []),
|
|
257
|
+
...(p.consoleErrors.length ? ['', '### 控制台报错', ...p.consoleErrors.slice(-5).map((e) => `- ${e}`)] : [])].join('\n'), p);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
s.registerTool('preview_set', {
|
|
261
|
+
description: '改预览的设置:config、注额、客户端种子、造型、极速。局中/自动中会被平台否决,rejected 里会说。',
|
|
262
|
+
inputSchema: {
|
|
263
|
+
config: z.record(z.string(), z.unknown()).optional(),
|
|
264
|
+
bet: z.number().int().optional().describe('最小货币单位(分)'),
|
|
265
|
+
clientSeed: z.string().optional(),
|
|
266
|
+
skin: z.string().optional(),
|
|
267
|
+
turbo: z.boolean().optional(),
|
|
268
|
+
},
|
|
269
|
+
}, async (a) => {
|
|
270
|
+
try {
|
|
271
|
+
const r = await engine.previewCommand<{ applied: string[]; rejected: { key: string; reason: string }[] }>({ t: 'set', ...a });
|
|
272
|
+
return out(`应用:${r.applied.join('、') || '(无)'}${r.rejected.length ? `\n否决:${r.rejected.map((x) => `${x.key}(${x.reason})`).join('、')}` : ''}`, r);
|
|
273
|
+
} catch (e) { return fail(e instanceof Error ? e.message : String(e)); }
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
s.registerTool('preview_play', {
|
|
277
|
+
description: '在预览里演几局并拿回每局结果(本地表模型:就地建表、按权重抽行、restore 还原)。给 seed 就复现那一局,给 simId 就直接演表里第 N 行。animate=false 跳过动画。',
|
|
278
|
+
inputSchema: {
|
|
279
|
+
rounds: z.number().int().min(1).max(50).default(1),
|
|
280
|
+
bet: z.number().int().optional(),
|
|
281
|
+
seed: z.object({ server: z.string(), client: z.string(), nonce: z.number().int() }).optional(),
|
|
282
|
+
simId: z.number().int().optional(),
|
|
283
|
+
animate: z.boolean().default(true),
|
|
284
|
+
},
|
|
285
|
+
}, async (a) => {
|
|
286
|
+
try {
|
|
287
|
+
const r = await engine.previewCommand<{ rounds: { payout: number; multiplier: number; payoutCenti: number; mode: string; checks: { label: string; ok: boolean }[]; simId: number | null; seed: unknown }[]; wallet: { balance: number }; errors: string[] }>({ t: 'play', ...a }, 30_000 + a.rounds * 8_000);
|
|
288
|
+
const lines = [`## 演了 ${r.rounds.length} 局,余额 ${r.wallet.balance}`, '', '| # | mode | 倍数 | 赔付 | 校验 | simId |', '|---|---|---|---|---|---|'];
|
|
289
|
+
r.rounds.forEach((x, i) => lines.push(`| ${i + 1} | ${x.mode} | ${x.multiplier.toFixed(2)}x | ${x.payout} | ${x.checks.every((c) => c.ok) ? '✓' : '✗ ' + x.checks.filter((c) => !c.ok).map((c) => c.label).join('、')} | ${x.simId ?? ''} |`));
|
|
290
|
+
if (r.errors.length) lines.push('', '### 错误', ...r.errors.map((e) => `- ${e}`));
|
|
291
|
+
return out(lines.join('\n'), r);
|
|
292
|
+
} catch (e) { return fail(e instanceof Error ? e.message : String(e)); }
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
s.registerTool('case_list', { description: '列出 studio/cases.jsonl 里的 case。', inputSchema: {} }, async () => {
|
|
296
|
+
const cs = engine.cases.list();
|
|
297
|
+
return out(cs.length ? ['| id | 标题 | 定位 | 期望 |', '|---|---|---|---|', ...cs.map((c) => `| ${c.id} | ${c.title} | ${c.table ? `表 ${c.table.mode}#${c.table.simId}` : ''}${c.seed ? ' 种子' : ''} | ${x(c.expect.payoutCenti)} |`)].join('\n') : '还没有 case。用 case_add 或在预览里「存为 case」。', { cases: cs });
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
s.registerTool('case_add', {
|
|
301
|
+
description: '记一条 case:给 config + (seed 或 table:{mode,simId}),studio 跑一遍算出期望值存进 cases.jsonl。作者点播、AI 单测、结果漂移护栏三用。',
|
|
302
|
+
inputSchema: {
|
|
303
|
+
title: z.string(), tags: z.array(z.string()).optional(), note: z.string().optional(),
|
|
304
|
+
config: z.record(z.string(), z.unknown()), bet: z.number().int().default(100),
|
|
305
|
+
seed: z.object({ server: z.string(), client: z.string(), nonce: z.number().int() }).optional(),
|
|
306
|
+
table: z.object({ mode: z.string(), simId: z.number().int() }).optional(),
|
|
307
|
+
},
|
|
308
|
+
}, async (a) => {
|
|
309
|
+
try {
|
|
310
|
+
const rec = await engine.caseAdd({ ...a, origin: 'mcp' });
|
|
311
|
+
return out(`## ✅ 已记 case ${rec.id}「${rec.title}」\n\n期望 ${x(rec.expect.payoutCenti)},outcomeHash ${rec.expect.outcomeHash ?? '-'},restoreHash ${rec.expect.restoreHash ?? '-'}`, rec);
|
|
312
|
+
} catch (e) { return fail(e instanceof Error ? e.message : String(e)); }
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
s.registerTool('case_run', {
|
|
316
|
+
description: '跑 case:直接跑 round() 比 payout + outcomeHash,表模型走 restore 比 payout + restoreHash。任何结果变化都是 fail——有意改规则就 case_bless 接受(前提是这个 mode 没发布过)。顺带验 rules/ 没偷用随机源。',
|
|
317
|
+
inputSchema: { ids: z.array(z.string()).optional(), model: z.enum(['formula', 'table', 'both']).default('both') },
|
|
318
|
+
}, async (a) => {
|
|
319
|
+
try {
|
|
320
|
+
const r = await engine.caseRun(a);
|
|
321
|
+
const lines = [`## ${r.fail || r.error ? '❌' : '✅'} ${r.total} 条:${r.pass} 过,${r.fail} 败,${r.skipped} 跳过,${r.error} 错`, ''];
|
|
322
|
+
for (const x of r.results) if (x.status !== 'pass') lines.push(`- **${x.status}** ${x.id}「${x.title}」[${x.model}]:${x.detail}`);
|
|
323
|
+
if (r.fail) lines.push('', 'fail 的那些:结果变了。是 bug 就修;是有意改的规则且这个 mode 没发布过,用 case_bless 接受;已发布过的 mode 解码逻辑不能改,要改就换 slug。');
|
|
324
|
+
return out(lines.join('\n'), r);
|
|
325
|
+
} catch (e) { return fail(e instanceof Error ? e.message : String(e)); }
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
s.registerTool('case_bless', {
|
|
329
|
+
description: '接受 fail case 的新结果(用当前代码重跑,把期望值更新)。这是显式的确认动作——只对没发布过的 mode 这么做。',
|
|
330
|
+
inputSchema: { ids: z.array(z.string()).min(1) },
|
|
331
|
+
}, async (a) => {
|
|
332
|
+
try {
|
|
333
|
+
const r = await engine.caseRun({ ids: a.ids, model: 'both' });
|
|
334
|
+
const byId = new Map<string, { payoutCenti: number; outcomeHash?: string; restoreHash?: string }>();
|
|
335
|
+
for (const x of r.results) if (x.actual) byId.set(x.id, { ...(byId.get(x.id) ?? { payoutCenti: x.actual.payoutCenti }), ...x.actual });
|
|
336
|
+
const n = engine.cases.bless([...byId].map(([id, actual]) => {
|
|
337
|
+
const old = engine.cases.get(id)!;
|
|
338
|
+
return { id, expect: { ...old.expect, ...actual } };
|
|
339
|
+
}));
|
|
340
|
+
return out(`已更新 ${n} 条 case 的期望值`, { blessed: n });
|
|
341
|
+
} catch (e) { return fail(e instanceof Error ? e.message : String(e)); }
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
s.registerTool('job_status', { description: '查长任务。', inputSchema: { id: z.string() } }, async (a) => {
|
|
345
|
+
const j = engine.jobs.get(a.id);
|
|
346
|
+
if (!j) return fail(`没有 job ${a.id}`);
|
|
347
|
+
return out(`## ${j.kind} ${j.id}:${j.status}\n\n${j.progress.map((p) => `- ${p}`).join('\n')}${j.error ? `\n\n错误:${j.error}` : ''}`, j);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
return s;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export async function serveStdio(engine: Engine): Promise<McpServer> {
|
|
354
|
+
const server = createMcpServer(engine);
|
|
355
|
+
await server.connect(new StdioServerTransport());
|
|
356
|
+
return server;
|
|
357
|
+
}
|