s2cfgtojson 1.0.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/Struct.mts ADDED
@@ -0,0 +1,222 @@
1
+ export type Value = Omit<Struct, "toTs"> | string | boolean | number;
2
+ type DefaultEntries = { _isArray?: boolean; _useAsterisk?: boolean };
3
+ export type Entries = Record<string | number, Value> & DefaultEntries;
4
+
5
+ /**
6
+ * This file is part of the Stalker 2 Modding Tools project.
7
+ * This is a base class for all structs.
8
+ */
9
+ export abstract class Struct<T extends Entries = {}> {
10
+ isRoot?: boolean;
11
+ refurl?: string;
12
+ refkey?: string | number;
13
+ bskipref?: boolean;
14
+ abstract _id: string;
15
+ entries: T;
16
+
17
+ static TAB = " ";
18
+ static pad(text: string): string {
19
+ return `${Struct.TAB}${text.replace(/\n+/g, `\n${Struct.TAB}`)}`;
20
+ }
21
+ static WILDCARD = "_wildcard";
22
+ static isNumber(ref: string): boolean {
23
+ return Number.isInteger(parseInt(ref)) || typeof ref === "number";
24
+ }
25
+ static isArrayKey(key: string) {
26
+ return key.includes("[") && key.includes("]");
27
+ }
28
+ static renderKeyName(ref: string, useAsterisk?: boolean): string {
29
+ if (`${ref}`.startsWith("_")) {
30
+ return Struct.renderKeyName(ref.slice(1), useAsterisk); // Special case for indexed structs
31
+ }
32
+ if (`${ref}`.includes("*") || useAsterisk) {
33
+ return "[*]"; // Special case for wildcard structs
34
+ }
35
+ if (`${ref}`.includes("_dupe_")) {
36
+ return Struct.renderKeyName(ref.slice(0, ref.indexOf("_dupe_")));
37
+ }
38
+ if (Struct.isNumber(ref)) {
39
+ return `[${parseInt(ref)}]`;
40
+ }
41
+ return ref;
42
+ }
43
+
44
+ static renderStructName(name: string): string {
45
+ if (name === Struct.WILDCARD) {
46
+ return "[*]"; // Special case for wildcard structs
47
+ }
48
+ if (`${name}`.startsWith("_")) {
49
+ return Struct.renderStructName(name.slice(1)); // Special case for indexed structs
50
+ }
51
+ if (Struct.isNumber(name)) {
52
+ return `[${parseInt(name)}]`;
53
+ }
54
+ return name;
55
+ }
56
+
57
+ static extractKeyFromBrackets(key: string) {
58
+ if (/\[(.+)]/.test(key)) {
59
+ return key.match(/\[(.+)]/)[1];
60
+ }
61
+ return "";
62
+ }
63
+
64
+ static parseStructName(name: string): string {
65
+ if (Struct.extractKeyFromBrackets(name) === "*") {
66
+ return Struct.WILDCARD; // Special case for wildcard structs
67
+ }
68
+ if (Struct.isNumber(Struct.extractKeyFromBrackets(name))) {
69
+ return `_${name.match(/\[(\d+)]/)[1]}`; // Special case for indexed structs
70
+ }
71
+ return name;
72
+ }
73
+
74
+ static createDynamicClass = (name: string): new () => Struct =>
75
+ new Function("parent", `return class ${name} extends parent {}`)(Struct);
76
+
77
+ toString(): string {
78
+ let text: string;
79
+ text = this.isRoot ? `${Struct.renderStructName(this._id)} : ` : "";
80
+ text += "struct.begin";
81
+ const refs = ["refurl", "refkey", "bskipref"]
82
+ .map((k) => [k, this[k]])
83
+ .filter(([_, v]) => v !== "" && v !== undefined && v !== false)
84
+ .map(([k, v]) => {
85
+ if (v === true) return k;
86
+ return `${k}=${Struct.renderKeyName(v)}`;
87
+ })
88
+ .join(";");
89
+ if (refs) {
90
+ text += ` {${refs}}`;
91
+ }
92
+ text += "\n";
93
+ // Add all keys
94
+ text += Object.entries(this.entries || {})
95
+ .filter(([key]) => key !== "_useAsterisk" && key !== "_isArray")
96
+ .map(([key, value]) => {
97
+ const keyOrIndex = Struct.renderKeyName(key, this.entries._useAsterisk);
98
+ const equalsOrColon = value instanceof Struct ? ":" : "=";
99
+ const spaceOrNoSpace = value === "" ? "" : " ";
100
+ return Struct.pad(
101
+ `${keyOrIndex} ${equalsOrColon}${spaceOrNoSpace}${value}`,
102
+ );
103
+ })
104
+ .join("\n");
105
+ text += "\nstruct.end";
106
+ return text;
107
+ }
108
+
109
+ toTs(): string {
110
+ return JSON.stringify(this, null, 2);
111
+ }
112
+
113
+ static addEntry(
114
+ parent: Struct<DefaultEntries>,
115
+ key: string,
116
+ value: Value,
117
+ index: number,
118
+ ) {
119
+ parent.entries ||= {};
120
+
121
+ const getKey = () => {
122
+ let normKey: string | number = key;
123
+ if (Struct.isArrayKey(key)) {
124
+ parent.entries._isArray = true;
125
+ normKey = Struct.extractKeyFromBrackets(key);
126
+
127
+ if (normKey === "*") {
128
+ parent.entries._useAsterisk = true;
129
+ return Object.keys(parent.entries).length;
130
+ }
131
+
132
+ if (parent.entries[normKey] !== undefined) {
133
+ return `${normKey}_dupe_${index}`;
134
+ }
135
+ return normKey;
136
+ }
137
+ if (parent.entries[normKey] !== undefined) {
138
+ return `${normKey}_dupe_${index}`;
139
+ }
140
+ return normKey;
141
+ };
142
+
143
+ parent.entries[getKey()] = value;
144
+ }
145
+
146
+ static fromString<IntendedType extends Partial<Struct> = Struct>(
147
+ text: string,
148
+ ): IntendedType[] {
149
+ const lines = text.trim().split("\n");
150
+
151
+ const parseHead = (line: string): Struct => {
152
+ const match = line.match(
153
+ /^(.*)\s*:\s*struct\.begin\s*({\s*((refurl|refkey|bskipref)\s*(=.+)?)\s*})?/,
154
+ );
155
+ if (!match) {
156
+ throw new Error(`Invalid struct head: ${line}`);
157
+ }
158
+ let name = Struct.parseStructName(match[1].trim());
159
+
160
+ const dummy = new (Struct.createDynamicClass(name))();
161
+ if (name === match[1].trim()) {
162
+ dummy._id = name;
163
+ }
164
+ if (match[3]) {
165
+ const refs = match[3]
166
+ .split(";")
167
+ .map((ref) => ref.trim())
168
+ .filter(Boolean)
169
+ .reduce(
170
+ (acc, ref) => {
171
+ const [key, value] = ref.split("=");
172
+ acc[key.trim()] = value ? value.trim() : true;
173
+ return acc;
174
+ },
175
+ {} as { refurl?: string; refkey?: string; bskipref?: boolean },
176
+ );
177
+ if (refs.refurl) dummy.refurl = refs.refurl;
178
+ if (refs.refkey) dummy.refkey = refs.refkey;
179
+ if (refs.bskipref) dummy.bskipref = refs.bskipref;
180
+ }
181
+ return dummy as Struct;
182
+ };
183
+
184
+ const parseKeyValue = (line: string, parent: Struct): void => {
185
+ const match = line.match(/^(.*?)(\s*:\s*|\s*=\s*)(.*)$/);
186
+ if (!match) {
187
+ throw new Error(`Invalid key-value pair: ${line}`);
188
+ }
189
+ const key = match[1].trim();
190
+ const value = match[3].trim();
191
+ Struct.addEntry(parent, key, value, index);
192
+ };
193
+ let index = 0;
194
+
195
+ const walk = () => {
196
+ const roots: Struct[] = [];
197
+ const stack = [];
198
+ while (index < lines.length) {
199
+ const line = lines[index++].trim();
200
+ const current = stack[stack.length - 1];
201
+ if (line.includes("struct.begin")) {
202
+ const newStruct = parseHead(line);
203
+ if (current) {
204
+ const key = Struct.renderStructName(newStruct.constructor.name);
205
+ Struct.addEntry(current, key, newStruct, index);
206
+ } else {
207
+ newStruct.isRoot = true;
208
+ roots.push(newStruct);
209
+ }
210
+ stack.push(newStruct);
211
+ } else if (line.includes("struct.end")) {
212
+ stack.pop();
213
+ } else if (line.includes("=") && current) {
214
+ parseKeyValue(line, current);
215
+ }
216
+ }
217
+ return roots;
218
+ };
219
+
220
+ return walk() as IntendedType[];
221
+ }
222
+ }
@@ -0,0 +1,174 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { Struct } from "./Struct.mjs";
3
+
4
+ class ChimeraHPFix extends Struct {
5
+ _id = "ChimeraHPFix";
6
+ bskipref = true;
7
+ entries = { MaxHP: 750 };
8
+ isRoot = true;
9
+ }
10
+ class TradersDontBuyWeaponsArmor extends Struct {
11
+ _id = "TradersDontBuyWeaponsArmor";
12
+ refurl = "../TradePrototypes.cfg";
13
+ refkey = 0;
14
+ isRoot = true;
15
+ entries = { TradeGenerators: new TradeGenerators() };
16
+ }
17
+ class TradeGenerators extends Struct {
18
+ _id = "TradeGenerators";
19
+ entries = { "*": new TradeGenerator() };
20
+ }
21
+ class TradeGenerator extends Struct {
22
+ _id = "TradeGenerator";
23
+ entries = { BuyLimitations: new BuyLimitations() };
24
+ }
25
+ class BuyLimitations extends Struct {
26
+ _id = "BuyLimitations";
27
+ entries = { [0]: "EItemType::Weapon", [1]: "EItemType::Armor" };
28
+ }
29
+
30
+ describe("Struct", () => {
31
+ test("toString()", () => {
32
+ expect(new ChimeraHPFix().toString()).toBe(
33
+ `ChimeraHPFix : struct.begin {bskipref}
34
+ MaxHP = 750
35
+ struct.end`,
36
+ );
37
+
38
+ expect(new TradersDontBuyWeaponsArmor().toString()).toBe(
39
+ `TradersDontBuyWeaponsArmor : struct.begin {refurl=../TradePrototypes.cfg;refkey=[0]}
40
+ TradeGenerators : struct.begin
41
+ [*] : struct.begin
42
+ BuyLimitations : struct.begin
43
+ [0] = EItemType::Weapon
44
+ [1] = EItemType::Armor
45
+ struct.end
46
+ struct.end
47
+ struct.end
48
+ struct.end`,
49
+ );
50
+ });
51
+
52
+ test("pad()", () => {
53
+ expect(Struct.pad("test")).toBe(" test");
54
+ expect(Struct.pad(Struct.pad("test"))).toBe(" test");
55
+ });
56
+
57
+ describe("fromString()", () => {
58
+ test("1", () => {
59
+ const chimeraText = new ChimeraHPFix().toString();
60
+
61
+ expect(Struct.fromString(chimeraText)[0].toString()).toBe(chimeraText);
62
+ });
63
+
64
+ test("2", () => {
65
+ const complexStructText = `BasePhantomAttack : struct.begin {refkey=BaseAttackAbility}
66
+ TriggeredCooldowns : struct.begin
67
+ [0] : struct.begin
68
+ CooldownTag = Ability.Cooldown.RunAttack
69
+ Duration = 50.f
70
+ struct.end
71
+ struct.end
72
+ Effects : struct.begin
73
+ [0] : struct.begin
74
+ EffectPrototypeSID = MutantMediumAttackCameraShake
75
+ Chance = 1.f
76
+ struct.end
77
+ struct.end
78
+ struct.end`;
79
+ expect(Struct.fromString(complexStructText)[0].toString()).toBe(
80
+ complexStructText,
81
+ );
82
+ });
83
+
84
+ test("3", () => {
85
+ const dynamicItemGeneratorText = `DynamicTraderItemGenerator : struct.begin {refurl=../ItemGeneratorPrototypes.cfg;refkey=[0]}
86
+ SID = DynamicTraderItemGenerator
87
+ ItemGenerator : struct.begin
88
+ [0] : struct.begin
89
+ Category = EItemGenerationCategory::WeaponPrimary
90
+ PlayerRank = ERank::Newbie
91
+ bAllowSameCategoryGeneration = true
92
+ ReputationThreshold = -500
93
+ RefreshTime = 1h
94
+ PossibleItems : struct.begin
95
+ [0] : struct.begin
96
+ ItemPrototypeSID = GunMark_SP
97
+ Weight = 1
98
+ MinDurability = 0.3
99
+ MaxDurability = 0.6
100
+ AmmoMinCount = 600
101
+ AmmoMaxCount = 900
102
+ struct.end
103
+ struct.end
104
+ struct.end
105
+ [1] : struct.begin
106
+ Category = EItemGenerationCategory::BodyArmor
107
+ PlayerRank = ERank::Newbie
108
+ bAllowSameCategoryGeneration = true
109
+ ReputationThreshold = -500
110
+ RefreshTime = 1h
111
+ PossibleItems : struct.begin
112
+ [0] : struct.begin
113
+ ItemPrototypeSID = DutyArmor_3_U1
114
+ Weight = 1
115
+ MinDurability = 1.
116
+ MaxDurability = 1.
117
+ struct.end
118
+ struct.end
119
+ struct.end
120
+ [2] : struct.begin
121
+ Category = EItemGenerationCategory::WeaponPistol
122
+ ReputationThreshold = -500
123
+ bAllowSameCategoryGeneration = true
124
+ RefreshTime = 1h
125
+ PossibleItems : struct.begin
126
+ [0] : struct.begin
127
+ ItemPrototypeSID = GunAPB_HG
128
+ Weight = 1
129
+ MinDurability = 0.25
130
+ MaxDurability = 0.5
131
+ AmmoMinCount = 60
132
+ AmmoMaxCount = 100
133
+ struct.end
134
+ struct.end
135
+ struct.end
136
+ [3] : struct.begin
137
+ Category = EItemGenerationCategory::WeaponSecondary
138
+ PlayerRank = ERank::Veteran
139
+ bAllowSameCategoryGeneration = true
140
+ RefreshTime = 1h
141
+ PossibleItems : struct.begin
142
+ [0] : struct.begin
143
+ ItemPrototypeSID = GunTOZ_SG
144
+ Weight = 1
145
+ MinDurability = 0.3
146
+ MaxDurability = 0.6
147
+ AmmoMinCount = 60
148
+ AmmoMaxCount = 90
149
+ struct.end
150
+ struct.end
151
+ struct.end
152
+ [4] : struct.begin
153
+ Category = EItemGenerationCategory::Head
154
+ PlayerRank = ERank::Veteran
155
+ bAllowSameCategoryGeneration = true
156
+ ReputationThreshold = -500
157
+ RefreshTime = 1h
158
+ PossibleItems : struct.begin
159
+ [0] : struct.begin
160
+ ItemPrototypeSID = DutyMask_1
161
+ Weight = 2
162
+ MinDurability = 1.
163
+ MaxDurability = 1.
164
+ struct.end
165
+ struct.end
166
+ struct.end
167
+ struct.end
168
+ struct.end`;
169
+ expect(Struct.fromString(dynamicItemGeneratorText)[0].toString()).toBe(
170
+ dynamicItemGeneratorText,
171
+ );
172
+ });
173
+ });
174
+ });
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "s2cfgtojson",
3
+ "version": "1.0.0",
4
+ "description": "Converts Stalker 2 Cfg file into a POJO",
5
+ "keywords": [
6
+ "stalker",
7
+ "unrealengine",
8
+ "typescript",
9
+ "javascript",
10
+ "modding",
11
+ "mod",
12
+ "utility"
13
+ ],
14
+ "homepage": "https://github.com/sdwvit/s2cfgtojson#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/sdwvit/s2cfgtojson/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/sdwvit/s2cfgtojson.git"
21
+ },
22
+ "license": "ISC",
23
+ "author": "sdwvit",
24
+ "type": "module",
25
+ "main": "Struct.mts",
26
+ "scripts": {
27
+ "test": "vitest"
28
+ },
29
+ "devDependencies": {
30
+ "vitest": "^3.2.4",
31
+ "typescript": "^5.8.3",
32
+ "prettier": "^3.6.2"
33
+ },
34
+ "engines": {
35
+ "node": ">=24"
36
+ }
37
+ }
package/readme.md ADDED
@@ -0,0 +1,78 @@
1
+ # Stalker 2 CFG to JSON Converter
2
+
3
+ A utility to convert Stalker 2 `.cfg` configuration files into structured JavaScript objects (POJOs), enabling easier mod development and analysis.
4
+
5
+ ---
6
+
7
+ ## Key Features
8
+
9
+ - **Struct Class**: A flexible base class for parsing and generating Stalker 2 config structures, handling nested data, wildcards, and indexed entries.
10
+ - **TypeScript Support**: Built with TypeScript for type safety and clarity.
11
+
12
+ ---
13
+
14
+ ## Getting Started
15
+
16
+ ### Prerequisites
17
+ - Node.js ≥24
18
+ - npm (or pnpm/yarn)
19
+
20
+ ### Installation
21
+ ```bash
22
+ npm install
23
+ ```
24
+
25
+ ### Usage
26
+ Import the `Struct` class from `Struct.mts` to parse or generate Stalker 2 config data. For example:
27
+ ```ts
28
+ import { Struct } from './Struct.mts';
29
+
30
+ const config = new Struct();
31
+ // Use methods like `toString()` or `fromString()` to process configs
32
+ ```
33
+
34
+ ---
35
+
36
+ ## Example
37
+ Convert a `.cfg` file to a structured object:
38
+ ```ts
39
+ const configText = `
40
+ BasePhantomAttack : struct.begin {refkey=BaseAttackAbility}
41
+ TriggeredCooldowns : struct.begin
42
+ [0] : struct.begin
43
+ CooldownTag = Ability.Cooldown.RunAttack
44
+ Duration = 50.f
45
+ struct.end
46
+ struct.end
47
+ struct.end
48
+ `;
49
+ const parsed = Struct.fromString(configText)[0];
50
+ console.log(parsed.toString());
51
+ ```
52
+
53
+ ---
54
+
55
+ ## Development
56
+
57
+ ### Tests
58
+ Run tests using:
59
+ ```bash
60
+ npm test
61
+ ```
62
+
63
+ ---
64
+
65
+ ## Contributing
66
+
67
+ Feel free to contribute by submitting issues or pull requests. Ensure your code adheres to the project's coding standards and includes tests for new features.
68
+
69
+ ---
70
+
71
+ ## License
72
+ Free to use in non-commercial projects.
73
+
74
+ ---
75
+
76
+ ## Notes
77
+
78
+ Thanks, GSC and modders for providing the community with tools to enhance the modding experience.
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "compilerOptions": {
3
+ "esModuleInterop": true,
4
+ "lib": ["es2024"],
5
+ "module": "nodenext",
6
+ "allowImportingTsExtensions": true
7
+ }
8
+ }