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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +35 -0
- data/LICENSE +21 -0
- data/README.md +117 -0
- data/docs/architecture.md +125 -0
- data/docs/authoring-guide.md +206 -0
- data/docs/getting-started.md +183 -0
- data/docs/reference/api/active-project.md +22 -0
- data/docs/reference/api/agent.md +24 -0
- data/docs/reference/api/docs-generator.md +20 -0
- data/docs/reference/api/http-server.md +46 -0
- data/docs/reference/api/index.md +38 -0
- data/docs/reference/api/introspection.md +17 -0
- data/docs/reference/api/mcp-installer.md +26 -0
- data/docs/reference/api/mcp-server.md +27 -0
- data/docs/reference/api/mw-builder.md +14 -0
- data/docs/reference/api/mw-data-files.md +20 -0
- data/docs/reference/api/mw-dialogue-dsl.md +58 -0
- data/docs/reference/api/mw-i18n.md +20 -0
- data/docs/reference/api/mw-linter.md +18 -0
- data/docs/reference/api/mw-loader.md +26 -0
- data/docs/reference/api/mw-openmw-config.md +15 -0
- data/docs/reference/api/mw-operations.md +24 -0
- data/docs/reference/api/mw-preflight.md +17 -0
- data/docs/reference/api/mw-reference-index.md +21 -0
- data/docs/reference/api/mw-scaffolder.md +13 -0
- data/docs/reference/api/mw-script-blob.md +31 -0
- data/docs/reference/api/mw-script-extractor.md +17 -0
- data/docs/reference/api/operations.md +25 -0
- data/docs/reference/api/plugins.md +24 -0
- data/docs/reference/api/preferences.md +13 -0
- data/docs/reference/api/project-marker.md +23 -0
- data/docs/reference/api/providers.md +22 -0
- data/docs/reference/api/recents.md +17 -0
- data/docs/reference/api/ui.md +21 -0
- data/docs/reference/api/vcs.md +17 -0
- data/docs/reference/api/watcher.md +11 -0
- data/docs/reference/commands.md +271 -0
- data/docs/walkthrough.md +193 -0
- data/exe/esp +10 -0
- data/lib/esp/active_project.rb +71 -0
- data/lib/esp/agent.rb +104 -0
- data/lib/esp/cli/docs.rb +44 -0
- data/lib/esp/cli/i18n.rb +67 -0
- data/lib/esp/cli/mcp.rb +52 -0
- data/lib/esp/cli/plugins.rb +42 -0
- data/lib/esp/cli/refs.rb +137 -0
- data/lib/esp/cli/support.rb +87 -0
- data/lib/esp/cli.rb +317 -0
- data/lib/esp/docs_generator.rb +148 -0
- data/lib/esp/http_server.rb +232 -0
- data/lib/esp/introspection.rb +151 -0
- data/lib/esp/mcp_installer.rb +122 -0
- data/lib/esp/mcp_server.rb +465 -0
- data/lib/esp/mw/builder.rb +71 -0
- data/lib/esp/mw/data_files.rb +67 -0
- data/lib/esp/mw/dialogue_dsl.rb +209 -0
- data/lib/esp/mw/i18n.rb +113 -0
- data/lib/esp/mw/linter.rb +103 -0
- data/lib/esp/mw/loader.rb +130 -0
- data/lib/esp/mw/openmw_config.rb +138 -0
- data/lib/esp/mw/operations.rb +374 -0
- data/lib/esp/mw/preflight.rb +161 -0
- data/lib/esp/mw/reference_index.rb +182 -0
- data/lib/esp/mw/scaffolder.rb +197 -0
- data/lib/esp/mw/script_blob.rb +87 -0
- data/lib/esp/mw/script_extractor.rb +85 -0
- data/lib/esp/mw/tes3conv.rb +38 -0
- data/lib/esp/operations.rb +285 -0
- data/lib/esp/plugins.rb +75 -0
- data/lib/esp/preferences.rb +63 -0
- data/lib/esp/project_marker.rb +99 -0
- data/lib/esp/providers/anthropic.rb +74 -0
- data/lib/esp/providers/ollama.rb +102 -0
- data/lib/esp/providers/openai.rb +91 -0
- data/lib/esp/providers.rb +76 -0
- data/lib/esp/recents.rb +74 -0
- data/lib/esp/ui.rb +144 -0
- data/lib/esp/vcs.rb +112 -0
- data/lib/esp/version.rb +11 -0
- data/lib/esp/watcher.rb +55 -0
- data/lib/esp.rb +85 -0
- data/locales/en.yml +164 -0
- data/locales/fr.yml +10 -0
- 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
|
data/lib/esp/mw/i18n.rb
ADDED
|
@@ -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
|