@rpgm-tools/neo-angband-content 0.11.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/LICENSE.md +43 -0
- package/README.md +65 -0
- package/dist/compile.d.ts +10 -0
- package/dist/compile.d.ts.map +1 -0
- package/dist/compile.js +42 -0
- package/dist/compile.js.map +1 -0
- package/dist/data-exactness.reader.d.ts +105 -0
- package/dist/data-exactness.reader.d.ts.map +1 -0
- package/dist/data-exactness.reader.js +547 -0
- package/dist/data-exactness.reader.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/parser.d.ts +70 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +365 -0
- package/dist/parser.js.map +1 -0
- package/dist/records.d.ts +101 -0
- package/dist/records.d.ts.map +1 -0
- package/dist/records.js +244 -0
- package/dist/records.js.map +1 -0
- package/dist/specs/generate.d.ts +12 -0
- package/dist/specs/generate.d.ts.map +1 -0
- package/dist/specs/generate.js +57 -0
- package/dist/specs/generate.js.map +1 -0
- package/dist/specs/index.d.ts +9 -0
- package/dist/specs/index.d.ts.map +1 -0
- package/dist/specs/index.js +60 -0
- package/dist/specs/index.js.map +1 -0
- package/dist/specs/init.d.ts +23 -0
- package/dist/specs/init.d.ts.map +1 -0
- package/dist/specs/init.js +305 -0
- package/dist/specs/init.js.map +1 -0
- package/dist/specs/misc.d.ts +33 -0
- package/dist/specs/misc.d.ts.map +1 -0
- package/dist/specs/misc.js +146 -0
- package/dist/specs/misc.js.map +1 -0
- package/dist/specs/mon-init.d.ts +22 -0
- package/dist/specs/mon-init.d.ts.map +1 -0
- package/dist/specs/mon-init.js +157 -0
- package/dist/specs/mon-init.js.map +1 -0
- package/dist/specs/obj-init.d.ts +19 -0
- package/dist/specs/obj-init.d.ts.map +1 -0
- package/dist/specs/obj-init.js +219 -0
- package/dist/specs/obj-init.js.map +1 -0
- package/dist/specs/ui-entry.d.ts +22 -0
- package/dist/specs/ui-entry.d.ts.map +1 -0
- package/dist/specs/ui-entry.js +69 -0
- package/dist/specs/ui-entry.js.map +1 -0
- package/dist/specs/visuals.d.ts +26 -0
- package/dist/specs/visuals.d.ts.map +1 -0
- package/dist/specs/visuals.js +35 -0
- package/dist/specs/visuals.js.map +1 -0
- package/pack/activation.json +3340 -0
- package/pack/artifact.json +5146 -0
- package/pack/blow_effects.json +370 -0
- package/pack/blow_methods.json +286 -0
- package/pack/body.json +59 -0
- package/pack/brand.json +100 -0
- package/pack/chest_trap.json +117 -0
- package/pack/class.json +5501 -0
- package/pack/constants.json +470 -0
- package/pack/curse.json +580 -0
- package/pack/dungeon_profile.json +836 -0
- package/pack/ego_item.json +2900 -0
- package/pack/flavor.json +1541 -0
- package/pack/hints.json +303 -0
- package/pack/history.json +1656 -0
- package/pack/manifest.json +52 -0
- package/pack/monster.json +29392 -0
- package/pack/monster_base.json +688 -0
- package/pack/monster_spell.json +2735 -0
- package/pack/names.json +1204 -0
- package/pack/object.json +11877 -0
- package/pack/object_base.json +364 -0
- package/pack/object_property.json +2034 -0
- package/pack/p_race.json +447 -0
- package/pack/pain.json +150 -0
- package/pack/pit.json +617 -0
- package/pack/player_property.json +519 -0
- package/pack/player_timed.json +1321 -0
- package/pack/projection.json +664 -0
- package/pack/quest.json +18 -0
- package/pack/realm.json +34 -0
- package/pack/room_template.json +9571 -0
- package/pack/shape.json +263 -0
- package/pack/slay.json +116 -0
- package/pack/store.json +675 -0
- package/pack/summon.json +143 -0
- package/pack/terrain.json +511 -0
- package/pack/trap.json +1483 -0
- package/pack/ui_entry.json +382 -0
- package/pack/ui_entry_base.json +60 -0
- package/pack/ui_entry_renderer.json +43 -0
- package/pack/ui_knowledge.json +302 -0
- package/pack/vault.json +4355 -0
- package/pack/visuals.json +680 -0
- package/pack/world.json +1030 -0
- package/package.json +67 -0
- package/src/compile.ts +55 -0
- package/src/data-exactness.reader.ts +559 -0
- package/src/index.ts +31 -0
- package/src/parser.ts +406 -0
- package/src/records.ts +341 -0
- package/src/specs/generate.ts +61 -0
- package/src/specs/index.ts +103 -0
- package/src/specs/init.ts +322 -0
- package/src/specs/misc.ts +153 -0
- package/src/specs/mon-init.ts +165 -0
- package/src/specs/obj-init.ts +230 -0
- package/src/specs/ui-entry.ts +74 -0
- package/src/specs/visuals.ts +37 -0
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rpgm-tools/neo-angband-content",
|
|
3
|
+
"version": "0.11.0",
|
|
4
|
+
"description": "The core content pack: Angband 4.2.6 gamedata compiled to schema-validated pack format",
|
|
5
|
+
"license": "GPL-2.0-only",
|
|
6
|
+
"author": "neostryder (RPGM Tools)",
|
|
7
|
+
"homepage": "https://github.com/neostryder/neo-angband#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/neostryder/neo-angband.git",
|
|
11
|
+
"directory": "packages/content"
|
|
12
|
+
},
|
|
13
|
+
"bugs": "https://github.com/neostryder/neo-angband/issues",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"angband",
|
|
16
|
+
"roguelike",
|
|
17
|
+
"gamedata",
|
|
18
|
+
"neo-angband",
|
|
19
|
+
"modding"
|
|
20
|
+
],
|
|
21
|
+
"type": "module",
|
|
22
|
+
"sideEffects": false,
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=22"
|
|
25
|
+
},
|
|
26
|
+
"main": "dist/index.js",
|
|
27
|
+
"types": "dist/index.d.ts",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"default": "./dist/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./package.json": "./package.json"
|
|
34
|
+
},
|
|
35
|
+
"//files": [
|
|
36
|
+
"pack/ is the point of this package - the compiled JSON the engine boots from -",
|
|
37
|
+
"so it ships alongside dist/. The negations match core's: tests live beside",
|
|
38
|
+
"sources in src/, and a bare `dist` would publish compiled test modules that",
|
|
39
|
+
"import vitest, which this package does not depend on.",
|
|
40
|
+
"src/ ships because the .js.map files point into it."
|
|
41
|
+
],
|
|
42
|
+
"files": [
|
|
43
|
+
"dist",
|
|
44
|
+
"src",
|
|
45
|
+
"pack",
|
|
46
|
+
"!**/*.test.ts",
|
|
47
|
+
"!**/*.test.js",
|
|
48
|
+
"!**/*.test.js.map",
|
|
49
|
+
"!**/*.test.d.ts",
|
|
50
|
+
"!**/*.test.d.ts.map",
|
|
51
|
+
"!**/*.tsbuildinfo",
|
|
52
|
+
"LICENSE.md",
|
|
53
|
+
"README.md"
|
|
54
|
+
],
|
|
55
|
+
"//publishConfig": [
|
|
56
|
+
"provenance is deliberately absent - see the same note in packages/core/package.json",
|
|
57
|
+
"and docs/RELEASING.md step 4. It cannot be generated outside a cloud CI runner, so",
|
|
58
|
+
"setting it would break the first manual publish a brand-new package needs."
|
|
59
|
+
],
|
|
60
|
+
"publishConfig": {
|
|
61
|
+
"access": "public"
|
|
62
|
+
},
|
|
63
|
+
"scripts": {
|
|
64
|
+
"build": "tsc",
|
|
65
|
+
"compile": "node dist/compile.js"
|
|
66
|
+
}
|
|
67
|
+
}
|
package/src/compile.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gamedata compiler CLI.
|
|
3
|
+
*
|
|
4
|
+
* Reads reference/lib/gamedata/<file>.txt (Angband 4.2.6, the read-only
|
|
5
|
+
* source of truth) and emits packages/content/pack/<file>.json plus a
|
|
6
|
+
* pack manifest. Run from the built output: `pnpm --filter
|
|
7
|
+
* @rpgm-tools/neo-angband-content compile` (or `node dist/compile.js`).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
|
|
14
|
+
import { compileGamedata } from "./records.js";
|
|
15
|
+
import { gamedataSpecs } from "./specs/index.js";
|
|
16
|
+
|
|
17
|
+
interface Manifest {
|
|
18
|
+
id: string;
|
|
19
|
+
name: string;
|
|
20
|
+
version: string;
|
|
21
|
+
engine: string;
|
|
22
|
+
files: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function main(): void {
|
|
26
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const contentRoot = path.resolve(here, "..");
|
|
28
|
+
const repoRoot = path.resolve(contentRoot, "..", "..");
|
|
29
|
+
const gamedataDir = path.join(repoRoot, "reference", "lib", "gamedata");
|
|
30
|
+
const packDir = path.join(contentRoot, "pack");
|
|
31
|
+
mkdirSync(packDir, { recursive: true });
|
|
32
|
+
|
|
33
|
+
const files: string[] = [];
|
|
34
|
+
for (const spec of gamedataSpecs) {
|
|
35
|
+
const sourcePath = path.join(gamedataDir, `${spec.name}.txt`);
|
|
36
|
+
const text = readFileSync(sourcePath, "utf8");
|
|
37
|
+
const compiled = compileGamedata(text, spec);
|
|
38
|
+
const outName = `${spec.name}.json`;
|
|
39
|
+
writeFileSync(path.join(packDir, outName), `${JSON.stringify(compiled, null, 2)}\n`);
|
|
40
|
+
files.push(outName);
|
|
41
|
+
console.log(`${outName}: ${compiled.records.length} records`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const manifest: Manifest = {
|
|
45
|
+
id: "core",
|
|
46
|
+
name: "Angband",
|
|
47
|
+
version: "4.2.6",
|
|
48
|
+
engine: ">=0.1.0",
|
|
49
|
+
files,
|
|
50
|
+
};
|
|
51
|
+
writeFileSync(path.join(packDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
52
|
+
console.log(`manifest.json: ${files.length} files`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
main();
|
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Independent gamedata re-parser for W5 data-exactness.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately does NOT import packages/content's parseLine / compileGamedata.
|
|
5
|
+
* Line-level semantics follow reference/src/parser.c (Angband 4.2.6);
|
|
6
|
+
* record assembly follows the C init parsers' attach/repeat idioms as
|
|
7
|
+
* documented in reference/src/{parser,parse,init,mon-init,obj-init}.c.
|
|
8
|
+
*
|
|
9
|
+
* Format registration data (parser_reg format strings + which directives
|
|
10
|
+
* repeat / nest) is supplied by the caller so this module stays free of
|
|
11
|
+
* packages/content's parser implementation. Because that metadata is itself
|
|
12
|
+
* port-supplied, extractParserRegFormats() below lets the caller check the
|
|
13
|
+
* format strings back against reference/src/*.c rather than trusting them.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type FieldType = "int" | "uint" | "sym" | "str" | "char" | "rand";
|
|
17
|
+
|
|
18
|
+
export interface FieldSpec {
|
|
19
|
+
readonly type: FieldType;
|
|
20
|
+
readonly name: string;
|
|
21
|
+
readonly optional: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface DirectiveSignature {
|
|
25
|
+
readonly directive: string;
|
|
26
|
+
readonly fields: readonly FieldSpec[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface DirectiveDef {
|
|
30
|
+
readonly fmt: string;
|
|
31
|
+
readonly repeat?: boolean;
|
|
32
|
+
readonly childOf?: readonly string[];
|
|
33
|
+
/**
|
|
34
|
+
* Synthetic key that records the file order of a cross-directive repeat
|
|
35
|
+
* group. C keeps such groups in one linked list (e.g. monster_drop, built by
|
|
36
|
+
* prepending in parse_monster_drop / parse_monster_drop_base), so splitting
|
|
37
|
+
* them into per-directive arrays would lose the interleaving. There is no
|
|
38
|
+
* upstream directive of this name; the key is generated, and every generated
|
|
39
|
+
* key must be declared here so the coverage guard can see it.
|
|
40
|
+
*/
|
|
41
|
+
readonly orderKey?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface FileSpec {
|
|
45
|
+
readonly name: string;
|
|
46
|
+
readonly recordStart: string | null;
|
|
47
|
+
readonly header?: readonly string[];
|
|
48
|
+
readonly directives: readonly DirectiveDef[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type JsonPrimitive = string | number | boolean;
|
|
52
|
+
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
|
|
53
|
+
export type JsonObject = { [key: string]: JsonValue };
|
|
54
|
+
|
|
55
|
+
export interface CompiledFile {
|
|
56
|
+
file: string;
|
|
57
|
+
source: string;
|
|
58
|
+
header?: JsonObject;
|
|
59
|
+
records: JsonObject[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const FIELD_TYPES: ReadonlySet<string> = new Set([
|
|
63
|
+
"int",
|
|
64
|
+
"uint",
|
|
65
|
+
"sym",
|
|
66
|
+
"str",
|
|
67
|
+
"char",
|
|
68
|
+
"rand",
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
/** C isspace() (default "C" locale). */
|
|
72
|
+
function isSpace(code: number): boolean {
|
|
73
|
+
return code === 0x20 || code === 0x09 || code === 0x0a || code === 0x0b || code === 0x0c || code === 0x0d;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function digitOf(ch: string, base: number): number | null {
|
|
77
|
+
const c = ch.charCodeAt(0);
|
|
78
|
+
let v: number;
|
|
79
|
+
if (c >= 0x30 && c <= 0x39) v = c - 0x30;
|
|
80
|
+
else if (c >= 0x41 && c <= 0x5a) v = c - 0x41 + 10;
|
|
81
|
+
else if (c >= 0x61 && c <= 0x7a) v = c - 0x61 + 10;
|
|
82
|
+
else return null;
|
|
83
|
+
return v < base ? v : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* strtol(_, _, 0)-style scan: optional sign, 0x/0 base inference, trailing
|
|
88
|
+
* garbage ignored (endptr not required to hit EOS).
|
|
89
|
+
*/
|
|
90
|
+
function scanInt(s: string, allowNegative: boolean): number | null {
|
|
91
|
+
let i = 0;
|
|
92
|
+
while (i < s.length && isSpace(s.charCodeAt(i))) i++;
|
|
93
|
+
let neg = false;
|
|
94
|
+
if (s[i] === "+" || s[i] === "-") {
|
|
95
|
+
if (s[i] === "-") {
|
|
96
|
+
if (!allowNegative) return null;
|
|
97
|
+
neg = true;
|
|
98
|
+
}
|
|
99
|
+
i++;
|
|
100
|
+
}
|
|
101
|
+
let base = 10;
|
|
102
|
+
if (s[i] === "0") {
|
|
103
|
+
const n = s[i + 1];
|
|
104
|
+
if ((n === "x" || n === "X") && s[i + 2] !== undefined && digitOf(s[i + 2]!, 16) !== null) {
|
|
105
|
+
base = 16;
|
|
106
|
+
i += 2;
|
|
107
|
+
} else {
|
|
108
|
+
base = 8;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const start = i;
|
|
112
|
+
let value = 0;
|
|
113
|
+
while (i < s.length) {
|
|
114
|
+
const d = digitOf(s[i]!, base);
|
|
115
|
+
if (d === null) break;
|
|
116
|
+
value = value * base + d;
|
|
117
|
+
i++;
|
|
118
|
+
}
|
|
119
|
+
if (i === start) return null;
|
|
120
|
+
return neg ? -value : value;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Faithful reimplementation of parse_random() from reference/src/parser.c.
|
|
125
|
+
* Validity only; the raw dice string is what gets stored.
|
|
126
|
+
*/
|
|
127
|
+
export function independentIsValidRandom(s: string): boolean {
|
|
128
|
+
let pos = 0;
|
|
129
|
+
let i = 0;
|
|
130
|
+
let minI = 1;
|
|
131
|
+
if (s[0] === "-") pos++;
|
|
132
|
+
for (;;) {
|
|
133
|
+
const c = s[pos];
|
|
134
|
+
if (c === "d") {
|
|
135
|
+
if (i > 2) return false;
|
|
136
|
+
if (i < 2) i = 2;
|
|
137
|
+
minI = 3;
|
|
138
|
+
pos++;
|
|
139
|
+
} else if (c === "M") {
|
|
140
|
+
if (i === 2) return false;
|
|
141
|
+
i = 3;
|
|
142
|
+
minI = 4;
|
|
143
|
+
pos++;
|
|
144
|
+
} else {
|
|
145
|
+
// scan base-10 number without base0 (upstream parse_random uses base 10)
|
|
146
|
+
let p = pos;
|
|
147
|
+
while (p < s.length && isSpace(s.charCodeAt(p))) p++;
|
|
148
|
+
let negative = false;
|
|
149
|
+
if (s[p] === "+" || s[p] === "-") {
|
|
150
|
+
negative = s[p] === "-";
|
|
151
|
+
p++;
|
|
152
|
+
}
|
|
153
|
+
const digStart = p;
|
|
154
|
+
let value = 0;
|
|
155
|
+
while (p < s.length) {
|
|
156
|
+
const d = digitOf(s[p]!, 10);
|
|
157
|
+
if (d === null) break;
|
|
158
|
+
value = value * 10 + d;
|
|
159
|
+
p++;
|
|
160
|
+
}
|
|
161
|
+
if (p === digStart) {
|
|
162
|
+
let t = pos;
|
|
163
|
+
while (t < s.length && isSpace(s.charCodeAt(t))) t++;
|
|
164
|
+
return t >= s.length && i >= minI;
|
|
165
|
+
}
|
|
166
|
+
if (value > 2147483647 || (negative && value !== 0) || s[pos] === "+") {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
pos = p;
|
|
170
|
+
if (i === 0) {
|
|
171
|
+
if (s[pos] === "d") i = 1;
|
|
172
|
+
else if (s[pos] === "+") {
|
|
173
|
+
pos++;
|
|
174
|
+
minI = 3;
|
|
175
|
+
} else {
|
|
176
|
+
let t = pos;
|
|
177
|
+
while (t < s.length && isSpace(s.charCodeAt(t))) t++;
|
|
178
|
+
if (t < s.length) return false;
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
} else if (i === 4) {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
i++;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function independentParseSignature(fmt: string): DirectiveSignature {
|
|
191
|
+
const tokens = fmt.split(/\s+/).filter((t) => t.length > 0);
|
|
192
|
+
const directive = tokens.shift();
|
|
193
|
+
if (directive === undefined) throw new Error(`empty format string`);
|
|
194
|
+
const fields: FieldSpec[] = [];
|
|
195
|
+
while (tokens.length > 0) {
|
|
196
|
+
const rawType = tokens.shift()!;
|
|
197
|
+
const name = tokens.shift();
|
|
198
|
+
if (name === undefined) throw new Error(`type without name in "${fmt}"`);
|
|
199
|
+
const optional = rawType.startsWith("?");
|
|
200
|
+
const typeName = optional ? rawType.slice(1) : rawType;
|
|
201
|
+
if (!FIELD_TYPES.has(typeName)) throw new Error(`unknown type "${rawType}" in "${fmt}"`);
|
|
202
|
+
const prev = fields[fields.length - 1];
|
|
203
|
+
if (prev !== undefined) {
|
|
204
|
+
if (!optional && prev.optional) throw new Error(`mandatory after optional in "${fmt}"`);
|
|
205
|
+
if (prev.type === "str") throw new Error(`field after str in "${fmt}"`);
|
|
206
|
+
}
|
|
207
|
+
fields.push({ type: typeName as FieldType, name, optional });
|
|
208
|
+
}
|
|
209
|
+
return { directive, fields };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
interface LineValues {
|
|
213
|
+
directive: string;
|
|
214
|
+
values: Record<string, string | number>;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Parse one physical line. Returns null for blanks/comments.
|
|
219
|
+
* Tokenizer mirrors strtok(":") for non-str/char and rest-of-line for str.
|
|
220
|
+
*/
|
|
221
|
+
export function independentParseLine(
|
|
222
|
+
line: string,
|
|
223
|
+
lookup: (directive: string) => DirectiveSignature | undefined,
|
|
224
|
+
): LineValues | null {
|
|
225
|
+
let start = 0;
|
|
226
|
+
while (start < line.length && isSpace(line.charCodeAt(start))) start++;
|
|
227
|
+
if (start >= line.length || line[start] === "#") return null;
|
|
228
|
+
|
|
229
|
+
// Cursor over the trimmed line body.
|
|
230
|
+
let pos = start;
|
|
231
|
+
|
|
232
|
+
const nextToken = (): string | null => {
|
|
233
|
+
while (pos < line.length && line[pos] === ":") pos++;
|
|
234
|
+
if (pos >= line.length) return null;
|
|
235
|
+
const a = pos;
|
|
236
|
+
while (pos < line.length && line[pos] !== ":") pos++;
|
|
237
|
+
const tok = line.slice(a, pos);
|
|
238
|
+
if (pos < line.length) pos++; // consume ':'
|
|
239
|
+
return tok;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const rest = (): string | null => {
|
|
243
|
+
if (pos >= line.length) return null;
|
|
244
|
+
const tok = line.slice(pos);
|
|
245
|
+
pos = line.length;
|
|
246
|
+
return tok;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const takeChar = (): string | null => {
|
|
250
|
+
if (pos >= line.length) return null;
|
|
251
|
+
const cp = line.codePointAt(pos)!;
|
|
252
|
+
const ch = String.fromCodePoint(cp);
|
|
253
|
+
pos += ch.length;
|
|
254
|
+
if (pos < line.length) {
|
|
255
|
+
if (line[pos] === ":") pos++;
|
|
256
|
+
else throw new Error(`FIELD_TOO_LONG`);
|
|
257
|
+
}
|
|
258
|
+
return ch;
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const directiveTok = nextToken();
|
|
262
|
+
if (directiveTok === null) throw new Error("MISSING_FIELD: directive");
|
|
263
|
+
const sig = lookup(directiveTok);
|
|
264
|
+
if (sig === undefined) throw new Error(`UNDEFINED_DIRECTIVE: ${directiveTok}`);
|
|
265
|
+
|
|
266
|
+
const values: Record<string, string | number> = {};
|
|
267
|
+
for (const field of sig.fields) {
|
|
268
|
+
let tok: string | null;
|
|
269
|
+
if (field.type === "char") tok = takeChar();
|
|
270
|
+
else if (field.type === "str") tok = rest();
|
|
271
|
+
else tok = nextToken();
|
|
272
|
+
|
|
273
|
+
if (tok === null) {
|
|
274
|
+
if (!field.optional) throw new Error(`MISSING_FIELD: ${field.name}`);
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
if (field.type === "int") {
|
|
278
|
+
const n = scanInt(tok, true);
|
|
279
|
+
if (n === null) throw new Error(`NOT_NUMBER: ${field.name}`);
|
|
280
|
+
values[field.name] = n;
|
|
281
|
+
} else if (field.type === "uint") {
|
|
282
|
+
if (tok[0] === "-") throw new Error(`NOT_NUMBER: ${field.name}`);
|
|
283
|
+
const n = scanInt(tok, false);
|
|
284
|
+
if (n === null) throw new Error(`NOT_NUMBER: ${field.name}`);
|
|
285
|
+
values[field.name] = n;
|
|
286
|
+
} else if (field.type === "rand") {
|
|
287
|
+
if (!independentIsValidRandom(tok)) throw new Error(`NOT_RANDOM: ${field.name}`);
|
|
288
|
+
values[field.name] = tok;
|
|
289
|
+
} else {
|
|
290
|
+
values[field.name] = tok;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return { directive: sig.directive, values };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Split a flags: payload the way C does: strtok(flags, " |"). */
|
|
297
|
+
export function splitFlagList(raw: string): string[] {
|
|
298
|
+
const out: string[] = [];
|
|
299
|
+
let i = 0;
|
|
300
|
+
while (i < raw.length) {
|
|
301
|
+
while (i < raw.length && (raw[i] === " " || raw[i] === "|")) i++;
|
|
302
|
+
if (i >= raw.length) break;
|
|
303
|
+
const a = i;
|
|
304
|
+
while (i < raw.length && raw[i] !== " " && raw[i] !== "|") i++;
|
|
305
|
+
out.push(raw.slice(a, i));
|
|
306
|
+
}
|
|
307
|
+
return out;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* A directive's compiled value. Deliberately excludes arrays so that
|
|
312
|
+
* Array.isArray() narrows a slot to "repeated" vs "single" unambiguously.
|
|
313
|
+
*/
|
|
314
|
+
type Value = JsonPrimitive | JsonObject | Container;
|
|
315
|
+
type Slot = Value | Value[];
|
|
316
|
+
|
|
317
|
+
class Container {
|
|
318
|
+
fields: Array<[string, JsonPrimitive]> = [];
|
|
319
|
+
children = new Map<string, Slot>();
|
|
320
|
+
orderGroups = new Map<string, string[]>();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
interface CompiledDirective {
|
|
324
|
+
def: DirectiveDef;
|
|
325
|
+
sig: DirectiveSignature;
|
|
326
|
+
isContainer: boolean;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function buildTable(spec: FileSpec): Map<string, CompiledDirective> {
|
|
330
|
+
const parents = new Set<string>();
|
|
331
|
+
for (const def of spec.directives) {
|
|
332
|
+
for (const p of def.childOf ?? []) parents.add(p);
|
|
333
|
+
}
|
|
334
|
+
const table = new Map<string, CompiledDirective>();
|
|
335
|
+
for (const def of spec.directives) {
|
|
336
|
+
const sig = independentParseSignature(def.fmt);
|
|
337
|
+
if (table.has(sig.directive)) {
|
|
338
|
+
throw new Error(`${spec.name}: duplicate directive "${sig.directive}"`);
|
|
339
|
+
}
|
|
340
|
+
table.set(sig.directive, { def, sig, isContainer: parents.has(sig.directive) });
|
|
341
|
+
}
|
|
342
|
+
return table;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function makeValue(cd: CompiledDirective, values: Record<string, string | number>): Value {
|
|
346
|
+
const entries: Array<[string, JsonPrimitive]> = [];
|
|
347
|
+
for (const field of cd.sig.fields) {
|
|
348
|
+
const v = values[field.name];
|
|
349
|
+
if (v !== undefined) entries.push([field.name, v]);
|
|
350
|
+
}
|
|
351
|
+
if (cd.isContainer) {
|
|
352
|
+
const node = new Container();
|
|
353
|
+
node.fields.push(...entries);
|
|
354
|
+
return node;
|
|
355
|
+
}
|
|
356
|
+
if (cd.sig.fields.length === 1) {
|
|
357
|
+
const only = entries[0];
|
|
358
|
+
return only === undefined ? true : only[1];
|
|
359
|
+
}
|
|
360
|
+
const obj: JsonObject = {};
|
|
361
|
+
for (const [k, v] of entries) obj[k] = v;
|
|
362
|
+
return obj;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function finalize(slot: Value, spec: FileSpec): JsonValue {
|
|
366
|
+
if (!(slot instanceof Container)) return slot;
|
|
367
|
+
const out: JsonObject = {};
|
|
368
|
+
for (const [k, v] of slot.fields) out[k] = v;
|
|
369
|
+
// Emit children in registration order (upstream registration order).
|
|
370
|
+
for (const def of spec.directives) {
|
|
371
|
+
const directive = def.fmt.split(/\s+/, 1)[0]!;
|
|
372
|
+
const child = slot.children.get(directive);
|
|
373
|
+
if (child === undefined) continue;
|
|
374
|
+
out[directive] = Array.isArray(child)
|
|
375
|
+
? child.map((c) => finalize(c, spec))
|
|
376
|
+
: finalize(child, spec);
|
|
377
|
+
}
|
|
378
|
+
for (const [key, order] of slot.orderGroups) out[key] = [...order];
|
|
379
|
+
return out;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Compile one gamedata file text into the same JSON shape as pack/*.json.
|
|
384
|
+
* Engine is independent of packages/content/src/{parser,records}.ts.
|
|
385
|
+
*/
|
|
386
|
+
export function independentCompile(text: string, spec: FileSpec): CompiledFile {
|
|
387
|
+
const table = buildTable(spec);
|
|
388
|
+
const lookup = (d: string) => table.get(d)?.sig;
|
|
389
|
+
|
|
390
|
+
const records: Container[] = [];
|
|
391
|
+
const headerNode = spec.header !== undefined ? new Container() : null;
|
|
392
|
+
let current: Container | null = null;
|
|
393
|
+
if (spec.recordStart === null) {
|
|
394
|
+
current = new Container();
|
|
395
|
+
records.push(current);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Most-recent container instances for childOf resolution.
|
|
399
|
+
const lastOf = new Map<string, { node: Container; seq: number }>();
|
|
400
|
+
let seq = 0;
|
|
401
|
+
|
|
402
|
+
const lines = text.split("\n");
|
|
403
|
+
for (let i = 0; i < lines.length; i++) {
|
|
404
|
+
let raw = lines[i] ?? "";
|
|
405
|
+
if (raw.endsWith("\r")) raw = raw.slice(0, -1);
|
|
406
|
+
if (i === 0 && raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1);
|
|
407
|
+
const where = `${spec.name}.txt:${i + 1}`;
|
|
408
|
+
let parsed: LineValues | null;
|
|
409
|
+
try {
|
|
410
|
+
parsed = independentParseLine(raw, lookup);
|
|
411
|
+
} catch (err) {
|
|
412
|
+
throw new Error(`${where}: ${err instanceof Error ? err.message : String(err)}`);
|
|
413
|
+
}
|
|
414
|
+
if (parsed === null) continue;
|
|
415
|
+
const cd = table.get(parsed.directive);
|
|
416
|
+
if (cd === undefined) throw new Error(`${where}: unregistered "${parsed.directive}"`);
|
|
417
|
+
|
|
418
|
+
if (spec.recordStart !== null && parsed.directive === spec.recordStart) {
|
|
419
|
+
current = new Container();
|
|
420
|
+
records.push(current);
|
|
421
|
+
lastOf.clear();
|
|
422
|
+
seq = 0;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
let root: Container;
|
|
426
|
+
if (current !== null) root = current;
|
|
427
|
+
else if (headerNode !== null && (spec.header ?? []).includes(parsed.directive)) root = headerNode;
|
|
428
|
+
else throw new Error(`${where}: directive "${parsed.directive}" before first record`);
|
|
429
|
+
|
|
430
|
+
const value = makeValue(cd, parsed.values);
|
|
431
|
+
|
|
432
|
+
let target = root;
|
|
433
|
+
if (cd.def.childOf !== undefined) {
|
|
434
|
+
let best: { node: Container; seq: number } | null = null;
|
|
435
|
+
for (const parent of cd.def.childOf) {
|
|
436
|
+
const inst = lastOf.get(parent);
|
|
437
|
+
if (inst !== undefined && (best === null || inst.seq > best.seq)) best = inst;
|
|
438
|
+
}
|
|
439
|
+
if (best !== null) target = best.node;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (cd.def.repeat === true) {
|
|
443
|
+
const existing = target.children.get(parsed.directive);
|
|
444
|
+
const occurrence = Array.isArray(existing) ? existing.length : 0;
|
|
445
|
+
if (existing === undefined) target.children.set(parsed.directive, [value]);
|
|
446
|
+
else if (Array.isArray(existing)) existing.push(value);
|
|
447
|
+
else throw new Error(`${where}: repeat collision on "${parsed.directive}"`);
|
|
448
|
+
if (cd.def.orderKey !== undefined) {
|
|
449
|
+
const order = target.orderGroups.get(cd.def.orderKey) ?? [];
|
|
450
|
+
order.push(`${parsed.directive}:${occurrence}`);
|
|
451
|
+
target.orderGroups.set(cd.def.orderKey, order);
|
|
452
|
+
}
|
|
453
|
+
} else {
|
|
454
|
+
if (target.children.has(parsed.directive)) {
|
|
455
|
+
throw new Error(`${where}: duplicate "${parsed.directive}" (not marked repeat)`);
|
|
456
|
+
}
|
|
457
|
+
target.children.set(parsed.directive, value);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (value instanceof Container) {
|
|
461
|
+
lastOf.set(parsed.directive, { node: value, seq: ++seq });
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const finalized = records.map((r) => finalize(r, spec) as JsonObject);
|
|
466
|
+
const source = `lib/gamedata/${spec.name}.txt`;
|
|
467
|
+
if (headerNode !== null && headerNode.children.size > 0) {
|
|
468
|
+
return {
|
|
469
|
+
file: spec.name,
|
|
470
|
+
source,
|
|
471
|
+
header: finalize(headerNode, spec) as JsonObject,
|
|
472
|
+
records: finalized,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
return { file: spec.name, source, records: finalized };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* The directive of every data line in a gamedata file, in file order.
|
|
480
|
+
*
|
|
481
|
+
* Mirrors the front of parser_parse(): skip leading isspace, drop blank and '#'
|
|
482
|
+
* lines, then take the first strtok(line, ":") token (strtok skips a run of
|
|
483
|
+
* leading delimiters). Deliberately does no field parsing, so it is an
|
|
484
|
+
* independent check on the record splitter and the directive table.
|
|
485
|
+
*/
|
|
486
|
+
export function extractDirectiveSequence(text: string): string[] {
|
|
487
|
+
const out: string[] = [];
|
|
488
|
+
const lines = text.split("\n");
|
|
489
|
+
for (let i = 0; i < lines.length; i++) {
|
|
490
|
+
let raw = lines[i] ?? "";
|
|
491
|
+
if (raw.endsWith("\r")) raw = raw.slice(0, -1);
|
|
492
|
+
if (i === 0 && raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1);
|
|
493
|
+
let pos = 0;
|
|
494
|
+
while (pos < raw.length && isSpace(raw.charCodeAt(pos))) pos++;
|
|
495
|
+
if (pos >= raw.length || raw[pos] === "#") continue;
|
|
496
|
+
while (pos < raw.length && raw[pos] === ":") pos++;
|
|
497
|
+
if (pos >= raw.length) continue;
|
|
498
|
+
const start = pos;
|
|
499
|
+
while (pos < raw.length && raw[pos] !== ":") pos++;
|
|
500
|
+
out.push(raw.slice(start, pos));
|
|
501
|
+
}
|
|
502
|
+
return out;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Every directive key that occurs in a gamedata file, with its line count. */
|
|
506
|
+
export function extractDirectiveKeys(text: string): Map<string, number> {
|
|
507
|
+
const counts = new Map<string, number>();
|
|
508
|
+
for (const key of extractDirectiveSequence(text)) {
|
|
509
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
510
|
+
}
|
|
511
|
+
return counts;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* parse_specs() tokenizes the format string with strtok(fmt, " "), so runs of
|
|
516
|
+
* whitespace collapse and leading/trailing whitespace is insignificant.
|
|
517
|
+
*/
|
|
518
|
+
export function normalizeFormat(fmt: string): string {
|
|
519
|
+
return fmt.split(/\s+/).filter((t) => t.length > 0).join(" ");
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Every format string handed to parser_reg() in a C source file, normalized.
|
|
524
|
+
*
|
|
525
|
+
* Adjacent string literals are concatenated the way the C compiler does, so
|
|
526
|
+
* registrations wrapped across source lines (e.g. init.c's melee-critical-level)
|
|
527
|
+
* come back as the single format string parse_specs() actually sees.
|
|
528
|
+
*/
|
|
529
|
+
export function extractParserRegFormats(source: string): string[] {
|
|
530
|
+
const out: string[] = [];
|
|
531
|
+
const needle = "parser_reg(";
|
|
532
|
+
let at = 0;
|
|
533
|
+
for (;;) {
|
|
534
|
+
const call = source.indexOf(needle, at);
|
|
535
|
+
if (call < 0) break;
|
|
536
|
+
at = call + needle.length;
|
|
537
|
+
/* The format is the first string literal argument of the call. */
|
|
538
|
+
let pos = at;
|
|
539
|
+
while (pos < source.length && source[pos] !== '"' && source[pos] !== ";") pos++;
|
|
540
|
+
if (source[pos] !== '"') continue;
|
|
541
|
+
let fmt = "";
|
|
542
|
+
for (;;) {
|
|
543
|
+
pos++; /* past the opening quote */
|
|
544
|
+
while (pos < source.length && source[pos] !== '"') {
|
|
545
|
+
if (source[pos] === "\\") pos++;
|
|
546
|
+
fmt += source[pos] ?? "";
|
|
547
|
+
pos++;
|
|
548
|
+
}
|
|
549
|
+
pos++; /* past the closing quote */
|
|
550
|
+
let peek = pos;
|
|
551
|
+
while (peek < source.length && isSpace(source.charCodeAt(peek))) peek++;
|
|
552
|
+
if (source[peek] !== '"') break;
|
|
553
|
+
pos = peek;
|
|
554
|
+
}
|
|
555
|
+
out.push(normalizeFormat(fmt));
|
|
556
|
+
at = pos;
|
|
557
|
+
}
|
|
558
|
+
return out;
|
|
559
|
+
}
|