Module: Esp::Mw::Operations

Defined in:
lib/esp/mw/operations.rb

Overview

The Morrowind plugin’s half of the service layer. Same contract as Esp::Operations (string-keyed params hash in, plain Hash out) — these are the ops that decode TES3 records, drive tes3conv, or otherwise know what a “mod” is. Frontends never call this module directly; they route through Esp::Operations.dispatch(op, input).

Every op resolves the project root from ‘Esp::ActiveProject.resolve` (step 23.5 slice 1) so an `open_project` request actually moves where build/lint/etc. operate — they no longer silently target Esp::ROOT. Precedence: explicit `root:` in params > active project > Esp::ROOT.

InputError is aliased from the shell so plugin ops raise the same class the frontends rescue. Loader / Preflight / Tes3conv error classes are registered with Esp::Operations::ERROR_CODES at the bottom of this file — they couldn’t be in the shell table because the plugin hadn’t loaded yet when that table was built.

Constant Summary collapse

InputError =
Esp::Operations::InputError
PLUGIN_EXT =
/\.(esp|esm|omwaddon)\z/i

Class Method Summary collapse

Class Method Details

.build(params) ⇒ Object



28
29
30
31
# File 'lib/esp/mw/operations.rb', line 28

def build(params)
  mod = require_mod(params)
  build_one(mod, params['locale'], root: project_root(params))
end

.build_all(params) ⇒ Object

Build every mod under mods/. Returns one entry per mod, same shape as a single build, so an agent can fan out without N calls.



35
36
37
38
39
40
41
# File 'lib/esp/mw/operations.rb', line 35

def build_all(params)
  root = project_root(params)
  results = Esp::Mw::Builder.discover_mods(root: root).map do |mod|
    build_one(mod, params['locale'], root: root)
  end
  { results: results }
end

.dialogue_write(params) ⇒ Object

Author dialogue from a JSON spec (the data-driven analog of the Ruby ‘dialogue { }` DSL) into a mod’s .json source. A topic is written wholesale: any existing DIAL/INFO records for the same topics are replaced, so re-authoring never leaves orphan info records.



155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
# File 'lib/esp/mw/operations.rb', line 155

def dialogue_write(params)
  mod = require_mod(params)
  spec = params['spec']
  unless spec.is_a?(Hash) || spec.is_a?(Array)
    raise InputError, Esp.t('errors.operations.missing_object', field: 'spec')
  end

  built = Esp::Mw::DialogueDsl.from_spec(spec)
  topics = built.select { |r| r['type'] == 'Dialogue' }.map { |r| r['id'] }
  root = project_root(params)
  source = json_source!(mod, root: root)
  records = strip_dialogue(JSON.parse(File.read(source)), topics).concat(built)
  File.write(source, "#{JSON.pretty_generate(records)}\n")
  { mod: mod, source: Esp::Operations.relative(source, root: root), topics: topics,
    records_written: built.size, records: built }
end

.extract_scripts(params) ⇒ Object



201
202
203
204
205
# File 'lib/esp/mw/operations.rb', line 201

def extract_scripts(params)
  mod = require_mod(params)
  result = Esp::Mw::ScriptExtractor.extract!(mod, root: project_root(params))
  { mod: mod, extracted: result.extracted, skipped: result.skipped }
end

.i18n_check(params) ⇒ Object



106
107
108
109
110
111
# File 'lib/esp/mw/operations.rb', line 106

def i18n_check(params)
  mod = require_mod(params)
  source = Esp::Mw::Loader.resolve(mod, root: project_root(params))
  { mod: mod, default_locale: Esp::Mw::I18n::DEFAULT_LOCALE,
    locales: Esp::Mw::I18n.check(File.dirname(source)) }
end

.install(params) ⇒ Object

Make a built dist/<mod>.esp available to a game engine. Two additive install paths (compose freely):

  • OpenMW (default): register the project’s dist/ + the plugin with openmw.cfg via ‘data=` + `content=`. Idempotent —an already-present line reports `added: false`. Skipped when `params` is explicitly false (e.g. an original-engine user who only wants the copy).

  • **Copy to a Data Files dir** (original engine, step 23.5 slice 4): pass ‘params` with a path, or `params => true` to auto-resolve the user’s vanilla Morrowind Data Files dir via Esp::Mw::DataFiles. The copy is “always overwrite” so a rebuild swaps the live plugin without the user touching anything.

‘data=` always points at the active project’s dist/, so OpenMW picks up the build output from wherever the project lives.



80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
# File 'lib/esp/mw/operations.rb', line 80

def install(params)
  mod = require_mod(params)
  root = project_root(params)
  esp = File.join(root, 'dist', "#{mod}.esp")
  unless File.exist?(esp)
    raise InputError,
          Esp.t('errors.operations.build_first', esp: Esp::Operations.relative(esp, root: root))
  end

  payload = { mod: mod }
  payload.merge!(register_openmw(mod, root, params)) unless params['register_openmw'] == false

  if (target = copy_target(params))
    payload[:copied_to] = copy_built_plugin(esp, mod, target)
  end

  payload
end

.lint(params) ⇒ Object



172
173
174
175
176
177
178
179
180
181
182
183
# File 'lib/esp/mw/operations.rb', line 172

def lint(params)
  mod = require_mod(params)
  index = require_index!
  records = Esp::Mw::Loader.load(Esp::Mw::Loader.resolve(mod, root: project_root(params)))
  issues = Esp::Mw::Linter.new(records, index).issues
  {
    mod: mod,
    errors: issues.count { |i| i.severity == :error },
    warnings: issues.count { |i| i.severity == :warning },
    issues: issues.map(&:to_h)
  }
end

.plugins_list(params = {}) ⇒ Object

List plugins installed in the OpenMW config’s data directories, with active flag + load order. ‘config` overrides the cfg path.



