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,59 @@
|
|
|
1
|
+
/* /studio/api 的类型化封装。页面和 node 侧同源,直接 fetch */
|
|
2
|
+
import type { Artifact, GroupsSummary } from '../../src/build.ts';
|
|
3
|
+
import type { CaseRecord, CaseResult } from '../../src/cases.ts';
|
|
4
|
+
import type { Engine, PublishRequest } from '../../src/engine.ts';
|
|
5
|
+
import type { Job } from '../../src/jobs.ts';
|
|
6
|
+
import type { ProbeResult } from '../../src/probe.ts';
|
|
7
|
+
import type { SolveReport, Targets } from '../../src/solver.ts';
|
|
8
|
+
import type { TaskName, TaskResult } from '../../src/tasks.ts';
|
|
9
|
+
|
|
10
|
+
const BASE = '/studio/api';
|
|
11
|
+
|
|
12
|
+
async function call<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
13
|
+
const r = await fetch(`${BASE}${path}`, {
|
|
14
|
+
method, headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
|
|
15
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
16
|
+
});
|
|
17
|
+
const data = await r.json() as T & { error?: string };
|
|
18
|
+
if (!r.ok) throw new Error(data.error ?? `${method} ${path} → ${r.status}`);
|
|
19
|
+
return data;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type Info = Awaited<ReturnType<Engine['info']>>;
|
|
23
|
+
|
|
24
|
+
export const api = {
|
|
25
|
+
info: () => call<Info>('GET', '/info'),
|
|
26
|
+
probe: (o: { sims?: number } = {}) => call<Job<ProbeResult>>('POST', '/probe', o),
|
|
27
|
+
sample: (modes: string[] | null, sims: number | null) => call<Job>('POST', '/sample', { modes, sims }),
|
|
28
|
+
groups: (mode: string | null, sims: number | null) =>
|
|
29
|
+
call<GroupsSummary>('GET', `/groups?${new URLSearchParams({ ...(mode ? { mode } : {}), ...(sims ? { sims: String(sims) } : {}) })}`),
|
|
30
|
+
solve: (mode: string | null, sims: number | null, targets: Targets) => call<SolveReport & { mode: string; rtpRangePpm: [number, number] }>('POST', '/solve', { mode, sims, targets }),
|
|
31
|
+
build: (o: { modes?: string[] | null; sims?: number; rtpPpm?: number; segments?: unknown }) => call<Job<Artifact[]>>('POST', '/build', o),
|
|
32
|
+
artifacts: () => call<Artifact[]>('GET', '/artifacts'),
|
|
33
|
+
artifactTable: async (mode: string) => new Uint8Array(await (await fetch(`${BASE}/artifacts/${encodeURIComponent(mode)}/table`)).arrayBuffer()),
|
|
34
|
+
artifactBooks: (mode: string) => call<{ simId: number; frames: string }[]>('GET', `/artifacts/${encodeURIComponent(mode)}/books`),
|
|
35
|
+
jobs: () => call<Job[]>('GET', '/jobs'),
|
|
36
|
+
job: <T>(id: string) => call<Job<T>>('GET', `/jobs/${id}`),
|
|
37
|
+
task: (task: TaskName) => call<Job<TaskResult>>('POST', '/task', { task }),
|
|
38
|
+
cases: () => call<CaseRecord[]>('GET', '/cases'),
|
|
39
|
+
caseRun: (o: { ids?: string[]; model?: 'formula' | 'table' | 'both' } = {}) =>
|
|
40
|
+
call<{ total: number; pass: number; fail: number; skipped: number; error: number; results: CaseResult[] }>('POST', '/cases/run', o),
|
|
41
|
+
caseBless: (updates: { id: string; expect: CaseRecord['expect'] }[]) => call<{ blessed: number }>('POST', '/cases/bless', { updates }),
|
|
42
|
+
caseRemove: (id: string) => call<{ removed: boolean }>('DELETE', `/cases/${id}`),
|
|
43
|
+
publishList: () => call<PublishRequest[]>('GET', '/publish'),
|
|
44
|
+
publishRequest: (modes: string[] | null, note: string | null) => call<PublishRequest>('POST', '/publish/request', { modes, note }),
|
|
45
|
+
publishUpdate: (id: string, patch: Partial<PublishRequest>) => call<PublishRequest>('POST', `/publish/${id}`, patch),
|
|
46
|
+
previewCommand: <T>(cmd: unknown) => call<T>('POST', '/preview/command', cmd),
|
|
47
|
+
log: (msg: string, level: 'info' | 'warn' | 'error' = 'info') => call<unknown>('POST', '/log', { msg, level }),
|
|
48
|
+
|
|
49
|
+
/** 等一个 job 结束 */
|
|
50
|
+
async waitJob<T>(id: string, onProgress?: (msg: string) => void): Promise<Job<T>> {
|
|
51
|
+
let seen = 0;
|
|
52
|
+
for (;;) {
|
|
53
|
+
const j = await api.job<T>(id);
|
|
54
|
+
for (; seen < j.progress.length; seen++) onProgress?.(j.progress[seen]!);
|
|
55
|
+
if (j.status !== 'running') return j;
|
|
56
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/* 页面侧的总线订阅:SSE → runes。日志、任务、指纹、预览状态、发布请求都从这里进来 */
|
|
2
|
+
import type { BusEvent } from '../../src/bus.ts';
|
|
3
|
+
import type { PreviewState, PublishRequest } from '../../src/engine.ts';
|
|
4
|
+
|
|
5
|
+
export interface LogLine { level: 'info' | 'warn' | 'error'; msg: string; at: number }
|
|
6
|
+
|
|
7
|
+
class Bus {
|
|
8
|
+
connected = $state(false);
|
|
9
|
+
clients = $state(0);
|
|
10
|
+
logs = $state<LogLine[]>([]);
|
|
11
|
+
jobs = $state<Record<string, { kind: string; status: string; msg: string; at: number }>>({});
|
|
12
|
+
fingerprint = $state<string | null>(null);
|
|
13
|
+
preview = $state<PreviewState | null>(null);
|
|
14
|
+
publish = $state<Record<string, PublishRequest>>({});
|
|
15
|
+
casesVersion = $state(0);
|
|
16
|
+
buildVersion = $state(0);
|
|
17
|
+
|
|
18
|
+
#es: EventSource | null = null;
|
|
19
|
+
|
|
20
|
+
connect(): void {
|
|
21
|
+
if (this.#es) return;
|
|
22
|
+
const es = new EventSource('/studio/api/events?role=page');
|
|
23
|
+
this.#es = es;
|
|
24
|
+
es.onopen = () => { this.connected = true; };
|
|
25
|
+
es.onerror = () => { this.connected = false; };
|
|
26
|
+
es.onmessage = (e) => {
|
|
27
|
+
let m: BusEvent;
|
|
28
|
+
try { m = JSON.parse(String(e.data)) as BusEvent; } catch { return; }
|
|
29
|
+
switch (m.t) {
|
|
30
|
+
case 'hello': this.clients = m.clients; break;
|
|
31
|
+
case 'log': this.logs = [...this.logs.slice(-199), { level: m.level, msg: m.msg, at: m.at }]; break;
|
|
32
|
+
case 'job': this.jobs = { ...this.jobs, [m.id]: { kind: m.kind, status: m.status, msg: m.msg, at: Date.now() } }; break;
|
|
33
|
+
case 'fingerprint': this.fingerprint = m.hash; break;
|
|
34
|
+
case 'preview': this.preview = m.state as PreviewState; break;
|
|
35
|
+
case 'cases': this.casesVersion++; break;
|
|
36
|
+
case 'build': this.buildVersion++; break;
|
|
37
|
+
case 'publish': { const r = m.request as PublishRequest; this.publish = { ...this.publish, [r.id]: r }; break; }
|
|
38
|
+
default: break;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const bus = new Bus();
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/* 浏览器里的发布:身份是页面上登录的那个人。
|
|
2
|
+
*
|
|
3
|
+
* 表字节从 /studio/api 取(node 侧建好的产物),描述配置由页面里 import 的游戏清单算——
|
|
4
|
+
* GameModule 本身就是一份 GameDefinition,上传流程原样复用 publish 的 upload-core:
|
|
5
|
+
* CLI 走的也是这一份,只是身份换成了浏览器 cookie。 */
|
|
6
|
+
import { createClient, type GameKitClient } from 'gamekit777/client';
|
|
7
|
+
import type { GameDefinition } from 'gamekit777/sdk';
|
|
8
|
+
import { uploadTables, type UploadResult, type UploadTable } from 'gamekit777/publish/upload-core';
|
|
9
|
+
import type { Artifact } from '../../src/build.ts';
|
|
10
|
+
import { api } from './api.ts';
|
|
11
|
+
|
|
12
|
+
/** 同源,cookie 由浏览器带。/v1 反代到平台后端 */
|
|
13
|
+
export const client: GameKitClient = createClient({ baseUrl: '' });
|
|
14
|
+
|
|
15
|
+
export interface PublishPlan {
|
|
16
|
+
spec: GameDefinition<object, unknown>;
|
|
17
|
+
tables: UploadTable[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 把 node 侧的产物 + 页面里的游戏清单拼成 uploadTables 要的形状 */
|
|
21
|
+
export async function planFromArtifacts(spec: GameDefinition<object, unknown>, arts: Artifact[]): Promise<PublishPlan> {
|
|
22
|
+
const tables: UploadTable[] = await Promise.all(arts.map(async (a) => ({
|
|
23
|
+
mode: a.mode,
|
|
24
|
+
bytes: await api.artifactTable(a.mode),
|
|
25
|
+
contentHash: a.contentHash, rowCount: a.rowCount, betCostCenti: a.betCostCenti,
|
|
26
|
+
books: a.hasBooks ? await api.artifactBooks(a.mode) : null,
|
|
27
|
+
verdict: { computedRtpPpm: a.computedRtpPpm },
|
|
28
|
+
})));
|
|
29
|
+
return { spec, tables };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function runUpload(plan: PublishPlan, o: { publishModes?: string[]; onProgress: (m: string) => void }): Promise<UploadResult> {
|
|
33
|
+
return uploadTables(plan.spec, plan.tables, { client, publishModes: o.publishModes, onProgress: o.onProgress });
|
|
34
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { api, type Info } from '../lib/api.ts';
|
|
3
|
+
import { bus } from '../lib/bus.svelte.ts';
|
|
4
|
+
import type { CaseRecord, CaseResult } from '../../src/cases.ts';
|
|
5
|
+
|
|
6
|
+
let { info }: { info: Info } = $props();
|
|
7
|
+
let cases = $state<CaseRecord[]>([]);
|
|
8
|
+
let results = $state<Map<string, CaseResult[]>>(new Map());
|
|
9
|
+
let summary = $state<{ pass: number; fail: number; skipped: number; error: number } | null>(null);
|
|
10
|
+
let busy = $state(false);
|
|
11
|
+
let err = $state<string | null>(null);
|
|
12
|
+
|
|
13
|
+
$effect(() => { void bus.casesVersion; void api.cases().then((c) => { cases = c; }); });
|
|
14
|
+
|
|
15
|
+
const x = (c: number) => `${(c / 100).toFixed(2)}x`;
|
|
16
|
+
|
|
17
|
+
async function run(ids?: string[]) {
|
|
18
|
+
busy = true; err = null;
|
|
19
|
+
try {
|
|
20
|
+
const r = await api.caseRun({ ids, model: 'both' });
|
|
21
|
+
const m = new Map<string, CaseResult[]>();
|
|
22
|
+
for (const x of r.results) m.set(x.id, [...(m.get(x.id) ?? []), x]);
|
|
23
|
+
results = m; summary = r;
|
|
24
|
+
} catch (e) { err = e instanceof Error ? e.message : String(e); }
|
|
25
|
+
finally { busy = false; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function bless(ids: string[]) {
|
|
29
|
+
busy = true; err = null;
|
|
30
|
+
try {
|
|
31
|
+
const r = await api.caseRun({ ids, model: 'both' });
|
|
32
|
+
const byId = new Map<string, CaseRecord['expect']>();
|
|
33
|
+
for (const x of r.results) if (x.actual) byId.set(x.id, { ...(byId.get(x.id) ?? cases.find((c) => c.id === x.id)!.expect), ...x.actual });
|
|
34
|
+
await api.caseBless([...byId].map(([id, expect]) => ({ id, expect })));
|
|
35
|
+
await run(ids);
|
|
36
|
+
} catch (e) { err = e instanceof Error ? e.message : String(e); }
|
|
37
|
+
finally { busy = false; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const failed = $derived([...new Set([...results.values()].flat().filter((r) => r.status === 'fail').map((r) => r.id))]);
|
|
41
|
+
const replay = (c: CaseRecord) => api.previewCommand({ t: 'set', config: c.config, bet: c.bet })
|
|
42
|
+
.then(() => api.previewCommand({ t: 'play', rounds: 1, seed: c.seed, simId: c.table?.simId }))
|
|
43
|
+
.catch((e: unknown) => { err = e instanceof Error ? e.message : String(e); });
|
|
44
|
+
</script>
|
|
45
|
+
|
|
46
|
+
<div class="card">
|
|
47
|
+
<h2>Case <span class="hint">{info.caseFile}</span></h2>
|
|
48
|
+
<p class="hint">一条 case 是一个局面:作者点一下就在预览里重演、AI 读它写单测、以及结果漂移的护栏——任何结果变化都是 fail;有意改的规则且这个 mode 没发布过,确认后 bless。文件可以直接手改,studio 会跟着重载。</p>
|
|
49
|
+
<div class="row">
|
|
50
|
+
<button class="pri" onclick={() => run()} disabled={busy || !cases.length}>{busy ? '…' : '跑全部'}</button>
|
|
51
|
+
{#if failed.length}<button class="sec" onclick={() => bless(failed)} disabled={busy}>接受 {failed.length} 条 fail 的新结果</button>{/if}
|
|
52
|
+
{#if summary}<span class="hint"><span class="ok">{summary.pass} 过</span> · <span class="bad">{summary.fail} 败</span> · {summary.skipped} 跳过 · {summary.error} 错</span>{/if}
|
|
53
|
+
</div>
|
|
54
|
+
{#if err}<p class="bad">{err}</p>{/if}
|
|
55
|
+
{#if !cases.length}<p class="hint">还没有 case。在预览的历史列表里点「存」,或让 AI 用 case_add。</p>{/if}
|
|
56
|
+
<table>
|
|
57
|
+
<thead><tr><th>id</th><th>标题</th><th>定位</th><th>期望</th><th>结果</th><th></th></tr></thead>
|
|
58
|
+
<tbody>
|
|
59
|
+
{#each cases as c (c.id)}
|
|
60
|
+
{@const rs = results.get(c.id) ?? []}
|
|
61
|
+
<tr>
|
|
62
|
+
<td class="mono hint">{c.id}</td>
|
|
63
|
+
<td>{c.title}{#if c.tags?.length}<span class="hint"> {c.tags.join(' ')}</span>{/if}</td>
|
|
64
|
+
<td class="mono hint">{c.table ? `${c.table.mode}#${c.table.simId}` : ''}{c.seed ? ' 种子' : ''}</td>
|
|
65
|
+
<td class="num">{x(c.expect.payoutCenti)}</td>
|
|
66
|
+
<td>{#each rs as r (r.model)}<span class:ok={r.status === 'pass'} class:bad={r.status === 'fail' || r.status === 'error'} title={r.detail}>{r.model[0]}:{r.status} </span>{/each}</td>
|
|
67
|
+
<td class="acts">
|
|
68
|
+
<button class="sec" onclick={() => replay(c)} disabled={!bus.preview?.connected} title="在预览里重演">演</button>
|
|
69
|
+
<button class="sec" onclick={() => run([c.id])} disabled={busy}>跑</button>
|
|
70
|
+
<button class="sec" onclick={() => api.caseRemove(c.id)}>删</button>
|
|
71
|
+
</td>
|
|
72
|
+
</tr>
|
|
73
|
+
{#each rs.filter((r) => r.status !== 'pass') as r (r.model)}<tr><td colspan="6" class="hint detail">[{r.model}] {r.detail}</td></tr>{/each}
|
|
74
|
+
{/each}
|
|
75
|
+
</tbody>
|
|
76
|
+
</table>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<style>
|
|
80
|
+
.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
|
|
81
|
+
.acts{display:flex;gap:4px} .acts button{padding:2px 8px;font-size:11px}
|
|
82
|
+
.detail{font-size:11px;padding-left:24px}
|
|
83
|
+
</style>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { bus } from '../lib/bus.svelte.ts';
|
|
3
|
+
const jobs = $derived(Object.entries(bus.jobs).sort((a, b) => b[1].at - a[1].at));
|
|
4
|
+
</script>
|
|
5
|
+
|
|
6
|
+
<div class="grid">
|
|
7
|
+
<div class="card">
|
|
8
|
+
<h2>任务</h2>
|
|
9
|
+
<table><thead><tr><th>id</th><th>类型</th><th>状态</th><th>最近进度</th></tr></thead><tbody>
|
|
10
|
+
{#each jobs as [id, j] (id)}<tr><td class="mono hint">{id}</td><td>{j.kind}</td><td class:ok={j.status === 'done'} class:bad={j.status === 'failed'} class:warn={j.status === 'running'}>{j.status}</td><td>{j.msg}</td></tr>{/each}
|
|
11
|
+
</tbody></table>
|
|
12
|
+
</div>
|
|
13
|
+
<div class="card">
|
|
14
|
+
<h2>日志</h2>
|
|
15
|
+
<pre>{bus.logs.map((l) => `${new Date(l.at).toLocaleTimeString()} [${l.level}] ${l.msg}`).join('\n') || '(空)'}</pre>
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<style>.grid{display:grid;gap:14px}</style>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/* 预览的状态。机台本身在左边那一栏,这里只放它报上来的东西:哪张表、注额、报错,以及怎么用 MCP 驱动它 */
|
|
3
|
+
import type { Info } from '../lib/api.ts';
|
|
4
|
+
import { bus } from '../lib/bus.svelte.ts';
|
|
5
|
+
let { info }: { info: Info } = $props();
|
|
6
|
+
const p = $derived(bus.preview);
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<div class="card">
|
|
10
|
+
<h2>预览状态</h2>
|
|
11
|
+
{#if p?.connected}
|
|
12
|
+
<p class="hint">表 <span class="mono">{p.mode}</span> · 注额 {p.bet !== null ? (p.bet / 100).toFixed(2) : '—'}</p>
|
|
13
|
+
{#if p.lastError}<p class="bad">{p.lastError}</p>{/if}
|
|
14
|
+
{#if p.consoleErrors?.length}
|
|
15
|
+
<details><summary class="warn">{p.consoleErrors.length} 条控制台报错</summary><pre>{p.consoleErrors.join('\n')}</pre></details>
|
|
16
|
+
{:else}
|
|
17
|
+
<p class="hint">控制台没有报错</p>
|
|
18
|
+
{/if}
|
|
19
|
+
{:else}
|
|
20
|
+
<p class="hint">预览页还没连上总线</p>
|
|
21
|
+
{/if}
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<div class="card">
|
|
25
|
+
<h2>这块预览是什么</h2>
|
|
26
|
+
<p class="hint">本地表模型,和玩家站点同一台机器:有建好的表就用建好的,没有就在第一注时就地采样建表。
|
|
27
|
+
机台下面的「历史」里,每一局都可以存成 case。</p>
|
|
28
|
+
<p class="hint">MCP:<code>claude mcp add gamekit -- bunx gamekit777 studio</code><br>在 {info.gameDir} 里起 Claude Code 即可驱动这块预览。</p>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<style>
|
|
32
|
+
code{font-family:var(--mono);font-size:11px;background:var(--field);padding:1px 5px;border-radius:4px}
|
|
33
|
+
</style>
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/* 发布:身份是这个页面上登录的人(/v1 同源反代,cookie 由浏览器带)。
|
|
3
|
+
node 侧只排队和记录;MCP 发起的请求也落在这里,作者点确认才真传。发布不可逆。 */
|
|
4
|
+
import { onMount } from 'svelte';
|
|
5
|
+
import type { GameModule } from 'gamekit777/sdk';
|
|
6
|
+
import type { SessionView } from 'gamekit777/protocol';
|
|
7
|
+
import game from 'virtual:studio/game';
|
|
8
|
+
import { api, type Info } from '../lib/api.ts';
|
|
9
|
+
import { bus } from '../lib/bus.svelte.ts';
|
|
10
|
+
import { client, planFromArtifacts, runUpload } from '../lib/upload.ts';
|
|
11
|
+
|
|
12
|
+
let { info }: { info: Info } = $props();
|
|
13
|
+
const spec = $derived(info.snapshot.spec);
|
|
14
|
+
let me = $state<SessionView | null>(null);
|
|
15
|
+
let meErr = $state<string | null>(null);
|
|
16
|
+
let note = $state('');
|
|
17
|
+
let running = $state<string | null>(null);
|
|
18
|
+
let log = $state<string[]>([]);
|
|
19
|
+
|
|
20
|
+
const reqs = $derived(Object.values(bus.publish).sort((a, b) => b.createdAt - a.createdAt));
|
|
21
|
+
const pct = (ppm: number) => `${(ppm / 1e4).toFixed(4)}%`;
|
|
22
|
+
|
|
23
|
+
async function whoami() {
|
|
24
|
+
try { me = await client.session.me(); meErr = null; }
|
|
25
|
+
catch (e) { me = null; meErr = e instanceof Error ? e.message : String(e); }
|
|
26
|
+
}
|
|
27
|
+
async function anon() {
|
|
28
|
+
try { me = await client.session.bootstrap(); meErr = null; }
|
|
29
|
+
catch (e) { meErr = e instanceof Error ? e.message : String(e); }
|
|
30
|
+
}
|
|
31
|
+
onMount(() => { void whoami(); void api.publishList().then((l) => { for (const r of l) bus.publish = { ...bus.publish, [r.id]: r }; }); });
|
|
32
|
+
|
|
33
|
+
const lowest = $derived(info.artifacts.reduce<number | null>((m, a) => (m === null || a.computedRtpPpm < m ? a.computedRtpPpm : m), null));
|
|
34
|
+
const localIssues = $derived.by(() => {
|
|
35
|
+
const out: string[] = [];
|
|
36
|
+
const declared = Math.round(spec.rtp * 1e6);
|
|
37
|
+
if (!info.artifacts.length) out.push('当前指纹下没有已建的表');
|
|
38
|
+
if (lowest !== null && lowest + 100 < declared) out.push(`最低实算 RTP ${pct(lowest)} 低于游戏声明 ${pct(declared)},平台会拒`);
|
|
39
|
+
const miss = spec.modes.filter((m) => !info.artifacts.some((a) => a.mode === m.name));
|
|
40
|
+
if (miss.length) out.push(`未建:${miss.map((m) => m.name).join('、')}`);
|
|
41
|
+
return out;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
async function approve(id: string) {
|
|
45
|
+
const req = bus.publish[id]; if (!req) return;
|
|
46
|
+
running = id; log = [];
|
|
47
|
+
const say = (m: string) => { log = [...log, m]; void api.publishUpdate(id, { progress: [...(bus.publish[id]?.progress ?? []), m] }); };
|
|
48
|
+
await api.publishUpdate(id, { status: 'running' });
|
|
49
|
+
try {
|
|
50
|
+
const arts = req.modes ? info.artifacts.filter((a) => req.modes!.includes(a.mode)) : info.artifacts;
|
|
51
|
+
if (!arts.length) throw new Error('没有可发布的表');
|
|
52
|
+
say(`拼装 ${arts.length} 张表…`);
|
|
53
|
+
const plan = await planFromArtifacts(game as GameModule<object, unknown>, arts);
|
|
54
|
+
const r = await runUpload(plan, { publishModes: req.modes ?? undefined, onProgress: say });
|
|
55
|
+
await api.publishUpdate(id, { status: 'published', result: { gameId: r.game.id, slug: r.game.slug, verifiedRtpPpm: r.game.verifiedRtpPpm, modes: r.tables.map((t) => t.mode) } });
|
|
56
|
+
say(`✅ 已发布 ${r.game.slug}(${r.game.id})`);
|
|
57
|
+
} catch (e) {
|
|
58
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
59
|
+
await api.publishUpdate(id, { status: 'failed', error: msg });
|
|
60
|
+
say(`✗ ${msg}`);
|
|
61
|
+
} finally { running = null; }
|
|
62
|
+
}
|
|
63
|
+
const reject = (id: string) => api.publishUpdate(id, { status: 'rejected' });
|
|
64
|
+
const request = () => api.publishRequest(null, note || null).then(() => { note = ''; });
|
|
65
|
+
</script>
|
|
66
|
+
|
|
67
|
+
<div class="grid">
|
|
68
|
+
<div class="card">
|
|
69
|
+
<h2>身份</h2>
|
|
70
|
+
{#if me}
|
|
71
|
+
<p>{me.user.isAnonymous ? '匿名用户' : me.user.id} <span class="mono hint">{me.user.id.slice(0, 8)}…</span> · 余额 {me.wallet.balanceCents}</p>
|
|
72
|
+
<p class="hint">发布出来的游戏归这个身份。cookie 不分端口——它和 apps/web 在 localhost 上共享。</p>
|
|
73
|
+
{:else}
|
|
74
|
+
<p class="hint">{meErr ?? '未登录'}</p>
|
|
75
|
+
<button class="sec" onclick={anon}>创建匿名会话</button>
|
|
76
|
+
{/if}
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<div class="card">
|
|
80
|
+
<h2>本地体检</h2>
|
|
81
|
+
{#if localIssues.length}{#each localIssues as i (i)}<p class="bad">✗ {i}</p>{/each}{:else}<p class="ok">✓ {info.artifacts.length} 张表就绪,最低实算 RTP {lowest !== null ? pct(lowest) : '-'}</p>{/if}
|
|
82
|
+
<div class="row"><input placeholder="说明(可空)" bind:value={note} style="flex:1" /><button class="pri" onclick={request} disabled={!!localIssues.length}>发起发布请求</button></div>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<div class="card">
|
|
86
|
+
<h2>发布请求</h2>
|
|
87
|
+
{#if !reqs.length}<p class="hint">没有待处理的请求。AI 用 publish 工具发起的请求会出现在这里。</p>{/if}
|
|
88
|
+
{#each reqs as r (r.id)}
|
|
89
|
+
<div class="req" class:active={r.status === 'awaiting-approval'}>
|
|
90
|
+
<div class="row">
|
|
91
|
+
<b class="mono">{r.id}</b><span class="hint">来自 {r.from} · {new Date(r.createdAt).toLocaleTimeString()}</span>
|
|
92
|
+
<span class:ok={r.status === 'published'} class:bad={r.status === 'failed' || r.status === 'rejected'} class:warn={r.status === 'awaiting-approval'}>{r.status}</span>
|
|
93
|
+
<span style="flex:1"></span>
|
|
94
|
+
{#if r.status === 'awaiting-approval'}
|
|
95
|
+
<button class="pri" onclick={() => approve(r.id)} disabled={!!running || !me || !!localIssues.length} title="不可逆:已生效的 mode 不能再换表">确认发布{r.modes ? `(${r.modes.join('、')})` : '(全部)'}</button>
|
|
96
|
+
<button class="sec" onclick={() => reject(r.id)}>拒绝</button>
|
|
97
|
+
{/if}
|
|
98
|
+
</div>
|
|
99
|
+
{#if r.note}<p class="hint">{r.note}</p>{/if}
|
|
100
|
+
{#if r.error}<p class="bad">{r.error}</p>{/if}
|
|
101
|
+
{#if running === r.id && log.length}<pre>{log.join('\n')}</pre>{:else if r.progress.length}<pre>{r.progress.join('\n')}</pre>{/if}
|
|
102
|
+
</div>
|
|
103
|
+
{/each}
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
<style>
|
|
108
|
+
.grid{display:grid;gap:14px;max-width:900px} .row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
|
|
109
|
+
.req{border:1px solid var(--line);border-radius:10px;padding:10px;display:flex;flex-direction:column;gap:6px}
|
|
110
|
+
.req.active{border-color:var(--accent-ink)}
|
|
111
|
+
</style>
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { api, type Info } from '../lib/api.ts';
|
|
3
|
+
import { bus } from '../lib/bus.svelte.ts';
|
|
4
|
+
import type { Artifact } from '../../src/build.ts';
|
|
5
|
+
|
|
6
|
+
let { info }: { info: Info } = $props();
|
|
7
|
+
const spec = $derived(info.snapshot.spec);
|
|
8
|
+
// 只取初始值是刻意的:之后由总线的 build 事件刷新 arts,sims 是作者的选择
|
|
9
|
+
// svelte-ignore state_referenced_locally
|
|
10
|
+
let arts = $state<Artifact[]>(info.artifacts);
|
|
11
|
+
// svelte-ignore state_referenced_locally
|
|
12
|
+
let sims = $state(info.snapshot.spec.sims);
|
|
13
|
+
let log = $state<string[]>([]);
|
|
14
|
+
let busy = $state(false);
|
|
15
|
+
let err = $state<string | null>(null);
|
|
16
|
+
|
|
17
|
+
$effect(() => { void bus.buildVersion; void api.artifacts().then((a) => { arts = a; }); });
|
|
18
|
+
|
|
19
|
+
const pct = (ppm: number) => `${(ppm / 1e4).toFixed(4)}%`;
|
|
20
|
+
const x = (c: number) => `${(c / 100).toFixed(2)}x`;
|
|
21
|
+
const built = (m: string) => arts.find((a) => a.mode === m);
|
|
22
|
+
|
|
23
|
+
async function build(modes: string[] | null) {
|
|
24
|
+
busy = true; err = null; log = [];
|
|
25
|
+
try {
|
|
26
|
+
const job = await api.build({ modes, sims });
|
|
27
|
+
const done = await api.waitJob(job.id, (m) => { log = [...log, m]; });
|
|
28
|
+
if (done.status !== 'done') err = done.error ?? '建表失败';
|
|
29
|
+
} catch (e) { err = e instanceof Error ? e.message : String(e); }
|
|
30
|
+
finally { busy = false; }
|
|
31
|
+
}
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<div class="card">
|
|
35
|
+
<h2>建表 <span class="hint">当前指纹 <span class="mono">{info.fingerprint}</span></span></h2>
|
|
36
|
+
<p class="hint">采样 → 求解 → 量化 → 编码 → 本地终验 → 落盘 .studio/build/<指纹>/。产物同时供表模型预览和发布,是同一份文件。改了 rules/ 指纹就变,这里的表全部作废。</p>
|
|
37
|
+
<div class="row">
|
|
38
|
+
<label>每张表采样 <input type="number" bind:value={sims} step="1000" min="1000" style="width:7em" /> 局</label>
|
|
39
|
+
<button class="pri" onclick={() => build(null)} disabled={busy}>{busy ? '建表中…' : `建全部 ${spec.modes.length} 张`}</button>
|
|
40
|
+
</div>
|
|
41
|
+
{#if err}<p class="bad">{err}</p>{/if}
|
|
42
|
+
{#if log.length}<pre>{log.join('\n')}</pre>{/if}
|
|
43
|
+
<table>
|
|
44
|
+
<thead><tr><th>mode</th><th>目标 RTP</th><th>状态</th><th>实算 RTP</th><th>行数</th><th>最大赔付</th><th>命中率</th><th>sha256</th><th></th></tr></thead>
|
|
45
|
+
<tbody>
|
|
46
|
+
{#each spec.modes as m (m.name)}
|
|
47
|
+
{@const a = built(m.name)}
|
|
48
|
+
<tr>
|
|
49
|
+
<td class="mono">{m.name}</td>
|
|
50
|
+
<td class="num">{pct(Math.round(spec.rtp * 1e6))}</td>
|
|
51
|
+
<td>{#if a}<span class="ok">已建</span>{#if a.stats.thin.length}<span class="warn" title={a.stats.thin.map((t) => `${x(t.payoutCenti)}×${t.samples}局`).join('、')}> ⚠{a.stats.thin.length}</span>{/if}{:else}<span class="hint">未建</span>{/if}</td>
|
|
52
|
+
<td class="num">{a ? pct(a.computedRtpPpm) : ''}</td>
|
|
53
|
+
<td class="num">{a ? a.rowCount.toLocaleString() : ''}</td>
|
|
54
|
+
<td class="num">{a ? x(a.maxPayoutCenti) : ''}</td>
|
|
55
|
+
<td class="num">{a ? pct(a.hitRatePpm) : ''}</td>
|
|
56
|
+
<td class="mono hint">{a ? a.contentHash.slice(0, 12) + '…' : ''}</td>
|
|
57
|
+
<td><button class="sec" onclick={() => build([m.name])} disabled={busy}>{a ? '重建' : '建'}</button></td>
|
|
58
|
+
</tr>
|
|
59
|
+
{/each}
|
|
60
|
+
</tbody>
|
|
61
|
+
</table>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<style>.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap} label{display:flex;gap:6px;align-items:center;font-size:13px}</style>
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/* 调参:拖滑杆 → 每帧在浏览器里 solve(3~4ms)→ 实算与目标并排。
|
|
3
|
+
采样在 node 侧算一次缓存,这里只拿 groups 摘要。满意了就带着 segments 去建表。 */
|
|
4
|
+
import { api, type Info } from '../lib/api.ts';
|
|
5
|
+
import type { GroupsSummary } from '../../src/build.ts';
|
|
6
|
+
import { defaultSegments, rtpRangePpm, runSolve, type SegmentTarget, type SolveReport } from '../../src/solver.ts';
|
|
7
|
+
|
|
8
|
+
let { info }: { info: Info } = $props();
|
|
9
|
+
const spec = $derived(info.snapshot.spec);
|
|
10
|
+
|
|
11
|
+
// 只取初始值是刻意的:mode / sims 是作者在这个面板里的选择,不该跟着 info 刷新被重置
|
|
12
|
+
// svelte-ignore state_referenced_locally
|
|
13
|
+
let mode = $state(info.snapshot.spec.modes[0]?.name ?? 'base');
|
|
14
|
+
// svelte-ignore state_referenced_locally
|
|
15
|
+
let sims = $state(Math.min(info.snapshot.spec.sims, 50_000));
|
|
16
|
+
let g = $state<GroupsSummary | null>(null);
|
|
17
|
+
let loading = $state<string | null>(null);
|
|
18
|
+
let err = $state<string | null>(null);
|
|
19
|
+
|
|
20
|
+
let rtpPpm = $state(970_000);
|
|
21
|
+
let segs = $state<SegmentTarget[]>([]);
|
|
22
|
+
let report = $state<SolveReport | null>(null);
|
|
23
|
+
let building = $state(false);
|
|
24
|
+
|
|
25
|
+
const range = $derived(g ? rtpRangePpm(g.groups, g.betCostCenti) : [800_000, 990_000] as [number, number]);
|
|
26
|
+
const pct = (ppm: number) => `${(ppm / 1e4).toFixed(2)}%`;
|
|
27
|
+
const x = (c: number) => `${(c / 100).toFixed(2)}x`;
|
|
28
|
+
|
|
29
|
+
async function load() {
|
|
30
|
+
err = null; loading = '采样中(首次会慢,之后命中缓存)…';
|
|
31
|
+
try {
|
|
32
|
+
g = await api.groups(mode, sims);
|
|
33
|
+
rtpPpm = g.declaredRtpPpm;
|
|
34
|
+
segs = defaultSegments(g.groups);
|
|
35
|
+
solve();
|
|
36
|
+
} catch (e) { err = e instanceof Error ? e.message : String(e); }
|
|
37
|
+
finally { loading = null; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let raf = 0;
|
|
41
|
+
function solve() {
|
|
42
|
+
if (!g) return;
|
|
43
|
+
cancelAnimationFrame(raf);
|
|
44
|
+
raf = requestAnimationFrame(() => { report = runSolve(g!.groups, { totalRtpPpm: rtpPpm, segments: segs }, g!.betCostCenti, g!.sims); });
|
|
45
|
+
}
|
|
46
|
+
$effect(() => { void rtpPpm; void JSON.stringify(segs); solve(); });
|
|
47
|
+
|
|
48
|
+
async function buildWithThese() {
|
|
49
|
+
if (!report?.ok) return;
|
|
50
|
+
building = true; err = null;
|
|
51
|
+
try {
|
|
52
|
+
const job = await api.build({ modes: [mode], sims, rtpPpm, segments: report.segments });
|
|
53
|
+
const done = await api.waitJob(job.id);
|
|
54
|
+
if (done.status !== 'done') err = done.error ?? '建表失败';
|
|
55
|
+
} catch (e) { err = e instanceof Error ? e.message : String(e); }
|
|
56
|
+
finally { building = false; }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const seg = (i: number, patch: Partial<SegmentTarget>) => { segs = segs.map((s, k) => (k === i ? { ...s, ...patch } : s)); };
|
|
60
|
+
const achieved = (name: string) => report?.achieved?.segments.find((s) => s.name === name);
|
|
61
|
+
</script>
|
|
62
|
+
|
|
63
|
+
<div class="grid">
|
|
64
|
+
<div class="card">
|
|
65
|
+
<h2>采样</h2>
|
|
66
|
+
<div class="row">
|
|
67
|
+
<label>mode <select bind:value={mode}>{#each spec.modes as m (m.name)}<option value={m.name}>{m.name}</option>{/each}</select></label>
|
|
68
|
+
<label>局数 <input type="number" bind:value={sims} min="1000" step="1000" style="width:7em" /></label>
|
|
69
|
+
<button class="pri" onclick={load} disabled={!!loading}>{loading ? '…' : g ? '重新采样' : '采样'}</button>
|
|
70
|
+
</div>
|
|
71
|
+
{#if loading}<p class="hint">{loading}</p>{/if}
|
|
72
|
+
{#if err}<p class="bad">{err}</p>{/if}
|
|
73
|
+
{#if g}
|
|
74
|
+
<p class="hint">{g.distinctPayouts} 种赔付 · 自然 RTP {pct(g.naturalRtpPpm)} · 命中 {pct(g.hitRatePpm)} · 最大 {x(g.maxPayoutCenti)} · 可达 RTP [{pct(range[0])}, {pct(range[1])}]</p>
|
|
75
|
+
<div class="bars">
|
|
76
|
+
{#each g.groups as gr (gr.payout)}
|
|
77
|
+
{@const w = report?.weights ? report.weights[g.groups.indexOf(gr)]! : gr.prior / g.sims}
|
|
78
|
+
<div class="bar" title="{x(gr.payout)}:自然 {(gr.prior / g.sims * 100).toFixed(3)}% → 求解 {(w * 100).toFixed(3)}%">
|
|
79
|
+
<i style="height:{Math.max(2, Math.sqrt(w) * 100)}%"></i><span>{x(gr.payout)}</span>
|
|
80
|
+
</div>
|
|
81
|
+
{/each}
|
|
82
|
+
</div>
|
|
83
|
+
{/if}
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
{#if g}
|
|
87
|
+
<div class="card">
|
|
88
|
+
<h2>目标</h2>
|
|
89
|
+
<label class="slider">总 RTP <b class="mono">{pct(rtpPpm)}</b>
|
|
90
|
+
<input type="range" min={range[0]} max={range[1]} step="100" bind:value={rtpPpm} />
|
|
91
|
+
</label>
|
|
92
|
+
<table>
|
|
93
|
+
<thead><tr><th>段</th><th>赔付区间</th><th>每 N 局中</th><th>RTP 份额</th><th>均值/中位</th><th>实算</th></tr></thead>
|
|
94
|
+
<tbody>
|
|
95
|
+
{#each segs as s, i (s.name)}
|
|
96
|
+
{@const a = achieved(s.name)}
|
|
97
|
+
<tr>
|
|
98
|
+
<td><input value={s.name} onchange={(e) => seg(i, { name: e.currentTarget.value })} style="width:5em" /></td>
|
|
99
|
+
<td class="num"><input type="number" value={s.payoutRange[0]} onchange={(e) => seg(i, { payoutRange: [+e.currentTarget.value, s.payoutRange[1]] })} style="width:5em" />–<input type="number" value={s.payoutRange[1]} onchange={(e) => seg(i, { payoutRange: [s.payoutRange[0], +e.currentTarget.value] })} style="width:5em" /></td>
|
|
100
|
+
<td><input type="number" step="0.1" value={s.oneIn ?? ''} placeholder="自由" onchange={(e) => seg(i, { oneIn: e.currentTarget.value ? +e.currentTarget.value : undefined })} style="width:5em" /></td>
|
|
101
|
+
<td><input type="number" step="1000" value={s.rtpPpm ?? ''} placeholder="自由" onchange={(e) => seg(i, { rtpPpm: e.currentTarget.value ? +e.currentTarget.value : undefined })} style="width:6em" /></td>
|
|
102
|
+
<td><input value={s.meanToMedian ? s.meanToMedian.join('-') : ''} placeholder="如 2-4" onchange={(e) => { const m = /^([\d.]+)\s*-\s*([\d.]+)$/.exec(e.currentTarget.value); seg(i, { meanToMedian: m ? [+m[1]!, +m[2]!] : undefined }); }} style="width:5em" /></td>
|
|
103
|
+
<td class="num hint">{#if a}每 {a.hitRate.toFixed(1)} 局 · {pct(Math.round(a.rtp * 1e6))} · {a.meanToMedian.toFixed(2)}{/if}</td>
|
|
104
|
+
</tr>
|
|
105
|
+
{/each}
|
|
106
|
+
</tbody>
|
|
107
|
+
</table>
|
|
108
|
+
<div class="row">
|
|
109
|
+
<button class="sec" onclick={() => segs = [...segs, { name: `段${segs.length + 1}`, payoutRange: [0, g!.maxPayoutCenti] }]}>加一段</button>
|
|
110
|
+
<button class="sec" onclick={() => segs = segs.slice(0, -1)} disabled={segs.length <= 1}>删末段</button>
|
|
111
|
+
<span style="flex:1"></span>
|
|
112
|
+
<button class="pri" onclick={buildWithThese} disabled={!report?.converged || building}>{building ? '建表中…' : '用这组参数建表'}</button>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
<div class="card">
|
|
117
|
+
<h2>求解 {#if report}<span class:ok={report.converged} class:warn={report.ok && !report.converged} class:bad={!report.ok}>{report.failure ? '失败' : report.converged ? '收敛' : '未收敛'}</span>{/if}</h2>
|
|
118
|
+
{#if report?.failure}
|
|
119
|
+
<p class="bad">{report.failure.detail}</p>
|
|
120
|
+
{:else if report}
|
|
121
|
+
<p class="hint mono">残差 {report.residual.toExponential(2)} · {report.iterations} 次迭代 · {report.millis.toFixed(1)} ms · 实算 RTP {pct(Math.round((report.achieved?.rtp ?? 0) * 1e6))}</p>
|
|
122
|
+
{#if report.hardest}<p class="hint">最吃力的约束:<b>{report.hardest.label}</b>(θ={report.hardest.theta.toFixed(3)})</p>{/if}
|
|
123
|
+
{#if report.dropped.length}<p class="warn">被判冗余丢弃:{report.dropped.join('、')}——这些目标没生效</p>{/if}
|
|
124
|
+
{#if report.thin.length}<p class="warn">{report.thin.length} 个档位样本不足(<30 局):{report.thin.slice(0, 6).map((t) => `${x(t.payoutCenti)}×${t.samples}`).join('、')}。它们的权重是从个位数样本外推的</p>{/if}
|
|
125
|
+
{#if report.distorted.length}<p class="warn">{report.distorted.length} 个档位被大幅改权:{report.distorted.slice(0, 6).map((t) => `${x(t.payoutCenti)} ×${t.distortion.toFixed(1)}`).join('、')}</p>{/if}
|
|
126
|
+
{/if}
|
|
127
|
+
</div>
|
|
128
|
+
{/if}
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
<style>
|
|
132
|
+
.grid{display:grid;grid-template-columns:1fr;gap:14px;max-width:1100px}
|
|
133
|
+
.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
|
|
134
|
+
label{display:flex;gap:6px;align-items:center;font-size:13px}
|
|
135
|
+
.slider{display:grid;grid-template-columns:auto 1fr;gap:8px 12px;align-items:center} .slider input{grid-column:1/-1}
|
|
136
|
+
.bars{display:flex;gap:3px;align-items:flex-end;height:110px;padding-top:6px;overflow-x:auto}
|
|
137
|
+
.bar{flex:1;min-width:26px;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;height:100%;font-size:9px;color:var(--dim)}
|
|
138
|
+
.bar i{display:block;width:100%;background:var(--accent-ink);border-radius:3px 3px 0 0;opacity:.85}
|
|
139
|
+
.bar span{margin-top:2px;white-space:nowrap}
|
|
140
|
+
</style>
|