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
package/sdk/sample.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/* 加权抽样的取数原语。规范见 gklt/v1。
|
|
2
|
+
*
|
|
3
|
+
* 为什么不扩展 rng.ts 的 below():
|
|
4
|
+
* - 它写死了 0x100000000,上限只到 2^32
|
|
5
|
+
* - below(1) 短路直接返回 0,不消耗随机数。跨语言复算时这是个分叉陷阱
|
|
6
|
+
* - 它被 shuffle 和两个游戏用着,已有卡方测试覆盖,不该动
|
|
7
|
+
*
|
|
8
|
+
* 为什么固定取 3 个字而不按 T 的位宽自适应:
|
|
9
|
+
* 自适应时 T ≈ 2^63 会落在 floor(2^64/T) = 1 的最坏点上,拒绝概率高达 50%。
|
|
10
|
+
* 固定 96 位则对任意 T < 2^64 拒绝概率统一 < 2^-32,期望抽取 3.0000000007 个字。
|
|
11
|
+
* 而且规范里少一个分支就少一处跨语言实现分歧。 */
|
|
12
|
+
import type { Rng } from './rng.ts';
|
|
13
|
+
|
|
14
|
+
export const SAMPLE_WORDS = 3;
|
|
15
|
+
export const SAMPLE_RANGE = 1n << 96n;
|
|
16
|
+
|
|
17
|
+
export interface SampleDraw {
|
|
18
|
+
/** r ∈ [0, total) */
|
|
19
|
+
value: bigint;
|
|
20
|
+
/** 抽到的每个 u32,big-endian hex,**含被拒绝丢弃的** */
|
|
21
|
+
words: string[];
|
|
22
|
+
/** 发生了几轮拒绝 */
|
|
23
|
+
rejections: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 从 rng 取 [0, total) 上的无偏整数。
|
|
28
|
+
*
|
|
29
|
+
* 契约(改任何一条都会让已有的公平证验不过):
|
|
30
|
+
* - 固定抽 SAMPLE_WORDS 个 u32,**先抽到的在高位**
|
|
31
|
+
* - 比较是 `x < lim` 严格小于
|
|
32
|
+
* - 拒绝时整组作废重抽,被丢弃的字仍计入 rng.draws
|
|
33
|
+
* - total === 1n 时也要走完整流程抽满字,不得短路
|
|
34
|
+
*/
|
|
35
|
+
export function sampleBelow(rng: Rng, total: bigint): SampleDraw {
|
|
36
|
+
if (total <= 0n) throw new RangeError(`sampleBelow(${total}):总权重必须为正`);
|
|
37
|
+
if (total >= 1n << 64n) throw new RangeError('总权重必须小于 2^64');
|
|
38
|
+
|
|
39
|
+
const lim = (SAMPLE_RANGE / total) * total;
|
|
40
|
+
const words: string[] = [];
|
|
41
|
+
let rejections = 0;
|
|
42
|
+
|
|
43
|
+
for (;;) {
|
|
44
|
+
let x = 0n;
|
|
45
|
+
for (let i = 0; i < SAMPLE_WORDS; i++) {
|
|
46
|
+
const w = rng.u32() >>> 0;
|
|
47
|
+
words.push(w.toString(16).padStart(8, '0'));
|
|
48
|
+
x = (x << 32n) | BigInt(w);
|
|
49
|
+
}
|
|
50
|
+
if (x < lim) return { value: x % total, words, rejections };
|
|
51
|
+
rejections++;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 规范的定位算法:按文件顺序累加,取第一个使 `r < 累积和` 的行。
|
|
57
|
+
*
|
|
58
|
+
* 服务端实际走的是 SQL 索引(`cum_weight > r ORDER BY cum_weight LIMIT 1`),
|
|
59
|
+
* 这份线性扫描是它的参照实现——两者必须逐位等价,有测试对拍。
|
|
60
|
+
*/
|
|
61
|
+
export function locateRow(weights: ArrayLike<number> | ArrayLike<bigint>, r: bigint): number {
|
|
62
|
+
let acc = 0n;
|
|
63
|
+
for (let i = 0; i < weights.length; i++) {
|
|
64
|
+
acc += BigInt(weights[i] as number);
|
|
65
|
+
if (r < acc) return i;
|
|
66
|
+
}
|
|
67
|
+
return -1;
|
|
68
|
+
}
|
package/sdk/schema.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/* 游戏向平台声明自己的参数,平台据此自动渲染设置 UI。
|
|
2
|
+
平台不认识任何一个具体游戏——它只认识这里的 kind。 */
|
|
3
|
+
|
|
4
|
+
export type I18n = Record<string, string>;
|
|
5
|
+
|
|
6
|
+
export interface Option {
|
|
7
|
+
value: string | number;
|
|
8
|
+
label: I18n;
|
|
9
|
+
/** 指向 manifest 里的资源 key,比如造型的缩略图 */
|
|
10
|
+
asset?: string;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type FieldKind =
|
|
15
|
+
| { kind: 'int'; min: number; max: number; step?: number }
|
|
16
|
+
| { kind: 'number'; min: number; max: number; step?: number }
|
|
17
|
+
| { kind: 'bool' }
|
|
18
|
+
| { kind: 'enum'; options: Option[] }
|
|
19
|
+
/** 从 of 里挑 count 个。coco 的「选 4 个点数」就是这个 */
|
|
20
|
+
| { kind: 'pick'; of: Option[]; count: number; exact?: boolean; ordered?: boolean; randomizable?: boolean };
|
|
21
|
+
|
|
22
|
+
export interface FieldMeta {
|
|
23
|
+
label: I18n;
|
|
24
|
+
hint?: I18n;
|
|
25
|
+
/**
|
|
26
|
+
* true = 影响结果。下注时必须冻结、随 RoundInput 上送、进 proof.configHash。
|
|
27
|
+
* false 的字段不该出现在这里,应该放进 ViewSettings。
|
|
28
|
+
*/
|
|
29
|
+
affectsOutcome: boolean;
|
|
30
|
+
/** 什么时候不许改 */
|
|
31
|
+
lock: 'never' | 'inRound' | 'inRoundOrAuto';
|
|
32
|
+
/**
|
|
33
|
+
* 谁渲染这个控件。真值始终在平台,这里只决定渲染位置:
|
|
34
|
+
* host —— 平台的设置面板(和下注额、自动局数放一起)
|
|
35
|
+
* stage —— 游戏画布内自己画(和画面连体的那种,比如 coco 的图纸点数)
|
|
36
|
+
* 两种都是受控组件:stage 侧渲染的也是平台的值,改动走 Host.requestConfig。
|
|
37
|
+
*/
|
|
38
|
+
ui: 'host' | 'stage';
|
|
39
|
+
/** 只在开发/演示环境允许开启。服务端在生产环境必须拒绝带这种字段的请求 */
|
|
40
|
+
devOnly?: boolean;
|
|
41
|
+
/** 建议控件,平台可以无视 */
|
|
42
|
+
control?: 'stepper' | 'switch' | 'slider' | 'chips' | 'grid' | 'select';
|
|
43
|
+
group?: string;
|
|
44
|
+
order?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type ConfigSchema<C> = { [K in keyof C]-?: FieldKind & FieldMeta & { default: C[K] } };
|
|
48
|
+
|
|
49
|
+
export interface ConfigIssue {
|
|
50
|
+
field: string;
|
|
51
|
+
code: string;
|
|
52
|
+
message: I18n;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface BetLimits {
|
|
56
|
+
min: number;
|
|
57
|
+
max: number;
|
|
58
|
+
step: number;
|
|
59
|
+
presets?: number[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** describe() 的产物:平台不用懂游戏就能渲染赔率说明 */
|
|
63
|
+
export interface GameInfo {
|
|
64
|
+
paytable: { title: I18n; rows: { label: I18n; mult: number; chance?: number }[] };
|
|
65
|
+
stats: { hitChance: number; rtp: number; maxMultiplier: number; volatility?: 'low' | 'mid' | 'high' };
|
|
66
|
+
notes?: I18n[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** schema 的默认值。平台初始化配置时用 */
|
|
70
|
+
export function defaultsOf<C>(schema: ConfigSchema<C>): C {
|
|
71
|
+
const out = {} as C;
|
|
72
|
+
for (const k of Object.keys(schema) as (keyof C)[]) out[k] = schema[k].default;
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 平台渲染面板时用:只要这个位置该渲染的字段,按 order 排好 */
|
|
77
|
+
export function fieldsFor<C>(schema: ConfigSchema<C>, where: 'host' | 'stage'): (keyof C)[] {
|
|
78
|
+
return (Object.keys(schema) as (keyof C)[])
|
|
79
|
+
.filter(k => schema[k].ui === where)
|
|
80
|
+
.sort((a, b) => (schema[a].order ?? 0) - (schema[b].order ?? 0));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 当前是否允许改这个字段。平台和游戏共用这一处判断,两边不会打架 */
|
|
84
|
+
export function editable<C>(schema: ConfigSchema<C>, key: keyof C, s: { inRound: boolean; inAuto: boolean }): boolean {
|
|
85
|
+
switch (schema[key].lock) {
|
|
86
|
+
case 'never': return true;
|
|
87
|
+
case 'inRound': return !s.inRound;
|
|
88
|
+
case 'inRoundOrAuto': return !s.inRound && !s.inAuto;
|
|
89
|
+
}
|
|
90
|
+
}
|
package/sdk/spec.ts
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/* 一个游戏 = 一份清单。
|
|
2
|
+
*
|
|
3
|
+
* 逻辑侧三个必填:schema 说有哪些参数,round 说结果怎么算,paytable 说赔多少。
|
|
4
|
+
* 表模型三个可选、互相独立:modes(要几张表)、book(演出怎么编)、restore(怎么还原)。
|
|
5
|
+
* 其余是元数据。normalize / validate / modeOf 不用游戏写——它们是 schema 的函数,
|
|
6
|
+
* 三个游戏的手写实现没有一行是 schema 表达不了的,写第二遍只会和第一遍打架。
|
|
7
|
+
*
|
|
8
|
+
* defineGame 住在这个零依赖的包里,浏览器和 Node 侧的采样 worker import 的是同一份清单。
|
|
9
|
+
* 它不是恒等函数:填默认值、挂推导出来的方法,消费方拿到的永远是填满的对象。 */
|
|
10
|
+
import type { RoundInput, RoundResult, Restore, RestoreInput } from './contract.ts';
|
|
11
|
+
import type { GameInstance, Host } from './host.ts';
|
|
12
|
+
import type { RngSeed } from './rng.ts';
|
|
13
|
+
import { defaultsOf, type ConfigIssue, type ConfigSchema, type I18n } from './schema.ts';
|
|
14
|
+
|
|
15
|
+
export interface PaytableRow { label: I18n; mult: number }
|
|
16
|
+
export type Paytable = PaytableRow[];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 一张表对应的配置。只写**影响赔付分布**的字段——出现在任何 mode.config 里的键
|
|
20
|
+
* 就是分布字段,modeOf 据此推导。coco 是 { N, goldOn },ranks 不在里面。
|
|
21
|
+
*/
|
|
22
|
+
export interface Mode<C> { name: string; config: Partial<C> }
|
|
23
|
+
|
|
24
|
+
export interface Sample<O> {
|
|
25
|
+
/** 采样序号,同时也是表里的 simId */
|
|
26
|
+
index: number;
|
|
27
|
+
seed: RngSeed;
|
|
28
|
+
/** 相对单注的赔付 ×100 */
|
|
29
|
+
payoutCenti: number;
|
|
30
|
+
result: RoundResult<O>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 演出数据怎么编码。
|
|
35
|
+
* 'none' 不传。演出可从 payout 推导(抛硬币:猜中了就落在你押的那面)
|
|
36
|
+
* 'seed' 存采样种子。客户端重跑 round(),渲染代码一行不用改——
|
|
37
|
+
* 前提是所有影响结果的字段都在 modes 里冻结了,否则表里写 0x、客户端算出 4.64x
|
|
38
|
+
* 自定义 还有自由字段会改变结果时用。coco 存 22 字节的 rank 无关牌序
|
|
39
|
+
*/
|
|
40
|
+
export type BookCodec<O> = 'none' | 'seed' | { encode(s: Sample<O>): Uint8Array };
|
|
41
|
+
|
|
42
|
+
export interface Skin { value: string; label: I18n; asset?: string }
|
|
43
|
+
|
|
44
|
+
export interface GameSpec<C, O> {
|
|
45
|
+
slug: string;
|
|
46
|
+
title: string;
|
|
47
|
+
description?: string;
|
|
48
|
+
/** 平台按它决定要不要显示自动按钮。默认 true */
|
|
49
|
+
autoplay?: boolean;
|
|
50
|
+
/** 求解目标,小数(0.98)。所有 mode 都解到它;CLI `--rtp` 可覆盖 */
|
|
51
|
+
rtp: number;
|
|
52
|
+
/** 每张表采样多少局 */
|
|
53
|
+
sims: number;
|
|
54
|
+
|
|
55
|
+
schema: ConfigSchema<C>;
|
|
56
|
+
/** 一局的结果。同步纯函数、零 DOM、不自取随机。**只在采样时跑** */
|
|
57
|
+
round(input: RoundInput<C>): RoundResult<O>;
|
|
58
|
+
/** 赔率表。常量,或 config 的函数(coco 的倍数随 N 变) */
|
|
59
|
+
paytable: Paytable | ((config: C) => Paytable);
|
|
60
|
+
|
|
61
|
+
/** 不给 = 单表 'base' */
|
|
62
|
+
modes?: Mode<C>[];
|
|
63
|
+
/** 不给 = 'seed' */
|
|
64
|
+
book?: BookCodec<O>;
|
|
65
|
+
/** 不给 = restoreFromSeed,只对 book 'seed' 成立 */
|
|
66
|
+
restore?: Restore<C, O>;
|
|
67
|
+
/** 采样种子前缀。换了它就是另一张表;默认 slug */
|
|
68
|
+
seedPrefix?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** defineGame 的产物:填满默认值、挂上推导方法 */
|
|
72
|
+
export interface GameDefinition<C, O> extends GameSpec<C, O> {
|
|
73
|
+
autoplay: boolean;
|
|
74
|
+
modes: Mode<C>[];
|
|
75
|
+
book: BookCodec<O>;
|
|
76
|
+
restore: Restore<C, O>;
|
|
77
|
+
seedPrefix: string;
|
|
78
|
+
/** 出现在任何 mode.config 里的键,即影响分布的字段。单表游戏为空 */
|
|
79
|
+
modeKeys: (keyof C)[];
|
|
80
|
+
normalize(patch: Partial<C>, prev?: C): C;
|
|
81
|
+
validate(config: C): ConfigIssue[];
|
|
82
|
+
/** 当前配置该抽哪张表。没有匹配的 mode 就抛——那是 modes 声明不完整 */
|
|
83
|
+
modeOf(config: C): string;
|
|
84
|
+
/** 这张表冻结的配置:mode.config 盖在 schema 默认值上 */
|
|
85
|
+
configOf(mode: string): C;
|
|
86
|
+
paytableFor(config: C): Paytable;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 浏览器入口 = 清单 + view。mount / skins import Svelte 与图片,进不了 Node 侧的采样 worker */
|
|
90
|
+
export interface GameModule<C, O> extends GameDefinition<C, O> {
|
|
91
|
+
mount(host: Host<C>, el: HTMLElement): Promise<GameInstance<O>>;
|
|
92
|
+
skins?: Skin[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* ── 采样种子 ────────────────────────────────────────────────────── */
|
|
96
|
+
|
|
97
|
+
export const SAMPLE_CLIENT_SEED = 'gamekit-publish';
|
|
98
|
+
|
|
99
|
+
/** 确定性派生。作者机器和审计脚本能各自独立重建同一张表;可预测不是问题,抽哪一行由 gklt/v1 决定 */
|
|
100
|
+
export const sampleSeed = (prefix: string, mode: string, index: number): RngSeed => ({
|
|
101
|
+
server: `${prefix}/${mode}/${index}`,
|
|
102
|
+
client: SAMPLE_CLIENT_SEED,
|
|
103
|
+
nonce: index,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/* ── 按 schema 推导 normalize / validate ───────────────────────────── */
|
|
107
|
+
|
|
108
|
+
const sameValue = (a: unknown, b: unknown): boolean =>
|
|
109
|
+
a === b || (typeof a === 'object' && typeof b === 'object' && JSON.stringify(a) === JSON.stringify(b));
|
|
110
|
+
|
|
111
|
+
export function normalizeBySchema<C>(schema: ConfigSchema<C>, patch: Partial<C>, prev?: C): C {
|
|
112
|
+
const base = prev ?? defaultsOf(schema);
|
|
113
|
+
const merged = { ...base, ...patch } as Record<keyof C, unknown>;
|
|
114
|
+
const out = {} as C;
|
|
115
|
+
|
|
116
|
+
for (const k of Object.keys(schema) as (keyof C)[]) {
|
|
117
|
+
const f = schema[k];
|
|
118
|
+
const v = merged[k];
|
|
119
|
+
switch (f.kind) {
|
|
120
|
+
case 'int': case 'number': {
|
|
121
|
+
const n = Number(v);
|
|
122
|
+
if (!Number.isFinite(n)) { out[k] = f.default; break; }
|
|
123
|
+
const step = f.step ?? (f.kind === 'int' ? 1 : 0);
|
|
124
|
+
let x = Math.min(f.max, Math.max(f.min, n));
|
|
125
|
+
if (step > 0) x = Math.min(f.max, Math.max(f.min, f.min + Math.round((x - f.min) / step) * step));
|
|
126
|
+
if (f.kind === 'int') x = Math.round(x);
|
|
127
|
+
out[k] = x as C[keyof C];
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
case 'bool': out[k] = !!v as C[keyof C]; break;
|
|
131
|
+
case 'enum': {
|
|
132
|
+
const ok = f.options.some((o) => !o.disabled && sameValue(o.value, v));
|
|
133
|
+
out[k] = (ok ? v : f.default) as C[keyof C];
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
case 'pick': {
|
|
137
|
+
/* 去重、过滤到合法值、最多留 count 个。不足 count 不在这里补齐——
|
|
138
|
+
那是玩家还没选完的合法中间态,由 validate 报出来 */
|
|
139
|
+
const legal = f.of.filter((o) => !o.disabled).map((o) => o.value);
|
|
140
|
+
const numeric = legal.every((x) => typeof x === 'number');
|
|
141
|
+
const picked: unknown[] = [];
|
|
142
|
+
for (const raw of Array.isArray(v) ? v : []) {
|
|
143
|
+
const x = numeric ? Math.round(Number(raw)) : raw;
|
|
144
|
+
if (legal.some((l) => sameValue(l, x)) && !picked.some((p) => sameValue(p, x))) picked.push(x);
|
|
145
|
+
}
|
|
146
|
+
out[k] = picked.slice(0, f.count) as C[keyof C];
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function validateBySchema<C>(schema: ConfigSchema<C>, config: C): ConfigIssue[] {
|
|
155
|
+
const out: ConfigIssue[] = [];
|
|
156
|
+
for (const k of Object.keys(schema) as (keyof C)[]) {
|
|
157
|
+
const f = schema[k];
|
|
158
|
+
if (f.kind !== 'pick') continue;
|
|
159
|
+
const n = Array.isArray(config[k]) ? (config[k] as unknown[]).length : 0;
|
|
160
|
+
if (f.exact ? n !== f.count : n > f.count) {
|
|
161
|
+
const label = Object.values(f.label)[0] ?? String(k);
|
|
162
|
+
out.push({
|
|
163
|
+
field: String(k), code: f.exact ? `need-exactly-${f.count}` : `at-most-${f.count}`,
|
|
164
|
+
message: { 'zh-CN': f.exact ? `${label}要选满 ${f.count} 个,现在选了 ${n} 个` : `${label}最多选 ${f.count} 个,现在选了 ${n} 个` },
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/* ── restore 的通用实现 ─────────────────────────────────────────── */
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* `'seed'` codec 的解码半边:frames 里是采样种子的 server 串(prefix/mode/index),
|
|
175
|
+
* 重跑 round() 拿回完整 outcome。nonce 从末尾解析,client 恒为 SAMPLE_CLIENT_SEED。
|
|
176
|
+
*
|
|
177
|
+
* 契约:返回的 payout 必须恰好等于 (bet / 100) × payoutCenti,对不上就抛、不演。
|
|
178
|
+
*/
|
|
179
|
+
export function restoreFromSeed<C, O>(spec: Pick<GameSpec<C, O>, 'round'> & { normalize(patch: Partial<C>, prev?: C): C }): Restore<C, O> {
|
|
180
|
+
return (input: RestoreInput<C>) => {
|
|
181
|
+
if (!input.frames) throw new Error('book 为 seed 的表必须带演出数据,这一行却是空的');
|
|
182
|
+
const server = new TextDecoder().decode(input.frames);
|
|
183
|
+
const at = server.lastIndexOf('/');
|
|
184
|
+
const index = Number(server.slice(at + 1));
|
|
185
|
+
if (at < 0 || !Number.isInteger(index) || index < 0) throw new Error(`采样种子格式不对:${server}`);
|
|
186
|
+
|
|
187
|
+
const result = spec.round({
|
|
188
|
+
config: spec.normalize(input.config),
|
|
189
|
+
bet: input.bet,
|
|
190
|
+
seed: { server, client: SAMPLE_CLIENT_SEED, nonce: index },
|
|
191
|
+
});
|
|
192
|
+
const want = (input.bet / 100) * input.payoutCenti;
|
|
193
|
+
if (result.payout !== want) {
|
|
194
|
+
throw new Error(`重跑 round() 赔 ${result.payout},表里那一行是 ${want}——有影响结果的字段没冻结进 mode,book 不能用 'seed'`);
|
|
195
|
+
}
|
|
196
|
+
return result;
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/* ── defineGame ─────────────────────────────────────────────────── */
|
|
201
|
+
|
|
202
|
+
const SLUG = /^[a-z0-9][a-z0-9-]*$/;
|
|
203
|
+
|
|
204
|
+
export function defineGame<C, O>(spec: GameSpec<C, O>): GameDefinition<C, O> {
|
|
205
|
+
if (!SLUG.test(spec.slug)) throw new Error(`slug "${spec.slug}" 要是小写短横线形式`);
|
|
206
|
+
if (!(spec.rtp > 0 && spec.rtp <= 1)) throw new Error(`rtp ${spec.rtp} 要是 (0, 1] 的小数,比如 0.98`);
|
|
207
|
+
if (!(Number.isInteger(spec.sims) && spec.sims > 0)) throw new Error(`sims ${spec.sims} 要是正整数`);
|
|
208
|
+
|
|
209
|
+
const modes: Mode<C>[] = spec.modes?.length ? spec.modes : [{ name: 'base', config: {} }];
|
|
210
|
+
const keySets = modes.map((m) => Object.keys(m.config).sort().join(','));
|
|
211
|
+
if (new Set(keySets).size > 1) {
|
|
212
|
+
throw new Error(`每个 mode 的 config 必须写同一组键(那组键就是影响分布的字段),现在有:${[...new Set(keySets)].join(' | ')}`);
|
|
213
|
+
}
|
|
214
|
+
const names = new Set<string>();
|
|
215
|
+
for (const m of modes) {
|
|
216
|
+
if (names.has(m.name)) throw new Error(`mode 名字重复:${m.name}`);
|
|
217
|
+
names.add(m.name);
|
|
218
|
+
}
|
|
219
|
+
const modeKeys = Object.keys(modes[0]!.config) as (keyof C)[];
|
|
220
|
+
for (const k of modeKeys) if (!(k in spec.schema)) throw new Error(`mode 里的字段 ${String(k)} 不在 schema 里`);
|
|
221
|
+
|
|
222
|
+
const normalize = (patch: Partial<C>, prev?: C): C => normalizeBySchema(spec.schema, patch, prev);
|
|
223
|
+
const validate = (config: C): ConfigIssue[] => validateBySchema(spec.schema, config);
|
|
224
|
+
const byName = new Map(modes.map((m) => [m.name, m]));
|
|
225
|
+
|
|
226
|
+
const modeOf = (config: C): string => {
|
|
227
|
+
if (modeKeys.length === 0) return modes[0]!.name;
|
|
228
|
+
const hit = modes.find((m) => modeKeys.every((k) => sameValue(normalize(m.config as Partial<C>)[k], config[k])));
|
|
229
|
+
if (!hit) {
|
|
230
|
+
throw new Error(`没有 mode 匹配 ${JSON.stringify(Object.fromEntries(modeKeys.map((k) => [k, config[k]])))}——modes 声明不完整`);
|
|
231
|
+
}
|
|
232
|
+
return hit.name;
|
|
233
|
+
};
|
|
234
|
+
const configOf = (mode: string): C => {
|
|
235
|
+
const m = byName.get(mode);
|
|
236
|
+
if (!m) throw new Error(`没有 mode "${mode}",有的是:${modes.map((x) => x.name).join('、')}`);
|
|
237
|
+
return normalize(m.config as Partial<C>);
|
|
238
|
+
};
|
|
239
|
+
const paytableFor = (config: C): Paytable =>
|
|
240
|
+
typeof spec.paytable === 'function' ? spec.paytable(config) : spec.paytable;
|
|
241
|
+
|
|
242
|
+
const book: BookCodec<O> = spec.book ?? 'seed';
|
|
243
|
+
const def: GameDefinition<C, O> = {
|
|
244
|
+
...spec,
|
|
245
|
+
autoplay: spec.autoplay ?? true,
|
|
246
|
+
seedPrefix: spec.seedPrefix ?? spec.slug,
|
|
247
|
+
modes, book, modeKeys,
|
|
248
|
+
normalize, validate, modeOf, configOf, paytableFor,
|
|
249
|
+
restore: spec.restore ?? restoreFromSeed({ round: spec.round, normalize }),
|
|
250
|
+
};
|
|
251
|
+
if (!spec.restore && book !== 'seed') {
|
|
252
|
+
throw new Error(`book 是 ${book === 'none' ? "'none'" : '自定义 codec'} 时必须自己写 restore——只有 'seed' 能通用还原`);
|
|
253
|
+
}
|
|
254
|
+
return def;
|
|
255
|
+
}
|
package/sdk/stage.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 游戏的逻辑分辨率。平台的舞台形状由这一组数决定,不由任何 CSS 里的字面量决定。
|
|
3
|
+
*
|
|
4
|
+
* 游戏容器的 CSS 尺寸恒为这一组数,屏幕放不下就整体 scale——游戏里看到的宽度
|
|
5
|
+
* 永远是 900,排版不随窗口变形,缩放的是整台机器的画面。
|
|
6
|
+
* 这和「让游戏自报高度」是两条路:那条路上每个游戏的舞台高矮不一,
|
|
7
|
+
* 机台的高度会跟着游戏变;这条路上机台的尺寸是确定的,游戏去适应它。
|
|
8
|
+
*
|
|
9
|
+
* 900 不是随便取的:游戏的内部布局是按这个宽度调的,换一个宽度它们会重新分配
|
|
10
|
+
* 各区块的占比——coco 放到 1280 上试过,牌面吃掉了多出来的空间,右边的发牌区
|
|
11
|
+
* 相对就瘪了。缩放不会有这个问题,因为它整块等比放大。
|
|
12
|
+
*
|
|
13
|
+
* 7:6 是量出来的:coco 在 900 逻辑宽下内容自然高 770,这里给 771。
|
|
14
|
+
* 比这更扁会把最后一行牌切掉,而且很不显眼——换比例之前先量一遍各游戏的自然高度。
|
|
15
|
+
*
|
|
16
|
+
* 放在 sdk 里是因为它是平台↔游戏契约的一部分:玩家站点的 iframe、参考平台的
|
|
17
|
+
* 舞台、studio 的预览都按同一组数缩放,游戏只需要为这一个尺寸排版。
|
|
18
|
+
*/
|
|
19
|
+
export const STAGE_W = 900;
|
|
20
|
+
export const STAGE_H = Math.round((STAGE_W * 6) / 7); // 771
|
package/sdk/store.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/* 跨框架的最小响应式原语。
|
|
2
|
+
|
|
3
|
+
刻意采用 Svelte store 的形状(subscribe 立即同步回调一次、返回退订函数),
|
|
4
|
+
因为它是 React / Vue / Svelte / 原生里适配成本最低的公分母:
|
|
5
|
+
React useSyncExternalStore(s.subscribe, s.get, s.get)
|
|
6
|
+
Vue const r = shallowRef(s.get()); onScopeDispose(s.subscribe(v => r.value = v))
|
|
7
|
+
Svelte 游戏侧的 fromStore() 桥接成 runes
|
|
8
|
+
平台是真值源,所以游戏拿到的都是 Readable,改值要走 Host.requestConfig。 */
|
|
9
|
+
|
|
10
|
+
export interface Readable<T> {
|
|
11
|
+
get(): T;
|
|
12
|
+
/** 立即同步调一次 run(当前值),返回退订函数 */
|
|
13
|
+
subscribe(run: (v: T) => void): () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface Writable<T> extends Readable<T> {
|
|
17
|
+
set(v: T): void;
|
|
18
|
+
patch(p: Partial<T>): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function writable<T extends object>(init: T): Writable<T> {
|
|
22
|
+
let value = init;
|
|
23
|
+
const subs = new Set<(v: T) => void>();
|
|
24
|
+
const emit = (): void => { for (const f of [...subs]) f(value); };
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
get: () => value,
|
|
28
|
+
set(v) { if (v === value) return; value = v; emit(); },
|
|
29
|
+
patch(p) {
|
|
30
|
+
/* 值没变就不通知:平台侧每帧 patch 同一份设置是常态,
|
|
31
|
+
少这道判断 React 会跟着无谓重渲染 */
|
|
32
|
+
let changed = false;
|
|
33
|
+
for (const k of Object.keys(p) as (keyof T)[]) {
|
|
34
|
+
if (!Object.is(value[k], p[k])) { changed = true; break; }
|
|
35
|
+
}
|
|
36
|
+
if (!changed) return;
|
|
37
|
+
value = { ...value, ...p };
|
|
38
|
+
emit();
|
|
39
|
+
},
|
|
40
|
+
subscribe(run) {
|
|
41
|
+
run(value);
|
|
42
|
+
subs.add(run);
|
|
43
|
+
return () => { subs.delete(run); };
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 只读视图,防止游戏侧拿到 store 后直接写 */
|
|
49
|
+
export const readonly = <T>(s: Readable<T>): Readable<T> =>
|
|
50
|
+
({ get: () => s.get(), subscribe: run => s.subscribe(run) });
|
|
51
|
+
|
|
52
|
+
/** 从一个 Readable 派生另一个 */
|
|
53
|
+
export function derived<A, B>(src: Readable<A>, map: (a: A) => B): Readable<B> {
|
|
54
|
+
return {
|
|
55
|
+
get: () => map(src.get()),
|
|
56
|
+
subscribe(run) {
|
|
57
|
+
let last: B | typeof NONE = NONE;
|
|
58
|
+
return src.subscribe(a => {
|
|
59
|
+
const b = map(a);
|
|
60
|
+
if (Object.is(b, last)) return;
|
|
61
|
+
last = b;
|
|
62
|
+
run(b);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const NONE = Symbol('none');
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/* studio 页面:一个头 + 左边机台 + 右边编辑区。
|
|
3
|
+
机台是预览 iframe(dev-host 的 DevShell,嵌入模式),和玩家站点的对局页同一套机台、同一块舞台;
|
|
4
|
+
它靠左、宽度固定,剩下的宽度全给右边的编辑面板。余额由预览页通过总线报上来,显示在这条头上。
|
|
5
|
+
页面和游戏同源,所以发布面板能直接 import 游戏清单算描述配置。 */
|
|
6
|
+
import { onMount } from 'svelte';
|
|
7
|
+
import { ensureHostFonts } from 'gamekit777/dev-host';
|
|
8
|
+
import { api, type Info } from './lib/api.ts';
|
|
9
|
+
import { bus } from './lib/bus.svelte.ts';
|
|
10
|
+
import PreviewPanel from './panels/PreviewPanel.svelte';
|
|
11
|
+
import TuningPanel from './panels/TuningPanel.svelte';
|
|
12
|
+
import TablePanel from './panels/TablePanel.svelte';
|
|
13
|
+
import CasePanel from './panels/CasePanel.svelte';
|
|
14
|
+
import PublishPanel from './panels/PublishPanel.svelte';
|
|
15
|
+
import LogPanel from './panels/LogPanel.svelte';
|
|
16
|
+
|
|
17
|
+
type Tab = 'preview' | 'tuning' | 'table' | 'cases' | 'publish' | 'log';
|
|
18
|
+
const TABS: { id: Tab; label: string }[] = [
|
|
19
|
+
{ id: 'preview', label: '状态' }, { id: 'tuning', label: '调参' }, { id: 'table', label: '建表' },
|
|
20
|
+
{ id: 'cases', label: 'Case' }, { id: 'publish', label: '发布' }, { id: 'log', label: '日志' },
|
|
21
|
+
];
|
|
22
|
+
let tab = $state<Tab>((location.hash.slice(1) as Tab) || 'preview');
|
|
23
|
+
$effect(() => { location.hash = tab; });
|
|
24
|
+
|
|
25
|
+
let info = $state<Info | null>(null);
|
|
26
|
+
let err = $state<string | null>(null);
|
|
27
|
+
const refresh = async () => { try { info = await api.info(); err = null; } catch (e) { err = e instanceof Error ? e.message : String(e); } };
|
|
28
|
+
|
|
29
|
+
onMount(() => { ensureHostFonts(); bus.connect(); void refresh(); });
|
|
30
|
+
$effect(() => { void bus.fingerprint; void bus.buildVersion; void refresh(); });
|
|
31
|
+
|
|
32
|
+
const name = $derived(info ? info.snapshot.spec.title : '…');
|
|
33
|
+
const running = $derived(Object.values(bus.jobs).filter((j) => j.status === 'running').length);
|
|
34
|
+
const money = (minor: number) =>
|
|
35
|
+
(minor / 100).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
<header>
|
|
39
|
+
<span class="brand">GameKit Studio</span>
|
|
40
|
+
<span class="title">{name}</span>
|
|
41
|
+
{#if info}
|
|
42
|
+
<span class="chip mono" title={info.gameDir}>{info.snapshot.spec.slug}</span>
|
|
43
|
+
<span class="chip mono" title="规则指纹">{info.fingerprint}</span>
|
|
44
|
+
<span class="chip" class:on={bus.preview?.connected}>预览 {bus.preview?.connected ? '已连' : '未连'}</span>
|
|
45
|
+
<span class="chip" class:on={bus.connected}>总线 {bus.connected ? '已连' : '断开'}</span>
|
|
46
|
+
{#if running}<span class="chip busy">{running} 个任务运行中</span>{/if}
|
|
47
|
+
{/if}
|
|
48
|
+
{#if err}<span class="chip bad">{err}</span>{/if}
|
|
49
|
+
<div class="grow"></div>
|
|
50
|
+
{#if bus.preview?.connected && bus.preview.balance !== null}
|
|
51
|
+
<span class="bal">{money(bus.preview.balance)}</span>
|
|
52
|
+
<span class="dim small">余额</span>
|
|
53
|
+
{/if}
|
|
54
|
+
<div class="nav" role="tablist">
|
|
55
|
+
{#each TABS as t (t.id)}<button role="tab" aria-selected={tab === t.id} onclick={() => tab = t.id}>{t.label}</button>{/each}
|
|
56
|
+
</div>
|
|
57
|
+
</header>
|
|
58
|
+
|
|
59
|
+
<main>
|
|
60
|
+
<!-- 左:机台。和玩家站点同一台机器,宽度也照那边的 1200 内容宽 + 两侧 16 留白 -->
|
|
61
|
+
<section class="machine">
|
|
62
|
+
<iframe title="游戏预览" src="/__studio/preview.html"></iframe>
|
|
63
|
+
</section>
|
|
64
|
+
|
|
65
|
+
<!-- 右:编辑区,拉满剩余宽度 -->
|
|
66
|
+
<section class="editor">
|
|
67
|
+
{#if info}
|
|
68
|
+
{#if tab === 'preview'}<PreviewPanel {info} />{/if}
|
|
69
|
+
{#if tab === 'tuning'}<TuningPanel {info} />{/if}
|
|
70
|
+
{#if tab === 'table'}<TablePanel {info} />{/if}
|
|
71
|
+
{#if tab === 'cases'}<CasePanel {info} />{/if}
|
|
72
|
+
{#if tab === 'publish'}<PublishPanel {info} />{/if}
|
|
73
|
+
{#if tab === 'log'}<LogPanel />{/if}
|
|
74
|
+
{:else if !err}
|
|
75
|
+
<p class="dim">连接 studio…</p>
|
|
76
|
+
{/if}
|
|
77
|
+
</section>
|
|
78
|
+
</main>
|
|
79
|
+
|
|
80
|
+
<style>
|
|
81
|
+
/* 头和玩家站点的页眉同一副样子:奶油色、底下一条线、粘在顶上 */
|
|
82
|
+
header{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:10px 16px;
|
|
83
|
+
background:var(--cab);box-shadow:inset 0 -1px 0 var(--line);position:sticky;top:0;z-index:20}
|
|
84
|
+
.brand{font-family:var(--fun);font-size:22px;color:var(--ink)}
|
|
85
|
+
.title{font-weight:700}
|
|
86
|
+
.chip{font-size:11px;padding:2px 8px;border-radius:999px;background:var(--field);color:var(--dim);border:1px solid var(--line)}
|
|
87
|
+
.chip.on{color:var(--ok);border-color:var(--ok)} .chip.bad{color:var(--coco-d);border-color:var(--coco-d)} .chip.busy{color:var(--accent-ink);border-color:var(--accent-ink)}
|
|
88
|
+
.nav{display:flex;gap:2px;padding:3px;background:var(--field);border:1px solid var(--line);border-radius:12px}
|
|
89
|
+
.nav button{border:0;background:none;border-radius:9px;padding:5px 12px;font-weight:700;color:var(--dim);font-size:13px}
|
|
90
|
+
.nav button[aria-selected="true"]{background:var(--ink);color:var(--cab)}
|
|
91
|
+
|
|
92
|
+
/* 左固定右拉满。机台这一栏 = 1200 内容宽 + 两侧各 16 留白,和 .wrap 的算法一致;
|
|
93
|
+
屏幕不够宽时先缩机台(舞台按比例跟着缩,画面不变形),再不够就把编辑区推到下面 */
|
|
94
|
+
main{display:flex;gap:16px;align-items:stretch;padding:14px 16px;flex-wrap:wrap}
|
|
95
|
+
.machine{flex:0 1 1232px;min-width:720px;max-width:100%}
|
|
96
|
+
.machine iframe{display:block;width:100%;height:calc(100vh - 78px);border:0;background:var(--page)}
|
|
97
|
+
.editor{flex:1 1 420px;min-width:0}
|
|
98
|
+
|
|
99
|
+
main :global(.card){background:var(--cab);border-radius:22px;box-shadow:var(--cab-shadow);padding:18px;display:flex;flex-direction:column;gap:10px;margin-bottom:14px}
|
|
100
|
+
main :global(h2){font-size:20px;margin:0}
|
|
101
|
+
main :global(button.pri){border:0;border-radius:12px;padding:9px 14px;font-weight:700;background:var(--coco);color:#FFF6EA;box-shadow:0 4px 0 var(--coco-d)}
|
|
102
|
+
main :global(button.pri:active:not(:disabled)){transform:translateY(3px);box-shadow:0 1px 0 var(--coco-d)}
|
|
103
|
+
main :global(button.sec){border:1px solid var(--line);border-radius:12px;padding:8px 12px;background:var(--field);font-weight:700}
|
|
104
|
+
main :global(button:disabled){opacity:.5}
|
|
105
|
+
main :global(table){width:100%;border-collapse:collapse;font-size:13px}
|
|
106
|
+
main :global(th),main :global(td){text-align:left;padding:6px 8px;border-bottom:1px solid var(--line)}
|
|
107
|
+
main :global(th){color:var(--dim);font-weight:700;font-size:12px}
|
|
108
|
+
main :global(td.num),main :global(.mono){font-family:var(--mono);font-variant-numeric:tabular-nums}
|
|
109
|
+
main :global(.ok){color:var(--ok)} main :global(.bad){color:var(--coco-d)} main :global(.warn){color:var(--accent-ink)}
|
|
110
|
+
main :global(.hint){color:var(--dim);font-size:12px;margin:0}
|
|
111
|
+
main :global(input),main :global(select){border:1px solid var(--line);background:var(--field);border-radius:10px;padding:7px 9px;font-family:var(--mono)}
|
|
112
|
+
main :global(pre){font-family:var(--mono);font-size:11px;line-height:1.4;background:var(--field);padding:10px;border-radius:12px;overflow:auto;margin:0;max-height:50vh}
|
|
113
|
+
</style>
|