esp-modkit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +35 -0
  3. data/LICENSE +21 -0
  4. data/README.md +117 -0
  5. data/docs/architecture.md +125 -0
  6. data/docs/authoring-guide.md +206 -0
  7. data/docs/getting-started.md +183 -0
  8. data/docs/reference/api/active-project.md +22 -0
  9. data/docs/reference/api/agent.md +24 -0
  10. data/docs/reference/api/docs-generator.md +20 -0
  11. data/docs/reference/api/http-server.md +46 -0
  12. data/docs/reference/api/index.md +38 -0
  13. data/docs/reference/api/introspection.md +17 -0
  14. data/docs/reference/api/mcp-installer.md +26 -0
  15. data/docs/reference/api/mcp-server.md +27 -0
  16. data/docs/reference/api/mw-builder.md +14 -0
  17. data/docs/reference/api/mw-data-files.md +20 -0
  18. data/docs/reference/api/mw-dialogue-dsl.md +58 -0
  19. data/docs/reference/api/mw-i18n.md +20 -0
  20. data/docs/reference/api/mw-linter.md +18 -0
  21. data/docs/reference/api/mw-loader.md +26 -0
  22. data/docs/reference/api/mw-openmw-config.md +15 -0
  23. data/docs/reference/api/mw-operations.md +24 -0
  24. data/docs/reference/api/mw-preflight.md +17 -0
  25. data/docs/reference/api/mw-reference-index.md +21 -0
  26. data/docs/reference/api/mw-scaffolder.md +13 -0
  27. data/docs/reference/api/mw-script-blob.md +31 -0
  28. data/docs/reference/api/mw-script-extractor.md +17 -0
  29. data/docs/reference/api/operations.md +25 -0
  30. data/docs/reference/api/plugins.md +24 -0
  31. data/docs/reference/api/preferences.md +13 -0
  32. data/docs/reference/api/project-marker.md +23 -0
  33. data/docs/reference/api/providers.md +22 -0
  34. data/docs/reference/api/recents.md +17 -0
  35. data/docs/reference/api/ui.md +21 -0
  36. data/docs/reference/api/vcs.md +17 -0
  37. data/docs/reference/api/watcher.md +11 -0
  38. data/docs/reference/commands.md +271 -0
  39. data/docs/walkthrough.md +193 -0
  40. data/exe/esp +10 -0
  41. data/lib/esp/active_project.rb +71 -0
  42. data/lib/esp/agent.rb +104 -0
  43. data/lib/esp/cli/docs.rb +44 -0
  44. data/lib/esp/cli/i18n.rb +67 -0
  45. data/lib/esp/cli/mcp.rb +52 -0
  46. data/lib/esp/cli/plugins.rb +42 -0
  47. data/lib/esp/cli/refs.rb +137 -0
  48. data/lib/esp/cli/support.rb +87 -0
  49. data/lib/esp/cli.rb +317 -0
  50. data/lib/esp/docs_generator.rb +148 -0
  51. data/lib/esp/http_server.rb +232 -0
  52. data/lib/esp/introspection.rb +151 -0
  53. data/lib/esp/mcp_installer.rb +122 -0
  54. data/lib/esp/mcp_server.rb +465 -0
  55. data/lib/esp/mw/builder.rb +71 -0
  56. data/lib/esp/mw/data_files.rb +67 -0
  57. data/lib/esp/mw/dialogue_dsl.rb +209 -0
  58. data/lib/esp/mw/i18n.rb +113 -0
  59. data/lib/esp/mw/linter.rb +103 -0
  60. data/lib/esp/mw/loader.rb +130 -0
  61. data/lib/esp/mw/openmw_config.rb +138 -0
  62. data/lib/esp/mw/operations.rb +374 -0
  63. data/lib/esp/mw/preflight.rb +161 -0
  64. data/lib/esp/mw/reference_index.rb +182 -0
  65. data/lib/esp/mw/scaffolder.rb +197 -0
  66. data/lib/esp/mw/script_blob.rb +87 -0
  67. data/lib/esp/mw/script_extractor.rb +85 -0
  68. data/lib/esp/mw/tes3conv.rb +38 -0
  69. data/lib/esp/operations.rb +285 -0
  70. data/lib/esp/plugins.rb +75 -0
  71. data/lib/esp/preferences.rb +63 -0
  72. data/lib/esp/project_marker.rb +99 -0
  73. data/lib/esp/providers/anthropic.rb +74 -0
  74. data/lib/esp/providers/ollama.rb +102 -0
  75. data/lib/esp/providers/openai.rb +91 -0
  76. data/lib/esp/providers.rb +76 -0
  77. data/lib/esp/recents.rb +74 -0
  78. data/lib/esp/ui.rb +144 -0
  79. data/lib/esp/vcs.rb +112 -0
  80. data/lib/esp/version.rb +11 -0
  81. data/lib/esp/watcher.rb +55 -0
  82. data/lib/esp.rb +85 -0
  83. data/locales/en.yml +164 -0
  84. data/locales/fr.yml +10 -0
  85. metadata +241 -0
