@moku-labs/game 0.0.1 → 0.0.3
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 +495 -40
- package/dist/assets.d.mts +213 -0
- package/dist/assets.mjs +6125 -0
- package/dist/commands-BOxqlv7w.d.mts +99 -0
- package/dist/component-DGg5DqKK.mjs +44 -0
- package/dist/control.d.mts +73 -0
- package/dist/control.mjs +4 -0
- package/dist/define-BMo0pHe_.d.mts +51 -0
- package/dist/dev-CcwFrwok.mjs +413 -0
- package/dist/index.d.mts +1396 -54
- package/dist/index.mjs +26433 -2013
- package/dist/inspect.d.mts +111 -0
- package/dist/inspect.mjs +3 -0
- package/dist/jsx-dev-runtime.d.mts +2 -0
- package/dist/jsx-dev-runtime.mjs +2 -0
- package/dist/jsx-runtime.d.mts +2 -0
- package/dist/jsx-runtime.mjs +2 -0
- package/dist/memory-CvgdnsQO.mjs +259 -0
- package/dist/{rolldown-runtime-D7D4PA-g.mjs → rolldown-runtime-8H4AJuhK.mjs} +1 -0
- package/dist/run-tlcn1w7y.mjs +944 -0
- package/dist/runtime-BstIkrhH.d.mts +77 -0
- package/dist/runtime-DRlwxkIv.mjs +182 -0
- package/dist/session-DmAxY6Ll.mjs +145 -0
- package/dist/sources-BooL2MsX.mjs +545 -0
- package/dist/testing.d.mts +302 -88
- package/dist/testing.mjs +2005 -228
- package/dist/types-BTycq8_Z.d.mts +1196 -0
- package/dist/types-C0SLQsx0.d.mts +2058 -0
- package/dist/types-Cjf3ywai.d.mts +5982 -0
- package/dist/types-D0uYTTC3.d.mts +392 -0
- package/dist/types-DgwbzDLP.d.mts +690 -0
- package/dist/types-KlE_GL1z.d.mts +1444 -0
- package/dist/visual-NPrDkvUk.mjs +740 -0
- package/package.json +42 -5
- package/dist/registry-DWV5C0Mf.mjs +0 -666
- package/dist/types-BfsmUzLC.d.mts +0 -1908
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { Tt as Time, Ut as Api, ft as FlowState, lt as Bookmark, t as Api$1, u as DebugSwitches } from "./types-Cjf3ywai.mjs";
|
|
2
|
+
import { d as HeadlessApp, n as Command, r as ControlApp } from "./types-D0uYTTC3.mjs";
|
|
3
|
+
import { H as InputApi } from "./types-C0SLQsx0.mjs";
|
|
4
|
+
import { b as UiApi, t as AnimApi } from "./types-KlE_GL1z.mjs";
|
|
5
|
+
|
|
6
|
+
//#region src/plugins/flow/doors/commands.d.ts
|
|
7
|
+
/**
|
|
8
|
+
* Every command of the engine, keyed by its short name: what an editor lists as buttons and MCP
|
|
9
|
+
* tools. Frozen. Each runs in dev builds only.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```ts
|
|
13
|
+
* // An e2e script taps Play, and the editor registers every command.
|
|
14
|
+
* (await run(app, commands.tap, { key: "play" })).state.path; // "board/awaitIntent"
|
|
15
|
+
* for (const command of Object.values(commands)) registry.add(command);
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
declare const commands: Readonly<{
|
|
19
|
+
answer: Command<{
|
|
20
|
+
intent: "string";
|
|
21
|
+
payload: "json?";
|
|
22
|
+
}, boolean, ControlApp>;
|
|
23
|
+
tap: Command<{
|
|
24
|
+
key: "string?";
|
|
25
|
+
target: "json?";
|
|
26
|
+
}, boolean, HeadlessApp & {
|
|
27
|
+
readonly log: import("@moku-labs/common/browser").LogApi;
|
|
28
|
+
} & {
|
|
29
|
+
readonly input: Pick<InputApi, "tap">;
|
|
30
|
+
readonly ui: Pick<UiApi, "find">;
|
|
31
|
+
}>;
|
|
32
|
+
drag: Command<{
|
|
33
|
+
from: "json";
|
|
34
|
+
to: "json";
|
|
35
|
+
}, boolean, HeadlessApp & {
|
|
36
|
+
readonly log: import("@moku-labs/common/browser").LogApi;
|
|
37
|
+
} & {
|
|
38
|
+
readonly input: InputApi;
|
|
39
|
+
}>;
|
|
40
|
+
key: Command<{
|
|
41
|
+
key: "string";
|
|
42
|
+
shift: "boolean?";
|
|
43
|
+
}, boolean, HeadlessApp & {
|
|
44
|
+
readonly log: import("@moku-labs/common/browser").LogApi;
|
|
45
|
+
} & {
|
|
46
|
+
readonly input: InputApi;
|
|
47
|
+
}>;
|
|
48
|
+
walk: Command<{
|
|
49
|
+
route: "json";
|
|
50
|
+
}, FlowState, ControlApp>;
|
|
51
|
+
bookmark: Command<{}, Bookmark, ControlApp>;
|
|
52
|
+
restore: Command<{
|
|
53
|
+
bookmark: "json?";
|
|
54
|
+
repro: "json?";
|
|
55
|
+
}, FlowState, ControlApp>;
|
|
56
|
+
step: Command<{
|
|
57
|
+
frames: "number";
|
|
58
|
+
deltaMs: "number?";
|
|
59
|
+
}, Readonly<Time>, ControlApp>;
|
|
60
|
+
pause: Command<{}, boolean, HeadlessApp & {
|
|
61
|
+
readonly log: import("@moku-labs/common/browser").LogApi;
|
|
62
|
+
} & {
|
|
63
|
+
readonly lifecycle: Api;
|
|
64
|
+
}>;
|
|
65
|
+
resume: Command<{}, boolean, HeadlessApp & {
|
|
66
|
+
readonly log: import("@moku-labs/common/browser").LogApi;
|
|
67
|
+
} & {
|
|
68
|
+
readonly lifecycle: Api;
|
|
69
|
+
}>;
|
|
70
|
+
capture: Command<{}, string | undefined, HeadlessApp & {
|
|
71
|
+
readonly log: import("@moku-labs/common/browser").LogApi;
|
|
72
|
+
} & {
|
|
73
|
+
readonly renderer: Api$1;
|
|
74
|
+
}>;
|
|
75
|
+
debug: Command<{
|
|
76
|
+
nineSlice: "boolean";
|
|
77
|
+
}, DebugSwitches, HeadlessApp & {
|
|
78
|
+
readonly log: import("@moku-labs/common/browser").LogApi;
|
|
79
|
+
} & {
|
|
80
|
+
readonly renderer: Api$1;
|
|
81
|
+
}>;
|
|
82
|
+
reducedMotion: Command<{
|
|
83
|
+
on: "boolean";
|
|
84
|
+
}, boolean, HeadlessApp & {
|
|
85
|
+
readonly log: import("@moku-labs/common/browser").LogApi;
|
|
86
|
+
} & {
|
|
87
|
+
readonly anim: AnimApi;
|
|
88
|
+
}>;
|
|
89
|
+
fill: Command<{
|
|
90
|
+
key: "string";
|
|
91
|
+
value: "string";
|
|
92
|
+
}, boolean, HeadlessApp & {
|
|
93
|
+
readonly log: import("@moku-labs/common/browser").LogApi;
|
|
94
|
+
} & {
|
|
95
|
+
readonly ui: Pick<UiApi, "fill">;
|
|
96
|
+
}>;
|
|
97
|
+
}>;
|
|
98
|
+
//#endregion
|
|
99
|
+
export { commands as t };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
//#region src/plugins/ui/jsx/component.ts
|
|
2
|
+
/** The local state a component that declares none starts every instance with. */
|
|
3
|
+
const noLocal = {};
|
|
4
|
+
/**
|
|
5
|
+
* Tells a component definition from a plain function component, which JSX calls at build time.
|
|
6
|
+
*
|
|
7
|
+
* @param type - What stood in the tag position.
|
|
8
|
+
* @returns True when `defineComponent` built it.
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* isComponentDefinition(() => ({ type: "row", props: {}, children: [] })); // false
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
function isComponentDefinition(type) {
|
|
15
|
+
return typeof type === "function" && type.isUiComponent === true;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The one implementation behind the two overloads.
|
|
19
|
+
*
|
|
20
|
+
* @param name - Component name.
|
|
21
|
+
* @param spec - What the game declared.
|
|
22
|
+
* @returns The callable definition.
|
|
23
|
+
*/
|
|
24
|
+
function defineComponent(name, spec) {
|
|
25
|
+
const local = spec.local ?? noLocal;
|
|
26
|
+
const call = (props) => ({
|
|
27
|
+
type: name,
|
|
28
|
+
props,
|
|
29
|
+
children: []
|
|
30
|
+
});
|
|
31
|
+
const definition = Object.assign(call, {
|
|
32
|
+
outcomes: spec.outcomes,
|
|
33
|
+
local,
|
|
34
|
+
view: spec.view,
|
|
35
|
+
isUiComponent: true
|
|
36
|
+
});
|
|
37
|
+
Object.defineProperty(definition, "name", {
|
|
38
|
+
value: name,
|
|
39
|
+
configurable: true
|
|
40
|
+
});
|
|
41
|
+
return definition;
|
|
42
|
+
}
|
|
43
|
+
//#endregion
|
|
44
|
+
export { isComponentDefinition as n, defineComponent as t };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { d as HeadlessApp, i as InputArguments, n as Command, o as InputSchema, s as Ran } from "./types-D0uYTTC3.mjs";
|
|
2
|
+
import { t as commands } from "./commands-BOxqlv7w.mjs";
|
|
3
|
+
import { t as defineCommand } from "./define-BMo0pHe_.mjs";
|
|
4
|
+
|
|
5
|
+
//#region src/plugins/flow/doors/dev.d.ts
|
|
6
|
+
/**
|
|
7
|
+
* @file flow/doors — the dev flag. `__MOKU_GAME_DEV__` is a global the engine never replaces: a
|
|
8
|
+
* game's dev build defines it `true` (bundler `define`) or sets `globalThis.__MOKU_GAME_DEV__`
|
|
9
|
+
* before the engine runs; undefined means a production build.
|
|
10
|
+
*
|
|
11
|
+
* Bun does not inline `isDev()` across modules (checked on Bun 1.3.14), so a branch that must
|
|
12
|
+
* vanish from a production bundle writes the guard inline:
|
|
13
|
+
* `if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();`.
|
|
14
|
+
* A `define` of `false` folds that condition, and the minifier drops the code behind it.
|
|
15
|
+
*/
|
|
16
|
+
declare global {
|
|
17
|
+
/**
|
|
18
|
+
* The dev flag of a game. A game never re-declares it: it defines it in the bundler, or sets
|
|
19
|
+
* `globalThis.__MOKU_GAME_DEV__ = true` in its dev entry.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```ts
|
|
23
|
+
* // web/dev.ts, imported first by the dev page: /control commands run from here on.
|
|
24
|
+
* globalThis.__MOKU_GAME_DEV__ = true;
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
var __MOKU_GAME_DEV__: boolean | undefined;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Reads the dev flag at call time. Undefined means off: production is the default.
|
|
31
|
+
*
|
|
32
|
+
* @returns True only when `__MOKU_GAME_DEV__` is exactly `true`.
|
|
33
|
+
* @example
|
|
34
|
+
* ```ts
|
|
35
|
+
* // The dev page set the flag in web/dev.ts before the engine started.
|
|
36
|
+
* globalThis.__MOKU_GAME_DEV__ = true;
|
|
37
|
+
* isDev(); // true
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
/**
|
|
41
|
+
* Builds the error a control command throws outside a dev build.
|
|
42
|
+
*
|
|
43
|
+
* @returns The error, ready to throw.
|
|
44
|
+
* @example
|
|
45
|
+
* ```ts
|
|
46
|
+
* // A production build called a /control command.
|
|
47
|
+
* controlRefused().message; // "[game] Control commands run in dev builds only.\n Define __MOKU_GAME_DEV__ as true in the dev build."
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
declare function controlRefused(): Error;
|
|
51
|
+
//#endregion
|
|
52
|
+
//#region src/plugins/flow/doors/run.d.ts
|
|
53
|
+
/**
|
|
54
|
+
* Runs a command in a dev build. A `cheat` or `raw` command taints the session and is journaled
|
|
55
|
+
* before it runs, so a failing one still counts.
|
|
56
|
+
*
|
|
57
|
+
* @param app - The app the command acts on.
|
|
58
|
+
* @param command - The command.
|
|
59
|
+
* @param input - Its input; left out when every field of its schema is optional.
|
|
60
|
+
* @returns The command's value and the envelope read after it.
|
|
61
|
+
* @throws {Error} Outside a dev build, and whatever the command throws.
|
|
62
|
+
* @example
|
|
63
|
+
* ```ts
|
|
64
|
+
* // An e2e script: load the bug report, then tap Play.
|
|
65
|
+
* await run(app, commands.restore, { repro }); // state: { path: "home", frame: 12, tainted: true }
|
|
66
|
+
* const ran = await run(app, commands.answer, { intent: "play" });
|
|
67
|
+
* ran.value; // true
|
|
68
|
+
* ran.state; // { path: "board/awaitIntent", frame: 14, tainted: true }
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
declare function run<S extends InputSchema, O, App extends HeadlessApp>(app: App, command: Command<S, O, App>, ...input: InputArguments<S>): Promise<Ran<O>>;
|
|
72
|
+
//#endregion
|
|
73
|
+
export { type Command, type Ran, commands, controlRefused, defineCommand, run };
|
package/dist/control.mjs
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { c as Source, d as HeadlessApp, n as Command, o as InputSchema } from "./types-D0uYTTC3.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/plugins/flow/doors/define.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Declares a source: a read-only view the editor lists, reads and watches. The types flow from
|
|
6
|
+
* the object: the input from `input`, the app from the `read` parameter, the output from its
|
|
7
|
+
* result.
|
|
8
|
+
*
|
|
9
|
+
* @param source - The descriptor.
|
|
10
|
+
* @returns The same descriptor, frozen.
|
|
11
|
+
* @throws {Error} When the id is not a dotted name such as `"game.position"`.
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* // A game's .dev module: the orders on the board, re-read after every edge.
|
|
15
|
+
* export const orders = defineSource({
|
|
16
|
+
* id: "timber.orders",
|
|
17
|
+
* title: "Orders",
|
|
18
|
+
* input: {},
|
|
19
|
+
* changes: "edge",
|
|
20
|
+
* read: app => app.flow.state().path
|
|
21
|
+
* });
|
|
22
|
+
* read(app, orders); // "board/awaitIntent"
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
declare function defineSource<S extends InputSchema, O, App = HeadlessApp>(source: Source<S, O, App>): Source<S, O, App>;
|
|
26
|
+
/**
|
|
27
|
+
* Declares a command: a dev-only action the editor lists and runs. Its body starts with the
|
|
28
|
+
* inline dev guard, so a production build drops it.
|
|
29
|
+
*
|
|
30
|
+
* @param command - The descriptor.
|
|
31
|
+
* @returns The same descriptor, frozen.
|
|
32
|
+
* @throws {Error} When the id is not a dotted name such as `"game.answer"`.
|
|
33
|
+
* @example
|
|
34
|
+
* ```ts
|
|
35
|
+
* // A game's .dev module: open the shop from anywhere, through the graph.
|
|
36
|
+
* export const openShop = defineCommand({
|
|
37
|
+
* id: "timber.openShop",
|
|
38
|
+
* title: "Open the shop",
|
|
39
|
+
* input: {},
|
|
40
|
+
* effect: "route",
|
|
41
|
+
* run: app => {
|
|
42
|
+
* if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
|
|
43
|
+
* return app.flow.walk([{ at: "home", intent: "shop" }]);
|
|
44
|
+
* }
|
|
45
|
+
* });
|
|
46
|
+
* (await run(app, openShop)).state.path; // "shop"
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
declare function defineCommand<S extends InputSchema, O, App = HeadlessApp>(command: Command<S, O, App>): Command<S, O, App>;
|
|
50
|
+
//#endregion
|
|
51
|
+
export { defineSource as n, defineCommand as t };
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
//#region src/plugins/flow/runner/journal.ts
|
|
2
|
+
/** FNV-1a 32-bit offset basis. */
|
|
3
|
+
const OFFSET_BASIS = 2166136261;
|
|
4
|
+
/** FNV-1a 32-bit prime. */
|
|
5
|
+
const PRIME = 16777619;
|
|
6
|
+
/** Separates the parts of a hashed edge, so "a|b" and "a" + "b" never collide. */
|
|
7
|
+
const SEPARATOR = "\0";
|
|
8
|
+
/**
|
|
9
|
+
* Hashes text with FNV-1a, 32 bit. Pure and deterministic: the same text always gives the same
|
|
10
|
+
* eight hexadecimal characters, in every runtime.
|
|
11
|
+
*
|
|
12
|
+
* @param text - The text to hash.
|
|
13
|
+
* @returns Eight lowercase hexadecimal characters.
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* hashText("board/merge"); // "53ceca70"
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
function hashText(text) {
|
|
20
|
+
let hash = OFFSET_BASIS;
|
|
21
|
+
for (const character of text) hash = Math.imul(hash ^ (character.codePointAt(0) ?? 0), PRIME);
|
|
22
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Hashes one edge, so a replay against edited code fails loudly in dev.
|
|
26
|
+
*
|
|
27
|
+
* @param path - Path of the node that produced the outcome.
|
|
28
|
+
* @param outcome - Outcome name.
|
|
29
|
+
* @param next - Rendered edge target.
|
|
30
|
+
* @returns The digest of the three parts together.
|
|
31
|
+
* @example
|
|
32
|
+
* ```ts
|
|
33
|
+
* entryHash("board/merge", "done", "board/awaitIntent"); // "e881176b"
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
function entryHash(path, outcome, next) {
|
|
37
|
+
return hashText([
|
|
38
|
+
path,
|
|
39
|
+
outcome,
|
|
40
|
+
next
|
|
41
|
+
].join(SEPARATOR));
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Appends an entry with the next index and its hash, and drops the oldest entries above the limit.
|
|
45
|
+
* The index keeps counting while entries are dropped, so two entries never share a number.
|
|
46
|
+
*
|
|
47
|
+
* @param state - Runner state that holds the journal.
|
|
48
|
+
* @param entry - The taken edge without `index` and `hash`.
|
|
49
|
+
* @param limit - Journal entries kept between checkpoints.
|
|
50
|
+
* @returns The stored entry.
|
|
51
|
+
*/
|
|
52
|
+
function pushEntry(state, entry, limit) {
|
|
53
|
+
const stored = {
|
|
54
|
+
...entry,
|
|
55
|
+
index: state.journalIndex,
|
|
56
|
+
hash: entryHash(entry.path, entry.outcome, entry.next)
|
|
57
|
+
};
|
|
58
|
+
state.journalIndex += 1;
|
|
59
|
+
state.journal.push(stored);
|
|
60
|
+
if (state.journal.length > limit) state.journal.splice(0, state.journal.length - limit);
|
|
61
|
+
return stored;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Clears the journal at a checkpoint. The running index is kept, and so is the array itself: a
|
|
65
|
+
* reader that holds `history()` sees the compaction.
|
|
66
|
+
*
|
|
67
|
+
* @param state - Runner state that holds the journal.
|
|
68
|
+
*/
|
|
69
|
+
function compact(state) {
|
|
70
|
+
state.journal.length = 0;
|
|
71
|
+
}
|
|
72
|
+
//#endregion
|
|
73
|
+
//#region src/plugins/flow/runner/registry.ts
|
|
74
|
+
/**
|
|
75
|
+
* Creates the empty flow map. It lives in its own function because the plugin's lint rule L5
|
|
76
|
+
* refuses a collection built inside an exported declaration.
|
|
77
|
+
*
|
|
78
|
+
* @returns An empty map of flow id to flow.
|
|
79
|
+
* @example
|
|
80
|
+
* ```ts
|
|
81
|
+
* emptyFlows().size; // 0
|
|
82
|
+
* ```
|
|
83
|
+
*/
|
|
84
|
+
function emptyFlows() {
|
|
85
|
+
return /* @__PURE__ */ new Map();
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Adds a flow and every flow it reaches by reference to the map. The first flow under an id wins,
|
|
89
|
+
* so a collision is visible to `validateGraph` instead of silently replacing a flow.
|
|
90
|
+
*
|
|
91
|
+
* @param flow - The flow to add.
|
|
92
|
+
* @param flows - The map being filled.
|
|
93
|
+
*/
|
|
94
|
+
function addFlow(flow, flows) {
|
|
95
|
+
if (flows.has(flow.id)) return;
|
|
96
|
+
flows.set(flow.id, flow);
|
|
97
|
+
for (const entry of Object.values(flow.nodes)) if (entry.kind === "flow") addFlow(entry, flows);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Collects every flow reachable from the main flow by reference, plus the registered extras.
|
|
101
|
+
* The main flow is the first entry: `describeGraph` reads it as the entry point of the graph.
|
|
102
|
+
*
|
|
103
|
+
* @param main - The top-level flow.
|
|
104
|
+
* @param extra - Flows added with `register`, not reachable by reference.
|
|
105
|
+
* @returns Flow id to flow, main first.
|
|
106
|
+
* @example
|
|
107
|
+
* ```ts
|
|
108
|
+
* [...collectFlows(mainFlow, []).keys()]; // ["main", "board"]: the main flow first
|
|
109
|
+
* ```
|
|
110
|
+
*/
|
|
111
|
+
function collectFlows(main, extra) {
|
|
112
|
+
const flows = emptyFlows();
|
|
113
|
+
addFlow(main, flows);
|
|
114
|
+
for (const flow of extra) addFlow(flow, flows);
|
|
115
|
+
return flows;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Renders a position as a path: the node of every frame, outermost first.
|
|
119
|
+
*
|
|
120
|
+
* @param stack - One frame per nesting level.
|
|
121
|
+
* @returns The path, for example `"board/awaitIntent"`; empty without a position.
|
|
122
|
+
* @example
|
|
123
|
+
* ```ts
|
|
124
|
+
* const board = { flow: "main", node: "board", input: null };
|
|
125
|
+
* framePath([board, { flow: "board", node: "merge", input: null }]); // "board/merge"
|
|
126
|
+
* ```
|
|
127
|
+
*/
|
|
128
|
+
function framePath(stack) {
|
|
129
|
+
return stack.map((frame) => frame.node).join("/");
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Picks the flow a path continues in after one entry: the sub-flow itself, or, behind a slot, the
|
|
133
|
+
* first contribution in order that has the next segment as a node. A path keeps node names only,
|
|
134
|
+
* so two contributions of one slot with the same node name cannot be told apart; `validateGraph`
|
|
135
|
+
* warns about that.
|
|
136
|
+
*
|
|
137
|
+
* @param entry - The entry the path just passed.
|
|
138
|
+
* @param next - The next segment of the path.
|
|
139
|
+
* @param contributionsOf - Reads the contributions of a slot, in order.
|
|
140
|
+
* @returns The flow to go on in, or `undefined` when the path ends here.
|
|
141
|
+
*/
|
|
142
|
+
function flowBehind(entry, next, contributionsOf) {
|
|
143
|
+
if (entry.kind === "flow") return entry;
|
|
144
|
+
if (entry.kind !== "slot") return void 0;
|
|
145
|
+
return contributionsOf(entry.name).find((item) => Object.hasOwn(item.flow.nodes, next))?.flow;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Reads no contributions: the default of `findNode` for a caller that has no features API.
|
|
149
|
+
*
|
|
150
|
+
* @returns An empty list.
|
|
151
|
+
* @example
|
|
152
|
+
* ```ts
|
|
153
|
+
* noContributions(); // []
|
|
154
|
+
* ```
|
|
155
|
+
*/
|
|
156
|
+
function noContributions() {
|
|
157
|
+
return [];
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Finds the node, sub-flow or slot a path names, starting at the main flow. A path descends one
|
|
161
|
+
* sub-flow per segment, and through a slot into the contribution that has the next segment; a
|
|
162
|
+
* segment after a plain node finds nothing.
|
|
163
|
+
*
|
|
164
|
+
* @param main - The top-level flow.
|
|
165
|
+
* @param path - A path as `framePath` renders it.
|
|
166
|
+
* @param contributionsOf - Reads the contributions of a slot, in order. Without it a path ends at
|
|
167
|
+
* a slot.
|
|
168
|
+
* @returns Where the path leads, or `undefined` when no such node exists.
|
|
169
|
+
* @example
|
|
170
|
+
* ```ts
|
|
171
|
+
* findNode(mainFlow, "board/awaitIntent")?.trail;
|
|
172
|
+
* // [{ flow: "main", node: "board" }, { flow: "board", node: "awaitIntent" }]
|
|
173
|
+
* findNode(mainFlow, "afterOrder/show", () => [{ flow: rewardFlow }])?.trail;
|
|
174
|
+
* // [{ flow: "main", node: "afterOrder" }, { flow: "rewardPopup", node: "show" }]
|
|
175
|
+
* ```
|
|
176
|
+
*/
|
|
177
|
+
function findNode(main, path, contributionsOf = noContributions) {
|
|
178
|
+
const names = path.split("/").filter(Boolean);
|
|
179
|
+
const trail = [];
|
|
180
|
+
let flow = main;
|
|
181
|
+
for (const [step, name] of names.entries()) {
|
|
182
|
+
const entry = flow.nodes[name];
|
|
183
|
+
if (!entry) return void 0;
|
|
184
|
+
trail.push({
|
|
185
|
+
flow: flow.id,
|
|
186
|
+
node: name
|
|
187
|
+
});
|
|
188
|
+
if (step === names.length - 1) return {
|
|
189
|
+
flow,
|
|
190
|
+
name,
|
|
191
|
+
entry,
|
|
192
|
+
trail
|
|
193
|
+
};
|
|
194
|
+
const behind = flowBehind(entry, names[step + 1] ?? "", contributionsOf);
|
|
195
|
+
if (behind === void 0) return void 0;
|
|
196
|
+
flow = behind;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Renders an edge target as a string: a node name, `"exit:win"` or `"map:node"`.
|
|
201
|
+
*
|
|
202
|
+
* @param target - The target as the edge table holds it.
|
|
203
|
+
* @returns The rendered target.
|
|
204
|
+
* @example
|
|
205
|
+
* ```ts
|
|
206
|
+
* renderTarget("home"); // "home"
|
|
207
|
+
* renderTarget({ kind: "exit", outcome: "win" }); // "exit:win"
|
|
208
|
+
* ```
|
|
209
|
+
*/
|
|
210
|
+
function renderTarget(target) {
|
|
211
|
+
if (typeof target === "string") return target;
|
|
212
|
+
return target.kind === "exit" ? `exit:${target.outcome}` : `map:${target.target}`;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Renders the edge table of one flow.
|
|
216
|
+
*
|
|
217
|
+
* @param flow - The flow to render.
|
|
218
|
+
* @returns Node name to outcome name to rendered target.
|
|
219
|
+
* @example
|
|
220
|
+
* ```ts
|
|
221
|
+
* renderEdges(boardFlow).awaitIntent?.leave; // "exit:left"
|
|
222
|
+
* ```
|
|
223
|
+
*/
|
|
224
|
+
function renderEdges(flow) {
|
|
225
|
+
const edges = {};
|
|
226
|
+
for (const [name, row] of Object.entries(flow.edges)) {
|
|
227
|
+
const rendered = {};
|
|
228
|
+
for (const [outcome, target] of Object.entries(row)) rendered[outcome] = renderTarget(target);
|
|
229
|
+
edges[name] = rendered;
|
|
230
|
+
}
|
|
231
|
+
return edges;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Maps every node and flow a feature brought to the name of that feature.
|
|
235
|
+
*
|
|
236
|
+
* @param features - Features API.
|
|
237
|
+
* @returns Node or flow object to feature name.
|
|
238
|
+
*/
|
|
239
|
+
function collectOwners(features) {
|
|
240
|
+
const owners = /* @__PURE__ */ new Map();
|
|
241
|
+
for (const { name, description } of features.all()) {
|
|
242
|
+
for (const node of description.nodes ?? []) owners.set(node, name);
|
|
243
|
+
for (const flow of description.flows ?? []) owners.set(flow, name);
|
|
244
|
+
}
|
|
245
|
+
return owners;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Describes one entry of a flow: its flags, its scene, its outcome names, and the slot, sub-flow
|
|
249
|
+
* or owning feature when it has one.
|
|
250
|
+
*
|
|
251
|
+
* @param flow - The flow that holds the entry.
|
|
252
|
+
* @param name - Name of the entry inside that flow.
|
|
253
|
+
* @param entry - The node, sub-flow or slot.
|
|
254
|
+
* @param owner - Name of the feature that brought it, when a feature did.
|
|
255
|
+
* @returns The entry as JSON.
|
|
256
|
+
* @example
|
|
257
|
+
* ```ts
|
|
258
|
+
* describeNode(mainFlow, "home", home, undefined).rest; // true
|
|
259
|
+
* describeNode(mainFlow, "board", boardFlow, "board").subFlow; // "board"
|
|
260
|
+
* ```
|
|
261
|
+
*/
|
|
262
|
+
function describeNode(flow, name, entry, owner) {
|
|
263
|
+
const node = entry.kind === "node" ? entry : void 0;
|
|
264
|
+
return {
|
|
265
|
+
flow: flow.id,
|
|
266
|
+
node: name,
|
|
267
|
+
rest: node?.rest ?? false,
|
|
268
|
+
over: node?.over ?? false,
|
|
269
|
+
checkpoint: node?.checkpoint ?? false,
|
|
270
|
+
barrier: node?.barrier ?? false,
|
|
271
|
+
outcomes: Object.keys(entry.outcomes),
|
|
272
|
+
...node?.scene === void 0 ? {} : { scene: node.scene },
|
|
273
|
+
...entry.kind === "slot" ? { slot: entry.name } : {},
|
|
274
|
+
...entry.kind === "flow" ? { subFlow: entry.id } : {},
|
|
275
|
+
...owner === void 0 ? {} : { owner }
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Describes every entry of one flow.
|
|
280
|
+
*
|
|
281
|
+
* @param flow - The flow to describe.
|
|
282
|
+
* @param owners - Node or flow object to feature name.
|
|
283
|
+
* @returns Node name to description.
|
|
284
|
+
* @example
|
|
285
|
+
* ```ts
|
|
286
|
+
* describeNodes(boardFlow, new Map()).merge?.outcomes; // ["done", "rejected"]
|
|
287
|
+
* ```
|
|
288
|
+
*/
|
|
289
|
+
function describeNodes(flow, owners) {
|
|
290
|
+
const nodes = {};
|
|
291
|
+
for (const [name, entry] of Object.entries(flow.nodes)) nodes[name] = describeNode(flow, name, entry, owners.get(entry) ?? owners.get(flow));
|
|
292
|
+
return nodes;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Collects the slot names of every flow of the graph. `describe()` and `validate` both read the
|
|
296
|
+
* contributions of exactly these slots.
|
|
297
|
+
*
|
|
298
|
+
* @param flows - Every collected flow by id.
|
|
299
|
+
* @returns The slot names, in the order the graph declares them, each once.
|
|
300
|
+
* @example
|
|
301
|
+
* ```ts
|
|
302
|
+
* slotNames(collectFlows(mainFlow, [])); // ["afterOrder"]
|
|
303
|
+
* ```
|
|
304
|
+
*/
|
|
305
|
+
function slotNames(flows) {
|
|
306
|
+
const names = [];
|
|
307
|
+
for (const flow of flows.values()) for (const entry of Object.values(flow.nodes)) if (entry.kind === "slot" && !names.includes(entry.name)) names.push(entry.name);
|
|
308
|
+
return names;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Renders the whole graph as JSON without running the game: nodes, flags, outcomes, edges,
|
|
312
|
+
* slots and who contributed. Edge targets become `"node"`, `"exit:win"`, `"map:node"`.
|
|
313
|
+
*
|
|
314
|
+
* @param flows - Every collected flow by id, the main flow first, as `collectFlows` returns them.
|
|
315
|
+
* @param features - Features API: owners and slot contributions.
|
|
316
|
+
* @returns The graph as plain JSON.
|
|
317
|
+
*/
|
|
318
|
+
function describeGraph(flows, features) {
|
|
319
|
+
const owners = collectOwners(features);
|
|
320
|
+
const described = {};
|
|
321
|
+
const slots = {};
|
|
322
|
+
for (const flow of flows.values()) described[flow.id] = {
|
|
323
|
+
nodes: describeNodes(flow, owners),
|
|
324
|
+
start: flow.start,
|
|
325
|
+
edges: renderEdges(flow)
|
|
326
|
+
};
|
|
327
|
+
for (const name of slotNames(flows)) slots[name] = features.contributions(name).map(({ feature, flow, order }) => ({
|
|
328
|
+
feature,
|
|
329
|
+
flow: flow.id,
|
|
330
|
+
order
|
|
331
|
+
}));
|
|
332
|
+
return {
|
|
333
|
+
main: [...flows.keys()][0] ?? "",
|
|
334
|
+
flows: described,
|
|
335
|
+
slots
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Compares two object keys without a locale, so the rendering is the same everywhere.
|
|
340
|
+
*
|
|
341
|
+
* @param first - First key.
|
|
342
|
+
* @param second - Second key.
|
|
343
|
+
* @returns A negative number, zero or a positive number.
|
|
344
|
+
* @example
|
|
345
|
+
* ```ts
|
|
346
|
+
* compareKeys("edges", "nodes"); // -1
|
|
347
|
+
* ```
|
|
348
|
+
*/
|
|
349
|
+
function compareKeys(first, second) {
|
|
350
|
+
if (first === second) return 0;
|
|
351
|
+
return first < second ? -1 : 1;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Renders a JSON value with its object keys sorted, so two equal graphs written in a different
|
|
355
|
+
* order hash the same.
|
|
356
|
+
*
|
|
357
|
+
* @param value - Any JSON value.
|
|
358
|
+
* @returns The value as text.
|
|
359
|
+
* @example
|
|
360
|
+
* ```ts
|
|
361
|
+
* stableJson({ b: 1, a: 2 }); // '{"a":2,"b":1}'
|
|
362
|
+
* ```
|
|
363
|
+
*/
|
|
364
|
+
function stableJson(value) {
|
|
365
|
+
if (Array.isArray(value)) return `[${value.map((item) => stableJson(item)).join(",")}]`;
|
|
366
|
+
if (typeof value !== "object" || !value) return JSON.stringify(value) ?? "";
|
|
367
|
+
return `{${Object.entries(value).filter(([, item]) => item !== void 0).toSorted(([first], [second]) => compareKeys(first, second)).map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`).join(",")}}`;
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Hashes a graph description. A bookmark of a plain rest node is accepted only while this hash
|
|
371
|
+
* is unchanged.
|
|
372
|
+
*
|
|
373
|
+
* @param graph - Result of `describeGraph`.
|
|
374
|
+
* @returns Eight lowercase hexadecimal characters.
|
|
375
|
+
* @example
|
|
376
|
+
* ```ts
|
|
377
|
+
* graphHash({ main: "main", flows: {}, slots: {} }); // "cfb16c03"
|
|
378
|
+
* ```
|
|
379
|
+
*/
|
|
380
|
+
function graphHash(graph) {
|
|
381
|
+
return hashText(stableJson(graph));
|
|
382
|
+
}
|
|
383
|
+
//#endregion
|
|
384
|
+
//#region src/plugins/flow/doors/dev.ts
|
|
385
|
+
/**
|
|
386
|
+
* Reads the dev flag at call time. Undefined means off: production is the default.
|
|
387
|
+
*
|
|
388
|
+
* @returns True only when `__MOKU_GAME_DEV__` is exactly `true`.
|
|
389
|
+
* @example
|
|
390
|
+
* ```ts
|
|
391
|
+
* // The dev page set the flag in web/dev.ts before the engine started.
|
|
392
|
+
* globalThis.__MOKU_GAME_DEV__ = true;
|
|
393
|
+
* isDev(); // true
|
|
394
|
+
* ```
|
|
395
|
+
*/
|
|
396
|
+
function isDev() {
|
|
397
|
+
return typeof __MOKU_GAME_DEV__ !== "undefined" && __MOKU_GAME_DEV__ === true;
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Builds the error a control command throws outside a dev build.
|
|
401
|
+
*
|
|
402
|
+
* @returns The error, ready to throw.
|
|
403
|
+
* @example
|
|
404
|
+
* ```ts
|
|
405
|
+
* // A production build called a /control command.
|
|
406
|
+
* controlRefused().message; // "[game] Control commands run in dev builds only.\n Define __MOKU_GAME_DEV__ as true in the dev build."
|
|
407
|
+
* ```
|
|
408
|
+
*/
|
|
409
|
+
function controlRefused() {
|
|
410
|
+
return /* @__PURE__ */ new Error("[game] Control commands run in dev builds only.\n Define __MOKU_GAME_DEV__ as true in the dev build.");
|
|
411
|
+
}
|
|
412
|
+
//#endregion
|
|
413
|
+
export { findNode as a, slotNames as c, describeGraph as i, compact as l, isDev as n, framePath as o, collectFlows as r, graphHash as s, controlRefused as t, pushEntry as u };
|