bg3-authoring 1.0.0b1__py3-none-any.whl

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 (50) hide show
  1. bg3_authoring/__init__.py +0 -0
  2. bg3_authoring/add_mod_dependency.py +367 -0
  3. bg3_authoring/cli.py +32 -0
  4. bg3_authoring/create_mod_scaffold.py +469 -0
  5. bg3_authoring/diagnostics/__init__.py +0 -0
  6. bg3_authoring/diagnostics/check_mod_conflicts.py +637 -0
  7. bg3_authoring/diagnostics/diagnose_toolkit_logs.py +765 -0
  8. bg3_authoring/diagnostics/inspect_save_game.py +29 -0
  9. bg3_authoring/diagnostics/sync_localization.py +280 -0
  10. bg3_authoring/diagnostics/validate_mod.py +576 -0
  11. bg3_authoring/generate_compat_patch.py +400 -0
  12. bg3_authoring/inject_treasure_table.py +267 -0
  13. bg3_authoring/merge_staging_to_mod.py +297 -0
  14. bg3_authoring/packaging/__init__.py +0 -0
  15. bg3_authoring/packaging/pack_mod_pak.py +385 -0
  16. bg3_authoring/packaging/sync_game_data.py +773 -0
  17. bg3_authoring/scaffold_action_resource.py +222 -0
  18. bg3_authoring/scaffold_appearance_visual.py +238 -0
  19. bg3_authoring/scaffold_class_description.py +455 -0
  20. bg3_authoring/scaffold_default_values.py +250 -0
  21. bg3_authoring/scaffold_feat.py +269 -0
  22. bg3_authoring/scaffold_icon_uvs.py +235 -0
  23. bg3_authoring/scaffold_level_map.py +210 -0
  24. bg3_authoring/scaffold_origin.py +223 -0
  25. bg3_authoring/scaffold_progression_node.py +360 -0
  26. bg3_authoring/scaffold_race.py +298 -0
  27. bg3_authoring/scaffold_root_template.py +403 -0
  28. bg3_authoring/scaffold_spell_list.py +196 -0
  29. bg3_authoring/scaffold_stat_entry.py +455 -0
  30. bg3_authoring/scaffold_tag.py +179 -0
  31. bg3_authoring/skills/bg3-check-mod-conflicts/SKILL.md +45 -0
  32. bg3_authoring/skills/bg3-check-mod-conflicts/references/conflict_methodology.md +33 -0
  33. bg3_authoring/skills/bg3-check-mod-conflicts/resources/remediation_patch_template.md +43 -0
  34. bg3_authoring/skills/bg3-create-mod/SKILL.md +55 -0
  35. bg3_authoring/skills/bg3-create-mod/references/grill_me_interview_protocol.md +15 -0
  36. bg3_authoring/skills/bg3-create-mod/resources/subagent_templates.md +40 -0
  37. bg3_authoring/skills/bg3-diagnose-mod/SKILL.md +52 -0
  38. bg3_authoring/skills/bg3-diagnose-mod/references/runtime_log_triage.md +30 -0
  39. bg3_authoring/skills/bg3-post-release/SKILL.md +52 -0
  40. bg3_authoring/skills/bg3-post-release/references/storefront_metadata_guide.md +37 -0
  41. bg3_authoring/skills/bg3-release-prep/SKILL.md +52 -0
  42. bg3_authoring/skills/bg3-release-prep/references/release_pipeline_specs.md +17 -0
  43. bg3_authoring/skills/bg3-test-mod/SKILL.md +58 -0
  44. bg3_authoring/skills/bg3-test-mod/references/save_audit_runbook.md +21 -0
  45. bg3_authoring/skills/bg3-test-mod/references/smoke_testing_runbook.md +23 -0
  46. bg3_authoring/templates/custom_mod_readme.md +30 -0
  47. bg3_authoring-1.0.0b1.dist-info/METADATA +70 -0
  48. bg3_authoring-1.0.0b1.dist-info/RECORD +50 -0
  49. bg3_authoring-1.0.0b1.dist-info/WHEEL +4 -0
  50. bg3_authoring-1.0.0b1.dist-info/entry_points.txt +2 -0
