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,465 @@
1
+ require 'json'
2
+
3
+ module Esp
4
+ # Model Context Protocol server over stdio, so AI assistants (Claude Code,
5
+ # Claude Desktop, …) can author, build, lint, and query a mod project as
6
+ # native tools. It is a thin shell over Esp::Operations — the same service
7
+ # layer the HTTP API uses — so every tool returns the same payload the CLI
8
+ # `--json` mode and `esp serve` already emit.
9
+ #
10
+ # Transport is the MCP stdio convention: newline-delimited JSON-RPC 2.0,
11
+ # one message per line, no embedded newlines. Requests carry an `id` and
12
+ # get a response; notifications (no `id`) get none. The protocol stream
13
+ # owns stdout, so all diagnostics go to stderr.
14
+ #
15
+ # Operation errors (missing mod, no index, bad source) come back as a
16
+ # tool result with `isError: true` — the model sees the message and can
17
+ # recover. Only malformed protocol traffic (bad JSON, unknown method,
18
+ # unknown tool) uses a JSON-RPC error response.
19
+ #
20
+ # Alongside tools, the server exposes read-only **resources**
21
+ # (`resources/list` + `resources/read`): the command-tree introspection
22
+ # and the narrative docs, so an agent can pull context without a tool call.
23
+ class McpServer
24
+ PROTOCOL_VERSION = '2024-11-05'.freeze
25
+
26
+ # Each tool maps an MCP tool name to an Esp::Operations method plus the
27
+ # JSON Schema the client uses to build calls. `arguments` arrive as a
28
+ # string-keyed hash, exactly the shape Operations expects.
29
+ TOOLS = [
30
+ {
31
+ name: 'version',
32
+ description: 'Print the esp toolchain version.',
33
+ input_schema: { type: 'object', properties: {}, additionalProperties: false },
34
+ op: :version
35
+ },
36
+ {
37
+ name: 'commands',
38
+ description: 'Discover the full esp command tree — names, usage, and options.',
39
+ input_schema: { type: 'object', properties: {}, additionalProperties: false },
40
+ op: :commands
41
+ },
42
+ {
43
+ name: 'open_project',
44
+ description: 'Point the server at a project directory. Validates the path + the project ' \
45
+ 'marker\'s game id; every subsequent op runs against this root until a new ' \
46
+ 'open_project request or an explicit `root:` overrides it.',
47
+ input_schema: {
48
+ type: 'object',
49
+ properties: {
50
+ root: { type: 'string', description: 'Absolute path to a directory (esp init creates one).' }
51
+ },
52
+ required: ['root'],
53
+ additionalProperties: false
54
+ },
55
+ op: :open_project
56
+ },
57
+ {
58
+ name: 'active_project',
59
+ description: 'The project currently active on the server (root + game). Returns nulls if no ' \
60
+ 'open_project has happened yet.',
61
+ input_schema: { type: 'object', properties: {}, additionalProperties: false },
62
+ op: :active_project
63
+ },
64
+ {
65
+ name: 'projects_recent',
66
+ description: 'Recently-opened projects, newest-first. Persisted per user (ESP_DATA_DIR).',
67
+ input_schema: { type: 'object', properties: {}, additionalProperties: false },
68
+ op: :projects_recent
69
+ },
70
+ {
71
+ name: 'projects_new',
72
+ description: 'Scaffold a brand-new project (git init + .esp/project.json + first mod) under ' \
73
+ "the user's mods_home. Same flow ESPresso's \"New Project\" runs.",
74
+ input_schema: {
75
+ type: 'object',
76
+ properties: {
77
+ project: { type: 'string', description: 'Project name (becomes the directory name).' },
78
+ mod: { type: 'string', description: 'Name of the first mod to scaffold inside it.' },
79
+ game: { type: 'string', description: "Game plugin id (default: 'mw')." },
80
+ format: { type: 'string', enum: %w[json rb py js mjs ts],
81
+ description: 'First mod source format (default: json).' },
82
+ author: { type: 'string', description: 'Author for the scaffolded mod header.' },
83
+ description: { type: 'string', description: 'Description for the scaffolded mod header.' }
84
+ },
85
+ required: %w[project mod],
86
+ additionalProperties: false
87
+ },
88
+ op: :projects_new
89
+ },
90
+ {
91
+ name: 'build',
92
+ description: 'Build mods/<MOD> to dist/<MOD>[.locale].esp via tes3conv.',
93
+ input_schema: {
94
+ type: 'object',
95
+ properties: {
96
+ mod: { type: 'string', description: 'Mod name (folder under mods/).' },
97
+ locale: { type: 'string', description: 'Optional locale; output becomes <MOD>.<locale>.esp.' }
98
+ },
99
+ required: ['mod'],
100
+ additionalProperties: false
101
+ },
102
+ op: :build
103
+ },
104
+ {
105
+ name: 'build_all',
106
+ description: 'Build every mod under mods/ to dist/. Returns one result per mod.',
107
+ input_schema: {
108
+ type: 'object',
109
+ properties: { locale: { type: 'string', description: 'Optional locale applied to every build.' } },
110
+ additionalProperties: false
111
+ },
112
+ op: :build_all
113
+ },
114
+ {
115
+ name: 'unpack',
116
+ description: 'Import an existing plugin (.esp/.esm/.omwaddon) into mods/<NAME>/<NAME>.json.',
117
+ input_schema: {
118
+ type: 'object',
119
+ properties: {
120
+ plugin: { type: 'string', description: 'Plugin path or installed plugin name (Fargoth.esp).' },
121
+ name: { type: 'string', description: 'Mod folder name (default: plugin basename).' },
122
+ config: { type: 'string', description: 'openmw.cfg to resolve a bare name against (optional).' }
123
+ },
124
+ required: ['plugin'],
125
+ additionalProperties: false
126
+ },
127
+ op: :unpack
128
+ },
129
+ {
130
+ name: 'install',
131
+ description: 'Make a built dist/<MOD>.esp available to the game. Default: register with ' \
132
+ 'openmw.cfg. Set copy_to or to_data_files to also copy the plugin into the ' \
133
+ 'vanilla Data Files dir for the original engine.',
134
+ input_schema: {
135
+ type: 'object',
136
+ properties: {
137
+ mod: { type: 'string', description: 'Mod name; built .esp must exist in dist/.' },
138
+ copy_to: { type: 'string',
139
+ description: 'Also copy the .esp into this directory (original engine).' },
140
+ to_data_files: { type: 'boolean',
141
+ description: 'Also copy to the auto-detected Morrowind Data Files dir.' },
142
+ register_openmw: { type: 'boolean',
143
+ description: 'Register with openmw.cfg (default true).' }
144
+ },
145
+ required: ['mod'],
146
+ additionalProperties: false
147
+ },
148
+ op: :install
149
+ },
150
+ {
151
+ name: 'plugins_list',
152
+ description: 'List plugins OpenMW has installed (from openmw.cfg) with active flag + load order.',
153
+ input_schema: {
154
+ type: 'object',
155
+ properties: { config: { type: 'string', description: 'Path to openmw.cfg (optional).' } },
156
+ additionalProperties: false
157
+ },
158
+ op: :plugins_list
159
+ },
160
+ {
161
+ name: 'i18n_check',
162
+ description: 'Report missing/orphan i18n keys per locale for a mod (vs. the default en catalogue).',
163
+ input_schema: {
164
+ type: 'object',
165
+ properties: { mod: { type: 'string', description: 'Mod name (folder under mods/).' } },
166
+ required: ['mod'],
167
+ additionalProperties: false
168
+ },
169
+ op: :i18n_check
170
+ },
171
+ {
172
+ name: 'lint',
173
+ description: 'Check a mod for dangling refs and missing-master issues against the reference index.',
174
+ input_schema: {
175
+ type: 'object',
176
+ properties: { mod: { type: 'string', description: 'Mod name (folder under mods/).' } },
177
+ required: ['mod'],
178
+ additionalProperties: false
179
+ },
180
+ op: :lint
181
+ },
182
+ {
183
+ name: 'scaffold',
184
+ description: 'Create a new mod folder under mods/<MOD>/ with a starter source file and README.',
185
+ input_schema: {
186
+ type: 'object',
187
+ properties: {
188
+ mod: { type: 'string', description: 'Mod name to create.' },
189
+ format: { type: 'string', enum: %w[json rb py js mjs ts], description: 'Format; default json.' },
190
+ author: { type: 'string', description: 'Author name (default: git config user.name).' },
191
+ description: { type: 'string', description: 'Plugin description.' },
192
+ force: { type: 'boolean', description: 'Overwrite an existing mod folder.' }
193
+ },
194
+ required: ['mod'],
195
+ additionalProperties: false
196
+ },
197
+ op: :scaffold
198
+ },
199
+ {
200
+ name: 'extract_scripts',
201
+ description: "Hoist each Script record's inline text into mods/<MOD>/scripts/<id>.mwscript.",
202
+ input_schema: {
203
+ type: 'object',
204
+ properties: { mod: { type: 'string', description: 'Mod name (folder under mods/).' } },
205
+ required: ['mod'],
206
+ additionalProperties: false
207
+ },
208
+ op: :extract_scripts
209
+ },
210
+ {
211
+ name: 'refs_find',
212
+ description: 'Search the vanilla reference index — substring match on id + name by default.',
213
+ input_schema: {
214
+ type: 'object',
215
+ properties: {
216
+ q: { type: 'string', description: 'Substring to match against record id and name.' },
217
+ type: { type: 'string', description: 'Filter by record type (e.g. Npc, Cell, Script).' },
218
+ like: { type: 'string', description: "SQL LIKE pattern on id (e.g. 'Fargoth%')." },
219
+ exact: { type: 'boolean', description: 'Match q as an exact id instead of a substring.' },
220
+ show: { type: 'boolean', description: 'Return the full JSON record for each match.' },
221
+ limit: { type: 'integer', description: 'Max rows to return (default 100).' }
222
+ },
223
+ additionalProperties: false
224
+ },
225
+ op: :refs_find
226
+ },
227
+ {
228
+ name: 'records_read',
229
+ description: "Read a mod's records as structured data (works for any source format).",
230
+ input_schema: {
231
+ type: 'object',
232
+ properties: { mod: { type: 'string', description: 'Mod name (folder under mods/).' } },
233
+ required: ['mod'],
234
+ additionalProperties: false
235
+ },
236
+ op: :records_read
237
+ },
238
+ {
239
+ name: 'record_write',
240
+ description: "Insert/update one record in a mod's .json source, keyed by type+id (JSON only).",
241
+ input_schema: {
242
+ type: 'object',
243
+ properties: {
244
+ mod: { type: 'string', description: 'Mod name (folder under mods/).' },
245
+ record: { type: 'object', description: 'TES3 record; needs "type" (+ "id" unless Header).' }
246
+ },
247
+ required: %w[mod record],
248
+ additionalProperties: false
249
+ },
250
+ op: :record_write
251
+ },
252
+ {
253
+ name: 'dialogue_write',
254
+ description: "Author dialogue from a JSON spec into a mod's .json source (data-driven Dialogue " \
255
+ 'DSL). A topic is replaced wholesale, so re-authoring leaves no orphan info records. ' \
256
+ 'Info filters mirror the DSL (speaker/race/class/faction/cell/sex/pc_faction/pc_rank/' \
257
+ 'speaker_rank/disposition/sound/result_script/journal_index); @t:key in text for i18n.',
258
+ input_schema: {
259
+ type: 'object',
260
+ properties: {
261
+ mod: { type: 'string', description: 'Mod name (folder under mods/).' },
262
+ spec: {
263
+ type: 'object',
264
+ properties: {
265
+ topics: {
266
+ type: 'array',
267
+ items: {
268
+ type: 'object',
269
+ properties: {
270
+ name: { type: 'string' },
271
+ type: { type: 'string', enum: %w[topic journal greeting persuasion voice] },
272
+ speaker: { type: 'string' },
273
+ infos: {
274
+ type: 'array',
275
+ items: { type: 'object', properties: { text: { type: 'string' } } }
276
+ }
277
+ },
278
+ required: %w[name infos]
279
+ }
280
+ }
281
+ },
282
+ required: ['topics']
283
+ }
284
+ },
285
+ required: %w[mod spec],
286
+ additionalProperties: false
287
+ },
288
+ op: :dialogue_write
289
+ }
290
+ ].freeze
291
+
292
+ TOOLS_BY_NAME = TOOLS.to_h { |t| [t[:name], t] }.freeze
293
+
294
+ # Narrative docs exposed as resources, by slug under docs/.
295
+ DOC_PAGES = [
296
+ ['walkthrough', 'Walkthrough', 'End-to-end: a plugin from empty to built.'],
297
+ ['getting-started', 'Getting started', 'Install, macOS paths, wiring mw into an AI client.'],
298
+ ['authoring-guide', 'Authoring guide', 'Source formats, scripts, dialogue DSL, i18n, linting.'],
299
+ ['architecture', 'Architecture', 'Layered design and the roadmap.']
300
+ ].freeze
301
+
302
+ # Read-only context an agent can pull without a tool call: the command
303
+ # tree (same payload as the `commands` tool) and the narrative docs.
304
+ # Each :read lambda produces the resource body on demand.
305
+ RESOURCES = ([
306
+ { uri: 'mw://introspection', name: 'mw command tree',
307
+ description: 'Full command tree, options, and module surface.',
308
+ mime_type: 'application/json',
309
+ read: -> { JSON.pretty_generate(Esp::Introspection.command_tree) } }
310
+ ] + DOC_PAGES.map do |slug, name, description|
311
+ { uri: "mw://docs/#{slug}", name: name, description: description, mime_type: 'text/markdown',
312
+ read: -> { File.read(File.join(Esp::ROOT, 'docs', "#{slug}.md")) } }
313
+ end).freeze
314
+
315
+ RESOURCES_BY_URI = RESOURCES.to_h { |r| [r[:uri], r] }.freeze
316
+
317
+ # JSON-RPC error codes (subset of the spec we actually emit).
318
+ PARSE_ERROR = -32_700
319
+ INVALID_REQUEST = -32_600
320
+ METHOD_NOT_FOUND = -32_601
321
+ INVALID_PARAMS = -32_602
322
+ INTERNAL_ERROR = -32_603
323
+
324
+ # Caller-fault errors surfaced as a tool result rather than a protocol
325
+ # error — the shared list Operations owns.
326
+ TOOL_ERRORS = Esp::Operations.caller_errors
327
+
328
+ # Raised internally to carry a JSON-RPC error code out to the responder.
329
+ class ProtocolError < StandardError
330
+ attr_reader :code
331
+
332
+ def initialize(code, message)
333
+ super(message)
334
+ @code = code
335
+ end
336
+ end
337
+
338
+ def initialize(input: $stdin, output: $stdout)
339
+ @input = input
340
+ @output = output
341
+ end
342
+
343
+ # Read JSON-RPC messages line by line until stdin closes. Each line is a
344
+ # complete message; blank lines are ignored.
345
+ def start
346
+ @input.each_line do |line|
347
+ line = line.strip
348
+ next if line.empty?
349
+
350
+ response = process(line)
351
+ write(response) if response
352
+ end
353
+ end
354
+
355
+ private
356
+
357
+ def process(line)
358
+ message = JSON.parse(line)
359
+ rescue JSON::ParserError
360
+ error_response(nil, PARSE_ERROR, 'parse error')
361
+ else
362
+ handle(message)
363
+ end
364
+
365
+ def handle(message)
366
+ raise ProtocolError.new(INVALID_REQUEST, 'invalid request') unless message.is_a?(Hash)
367
+
368
+ id = message['id']
369
+ result = dispatch(message['method'], message['params'] || {}, id)
370
+ id.nil? ? nil : success_response(id, result)
371
+ rescue ProtocolError => e
372
+ error_response(message_id(message), e.code, e.message)
373
+ rescue StandardError => e
374
+ error_response(message_id(message), INTERNAL_ERROR, e.message)
375
+ end
376
+
377
+ # Returns the result for a known method. Unknown methods raise
378
+ # METHOD_NOT_FOUND for requests but are silently dropped for
379
+ # notifications (no id), per JSON-RPC.
380
+ def dispatch(method, params, id)
381
+ case method
382
+ when 'initialize' then initialize_result
383
+ when 'tools/list' then { tools: tool_descriptors }
384
+ when 'tools/call' then call_tool(params)
385
+ when 'resources/list' then { resources: resource_descriptors }
386
+ when 'resources/templates/list' then { resourceTemplates: [] }
387
+ when 'resources/read' then read_resource(params)
388
+ when 'ping' then {}
389
+ when %r{\Anotifications/} then nil # fire-and-forget, no response
390
+ else
391
+ raise ProtocolError.new(METHOD_NOT_FOUND, "method not found: #{method}") unless id.nil?
392
+
393
+ nil
394
+ end
395
+ end
396
+
397
+ def initialize_result
398
+ {
399
+ protocolVersion: PROTOCOL_VERSION,
400
+ capabilities: { tools: {}, resources: {} },
401
+ serverInfo: { name: 'esp', version: Esp::VERSION }
402
+ }
403
+ end
404
+
405
+ def tool_descriptors
406
+ TOOLS.map do |tool|
407
+ { name: tool[:name], description: tool[:description], inputSchema: tool[:input_schema] }
408
+ end
409
+ end
410
+
411
+ def resource_descriptors
412
+ RESOURCES.map do |r|
413
+ { uri: r[:uri], name: r[:name], description: r[:description], mimeType: r[:mime_type] }
414
+ end
415
+ end
416
+
417
+ def read_resource(params)
418
+ uri = params['uri']
419
+ resource = RESOURCES_BY_URI[uri]
420
+ raise ProtocolError.new(INVALID_PARAMS, "unknown resource: #{uri}") unless resource
421
+
422
+ { contents: [{ uri: uri, mimeType: resource[:mime_type], text: resource[:read].call }] }
423
+ end
424
+
425
+ def call_tool(params)
426
+ name = params['name']
427
+ tool = TOOLS_BY_NAME[name]
428
+ raise ProtocolError.new(INVALID_PARAMS, "unknown tool: #{name}") unless tool
429
+
430
+ payload = Esp::Operations.dispatch(tool[:op], params['arguments'] || {})
431
+ tool_result(payload)
432
+ rescue *TOOL_ERRORS => e
433
+ tool_error(e)
434
+ end
435
+
436
+ def tool_result(payload)
437
+ { content: [{ type: 'text', text: JSON.pretty_generate(payload) }] }
438
+ end
439
+
440
+ # Structured tool error: the text content is a JSON object carrying a
441
+ # stable code + message, so an agent can branch on the code rather than
442
+ # parse prose. isError tells the client the call failed but is recoverable.
443
+ def tool_error(exception)
444
+ payload = { error: { code: Esp::Operations.error_code(exception), message: exception.message } }
445
+ { content: [{ type: 'text', text: JSON.pretty_generate(payload) }], isError: true }
446
+ end
447
+
448
+ def success_response(id, result)
449
+ { jsonrpc: '2.0', id: id, result: result }
450
+ end
451
+
452
+ def error_response(id, code, message)
453
+ { jsonrpc: '2.0', id: id, error: { code: code, message: message } }
454
+ end
455
+
456
+ def message_id(message)
457
+ message.is_a?(Hash) ? message['id'] : nil
458
+ end
459
+
460
+ def write(response)
461
+ @output.puts(JSON.generate(response))
462
+ @output.flush
463
+ end
464
+ end
465
+ end
@@ -0,0 +1,71 @@
1
+ require 'fileutils'
2
+ require 'json'
3
+ require 'tempfile'
4
+
5
+ module Esp
6
+ module Mw
7
+ # Orchestrates mods/<Mod>/<Mod>.json -> dist/<Mod>.esp:
8
+ # 1. Load source JSON.
9
+ # 2. Run Esp::Mw::Preflight to inline text_source files and regenerate
10
+ # Script record blobs.
11
+ # 3. Hand the canonical JSON to tes3conv via a tempfile (whose
12
+ # extension must be .json — tes3conv sniffs by extension).
13
+ module Builder
14
+ Result = Struct.new(:output, :logs, keyword_init: true)
15
+
16
+ class << self
17
+ def build(mod, root: Esp::ROOT, locale: nil)
18
+ source = Esp::Mw::Loader.resolve(mod, root: root)
19
+ source_dir = File.dirname(source)
20
+
21
+ i18n = build_i18n(source_dir, locale)
22
+ records = Esp::Mw::Loader.load(source, i18n: i18n)
23
+ i18n.resolve!(records)
24
+
25
+ logs = Esp::Mw::Preflight.process!(records, source_dir: source_dir)
26
+ logs.concat(i18n_miss_logs(i18n))
27
+
28
+ output = output_path(mod, root, locale: locale)
29
+ FileUtils.mkdir_p(File.dirname(output))
30
+
31
+ Tempfile.create(['mw-build-', '.json']) do |tmp|
32
+ tmp.write("#{JSON.pretty_generate(records)}\n")
33
+ tmp.close
34
+ Esp::Mw::Tes3conv.convert(tmp.path, output)
35
+ end
36
+
37
+ Result.new(output: output, logs: logs)
38
+ end
39
+
40
+ def output_path(mod, root, locale: nil)
41
+ name = locale ? "#{mod}.#{locale}" : mod
42
+ File.join(root, 'dist', "#{name}.esp")
43
+ end
44
+
45
+ def discover_mods(root: Esp::ROOT)
46
+ Dir.glob(File.join(root, 'mods', '*')).filter_map do |dir|
47
+ next unless File.directory?(dir)
48
+
49
+ name = File.basename(dir)
50
+ unless Esp::Mw::Loader.supported_exts.any? { |ext| File.exist?(File.join(dir, "#{name}#{ext}")) }
51
+ next
52
+ end
53
+
54
+ name
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def build_i18n(source_dir, locale)
61
+ catalogues = Esp::Mw::I18n.load_catalogues(source_dir)
62
+ Esp::Mw::I18n.new(catalogues, locale: locale || Esp::Mw::I18n::DEFAULT_LOCALE)
63
+ end
64
+
65
+ def i18n_miss_logs(i18n)
66
+ i18n.misses.map { |m| "i18n: missing key #{m.key.inspect} in locale #{m.locale.inspect}" }
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,67 @@
1
+ module Esp
2
+ module Mw
3
+ # Where Morrowind keeps its vanilla data files (`Morrowind.esm` and
4
+ # friends, plus user-installed `.esp` plugins for the original engine).
5
+ # Engine-agnostic — OpenMW reads them via `data=` in openmw.cfg; the
6
+ # original engine reads them in place. The same set of probe paths
7
+ # serves both:
8
+ #
9
+ # - `esp refs unpack` reads the vanilla ESMs out of here.
10
+ # - `esp install --to-data-files` copies a built plugin into here so
11
+ # the original engine's launcher picks it up.
12
+ #
13
+ # Detection is best-effort, per-OS Steam/GOG defaults. Anything
14
+ # non-default is overridable via $MORROWIND_DATA or an explicit flag.
15
+ module DataFiles
16
+ class << self
17
+ def candidates
18
+ home = Dir.home
19
+ case Esp::Mw::OpenmwConfig.host_os
20
+ when :macos then macos_candidates(home)
21
+ when :windows then windows_candidates
22
+ else linux_candidates(home)
23
+ end
24
+ end
25
+
26
+ # The first existing candidate, else the first candidate (so error
27
+ # messages can still name a reasonable path even when nothing's
28
+ # installed).
29
+ def default
30
+ candidates.find { |dir| File.directory?(dir) } || candidates.first
31
+ end
32
+
33
+ # Resolve the user's intent: --data override beats $MORROWIND_DATA
34
+ # beats the per-OS default. Returns the resolved path (no validation
35
+ # — the caller decides whether to error on a missing dir).
36
+ def resolve(override: nil)
37
+ return override if override && !override.to_s.empty?
38
+ return ENV['MORROWIND_DATA'] if ENV['MORROWIND_DATA'] && !ENV['MORROWIND_DATA'].empty?
39
+
40
+ default
41
+ end
42
+
43
+ private
44
+
45
+ def macos_candidates(home)
46
+ steam = "#{home}/Library/Application Support/Steam/steamapps/common"
47
+ ["#{steam}/The Elder Scrolls III - Morrowind/Data Files",
48
+ "#{steam}/Morrowind/Data Files"]
49
+ end
50
+
51
+ def windows_candidates
52
+ ['C:/Program Files (x86)/Steam/steamapps/common/Morrowind/Data Files',
53
+ 'C:/GOG Games/Morrowind/Data Files',
54
+ 'C:/Program Files (x86)/GOG Galaxy/Games/Morrowind/Data Files',
55
+ 'C:/Program Files/Bethesda Softworks/Morrowind/Data Files']
56
+ end
57
+
58
+ def linux_candidates(home)
59
+ ["#{home}/.steam/steam/steamapps/common/Morrowind/Data Files",
60
+ "#{home}/.local/share/Steam/steamapps/common/Morrowind/Data Files",
61
+ "#{home}/Documents/Games/Morrowind/app/Data Files",
62
+ "#{home}/.wine/drive_c/Program Files/Bethesda Softworks/Morrowind/Data Files"]
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end