@origingame/engine 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/og.mjs +25 -0
- package/dist/web/origin-engine.d.ts +1233 -0
- package/dist/web/origin-engine.js +8628 -0
- package/dist/web/origin-engine_bg.wasm +0 -0
- package/dist/web/origin-engine_bg.wasm.d.ts +366 -0
- package/dist/web-publish/origin-engine-lanes.js +8509 -0
- package/dist/web-publish/origin-engine-lanes_bg.wasm +0 -0
- package/dist/web-publish/origin-engine.d.ts +1176 -0
- package/dist/web-publish/origin-engine.js +8313 -0
- package/dist/web-publish/origin-engine_bg.wasm +0 -0
- package/dist/web-publish/origin-engine_bg.wasm.d.ts +347 -0
- package/dist/web-publish/snippets/wasm-bindgen-rayon-38edf6e439f6d70d/src/workerHelpers.no-bundler.js +77 -0
- package/dist/web-publish-2d/origin-engine-lanes.js +8509 -0
- package/dist/web-publish-2d/origin-engine-lanes_bg.wasm +0 -0
- package/dist/web-publish-2d/origin-engine.d.ts +1176 -0
- package/dist/web-publish-2d/origin-engine.js +8313 -0
- package/dist/web-publish-2d/origin-engine_bg.wasm +0 -0
- package/dist/web-publish-2d/origin-engine_bg.wasm.d.ts +347 -0
- package/dist/web-publish-2d/snippets/wasm-bindgen-rayon-38edf6e439f6d70d/src/workerHelpers.no-bundler.js +77 -0
- package/knowledge/README.md +31 -0
- package/knowledge/knowledge.json +13 -0
- package/knowledge/looks/catalogue.json +1871 -0
- package/knowledge/package.json +33 -0
- package/knowledge/rules/platform.json +329 -0
- package/knowledge/scaffold/README.md +27 -0
- package/knowledge/scaffold/distribution.json +48 -0
- package/knowledge/scaffold/origin-engine.json +19 -0
- package/knowledge/scaffold/template/.origin/setup +14 -0
- package/knowledge/scaffold/template/LICENSES.md +23 -0
- package/knowledge/scaffold/template/README.md +37 -0
- package/knowledge/scaffold/template/assets/ai/guard.ogt +48 -0
- package/knowledge/scaffold/template/assets/audio/click.ogt +9 -0
- package/knowledge/scaffold/template/assets/fonts/NotoSansSC-Regular-subset.ttf.ogt +12 -0
- package/knowledge/scaffold/template/assets/fonts/OFL.txt +93 -0
- package/knowledge/scaffold/template/assets/looks/origin-game.ogt +12 -0
- package/knowledge/scaffold/template/assets/nav/yard.ogt +27 -0
- package/knowledge/scaffold/template/assets/scenes/main.ogt +34 -0
- package/knowledge/scaffold/template/assets/strings/en.ogt +14 -0
- package/knowledge/scaffold/template/assets/textures/stone_n.png.ogt +7 -0
- package/knowledge/scaffold/template/gitattributes +22 -0
- package/knowledge/scaffold/template/package.json +18 -0
- package/knowledge/scaffold/template/project.ogt +25 -0
- package/knowledge/scaffold/template/src/main.ts +246 -0
- package/knowledge/scaffold/template/tests/guard.og.ts +67 -0
- package/knowledge/scaffold/template/tests/smoke.og.ts +43 -0
- package/knowledge/scaffold/template/tsconfig.json +17 -0
- package/knowledge/scripts/lib.ts +203 -0
- package/knowledge/scripts/mount.ts +210 -0
- package/knowledge/scripts/pack.ts +24 -0
- package/knowledge/scripts/scaffold.ts +72 -0
- package/knowledge/scripts/validate.ts +749 -0
- package/knowledge/skills/og-ai/SKILL.md +386 -0
- package/knowledge/skills/og-audio/SKILL.md +191 -0
- package/knowledge/skills/og-build/SKILL.md +330 -0
- package/knowledge/skills/og-core/SKILL.md +285 -0
- package/knowledge/skills/og-feel/SKILL.md +385 -0
- package/knowledge/skills/og-game/SKILL.md +1479 -0
- package/knowledge/skills/og-i18n/SKILL.md +220 -0
- package/knowledge/skills/og-platform-sdk/SKILL.md +199 -0
- package/knowledge/skills/og-taste/SKILL.md +537 -0
- package/knowledge/skills/og-ui/SKILL.md +444 -0
- package/knowledge/wiki/ai.md +627 -0
- package/knowledge/wiki/anim.md +1187 -0
- package/knowledge/wiki/atmosphere.md +636 -0
- package/knowledge/wiki/audio.md +732 -0
- package/knowledge/wiki/camera.md +711 -0
- package/knowledge/wiki/commands.md +357 -0
- package/knowledge/wiki/game.md +220 -0
- package/knowledge/wiki/i18n.md +213 -0
- package/knowledge/wiki/index.md +1027 -0
- package/knowledge/wiki/input.md +1714 -0
- package/knowledge/wiki/load.md +1280 -0
- package/knowledge/wiki/math.md +166 -0
- package/knowledge/wiki/nav.md +1154 -0
- package/knowledge/wiki/physics.md +3142 -0
- package/knowledge/wiki/platform.md +857 -0
- package/knowledge/wiki/probe.md +1692 -0
- package/knowledge/wiki/reflect.md +285 -0
- package/knowledge/wiki/render.md +3511 -0
- package/knowledge/wiki/rng.md +393 -0
- package/knowledge/wiki/stream.md +376 -0
- package/knowledge/wiki/system.md +197 -0
- package/knowledge/wiki/systems.md +43 -0
- package/knowledge/wiki/terrain.md +727 -0
- package/knowledge/wiki/tilemap.md +422 -0
- package/knowledge/wiki/time.md +150 -0
- package/knowledge/wiki/transform.md +230 -0
- package/knowledge/wiki/tween.md +580 -0
- package/knowledge/wiki/ui.md +8000 -0
- package/knowledge/wiki/world.md +909 -0
- package/package.json +68 -4
- package/schema/api.schema.json +29675 -0
- package/shell/README.md +22 -0
- package/shell/brand/README.md +23 -0
- package/shell/brand/icon-192.png +0 -0
- package/shell/brand/icon-512.png +0 -0
- package/shell/brand/mark-on-paper.svg +6 -0
- package/shell/brand/origin-mark.svg +5 -0
- package/shell/brand/origin-motion.css +251 -0
- package/shell/brand/tokens.css +20 -0
- package/shell/brand.ts +132 -0
- package/shell/page.ts +715 -0
- package/shell/platform.ts +203 -0
- package/shell/player.ts +238 -0
- package/shell/shell.css +185 -0
- package/shell/sw.ts +177 -0
- package/shell/transport.ts +515 -0
- package/shell/wire.ts +369 -0
- package/src/e6.ts +69 -0
- package/src/env.d.ts +100 -0
- package/src/generated/abi.ts +980 -0
- package/src/index.ts +193 -0
- package/src/node/audio.ts +86 -0
- package/src/node/build.ts +109 -0
- package/src/node/device.ts +981 -0
- package/src/node/doctor.ts +60 -0
- package/src/node/index.ts +102 -0
- package/src/node/platform.ts +24 -0
- package/src/node/playtest-run.ts +249 -0
- package/src/node/png.ts +130 -0
- package/src/node/polish.ts +976 -0
- package/src/node/run.ts +373 -0
- package/src/node/web-build.ts +808 -0
- package/src/p2.ts +35 -0
- package/src/platform-local.ts +211 -0
- package/src/playtest.ts +131 -0
- package/src/runtime.ts +1509 -0
- package/src/systems/cameras.ts +247 -0
- package/src/ui-kit/bind.ts +141 -0
- package/src/ui-kit/huds.ts +303 -0
- package/src/ui-kit/index.ts +36 -0
- package/src/ui-kit/menus.ts +187 -0
- package/src/ui-kit/settings.ts +131 -0
- package/src/ui-kit/store.ts +222 -0
- package/src/ui-kit/story.ts +89 -0
- package/src/ui-kit/util.ts +106 -0
- package/src/web/a11y.ts +429 -0
- package/src/web/audio-worklet.ts +193 -0
- package/src/web/audio.ts +307 -0
- package/src/web/basis.ts +239 -0
- package/src/web/gamepad.ts +123 -0
- package/src/web/host.ts +166 -0
- package/src/web/input.ts +237 -0
- package/src/web/lanes.ts +21 -0
- package/src/web/play.ts +20 -0
- package/src/web/rpc-ring.ts +110 -0
- package/src/web/settle.ts +122 -0
- package/src/web/shell.ts +643 -0
- package/src/web/text-input.ts +248 -0
- package/src/web/worker.ts +1002 -0
- package/types/bindings.json +13654 -0
- package/types/index.d.ts +20090 -0
- package/vendor/basis/LICENSE +201 -0
- package/vendor/basis/NOTICE +15 -0
- package/vendor/basis/README.md +18 -0
- package/vendor/basis/basis_transcoder.js +19 -0
- package/vendor/basis/basis_transcoder.wasm +0 -0
- package/README.md +0 -3
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
// Validates @origingame/engine-knowledge against the OriginGame platform's
|
|
2
|
+
// knowledge rules (rules/platform.json, transcribed from the platform with
|
|
3
|
+
// scripts/sync-rules.ts; each rule names its platform source), and the
|
|
4
|
+
// package's own contract: a generated wiki, the skills in SKILLS, and a scaffold
|
|
5
|
+
// overlay whose pin and distribution list match the project template.
|
|
6
|
+
//
|
|
7
|
+
// bun scripts/validate.ts problems, then the packs and hashes
|
|
8
|
+
// bun scripts/validate.ts --bundle the payload as the platform's
|
|
9
|
+
// readProductionBundle shape (text only)
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
12
|
+
import { join, relative } from "node:path";
|
|
13
|
+
import {
|
|
14
|
+
defaultRoots,
|
|
15
|
+
frontMatter,
|
|
16
|
+
isText,
|
|
17
|
+
ogtTopLevel,
|
|
18
|
+
packName,
|
|
19
|
+
packs,
|
|
20
|
+
payload,
|
|
21
|
+
readManifest,
|
|
22
|
+
sha256,
|
|
23
|
+
TEMPLATE_SKIP,
|
|
24
|
+
walk,
|
|
25
|
+
type PayloadFile,
|
|
26
|
+
type Roots,
|
|
27
|
+
} from "./lib.ts";
|
|
28
|
+
|
|
29
|
+
export interface Rules {
|
|
30
|
+
payload: { roots: string[] };
|
|
31
|
+
skills: { file: string; namePattern: string; existing: string[] };
|
|
32
|
+
tools: { snake: string; notTools: string[]; retired: string[]; known: string[] };
|
|
33
|
+
scaffold: {
|
|
34
|
+
pinFile: string;
|
|
35
|
+
pin: { schema: number; probe: { protocol: number; wire: number } };
|
|
36
|
+
distributionFile: string;
|
|
37
|
+
distributionEntry: string[];
|
|
38
|
+
existingOverlays: string[];
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function readRules(pkg: string): Rules {
|
|
43
|
+
return JSON.parse(readFileSync(join(pkg, "rules/platform.json"), "utf8")) as Rules;
|
|
44
|
+
}
|
|
45
|
+
|
|
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;
|
|
48
|
+
|
|
49
|
+
/** What a scaffolded project must contain (scaffold/README.md describes each). */
|
|
50
|
+
export const TEMPLATE_FILES = ["project.ogt", "src/main.ts", "package.json", "tsconfig.json", "tests/smoke.og.ts"] as const;
|
|
51
|
+
|
|
52
|
+
/** The keys project.ogt must set (scaffold/README.md). */
|
|
53
|
+
export const PROJECT_KEYS = ["name", "id", "engine", "entry", "profile", "tick", "seed", "scenes", "scene", "publish"] as const;
|
|
54
|
+
|
|
55
|
+
const GENERATED = "<!-- Generated from crates/og-api/api.ogt";
|
|
56
|
+
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
57
|
+
|
|
58
|
+
/** Every problem found, each naming the file and the fix; empty when the package is valid. */
|
|
59
|
+
export function validate(roots: Roots = defaultRoots()): string[] {
|
|
60
|
+
const problems: string[] = [];
|
|
61
|
+
const rules = readRules(roots.pkg);
|
|
62
|
+
const manifest = readManifest(roots.pkg);
|
|
63
|
+
const files = payload(roots);
|
|
64
|
+
const byPath = new Map(files.map((f) => [f.path, f]));
|
|
65
|
+
|
|
66
|
+
packageJson(roots, problems);
|
|
67
|
+
wiki(roots, problems);
|
|
68
|
+
|
|
69
|
+
// The platform payload: production roots, regular files, UTF-8 text.
|
|
70
|
+
for (const f of files) {
|
|
71
|
+
if (!rules.payload.roots.some((r) => f.path.startsWith(`${r}/`))) {
|
|
72
|
+
problems.push(`${f.path}: outside the platform's production roots (${rules.payload.roots.join(", ")}); fix its mount in knowledge.json`);
|
|
73
|
+
}
|
|
74
|
+
if (f.symlink) problems.push(`${f.path}: a symbolic link (${f.source}); the platform bundle takes regular files only`);
|
|
75
|
+
const binaryAllowed = f.path.startsWith(`game-scaffold/${manifest.overlay}/`);
|
|
76
|
+
if (!binaryAllowed && !isText(f.bytes)) {
|
|
77
|
+
problems.push(`${f.path}: not UTF-8 text; the platform payload is text only (packages/game-knowledge/scripts/production-bundle.mjs)`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
skills(rules, files, problems);
|
|
82
|
+
coherence(files, problems);
|
|
83
|
+
apiNames(roots, files, problems);
|
|
84
|
+
looks(roots, files, problems);
|
|
85
|
+
grammar(roots, files, problems);
|
|
86
|
+
scaffold(roots, rules, manifest.overlay, files, byPath, problems);
|
|
87
|
+
return problems;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function packageJson(roots: Roots, problems: string[]): void {
|
|
91
|
+
const p = JSON.parse(readFileSync(join(roots.pkg, "package.json"), "utf8")) as { name?: string; version?: string; files?: string[]; scripts?: Record<string, string> };
|
|
92
|
+
if (p.name !== "@origingame/engine-knowledge") problems.push(`package.json: name is ${p.name}; the package is @origingame/engine-knowledge (design §9)`);
|
|
93
|
+
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"]) {
|
|
95
|
+
if (!p.files?.some((x) => x === f || x === `${f}/`)) problems.push(`package.json: files does not publish ${f}`);
|
|
96
|
+
}
|
|
97
|
+
if (!p.scripts?.test) problems.push("package.json: no test script, so scripts/check.sh bun-test would skip the package");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function wiki(roots: Roots, problems: string[]): void {
|
|
101
|
+
const dir = join(roots.pkg, "wiki");
|
|
102
|
+
const index = join(dir, "index.md");
|
|
103
|
+
if (!existsSync(index)) {
|
|
104
|
+
problems.push("wiki/index.md: missing; run `og api gen --wiki` in the engine repository");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const pages = readdirSync(dir).filter((n) => n.endsWith(".md")).sort();
|
|
108
|
+
for (const name of pages) {
|
|
109
|
+
const text = readFileSync(join(dir, name), "utf8");
|
|
110
|
+
if (!text.startsWith(GENERATED)) problems.push(`wiki/${name}: not generated from api.ogt (no generated header); wiki pages are never hand-written: run \`og api gen --wiki\``);
|
|
111
|
+
}
|
|
112
|
+
const indexText = readFileSync(index, "utf8");
|
|
113
|
+
const linked = new Set([...indexText.matchAll(/\]\(([a-z0-9_-]+\.md)[#)]/gi)].map((m) => m[1]!));
|
|
114
|
+
for (const name of linked) if (!pages.includes(name)) problems.push(`wiki/index.md: links to ${name}, which does not exist; run \`og api gen --wiki\``);
|
|
115
|
+
for (const name of pages) if (name !== "index.md" && !linked.has(name)) problems.push(`wiki/${name}: not linked from wiki/index.md (a page for a module the IDL no longer has?); run \`og api gen --wiki\``);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Tokens a skill names as tools, as the platform's production test reads them. */
|
|
119
|
+
function namedTools(text: string, rules: Rules): string[] {
|
|
120
|
+
const snake = new RegExp(rules.tools.snake);
|
|
121
|
+
const known = new Set(rules.tools.known);
|
|
122
|
+
const families = new Set([...known].filter((n) => snake.test(n)).map((n) => n.split("_")[0]!));
|
|
123
|
+
const notTools = new Set(rules.tools.notTools);
|
|
124
|
+
const prose = text.replace(/^```[\s\S]*?^```/gm, "");
|
|
125
|
+
const description = /^description: (.*)$/m.exec(text)?.[1] ?? "";
|
|
126
|
+
const named = [...[...prose.matchAll(/`([^`\s]+)`/g)].map((m) => m[1]!), ...(description.match(/\b[a-z][a-z0-9]*(?:_[a-z0-9]+)+\b/g) ?? [])]
|
|
127
|
+
.filter((t) => snake.test(t) && families.has(t.split("_")[0]!) && !notTools.has(t));
|
|
128
|
+
const unknown = named.filter((t) => !known.has(t));
|
|
129
|
+
for (const m of text.matchAll(/(?:tools\.call|origin\.execute)\(\s*['"]([^'"]+)['"]/g)) if (!known.has(m[1]!)) unknown.push(m[0]);
|
|
130
|
+
for (const r of rules.tools.retired) for (const m of text.matchAll(new RegExp(`\\b${r}\\b`, "gi"))) unknown.push(m[0]);
|
|
131
|
+
return [...new Set(unknown)];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function skills(rules: Rules, files: readonly PayloadFile[], problems: string[]): void {
|
|
135
|
+
const pattern = new RegExp(rules.skills.namePattern);
|
|
136
|
+
const pages = new Set(
|
|
137
|
+
files.map((f) => /^game\/skills\/og-game\/reference\/([^/]+)$/.exec(f.path)?.[1]).filter((n): n is string => n !== undefined),
|
|
138
|
+
);
|
|
139
|
+
const found = new Set<string>();
|
|
140
|
+
for (const f of files) {
|
|
141
|
+
const m = /^game\/skills\/([^/]+)\/(.+)$/.exec(f.path);
|
|
142
|
+
if (!m || m[2] !== rules.skills.file) continue;
|
|
143
|
+
const dir = m[1]!;
|
|
144
|
+
found.add(dir);
|
|
145
|
+
if (!isText(f.bytes)) continue;
|
|
146
|
+
const text = decoder.decode(f.bytes);
|
|
147
|
+
const fm = frontMatter(text);
|
|
148
|
+
if (!fm) {
|
|
149
|
+
problems.push(`${f.path}: no YAML front matter between --- lines; a skill starts with name and description`);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (fm.name !== dir) problems.push(`${f.path}: front matter name is "${fm.name ?? ""}", its directory is "${dir}"; they must be equal`);
|
|
153
|
+
if (!pattern.test(dir)) problems.push(`${f.path}: skill name "${dir}" is not lowercase-hyphen`);
|
|
154
|
+
if (!fm.description) problems.push(`${f.path}: no description; the runner lists skills by it`);
|
|
155
|
+
if (rules.skills.existing.includes(dir)) problems.push(`${f.path}: the platform already has a skill named ${dir}; installing this one would replace it`);
|
|
156
|
+
for (const t of namedTools(text, rules)) problems.push(`${f.path}: 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`);
|
|
157
|
+
// reference/ is the generated wiki, mounted under og-game: a page a
|
|
158
|
+
// skill names must be one the IDL generates.
|
|
159
|
+
for (const page of new Set([...text.matchAll(/\breference\/([a-z0-9-]+\.md)\b/g)].map((m) => m[1]!))) {
|
|
160
|
+
if (!pages.has(page)) {
|
|
161
|
+
problems.push(`${f.path}: names reference/${page}, which the generated wiki does not have (the wiki's pages: reference/index.md lists them; a module's page is reference/<module>.md)`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
for (const s of SKILLS) if (!found.has(s)) problems.push(`skills/${s}/SKILL.md: missing`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function scaffold(roots: Roots, rules: Rules, overlay: string, files: readonly PayloadFile[], byPath: Map<string, PayloadFile>, problems: string[]): void {
|
|
169
|
+
const prefix = `game-scaffold/${overlay}/`;
|
|
170
|
+
if (rules.scaffold.existingOverlays.includes(overlay)) {
|
|
171
|
+
problems.push(`knowledge.json: overlay "${overlay}" is already a platform scaffold overlay; preparing knowledge would replace it`);
|
|
172
|
+
}
|
|
173
|
+
for (const f of TEMPLATE_FILES) if (!byPath.has(prefix + f)) problems.push(`${prefix}${f}: the template has no ${f} (${join(relative(roots.pkg, roots.template), f)}); scaffold/README.md says every project starts with it`);
|
|
174
|
+
if (!files.some((f) => f.path.startsWith(`${prefix}assets/`))) problems.push(`${prefix}assets/: the template has no assets`);
|
|
175
|
+
|
|
176
|
+
// The pin.
|
|
177
|
+
const pinFile = byPath.get(prefix + rules.scaffold.pinFile);
|
|
178
|
+
let pin: { schema?: number; distribution?: string; engine?: { package?: string; version?: string }; probe?: { protocol?: number; wire?: number } } = {};
|
|
179
|
+
if (!pinFile) problems.push(`${prefix}${rules.scaffold.pinFile}: missing; the overlay pins its engine there`);
|
|
180
|
+
else pin = JSON.parse(decoder.decode(pinFile.bytes));
|
|
181
|
+
if (pinFile) {
|
|
182
|
+
if (pin.schema !== rules.scaffold.pin.schema) problems.push(`${prefix}${rules.scaffold.pinFile}: schema ${pin.schema}, the platform reads ${rules.scaffold.pin.schema}`);
|
|
183
|
+
if (!pin.distribution) problems.push(`${prefix}${rules.scaffold.pinFile}: no distribution`);
|
|
184
|
+
if (pin.probe?.protocol !== rules.scaffold.pin.probe.protocol || pin.probe?.wire !== rules.scaffold.pin.probe.wire) {
|
|
185
|
+
problems.push(`${prefix}${rules.scaffold.pinFile}: probe ${JSON.stringify(pin.probe)}, the platform speaks protocol ${rules.scaffold.pin.probe.protocol} wire ${rules.scaffold.pin.probe.wire}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const engineVersion = (JSON.parse(readFileSync(join(roots.engine, "package.json"), "utf8")) as { version: string }).version;
|
|
189
|
+
if (pinFile && pin.engine?.version !== engineVersion) problems.push(`${prefix}${rules.scaffold.pinFile}: pins engine ${pin.engine?.version}, packages/engine is ${engineVersion}`);
|
|
190
|
+
|
|
191
|
+
// project.ogt.
|
|
192
|
+
const project = byPath.get(`${prefix}project.ogt`);
|
|
193
|
+
if (project && isText(project.bytes)) {
|
|
194
|
+
const { type, entries } = ogtTopLevel(decoder.decode(project.bytes));
|
|
195
|
+
if (type !== "project") problems.push(`${prefix}project.ogt: type ${type}, expected project`);
|
|
196
|
+
for (const k of PROJECT_KEYS) if (!entries.has(k)) problems.push(`${prefix}project.ogt: no \`${k}\``);
|
|
197
|
+
if (entries.has("engine") && entries.get("engine") !== engineVersion) problems.push(`${prefix}project.ogt: engine "${entries.get("engine")}", packages/engine is ${engineVersion}`);
|
|
198
|
+
const entry = entries.get("entry");
|
|
199
|
+
if (entry && !byPath.has(prefix + entry)) problems.push(`${prefix}project.ogt: entry ${entry} does not exist in the template`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Binaries: never in the text payload; each named in distribution.json with its digest.
|
|
203
|
+
const distFile = byPath.get(prefix + rules.scaffold.distributionFile);
|
|
204
|
+
const listed = new Map<string, Record<string, unknown>>();
|
|
205
|
+
if (!distFile) problems.push(`${prefix}${rules.scaffold.distributionFile}: missing; run \`bun scripts/scaffold.ts --write\``);
|
|
206
|
+
else {
|
|
207
|
+
const dist = JSON.parse(decoder.decode(distFile.bytes)) as { schema?: number; files?: Record<string, unknown>[] };
|
|
208
|
+
for (const e of dist.files ?? []) {
|
|
209
|
+
for (const k of rules.scaffold.distributionEntry) if (!(k in e)) problems.push(`${prefix}${rules.scaffold.distributionFile}: ${String(e.path)} has no ${k}`);
|
|
210
|
+
listed.set(String(e.path), e);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const binaries = files.filter((f) => f.path.startsWith(prefix) && !isText(f.bytes));
|
|
214
|
+
for (const b of binaries) {
|
|
215
|
+
const rel = b.path.slice(prefix.length);
|
|
216
|
+
const e = listed.get(rel);
|
|
217
|
+
if (!e) problems.push(`${b.path}: a binary file distribution.json does not list; run \`bun scripts/scaffold.ts --write\``);
|
|
218
|
+
else if (e.sha256 !== sha256(b.bytes) || e.bytes !== b.bytes.length) problems.push(`${b.path}: differs from its distribution.json digest; run \`bun scripts/scaffold.ts --write\` after a deliberate asset change`);
|
|
219
|
+
}
|
|
220
|
+
// The published package's scaffold/template carries text only (scripts/pack.ts): the binaries it lists are @origingame/create-game's.
|
|
221
|
+
const published = roots.template === join(roots.pkg, "scaffold", "template");
|
|
222
|
+
if (!published) for (const rel of listed.keys()) if (!binaries.some((b) => b.path === prefix + rel)) problems.push(`${prefix}${rules.scaffold.distributionFile}: lists ${rel}, which the template does not have`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The bundle the platform's production reader would build: text files only. */
|
|
226
|
+
/**
|
|
227
|
+
* The skills as one set: the wiki index's grouping (a `### \`og-x\`` heading
|
|
228
|
+
* over its modules' rows, from each module's `skill` in api.ogt) names
|
|
229
|
+
* shipped skills; the skill a module is grouped under names its page
|
|
230
|
+
* (`reference/<module>.md`) and no other skill does (they point to that
|
|
231
|
+
* skill); and every section reference (`og-feel §4`, or `§4` for the skill's
|
|
232
|
+
* own) names a numbered `## N.` section that exists.
|
|
233
|
+
*/
|
|
234
|
+
function coherence(files: readonly PayloadFile[], problems: string[]): void {
|
|
235
|
+
const textOf = (path: string) => {
|
|
236
|
+
const f = files.find((x) => x.path === path);
|
|
237
|
+
return f && isText(f.bytes) ? decoder.decode(f.bytes) : undefined;
|
|
238
|
+
};
|
|
239
|
+
const index = textOf("game/skills/og-game/reference/index.md");
|
|
240
|
+
if (index === undefined) return;
|
|
241
|
+
const home = new Map<string, string>();
|
|
242
|
+
let group: string | undefined;
|
|
243
|
+
for (const line of index.slice(0, index.indexOf("\n## Units")).split("\n")) {
|
|
244
|
+
const heading = /^### `([^`]+)`$/.exec(line);
|
|
245
|
+
if (heading) {
|
|
246
|
+
group = heading[1]!;
|
|
247
|
+
if (!(SKILLS as readonly string[]).includes(group)) problems.push(`wiki/index.md: groups modules under ${group}, which is no skill this package ships; fix the modules' \`skill\` in api.ogt`);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const row = /^\| \[`([a-z0-9]+)`\]\(([a-z0-9]+)\.md\)/.exec(line);
|
|
251
|
+
if (row && group) home.set(row[1]!, group);
|
|
252
|
+
}
|
|
253
|
+
const texts = new Map<string, string>();
|
|
254
|
+
for (const s of SKILLS) {
|
|
255
|
+
const t = textOf(`game/skills/${s}/SKILL.md`);
|
|
256
|
+
if (t !== undefined) texts.set(s, t);
|
|
257
|
+
}
|
|
258
|
+
const sections = new Map([...texts].map(([s, t]) => [s, new Set([...t.matchAll(/^## (\d+)\. /gm)].map((m) => m[1]!))]));
|
|
259
|
+
for (const [module, skill] of home) {
|
|
260
|
+
const t = texts.get(skill);
|
|
261
|
+
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`);
|
|
262
|
+
}
|
|
263
|
+
for (const [skill, t] of texts) {
|
|
264
|
+
for (const m of new Set([...t.matchAll(/\breference\/([a-z0-9-]+)\.md\b/g)].map((x) => x[1]!))) {
|
|
265
|
+
const owner = home.get(m);
|
|
266
|
+
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
|
+
}
|
|
268
|
+
const prose = t.replace(/^```[\s\S]*?^```/gm, "");
|
|
269
|
+
for (const m of prose.matchAll(/(og-[a-z]+(?:-[a-z]+)*) §([A-Za-z][\w ]*)/g)) problems.push(`skills/${skill}/SKILL.md: refers to ${m[1]} §${m[2]!.split(" ")[0]} by name; refer to a skill's sections by their numbers (## N.)`);
|
|
270
|
+
for (const m of prose.matchAll(/§(\d+)/g)) {
|
|
271
|
+
// The skill this § belongs to: walk back over a list of §s ("og-feel
|
|
272
|
+
// §9–§11", "og-taste §4, §10") to the word before it.
|
|
273
|
+
const before = prose.slice(0, m.index).split(/\s+/);
|
|
274
|
+
let i = before.length - 1;
|
|
275
|
+
while (i >= 0 && (before[i] === "" || /^\(?§\d+(\.\d+)?[–,;)]*$/.test(before[i]!) || before[i] === "and" || before[i] === "or")) i--;
|
|
276
|
+
const word = (before[i] ?? "").replace(/^[(`*]+/, "");
|
|
277
|
+
let target: string | undefined = skill;
|
|
278
|
+
if (/^og-[a-z]+(?:-[a-z]+)*$/.test(word)) target = word;
|
|
279
|
+
else if (/\.md\b|\//.test(word) || /^design/.test(word)) target = undefined;
|
|
280
|
+
if (target === undefined) continue;
|
|
281
|
+
const have = sections.get(target);
|
|
282
|
+
if (!have) problems.push(`skills/${skill}/SKILL.md: refers to ${target} §${m[1]}, and there is no skill ${target}`);
|
|
283
|
+
else if (!have.has(m[1]!)) problems.push(`skills/${skill}/SKILL.md: refers to ${target === skill ? "its own" : target} §${m[1]}, which ${target === skill ? "it does not have" : "does not exist there"} (its sections: ${[...have].join(", ") || "none numbered"})`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** The names the generated API answers to, read from packages/engine/schema/api.schema.json. */
|
|
289
|
+
export interface ApiNames {
|
|
290
|
+
/** `og.<name>`: root functions, component, resource and event tokens, and module namespaces. */
|
|
291
|
+
root: Set<string>;
|
|
292
|
+
/** `og.<module>.<name>`: a namespace module's functions and values. */
|
|
293
|
+
members: Map<string, Set<string>>;
|
|
294
|
+
/** `<Type>.<field>` and `og.<Token>.<field>`: the fields of every struct, desc, component, resource and event. */
|
|
295
|
+
fields: Map<string, Set<string>>;
|
|
296
|
+
/** Every field name of any of them. */
|
|
297
|
+
anyField: Set<string>;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** `og.` names a skill may write that are not API: each with why. */
|
|
301
|
+
export const NOT_API: Record<string, string> = {
|
|
302
|
+
"og.settings": "the platform store key settings are saved under (og-platform-sdk §2)",
|
|
303
|
+
"og.AudioPlayback": "an engine-owned component dump_entity shows while a voice plays; game code never names it",
|
|
304
|
+
"og.AudioStream": "an engine-owned component dump_entity shows on a streamed voice; game code never names it",
|
|
305
|
+
"og.physics.queries": "the engine system dump_world lists that records the colliders a system's queries see",
|
|
306
|
+
"og.events": "the key of a glTF animation's extras the cook reads a clip's markers from (og-game §9), a name in the glTF file",
|
|
307
|
+
"og.TimeScale": "the engine-owned resource that keeps og.Time's scale and hit stop; dumps list it and the probe's get and set reach it, game code reads og.Time",
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
/** `` `key: value` `` spans a skill may write whose key is no field of the generated API: each with why. */
|
|
311
|
+
export const NOT_FIELDS: Record<string, string> = {
|
|
312
|
+
runs: "PlaytestDesc.runs (packages/engine/src/playtest.ts)",
|
|
313
|
+
then: "PlaytestDesc.then (packages/engine/src/playtest.ts)",
|
|
314
|
+
store: "PlaytestRun.store (packages/engine/src/playtest.ts)",
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
export function readApiNames(engine: string): ApiNames | undefined {
|
|
318
|
+
const path = join(engine, "schema", "api.schema.json");
|
|
319
|
+
if (!existsSync(path)) return undefined;
|
|
320
|
+
const schema = JSON.parse(readFileSync(path, "utf8")) as {
|
|
321
|
+
"x-og-modules"?: { name: string; place: string; functions?: { name: string }[]; values?: { name: string }[] }[];
|
|
322
|
+
$defs?: Record<string, { "x-og-kind"?: string; properties?: Record<string, unknown> }>;
|
|
323
|
+
};
|
|
324
|
+
const names: ApiNames = { root: new Set(), members: new Map(), fields: new Map(), anyField: new Set() };
|
|
325
|
+
for (const m of schema["x-og-modules"] ?? []) {
|
|
326
|
+
const fns = [...(m.functions ?? []), ...(m.values ?? [])].map((f) => f.name);
|
|
327
|
+
if (m.place === "root") for (const f of fns) names.root.add(f);
|
|
328
|
+
if (m.place === "namespace") {
|
|
329
|
+
names.root.add(m.name);
|
|
330
|
+
names.members.set(m.name, new Set(fns));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
for (const [name, def] of Object.entries(schema.$defs ?? {})) {
|
|
334
|
+
const kind = def["x-og-kind"] ?? "";
|
|
335
|
+
if (["component", "resource", "event"].includes(kind)) names.root.add(name);
|
|
336
|
+
if (def.properties) names.fields.set(name, new Set(Object.keys(def.properties)));
|
|
337
|
+
for (const k of Object.keys(def.properties ?? {})) names.anyField.add(k);
|
|
338
|
+
}
|
|
339
|
+
return names;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Every API name a skill writes resolves in the generated API: `og.<x>` is a
|
|
344
|
+
* root function, a component, resource or event token or a module
|
|
345
|
+
* namespace; `og.<module>.<y>` one of the module's functions or values;
|
|
346
|
+
* `og.<Token>.<field>` and `<Type>.<field>` (for a struct, desc, component,
|
|
347
|
+
* resource or event the IDL declares) one of its fields. Code blocks are
|
|
348
|
+
* read too: an example is API as much as prose is. A file name such as
|
|
349
|
+
* `away.og.ts` is not a name.
|
|
350
|
+
*/
|
|
351
|
+
function apiNames(roots: Roots, files: readonly PayloadFile[], problems: string[]): void {
|
|
352
|
+
const api = readApiNames(roots.engine);
|
|
353
|
+
if (!api) return;
|
|
354
|
+
for (const f of files) {
|
|
355
|
+
const m = /^game\/skills\/([^/]+)\/SKILL\.md$/.exec(f.path);
|
|
356
|
+
if (!m || !isText(f.bytes)) continue;
|
|
357
|
+
const at = `skills/${m[1]}/SKILL.md`;
|
|
358
|
+
const text = decoder.decode(f.bytes);
|
|
359
|
+
const bad = new Set<string>();
|
|
360
|
+
for (const x of text.matchAll(/(?<![\w./@-])og\.([A-Za-z_]\w*)(?:\.([A-Za-z_]\w*))?/g)) {
|
|
361
|
+
const [whole, head, member] = [x[0], x[1]!, x[2]];
|
|
362
|
+
if (whole in NOT_API || `og.${head}` in NOT_API) continue;
|
|
363
|
+
if (!api.root.has(head)) {
|
|
364
|
+
bad.add(`og.${head}`);
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
if (member === undefined) continue;
|
|
368
|
+
const ok = api.members.has(head) ? api.members.get(head)!.has(member) : (api.fields.get(head)?.has(member) ?? false);
|
|
369
|
+
if (!ok) bad.add(whole);
|
|
370
|
+
}
|
|
371
|
+
for (const x of text.matchAll(/(?<![\w./@-])([A-Z][A-Za-z0-9]*)\.([a-z]\w*)\b/g)) {
|
|
372
|
+
const have = api.fields.get(x[1]!);
|
|
373
|
+
if (have && !have.has(x[2]!)) bad.add(x[0]);
|
|
374
|
+
}
|
|
375
|
+
// A component, resource or event's value written beside its token
|
|
376
|
+
// (`world.insert(e, og.AudioSource, { sound, loop: true })`): each key one of its fields.
|
|
377
|
+
for (const x of text.matchAll(/(?<![\w./@-])og\.([A-Z]\w*)\s*,\s*\{([^{}]*)\}/g)) {
|
|
378
|
+
const have = api.fields.get(x[1]!);
|
|
379
|
+
if (!have) continue;
|
|
380
|
+
for (const part of x[2]!.split(",")) {
|
|
381
|
+
const key = /^\s*([A-Za-z_]\w*)\s*(?::|$)/.exec(part)?.[1];
|
|
382
|
+
if (key && !have.has(key)) bad.add(`og.${x[1]}.${key}`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
// A field set in prose, `` `looping: true` ``: its key is some type's field.
|
|
386
|
+
for (const x of text.matchAll(/`([a-z]\w*): [^`]+`/g)) {
|
|
387
|
+
if (!api.anyField.has(x[1]!) && !(x[1]! in NOT_FIELDS)) bad.add(x[1]!);
|
|
388
|
+
}
|
|
389
|
+
for (const b of bad) problems.push(`${at}: names \`${b}\`, which the generated API (api.ogt) does not have; check the name in reference/ or packages/engine/types/index.d.ts`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** A style catalogue entry (looks/catalogue.json; og-taste §12). */
|
|
394
|
+
interface Style {
|
|
395
|
+
slug: string;
|
|
396
|
+
name: string;
|
|
397
|
+
category: string;
|
|
398
|
+
descriptor: string;
|
|
399
|
+
checks: string[];
|
|
400
|
+
premise: Record<string, string>;
|
|
401
|
+
look: { preset: string; presetReady: boolean; support: string; primitives: string[]; missing: string[] };
|
|
402
|
+
skin: string;
|
|
403
|
+
palette: { size: string; rules: string };
|
|
404
|
+
assets: Record<string, string>;
|
|
405
|
+
artBible: { direction: string; avoid: string[] };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** 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
|
+
export const SUPPORT = ["full", "partial", "planned", "content"] as const;
|
|
410
|
+
/** The placeholders the art-bible template may use. */
|
|
411
|
+
export const BIBLE_FIELDS = ["title", "premise", "camera", "platform", "name", "descriptor", "direction", "palette", "assets", "skin", "avoid", "game", "slug", "checks"] as const;
|
|
412
|
+
const PREMISE = ["genre", "mood", "era", "audience"];
|
|
413
|
+
const ASSET_SPECS = ["logicalResolution", "spriteScale", "faceBudget", "outlinePx", "textureDensity"];
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* The style catalogue (looks/catalogue.json, mounted beside og-taste): each
|
|
417
|
+
* style complete (three checks, the premise, palette, asset specs, an art
|
|
418
|
+
* bible direction), its preset path `templates/looks/<slug>.ogt` (a
|
|
419
|
+
* `type look` file when presetReady), its support honest (`missing` names
|
|
420
|
+
* the primitives a style that is not `full` lacks, and is empty for one
|
|
421
|
+
* that is), every `Resource.field` it names a member of the generated API,
|
|
422
|
+
* og-taste naming every slug, and no text phrased as an imitation.
|
|
423
|
+
*/
|
|
424
|
+
function looks(roots: Roots, files: readonly PayloadFile[], problems: string[]): void {
|
|
425
|
+
const path = join(roots.pkg, "looks", "catalogue.json");
|
|
426
|
+
const at = "looks/catalogue.json";
|
|
427
|
+
if (!existsSync(path)) {
|
|
428
|
+
problems.push(`${at}: missing; og-taste §12 reads the style catalogue from it`);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
let cat: { schemaVersion?: number; categories?: Record<string, string>; skins?: string[]; artBibleTemplate?: string; styles?: Style[] };
|
|
432
|
+
try {
|
|
433
|
+
cat = JSON.parse(readFileSync(path, "utf8"));
|
|
434
|
+
} catch (e) {
|
|
435
|
+
problems.push(`${at}: not JSON (${(e as Error).message})`);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
if (cat.schemaVersion !== 1) problems.push(`${at}: schemaVersion ${cat.schemaVersion}, expected 1`);
|
|
439
|
+
const categories = Object.keys(cat.categories ?? {});
|
|
440
|
+
const skins = cat.skins ?? [];
|
|
441
|
+
for (const m of (cat.artBibleTemplate ?? "").matchAll(/\{(\w+)\}/g)) {
|
|
442
|
+
if (!(BIBLE_FIELDS as readonly string[]).includes(m[1]!)) problems.push(`${at}: artBibleTemplate uses {${m[1]}}, which is none of ${BIBLE_FIELDS.join(", ")}`);
|
|
443
|
+
}
|
|
444
|
+
if (!cat.artBibleTemplate) problems.push(`${at}: no artBibleTemplate`);
|
|
445
|
+
const schemaPath = join(roots.engine, "schema", "api.schema.json");
|
|
446
|
+
const defs = existsSync(schemaPath) ? ((JSON.parse(readFileSync(schemaPath, "utf8")) as { $defs?: Record<string, { properties?: Record<string, unknown> }> }).$defs ?? {}) : undefined;
|
|
447
|
+
const repo = join(roots.pkg, "..", "..");
|
|
448
|
+
const taste = files.find((f) => f.path === "game/skills/og-taste/SKILL.md");
|
|
449
|
+
const tasteText = taste && isText(taste.bytes) ? decoder.decode(taste.bytes) : "";
|
|
450
|
+
const seen = new Set<string>();
|
|
451
|
+
const text = (s: unknown) => typeof s === "string" && s.trim().length > 0;
|
|
452
|
+
for (const [i, s] of (cat.styles ?? []).entries()) {
|
|
453
|
+
const id = `${at}: ${s.slug ?? `styles[${i}]`}`;
|
|
454
|
+
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(s.slug ?? "")) problems.push(`${id}: slug is not lowercase-hyphen`);
|
|
455
|
+
if (seen.has(s.slug)) problems.push(`${id}: a second style with this slug`);
|
|
456
|
+
seen.add(s.slug);
|
|
457
|
+
for (const k of ["name", "descriptor", "skin"] as const) if (!text(s[k])) problems.push(`${id}: no ${k}`);
|
|
458
|
+
if (!categories.includes(s.category)) problems.push(`${id}: category ${s.category} is none of ${categories.join(", ")}`);
|
|
459
|
+
if (!skins.includes(s.skin)) problems.push(`${id}: skin ${s.skin} is none of the packs (${skins.join(", ")})`);
|
|
460
|
+
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
|
+
for (const k of PREMISE) if (!text(s.premise?.[k])) problems.push(`${id}: premise has no ${k}`);
|
|
462
|
+
for (const k of ASSET_SPECS) if (!text(s.assets?.[k])) problems.push(`${id}: assets has no ${k}`);
|
|
463
|
+
if (!text(s.palette?.size) || !text(s.palette?.rules)) problems.push(`${id}: palette needs a size and rules`);
|
|
464
|
+
if (!text(s.artBible?.direction) || !Array.isArray(s.artBible?.avoid) || s.artBible.avoid.length === 0) problems.push(`${id}: artBible needs a direction and what to avoid`);
|
|
465
|
+
const look = s.look;
|
|
466
|
+
if (!look) {
|
|
467
|
+
problems.push(`${id}: no look`);
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
if (look.preset !== `templates/looks/${s.slug}.ogt`) problems.push(`${id}: look.preset is ${look.preset}, expected templates/looks/${s.slug}.ogt`);
|
|
471
|
+
if (!(SUPPORT as readonly string[]).includes(look.support)) problems.push(`${id}: look.support ${look.support} is none of ${SUPPORT.join(", ")}`);
|
|
472
|
+
const missing = look.missing ?? [];
|
|
473
|
+
if (look.support === "full" && missing.length > 0) problems.push(`${id}: support full, yet it names missing primitives`);
|
|
474
|
+
if (look.support !== "full" && !missing.some(text)) problems.push(`${id}: support ${look.support} must name what is missing (the primitive or the authored art)`);
|
|
475
|
+
if (!Array.isArray(look.primitives) || look.primitives.length === 0) problems.push(`${id}: look.primitives names none`);
|
|
476
|
+
if (defs) {
|
|
477
|
+
for (const p of look.primitives ?? []) {
|
|
478
|
+
const m = /^([A-Z]\w*)\.(\w+)$/.exec(p);
|
|
479
|
+
if (m && !defs[m[1]!]?.properties?.[m[2]!]) problems.push(`${id}: names ${p}, which the generated API does not have`);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
const preset = join(repo, look.preset);
|
|
483
|
+
if (look.presetReady) {
|
|
484
|
+
if (existsSync(join(repo, "templates")) && !existsSync(preset)) problems.push(`${id}: presetReady, but ${look.preset} does not exist`);
|
|
485
|
+
else if (existsSync(preset) && !/^type look\s*$/m.test(readFileSync(preset, "utf8"))) problems.push(`${id}: ${look.preset} is not an .ogt of type look`);
|
|
486
|
+
}
|
|
487
|
+
if (tasteText && !tasteText.includes(`\`${s.slug}\``)) problems.push(`skills/og-taste/SKILL.md: the style catalogue (§12) does not list \`${s.slug}\``);
|
|
488
|
+
else if (tasteText) {
|
|
489
|
+
// §12's row for the style says what the catalogue says: family, name, support, skin.
|
|
490
|
+
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.skin];
|
|
492
|
+
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 | skin |)`);
|
|
494
|
+
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
|
+
}
|
|
496
|
+
const all = JSON.stringify(s);
|
|
497
|
+
if (/in the style of|™|®/i.test(all)) problems.push(`${id}: phrased as an imitation (\"in the style of\", a trademark sign); describe the look in its own terms`);
|
|
498
|
+
}
|
|
499
|
+
if (seen.size === 0) problems.push(`${at}: no styles`);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* A text format the skills write out line by line, held to the parser that
|
|
504
|
+
* reads it: every `.ogt` block of that `type` in a skill (a ```text code
|
|
505
|
+
* block) and the parser's source.
|
|
506
|
+
*/
|
|
507
|
+
export interface Grammar {
|
|
508
|
+
/** The block's `type` line. */
|
|
509
|
+
readonly type: string;
|
|
510
|
+
/** For `type import`: the nested block (`tilemap { ... }`) whose lines are this grammar. */
|
|
511
|
+
readonly block?: string;
|
|
512
|
+
/** The parser, relative to the engine repository. */
|
|
513
|
+
readonly source: string;
|
|
514
|
+
/** The part of the source that parses it: from the first match to the second. */
|
|
515
|
+
readonly region?: readonly [RegExp, RegExp];
|
|
516
|
+
/** Keys a block may write that the parser accepts without naming them: each with why. */
|
|
517
|
+
readonly unnamed?: Readonly<Record<string, string>>;
|
|
518
|
+
/** Keys whose next words are names the author chooses (a state, a route, a motion), not grammar: how many. */
|
|
519
|
+
readonly names?: Readonly<Record<string, number>>;
|
|
520
|
+
/** The keys the parser reads, from its region; default: its match arms' first keys (`"grid" =>`). */
|
|
521
|
+
readonly reads?: (region: string) => string[];
|
|
522
|
+
/** The skill that writes the grammar out (default og-game), for the messages. */
|
|
523
|
+
readonly home?: string;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const quotedIn = (s: string) => [...s.matchAll(/"([A-Za-z_]\w*)"/g)].map((m) => m[1]!);
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Every key a parser reads by name: each alternative of a match arm
|
|
530
|
+
* (`"enter" | "exit" =>`, `Some("all") =>`), a block lookup (`.get("agent")`,
|
|
531
|
+
* `.get_all("box")`, `f32_of(agent, "radius"`, `opt_f32(b, "reach"`), a key
|
|
532
|
+
* compared (`.key != "child"`), a `matches!` list, an option list
|
|
533
|
+
* (`check_options(e, &["speed"])`) and the list of keys og.ai writes itself.
|
|
534
|
+
*/
|
|
535
|
+
export function readKeys(region: string): string[] {
|
|
536
|
+
const id = '"([A-Za-z_]\\w*)"';
|
|
537
|
+
const one = [
|
|
538
|
+
new RegExp(`${id}(?=(?:\\s*\\|\\s*"[A-Za-z_]\\w*")*\\s*=>)`, "g"),
|
|
539
|
+
new RegExp(`Some\\(${id}\\)\\s*=>`, "g"),
|
|
540
|
+
new RegExp(`\\.get(?:_all)?\\(${id}\\)`, "g"),
|
|
541
|
+
new RegExp(`(?:_of|opt_f32)\\([^,()]*(?:\\([^()]*\\))?,\\s*${id}`, "g"),
|
|
542
|
+
new RegExp(`\\.key\\s*[!=]=\\s*${id}`, "g"),
|
|
543
|
+
];
|
|
544
|
+
const lists = [/matches!\(\s*[^,]+,\s*((?:"\w+"\s*\|?\s*)+)\)/g, /check_options\(\w+,\s*&\[([^\]]*)\]/g, /BUILTIN_KEYS: &\[&str\] = &\[([^\]]*)\]/g];
|
|
545
|
+
return [...one.flatMap((re) => [...region.matchAll(re)].map((m) => m[1]!)), ...lists.flatMap((re) => [...region.matchAll(re)].flatMap((m) => quotedIn(m[1]!)))];
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* The grammars the skills write out (og-game §8 tilesets and maps, §9 a
|
|
550
|
+
* model's animation settings and the animation graph, §10 emitters; og-ai
|
|
551
|
+
* §1 the navmesh, §3 behaviours) and the parsers they come from.
|
|
552
|
+
*/
|
|
553
|
+
export const GRAMMARS: readonly Grammar[] = [
|
|
554
|
+
{ type: "tileset", source: "crates/og-tilemap/src/tileset.rs" },
|
|
555
|
+
{ type: "tilemap", source: "crates/og-tilemap/src/map.rs", region: [/pub fn parse\(text/, /^pub fn world_to_cell/m] },
|
|
556
|
+
{ type: "particles", source: "crates/og-cook/src/fx.rs", unnamed: { key: "colorOverLife's lines: the parser reads their five numbers whatever the key" } },
|
|
557
|
+
{ type: "import", block: "tilemap", source: "crates/og-cook/src/cook/tilemap.rs", region: [/pub fn parse\(block/, /pub fn describe/] },
|
|
558
|
+
{ type: "import", block: "animation", source: "crates/og-cook/src/cook/anim.rs", region: [/pub fn parse\(block/, /^fn asset_path/m] },
|
|
559
|
+
{
|
|
560
|
+
type: "animgraph",
|
|
561
|
+
source: "crates/og-anim/src/graph.rs",
|
|
562
|
+
region: [/pub fn from_document/, /\/\/\/ The slot of a parameter/],
|
|
563
|
+
reads: readKeys,
|
|
564
|
+
names: { param: 1, clip: 1, blend1d: 2, blend2d: 3, child: 1, state: 2, entry: 1, transition: 2, when: 1, weight: 1 },
|
|
565
|
+
},
|
|
566
|
+
{ type: "navmesh", source: "crates/og-cook/src/cook/e6.rs", region: [/pub\(super\) fn cook_navmesh/, /og_nav::bake::bake\(/], reads: readKeys, home: "og-ai" },
|
|
567
|
+
{
|
|
568
|
+
type: "behaviour",
|
|
569
|
+
source: "crates/og-ai/src/asset.rs",
|
|
570
|
+
reads: readKeys,
|
|
571
|
+
home: "og-ai",
|
|
572
|
+
// A label, a route, a state, a key, an operand or a speed's field are the author's; a command's arguments too.
|
|
573
|
+
names: { selector: 1, sequence: 1, steps: 1, parallel: 1, route: 1, patrol: 1, state: 1, initial: 1, to: 1, key: 1, set: 1, check: 1, move_to: 1, chase: 1, flee: 1, speed: 1, command: Infinity },
|
|
574
|
+
},
|
|
575
|
+
];
|
|
576
|
+
|
|
577
|
+
/** The Tiled fields a skill's `<!-- grammar: tiled -->` table names, and the importer that reads them. */
|
|
578
|
+
export const TILED = {
|
|
579
|
+
source: "crates/og-cook/src/cook/tilemap.rs",
|
|
580
|
+
region: [/pub fn import_tiled/, /^fn tiled_data/m] as const,
|
|
581
|
+
/** Fields the importer reads that are structure, not settings: each with why. */
|
|
582
|
+
structural: { layers: "a group layer's children, flattened", objects: "an object layer's objects" } as Record<string, string>,
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
/** Keys after which the next word is a name the author chooses, not grammar. */
|
|
586
|
+
const NAMING = new Set(["terrain", "layer", "object", "kind", "prop"]);
|
|
587
|
+
|
|
588
|
+
interface Line {
|
|
589
|
+
readonly words: string[];
|
|
590
|
+
readonly depth: number;
|
|
591
|
+
readonly text: string;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** An `.ogt` block's lines as tokens: strings dropped, comments cut, numbers kept as written. */
|
|
595
|
+
function ogtLines(block: string): Line[] {
|
|
596
|
+
const out: Line[] = [];
|
|
597
|
+
let depth = 0;
|
|
598
|
+
for (const raw of block.split("\n")) {
|
|
599
|
+
const words: string[] = [];
|
|
600
|
+
const re = /"(?:[^"\\]|\\.)*"|\S+/g;
|
|
601
|
+
let opens = false;
|
|
602
|
+
for (const m of raw.matchAll(re)) {
|
|
603
|
+
const t = m[0];
|
|
604
|
+
if (t.startsWith("#")) break;
|
|
605
|
+
if (t === "{") opens = true;
|
|
606
|
+
else if (t === "}") depth--;
|
|
607
|
+
else words.push(t.startsWith('"') ? '""' : t);
|
|
608
|
+
}
|
|
609
|
+
out.push({ words, depth, text: raw });
|
|
610
|
+
if (opens) depth++;
|
|
611
|
+
}
|
|
612
|
+
return out;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** The ```text blocks of a markdown file. */
|
|
616
|
+
function textBlocks(md: string): string[] {
|
|
617
|
+
return [...md.matchAll(/^```text\n([\s\S]*?)^```/gm)].map((m) => m[1]!);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Every problem between the grammars the skills write and their parsers:
|
|
622
|
+
* a bare word a block writes (a key, or a word value such as `blob47`)
|
|
623
|
+
* that the parser never names in quotes, and a key the parser matches
|
|
624
|
+
* (`"grid" =>`) that no block of its type writes, in code or comment. So a
|
|
625
|
+
* key renamed, added or dropped on either side fails here.
|
|
626
|
+
*/
|
|
627
|
+
export function grammarDrift(skills: ReadonlyMap<string, string>, read: (path: string) => string | undefined): string[] {
|
|
628
|
+
const problems: string[] = [];
|
|
629
|
+
for (const g of GRAMMARS) {
|
|
630
|
+
let source = read(g.source);
|
|
631
|
+
if (source === undefined) continue;
|
|
632
|
+
if (g.region) {
|
|
633
|
+
const a = g.region[0].exec(source)?.index ?? -1;
|
|
634
|
+
const b = a < 0 ? -1 : source.slice(a).search(g.region[1]);
|
|
635
|
+
if (a < 0 || b < 0) {
|
|
636
|
+
problems.push(`${g.source}: the ${g.type} parser's region (${g.region[0]} to ${g.region[1]}) is not found; update GRAMMARS in scripts/validate.ts`);
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
source = source.slice(a, a + b);
|
|
640
|
+
}
|
|
641
|
+
const home = g.home ?? "og-game";
|
|
642
|
+
const quoted = new Set(quotedIn(source));
|
|
643
|
+
const arms = new Set(g.reads ? g.reads(source) : [...source.matchAll(/"([A-Za-z_]\w*)"\s*(?:\|[^=]*)?=>/g)].map((m) => m[1]!));
|
|
644
|
+
const written: string[] = [];
|
|
645
|
+
let blocks = 0;
|
|
646
|
+
for (const [skill, text] of skills) {
|
|
647
|
+
for (const block of textBlocks(text)) {
|
|
648
|
+
const lines = ogtLines(block);
|
|
649
|
+
if (lines[0]?.words.join(" ") !== `type ${g.type}`) continue;
|
|
650
|
+
let body = lines.slice(lines.findIndex((l) => l.words[0] === "---") + 1);
|
|
651
|
+
if (g.block) {
|
|
652
|
+
const start = body.findIndex((l) => l.depth === 0 && l.words[0] === g.block && l.text.includes("{"));
|
|
653
|
+
if (start < 0) continue;
|
|
654
|
+
const end = body.findIndex((l, i) => i > start && l.depth === 0);
|
|
655
|
+
body = body.slice(start + 1, end < 0 ? undefined : end);
|
|
656
|
+
}
|
|
657
|
+
blocks++;
|
|
658
|
+
written.push(body.map((l) => l.text).join("\n"));
|
|
659
|
+
for (const l of body) {
|
|
660
|
+
l.words.forEach((w, i) => {
|
|
661
|
+
if (!/^[A-Za-z_]\w*$/.test(w) || w === "true" || w === "false") return;
|
|
662
|
+
if (i > 0 && NAMING.has(l.words[i - 1]!)) return;
|
|
663
|
+
if (i > 0 && i <= (g.names?.[l.words[0]!] ?? 0)) return;
|
|
664
|
+
if (i === 0 && g.unnamed && w in g.unnamed) return;
|
|
665
|
+
if (!quoted.has(w)) problems.push(`skills/${skill}/SKILL.md: a \`type ${g.type}\` block${g.block ? `'s ${g.block} block` : ""} writes \`${w}\` (${l.text.trim()}), which ${g.source} does not read; fix the grammar to the parser's`);
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
if (blocks === 0) {
|
|
671
|
+
problems.push(`skills: no skill writes a \`type ${g.type}\` block${g.block ? ` with ${/^[aeiou]/.test(g.block) ? "an" : "a"} ${g.block} block` : ""}; ${home}'s grammar of it is gone`);
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
const all = written.join("\n");
|
|
675
|
+
for (const k of arms) {
|
|
676
|
+
if (!new RegExp(`(?<![\\w-])${k}(?![\\w-])`).test(all)) problems.push(`${g.source}: reads \`${k}\`, which no \`type ${g.type}\`${g.block ? ` ${g.block}` : ""} block in the skills writes or names; add it to ${home}'s grammar`);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Tiled: the table after `<!-- grammar: tiled -->`.
|
|
681
|
+
const importer = read(TILED.source);
|
|
682
|
+
if (importer !== undefined) {
|
|
683
|
+
const a = TILED.region[0].exec(importer)?.index ?? -1;
|
|
684
|
+
const b = a < 0 ? -1 : importer.slice(a).search(TILED.region[1]);
|
|
685
|
+
const region = a < 0 || b < 0 ? undefined : importer.slice(a, a + b);
|
|
686
|
+
if (!region) problems.push(`${TILED.source}: the Tiled importer's region is not found; update TILED in scripts/validate.ts`);
|
|
687
|
+
const named = new Set<string>();
|
|
688
|
+
let tables = 0;
|
|
689
|
+
for (const [skill, text] of skills) {
|
|
690
|
+
for (const m of text.matchAll(/<!-- grammar: tiled -->\n((?:\|.*\n?)+)/g)) {
|
|
691
|
+
tables++;
|
|
692
|
+
for (const row of m[1]!.split("\n").slice(2)) {
|
|
693
|
+
const first = row.split("|")[1] ?? "";
|
|
694
|
+
for (const n of first.matchAll(/`([^`]+)`/g)) {
|
|
695
|
+
named.add(n[1]!);
|
|
696
|
+
if (region && !region.includes(`"${n[1]}"`)) problems.push(`skills/${skill}/SKILL.md: the Tiled table names \`${n[1]}\`, which ${TILED.source}'s Tiled importer does not read`);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
if (tables === 0) problems.push("skills: no skill has the Tiled table (`<!-- grammar: tiled -->`); og-game §8's list of what the cook reads of a Tiled map is gone");
|
|
702
|
+
else if (region) {
|
|
703
|
+
const read = [
|
|
704
|
+
...region.matchAll(/\bprops\s*\.get\("(\w+)"\)/g),
|
|
705
|
+
...region.matchAll(/\bf\(l, "(\w+)"/g),
|
|
706
|
+
...region.matchAll(/\bl\s*\.get\("(\w+)"\)/g),
|
|
707
|
+
].map((m) => m[1]!);
|
|
708
|
+
for (const k of new Set(read)) {
|
|
709
|
+
if (!named.has(k) && !(k in TILED.structural)) problems.push(`${TILED.source}: the Tiled importer reads \`${k}\`, which og-game's Tiled table does not name; add a row`);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
return problems;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function grammar(roots: Roots, files: readonly PayloadFile[], problems: string[]): void {
|
|
717
|
+
const repo = join(roots.pkg, "..", "..");
|
|
718
|
+
if (!existsSync(join(repo, "crates"))) return; // the published package: the parsers are not beside it
|
|
719
|
+
const skills = new Map<string, string>();
|
|
720
|
+
for (const f of files) {
|
|
721
|
+
const m = /^game\/skills\/([^/]+)\/SKILL\.md$/.exec(f.path);
|
|
722
|
+
if (m && isText(f.bytes)) skills.set(m[1]!, decoder.decode(f.bytes));
|
|
723
|
+
}
|
|
724
|
+
problems.push(...grammarDrift(skills, (p) => (existsSync(join(repo, p)) ? readFileSync(join(repo, p), "utf8") : undefined)));
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
export function bundle(roots: Roots = defaultRoots()): { schemaVersion: 1; files: Record<string, string> } {
|
|
728
|
+
const files: Record<string, string> = {};
|
|
729
|
+
for (const f of payload(roots)) if (isText(f.bytes)) files[f.path] = decoder.decode(f.bytes);
|
|
730
|
+
return { schemaVersion: 1, files };
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
export { packName, walk, TEMPLATE_SKIP };
|
|
734
|
+
|
|
735
|
+
if (import.meta.main) {
|
|
736
|
+
const roots = defaultRoots();
|
|
737
|
+
if (process.argv.includes("--bundle")) {
|
|
738
|
+
process.stdout.write(`${JSON.stringify(bundle(roots))}\n`);
|
|
739
|
+
} else {
|
|
740
|
+
const problems = validate(roots);
|
|
741
|
+
for (const p of problems) process.stderr.write(`${p}\n`);
|
|
742
|
+
if (problems.length) {
|
|
743
|
+
process.stderr.write(`\nengine knowledge: ${problems.length} problem(s).\n`);
|
|
744
|
+
process.exit(1);
|
|
745
|
+
}
|
|
746
|
+
const hashes = packs(payload(roots));
|
|
747
|
+
process.stdout.write(`engine knowledge valid against the platform rules: ${Object.entries(hashes).map(([n, h]) => `${n} ${h}`).join(", ")}\n`);
|
|
748
|
+
}
|
|
749
|
+
}
|