@playfield/kernel 0.4.0-alpha.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/arithmetic.ts ADDED
@@ -0,0 +1,196 @@
1
+ /**
2
+ * 确定性算术与规范化状态哈希。
3
+ *
4
+ * 两条纪律(设计文档 §4.2):
5
+ * 1. 规则数值优先整数/定点;超越函数不直接调 Math,统一走本模块
6
+ * (锁行为,防 V8 版本差异破坏老回放——黄金回放要在项目生命周期内
7
+ * 跨 Chromium/V8 版本逐位复现);
8
+ * 2. 状态哈希必须对同一语义状态稳定:键序无关、-0 归一为 0、NaN 规范化。
9
+ */
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // 规范化序列化与哈希
13
+ // ---------------------------------------------------------------------------
14
+
15
+ function serializeNumber(n: number): string {
16
+ // 特殊数值用原始控制字符类型化编码(issue #10):JSON.stringify 必然把控制
17
+ // 字符转义成 \u0000 形式,普通字符串永远无法产生该输出——杜绝
18
+ // NaN↔"nan"、±Infinity↔"inf"/"-inf" 的序列化碰撞。undefined 编码同思路。
19
+ if (Number.isNaN(n)) return '"\u0000nan"';
20
+ if (n === 0) return "0"; // 覆盖 -0
21
+ if (!Number.isFinite(n)) return n > 0 ? '"\u0000inf"' : '"\u0000-inf"';
22
+ // ECMAScript 规定 Number::toString 为最短往返表示——对同一数值确定
23
+ return n.toString();
24
+ }
25
+
26
+ export function canonicalSerialize(value: unknown): string {
27
+ switch (typeof value) {
28
+ case "number":
29
+ return serializeNumber(value);
30
+ case "string":
31
+ return JSON.stringify(value);
32
+ case "boolean":
33
+ return value ? "true" : "false";
34
+ case "undefined":
35
+ return '"\u0000undef"';
36
+ case "object": {
37
+ if (value === null) return "null";
38
+ if (Array.isArray(value)) {
39
+ return `[${value.map((v) => canonicalSerialize(v)).join(",")}]`;
40
+ }
41
+ const proto = Object.getPrototypeOf(value);
42
+ if (proto !== Object.prototype && proto !== null) {
43
+ throw new TypeError(
44
+ "hash/unsupported-type: only plain objects / arrays / primitives are hashable",
45
+ );
46
+ }
47
+ const keys = Object.keys(value).sort();
48
+ const body = keys
49
+ .map((k) => `${JSON.stringify(k)}:${canonicalSerialize((value as Record<string, unknown>)[k])}`)
50
+ .join(",");
51
+ return `{${body}}`;
52
+ }
53
+ default:
54
+ throw new TypeError(`hash/unsupported-type: ${typeof value}`);
55
+ }
56
+ }
57
+
58
+ /** 双通道 FNV 变体,输出 16 位十六进制;用于回放 conformance 的状态指纹 */
59
+ export function canonicalStateHash(value: unknown): string {
60
+ const s = canonicalSerialize(value);
61
+ let h1 = 0x811c9dc5;
62
+ let h2 = 0x01000193;
63
+ for (let i = 0; i < s.length; i++) {
64
+ const c = s.charCodeAt(i);
65
+ h1 = Math.imul(h1 ^ c, 0x01000193) >>> 0;
66
+ h2 = Math.imul(h2 ^ ((c + i) & 0xffff), 0x85ebca6b) >>> 0;
67
+ }
68
+ return (
69
+ h1.toString(16).padStart(8, "0") + h2.toString(16).padStart(8, "0")
70
+ );
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // 确定性数学(全部整数/定点运算,无 Math.* 依赖)
75
+ // ---------------------------------------------------------------------------
76
+
77
+ /** Q16 定点标度(1.0 = 65536) */
78
+ export const Q16 = 65536;
79
+
80
+ /** 整数平方根(下取整)。牛顿迭代,对 0 ≤ n ≤ 2^52 精确 */
81
+ export function isqrt(n: number): number {
82
+ if (!Number.isInteger(n) || n < 0 || n > Number.MAX_SAFE_INTEGER) {
83
+ throw new RangeError(`math/isqrt-domain: ${n}`);
84
+ }
85
+ if (n < 2) return n;
86
+ let x = n;
87
+ let y = Math.floor((x + Math.floor(n / x)) / 2);
88
+ while (y < x) {
89
+ x = y;
90
+ y = Math.floor((x + Math.floor(n / x)) / 2);
91
+ }
92
+ return x;
93
+ }
94
+
95
+ /** 整数幂(平方求幂);结果超出安全整数即抛错,绝不静默失真 */
96
+ export function powInt(base: number, exp: number): number {
97
+ if (!Number.isInteger(base) || !Number.isInteger(exp) || exp < 0) {
98
+ throw new RangeError(`math/powInt-domain: ${base}^${exp}`);
99
+ }
100
+ let result = 1;
101
+ let b = base;
102
+ let e = exp;
103
+ while (e > 0) {
104
+ if (e & 1) {
105
+ result *= b;
106
+ if (!Number.isSafeInteger(result)) {
107
+ throw new RangeError(`math/powInt-overflow: ${base}^${exp}`);
108
+ }
109
+ }
110
+ e = Math.floor(e / 2);
111
+ if (e > 0) {
112
+ b *= b;
113
+ if (!Number.isSafeInteger(b)) {
114
+ throw new RangeError(`math/powInt-overflow: ${base}^${exp}`);
115
+ }
116
+ }
117
+ }
118
+ return result;
119
+ }
120
+
121
+ /** Q16 定点乘法:qmul(a, b) = (a × b) >> 16 */
122
+ export function qmul(a: number, b: number): number {
123
+ return Math.floor((a * b) / Q16);
124
+ }
125
+
126
+ // CORDIC 常量表:atan(2^-i) × 65536 四舍五入为整数,硬编码锁定行为。
127
+ // i = 0..16(i ≥ 17 时表值四舍五入为 0,无信息量)。
128
+ const ATAN_Q16: readonly number[] = [
129
+ 51472, 30386, 16055, 8150, 4091, 2047, 1024, 512, 256, 128, 64, 32, 16, 8, 4,
130
+ 2, 1,
131
+ ];
132
+ const K_Q16 = 39797; // ∏ 1/√(1+2^-2i) ≈ 0.607252935
133
+ const PI_Q16 = 205887;
134
+ const HALF_PI_Q16 = 102944;
135
+ const TWO_PI_Q16 = 411774;
136
+
137
+ function imod(a: number, m: number): number {
138
+ return ((a % m) + m) % m;
139
+ }
140
+
141
+ /** 角度(度,可为负)→ Q16 弧度 */
142
+ export function degreesToQ16(degrees: number): number {
143
+ if (!Number.isFinite(degrees)) {
144
+ throw new RangeError(`math/degrees-domain: ${degrees}`);
145
+ }
146
+ return Math.round((degrees * PI_Q16) / 180);
147
+ }
148
+
149
+ /**
150
+ * 整数 CORDIC 正弦/余弦。输入 Q16 弧度,输出 Q16 定点(×65536)。
151
+ * 全程整数运算,逐位确定。
152
+ */
153
+ export function sinCosQ16(angleQ16: number): { sinQ16: number; cosQ16: number } {
154
+ if (!Number.isInteger(angleQ16)) {
155
+ throw new RangeError(`math/angle-domain: ${angleQ16}`);
156
+ }
157
+ // 归约到 [-π, π),再折叠到 [-π/2, π/2](CORDIC 收敛域)
158
+ let theta = imod(angleQ16 + PI_Q16, TWO_PI_Q16) - PI_Q16;
159
+ let flip = 1;
160
+ if (theta > HALF_PI_Q16) {
161
+ theta = PI_Q16 - theta;
162
+ flip = -1;
163
+ } else if (theta < -HALF_PI_Q16) {
164
+ theta = -PI_Q16 - theta;
165
+ flip = -1;
166
+ }
167
+ let x = K_Q16;
168
+ let y = 0;
169
+ let z = theta;
170
+ for (let i = 0; i < ATAN_Q16.length; i++) {
171
+ const d = z >= 0 ? 1 : -1;
172
+ const xi = x - d * (y >> i);
173
+ const yi = y + d * (x >> i);
174
+ x = xi;
175
+ y = yi;
176
+ z = z - d * (ATAN_Q16[i] as number);
177
+ }
178
+ return { sinQ16: y, cosQ16: flip * x };
179
+ }
180
+
181
+ /** SimContext 注入的确定性数学门面(无状态,可直接共享单例) */
182
+ export interface DeterministicMath {
183
+ readonly isqrt: typeof isqrt;
184
+ readonly powInt: typeof powInt;
185
+ readonly qmul: typeof qmul;
186
+ readonly degreesToQ16: typeof degreesToQ16;
187
+ readonly sinCosQ16: typeof sinCosQ16;
188
+ }
189
+
190
+ export const deterministicMath: DeterministicMath = {
191
+ isqrt,
192
+ powInt,
193
+ qmul,
194
+ degreesToQ16,
195
+ sinCosQ16,
196
+ };
package/clock.ts ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * 虚拟时钟:tick 计数器,状态内嵌于 GameState(`clock.tick`)。
3
+ * Sim 内禁止使用真实时间(Date.now / performance.now / setTimeout);
4
+ * 一切时间语义都表达为 tick 的推进。
5
+ */
6
+
7
+ export interface SimulationClock {
8
+ readonly tick: number;
9
+ advance(by?: number): void;
10
+ }
11
+
12
+ export class SimClock implements SimulationClock {
13
+ private _tick: number;
14
+
15
+ constructor(startTick = 0) {
16
+ this._tick = startTick;
17
+ }
18
+
19
+ get tick(): number {
20
+ return this._tick;
21
+ }
22
+
23
+ advance(by = 1): void {
24
+ if (!Number.isInteger(by) || by <= 0) {
25
+ throw new Error(`clock/invalid-advance: ${by}`);
26
+ }
27
+ this._tick += by;
28
+ }
29
+ }
@@ -0,0 +1,282 @@
1
+ /**
2
+ * `__GAME_DEBUG__` 调试协议宿主。
3
+ *
4
+ * 方法集固定(设计文档 §3.4),不做项目裁剪:
5
+ * getStateSnapshot / getStateHash / dispatch / runQuery / getAvailableCommands /
6
+ * loadFixture / setSeed / advanceUntilIdle / getRecentEvents / getEntityBounds /
7
+ * getPerformanceSnapshot / exportReplay
8
+ *
9
+ * 纪律:只读优先、全部返回可序列化数据、Release 构建不挂载;
10
+ * loadFixture / setSeed 会破坏会话与导出回放的一致性——之后 exportReplay
11
+ * 显式报错,而不是导出一份假的"可复现"回放。
12
+ */
13
+
14
+ import type { CommandResult, GameModule, KernelState } from "./module.ts";
15
+ import { createSimContext, type SimContext } from "./module.ts";
16
+ import { canonicalStateHash } from "./arithmetic.ts";
17
+ import { Xoshiro } from "./rng.ts";
18
+ import type { RngState } from "./rng.ts";
19
+ import type { ReplayFile, UnknownCommand } from "./replay/schema.ts";
20
+ import { REPLAY_FORMAT_VERSION, snapshotsFromHashes } from "./replay/schema.ts";
21
+
22
+ export const DEBUG_METHODS = [
23
+ "getStateSnapshot",
24
+ "getStateHash",
25
+ "dispatch",
26
+ "runQuery",
27
+ "getAvailableCommands",
28
+ "loadFixture",
29
+ "setSeed",
30
+ "advanceUntilIdle",
31
+ "getRecentEvents",
32
+ "getEntityBounds",
33
+ "getPerformanceSnapshot",
34
+ "exportReplay",
35
+ ] as const;
36
+
37
+ export type DebugMethodName = (typeof DEBUG_METHODS)[number];
38
+
39
+ /**
40
+ * 实体包围盒。坐标空间契约(BUG-003 / issue #14):**viewport CSS 像素**——
41
+ * 返回值可直接交给 Playwright page.mouse.click / 视口坐标断言;页面偏移、
42
+ * 滚动、CSS 缩放与设备像素比由实现侧换算,调用方不做任何二次换算。
43
+ */
44
+ export interface Rect {
45
+ x: number;
46
+ y: number;
47
+ width: number;
48
+ height: number;
49
+ }
50
+
51
+ export class DebugSessionError extends Error {
52
+ readonly code: string;
53
+
54
+ constructor(code: string, message: string) {
55
+ super(message);
56
+ this.code = code;
57
+ }
58
+ }
59
+
60
+ export interface PerformanceSnapshot {
61
+ commandsDispatched: number;
62
+ totalExecuteMs: number;
63
+ lastExecuteMs: number;
64
+ avgExecuteMs: number;
65
+ uptimeMs: number;
66
+ }
67
+
68
+ export interface DebugHostOptions<S extends KernelState, C, E> {
69
+ module: GameModule<S, C, E>;
70
+ init: { input: unknown; seed: number };
71
+ /** 项目注册的受控查询(name → handler);runQuery 只走此注册表 */
72
+ queries?: Record<string, (args: unknown, state: S) => unknown>;
73
+ /** 实体屏幕包围盒来源;M2 View 层接入前可不提供 */
74
+ entityBounds?: (id: string, state: S) => Rect | undefined;
75
+ /** advanceUntilIdle 的步数上限(防推演器空转) */
76
+ maxAutoSteps?: number;
77
+ recentEventLimit?: number;
78
+ }
79
+
80
+ export interface DebugHost<S extends KernelState, C, E> {
81
+ getStateSnapshot(): S;
82
+ getStateHash(): string;
83
+ dispatch(command: C): CommandResult<S, E>;
84
+ runQuery(name: string, args?: unknown): { ok: true; result: unknown } | { ok: false; error: { code: string; message: string } };
85
+ getAvailableCommands(): string[] | { supported: false };
86
+ loadFixture(state: S): { valid: true } | { valid: false; errors: unknown[] };
87
+ setSeed(seed: number): void;
88
+ advanceUntilIdle(): { idle: true; steps: number } | { ok: false; error: { code: string; message: string } };
89
+ getRecentEvents(limit?: number): E[];
90
+ getEntityBounds(id: string): Rect | undefined;
91
+ getPerformanceSnapshot(): PerformanceSnapshot;
92
+ exportReplay(): ReplayFile;
93
+ }
94
+
95
+ export function createDebugHost<S extends KernelState, C, E>(
96
+ options: DebugHostOptions<S, C, E>,
97
+ ): DebugHost<S, C, E> {
98
+ const {
99
+ module,
100
+ init,
101
+ queries = {},
102
+ entityBounds,
103
+ maxAutoSteps = 10_000,
104
+ recentEventLimit = 100,
105
+ } = options;
106
+
107
+ const startedAt = Date.now();
108
+ const commands: UnknownCommand[] = [];
109
+ const recentEvents: E[] = [];
110
+ let replayDirty = false;
111
+ let perf = { dispatched: 0, totalMs: 0, lastMs: 0 };
112
+
113
+ let rngState: RngState = Xoshiro.fromSeed(init.seed).state;
114
+ // createInitialState 阶段 state 尚不存在,context 从初始记账值构造
115
+ const initialCtx = createSimContext({
116
+ rng: rngState,
117
+ clock: { tick: 0 },
118
+ ids: { next: 1 },
119
+ });
120
+ let state: S = module.createInitialState(init.input, initialCtx);
121
+ // 逐命令状态锚点(ENH-005 / issue #12):与 commands 严格 1:1,仅成功命令记录
122
+ const initialStateHash = canonicalStateHash(state);
123
+ const commandHashes: string[] = [];
124
+
125
+ function refreshContext(): SimContext {
126
+ return createSimContext(state);
127
+ }
128
+
129
+ let ctx: SimContext = refreshContext();
130
+
131
+ function recordResult(result: CommandResult<S, E>): void {
132
+ perf.dispatched += 1;
133
+ if (result.ok) {
134
+ state = result.state;
135
+ ctx = refreshContext();
136
+ commandHashes.push(canonicalStateHash(state)); // 与 commands.push 同点(ENH-005)
137
+ for (const event of result.events) {
138
+ recentEvents.push(event);
139
+ if (recentEvents.length > recentEventLimit) recentEvents.shift();
140
+ }
141
+ }
142
+ }
143
+
144
+ return {
145
+ getStateSnapshot(): S {
146
+ return structuredClone(state);
147
+ },
148
+
149
+ getStateHash(): string {
150
+ return canonicalStateHash(state);
151
+ },
152
+
153
+ dispatch(command: C): CommandResult<S, E> {
154
+ const t0 = performance.now();
155
+ const result = module.execute(state, command, ctx);
156
+ const dt = performance.now() - t0;
157
+ perf.lastMs = dt;
158
+ perf.totalMs += dt;
159
+ // 只有成功命令才入回放(issue #5):失败命令不改状态,混入会让导出的
160
+ // Replay 交给 runReplay 立即 command-failed——回放必须是可复现证据。
161
+ if (result.ok) {
162
+ commands.push(command as unknown as UnknownCommand);
163
+ }
164
+ recordResult(result);
165
+ return result;
166
+ },
167
+
168
+ runQuery(name: string, args?: unknown) {
169
+ const handler = queries[name];
170
+ if (!handler) {
171
+ return {
172
+ ok: false as const,
173
+ error: { code: "debug/unknown-query", message: `query "${name}" not registered` },
174
+ };
175
+ }
176
+ return { ok: true as const, result: handler(args, structuredClone(state)) };
177
+ },
178
+
179
+ getAvailableCommands(): string[] | { supported: false } {
180
+ return module.listCommands ? module.listCommands(state) : { supported: false };
181
+ },
182
+
183
+ loadFixture(fixture: S) {
184
+ const validation = module.validateState(fixture);
185
+ if (!validation.valid) {
186
+ return { valid: false as const, errors: validation.errors };
187
+ }
188
+ state = structuredClone(fixture);
189
+ ctx = refreshContext();
190
+ recentEvents.length = 0;
191
+ replayDirty = true;
192
+ return { valid: true as const };
193
+ },
194
+
195
+ setSeed(seed: number): void {
196
+ rngState = Xoshiro.fromSeed(seed).state;
197
+ state = { ...structuredClone(state), rng: rngState };
198
+ ctx = refreshContext();
199
+ replayDirty = true;
200
+ },
201
+
202
+ advanceUntilIdle() {
203
+ if (!module.autoNext) {
204
+ return { idle: true as const, steps: 0 };
205
+ }
206
+ let steps = 0;
207
+ while (steps < maxAutoSteps) {
208
+ const command = module.autoNext(state, ctx);
209
+ if (command === null || command === undefined) {
210
+ return { idle: true as const, steps };
211
+ }
212
+ const t0 = performance.now();
213
+ const result = module.execute(state, command, ctx);
214
+ perf.lastMs = performance.now() - t0;
215
+ perf.totalMs += perf.lastMs;
216
+ // 计量入口统一为 recordResult(BUG-006 / issue #18):每次实际
217
+ // execute 恰计一次(成功/失败同权),不再手动自增导致双计。
218
+ recordResult(result);
219
+ if (!result.ok) {
220
+ return {
221
+ ok: false as const,
222
+ error: {
223
+ code: "debug/auto-command-failed",
224
+ message: `autoNext command #${steps + 1} rejected`,
225
+ },
226
+ };
227
+ }
228
+ commands.push(command as unknown as UnknownCommand); // 仅成功入档(issue #5)
229
+ steps += 1;
230
+ }
231
+ return {
232
+ ok: false as const,
233
+ error: {
234
+ code: "debug/idle-limit",
235
+ message: `advanceUntilIdle exceeded ${maxAutoSteps} steps (harness guard: strategy is spinning)`,
236
+ },
237
+ };
238
+ },
239
+
240
+ getRecentEvents(limit = 20): E[] {
241
+ return recentEvents.slice(-limit);
242
+ },
243
+
244
+ getEntityBounds(id: string): Rect | undefined {
245
+ return entityBounds ? entityBounds(id, state) : undefined;
246
+ },
247
+
248
+ getPerformanceSnapshot(): PerformanceSnapshot {
249
+ return {
250
+ commandsDispatched: perf.dispatched,
251
+ totalExecuteMs: perf.totalMs,
252
+ lastExecuteMs: perf.lastMs,
253
+ avgExecuteMs: perf.dispatched === 0 ? 0 : perf.totalMs / perf.dispatched,
254
+ uptimeMs: Date.now() - startedAt,
255
+ };
256
+ },
257
+
258
+ exportReplay(): ReplayFile {
259
+ if (replayDirty) {
260
+ throw new DebugSessionError(
261
+ "debug/session-dirty",
262
+ "loadFixture/setSeed was called; exported replay would not reproduce this session",
263
+ );
264
+ }
265
+ return {
266
+ formatVersion: REPLAY_FORMAT_VERSION,
267
+ meta: {
268
+ game: module.name,
269
+ module: module.name,
270
+ createdAt: new Date().toISOString(),
271
+ },
272
+ init: structuredClone(init) as { input: unknown; seed: number },
273
+ commands: structuredClone(commands),
274
+ expected: {
275
+ initialHash: initialStateHash,
276
+ finalHash: canonicalStateHash(state),
277
+ snapshots: snapshotsFromHashes(commandHashes),
278
+ },
279
+ };
280
+ },
281
+ };
282
+ }
package/ids.ts ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * 确定性 ID 生成器:单调递增计数器,状态内嵌于 GameState(`ids.next`)。
3
+ * 不使用随机 ID / 时间戳 ID——同一起点必然得到同一 ID 序列。
4
+ */
5
+
6
+ export interface DeterministicIdGenerator {
7
+ /** 返回形如 "n1"、"n2" 的稳定 ID,并推进计数器 */
8
+ next(): string;
9
+ readonly counter: number;
10
+ }
11
+
12
+ export class IdGen implements DeterministicIdGenerator {
13
+ private _next: number;
14
+
15
+ constructor(start = 1) {
16
+ this._next = start;
17
+ }
18
+
19
+ get counter(): number {
20
+ return this._next - 1;
21
+ }
22
+
23
+ next(): string {
24
+ const id = `n${this._next}`;
25
+ this._next += 1;
26
+ return id;
27
+ }
28
+ }
package/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ export * from "./rng.ts";
2
+ export * from "./clock.ts";
3
+ export * from "./ids.ts";
4
+ export * from "./arithmetic.ts";
5
+ export * from "./module.ts";
6
+ export * from "./replay/schema.ts";
7
+ export * from "./replay/migrate.ts";
8
+ export * from "./replay/runner.ts";
9
+ export * from "./debug-protocol.ts";
package/module.ts ADDED
@@ -0,0 +1,129 @@
1
+ /**
2
+ * GameModule 契约——题材中立的规则模块接口。
3
+ *
4
+ * 内核不预设单位/HP/棋盘/卡牌等任何题材概念;它只约定:
5
+ *
6
+ * 1. State 内嵌 kernel 记账字段(rng/clock/ids),是可序列化纯数据;
7
+ * 2. createInitialState 必须把 ctx 的初始状态写入返回的 State;
8
+ * 3. execute 不修改入参 state(返回新对象),且必须把 ctx 演进后的
9
+ * rng/clock/ids 写回新 State(用 syncKernelState 免手写);
10
+ * 4. 一切变更经 Command 进入、以 Event 流出(CommandResult);
11
+ * 5. Sim 内禁止真实时间、真实随机、异步——时间=时钟 tick,随机=ctx.rng。
12
+ */
13
+
14
+ import type { SimClock } from "./clock.ts";
15
+ import { SimClock as SimClockImpl } from "./clock.ts";
16
+ import type { DeterministicIdGenerator } from "./ids.ts";
17
+ import { IdGen } from "./ids.ts";
18
+ import type { RandomSource, RngState } from "./rng.ts";
19
+ import { Xoshiro } from "./rng.ts";
20
+ import type { DeterministicMath } from "./arithmetic.ts";
21
+ import { deterministicMath } from "./arithmetic.ts";
22
+
23
+ /** 所有 GameState 的公共骨架(kernel 记账字段,非题材概念) */
24
+ export interface KernelState {
25
+ rng: RngState;
26
+ clock: { tick: number };
27
+ ids: { next: number };
28
+ }
29
+
30
+ export interface StructuredError {
31
+ code: string;
32
+ message: string;
33
+ details?: unknown;
34
+ }
35
+
36
+ export type CommandResult<S, E> =
37
+ | { ok: true; state: S; events: E[] }
38
+ | { ok: false; error: StructuredError };
39
+
40
+ export type ValidationResult =
41
+ | { valid: true }
42
+ | { valid: false; errors: StructuredError[] };
43
+
44
+ export interface InitContext {
45
+ rng: RandomSource;
46
+ clock: SimClock;
47
+ ids: DeterministicIdGenerator;
48
+ }
49
+
50
+ export interface SimContext extends InitContext {
51
+ /** 确定性数学(整数 sqrt / Q16 CORDIC 三角 / 定点乘法),禁止直接调 Math */
52
+ math: DeterministicMath;
53
+ }
54
+
55
+ export interface GameModule<
56
+ S extends KernelState = KernelState,
57
+ C = unknown,
58
+ E = unknown,
59
+ > {
60
+ readonly name: string;
61
+ createInitialState(input: unknown, ctx: InitContext): S;
62
+ execute(
63
+ state: Readonly<S>,
64
+ command: C,
65
+ ctx: SimContext,
66
+ ): CommandResult<S, E>;
67
+ validateState(state: S): ValidationResult;
68
+ /** 调试协议 getAvailableCommands 的数据源(可选) */
69
+ listCommands?(state: S): string[];
70
+ /** advanceUntilIdle 的系统驱动命令源(可选;返回 null 表示空闲) */
71
+ autoNext?(state: Readonly<S>, ctx: SimContext): C | null;
72
+ }
73
+
74
+ /** 由 State 内嵌的 kernel 字段构造 SimContext(host/runner/App 通用入口) */
75
+ export function createSimContext(state: KernelState): SimContext {
76
+ return {
77
+ rng: new Xoshiro(state.rng),
78
+ clock: new SimClockImpl(state.clock.tick),
79
+ ids: new IdGen(state.ids.next),
80
+ math: deterministicMath,
81
+ };
82
+ }
83
+
84
+ /** 把 ctx 演进后的 rng/clock/ids 写回 State(execute 返回前调用) */
85
+ export function syncKernelState<S extends KernelState>(draft: S, ctx: SimContext): S {
86
+ draft.rng = ctx.rng.state;
87
+ draft.clock = { tick: ctx.clock.tick };
88
+ draft.ids = { next: ctx.ids.counter + 1 };
89
+ return draft;
90
+ }
91
+
92
+ /** KernelState 字段的结构校验(模块 validateState 应组合调用) */
93
+ export function validateKernelState(state: KernelState): ValidationResult {
94
+ const errors: StructuredError[] = [];
95
+ const s = state.rng?.s;
96
+ if (
97
+ !Array.isArray(s) ||
98
+ s.length !== 4 ||
99
+ !s.every((w) => Number.isInteger(w) && w >= 0 && w <= 0xffffffff)
100
+ ) {
101
+ errors.push({ code: "kernel/bad-rng", message: "rng.s must be 4 × uint32" });
102
+ } else if (s.every((w) => w === 0)) {
103
+ errors.push({ code: "kernel/zero-rng", message: "all-zero rng state" });
104
+ }
105
+ if (
106
+ typeof state.clock?.tick !== "number" ||
107
+ !Number.isInteger(state.clock.tick) ||
108
+ state.clock.tick < 0
109
+ ) {
110
+ errors.push({ code: "kernel/bad-clock", message: "clock.tick must be uint" });
111
+ }
112
+ if (
113
+ typeof state.ids?.next !== "number" ||
114
+ !Number.isInteger(state.ids.next) ||
115
+ state.ids.next < 1
116
+ ) {
117
+ errors.push({ code: "kernel/bad-ids", message: "ids.next must be ≥ 1" });
118
+ }
119
+ return errors.length === 0 ? { valid: true } : { valid: false, errors };
120
+ }
121
+
122
+ /** 空闲态初始 KernelState(rng 由种子派生)——createInitialState 的起点 */
123
+ export function initialKernelState(seed: number): KernelState {
124
+ return {
125
+ rng: Xoshiro.fromSeed(seed).state,
126
+ clock: { tick: 0 },
127
+ ids: { next: 1 },
128
+ };
129
+ }
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@playfield/kernel",
3
+ "version": "0.4.0-alpha.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "playfield kernel package — 确定性内核(clock/rng/ids/算术/模块协议/replay)",
7
+ "exports": {
8
+ "./*.ts": "./*.ts",
9
+ "./*.tsx": "./*.tsx",
10
+ "./*.css": "./*.css"
11
+ },
12
+ "dependencies": {},
13
+ "license": "UNLICENSED",
14
+ "files": [
15
+ "**/*.ts",
16
+ "**/*.tsx"
17
+ ]
18
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * 回放版本迁移。
3
+ *
4
+ * 规则改动导致既有回放无法逐位复现时:REPLAY_FORMAT_VERSION +1,
5
+ * 并在 MIGRATIONS 注册 `旧版本 → 下一版本` 的转换函数。
6
+ * 未提供迁移链的旧版本回放必须显式报错,禁止静默重解释。
7
+ */
8
+
9
+ import type { ReplayFile } from "./schema.ts";
10
+ import { REPLAY_FORMAT_VERSION } from "./schema.ts";
11
+
12
+ export class ReplayFormatError extends Error {
13
+ readonly code: string;
14
+
15
+ constructor(code: string, message: string) {
16
+ super(message);
17
+ this.code = code;
18
+ }
19
+ }
20
+
21
+ /** fromVersion → (replay) => 下一版本形态。新增破坏性改动时在此登记 */
22
+ const MIGRATIONS: Readonly<Record<number, (replay: Record<string, unknown>) => Record<string, unknown>>> = {};
23
+
24
+ export function migrateReplay(raw: unknown): ReplayFile {
25
+ if (typeof raw !== "object" || raw === null) {
26
+ throw new ReplayFormatError("replay/not-an-object", "replay must be a JSON object");
27
+ }
28
+ let current = raw as Record<string, unknown>;
29
+ const version = current.formatVersion;
30
+ if (typeof version !== "number" || !Number.isInteger(version) || version < 1) {
31
+ throw new ReplayFormatError(
32
+ "replay/missing-version",
33
+ `formatVersion must be a positive integer, got ${String(version)}`,
34
+ );
35
+ }
36
+ let v = version;
37
+ while (v < REPLAY_FORMAT_VERSION) {
38
+ const step = MIGRATIONS[v];
39
+ if (!step) {
40
+ throw new ReplayFormatError(
41
+ "replay/no-migration-path",
42
+ `no migration registered from formatVersion ${v}; this kernel expects v${REPLAY_FORMAT_VERSION}`,
43
+ );
44
+ }
45
+ current = step(current);
46
+ v = (current.formatVersion as number) ?? v + 1;
47
+ }
48
+ if (v > REPLAY_FORMAT_VERSION) {
49
+ throw new ReplayFormatError(
50
+ "replay/unsupported-version",
51
+ `replay formatVersion ${v} is newer than kernel-supported v${REPLAY_FORMAT_VERSION}`,
52
+ );
53
+ }
54
+ assertV1Shape(current);
55
+ return current as unknown as ReplayFile;
56
+ }
57
+
58
+ function assertV1Shape(r: Record<string, unknown>): void {
59
+ const need = (ok: boolean, code: string, what: string) => {
60
+ if (!ok) throw new ReplayFormatError(code, `replay missing/invalid: ${what}`);
61
+ };
62
+ need(typeof r.meta === "object" && r.meta !== null, "replay/bad-meta", "meta");
63
+ need(
64
+ typeof (r.meta as Record<string, unknown>).game === "string",
65
+ "replay/bad-meta",
66
+ "meta.game",
67
+ );
68
+ need(
69
+ r.init !== null && typeof r.init === "object" &&
70
+ Number.isInteger((r.init as Record<string, unknown>).seed),
71
+ "replay/bad-init",
72
+ "init.seed",
73
+ );
74
+ need(Array.isArray(r.commands), "replay/bad-commands", "commands[]");
75
+ need(
76
+ r.expected === null || typeof r.expected === "object",
77
+ "replay/bad-expected",
78
+ "expected",
79
+ );
80
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * 回放 conformance runner——diagnose 的核心。
3
+ *
4
+ * 以 (input, seed, commands) 重放整局,在记录点校验状态哈希;
5
+ * 不一致时报告首分歧(命令序号 + 期望/实际哈希),即最小复现定位。
6
+ */
7
+
8
+ import type { GameModule } from "../module.ts";
9
+ import { createSimContext, initialKernelState } from "../module.ts";
10
+ import type { ReplayFile, UnknownCommand } from "./schema.ts";
11
+ import { canonicalStateHash } from "../arithmetic.ts";
12
+
13
+ export type ConformanceResult =
14
+ | {
15
+ ok: true;
16
+ commandsExecuted: number;
17
+ finalHash: string;
18
+ }
19
+ | {
20
+ ok: false;
21
+ reason:
22
+ | "command-failed"
23
+ | "hash-mismatch"
24
+ | "invalid-state";
25
+ commandIndex: number;
26
+ error?: unknown;
27
+ expectedHash?: string;
28
+ actualHash?: string;
29
+ };
30
+
31
+ export function runReplay<S extends import("../module.ts").KernelState, C, E>(
32
+ mod: GameModule<S, C, E>,
33
+ replay: ReplayFile,
34
+ ): ConformanceResult {
35
+ const ctx = createSimContext(initialKernelState(replay.init.seed));
36
+ let state = mod.createInitialState(replay.init.input, ctx);
37
+
38
+ const initialHash = canonicalStateHash(state);
39
+ if (
40
+ replay.expected.initialHash !== undefined &&
41
+ replay.expected.initialHash !== initialHash
42
+ ) {
43
+ return {
44
+ ok: false,
45
+ reason: "hash-mismatch",
46
+ commandIndex: 0,
47
+ expectedHash: replay.expected.initialHash,
48
+ actualHash: initialHash,
49
+ };
50
+ }
51
+
52
+ const snapshots = new Map(
53
+ (replay.expected.snapshots ?? []).map((snap) => [snap.afterCommand, snap.stateHash]),
54
+ );
55
+
56
+ const commands: readonly UnknownCommand[] = replay.commands;
57
+ for (let i = 0; i < commands.length; i++) {
58
+ const command = commands[i] as unknown as C;
59
+ const result = mod.execute(state, command, ctx);
60
+ if (!result.ok) {
61
+ return {
62
+ ok: false,
63
+ reason: "command-failed",
64
+ commandIndex: i,
65
+ error: result.error,
66
+ };
67
+ }
68
+ state = result.state;
69
+
70
+ const hash = canonicalStateHash(state);
71
+ const expected = snapshots.get(i + 1);
72
+ if (expected !== undefined && expected !== hash) {
73
+ return {
74
+ ok: false,
75
+ reason: "hash-mismatch",
76
+ commandIndex: i,
77
+ expectedHash: expected,
78
+ actualHash: hash,
79
+ };
80
+ }
81
+ }
82
+
83
+ const finalHash = canonicalStateHash(state);
84
+ if (
85
+ replay.expected.finalHash !== undefined &&
86
+ replay.expected.finalHash !== finalHash
87
+ ) {
88
+ return {
89
+ ok: false,
90
+ reason: "hash-mismatch",
91
+ commandIndex: commands.length - 1,
92
+ expectedHash: replay.expected.finalHash,
93
+ actualHash: finalHash,
94
+ };
95
+ }
96
+
97
+ const validation = mod.validateState(state);
98
+ if (!validation.valid) {
99
+ return {
100
+ ok: false,
101
+ reason: "invalid-state",
102
+ commandIndex: commands.length - 1,
103
+ error: validation.errors,
104
+ };
105
+ }
106
+
107
+ return { ok: true, commandsExecuted: commands.length, finalHash };
108
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * 回放格式 v1。
3
+ *
4
+ * 回放 = 初始条件(input + seed)+ 命令序列 + 期望指纹(周期快照/终局哈希)。
5
+ * 它是项目内部的移植契约与介入货币:conformance runner 以状态哈希逐位校验。
6
+ * 任何会改变既有回放结果的规则改动 = 破坏契约,必须显式升版本(见 migrate.ts)。
7
+ */
8
+
9
+ export const REPLAY_FORMAT_VERSION = 1;
10
+
11
+ export interface UnknownCommand {
12
+ type: string;
13
+ [key: string]: unknown;
14
+ }
15
+
16
+ export interface ReplaySnapshot {
17
+ /** 该快照对应已执行命令数(1-based) */
18
+ afterCommand: number;
19
+ stateHash: string;
20
+ }
21
+
22
+ /** 由逐命令状态哈希构造锚点(与命令序列严格 1:1,ENH-005 / issue #12) */
23
+ export function snapshotsFromHashes(hashes: readonly string[]): ReplaySnapshot[] {
24
+ return hashes.map((stateHash, i) => ({ afterCommand: i + 1, stateHash }));
25
+ }
26
+
27
+ export interface ReplayMeta {
28
+ game: string;
29
+ module: string;
30
+ createdAt: string;
31
+ note?: string;
32
+ }
33
+
34
+ export interface ReplayFile {
35
+ formatVersion: typeof REPLAY_FORMAT_VERSION;
36
+ meta: ReplayMeta;
37
+ init: {
38
+ input: unknown;
39
+ /** 单整数种子(等价于 Xoshiro.fromSeed(seed) 的初始状态) */
40
+ seed: number;
41
+ };
42
+ commands: UnknownCommand[];
43
+ expected: {
44
+ initialHash?: string;
45
+ finalHash?: string;
46
+ snapshots?: ReplaySnapshot[];
47
+ };
48
+ }
package/rng.ts ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * 种子化确定性 RNG(xoshiro128**)。
3
+ *
4
+ * 约定:RNG 状态内嵌于 GameState(`RngState`),随状态快照演进;
5
+ * 同一状态继续抽取,结果逐位一致。禁止在 Sim 内使用 Math.random。
6
+ */
7
+
8
+ export interface RngState {
9
+ readonly s: readonly [number, number, number, number];
10
+ }
11
+
12
+ export interface RandomSource {
13
+ nextU32(): number;
14
+ /** [0, 1) 确定性浮点:24 位尾数构造 */
15
+ nextFloat(): number;
16
+ /** 闭区间 [min, max],拒绝采样保证无偏 */
17
+ intRange(min: number, max: number): number;
18
+ pick<T>(items: readonly T[]): T;
19
+ readonly state: RngState;
20
+ }
21
+
22
+ const ROTL = (x: number, k: number): number => ((x << k) | (x >>> (32 - k))) >>> 0;
23
+
24
+ /** splitmix32:由单一整数种子填充 4 个状态字 */
25
+ function splitmix32(a: number): number {
26
+ a = (a + 0x9e3779b9) | 0;
27
+ let t = Math.imul(a ^ (a >>> 16), 0x21f0aaad);
28
+ t = Math.imul(t ^ (t >>> 15), 0x735a2d97);
29
+ return (t ^ (t >>> 15)) >>> 0;
30
+ }
31
+
32
+ function isRngState(v: unknown): v is RngState {
33
+ if (typeof v !== "object" || v === null) return false;
34
+ const s = (v as { s?: unknown }).s;
35
+ if (!Array.isArray(s) || s.length !== 4) return false;
36
+ return s.every(
37
+ (w) => typeof w === "number" && Number.isInteger(w) && w >= 0 && w <= 0xffffffff,
38
+ );
39
+ }
40
+
41
+ export class Xoshiro implements RandomSource {
42
+ private s0: number;
43
+ private s1: number;
44
+ private s2: number;
45
+ private s3: number;
46
+
47
+ constructor(state: RngState) {
48
+ if (!isRngState(state)) {
49
+ throw new Error("rng/invalid-state: expect { s: [4 × uint32] }");
50
+ }
51
+ const [a, b, c, d] = state.s;
52
+ if (a === 0 && b === 0 && c === 0 && d === 0) {
53
+ // 全零是 xoshiro 的不动点,永久输出 0
54
+ throw new Error("rng/zero-state: all-zero state is forbidden");
55
+ }
56
+ this.s0 = a;
57
+ this.s1 = b;
58
+ this.s2 = c;
59
+ this.s3 = d;
60
+ }
61
+
62
+ static fromSeed(seed: number): Xoshiro {
63
+ if (!Number.isInteger(seed)) {
64
+ throw new Error("rng/invalid-seed: seed must be an integer");
65
+ }
66
+ let a = seed | 0;
67
+ const words = [0, 0, 0, 0].map(() => {
68
+ a = (a + 0x9e3779b9) | 0;
69
+ return splitmix32(a);
70
+ });
71
+ const rng = new Xoshiro({ s: words as [number, number, number, number] });
72
+ if (rng.s0 === 0 && rng.s1 === 0 && rng.s2 === 0 && rng.s3 === 0) {
73
+ // splitmix32 链不会产生全零,此为防御性兜底
74
+ return new Xoshiro({ s: [1, 2, 3, 4] });
75
+ }
76
+ return rng;
77
+ }
78
+
79
+ get state(): RngState {
80
+ return { s: [this.s0, this.s1, this.s2, this.s3] };
81
+ }
82
+
83
+ nextU32(): number {
84
+ const result = Math.imul(ROTL(Math.imul(this.s0, 5), 7), 9) >>> 0;
85
+ const t = (this.s1 << 9) >>> 0;
86
+ this.s2 = (this.s2 ^ this.s0) >>> 0;
87
+ this.s3 = (this.s3 ^ this.s1) >>> 0;
88
+ this.s1 = (this.s1 ^ this.s2) >>> 0;
89
+ this.s0 = (this.s0 ^ this.s3) >>> 0;
90
+ this.s2 = (this.s2 ^ t) >>> 0;
91
+ this.s3 = ROTL(this.s3, 11);
92
+ return result;
93
+ }
94
+
95
+ nextFloat(): number {
96
+ // 丢弃低 8 位取 24 位尾数 → [0, 1),构造确定且跨实现稳定
97
+ return (this.nextU32() >>> 8) / 0x1000000;
98
+ }
99
+
100
+ intRange(min: number, max: number): number {
101
+ if (!Number.isInteger(min) || !Number.isInteger(max) || min > max) {
102
+ throw new Error(`rng/invalid-range: [${min}, ${max}]`);
103
+ }
104
+ const span = max - min + 1;
105
+ if (span > 0x100000000) {
106
+ throw new Error("rng/range-too-wide");
107
+ }
108
+ const limit = Math.floor(0x100000000 / span) * span;
109
+ let x = this.nextU32();
110
+ while (x >= limit) x = this.nextU32();
111
+ return min + (x % span);
112
+ }
113
+
114
+ pick<T>(items: readonly T[]): T {
115
+ if (items.length === 0) {
116
+ throw new Error("rng/pick-empty");
117
+ }
118
+ return items[this.intRange(0, items.length - 1)] as T;
119
+ }
120
+ }