101
102
103
104
# File 'lib/esp/mw/operations.rb', line 101

def plugins_list(params = {})
  cfg = openmw_config(params)
  { openmw_cfg: cfg.path, exists: cfg.exist?, plugins: cfg.installed_plugins }
end

.record_write(params) ⇒ Object

Upsert one record into a mod’s .json source, keyed by (type, id). JSON only: polyglot sources are programs, not editable data, so we refuse rather than silently no-op. Header is matched by type alone (one per file); every other record needs an id.

Raises:



128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
# File 'lib/esp/mw/operations.rb', line 128

def record_write(params)
  mod = require_mod(params)
  record = params['record']
  unless record.is_a?(Hash)
    raise InputError,
          Esp.t('errors.operations.missing_object', field: 'record')
  end

  type = record['type']
  raise InputError, Esp.t('errors.operations.record_needs_type') if blank?(type)
  if type != 'Header' && blank?(record['id'])
    raise InputError, Esp.t('errors.operations.record_needs_id')
  end

  root = project_root(params)
  source = json_source!(mod, root: root)
  records = JSON.parse(File.read(source))
  action, index = upsert(records, record)
  File.write(source, "#{JSON.pretty_generate(records)}\n")
  { mod: mod, source: Esp::Operations.relative(source, root: root), action: action,
    type: type, id: record['id'], index: index }
end

.records_read(params) ⇒ Object

Read a mod’s records as structured data. Format-agnostic — runs the source through the loader, so .rb/.py/.js/.ts all read back too.



115
116
117
118
119
120
121
122
# File 'lib/esp/mw/operations.rb', line 115

def records_read(params)
  mod = require_mod(params)
  root = project_root(params)
  source = Esp::Mw::Loader.resolve(mod, root: root)
  records = Esp::Mw::Loader.load(source)
  { mod: mod, source: Esp::Operations.relative(source, root: root),
    count: records.size, records: records }
end

.refs_find(params) ⇒ Object



207
208
209
210
211
212
213
214
215
216
217
218
219
# File 'lib/esp/mw/operations.rb', line 207

def refs_find(params)
  index = require_index!
  criteria = { query: params['q'], type: params['type'],
               like: params['like'], exact: params['exact'] }
  rows = index.find(**criteria, limit: (params['limit'] || 100).to_i)
  total = index.count_matching(**criteria)
  if params['show']
    records = rows.map { |r| index.fetch_record(r['source_esm'], r['record_index']) }
    { records: records, count: records.size, total: total }
  else
    { matches: rows, count: rows.size, total: total }
  end
end

.refs_resolve(params) ⇒ Object

Map a batch of reference ids → record type via the SQLite reference index. Backs the cell-view marker colouring (step 21 slice 3) — one call resolves all of a cell’s references in a single SQL.



224
225
226
227
228
# File 'lib/esp/mw/operations.rb', line 224

def refs_resolve(params)
  index = require_index!
  ids = Array(params['ids']).map(&:to_s)
  { types: index.types_for(ids) }
end

.scaffold(params) ⇒ Object



185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
# File 'lib/esp/mw/operations.rb', line 185

def scaffold(params)
  mod = require_mod(params)
  root = project_root(params)
  result = Esp::Mw::Scaffolder.create(
    mod,
    format: params['format'] || Esp::Mw::Scaffolder::DEFAULT_FORMAT,
    author: params['author'],
    description: params['description'],
    force: params['force'] || false,
    root: root
  )
  { mod: result.mod, format: result.format,
    source: Esp::Operations.relative(result.source, root: root),
    readme: Esp::Operations.relative(result.readme, root: root) }
end

.unpack(params) ⇒ Object

Import an existing plugin into source. ‘plugin` is either a filesystem path or, if it’s a bare name (no path separator), a plugin name resolved against the OpenMW config’s installed plugins. ‘name` defaults to the resolved file’s basename. Source mod lands under the active project’s mods/, not the toolchain repo’s.

Raises:



48
49
50
51
52
53
54
55
56
57
58
59
60
61
# File 'lib/esp/mw/operations.rb', line 48

def unpack(params)
  plugin = params['plugin']
  raise InputError, Esp.t('errors.operations.missing_field', field: 'plugin') if blank?(plugin)

  source = resolve_plugin(plugin, params)
  raise InputError, Esp.t('errors.operations.plugin_not_found', plugin: plugin) unless source

  root = project_root(params)
  name = params['name'] || File.basename(source).sub(PLUGIN_EXT, '')
  dst = File.join(root, 'mods', name, "#{name}.json")
  FileUtils.mkdir_p(File.dirname(dst))
  Esp::Mw::Tes3conv.convert(source, dst)
  { plugin: plugin, source: source, mod: name, output: Esp::Operations.relative(dst, root: root) }
end