afnm-types 0.6.49 → 0.6.51
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/dist/CharacterCraftingEncounter.d.ts +7 -0
- package/dist/CharacterCraftingEncounter.js +1 -0
- package/dist/GameScreen.d.ts +1 -1
- package/dist/avatarEffects.d.ts +23 -0
- package/dist/avatarEffects.js +98 -0
- package/dist/buff.d.ts +15 -1
- package/dist/buildSubmission.d.ts +111 -0
- package/dist/buildSubmission.js +1 -0
- package/dist/character.d.ts +7 -0
- package/dist/cli/extract-mod-translations/index.js +367 -0
- package/dist/cli/extract-translations/config.js +632 -0
- package/dist/cli/extract-translations/extractors.js +1283 -0
- package/dist/cli/extract-translations/registries.js +40 -0
- package/dist/cli/extract-translations/registry-builders.js +661 -0
- package/dist/cli/extract-translations/reporters.js +1369 -0
- package/dist/cli/extract-translations/resolvers.js +180 -0
- package/dist/cli/extract-translations/template-processor.js +380 -0
- package/dist/cli/extract-translations/types.js +5 -0
- package/dist/craftingTechnique.d.ts +1 -0
- package/dist/element.js +3 -3
- package/dist/entity.d.ts +16 -0
- package/dist/event.d.ts +6 -2
- package/dist/event.js +1 -0
- package/dist/gameVersion.d.ts +1 -1
- package/dist/gameVersion.js +1 -1
- package/dist/herbField.d.ts +0 -1
- package/dist/item.d.ts +36 -2
- package/dist/item.js +5 -0
- package/dist/itemHarmonyType.js +1 -0
- package/dist/location.d.ts +5 -2
- package/dist/location.js +1 -0
- package/dist/mod.d.ts +110 -1
- package/dist/reduxState.d.ts +41 -1
- package/dist/stat.d.ts +1 -1
- package/dist/stat.js +11 -1
- package/dist/technique.d.ts +6 -1
- package/package.json +11 -3
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/GameScreen.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export type GameScreen = 'location' | 'recipe' | 'mission' | 'craftingHall' | 'manual' | 'cultivation' | 'map' | 'healer' | 'market' | 'favour' | 'herbField' | 'mine' | 'recipeLibrary' | 'requestBoard' | 'compendium' | 'library' | 'altar' | 'research' | 'reforge' | 'pillarGrid' | 'fallenStar' | 'trainingGround' | 'tenThousandFlames' | 'lifeScreen' | 'soulShardDelve' | 'enchantmentShop';
|
|
1
|
+
export type GameScreen = 'location' | 'recipe' | 'mission' | 'craftingHall' | 'manual' | 'cultivation' | 'map' | 'healer' | 'market' | 'favour' | 'herbField' | 'mine' | 'recipeLibrary' | 'requestBoard' | 'compendium' | 'library' | 'altar' | 'research' | 'reforge' | 'pillarGrid' | 'fallenStar' | 'trainingGround' | 'tenThousandFlames' | 'lifeScreen' | 'soulShardDelve' | 'enchantmentShop' | 'challengeBoard';
|
|
@@ -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,111 @@
|
|
|
1
|
+
import { BreakthroughState } from './breakthrough';
|
|
2
|
+
import { PhysicalStatistic } from './stat';
|
|
3
|
+
import { KnownTechnique } from './technique';
|
|
4
|
+
import { StoredStance, StoredStyle } from './entity';
|
|
5
|
+
import { TechniqueElement } from './element';
|
|
6
|
+
import { ItemDesc } from './item';
|
|
7
|
+
/**
|
|
8
|
+
* Optional combat modifiers chosen by the submitter when posting a challenge seal.
|
|
9
|
+
* Applied symmetrically to both sides when the challenge is accepted.
|
|
10
|
+
*/
|
|
11
|
+
export interface ChallengeModifiers {
|
|
12
|
+
/** Number of grace period rounds both sides get damage reduction at the start of combat (0 or undefined = no grace period). */
|
|
13
|
+
gracePeriodRounds?: number;
|
|
14
|
+
/** Restrict both sides to a single technique school — all other schools are disabled (null or undefined = no restriction). */
|
|
15
|
+
schoolVow?: TechniqueElement | null;
|
|
16
|
+
/** Disable auto-use items (pills/concoctions) for both sides. */
|
|
17
|
+
disableItems?: boolean;
|
|
18
|
+
/** Disable qi droplets for both sides — techniques with droplet costs cannot be used. */
|
|
19
|
+
disableDroplets?: boolean;
|
|
20
|
+
/** Both sides gain 1 stack of Shadow Sickness at the start of each round. */
|
|
21
|
+
shadowSickness?: boolean;
|
|
22
|
+
}
|
|
23
|
+
export interface SubmittedEquipment {
|
|
24
|
+
clothing: string | ItemDesc | undefined;
|
|
25
|
+
mount: string | ItemDesc | undefined;
|
|
26
|
+
talismans: (string | ItemDesc | undefined)[];
|
|
27
|
+
artefacts: (string | ItemDesc | undefined)[];
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Serialisable representation of a player build, ready to send to the server.
|
|
31
|
+
* Stored temporarily in GameEventState.pendingBuildSubmission until dispatched.
|
|
32
|
+
*
|
|
33
|
+
* Stat normalisation:
|
|
34
|
+
* physicalStatRelativeRatios — each stat's share of the player's total
|
|
35
|
+
* physical stat pool (all values sum to 1.0). On the server the NPC
|
|
36
|
+
* rebuild allocates `getExpectedPhysicalStat(realm) × 6` points distributed
|
|
37
|
+
* according to these ratios, so modded absolute values never inflate the NPC.
|
|
38
|
+
*/
|
|
39
|
+
export interface NormalizedBuildSubmission {
|
|
40
|
+
/** Game version that produced the submission. */
|
|
41
|
+
version: string;
|
|
42
|
+
/** Stable per-save UUID that identifies the submitting player. */
|
|
43
|
+
playerId: string;
|
|
44
|
+
/** Player-chosen display name for the board entry. */
|
|
45
|
+
displayName: string;
|
|
46
|
+
realm: string;
|
|
47
|
+
realmProgress: string;
|
|
48
|
+
/**
|
|
49
|
+
* Relative distribution of physical stats. Each value is the fraction of
|
|
50
|
+
* the player's *total* physical stat pool allocated to that stat
|
|
51
|
+
* (values sum to 1.0 across all six stats).
|
|
52
|
+
*/
|
|
53
|
+
physicalStatRelativeRatios: Record<PhysicalStatistic, number>;
|
|
54
|
+
/** Raw battlesense value. */
|
|
55
|
+
battlesense: number;
|
|
56
|
+
/** All techniques known by the player at time of submission. */
|
|
57
|
+
knownTechniques: KnownTechnique[];
|
|
58
|
+
/** The active style at time of submission, or null. */
|
|
59
|
+
currentStyle: StoredStyle | null;
|
|
60
|
+
/** Equipment item names at time of submission. */
|
|
61
|
+
equipment: SubmittedEquipment;
|
|
62
|
+
submittedAt: string;
|
|
63
|
+
/** Player sex — used to select the appropriate default NPC sprite when images are not yet approved. */
|
|
64
|
+
sex?: string;
|
|
65
|
+
/**
|
|
66
|
+
* Portrait index into the player sprite array for this sex.
|
|
67
|
+
* Indices < MALE/FEMALE_BUILT_IN_COUNT refer to base-game portraits and are
|
|
68
|
+
* used directly at roster load time. Indices >= the built-in count indicate a
|
|
69
|
+
* mod sprite; these are serialised to base64 and sent through the custom image
|
|
70
|
+
* approval flow the same way user-uploaded images are.
|
|
71
|
+
*/
|
|
72
|
+
imageIndex: number;
|
|
73
|
+
/** Background IDs — resolved via backgroundMap at roster load time. */
|
|
74
|
+
background: string[];
|
|
75
|
+
/** Destiny IDs — resolved via destiniesMap for combat stat bonuses. */
|
|
76
|
+
destiny?: string[];
|
|
77
|
+
/** Names of consumed stat pills — resolved via itemMap for permanent rawStats bonuses. */
|
|
78
|
+
consumedStatPills?: string[];
|
|
79
|
+
/** Full breakthrough state — used for condensation art, pillar shards, figments, etc. */
|
|
80
|
+
breakthrough: BreakthroughState;
|
|
81
|
+
/** Technique affinities — used to apply the player's affinity multipliers in combat. */
|
|
82
|
+
affinities?: Partial<Record<string, number>>;
|
|
83
|
+
/** Pills per round from socialStats — applied directly to the combat entity. */
|
|
84
|
+
pillsPerRound?: number;
|
|
85
|
+
/** Qi droplets at time of submission — applied directly to the combat entity. */
|
|
86
|
+
qiDroplets?: number;
|
|
87
|
+
/**
|
|
88
|
+
* Custom combat image filenames (locally stored, not base64).
|
|
89
|
+
* Resolved to base64 before submission by the useBuildSubmission hook.
|
|
90
|
+
*/
|
|
91
|
+
customImages?: {
|
|
92
|
+
idle?: string;
|
|
93
|
+
hit?: string;
|
|
94
|
+
support?: string;
|
|
95
|
+
defensive?: string;
|
|
96
|
+
utility?: string;
|
|
97
|
+
offensive?: string;
|
|
98
|
+
aggressive?: string;
|
|
99
|
+
};
|
|
100
|
+
/** Optional combat modifiers chosen by the submitter — applied symmetrically to both sides. */
|
|
101
|
+
combatModifiers?: ChallengeModifiers;
|
|
102
|
+
/** Manifested figment (lifeform companion) at time of submission — added as a party member during challenge fights. */
|
|
103
|
+
lifeform?: {
|
|
104
|
+
primarySpecies: string;
|
|
105
|
+
secondarySpecies: string;
|
|
106
|
+
/** Display name (custom or auto-generated). */
|
|
107
|
+
name?: string;
|
|
108
|
+
stances: StoredStance[];
|
|
109
|
+
currentStyle?: StoredStyle;
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/character.d.ts
CHANGED
|
@@ -273,6 +273,13 @@ export interface CharacterEncounter {
|
|
|
273
273
|
max: number;
|
|
274
274
|
};
|
|
275
275
|
locations?: string[];
|
|
276
|
+
/**
|
|
277
|
+
* Marks this encounter as part of the global pity pool.
|
|
278
|
+
* Works the same way as `pity` on location events: the encounter's selection
|
|
279
|
+
* weight is multiplied by the global pity counter, and a successful pity
|
|
280
|
+
* encounter resets the counter.
|
|
281
|
+
*/
|
|
282
|
+
pity?: true;
|
|
276
283
|
}
|
|
277
284
|
export type CharacterDefinition = NeutralCharacterDefinition | EnemyCharacterDefinition | CompanionCharacterDefinition;
|
|
278
285
|
interface BaseCharacterDefinition {
|
|
@@ -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
|
+
});
|