afnm-types 0.6.49 → 0.6.50

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.
@@ -0,0 +1,7 @@
1
+ import type { EventStep } from '.';
2
+ export interface CharacterCraftingEncounter {
3
+ condition: string;
4
+ introSteps: EventStep[];
5
+ exitSteps: EventStep[];
6
+ character: string;
7
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Avatar visual effect registry.
3
+ *
4
+ * Each effect maps to a WebGL fragment shader that replaces the default
5
+ * crossfade shader while the effect is active. Mods can extend
6
+ * AVATAR_EFFECT_REGISTRY with additional string keys before combat starts.
7
+ *
8
+ * Built-in effect IDs are defined in AvatarEffectId.
9
+ */
10
+ export type AvatarEffectId = 'glitch';
11
+ export interface AvatarEffectShader {
12
+ /**
13
+ * Fragment shader source. Must accept the same base uniforms as the default
14
+ * fragment shader (u_texA, u_texB, u_mix, u_aspectA, u_aspectB,
15
+ * u_canvasAspect) plus u_time (float, seconds since the effect started).
16
+ */
17
+ fragSrc: string;
18
+ }
19
+ /**
20
+ * Central record from effect ID → shader definition.
21
+ * Mods can add entries with arbitrary string keys before combat starts.
22
+ */
23
+ export declare const AVATAR_EFFECT_REGISTRY: Record<string, AvatarEffectShader>;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Avatar visual effect registry.
3
+ *
4
+ * Each effect maps to a WebGL fragment shader that replaces the default
5
+ * crossfade shader while the effect is active. Mods can extend
6
+ * AVATAR_EFFECT_REGISTRY with additional string keys before combat starts.
7
+ *
8
+ * Built-in effect IDs are defined in AvatarEffectId.
9
+ */
10
+ // ─── Glitch shader ────────────────────────────────────────────────────────────
11
+ // Replicates the hand-authored glitch art style: strong persistent chromatic
12
+ // aberration (cyan/magenta fringing always visible), periodic brightness
13
+ // washout, and horizontal scanline tears during burst events.
14
+ const GLITCH_FRAG_SRC = `
15
+ precision mediump float;
16
+ varying vec2 v_uv;
17
+ uniform sampler2D u_texA;
18
+ uniform sampler2D u_texB;
19
+ uniform float u_mix;
20
+ uniform float u_aspectA;
21
+ uniform float u_aspectB;
22
+ uniform float u_canvasAspect;
23
+ uniform float u_time;
24
+
25
+ float hash(float n) {
26
+ return fract(sin(n) * 43758.5453);
27
+ }
28
+
29
+ vec4 sampleContain(sampler2D tex, vec2 uv, float texAspect) {
30
+ float rel = texAspect / u_canvasAspect;
31
+ vec2 scale;
32
+ if (rel > 1.0) {
33
+ scale = vec2(1.0, 1.0 / rel);
34
+ } else {
35
+ scale = vec2(rel, 1.0);
36
+ }
37
+ vec2 offset = (1.0 - scale) * 0.5;
38
+ vec2 mapped = (uv - offset) / scale;
39
+ if (mapped.x < 0.0 || mapped.x > 1.0 || mapped.y < 0.0 || mapped.y > 1.0) {
40
+ return vec4(0.0);
41
+ }
42
+ return texture2D(tex, mapped);
43
+ }
44
+
45
+ void main() {
46
+ vec2 uv = v_uv;
47
+
48
+ // Glitch burst timing — ~0.8 event slots per second, 35% chance each.
49
+ float eventT = floor(u_time * 0.8);
50
+ float isGlitching = step(0.65, hash(eventT * 1.618));
51
+ // Sharp onset, slow decay — the burst lingers before fading.
52
+ float slotFrac = fract(u_time * 0.8);
53
+ float decay = (1.0 - smoothstep(0.1, 0.9, slotFrac)) * isGlitching;
54
+
55
+ // Band-based horizontal displacement (scanline tearing) during bursts.
56
+ float band = floor(uv.y * 24.0);
57
+ float bandActive = step(0.55, hash(band + eventT * 23.7));
58
+ float bandShift = (hash(band * 5.2 + eventT * 7.1) * 2.0 - 1.0) * 0.025;
59
+ float shift = bandShift * bandActive * decay;
60
+
61
+ // Occasional large block tears.
62
+ float bigTear = step(0.92, hash(band * 3.7 + eventT * 11.3));
63
+ shift += (hash(band * 9.1 + eventT * 4.7) * 2.0 - 1.0) * 0.08 * bigTear * decay;
64
+
65
+ vec2 shiftedUv = vec2(uv.x + shift, uv.y);
66
+
67
+ // Chromatic aberration: strong baseline always present, amplified by bursts.
68
+ float aberr = 0.012 + 0.018 * decay;
69
+
70
+ float rA = sampleContain(u_texA, vec2(shiftedUv.x + aberr, shiftedUv.y), u_aspectA).r;
71
+ float gA = sampleContain(u_texA, shiftedUv, u_aspectA).g;
72
+ float bA = sampleContain(u_texA, vec2(shiftedUv.x - aberr, shiftedUv.y), u_aspectA).b;
73
+ float aA = sampleContain(u_texA, shiftedUv, u_aspectA).a;
74
+ vec4 colA = vec4(rA, gA, bA, aA);
75
+
76
+ float rB = sampleContain(u_texB, vec2(shiftedUv.x + aberr, shiftedUv.y), u_aspectB).r;
77
+ float gB = sampleContain(u_texB, shiftedUv, u_aspectB).g;
78
+ float bB = sampleContain(u_texB, vec2(shiftedUv.x - aberr, shiftedUv.y), u_aspectB).b;
79
+ float aB = sampleContain(u_texB, shiftedUv, u_aspectB).a;
80
+ vec4 colB = vec4(rB, gB, bB, aB);
81
+
82
+ vec4 col = mix(colA, colB, u_mix);
83
+
84
+ // Brightness washout during bursts — push toward overexposed white,
85
+ // scaled by alpha so transparent edges don't bloom.
86
+ float blowout = decay * 0.45;
87
+ col.rgb = mix(col.rgb, vec3(1.0), blowout * col.a);
88
+
89
+ gl_FragColor = col;
90
+ }`;
91
+ // ─── Registry ─────────────────────────────────────────────────────────────────
92
+ /**
93
+ * Central record from effect ID → shader definition.
94
+ * Mods can add entries with arbitrary string keys before combat starts.
95
+ */
96
+ export const AVATAR_EFFECT_REGISTRY = {
97
+ glitch: { fragSrc: GLITCH_FRAG_SRC },
98
+ };
package/dist/buff.d.ts CHANGED
@@ -3,6 +3,7 @@ import type { TechniqueElement } from './element';
3
3
  import type { CombatStatistic, Scaling } from './stat';
4
4
  import type { DamageType } from './DamageType';
5
5
  import type { CombatEntity } from './entity';
6
+ import { AvatarEffectId } from './avatarEffects';
6
7
  export type TechniqueCondition = ChanceTechniqueCondition | BuffTechniqueCondition | HpTechniqueCondition | ConditionTechniqueCondition | InventoryItemTechniqueCondition;
7
8
  interface BaseTechniqueCondition {
8
9
  /**
@@ -154,7 +155,7 @@ export interface Buff {
154
155
  /** Runtime guardian max HP — set automatically from guardianIntercept.maxHp at buff creation */
155
156
  guardianMaxHp?: number;
156
157
  }
157
- type BuffCombatImage = ScatterCombatImage | ArcCombatImage | FloatingCombatImage | OverlayCombatImage | CompanionCombatImage | GuardianCombatImage | GroundCombatImage | FormationCombatImage | ForegroundCombatImage | BackgroundCombatImage | TransformationCombatImage;
158
+ type BuffCombatImage = ScatterCombatImage | ArcCombatImage | FloatingCombatImage | OverlayCombatImage | CompanionCombatImage | GuardianCombatImage | GroundCombatImage | FormationCombatImage | ForegroundCombatImage | BackgroundCombatImage | TransformationCombatImage | AvatarEffectCombatImage;
158
159
  interface BaseCombatImage {
159
160
  image: string;
160
161
  imageOverrides?: {
@@ -165,6 +166,19 @@ interface BaseCombatImage {
165
166
  animations?: ('buff' | 'bump' | 'attack' | 'debuff')[];
166
167
  animateOnEntity?: boolean;
167
168
  }
169
+ /**
170
+ * Avatar effect combat image — applies a visual shader effect to the character
171
+ * avatar sprite while this buff is active. Only the last active avatarEffect
172
+ * buff applies (same priority rule as transformation).
173
+ */
174
+ export interface AvatarEffectCombatImage {
175
+ position: 'avatarEffect';
176
+ /** The shader effect to apply. Use AvatarEffectId for built-ins, or a
177
+ * custom string key registered in AVATAR_EFFECT_REGISTRY for mod effects. */
178
+ effect: AvatarEffectId | string;
179
+ animations?: ('buff' | 'bump' | 'attack' | 'debuff')[];
180
+ animateOnEntity?: boolean;
181
+ }
168
182
  export interface ScatterCombatImage extends BaseCombatImage {
169
183
  position: 'scatter';
170
184
  }
@@ -0,0 +1,367 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ /**
4
+ * Mod Translation String Extraction Script
5
+ *
6
+ * Extracts translatable strings from a mod's TypeScript source code and generates
7
+ * a template.json file that translators can fill in to provide translations.
8
+ *
9
+ * Translators fill in template.json (or a copy of it named <lang>.json), then the
10
+ * mod registers those translations at runtime via:
11
+ * import ruTranslations from './translations/ru.json';
12
+ * api.addTranslation('ru', ruTranslations);
13
+ *
14
+ * Usage: WHEN RUNNING THIS, USE THE NPM COMMAND. DO NOT TRY TO RUN DIRECTLY
15
+ * npm run extract-mod-translations -- --src <path> [--output <path>]
16
+ *
17
+ * Options:
18
+ * --src <path> Path to the mod source directory (default: ./src)
19
+ * --output <path> Path to the translations output directory (default: ./translations)
20
+ */
21
+ var __importDefault = (this && this.__importDefault) || function (mod) {
22
+ return (mod && mod.__esModule) ? mod : { "default": mod };
23
+ };
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ const fs_1 = __importDefault(require("fs"));
26
+ const path_1 = __importDefault(require("path"));
27
+ const glob_1 = require("glob");
28
+ const registries_js_1 = require("../extract-translations/registries.js");
29
+ const registry_builders_js_1 = require("../extract-translations/registry-builders.js");
30
+ const extractors_js_1 = require("../extract-translations/extractors.js");
31
+ const reporters_js_1 = require("../extract-translations/reporters.js");
32
+ // ─── CLI argument parsing ───────────────────────────────────────────────────
33
+ function parseArgs() {
34
+ const args = process.argv.slice(2);
35
+ let srcDir = './src';
36
+ let outputDir = './translations';
37
+ for (let i = 0; i < args.length; i++) {
38
+ if (args[i] === '--src' && args[i + 1]) {
39
+ srcDir = args[++i];
40
+ }
41
+ else if (args[i] === '--output' && args[i + 1]) {
42
+ outputDir = args[++i];
43
+ }
44
+ }
45
+ return { srcDir, outputDir };
46
+ }
47
+ // ─── Translation migration helpers ─────────────────────────────────────────
48
+ /**
49
+ * Build a lookup structure from an existing language file so translations
50
+ * can be migrated to the new template structure.
51
+ */
52
+ function buildTranslationLookup(lang) {
53
+ const flat = new Map();
54
+ const flatWithField = new Map();
55
+ const nested = new Map();
56
+ for (const [category, objects] of Object.entries(lang)) {
57
+ const categoryMap = new Map();
58
+ nested.set(category, categoryMap);
59
+ for (const [objectId, fields] of Object.entries(objects)) {
60
+ const objectMap = new Map();
61
+ categoryMap.set(objectId, objectMap);
62
+ for (const [key, translation] of Object.entries(fields)) {
63
+ if (typeof translation !== 'string' || !translation.trim())
64
+ continue;
65
+ // Key format: "[field] English text" or "[field:context] English text"
66
+ const match = key.match(/^\[([^\]]+)\]\s*(.+)$/s);
67
+ if (!match)
68
+ continue;
69
+ const field = match[1];
70
+ const englishText = match[2].trim();
71
+ objectMap.set(englishText, translation);
72
+ flat.set(englishText, translation);
73
+ flatWithField.set(`${field}|${englishText}`, translation);
74
+ }
75
+ }
76
+ }
77
+ return { flat, flatWithField, nested };
78
+ }
79
+ /**
80
+ * Migrate an existing language file to the new template structure.
81
+ */
82
+ function migrateTranslationsToTemplate(template, existingLang) {
83
+ const lookup = buildTranslationLookup(existingLang);
84
+ const migrated = {};
85
+ let matched = 0;
86
+ let unmatched = 0;
87
+ for (const [category, objects] of Object.entries(template)) {
88
+ migrated[category] = {};
89
+ for (const [objectId, fields] of Object.entries(objects)) {
90
+ migrated[category][objectId] = {};
91
+ for (const key of Object.keys(fields)) {
92
+ const match = key.match(/^\[([^\]]+)\]\s*(.+)$/s);
93
+ if (!match) {
94
+ migrated[category][objectId][key] = '';
95
+ unmatched++;
96
+ continue;
97
+ }
98
+ const field = match[1];
99
+ const englishText = match[2].trim();
100
+ let translation;
101
+ // Strategy 1: Exact match at same category/objectId
102
+ translation = lookup.nested.get(category)?.get(objectId)?.get(englishText);
103
+ // Strategy 1.5: Context migration — look for same key without context
104
+ if (!translation && field.includes(':')) {
105
+ const baseField = field.split(':')[0];
106
+ const existingObject = existingLang[category]?.[objectId];
107
+ if (existingObject) {
108
+ for (const [oldKey, oldTranslation] of Object.entries(existingObject)) {
109
+ if (typeof oldTranslation !== 'string' || !oldTranslation.trim())
110
+ continue;
111
+ const oldMatch = oldKey.match(/^\[([^\]]+)\]\s*(.+)$/s);
112
+ if (!oldMatch)
113
+ continue;
114
+ if (oldMatch[2].trim() === englishText && oldMatch[1] === baseField) {
115
+ translation = oldTranslation;
116
+ break;
117
+ }
118
+ }
119
+ }
120
+ }
121
+ // Strategy 2: Match by field + English text across all locations
122
+ if (!translation) {
123
+ translation = lookup.flatWithField.get(`${field}|${englishText}`);
124
+ }
125
+ // Strategy 3: Match by base field (ignore context) + English text
126
+ if (!translation && field.includes(':')) {
127
+ const baseField = field.split(':')[0];
128
+ translation = lookup.flatWithField.get(`${baseField}|${englishText}`);
129
+ }
130
+ // Strategy 4: Fallback — match English text only
131
+ if (!translation) {
132
+ translation = lookup.flat.get(englishText);
133
+ }
134
+ if (translation) {
135
+ migrated[category][objectId][key] = translation;
136
+ matched++;
137
+ }
138
+ else {
139
+ migrated[category][objectId][key] = '';
140
+ unmatched++;
141
+ }
142
+ }
143
+ }
144
+ }
145
+ return { migrated, stats: { matched, unmatched } };
146
+ }
147
+ /**
148
+ * Deep sort an object's keys alphabetically (recursive) for consistent output.
149
+ */
150
+ function deepSortObject(obj) {
151
+ if (typeof obj !== 'object' || Array.isArray(obj)) {
152
+ return obj;
153
+ }
154
+ const result = {};
155
+ for (const key of Object.keys(obj).sort((a, b) => a.localeCompare(b))) {
156
+ const value = obj[key];
157
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
158
+ result[key] = deepSortObject(value);
159
+ }
160
+ else {
161
+ result[key] = value;
162
+ }
163
+ }
164
+ return result;
165
+ }
166
+ /**
167
+ * Load existing language files from the output directory.
168
+ * These will be migrated to the new template structure.
169
+ */
170
+ function loadExistingModLanguageFiles(outputDir) {
171
+ const languageFiles = new Map();
172
+ if (!fs_1.default.existsSync(outputDir)) {
173
+ return languageFiles;
174
+ }
175
+ const entries = fs_1.default.readdirSync(outputDir);
176
+ for (const entry of entries) {
177
+ if (!entry.endsWith('.json') || entry === 'template.json')
178
+ continue;
179
+ const lang = entry.replace('.json', '');
180
+ const langPath = path_1.default.join(outputDir, entry);
181
+ try {
182
+ const data = JSON.parse(fs_1.default.readFileSync(langPath, 'utf-8'));
183
+ languageFiles.set(lang, data);
184
+ console.log(` Loaded existing translations from ${entry}`);
185
+ }
186
+ catch (error) {
187
+ const message = error instanceof Error ? error.message : String(error);
188
+ console.warn(` Warning: Failed to parse ${entry}: ${message}`);
189
+ }
190
+ }
191
+ return languageFiles;
192
+ }
193
+ /**
194
+ * Count total translatable entries in a hierarchical template.
195
+ */
196
+ function countHierarchicalStrings(template) {
197
+ let count = 0;
198
+ for (const category of Object.values(template)) {
199
+ for (const objectStrings of Object.values(category)) {
200
+ count += Object.keys(objectStrings).length;
201
+ }
202
+ }
203
+ return count;
204
+ }
205
+ // ─── Main extraction function ───────────────────────────────────────────────
206
+ async function main() {
207
+ const { srcDir, outputDir } = parseArgs();
208
+ const resolvedSrc = path_1.default.resolve(srcDir);
209
+ const resolvedOutput = path_1.default.resolve(outputDir);
210
+ console.log('Extracting translatable strings from mod...\n');
211
+ console.log(` Source directory : ${resolvedSrc}`);
212
+ console.log(` Output directory : ${resolvedOutput}\n`);
213
+ if (!fs_1.default.existsSync(resolvedSrc)) {
214
+ console.error(`Error: Source directory does not exist: ${resolvedSrc}`);
215
+ process.exit(1);
216
+ }
217
+ // Clear all registries so this run is isolated (important when running multiple times)
218
+ (0, registries_js_1.clearRegistries)();
219
+ // Find all TypeScript/TSX files in the mod source
220
+ const files = await (0, glob_1.glob)('**/*.{ts,tsx}', {
221
+ cwd: resolvedSrc,
222
+ ignore: ['**/*.test.ts', '**/*.test.tsx', '**/*.d.ts'],
223
+ absolute: true,
224
+ });
225
+ if (files.length === 0) {
226
+ console.warn('Warning: No TypeScript files found in the source directory.');
227
+ console.warn(` Searched in: ${resolvedSrc}`);
228
+ }
229
+ else {
230
+ console.log(`Found ${files.length} source file(s) to scan.\n`);
231
+ }
232
+ // PASS 1: Build string export registry
233
+ console.log('Pass 1: Building string export registry...');
234
+ for (const file of files) {
235
+ try {
236
+ (0, registry_builders_js_1.buildStringExportRegistry)(file);
237
+ }
238
+ catch (error) {
239
+ const message = error instanceof Error ? error.message : String(error);
240
+ console.warn(` Warning: Failed to parse ${file}: ${message}`);
241
+ }
242
+ }
243
+ // PASS 2: Build import registry
244
+ console.log('Pass 2: Building import registry...');
245
+ for (const file of files) {
246
+ try {
247
+ (0, registry_builders_js_1.buildImportRegistry)(file);
248
+ }
249
+ catch (error) {
250
+ const message = error instanceof Error ? error.message : String(error);
251
+ console.warn(` Warning: Failed to parse ${file}: ${message}`);
252
+ }
253
+ }
254
+ // PASS 3: Build name export registry
255
+ console.log('Pass 3: Building name export registry...');
256
+ for (const file of files) {
257
+ try {
258
+ (0, registry_builders_js_1.buildNameExportRegistry)(file);
259
+ }
260
+ catch (error) {
261
+ const message = error instanceof Error ? error.message : String(error);
262
+ console.warn(` Warning: Failed to parse ${file}: ${message}`);
263
+ }
264
+ }
265
+ // PASS 4: Build realm map registry
266
+ console.log('Pass 4: Building realm map registry...');
267
+ for (const file of files) {
268
+ try {
269
+ (0, registry_builders_js_1.buildRealmMapRegistry)(file);
270
+ }
271
+ catch (error) {
272
+ const message = error instanceof Error ? error.message : String(error);
273
+ console.warn(` Warning: Failed to parse ${file}: ${message}`);
274
+ }
275
+ }
276
+ (0, registry_builders_js_1.computeRealmMapNames)();
277
+ // PASS 5: Extract strings
278
+ console.log('Pass 5: Extracting strings...');
279
+ const allStrings = [];
280
+ for (const file of files) {
281
+ try {
282
+ const extracted = (0, extractors_js_1.extractFromFile)(file);
283
+ allStrings.push(...extracted);
284
+ }
285
+ catch (error) {
286
+ const message = error instanceof Error ? error.message : String(error);
287
+ console.error(` Error processing ${file}: ${message}`);
288
+ }
289
+ }
290
+ // PASS 5.5: Extract from xxxToName maps defined in the mod source
291
+ console.log('Pass 5.5: Extracting from xxxToName maps...');
292
+ try {
293
+ const toNameStrings = await (0, extractors_js_1.extractFromToNameMaps)(resolvedSrc);
294
+ allStrings.push(...toNameStrings);
295
+ console.log(` Found ${toNameStrings.length} strings in xxxToName maps.`);
296
+ }
297
+ catch (error) {
298
+ const message = error instanceof Error ? error.message : String(error);
299
+ console.error(` Error extracting from xxxToName maps: ${message}`);
300
+ }
301
+ // PASS 5.6: Add realm-indexed map names
302
+ let realmMapNameCount = 0;
303
+ for (const [mapKey, realmNames] of registries_js_1.realmMapResolvedNames) {
304
+ const [filePath] = mapKey.split(':');
305
+ for (const [, name] of realmNames) {
306
+ if (name && name.trim().length > 0) {
307
+ allStrings.push({
308
+ text: name,
309
+ file: filePath,
310
+ line: 0,
311
+ context: 'data-name-static',
312
+ objectType: 'item',
313
+ });
314
+ realmMapNameCount++;
315
+ }
316
+ }
317
+ }
318
+ if (realmMapNameCount > 0) {
319
+ console.log(` Found ${realmMapNameCount} realm-indexed map names.`);
320
+ }
321
+ // Collect simple and static strings for template generation
322
+ const templateStrings = allStrings.filter((s) => s.context !== 'INVALID_TEMPLATE_LITERAL' &&
323
+ !s.context.startsWith('NEEDS_MANUAL_REVIEW') &&
324
+ !s.context.includes('-dynamic'));
325
+ console.log(`\nTotal extracted strings: ${allStrings.length}`);
326
+ console.log(`Template-ready strings : ${templateStrings.length}`);
327
+ // Generate hierarchical template
328
+ console.log('\nGenerating translation template...');
329
+ const template = (0, reporters_js_1.generateHierarchicalTemplate)(templateStrings);
330
+ const sortedTemplate = deepSortObject(template);
331
+ const stringCount = countHierarchicalStrings(sortedTemplate);
332
+ console.log(` ${stringCount} unique translatable entries found.`);
333
+ // Ensure output directory exists
334
+ fs_1.default.mkdirSync(resolvedOutput, { recursive: true });
335
+ // Load existing language files for migration
336
+ console.log('\nLoading existing language files...');
337
+ const existingLanguages = loadExistingModLanguageFiles(resolvedOutput);
338
+ // Write template.json
339
+ const templatePath = path_1.default.join(resolvedOutput, 'template.json');
340
+ fs_1.default.writeFileSync(templatePath, JSON.stringify(sortedTemplate, null, 2), 'utf-8');
341
+ console.log(`\nWrote template: ${templatePath}`);
342
+ // Migrate existing language files
343
+ if (existingLanguages.size > 0) {
344
+ console.log('\nMigrating existing translations to new template structure...');
345
+ for (const [lang, existingData] of existingLanguages) {
346
+ const { migrated, stats } = migrateTranslationsToTemplate(sortedTemplate, existingData);
347
+ const sortedMigrated = deepSortObject(migrated);
348
+ const langPath = path_1.default.join(resolvedOutput, `${lang}.json`);
349
+ fs_1.default.writeFileSync(langPath, JSON.stringify(sortedMigrated, null, 2), 'utf-8');
350
+ const total = stats.matched + stats.unmatched;
351
+ const pct = total > 0 ? Math.round((stats.matched / total) * 100) : 0;
352
+ console.log(` ${lang}.json: ${stats.matched}/${total} strings preserved (${pct}%)`);
353
+ }
354
+ }
355
+ console.log('\n✓ Extraction complete!\n');
356
+ console.log('Next steps:');
357
+ console.log(` 1. Open ${templatePath}`);
358
+ console.log(' 2. Copy it to a new language file, e.g. translations/ru.json');
359
+ console.log(' 3. Fill in the translated values for each key');
360
+ console.log(' 4. Register the translations in your mod:');
361
+ console.log(" import ruTranslations from './translations/ru.json';");
362
+ console.log(" api.addTranslation('ru', ruTranslations);");
363
+ }
364
+ main().catch((error) => {
365
+ console.error('Fatal error:', error);
366
+ process.exit(1);
367
+ });