@origingame/engine 0.2.0 → 0.4.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/bin/og.mjs +1 -1
- package/dist/web/origin-engine.d.ts +255 -5
- package/dist/web/origin-engine.js +1584 -125
- package/dist/web/origin-engine_bg.wasm +0 -0
- package/dist/web/origin-engine_bg.wasm.d.ts +80 -3
- package/dist/web-publish/origin-engine-lanes.js +1288 -82
- package/dist/web-publish/origin-engine-lanes_bg.wasm +0 -0
- package/dist/web-publish/origin-engine.d.ts +199 -6
- package/dist/web-publish/origin-engine.js +1285 -79
- package/dist/web-publish/origin-engine_bg.wasm +0 -0
- package/dist/web-publish/origin-engine_bg.wasm.d.ts +65 -4
- package/dist/web-publish-2d/origin-engine-lanes.js +1285 -79
- package/dist/web-publish-2d/origin-engine-lanes_bg.wasm +0 -0
- package/dist/web-publish-2d/origin-engine.d.ts +198 -5
- package/dist/web-publish-2d/origin-engine.js +1282 -76
- package/dist/web-publish-2d/origin-engine_bg.wasm +0 -0
- package/dist/web-publish-2d/origin-engine_bg.wasm.d.ts +64 -3
- package/knowledge/README.md +2 -1
- package/knowledge/guides/three-to-og.md +393 -0
- package/knowledge/knowledge.json +2 -1
- package/knowledge/looks/catalogue.json +40 -40
- package/knowledge/package.json +3 -2
- package/knowledge/scaffold/distribution.json +14 -0
- package/knowledge/scaffold/origin-engine.json +2 -2
- package/knowledge/scaffold/template/README.md +2 -1
- package/knowledge/scaffold/template/assets/audio/sting.ogt +10 -0
- package/knowledge/scaffold/template/assets/timelines/intro.ogt +101 -0
- package/knowledge/scaffold/template/assets/timelines/narration.srt +11 -0
- package/knowledge/scaffold/template/host.d.ts +46 -0
- package/knowledge/scaffold/template/package.json +1 -1
- package/knowledge/scaffold/template/project.ogt +1 -1
- package/knowledge/scaffold/template/src/main.ts +1 -1
- package/knowledge/scaffold/template/tests/frames.ts +134 -0
- package/knowledge/scaffold/template/tests/intro.og.ts +65 -0
- package/knowledge/scaffold/template/tests/smoke.og.ts +30 -36
- package/knowledge/scaffold/template/tsconfig.json +1 -1
- package/knowledge/scripts/mount.ts +2 -1
- package/knowledge/scripts/validate.ts +159 -31
- package/knowledge/skills/og-audio/SKILL.md +2 -2
- package/knowledge/skills/og-build/SKILL.md +29 -7
- package/knowledge/skills/og-core/SKILL.md +31 -5
- package/knowledge/skills/og-feel/SKILL.md +2 -2
- package/knowledge/skills/og-game/SKILL.md +1331 -16
- package/knowledge/skills/og-i18n/SKILL.md +7 -7
- package/knowledge/skills/og-model/SKILL.md +374 -0
- package/knowledge/skills/og-platform-sdk/SKILL.md +2 -1
- package/knowledge/skills/og-taste/SKILL.md +82 -73
- package/knowledge/skills/og-ui/SKILL.md +80 -58
- package/knowledge/wiki/ai.md +20 -20
- package/knowledge/wiki/anim.md +392 -41
- package/knowledge/wiki/atmosphere.md +36 -13
- package/knowledge/wiki/audio.md +18 -18
- package/knowledge/wiki/camera.md +19 -13
- package/knowledge/wiki/commands.md +19 -13
- package/knowledge/wiki/debug.md +414 -0
- package/knowledge/wiki/game.md +8 -8
- package/knowledge/wiki/i18n.md +7 -7
- package/knowledge/wiki/index.md +317 -8
- package/knowledge/wiki/input.md +47 -47
- package/knowledge/wiki/load.md +238 -70
- package/knowledge/wiki/math.md +2607 -0
- package/knowledge/wiki/nav.md +34 -34
- package/knowledge/wiki/net.md +340 -0
- package/knowledge/wiki/path.md +402 -0
- package/knowledge/wiki/physics.md +95 -95
- package/knowledge/wiki/platform.md +463 -29
- package/knowledge/wiki/probe.md +681 -67
- package/knowledge/wiki/reflect.md +29 -17
- package/knowledge/wiki/render.md +4993 -1998
- package/knowledge/wiki/rng.md +14 -14
- package/knowledge/wiki/shapes.md +727 -0
- package/knowledge/wiki/stream.md +15 -15
- package/knowledge/wiki/system.md +8 -8
- package/knowledge/wiki/systems.md +2 -2
- package/knowledge/wiki/terrain.md +24 -20
- package/knowledge/wiki/tilemap.md +13 -13
- package/knowledge/wiki/time.md +4 -4
- package/knowledge/wiki/timeline.md +396 -0
- package/knowledge/wiki/transform.md +8 -8
- package/knowledge/wiki/tween.md +17 -17
- package/knowledge/wiki/ui.md +560 -654
- package/knowledge/wiki/video.md +157 -0
- package/knowledge/wiki/world.md +36 -36
- package/package.json +4 -3
- package/schema/api.schema.json +28151 -19640
- package/shell/page.ts +26 -2
- package/shell/platform.ts +51 -0
- package/shell/player.ts +15 -2
- package/shell/transport.ts +6 -0
- package/src/depth.ts +71 -0
- package/src/e6.ts +32 -1
- package/src/env.d.ts +5 -1
- package/src/generated/abi.ts +226 -4
- package/src/geom.ts +260 -0
- package/src/index.ts +32 -4
- package/src/light.ts +18 -0
- package/src/math.ts +622 -0
- package/src/node/index.ts +17 -0
- package/src/node/playtest-run.ts +115 -18
- package/src/node/polish.ts +13 -3
- package/src/node/render.ts +291 -0
- package/src/node/run.ts +86 -5
- package/src/p2.ts +19 -1
- package/src/platform-local.ts +2 -0
- package/src/playtest.ts +65 -4
- package/src/runtime.ts +100 -9
- package/src/targets.ts +28 -0
- package/src/trace.ts +169 -0
- package/src/ui-kit/huds.ts +34 -34
- package/src/ui-kit/menus.ts +12 -12
- package/src/ui-kit/settings.ts +11 -2
- package/src/ui-kit/store.ts +18 -18
- package/src/ui-kit/story.ts +6 -4
- package/src/ui-kit/util.ts +8 -8
- package/src/web/shell.ts +59 -1
- package/src/web/worker.ts +105 -4
- package/types/bindings.json +9368 -6619
- package/types/index.d.ts +16377 -11520
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// The reference-frame check: decode the PNGs a run wrote and compare them
|
|
2
|
+
// with references/<tier>/ under og-tests' tolerance (a channel off by more
|
|
3
|
+
// than 32/255 is an outlier; fail above 0.5 % outliers or an RGB RMSE above
|
|
4
|
+
// 0.01). A tier without its own references compares with `metal` and says
|
|
5
|
+
// so. `OG_FX_CAPTURE=1 og test .` records this tier's references instead.
|
|
6
|
+
// (Copied from templates/fx-showcase/tests/frames.ts, so the template stands alone.)
|
|
7
|
+
|
|
8
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
9
|
+
import { dirname, join, resolve } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { inflateSync } from "node:zlib";
|
|
12
|
+
import type { PlaytestChecks, PlaytestRun } from "@origingame/engine/playtest";
|
|
13
|
+
|
|
14
|
+
export interface Image {
|
|
15
|
+
width: number;
|
|
16
|
+
height: number;
|
|
17
|
+
/** RGBA8 rows. */
|
|
18
|
+
rgba: Uint8Array;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** An 8-bit RGB or RGBA, non-interlaced PNG, unfiltered. */
|
|
22
|
+
export function decodePng(bytes: Uint8Array): Image {
|
|
23
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
24
|
+
let at = 8;
|
|
25
|
+
let width = 0;
|
|
26
|
+
let height = 0;
|
|
27
|
+
let channels = 4;
|
|
28
|
+
const idat: Uint8Array[] = [];
|
|
29
|
+
while (at < bytes.length) {
|
|
30
|
+
const len = view.getUint32(at);
|
|
31
|
+
const type = String.fromCharCode(...bytes.subarray(at + 4, at + 8));
|
|
32
|
+
const data = bytes.subarray(at + 8, at + 8 + len);
|
|
33
|
+
if (type === "IHDR") {
|
|
34
|
+
width = view.getUint32(at + 8);
|
|
35
|
+
height = view.getUint32(at + 12);
|
|
36
|
+
const depth = data[8]!;
|
|
37
|
+
const color = data[9]!;
|
|
38
|
+
if (depth !== 8 || (color !== 6 && color !== 2) || data[12] !== 0) throw new Error(`unsupported PNG (depth ${depth}, colour ${color}, interlace ${data[12]})`);
|
|
39
|
+
channels = color === 6 ? 4 : 3;
|
|
40
|
+
} else if (type === "IDAT") idat.push(data);
|
|
41
|
+
else if (type === "IEND") break;
|
|
42
|
+
at += 12 + len;
|
|
43
|
+
}
|
|
44
|
+
const raw = inflateSync(Buffer.concat(idat));
|
|
45
|
+
const stride = width * channels;
|
|
46
|
+
const out = new Uint8Array(width * height * 4);
|
|
47
|
+
const prev = new Uint8Array(stride);
|
|
48
|
+
const cur = new Uint8Array(stride);
|
|
49
|
+
for (let y = 0; y < height; y++) {
|
|
50
|
+
const filter = raw[y * (stride + 1)]!;
|
|
51
|
+
const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
|
|
52
|
+
for (let i = 0; i < stride; i++) {
|
|
53
|
+
const a = i >= channels ? cur[i - channels]! : 0;
|
|
54
|
+
const b = prev[i]!;
|
|
55
|
+
const c = i >= channels ? prev[i - channels]! : 0;
|
|
56
|
+
let v = line[i]!;
|
|
57
|
+
if (filter === 1) v += a;
|
|
58
|
+
else if (filter === 2) v += b;
|
|
59
|
+
else if (filter === 3) v += (a + b) >> 1;
|
|
60
|
+
else if (filter === 4) {
|
|
61
|
+
const p = a + b - c;
|
|
62
|
+
const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
|
|
63
|
+
v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
64
|
+
}
|
|
65
|
+
cur[i] = v & 255;
|
|
66
|
+
}
|
|
67
|
+
for (let x = 0; x < width; x++) {
|
|
68
|
+
for (let k = 0; k < 4; k++) out[(y * width + x) * 4 + k] = k < channels ? cur[x * channels + k]! : 255;
|
|
69
|
+
}
|
|
70
|
+
prev.set(cur);
|
|
71
|
+
}
|
|
72
|
+
return { width, height, rgba: out };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** og-tests' rule: outlier fraction and RGB RMSE between two equal-size frames. */
|
|
76
|
+
export function difference(a: Image, b: Image): { outliers: number; rmse: number } {
|
|
77
|
+
if (a.width !== b.width || a.height !== b.height) return { outliers: 1, rmse: 1 };
|
|
78
|
+
let outliers = 0;
|
|
79
|
+
let sum = 0;
|
|
80
|
+
const n = a.width * a.height;
|
|
81
|
+
for (let p = 0; p < n; p++) {
|
|
82
|
+
let out = false;
|
|
83
|
+
for (let k = 0; k < 3; k++) {
|
|
84
|
+
const d = Math.abs(a.rgba[p * 4 + k]! - b.rgba[p * 4 + k]!);
|
|
85
|
+
if (d > 32) out = true;
|
|
86
|
+
const f = d / 255;
|
|
87
|
+
sum += f * f;
|
|
88
|
+
}
|
|
89
|
+
if (out) outliers++;
|
|
90
|
+
}
|
|
91
|
+
return { outliers: outliers / n, rmse: Math.sqrt(sum / (n * 3)) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The playtest's output directory (the bundled playtest runs from it). */
|
|
95
|
+
export function outDir(): string {
|
|
96
|
+
return dirname(fileURLToPath(import.meta.url));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The project's references/, from its og-out/test/<name>/. */
|
|
100
|
+
export function referencesDir(): string {
|
|
101
|
+
return resolve(outDir(), "..", "..", "..", "references");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Compares each screenshot with its reference (`<prefix><file>`), or records it. */
|
|
105
|
+
export function checkFrames(run: PlaytestRun, t: PlaytestChecks, files: readonly string[], prefix: string): void {
|
|
106
|
+
// Read only where there is a renderer: without one (tier `none`) each frame is
|
|
107
|
+
// reported "no renderer, frame unchecked" and every other check still runs.
|
|
108
|
+
t.frame(files, () => {
|
|
109
|
+
const refs = referencesDir();
|
|
110
|
+
t.ok(existsSync(refs) || process.env.OG_FX_CAPTURE !== undefined, `the references directory exists at ${refs}`);
|
|
111
|
+
const own = join(refs, run.tier);
|
|
112
|
+
const capture = process.env.OG_FX_CAPTURE !== undefined;
|
|
113
|
+
for (const f of files) {
|
|
114
|
+
const actual = join(outDir(), f);
|
|
115
|
+
// Each file on its own: this tier's reference where it has one, metal's otherwise.
|
|
116
|
+
const mine = join(own, prefix + f);
|
|
117
|
+
const ref = capture || existsSync(mine) ? mine : join(refs, "metal", prefix + f);
|
|
118
|
+
if (ref !== mine) process.stdout.write(` note: no ${run.tier} reference for ${prefix}${f}; comparing with metal\n`);
|
|
119
|
+
if (capture) {
|
|
120
|
+
mkdirSync(own, { recursive: true });
|
|
121
|
+
copyFileSync(actual, ref);
|
|
122
|
+
t.ok(true, `recorded ${ref}`);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (!existsSync(ref)) {
|
|
126
|
+
t.ok(false, `${ref} is missing: record it with OG_FX_CAPTURE=1 og test .`);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const d = difference(decodePng(readFileSync(actual)), decodePng(readFileSync(ref)));
|
|
130
|
+
process.stdout.write(` ${f}: ${(d.outliers * 100).toFixed(3)} % outliers, RMSE ${d.rmse.toFixed(5)} against ${ref}\n`);
|
|
131
|
+
t.ok(d.outliers <= 0.005 && d.rmse <= 0.01, `${f} matches ${prefix}${f}: ${(d.outliers * 100).toFixed(3)} % outliers (≤ 0.5 %), RMSE ${d.rmse.toFixed(5)} (≤ 0.01)`);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// The intro cutscene (assets/timelines/intro.ogt) played from the command
|
|
2
|
+
// channel: `timeline:play:intro` makes the entity `timeline-intro`, and the
|
|
3
|
+
// timeline drives the camera along the "flyby" rail, walks the trickster
|
|
4
|
+
// with its clips, shows the narration from narration.srt, plays two sounds,
|
|
5
|
+
// runs `spawn_wave:2` at 5.5 s and fades in and out. At 4 s and 8 s the
|
|
6
|
+
// caption on screen is the .srt's cue for that time and the frames are
|
|
7
|
+
// compared with references/<tier>/intro-4.png and intro-8.png
|
|
8
|
+
// (tests/frames.ts; `OG_FX_CAPTURE=1 og test .` records them). After the end
|
|
9
|
+
// the camera is the game's again.
|
|
10
|
+
import { playtest } from "@origingame/engine/playtest";
|
|
11
|
+
import { checkFrames } from "./frames.ts";
|
|
12
|
+
|
|
13
|
+
interface EntityDump {
|
|
14
|
+
components: Record<string, Record<string, number | boolean | string | number[]>>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface UiDump {
|
|
18
|
+
elements: Array<{ id: string; text: string; x: number; y: number; width: number; height: number }>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const FILES = ["intro-4.png", "intro-8.png"] as const;
|
|
22
|
+
|
|
23
|
+
export default playtest({
|
|
24
|
+
name: "the intro plays its rail camera, captions, clips, event and fades from timeline:play:intro",
|
|
25
|
+
size: "1280x720",
|
|
26
|
+
commands: [
|
|
27
|
+
"0:timeline:play:intro",
|
|
28
|
+
"4:screenshot:intro-4.png",
|
|
29
|
+
"4:dump_ui:ui-4.json",
|
|
30
|
+
"4:dump_entity:timeline-intro:timeline-4.json",
|
|
31
|
+
"4:dump_entity:trickster:trickster-4.json",
|
|
32
|
+
"8:screenshot:intro-8.png",
|
|
33
|
+
"8:dump_ui:ui-8.json",
|
|
34
|
+
"8:dump_world:world-8.json",
|
|
35
|
+
"10.5:dump_entity:timeline-intro:timeline-end.json",
|
|
36
|
+
"10.5:dump_ui:ui-end.json",
|
|
37
|
+
"10.6:quit",
|
|
38
|
+
].join(";"),
|
|
39
|
+
check(run, t) {
|
|
40
|
+
t.equal(run.warnings.join("; "), "", "the script ran without warnings");
|
|
41
|
+
t.ok(run.quit, "the script reached quit");
|
|
42
|
+
const caption = (file: string) =>
|
|
43
|
+
run.json<UiDump>(file).elements.find((e) => e.id.startsWith("timeline-caption-") && e.id.endsWith("-text"));
|
|
44
|
+
const at4 = caption("ui-4.json");
|
|
45
|
+
t.equal(at4?.text, "The trickster walks out to meet it.", "at 4 s the caption is the .srt's second cue");
|
|
46
|
+
t.ok(at4 !== undefined && at4.y > 600, `the caption sits at the bottom (y ${at4?.y})`);
|
|
47
|
+
t.equal(caption("ui-8.json")?.text, "Here they come.", "at 8 s the caption is the third cue");
|
|
48
|
+
const tl = run.json<EntityDump>("timeline-4.json").components["og.Timeline"]!;
|
|
49
|
+
t.equal(tl["playing"], true, "the timeline plays at 4 s");
|
|
50
|
+
t.near(Number(tl["time"]), 4, 0.02, "its time is the run's (it started at tick 0)");
|
|
51
|
+
// The transform track walks the trickster from (2 0 0) at 2 s to (0.5 0 1.5) at 5 s.
|
|
52
|
+
const p = run.json<EntityDump>("trickster-4.json").components["og.Transform"]!["position"] as number[];
|
|
53
|
+
t.ok(p[0]! < 2 && p[0]! > 0.5 && p[2]! > 0 && p[2]! < 1.5, `the trickster is on its way at 4 s (${p.join(", ")})`);
|
|
54
|
+
const world = run.json<{ entities: number }>("world-8.json");
|
|
55
|
+
t.ok(world.entities >= 10, `the event key at 5.5 s ran spawn_wave:2 (${world.entities} entities)`);
|
|
56
|
+
const end = run.json<EntityDump>("timeline-end.json").components["og.Timeline"]!;
|
|
57
|
+
t.equal(end["playing"], false, "the timeline ended");
|
|
58
|
+
t.equal(Number(end["time"]), 10, "at its duration");
|
|
59
|
+
t.equal(caption("ui-end.json"), undefined, "no caption after the end");
|
|
60
|
+
t.frame(FILES, () => {
|
|
61
|
+
for (const f of FILES) t.equal(run.png(f).width, 1280, `${f} is 1280 wide`);
|
|
62
|
+
});
|
|
63
|
+
checkFrames(run, t, FILES, "");
|
|
64
|
+
},
|
|
65
|
+
});
|
|
@@ -1,43 +1,37 @@
|
|
|
1
1
|
// The template's playtest, run by `og test` through `og run --headless
|
|
2
2
|
// --commands`: a tap on the menu's Start button (found by its UI id, no key
|
|
3
3
|
// fallback) starts the trickster spinning and drops the crate onto the
|
|
4
|
-
// ground
|
|
4
|
+
// ground. Every check is an `expect:` step evaluated in the run at its tick
|
|
5
|
+
// (docs/design.md §3.4): a failed one fails the run with the tick and what
|
|
6
|
+
// it observed, and a frame one without a renderer is reported unchecked.
|
|
7
|
+
// The frame, the scene described as data, the dumps and the UI tree are
|
|
8
|
+
// written beside them.
|
|
5
9
|
import { playtest } from "@origingame/engine/playtest";
|
|
6
10
|
|
|
7
|
-
interface EntityDump {
|
|
8
|
-
components: Record<string, Record<string, number | boolean | string | number[]>>;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
interface UiDump {
|
|
12
|
-
elements: Array<{ id: string; parent: string; text: string; x: number; y: number; width: number; height: number }>;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
11
|
export default playtest({
|
|
16
|
-
name: "A tap on Start spins the trickster and drops the crate; the run writes its frame, dumps and UI tree",
|
|
17
|
-
commands:
|
|
18
|
-
"0.5:touch_move_to:Start
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
t.ok(y > 0.4 && y < 0.6, `the crate fell from 4 m and rests on the ground (y ${y.toFixed(3)})`);
|
|
42
|
-
},
|
|
12
|
+
name: "A tap on Start spins the trickster and drops the crate; the run writes its frame, description, dumps and UI tree",
|
|
13
|
+
commands: [
|
|
14
|
+
"0.5:touch_move_to:Start",
|
|
15
|
+
"0.6:touch_start",
|
|
16
|
+
"0.7:touch_end",
|
|
17
|
+
// The tap was one press; the run started; the button says so.
|
|
18
|
+
"0.8:expect:event:og.PointerDown:1",
|
|
19
|
+
"0.8:expect:state:started==true",
|
|
20
|
+
"0.9:expect:ui:text:=Started",
|
|
21
|
+
// The trickster spins; the crate fell from 4 m and rests on the ground.
|
|
22
|
+
"2.0:expect:state:spin>0",
|
|
23
|
+
"2.0:expect:entity:crate:within:-3,0.4,-1,1,0.6,2",
|
|
24
|
+
"2.0:expect:event:og.CollisionStarted",
|
|
25
|
+
// The frame (1280x720): the sky at the top, the crate and the trickster where they stand.
|
|
26
|
+
"2.0:screenshot:smoke.png",
|
|
27
|
+
"2.0:expect:region:560,16,64,24:color:#9da5b1:24",
|
|
28
|
+
"2.0:expect:entity:crate:within:380,360,280,240",
|
|
29
|
+
"2.0:expect:entity:trickster:within:640,300,240,280",
|
|
30
|
+
"2.1:describe:describe.json",
|
|
31
|
+
"2.1:dump_entity:trickster:trickster.json",
|
|
32
|
+
"2.15:dump_entity:crate:crate.json",
|
|
33
|
+
"2.2:dump_world:world.json",
|
|
34
|
+
"2.3:dump_ui:ui.json",
|
|
35
|
+
"2.5:quit",
|
|
36
|
+
].join(";"),
|
|
43
37
|
});
|
|
@@ -90,7 +90,8 @@ export function mount(roots: Roots, production: string, write: boolean): MountRe
|
|
|
90
90
|
if (exists && lstatSync(target).isDirectory()) throw new Error(`${f.path}: a directory in the platform tree where the package has a file`);
|
|
91
91
|
const same = exists && Buffer.compare(readFileSync(target), Buffer.from(f.bytes)) === 0;
|
|
92
92
|
const mode = executable(f.source) ? 0o755 : 0o644;
|
|
93
|
-
|
|
93
|
+
// Windows chmod cannot represent POSIX group/other or executable bits.
|
|
94
|
+
const modeSame = exists && (process.platform === "win32" || (statSync(target).mode & 0o777) === mode);
|
|
94
95
|
if (same && modeSame) continue;
|
|
95
96
|
changes.push({ kind: exists ? "changed" : "added", path: f.path });
|
|
96
97
|
if (write) {
|
|
@@ -44,7 +44,20 @@ export function readRules(pkg: string): Rules {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
/** The skills this package ships. */
|
|
47
|
-
export const SKILLS = ["og-ai", "og-audio", "og-build", "og-core", "og-feel", "og-game", "og-i18n", "og-platform-sdk", "og-taste", "og-ui"] as const;
|
|
47
|
+
export const SKILLS = ["og-ai", "og-audio", "og-build", "og-core", "og-feel", "og-game", "og-i18n", "og-model", "og-platform-sdk", "og-taste", "og-ui"] as const;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The guides this package ships: hand-written pages in guides/, mounted
|
|
51
|
+
* beside og-game's SKILL.md (knowledge.json), each named by og-game and
|
|
52
|
+
* held to the skills' rules (API names, section references, tools).
|
|
53
|
+
*/
|
|
54
|
+
export const GUIDES = ["three-to-og"] as const;
|
|
55
|
+
|
|
56
|
+
/** Where knowledge.json mounts guides/ in the production tree. */
|
|
57
|
+
export const GUIDE_MOUNT = "game/skills/og-game/guides/";
|
|
58
|
+
|
|
59
|
+
/** The og-cli source that cites the guides by section, in the engine repository. */
|
|
60
|
+
export const GUIDE_CITERS = ["crates/og-cli/src/migrate.rs"] as const;
|
|
48
61
|
|
|
49
62
|
/** What a scaffolded project must contain (scaffold/README.md describes each). */
|
|
50
63
|
export const TEMPLATE_FILES = ["project.ogt", "src/main.ts", "package.json", "tsconfig.json", "tests/smoke.og.ts"] as const;
|
|
@@ -79,7 +92,9 @@ export function validate(roots: Roots = defaultRoots()): string[] {
|
|
|
79
92
|
}
|
|
80
93
|
|
|
81
94
|
skills(rules, files, problems);
|
|
95
|
+
guideFiles(roots, rules, files, problems);
|
|
82
96
|
coherence(files, problems);
|
|
97
|
+
skinWord(roots, files, problems);
|
|
83
98
|
apiNames(roots, files, problems);
|
|
84
99
|
looks(roots, files, problems);
|
|
85
100
|
grammar(roots, files, problems);
|
|
@@ -91,7 +106,7 @@ function packageJson(roots: Roots, problems: string[]): void {
|
|
|
91
106
|
const p = JSON.parse(readFileSync(join(roots.pkg, "package.json"), "utf8")) as { name?: string; version?: string; files?: string[]; scripts?: Record<string, string> };
|
|
92
107
|
if (p.name !== "@origingame/engine-knowledge") problems.push(`package.json: name is ${p.name}; the package is @origingame/engine-knowledge (design §9)`);
|
|
93
108
|
if (!/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(p.version ?? "")) problems.push(`package.json: version ${p.version} is not semver`);
|
|
94
|
-
for (const f of ["wiki", "skills", "looks", "scaffold", "rules", "knowledge.json"]) {
|
|
109
|
+
for (const f of ["wiki", "skills", "guides", "looks", "scaffold", "rules", "knowledge.json"]) {
|
|
95
110
|
if (!p.files?.some((x) => x === f || x === `${f}/`)) problems.push(`package.json: files does not publish ${f}`);
|
|
96
111
|
}
|
|
97
112
|
if (!p.scripts?.test) problems.push("package.json: no test script, so scripts/check.sh bun-test would skip the package");
|
|
@@ -165,6 +180,46 @@ function skills(rules: Rules, files: readonly PayloadFile[], problems: string[])
|
|
|
165
180
|
for (const s of SKILLS) if (!found.has(s)) problems.push(`skills/${s}/SKILL.md: missing`);
|
|
166
181
|
}
|
|
167
182
|
|
|
183
|
+
/**
|
|
184
|
+
* The guides as files: guides/ holds exactly GUIDES, each reaches the
|
|
185
|
+
* payload at GUIDE_MOUNT, names no tool a Session lacks and no wiki page
|
|
186
|
+
* the IDL does not generate, and every `three-to-og §N` the og-cli source
|
|
187
|
+
* cites (GUIDE_CITERS: `og migrate three` points to its sections) is a
|
|
188
|
+
* numbered section of the guide.
|
|
189
|
+
*/
|
|
190
|
+
function guideFiles(roots: Roots, rules: Rules, files: readonly PayloadFile[], problems: string[]): void {
|
|
191
|
+
const dir = join(roots.pkg, "guides");
|
|
192
|
+
const present = existsSync(dir) ? readdirSync(dir).filter((n) => n.endsWith(".md")).map((n) => n.slice(0, -3)) : [];
|
|
193
|
+
for (const g of present) if (!(GUIDES as readonly string[]).includes(g)) problems.push(`guides/${g}.md: not in GUIDES (scripts/validate.ts); list it there and name it from og-game, or remove it`);
|
|
194
|
+
const pages = new Set(files.map((f) => /^game\/skills\/og-game\/reference\/([^/]+)$/.exec(f.path)?.[1]).filter((n): n is string => n !== undefined));
|
|
195
|
+
const repo = join(roots.pkg, "..", "..");
|
|
196
|
+
for (const g of GUIDES) {
|
|
197
|
+
const f = files.find((x) => x.path === `${GUIDE_MOUNT}${g}.md`);
|
|
198
|
+
if (!f) {
|
|
199
|
+
problems.push(`guides/${g}.md: missing, or not mounted at ${GUIDE_MOUNT} (knowledge.json mounts guides/ there)`);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (!isText(f.bytes)) continue;
|
|
203
|
+
const text = decoder.decode(f.bytes);
|
|
204
|
+
for (const t of namedTools(text, rules)) problems.push(`guides/${g}.md: names \`${t}\`, which is no tool a Session can be offered; rename it or put it in a code block if it is not a tool`);
|
|
205
|
+
for (const page of new Set([...text.matchAll(/\breference\/([a-z0-9-]+\.md)\b/g)].map((m) => m[1]!))) {
|
|
206
|
+
if (!pages.has(page)) problems.push(`guides/${g}.md: names reference/${page}, which the generated wiki does not have (a module's page is reference/<module>.md)`);
|
|
207
|
+
}
|
|
208
|
+
const have = new Set([...text.matchAll(/^## (\d+)\. /gm)].map((m) => m[1]!));
|
|
209
|
+
for (const citer of GUIDE_CITERS) {
|
|
210
|
+
const path = join(repo, citer);
|
|
211
|
+
if (!existsSync(join(repo, "crates"))) break; // the published package: the sources are not beside it
|
|
212
|
+
if (!existsSync(path)) {
|
|
213
|
+
problems.push(`${citer}: missing; GUIDE_CITERS in scripts/validate.ts names it`);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
for (const m of readFileSync(path, "utf8").matchAll(new RegExp(`${g} §(\\d+)`, "g"))) {
|
|
217
|
+
if (!have.has(m[1]!)) problems.push(`${citer}: cites ${g} §${m[1]}, which guides/${g}.md does not have (its sections: ${[...have].join(", ")})`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
168
223
|
function scaffold(roots: Roots, rules: Rules, overlay: string, files: readonly PayloadFile[], byPath: Map<string, PayloadFile>, problems: string[]): void {
|
|
169
224
|
const prefix = `game-scaffold/${overlay}/`;
|
|
170
225
|
if (rules.scaffold.existingOverlays.includes(overlay)) {
|
|
@@ -255,7 +310,15 @@ function coherence(files: readonly PayloadFile[], problems: string[]): void {
|
|
|
255
310
|
const t = textOf(`game/skills/${s}/SKILL.md`);
|
|
256
311
|
if (t !== undefined) texts.set(s, t);
|
|
257
312
|
}
|
|
258
|
-
|
|
313
|
+
// The guides (guides/<name>.md, mounted beside og-game's SKILL.md) are
|
|
314
|
+
// held to the same section references; their owner names each.
|
|
315
|
+
const guides = new Map<string, string>();
|
|
316
|
+
for (const g of GUIDES) {
|
|
317
|
+
const t = textOf(`${GUIDE_MOUNT}${g}.md`);
|
|
318
|
+
if (t !== undefined) guides.set(g, t);
|
|
319
|
+
}
|
|
320
|
+
const numbered = (t: string) => new Set([...t.matchAll(/^## (\d+)\. /gm)].map((m) => m[1]!));
|
|
321
|
+
const sections = new Map([...texts, ...guides].map(([s, t]) => [s, numbered(t)]));
|
|
259
322
|
for (const [module, skill] of home) {
|
|
260
323
|
const t = texts.get(skill);
|
|
261
324
|
if (t !== undefined && !t.includes(`reference/${module}.md`)) problems.push(`skills/${skill}/SKILL.md: teaches the ${module} module (wiki/index.md) but never names reference/${module}.md`);
|
|
@@ -265,22 +328,76 @@ function coherence(files: readonly PayloadFile[], problems: string[]): void {
|
|
|
265
328
|
const owner = home.get(m);
|
|
266
329
|
if (owner && owner !== skill) problems.push(`skills/${skill}/SKILL.md: names reference/${m}.md, which ${owner} teaches; point to ${owner} instead, so each module is taught in one place`);
|
|
267
330
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
331
|
+
}
|
|
332
|
+
for (const [name, t] of [...texts, ...guides]) {
|
|
333
|
+
const at = guides.has(name) ? `guides/${name}.md` : `skills/${name}/SKILL.md`;
|
|
334
|
+
problems.push(...sectionRefs(at, name, t, sections));
|
|
335
|
+
}
|
|
336
|
+
// What the guides say of a page, and who names them.
|
|
337
|
+
const names = (t: string) => new Set([...t.matchAll(/\bguides\/([a-z0-9-]+)\.md\b/g)].map((m) => m[1]!));
|
|
338
|
+
for (const [name, t] of [...texts, ...guides]) {
|
|
339
|
+
const at = guides.has(name) ? `guides/${name}.md` : `skills/${name}/SKILL.md`;
|
|
340
|
+
for (const g of names(t)) if (!(GUIDES as readonly string[]).includes(g)) problems.push(`${at}: names guides/${g}.md, which this package does not ship (its guides: ${GUIDES.join(", ")})`);
|
|
341
|
+
}
|
|
342
|
+
for (const g of guides.keys()) {
|
|
343
|
+
if (!texts.get("og-game")?.includes(`guides/${g}.md`)) problems.push(`skills/og-game/SKILL.md: never names guides/${g}.md, so no agent finds the guide; name it where its subject is taught`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Every section reference of one text (`og-feel §4`, or `§4` for its own,
|
|
349
|
+
* `three-to-og §2` for a guide's) names a numbered `## N.` section that
|
|
350
|
+
* exists: `at` names the text in the messages, `own` the text itself.
|
|
351
|
+
*/
|
|
352
|
+
export function sectionRefs(at: string, own: string, text: string, sections: ReadonlyMap<string, ReadonlySet<string>>): string[] {
|
|
353
|
+
const problems: string[] = [];
|
|
354
|
+
const prose = text.replace(/^```[\s\S]*?^```/gm, "");
|
|
355
|
+
for (const m of prose.matchAll(/(og(?:-[a-z]+)+|three-to-og) §([A-Za-z][\w ]*)/g)) problems.push(`${at}: refers to ${m[1]} §${m[2]!.split(" ")[0]} by name; refer to a skill's sections by their numbers (## N.)`);
|
|
356
|
+
for (const m of prose.matchAll(/§(\d+)/g)) {
|
|
357
|
+
// The text this § belongs to: walk back over a list of §s ("og-feel
|
|
358
|
+
// §9–§11", "og-taste §4, §10") to the word before it.
|
|
359
|
+
const before = prose.slice(0, m.index).split(/\s+/);
|
|
360
|
+
let i = before.length - 1;
|
|
361
|
+
while (i >= 0 && (before[i] === "" || /^\(?§\d+(\.\d+)?[–,;)]*$/.test(before[i]!) || before[i] === "and" || before[i] === "or")) i--;
|
|
362
|
+
const word = (before[i] ?? "").replace(/^[(`*]+/, "");
|
|
363
|
+
let target: string | undefined = own;
|
|
364
|
+
if (/^og-[a-z]+(?:-[a-z]+)*$/.test(word) || (GUIDES as readonly string[]).includes(word)) target = word;
|
|
365
|
+
else if (/\.md\b|\//.test(word) || /^design/.test(word)) target = undefined;
|
|
366
|
+
if (target === undefined) continue;
|
|
367
|
+
const have = sections.get(target);
|
|
368
|
+
if (!have) problems.push(`${at}: refers to ${target} §${m[1]}, and there is no skill ${target}`);
|
|
369
|
+
else if (!have.has(m[1]!)) problems.push(`${at}: refers to ${target === own ? "its own" : target} §${m[1]}, which ${target === own ? "it does not have" : "does not exist there"} (its sections: ${[...have].join(", ") || "none numbered"})`);
|
|
370
|
+
}
|
|
371
|
+
return problems;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* What a sentence that says "skin" must also name for the word to be one of
|
|
376
|
+
* its other meanings: a glTF rig's skin (joints, morphs, a model), the
|
|
377
|
+
* character controllers' collision gap (the `skin` field, off a surface),
|
|
378
|
+
* a 2D skeleton's skin (a rig2d `skin` block swapping a slot's attachment,
|
|
379
|
+
* `og.Skeleton2D.skin`, an armature's setup pose), or a surface (a skin
|
|
380
|
+
* tone, peach skin, a crowd's or a tile set's looks).
|
|
381
|
+
*/
|
|
382
|
+
export const SKIN_ELSEWHERE = /gltf|joint|skelet|morph|\brig\b|bone|mesh|model|clips?\b|anim|`skins?`|slot|attach|armature|\bswap\b|setup pose|surface|\bgap\b|collid|\btones?\b|peach|crowd|tiles/i;
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* A skill or wiki page never calls a UI style a "skin": og-ui's word is
|
|
386
|
+
* style (`type style`, `og.load.style`, `UiDesc.style`, `styles/<name>/`).
|
|
387
|
+
* Each sentence holding `skin` or `skins` must name one of the word's
|
|
388
|
+
* other meanings (SKIN_ELSEWHERE).
|
|
389
|
+
*/
|
|
390
|
+
function skinWord(roots: Roots, files: readonly PayloadFile[], problems: string[]): void {
|
|
391
|
+
for (const f of files) {
|
|
392
|
+
if (!f.path.startsWith("game/skills/") || !f.path.endsWith(".md") || !isText(f.bytes)) continue;
|
|
393
|
+
const name = relative(roots.pkg, f.source).split("\\").join("/");
|
|
394
|
+
for (const paragraph of decoder.decode(f.bytes).split(/\n\s*\n/)) {
|
|
395
|
+
// Sentences, and a table's cells, of the paragraph's joined lines.
|
|
396
|
+
for (const sentence of paragraph.replace(/\s*\n\s*/g, " ").split(/(?<=[.;!?])\s+|\s\|\s/)) {
|
|
397
|
+
if (/\bskins?\b/i.test(sentence) && !SKIN_ELSEWHERE.test(sentence)) {
|
|
398
|
+
problems.push(`${name}: calls a UI style a skin ("${sentence.trim().slice(0, 120)}"); og-ui's word is style (type style, og.load.style, styles/<name>/)`);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
284
401
|
}
|
|
285
402
|
}
|
|
286
403
|
}
|
|
@@ -312,6 +429,7 @@ export const NOT_FIELDS: Record<string, string> = {
|
|
|
312
429
|
runs: "PlaytestDesc.runs (packages/engine/src/playtest.ts)",
|
|
313
430
|
then: "PlaytestDesc.then (packages/engine/src/playtest.ts)",
|
|
314
431
|
store: "PlaytestRun.store (packages/engine/src/playtest.ts)",
|
|
432
|
+
setup: "PlaytestDesc.setup (packages/engine/src/playtest.ts)",
|
|
315
433
|
};
|
|
316
434
|
|
|
317
435
|
export function readApiNames(engine: string): ApiNames | undefined {
|
|
@@ -353,8 +471,9 @@ function apiNames(roots: Roots, files: readonly PayloadFile[], problems: string[
|
|
|
353
471
|
if (!api) return;
|
|
354
472
|
for (const f of files) {
|
|
355
473
|
const m = /^game\/skills\/([^/]+)\/SKILL\.md$/.exec(f.path);
|
|
356
|
-
|
|
357
|
-
|
|
474
|
+
const guide = f.path.startsWith(GUIDE_MOUNT) && f.path.endsWith(".md") ? f.path.slice(GUIDE_MOUNT.length) : undefined;
|
|
475
|
+
if ((!m && !guide) || !isText(f.bytes)) continue;
|
|
476
|
+
const at = guide ? `guides/${guide}` : `skills/${m![1]}/SKILL.md`;
|
|
358
477
|
const text = decoder.decode(f.bytes);
|
|
359
478
|
const bad = new Set<string>();
|
|
360
479
|
for (const x of text.matchAll(/(?<![\w./@-])og\.([A-Za-z_]\w*)(?:\.([A-Za-z_]\w*))?/g)) {
|
|
@@ -399,7 +518,7 @@ interface Style {
|
|
|
399
518
|
checks: string[];
|
|
400
519
|
premise: Record<string, string>;
|
|
401
520
|
look: { preset: string; presetReady: boolean; support: string; primitives: string[]; missing: string[] };
|
|
402
|
-
|
|
521
|
+
ui: string;
|
|
403
522
|
palette: { size: string; rules: string };
|
|
404
523
|
assets: Record<string, string>;
|
|
405
524
|
artBible: { direction: string; avoid: string[] };
|
|
@@ -408,7 +527,7 @@ interface Style {
|
|
|
408
527
|
/** What `look.support` may say: full (every primitive draws), partial or planned (`missing` names what is still to land), content (the look is authored art). */
|
|
409
528
|
export const SUPPORT = ["full", "partial", "planned", "content"] as const;
|
|
410
529
|
/** The placeholders the art-bible template may use. */
|
|
411
|
-
export const BIBLE_FIELDS = ["title", "premise", "camera", "platform", "name", "descriptor", "direction", "palette", "assets", "
|
|
530
|
+
export const BIBLE_FIELDS = ["title", "premise", "camera", "platform", "name", "descriptor", "direction", "palette", "assets", "ui", "avoid", "game", "slug", "checks"] as const;
|
|
412
531
|
const PREMISE = ["genre", "mood", "era", "audience"];
|
|
413
532
|
const ASSET_SPECS = ["logicalResolution", "spriteScale", "faceBudget", "outlinePx", "textureDensity"];
|
|
414
533
|
|
|
@@ -428,7 +547,7 @@ function looks(roots: Roots, files: readonly PayloadFile[], problems: string[]):
|
|
|
428
547
|
problems.push(`${at}: missing; og-taste §12 reads the style catalogue from it`);
|
|
429
548
|
return;
|
|
430
549
|
}
|
|
431
|
-
let cat: { schemaVersion?: number; categories?: Record<string, string>;
|
|
550
|
+
let cat: { schemaVersion?: number; categories?: Record<string, string>; ui?: string[]; artBibleTemplate?: string; styles?: Style[] };
|
|
432
551
|
try {
|
|
433
552
|
cat = JSON.parse(readFileSync(path, "utf8"));
|
|
434
553
|
} catch (e) {
|
|
@@ -437,7 +556,10 @@ function looks(roots: Roots, files: readonly PayloadFile[], problems: string[]):
|
|
|
437
556
|
}
|
|
438
557
|
if (cat.schemaVersion !== 1) problems.push(`${at}: schemaVersion ${cat.schemaVersion}, expected 1`);
|
|
439
558
|
const categories = Object.keys(cat.categories ?? {});
|
|
440
|
-
const
|
|
559
|
+
const packs = cat.ui ?? [];
|
|
560
|
+
for (const p of packs) {
|
|
561
|
+
if (existsSync(join(roots.pkg, "..", "..", "templates")) && !existsSync(join(roots.pkg, "..", "..", "templates", "styles", p, "style.ogt"))) problems.push(`${at}: ui names ${p}, which is not a built-in style (templates/styles/${p}/style.ogt)`);
|
|
562
|
+
}
|
|
441
563
|
for (const m of (cat.artBibleTemplate ?? "").matchAll(/\{(\w+)\}/g)) {
|
|
442
564
|
if (!(BIBLE_FIELDS as readonly string[]).includes(m[1]!)) problems.push(`${at}: artBibleTemplate uses {${m[1]}}, which is none of ${BIBLE_FIELDS.join(", ")}`);
|
|
443
565
|
}
|
|
@@ -454,9 +576,9 @@ function looks(roots: Roots, files: readonly PayloadFile[], problems: string[]):
|
|
|
454
576
|
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(s.slug ?? "")) problems.push(`${id}: slug is not lowercase-hyphen`);
|
|
455
577
|
if (seen.has(s.slug)) problems.push(`${id}: a second style with this slug`);
|
|
456
578
|
seen.add(s.slug);
|
|
457
|
-
for (const k of ["name", "descriptor", "
|
|
579
|
+
for (const k of ["name", "descriptor", "ui"] as const) if (!text(s[k])) problems.push(`${id}: no ${k}`);
|
|
458
580
|
if (!categories.includes(s.category)) problems.push(`${id}: category ${s.category} is none of ${categories.join(", ")}`);
|
|
459
|
-
if (!
|
|
581
|
+
if (!packs.includes(s.ui)) problems.push(`${id}: ui ${s.ui} is none of the built-in styles (${packs.join(", ")})`);
|
|
460
582
|
if (!Array.isArray(s.checks) || s.checks.length !== 3 || !s.checks.every(text)) problems.push(`${id}: needs exactly three checks a critic can judge on a frame`);
|
|
461
583
|
for (const k of PREMISE) if (!text(s.premise?.[k])) problems.push(`${id}: premise has no ${k}`);
|
|
462
584
|
for (const k of ASSET_SPECS) if (!text(s.assets?.[k])) problems.push(`${id}: assets has no ${k}`);
|
|
@@ -482,15 +604,21 @@ function looks(roots: Roots, files: readonly PayloadFile[], problems: string[]):
|
|
|
482
604
|
const preset = join(repo, look.preset);
|
|
483
605
|
if (look.presetReady) {
|
|
484
606
|
if (existsSync(join(repo, "templates")) && !existsSync(preset)) problems.push(`${id}: presetReady, but ${look.preset} does not exist`);
|
|
485
|
-
else if (existsSync(preset)
|
|
607
|
+
else if (existsSync(preset)) {
|
|
608
|
+
const body = readFileSync(preset, "utf8");
|
|
609
|
+
if (!/^type look\s*$/m.test(body)) problems.push(`${id}: ${look.preset} is not an .ogt of type look`);
|
|
610
|
+
// The preset dresses the UI in the style the catalogue gives it.
|
|
611
|
+
const ui = /^ui[ \t]+"([^"]+)"/m.exec(body)?.[1];
|
|
612
|
+
if (ui !== s.ui) problems.push(`${id}: ${look.preset} has ${ui ? `ui "${ui}"` : "no ui line"}; the catalogue says ui "${s.ui}"`);
|
|
613
|
+
}
|
|
486
614
|
}
|
|
487
615
|
if (tasteText && !tasteText.includes(`\`${s.slug}\``)) problems.push(`skills/og-taste/SKILL.md: the style catalogue (§12) does not list \`${s.slug}\``);
|
|
488
616
|
else if (tasteText) {
|
|
489
|
-
// §12's row for the style says what the catalogue says: family, name, support,
|
|
617
|
+
// §12's row for the style says what the catalogue says: family, name, support, ui style.
|
|
490
618
|
const row = new RegExp(`^\\| \`${s.slug.replace(/[-]/g, "\\-")}\` \\| ([^|]+) \\| ([^|]+) \\| ([^|]+) \\| \`([^\`]+)\` \\|$`, "m").exec(tasteText);
|
|
491
|
-
const want = [cat.categories?.[s.category] ?? s.category, s.name, look.support, s.
|
|
619
|
+
const want = [cat.categories?.[s.category] ?? s.category, s.name, look.support, s.ui];
|
|
492
620
|
const have = row?.slice(1, 5).map((x) => x.trim());
|
|
493
|
-
if (!have) problems.push(`skills/og-taste/SKILL.md: §12's table has no row for \`${s.slug}\` (| slug | family | style | support |
|
|
621
|
+
if (!have) problems.push(`skills/og-taste/SKILL.md: §12's table has no row for \`${s.slug}\` (| slug | family | style | support | ui |)`);
|
|
494
622
|
else if (have.join("|") !== want.join("|")) problems.push(`skills/og-taste/SKILL.md: §12's row for \`${s.slug}\` reads ${have.join(", ")}; the catalogue says ${want.join(", ")}`);
|
|
495
623
|
}
|
|
496
624
|
const all = JSON.stringify(s);
|
|
@@ -58,14 +58,14 @@ game's style needs it; presets are a floor, not a style.
|
|
|
58
58
|
## 3. UI sounds
|
|
59
59
|
|
|
60
60
|
`og.ui.configure(world, { sounds: { hover, press, open, close, success, error } })`
|
|
61
|
-
(or the
|
|
61
|
+
(or the style's `sound` lines) plays them on the `ui` bus: `click` for
|
|
62
62
|
press, `select` for success, `back` for close, `error` for a refused press
|
|
63
63
|
(and for `og.ui.shake({ sound: true })`, og-ui §6). Each starts in the
|
|
64
64
|
tick of its input. `hover` also answers keyboard and gamepad focus moving
|
|
65
65
|
to a control, and `press` a press by Enter, Space or the pad's A, so a
|
|
66
66
|
menu walked with a pad is heard step by step; keep `hover` short and quiet
|
|
67
67
|
(volume ≤ 0.3). `configure` changes only the fields it is given: setting
|
|
68
|
-
the sounds after a
|
|
68
|
+
the sounds after a style (or a look that brings one) keeps the style and its
|
|
69
69
|
fonts, and `sounds: null` silences them again.
|
|
70
70
|
|
|
71
71
|
## 4. Buses and the mix
|