@@ -0,0 +1,209 @@
1
+ module Esp
2
+ module Mw
3
+ # Block-style DSL for emitting Dialogue (DIAL) and DialogueInfo (INFO)
4
+ # records without the bookkeeping. Used inside Ruby mod sources via the
5
+ # loader-exposed `dialogue { ... }` helper.
6
+ #
7
+ # The contract: a `dialogue` block returns a flat Array of records
8
+ # that the author splats into their mod's records array. The DSL
9
+ # handles:
10
+ #
11
+ # - One DIAL record per `topic`, with `dialogue_type` carried through.
12
+ # - INFO records under each topic, chained via prev_id/next_id in
13
+ # author order — Morrowind evaluates filters top-down, so author
14
+ # order is filter precedence.
15
+ # - Speaker scoping via `speaker "Name" do ... end`. Nested blocks
16
+ # inherit; an explicit `speaker:` on an info wins.
17
+ # - i18n: a `t("key")` helper is available inside the block when the
18
+ # loader passes an Esp::Mw::I18n instance.
19
+ #
20
+ # Example:
21
+ #
22
+ # dialogue do
23
+ # speaker "Hrisskar Flat-Foot" do
24
+ # topic "Flat-Foot" do
25
+ # info t("hrisskar.flat_foot.intro")
26
+ # info t("hrisskar.flat_foot.legion"), pc_faction: "Imperial Legion"
27
+ # end
28
+ # end
29
+ #
30
+ # topic "AFSN_Tracker", type: :journal do
31
+ # info "Stage 10 text", journal_index: 10
32
+ # info "Stage 20 text", journal_index: 20
33
+ # end
34
+ # end
35
+ #
36
+ # Supported info kwargs:
37
+ # speaker: speaker_id filter (overrides surrounding scope)
38
+ # race: speaker_race filter
39
+ # class: speaker_class filter (note the keyword clash —
40
+ # use the string key or the `class_:` alias)
41
+ # faction: speaker_faction filter
42
+ # cell: speaker_cell filter
43
+ # sex: :any | :female | :male
44
+ # pc_faction: player_faction filter
45
+ # pc_rank: player_rank filter
46
+ # speaker_rank: NPC rank filter
47
+ # disposition: minimum disposition
48
+ # sound: sound_path
49
+ # result_script: inline MWScript text to run on activation
50
+ # result_script_source: path to .mwscript file (resolved relative to
51
+ # the mod source dir at preflight time)
52
+ # journal_index: for Journal-type topics — maps to data.disposition
53
+ class DialogueDsl
54
+ VALID_TYPES = %i[topic journal persuasion greeting voice].freeze
55
+ VALID_SEX = %i[any female male].freeze
56
+
57
+ def self.build(i18n: nil, &block)
58
+ raise ArgumentError, Esp.t('errors.dialogue.needs_block') unless block
59
+
60
+ dsl = new(i18n: i18n)
61
+ dsl.instance_eval(&block)
62
+ dsl.records
63
+ end
64
+
65
+ # Data-driven equivalent of `build`, for callers that can't pass a Ruby
66
+ # block (MCP/HTTP). Spec is JSON-shaped:
67
+ # { "topics" => [ { "name"=>, "type"=>, "speaker"=>,
68
+ # "infos"=>[ { "text"=>, <filter keys>... } ] } ] }
69
+ # Filter keys mirror the block DSL's info kwargs. `text` carries literal
70
+ # strings or `@t:` sentinels (resolved at build), so there's no t()
71
+ # helper and no i18n instance here — unlike the block form.
72
+ def self.from_spec(spec)
73
+ topics = spec.is_a?(Hash) ? spec['topics'] : spec
74
+ raise ArgumentError, Esp.t('errors.dialogue.needs_topics') unless topics.is_a?(Array)
75
+
76
+ dsl = new
77
+ topics.each { |t| dsl.send(:apply_topic_spec, t) }
78
+ dsl.records
79
+ end
80
+
81
+ attr_reader :records
82
+
83
+ def initialize(i18n: nil)
84
+ @records = []
85
+ @speaker_stack = []
86
+ @current_topic = nil
87
+ return unless i18n
88
+
89
+ define_singleton_method(:t) { |key| i18n.t(key) }
90
+ end
91
+
92
+ def speaker(name, &block)
93
+ raise ArgumentError, Esp.t('errors.dialogue.speaker_needs_block') unless block
94
+
95
+ @speaker_stack.push(name)
96
+ instance_eval(&block)
97
+ @speaker_stack.pop
98
+ end
99
+
100
+ def topic(name, type: :topic, &block)
101
+ unless VALID_TYPES.include?(type)
102
+ raise ArgumentError, Esp.t('errors.dialogue.bad_topic_type', types: VALID_TYPES)
103
+ end
104
+ raise ArgumentError, Esp.t('errors.dialogue.topic_needs_block') unless block
105
+ raise Esp.t('errors.dialogue.nested_topics') if @current_topic
106
+
107
+ @records << { 'type' => 'Dialogue', 'flags' => '', 'id' => name,
108
+ 'dialogue_type' => normalize_type(type) }
109
+ @current_topic = { name: name, type: type, infos: [] }
110
+ instance_eval(&block)
111
+ flush_topic
112
+ end
113
+
114
+ def info(text, **filters)
115
+ raise Esp.t('errors.dialogue.info_outside_topic') unless @current_topic
116
+
117
+ @current_topic[:infos] << { text: text, filters: filters }
118
+ end
119
+
120
+ private
121
+
122
+ # Drive speaker/topic/info from one topic spec hash (string-keyed).
123
+ def apply_topic_spec(tspec)
124
+ name = tspec['name']
125
+ raise ArgumentError, Esp.t('errors.dialogue.topic_needs_name') if name.nil? || name.to_s.empty?
126
+
127
+ type = (tspec['type'] || 'topic').to_sym
128
+ infos = tspec['infos'] || []
129
+ body = proc { infos.each { |i| info(i['text'].to_s, **info_filters(i)) } }
130
+ speaker_name = tspec['speaker']
131
+ if speaker_name
132
+ speaker(speaker_name) { topic(name, type: type, &body) }
133
+ else
134
+ topic(name, type: type, &body)
135
+ end
136
+ end
137
+
138
+ # Every key except text becomes a symbol-keyed filter the DSL understands.
139
+ def info_filters(ispec)
140
+ ispec.reject { |k, _| k.to_s == 'text' }.transform_keys(&:to_sym)
141
+ end
142
+
143
+ def normalize_type(type)
144
+ type.to_s.capitalize
145
+ end
146
+
147
+ def flush_topic
148
+ topic = @current_topic
149
+ topic[:infos].each_with_index do |spec, idx|
150
+ @records << build_info(topic, spec, idx, topic[:infos].size)
151
+ end
152
+ @current_topic = nil
153
+ end
154
+
155
+ # Mostly a wide hash literal of optional filter defaults. Readability
156
+ # is in seeing the record shape; per-field extraction would scatter it.
157
+ def build_info(topic, spec, idx, total)
158
+ filters = spec[:filters]
159
+ {
160
+ 'type' => 'DialogueInfo',
161
+ 'flags' => '',
162
+ 'id' => "#{topic[:name]}_#{idx}",
163
+ 'prev_id' => idx.positive? ? "#{topic[:name]}_#{idx - 1}" : '',
164
+ 'next_id' => idx < total - 1 ? "#{topic[:name]}_#{idx + 1}" : '',
165
+ 'data' => info_data(topic, filters),
166
+ 'speaker_id' => filters[:speaker] || @speaker_stack.last || '',
167
+ 'speaker_race' => filters[:race] || '',
168
+ 'speaker_class' => filters[:class] || filters[:class_] || '',
169
+ 'speaker_faction' => filters[:faction] || '',
170
+ 'speaker_cell' => filters[:cell] || '',
171
+ 'player_faction' => filters[:pc_faction] || '',
172
+ 'sound_path' => filters[:sound] || '',
173
+ 'text' => spec[:text].to_s,
174
+ 'filters' => [],
175
+ 'script_text' => resolve_result_script(filters)
176
+ }
177
+ end
178
+
179
+ def info_data(topic, filters)
180
+ {
181
+ 'dialogue_type' => normalize_type(topic[:type]),
182
+ 'disposition' => filters[:journal_index] || filters[:disposition] || 0,
183
+ 'speaker_rank' => filters[:speaker_rank] || -1,
184
+ 'speaker_sex' => normalize_sex(filters[:sex]),
185
+ 'player_rank' => filters[:pc_rank] || -1
186
+ }
187
+ end
188
+
189
+ def normalize_sex(value)
190
+ return 'Any' if value.nil?
191
+
192
+ sym = value.to_s.downcase.to_sym
193
+ unless VALID_SEX.include?(sym)
194
+ raise ArgumentError,
195
+ Esp.t('errors.dialogue.bad_sex', values: VALID_SEX)
196
+ end
197
+
198
+ sym.to_s.capitalize
199
+ end
200
+
201
+ def resolve_result_script(filters)
202
+ return filters[:result_script] if filters[:result_script]
203
+ return '' unless filters[:result_script_source]
204
+
205
+ raise NotImplementedError, Esp.t('errors.dialogue.result_script_source')
206
+ end
207
+ end
208
+ end
209
+ end
@@ -0,0 +1,113 @@
1
+ require 'yaml'
2
+
3
+ module Esp
4
+ module Mw
5
+ # Locale-aware string lookup. Two interfaces:
6
+ #
7
+ # - `t(key)` returns the resolved string (with default-locale fallback)
8
+ # and tracks misses. Used directly from the Ruby DSL.
9
+ # - `resolve!(value)` walks any data structure and replaces
10
+ # `"@t:<key>"` sentinel strings via `t`. Used post-load for JSON or
11
+ # subprocess-loader output (Python/JS/TS) where in-process helpers
12
+ # can't reach.
13
+ #
14
+ # Catalogues are nested-hash YAML files at
15
+ # `mods/<Mod>/i18n/<locale>.yaml`. Lookup is dot-pathed:
16
+ # `t("activator.stump")` reads `{activator: {stump: "..."}}`.
17
+ class I18n
18
+ DEFAULT_LOCALE = 'en'.freeze
19
+ SENTINEL_PREFIX = '@t:'.freeze
20
+
21
+ Miss = Struct.new(:key, :locale, keyword_init: true)
22
+
23
+ attr_reader :locale, :default_locale
24
+
25
+ def initialize(catalogues, locale: DEFAULT_LOCALE, default_locale: DEFAULT_LOCALE)
26
+ @catalogues = catalogues
27
+ @locale = locale
28
+ @default_locale = default_locale
29
+ @misses = []
30
+ end
31
+
32
+ # Returns the value at `key` in the active locale, falling back to
33
+ # the default locale, then to the literal key. Records misses for
34
+ # later reporting.
35
+ def t(key)
36
+ value = lookup(@locale, key)
37
+ return value if value
38
+
39
+ @misses << Miss.new(key: key, locale: @locale)
40
+ lookup(@default_locale, key) || key
41
+ end
42
+
43
+ # Recursively replaces `"@t:<key>"` strings in any structure.
44
+ # Mutates Hash values and Array elements in place; returns the
45
+ # result for convenience.
46
+ def resolve!(value)
47
+ case value
48
+ when String
49
+ value.start_with?(SENTINEL_PREFIX) ? t(value.delete_prefix(SENTINEL_PREFIX)) : value
50
+ when Array
51
+ value.map! { |v| resolve!(v) }
52
+ value
53
+ when Hash
54
+ value.each { |k, v| value[k] = resolve!(v) }
55
+ value
56
+ else
57
+ value
58
+ end
59
+ end
60
+
61
+ def misses
62
+ @misses.uniq
63
+ end
64
+
65
+ def self.load_catalogues(source_dir)
66
+ i18n_dir = File.join(source_dir, 'i18n')
67
+ return {} unless File.directory?(i18n_dir)
68
+
69
+ Dir.glob(File.join(i18n_dir, '*.yaml')).each_with_object({}) do |path, out|
70
+ locale = File.basename(path, '.yaml')
71
+ out[locale] = YAML.safe_load_file(path) || {}
72
+ end
73
+ end
74
+
75
+ # Compares each non-default locale catalogue against the default
76
+ # locale's key set, reporting keys missing from / orphaned in each.
77
+ # Returns { locale => { missing: [...], orphan: [...] } }; shared by
78
+ # the `i18n check` CLI command and the Operations surface.
79
+ def self.check(source_dir)
80
+ catalogues = load_catalogues(source_dir)
81
+ default_keys = flatten(catalogues[DEFAULT_LOCALE] || {}).keys
82
+ catalogues.each_with_object({}) do |(locale, cat), out|
83
+ next if locale == DEFAULT_LOCALE
84
+
85
+ keys = flatten(cat).keys
86
+ out[locale] = { missing: default_keys - keys, orphan: keys - default_keys }
87
+ end
88
+ end
89
+
90
+ # Flattens nested catalogue hash to dot-pathed keys:
91
+ # `{a: {b: "x"}}` -> `{"a.b" => "x"}`.
92
+ def self.flatten(hash, prefix = '')
93
+ hash.each_with_object({}) do |(k, v), out|
94
+ key = prefix.empty? ? k.to_s : "#{prefix}.#{k}"
95
+ if v.is_a?(Hash)
96
+ out.merge!(flatten(v, key))
97
+ else
98
+ out[key] = v
99
+ end
100
+ end
101
+ end
102
+
103
+ private
104
+
105
+ def lookup(locale, key)
106
+ cat = @catalogues[locale]
107
+ return nil unless cat
108
+
109
+ key.split('.').reduce(cat) { |h, k| h.is_a?(Hash) ? h[k] : nil }
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,103 @@
1
+ module Esp
2
+ module Mw
3
+ # Checks a mod's records for dangling references and missing-master
4
+ # coverage. Uses Esp::Mw::ReferenceIndex for vanilla lookups and the mod's
5
+ # own records for self-references.
6
+ #
7
+ # Severity model:
8
+ # - :error — the ref points at nothing the mod or any indexed ESM defines.
9
+ # - :warning — the ref resolves in a vanilla ESM that isn't listed in
10
+ # the mod's TES3 Header.masters. OpenMW will load the mod
11
+ # but log warnings; the mod author probably forgot to add
12
+ # the dependency.
13
+ class Linter
14
+ Issue = Struct.new(:severity, :record_id, :record_type, :field, :ref_id, :message, keyword_init: true)
15
+
16
+ # Record types that carry a `script` field pointing at a Script record.
17
+ SCRIPTED_TYPES = %w[
18
+ Activator Alchemy Apparatus Armor Book Clothing Container Creature
19
+ Door Ingredient Light Lockpick MiscItem Probe RepairItem Weapon
20
+ ].freeze
21
+
22
+ def initialize(records, index)
23
+ @records = records
24
+ @index = index
25
+ @mod_ids = build_mod_index
26
+ @masters = extract_masters_set
27
+ end
28
+
29
+ def issues
30
+ @issues ||= collect_issues
31
+ end
32
+
33
+ private
34
+
35
+ def collect_issues
36
+ out = []
37
+ @records.each do |record|
38
+ case record['type']
39
+ when 'Npc'
40
+ out.concat(check_npc(record))
41
+ when *SCRIPTED_TYPES
42
+ issue = check_ref(record, 'script', 'Script')
43
+ out << issue if issue
44
+ end
45
+ end
46
+ out
47
+ end
48
+
49
+ def check_npc(record)
50
+ [
51
+ check_ref(record, 'script', 'Script'),
52
+ check_ref(record, 'race', 'Race'),
53
+ check_ref(record, 'class', 'Class'),
54
+ check_ref(record, 'faction', 'Faction')
55
+ ].compact
56
+ end
57
+
58
+ def check_ref(record, field, type)
59
+ id = record[field]
60
+ return nil if id.nil? || (id.is_a?(String) && id.empty?)
61
+ return nil if @mod_ids[[id.downcase, type]]
62
+
63
+ hits = @index.find(query: id, type: type, limit: 1)
64
+ return missing_ref_issue(record, field, type, id) if hits.empty?
65
+
66
+ # source_esm is the basename minus .json — already includes .esm
67
+ # (e.g. File.basename('Morrowind.esm.json', '.json') => 'Morrowind.esm').
68
+ master = hits.first['source_esm']
69
+ return missing_master_issue(record, field, type, id, master) unless @masters.include?(master.downcase)
70
+
71
+ nil
72
+ end
73
+
74
+ def missing_ref_issue(record, field, type, id)
75
+ Issue.new(severity: :error, record_id: record['id'], record_type: record['type'],
76
+ field: field, ref_id: id, message: "unknown #{type}")
77
+ end
78
+
79
+ def missing_master_issue(record, field, type, id, master)
80
+ Issue.new(severity: :warning, record_id: record['id'], record_type: record['type'],
81
+ field: field, ref_id: id, message: "#{type} defined in #{master}; not in masters list")
82
+ end
83
+
84
+ def build_mod_index
85
+ @records.each_with_object({}) do |r, h|
86
+ next unless r['id'] && r['type']
87
+
88
+ h[[r['id'].downcase, r['type']]] = true
89
+ end
90
+ end
91
+
92
+ def extract_masters_set
93
+ header = @records.find { |r| r['type'] == 'Header' }
94
+ return Set.new unless header
95
+
96
+ (header['masters'] || []).each_with_object(Set.new) do |entry, set|
97
+ name = entry.is_a?(Array) ? entry[0] : entry
98
+ set << name.downcase if name
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,130 @@
1
+ require 'json'
2
+ require 'open3'
3
+
4
+ module Esp
5
+ module Mw
6
+ # Loads a mod's source records from one of the supported formats.
7
+ #
8
+ # The contract: every loader returns an Array of record hashes shaped
9
+ # for tes3conv. Keys are normalised to strings so downstream code
10
+ # (preflight, builder) doesn't have to care which format the source
11
+ # was authored in.
12
+ #
13
+ # Supported source files (per mod folder, exactly one):
14
+ # mods/<Mod>/<Mod>.json — straight JSON
15
+ # mods/<Mod>/<Mod>.rb — Ruby file; last expression is the Array
16
+ # mods/<Mod>/<Mod>.py — Python script; prints JSON Array to stdout
17
+ # mods/<Mod>/<Mod>.js — Node script; same contract
18
+ # mods/<Mod>/<Mod>.mjs — Node ES module; same contract
19
+ # mods/<Mod>/<Mod>.ts — Deno TypeScript; same contract
20
+ #
21
+ # For subprocess loaders (py/js/mjs/ts) the interpreter runs with cwd
22
+ # set to the mod's source directory, so relative file reads from the
23
+ # script just work. The script's own path is passed as the last arg.
24
+ module Loader
25
+ class LoadError < StandardError; end
26
+
27
+ class << self
28
+ # Extension -> command vector. Mutable so tests / local config
29
+ # can poke at it without freeze/unfreeze dances.
30
+ attr_accessor :interpreters
31
+
32
+ def supported_exts
33
+ %w[.rb .json] + interpreters.keys
34
+ end
35
+
36
+ def resolve(mod, root: Esp::ROOT)
37
+ candidates = supported_exts
38
+ .map { |ext| File.join(root, 'mods', mod, "#{mod}#{ext}") }
39
+ .select { |p| File.exist?(p) }
40
+ if candidates.empty?
41
+ wanted = supported_exts.map { |e| "#{mod}#{e}" }.join(', ')
42
+ raise LoadError, Esp.t('errors.loader.no_source', mod: mod.inspect, exts: wanted)
43
+ end
44
+ if candidates.size > 1
45
+ names = candidates.map { |c| File.basename(c) }.join(', ')
46
+ raise LoadError, Esp.t('errors.loader.multiple_sources', mod: mod.inspect, names: names)
47
+ end
48
+
49
+ candidates.first
50
+ end
51
+
52
+ def load(path, i18n: nil)
53
+ raise LoadError, Esp.t('errors.loader.not_found', path: path) unless File.exist?(path)
54
+
55
+ records = dispatch(path, i18n)
56
+ unless records.is_a?(Array)
57
+ raise LoadError, Esp.t('errors.loader.not_array', path: path, klass: records.class)
58
+ end
59
+
60
+ records.map { |r| stringify_keys(r) }
61
+ end
62
+
63
+ private
64
+
65
+ def dispatch(path, i18n)
66
+ ext = File.extname(path)
67
+ case ext
68
+ when '.json' then load_json(path)
69
+ when '.rb' then load_rb(path, i18n)
70
+ when *interpreters.keys then load_via_interpreter(path, ext)
71
+ else raise LoadError, Esp.t('errors.loader.unsupported_ext', ext: ext, path: path)
72
+ end
73
+ end
74
+
75
+ def load_json(path)
76
+ JSON.parse(File.read(path))
77
+ rescue JSON::ParserError => e
78
+ raise LoadError, Esp.t('errors.loader.invalid_json', file: File.basename(path), message: e.message)
79
+ end
80
+
81
+ def load_rb(path, i18n)
82
+ anon = Module.new
83
+ anon.define_singleton_method(:t) { |key| i18n.t(key) } if i18n
84
+ anon.define_singleton_method(:dialogue) do |&block|
85
+ Esp::Mw::DialogueDsl.build(i18n: i18n, &block)
86
+ end
87
+ anon.module_eval(File.read(path), path)
88
+ end
89
+
90
+ def load_via_interpreter(path, ext)
91
+ cmd = interpreters.fetch(ext) + [path]
92
+ stdout, stderr, status = Open3.capture3(*cmd, chdir: File.dirname(path))
93
+ unless status.success?
94
+ raise LoadError, Esp.t('errors.loader.subprocess_failed',
95
+ file: File.basename(path), code: status.exitstatus, stderr: stderr.strip)
96
+ end
97
+
98
+ begin
99
+ JSON.parse(stdout)
100
+ rescue JSON::ParserError => e
101
+ raise LoadError, Esp.t('errors.loader.subprocess_bad_json',
102
+ file: File.basename(path), message: e.message)
103
+ end
104
+ rescue Errno::ENOENT
105
+ interpreter = interpreters.fetch(ext).first
106
+ raise LoadError, Esp.t('errors.loader.interpreter_missing',
107
+ interpreter: interpreter.inspect, file: File.basename(path))
108
+ end
109
+
110
+ def stringify_keys(obj)
111
+ case obj
112
+ when Hash
113
+ obj.each_with_object({}) { |(k, v), h| h[k.to_s] = stringify_keys(v) }
114
+ when Array
115
+ obj.map { |v| stringify_keys(v) }
116
+ else
117
+ obj
118
+ end
119
+ end
120
+ end
121
+
122
+ self.interpreters = {
123
+ '.py' => %w[python3],
124
+ '.js' => %w[node],
125
+ '.mjs' => %w[node],
126
+ '.ts' => %w[deno run --quiet]
127
+ }
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,138 @@
1
+ require 'fileutils'
2
+ require 'rbconfig'
3
+
4
+ module Esp
5
+ module Mw
6
+ # Reads and edits OpenMW's openmw.cfg. Knows the per-OS location of the
7
+ # *user* config (the one OpenMW's launcher edits) and can parse the
8
+ # data=/content= lines to enumerate installed plugins.
9
+ #
10
+ # v1 targets the single user config; OPENMW_CONFIG (or an explicit path)
11
+ # overrides it. The full global/local/user hierarchy and ?token? data
12
+ # paths are not merged/expanded yet — see roadmap/17.
13
+ class OpenmwConfig
14
+ class << self
15
+ def default_path
16
+ File.join(default_dir, 'openmw.cfg')
17
+ end
18
+
19
+ def default_dir
20
+ env = ENV['OPENMW_CONFIG'].to_s
21
+ return env.split(File::PATH_SEPARATOR).first unless env.empty?
22
+
23
+ platform_config_dir
24
+ end
25
+
26
+ def host_os
27
+ case RbConfig::CONFIG['host_os']
28
+ when /mswin|mingw|cygwin/ then :windows
29
+ when /darwin/ then :macos
30
+ else :linux
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def platform_config_dir
37
+ case host_os
38
+ when :windows
39
+ File.join(ENV.fetch('USERPROFILE', Dir.home), 'Documents', 'My Games', 'OpenMW')
40
+ when :macos
41
+ File.expand_path('~/Library/Preferences/openmw')
42
+ else
43
+ File.join(ENV['XDG_CONFIG_HOME'] || File.expand_path('~/.config'), 'openmw')
44
+ end
45
+ end
46
+ end
47
+
48
+ # Computed at load from the current OS/env. Callers may pass an explicit
49
+ # path to OpenmwConfig.new instead.
50
+ DEFAULT_PATH = default_path
51
+
52
+ attr_reader :path
53
+
54
+ def initialize(path = DEFAULT_PATH)
55
+ @path = path
56
+ end
57
+
58
+ def exist?
59
+ File.exist?(@path)
60
+ end
61
+
62
+ def include?(line)
63
+ exist? && File.readlines(@path, chomp: true).include?(line)
64
+ end
65
+
66
+ def append(line)
67
+ return false if include?(line)
68
+
69
+ File.open(@path, 'a') { |f| f.puts(line) }
70
+ true
71
+ end
72
+
73
+ def backup_once_per_day(now: Time.now)
74
+ backup = "#{@path}.bak.#{now.strftime('%Y%m%d')}"
75
+ FileUtils.cp(@path, backup) unless File.exist?(backup)
76
+ backup
77
+ end
78
+
79
+ # Absolute data= directories, in file order (relative paths resolve
80
+ # against the config's directory). Token paths (?userdata? etc.) are
81
+ # passed through unexpanded and simply won't glob.
82
+ def data_dirs
83
+ lines.filter_map do |line|
84
+ value = match_value(line, 'data')
85
+ value && resolve_dir(value)
86
+ end
87
+ end
88
+
89
+ # content= plugin filenames, in file order — this is the load order.
90
+ def content_entries
91
+ lines.filter_map { |line| match_value(line, 'content') }
92
+ end
93
+
94
+ # Every plugin file found across the data dirs, annotated with whether
95
+ # it's active (has a content= line) and its load-order index.
96
+ def installed_plugins
97
+ order = content_entries.each_with_index.to_h
98
+ data_dirs.flat_map { |dir| plugins_in(dir, order) }
99
+ end
100
+
101
+ private
102
+
103
+ # Plugins in one data dir. A real install can have dirs we can't read
104
+ # (permissions, broken symlinks) — skip those rather than crash the
105
+ # whole listing.
106
+ def plugins_in(dir, order)
107
+ Dir.glob(File.join(dir, '*.{esm,esp,omwaddon}')).map do |file|
108
+ name = File.basename(file)
109
+ { name: name, path: file, dir: dir, active: order.key?(name), load_order: order[name] }
110
+ end
111
+ rescue SystemCallError
112
+ []
113
+ end
114
+
115
+ def lines
116
+ exist? ? File.readlines(@path, chomp: true) : []
117
+ end
118
+
119
+ # `key=value`, tolerant of spaces; returns the unquoted value or nil.
120
+ def match_value(line, key)
121
+ m = line.match(/\A#{key}\s*=\s*(.*)\z/)
122
+ return nil unless m
123
+
124
+ unquote(m[1].strip)
125
+ end
126
+
127
+ def unquote(value)
128
+ value.start_with?('"') && value.end_with?('"') ? value[1..-2] : value
129
+ end
130
+
131
+ def resolve_dir(dir)
132
+ return dir if dir.include?('?') || File.absolute_path?(dir)
133
+
134
+ File.expand_path(dir, File.dirname(@path))
135
+ end
136
+ end
137
+ end
138
+ end