File without changes
@@ -0,0 +1,367 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ packages/bg3-authoring/bg3_authoring/add_mod_dependency.py
4
+ -----------------------------------------------------------
5
+ Appends community or core mod dependencies and test dependencies to an existing custom mod in my_mods/.
6
+
7
+ Features:
8
+ - Resolves dependency metadata (UUID, Version64, MD5, Folder, Name, PublishHandle)
9
+ via local mods/library.md before falling back to mod.io.
10
+ - Injects standard Larian v4.8 ModuleShortDesc nodes into Mods/*/meta.lsx.
11
+ - Updates mod README.md under ## Dependencies.
12
+ - Supports adding both permanent dependencies and test dependencies in a single invocation.
13
+ - Supports multiple comma-separated mod slugs or repeated CLI flags.
14
+ - Supports --test-only (with values or as a flag) and --test-dep flags to manage test dependencies in .test_deps.json.
15
+ - Supports target mod detection via --mod, positional argument, or current working directory.
16
+ - Supports --dry-run and structured --json output.
17
+ """
18
+
19
+ import argparse
20
+ import json
21
+ import re
22
+ import sys
23
+ import xml.etree.ElementTree as ET
24
+ from pathlib import Path
25
+ from typing import Any, Dict, List, Optional, Tuple
26
+
27
+ sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
28
+ from bg3_core.env_paths import MY_MODS_DIR, resolve_mod_dir
29
+ from bg3_core.mod_resolver import resolve_dependency_metadata
30
+
31
+
32
+ def detect_mod_from_cwd() -> Optional[str]:
33
+ """Detects mod name if the current working directory is inside a mod in MY_MODS_DIR."""
34
+ try:
35
+ cwd = Path.cwd().resolve()
36
+ my_mods = MY_MODS_DIR.resolve()
37
+ rel = cwd.relative_to(my_mods)
38
+ if rel.parts:
39
+ return rel.parts[0]
40
+ except Exception:
41
+ pass
42
+ return None
43
+
44
+
45
+ def parse_slug_list(values: Any) -> List[str]:
46
+ """Parses a list of CLI values which may contain comma-separated mod slugs."""
47
+ if not values:
48
+ return []
49
+ if isinstance(values, str):
50
+ values = [values]
51
+ slugs: List[str] = []
52
+ for val in values:
53
+ if isinstance(val, str):
54
+ for part in val.split(","):
55
+ cleaned = part.strip()
56
+ if cleaned and cleaned not in slugs:
57
+ slugs.append(cleaned)
58
+ return slugs
59
+
60
+
61
+ def add_test_dependencies(mod_dir: Path, dep_metas: List[Dict[str, Any]], dry_run: bool = False) -> List[Dict[str, Any]]:
62
+ """Adds temporary test dependencies to .test_deps.json in the mod workspace."""
63
+ config_file = mod_dir / ".test_deps.json"
64
+ data: Dict[str, Any] = {"test_dependencies": []}
65
+ if config_file.is_file():
66
+ try:
67
+ data = json.loads(config_file.read_text(encoding="utf-8"))
68
+ if "test_dependencies" not in data or not isinstance(data["test_dependencies"], list):
69
+ data["test_dependencies"] = []
70
+ except Exception:
71
+ data = {"test_dependencies": []}
72
+
73
+ existing = [str(d).lower() for d in data["test_dependencies"]]
74
+ results: List[Dict[str, Any]] = []
75
+ modified = False
76
+
77
+ for dep_meta in dep_metas:
78
+ dep_name = dep_meta.get("Name") or dep_meta.get("Folder") or dep_meta.get("UUID") or "Unknown"
79
+ dep_identifier = dep_meta.get("Folder") or dep_name
80
+
81
+ if dep_name.lower() in existing or dep_identifier.lower() in existing:
82
+ results.append({
83
+ "status": "already_present",
84
+ "mod": mod_dir.name,
85
+ "type": "test_dependency",
86
+ "dependency": dep_name,
87
+ "config_file": str(config_file),
88
+ })
89
+ continue
90
+
91
+ data["test_dependencies"].append(dep_name)
92
+ existing.append(dep_name.lower())
93
+ existing.append(dep_identifier.lower())
94
+ modified = True
95
+
96
+ results.append({
97
+ "status": "added_test_dependency",
98
+ "mod": mod_dir.name,
99
+ "type": "test_dependency",
100
+ "dependency": dep_name,
101
+ "uuid": dep_meta.get("UUID"),
102
+ "config_file": str(config_file),
103
+ "dry_run": dry_run,
104
+ })
105
+
106
+ if modified and not dry_run:
107
+ config_file.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
108
+
109
+ return results
110
+
111
+
112
+ def add_test_dependency(mod_dir: Path, dep_meta: Dict[str, Any], dry_run: bool = False) -> Dict[str, Any]:
113
+ """Single-dependency wrapper for add_test_dependencies."""
114
+ res = add_test_dependencies(mod_dir, [dep_meta], dry_run=dry_run)
115
+ return res[0] if res else {}
116
+
117
+
118
+ def add_permanent_dependencies(mod_dir: Path, dep_metas: List[Dict[str, Any]], dry_run: bool = False) -> List[Dict[str, Any]]:
119
+ """Injects dependency ModuleShortDesc nodes into meta.lsx and updates README.md."""
120
+ meta_files = list(mod_dir.glob("Mods/*/meta.lsx"))
121
+ if not meta_files:
122
+ raise FileNotFoundError(f"Could not locate Mods/*/meta.lsx in '{mod_dir}'.")
123
+
124
+ target_meta = meta_files[0]
125
+ tree = ET.parse(target_meta)
126
+ root = tree.getroot()
127
+
128
+ # Find or create Dependencies node
129
+ deps_node = root.find(".//node[@id='Dependencies']")
130
+ if deps_node is None:
131
+ root_node = root.find(".//node[@id='root']/children")
132
+ if root_node is None:
133
+ raise ValueError(f"Malformed meta.lsx in '{target_meta}': missing root children.")
134
+ deps_node = ET.SubElement(root_node, "node", id="Dependencies")
135
+
136
+ children = deps_node.find("children")
137
+ if children is None:
138
+ children = ET.SubElement(deps_node, "children")
139
+
140
+ existing_uuids = set()
141
+ for short_desc in children.findall("node[@id='ModuleShortDesc']"):
142
+ u_attr = short_desc.find("attribute[@id='UUID']")
143
+ if u_attr is not None and u_attr.get("value"):
144
+ existing_uuids.add(u_attr.get("value", "").lower())
145
+
146
+ results: List[Dict[str, Any]] = []
147
+ meta_modified = False
148
+ new_readme_entries: List[Tuple[Dict[str, Any], str]] = []
149
+
150
+ for dep_meta in dep_metas:
151
+ dep_uuid = (dep_meta.get("UUID") or "").lower()
152
+ if dep_uuid and dep_uuid in existing_uuids:
153
+ results.append({
154
+ "status": "already_present",
155
+ "mod": mod_dir.name,
156
+ "type": "permanent_dependency",
157
+ "dependency": dep_meta.get("Name"),
158
+ "uuid": dep_meta.get("UUID"),
159
+ "file": str(target_meta),
160
+ })
161
+ continue
162
+
163
+ # Construct new ModuleShortDesc node
164
+ new_desc = ET.Element("node", id="ModuleShortDesc")
165
+ ET.SubElement(new_desc, "attribute", id="Folder", type="LSString", value=dep_meta.get("Folder", dep_meta.get("Name", "")))
166
+ ET.SubElement(new_desc, "attribute", id="MD5", type="LSString", value=dep_meta.get("MD5", ""))
167
+ ET.SubElement(new_desc, "attribute", id="Name", type="LSString", value=dep_meta.get("Name", ""))
168
+ ET.SubElement(new_desc, "attribute", id="PublishHandle", type="uint64", value=str(dep_meta.get("PublishHandle", 0)))
169
+ ET.SubElement(new_desc, "attribute", id="UUID", type="guid", value=dep_meta.get("UUID", ""))
170
+ ET.SubElement(new_desc, "attribute", id="Version64", type="int64", value=str(dep_meta.get("Version64", 36028797018963968)))
171
+
172
+ children.append(new_desc)
173
+ if dep_uuid:
174
+ existing_uuids.add(dep_uuid)
175
+ meta_modified = True
176
+
177
+ dep_entry = f"- `{dep_meta.get('Name')}` (`{dep_meta.get('UUID')}`)"
178
+ new_readme_entries.append((dep_meta, dep_entry))
179
+
180
+ results.append({
181
+ "status": "added_dependency",
182
+ "mod": mod_dir.name,
183
+ "type": "permanent_dependency",
184
+ "dependency": dep_meta.get("Name"),
185
+ "uuid": dep_meta.get("UUID"),
186
+ "folder": dep_meta.get("Folder"),
187
+ "publish_handle": dep_meta.get("PublishHandle"),
188
+ "meta_file": str(target_meta),
189
+ "readme_updated": False,
190
+ "dry_run": dry_run,
191
+ })
192
+
193
+ if meta_modified and not dry_run:
194
+ ET.indent(tree, space=" ")
195
+ tree.write(target_meta, encoding="utf-8", xml_declaration=True)
196
+
197
+ # Update README.md if present
198
+ readme_path = mod_dir / "README.md"
199
+ readme_updated = False
200
+ if readme_path.is_file() and new_readme_entries:
201
+ readme_txt = readme_path.read_text(encoding="utf-8")
202
+ unlisted = [e for e in new_readme_entries if e[0].get("UUID") and e[0]["UUID"] not in readme_txt]
203
+ if unlisted and "## Dependencies" in readme_txt:
204
+ entries_block = "\n".join(e[1] for e in unlisted)
205
+ if re.search(r"## Dependencies\s*\n\s*- None\.", readme_txt):
206
+ readme_txt = re.sub(r"(## Dependencies\s*\n)\s*- None\.", r"\1" + entries_block, readme_txt)
207
+ else:
208
+ readme_txt = re.sub(r"(## Dependencies\s*\n)", r"\1" + entries_block + "\n", readme_txt)
209
+ readme_updated = True
210
+ if not dry_run:
211
+ readme_path.write_text(readme_txt, encoding="utf-8")
212
+
213
+ for r in results:
214
+ if r.get("status") == "added_dependency":
215
+ r["readme_updated"] = readme_updated
216
+
217
+ return results
218
+
219
+
220
+ def add_permanent_dependency(mod_dir: Path, dep_meta: Dict[str, Any], dry_run: bool = False) -> Dict[str, Any]:
221
+ """Single-dependency wrapper for add_permanent_dependencies."""
222
+ res = add_permanent_dependencies(mod_dir, [dep_meta], dry_run=dry_run)
223
+ return res[0] if res else {}
224
+
225
+
226
+ def resolve_cli_targets(args: argparse.Namespace) -> Tuple[List[str], List[str]]:
227
+ """
228
+ Categorizes CLI input into permanent dependencies and test dependencies.
229
+ Supports comma-separated slugs, repeated flags, --test-dep, and --test-only as flag or value.
230
+ """
231
+ perm_slugs: List[str] = []
232
+ test_slugs: List[str] = []
233
+
234
+ # 1. Parse explicit --test-dep / --test-deps values
235
+ if getattr(args, "test_dep", None):
236
+ test_slugs.extend(parse_slug_list(args.test_dep))
237
+
238
+ # 2. Parse --test-only
239
+ raw_test_only = getattr(args, "test_only", [])
240
+ has_test_only_flag = any(v is True for v in raw_test_only)
241
+ test_only_values = parse_slug_list([v for v in raw_test_only if isinstance(v, str)])
242
+ if test_only_values:
243
+ test_slugs.extend(test_only_values)
244
+
245
+ # 3. Parse --dep / --dependency
246
+ raw_dep = getattr(args, "dep", [])
247
+ dep_slugs = parse_slug_list(raw_dep)
248
+
249
+ # If --test-only was passed as a bare flag without slug values:
250
+ # route --dep values to test dependencies (preserves backward compatibility)
251
+ if has_test_only_flag and not test_only_values:
252
+ test_slugs.extend(dep_slugs)
253
+ else:
254
+ perm_slugs.extend(dep_slugs)
255
+
256
+ # Remove duplicates preserving order
257
+ dedup_perm: List[str] = []
258
+ for s in perm_slugs:
259
+ if s not in dedup_perm:
260
+ dedup_perm.append(s)
261
+
262
+ dedup_test: List[str] = []
263
+ for s in test_slugs:
264
+ if s not in dedup_test:
265
+ dedup_test.append(s)
266
+
267
+ return dedup_perm, dedup_test
268
+
269
+
270
+ def main() -> None:
271
+ parser = argparse.ArgumentParser(
272
+ description="Add community or core mod dependencies and test dependencies to an existing custom mod in my_mods/."
273
+ )
274
+ parser.add_argument("pos_mod", nargs="?", default=None, help="Target mod name or slug (optional if --mod is specified)")
275
+ parser.add_argument("--mod", default=None, help="Custom mod name, slug, or folder in my_mods/")
276
+ parser.add_argument("--dep", "--dependency", dest="dep", action="append", default=[], help="Dependency mod name(s), slug(s), or UUID(s) (comma-separated or repeated)")
277
+ parser.add_argument("--test-dep", "--test-deps", dest="test_dep", action="append", default=[], help="Test dependency mod name(s), slug(s), or UUID(s) (comma-separated or repeated)")
278
+ parser.add_argument(
279
+ "--test-only",
280
+ dest="test_only",
281
+ nargs="?",
282
+ const=True,
283
+ action="append",
284
+ default=[],
285
+ help="Add as test dependency in .test_deps.json. Can be a bare flag or take comma-separated mod slug(s)",
286
+ )
287
+ parser.add_argument("--dry-run", action="store_true", help="Preview changes without modifying files")
288
+ parser.add_argument("--json", action="store_true", help="Output machine-readable JSON result")
289
+
290
+ args = parser.parse_args()
291
+
292
+ try:
293
+ target_mod = args.mod or args.pos_mod or detect_mod_from_cwd()
294
+ if not target_mod:
295
+ raise ValueError("Target mod must be specified via --mod <name>, positional argument, or by running inside a mod folder in my_mods/.")
296
+
297
+ mod_dir = resolve_mod_dir(target_mod, prefer_my_mods=True)
298
+ if not mod_dir or not mod_dir.is_dir():
299
+ raise FileNotFoundError(f"Could not locate mod directory for '{target_mod}'.")
300
+
301
+ perm_slugs, test_slugs = resolve_cli_targets(args)
302
+ if not perm_slugs and not test_slugs:
303
+ raise ValueError("No dependencies specified. Provide dependency mod slugs via --dep, --test-dep, or --test-only.")
304
+
305
+ perm_results: List[Dict[str, Any]] = []
306
+ if perm_slugs:
307
+ perm_metas = [
308
+ resolve_dependency_metadata(slug, auto_document=True, strict=True)
309
+ for slug in perm_slugs
310
+ ]
311
+ perm_results = add_permanent_dependencies(mod_dir, perm_metas, dry_run=args.dry_run)
312
+
313
+ test_results: List[Dict[str, Any]] = []
314
+ if test_slugs:
315
+ test_metas = [
316
+ resolve_dependency_metadata(slug, auto_document=True, strict=False)
317
+ for slug in test_slugs
318
+ ]
319
+ test_results = add_test_dependencies(mod_dir, test_metas, dry_run=args.dry_run)
320
+
321
+ all_results = perm_results + test_results
322
+ success = True
323
+
324
+ if args.json:
325
+ response: Dict[str, Any] = {
326
+ "mod": mod_dir.name,
327
+ "status": "success",
328
+ "dependencies": perm_results,
329
+ "test_dependencies": test_results,
330
+ "results": all_results,
331
+ "success": success,
332
+ "dry_run": args.dry_run,
333
+ }
334
+ if len(all_results) == 1:
335
+ single = all_results[0]
336
+ for k, v in single.items():
337
+ if k not in response:
338
+ response[k] = v
339
+ response["status"] = single.get("status", "success")
340
+ print(json.dumps(response, indent=2))
341
+ else:
342
+ verb = "[DRY-RUN] Would add" if args.dry_run else "Successfully added"
343
+ for r in all_results:
344
+ dep_name = r.get("dependency") or r.get("uuid") or "Unknown"
345
+ status = r.get("status")
346
+ dep_kind = "test dependency" if r.get("type") == "test_dependency" else "dependency"
347
+ if status == "already_present":
348
+ print(f"• {dep_kind.capitalize()} '{dep_name}' is already present in '{r.get('mod')}'.")
349
+ else:
350
+ print(f"✓ {verb} {dep_kind} '{dep_name}' to '{r.get('mod')}'.")
351
+ if r.get("uuid"):
352
+ print(f" UUID: {r.get('uuid')}")
353
+ if r.get("meta_file"):
354
+ print(f" Updated: {r.get('meta_file')}")
355
+ elif r.get("config_file") and status != "already_present":
356
+ print(f" Updated: {r.get('config_file')}")
357
+
358
+ except Exception as e:
359
+ if args.json:
360
+ print(json.dumps({"error": str(e), "success": False}, indent=2))
361
+ else:
362
+ print(f"Error: {e}", file=sys.stderr)
363
+ sys.exit(1)
364
+
365
+
366
+ if __name__ == "__main__":
367
+ main()
bg3_authoring/cli.py ADDED
@@ -0,0 +1,32 @@
1
+ """bg3-authoring CLI entrypoint."""
2
+
3
+ import sys
4
+
5
+ from bg3_core.cli import build_parser, dispatch_command
6
+ from bg3_core.cli_common import ConsoleColor, ExitCode, OutputEmitter
7
+
8
+
9
+ def main() -> None:
10
+ parser = build_parser()
11
+ if len(sys.argv) == 1:
12
+ parser.print_help(sys.stderr)
13
+ sys.exit(ExitCode.USAGE)
14
+
15
+ args = parser.parse_args()
16
+ color = ConsoleColor(stream=sys.stderr, force_color=False)
17
+ emitter = OutputEmitter(json_mode=args.json, color=color)
18
+
19
+ try:
20
+ ret = dispatch_command(args, emitter)
21
+ sys.exit(ret)
22
+ except KeyboardInterrupt:
23
+ emitter.error(
24
+ message="Operation aborted by user.",
25
+ code="SIGINT",
26
+ exit_code=130,
27
+ exit_process=True,
28
+ )
29
+
30
+
31
+ if __name__ == "__main__":
32
+ main()