ff9mapkit 1.0.0b3__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 (193) hide show
  1. ff9mapkit/__init__.py +18 -0
  2. ff9mapkit/__main__.py +36 -0
  3. ff9mapkit/_animdb.py +2994 -0
  4. ff9mapkit/_animdb_all.py +14125 -0
  5. ff9mapkit/_fieldtable.py +1516 -0
  6. ff9mapkit/_fieldtext.py +845 -0
  7. ff9mapkit/_held_poses.py +44 -0
  8. ff9mapkit/_itemdb.py +65 -0
  9. ff9mapkit/_modeldb.py +725 -0
  10. ff9mapkit/_narrowmap_data.py +10 -0
  11. ff9mapkit/_npcparams.py +634 -0
  12. ff9mapkit/_regen_animdb.py +72 -0
  13. ff9mapkit/_regen_animdb_all.py +66 -0
  14. ff9mapkit/_regen_fieldtable.py +95 -0
  15. ff9mapkit/_regen_fieldtext.py +66 -0
  16. ff9mapkit/_regen_modeldb.py +67 -0
  17. ff9mapkit/_regen_npcparams.py +123 -0
  18. ff9mapkit/_regen_scenedb.py +57 -0
  19. ff9mapkit/_scenedb.py +869 -0
  20. ff9mapkit/abilities.py +225 -0
  21. ff9mapkit/animations.py +120 -0
  22. ff9mapkit/archetypes.py +218 -0
  23. ff9mapkit/areatitle.py +76 -0
  24. ff9mapkit/battle/__init__.py +21 -0
  25. ff9mapkit/battle/abilityfeatures.py +294 -0
  26. ff9mapkit/battle/actiondelta.py +441 -0
  27. ff9mapkit/battle/aiauthor.py +305 -0
  28. ff9mapkit/battle/ailint.py +140 -0
  29. ff9mapkit/battle/aipatch.py +175 -0
  30. ff9mapkit/battle/battleai.py +148 -0
  31. ff9mapkit/battle/battlecsv.py +390 -0
  32. ff9mapkit/battle/battlepatch.py +395 -0
  33. ff9mapkit/battle/build.py +558 -0
  34. ff9mapkit/battle/camera_codec.py +332 -0
  35. ff9mapkit/battle/camera_data.py +128 -0
  36. ff9mapkit/battle/characterdelta.py +789 -0
  37. ff9mapkit/battle/event_data.py +72 -0
  38. ff9mapkit/battle/extract.py +540 -0
  39. ff9mapkit/battle/fbx.py +223 -0
  40. ff9mapkit/battle/reskin.py +149 -0
  41. ff9mapkit/battle/scene_codec.py +314 -0
  42. ff9mapkit/battle/scene_data.py +369 -0
  43. ff9mapkit/battle/scenelint.py +125 -0
  44. ff9mapkit/battle/seqasm.py +131 -0
  45. ff9mapkit/battle/seqauthor.py +220 -0
  46. ff9mapkit/battle/seqcodec.py +300 -0
  47. ff9mapkit/battle/seqdis.py +106 -0
  48. ff9mapkit/battle/seqpatch.py +137 -0
  49. ff9mapkit/battle_bgm.py +133 -0
  50. ff9mapkit/binutils.py +60 -0
  51. ff9mapkit/build.py +5445 -0
  52. ff9mapkit/campaign.py +1276 -0
  53. ff9mapkit/catalog.py +316 -0
  54. ff9mapkit/chain.py +358 -0
  55. ff9mapkit/cli.py +3114 -0
  56. ff9mapkit/config.py +360 -0
  57. ff9mapkit/content/__init__.py +13 -0
  58. ff9mapkit/content/areatitle.py +36 -0
  59. ff9mapkit/content/ate.py +118 -0
  60. ff9mapkit/content/camera.py +123 -0
  61. ff9mapkit/content/chest.py +186 -0
  62. ff9mapkit/content/choice.py +163 -0
  63. ff9mapkit/content/conductor.py +217 -0
  64. ff9mapkit/content/cutscene.py +290 -0
  65. ff9mapkit/content/encounter.py +41 -0
  66. ff9mapkit/content/entry_settle.py +50 -0
  67. ff9mapkit/content/equipment.py +93 -0
  68. ff9mapkit/content/event.py +191 -0
  69. ff9mapkit/content/gateway.py +101 -0
  70. ff9mapkit/content/inventory.py +59 -0
  71. ff9mapkit/content/itemdata.py +644 -0
  72. ff9mapkit/content/itemtext.py +168 -0
  73. ff9mapkit/content/jump.py +114 -0
  74. ff9mapkit/content/ladder.py +633 -0
  75. ff9mapkit/content/movement.py +53 -0
  76. ff9mapkit/content/music.py +97 -0
  77. ff9mapkit/content/npc.py +348 -0
  78. ff9mapkit/content/object.py +340 -0
  79. ff9mapkit/content/onentry.py +135 -0
  80. ff9mapkit/content/party.py +111 -0
  81. ff9mapkit/content/pathfind.py +138 -0
  82. ff9mapkit/content/platform.py +314 -0
  83. ff9mapkit/content/player.py +168 -0
  84. ff9mapkit/content/prop.py +75 -0
  85. ff9mapkit/content/region.py +340 -0
  86. ff9mapkit/content/reinit.py +59 -0
  87. ff9mapkit/content/savepoint.py +90 -0
  88. ff9mapkit/content/shop.py +178 -0
  89. ff9mapkit/content/sps_trigger.py +66 -0
  90. ff9mapkit/content/startup.py +71 -0
  91. ff9mapkit/content/synthesis.py +106 -0
  92. ff9mapkit/content/text.py +183 -0
  93. ff9mapkit/content/textcarry.py +290 -0
  94. ff9mapkit/content/verbatim.py +86 -0
  95. ff9mapkit/content/walkmesh_hotfix.py +38 -0
  96. ff9mapkit/data/__init__.py +48 -0
  97. ff9mapkit/data/_regen_provenance.py +142 -0
  98. ff9mapkit/data/provenance/blank.es.patch +1 -0
  99. ff9mapkit/data/provenance/blank.fr.patch +1 -0
  100. ff9mapkit/data/provenance/blank.gr.patch +1 -0
  101. ff9mapkit/data/provenance/blank.it.patch +1 -0
  102. ff9mapkit/data/provenance/blank.jp.patch +1 -0
  103. ff9mapkit/data/provenance/blank.uk.patch +1 -0
  104. ff9mapkit/data/provenance/blank.us.patch +1 -0
  105. ff9mapkit/data/provenance/manifest.json +65 -0
  106. ff9mapkit/data/provenance/region_template.patch +1 -0
  107. ff9mapkit/data/reference_arcs.toml +89 -0
  108. ff9mapkit/data/region_catalog.toml +593 -0
  109. ff9mapkit/deploystack.py +358 -0
  110. ff9mapkit/dialogue.py +803 -0
  111. ff9mapkit/eb/__init__.py +12 -0
  112. ff9mapkit/eb/_exprtable.py +59 -0
  113. ff9mapkit/eb/_membertable.py +38 -0
  114. ff9mapkit/eb/_optables.py +537 -0
  115. ff9mapkit/eb/_regen_optables.py +76 -0
  116. ff9mapkit/eb/cmdasm.py +323 -0
  117. ff9mapkit/eb/disasm.py +332 -0
  118. ff9mapkit/eb/edit.py +439 -0
  119. ff9mapkit/eb/exprasm.py +158 -0
  120. ff9mapkit/eb/model.py +178 -0
  121. ff9mapkit/eb/opcodes.py +463 -0
  122. ff9mapkit/eblint.py +177 -0
  123. ff9mapkit/editor/__init__.py +20 -0
  124. ff9mapkit/editor/app.py +950 -0
  125. ff9mapkit/editor/battle_forms.py +240 -0
  126. ff9mapkit/editor/breadcrumb.py +89 -0
  127. ff9mapkit/editor/dialogs.py +116 -0
  128. ff9mapkit/editor/feedback.py +208 -0
  129. ff9mapkit/editor/forms.py +632 -0
  130. ff9mapkit/editor/graphview.py +350 -0
  131. ff9mapkit/editor/jobs.py +342 -0
  132. ff9mapkit/editor/model.py +243 -0
  133. ff9mapkit/editor/picker.py +120 -0
  134. ff9mapkit/editor/theme.py +212 -0
  135. ff9mapkit/eventscan.py +1441 -0
  136. ff9mapkit/extract.py +2279 -0
  137. ff9mapkit/flags.py +693 -0
  138. ff9mapkit/forkreport.py +1383 -0
  139. ff9mapkit/hub.py +477 -0
  140. ff9mapkit/idgated.py +101 -0
  141. ff9mapkit/infohub.py +580 -0
  142. ff9mapkit/items.py +63 -0
  143. ff9mapkit/itemstats.py +346 -0
  144. ff9mapkit/journey.py +1902 -0
  145. ff9mapkit/keyitems.py +93 -0
  146. ff9mapkit/logic_add.py +632 -0
  147. ff9mapkit/logic_edit.py +728 -0
  148. ff9mapkit/logic_map.py +526 -0
  149. ff9mapkit/pack.py +175 -0
  150. ff9mapkit/playerswap.py +231 -0
  151. ff9mapkit/prop_archetypes.py +228 -0
  152. ff9mapkit/provision.py +282 -0
  153. ff9mapkit/refarc.py +825 -0
  154. ff9mapkit/save.py +337 -0
  155. ff9mapkit/save_items.py +1673 -0
  156. ff9mapkit/scene/__init__.py +11 -0
  157. ff9mapkit/scene/arena.py +63 -0
  158. ff9mapkit/scene/bgart.py +140 -0
  159. ff9mapkit/scene/bgi.py +732 -0
  160. ff9mapkit/scene/bgs.py +174 -0
  161. ff9mapkit/scene/bgx.py +185 -0
  162. ff9mapkit/scene/cam.py +345 -0
  163. ff9mapkit/scene/guide.py +311 -0
  164. ff9mapkit/scene/paint.py +506 -0
  165. ff9mapkit/scene/placeholder.py +107 -0
  166. ff9mapkit/sjbinary.py +285 -0
  167. ff9mapkit/sps/__init__.py +17 -0
  168. ff9mapkit/sps/author.py +294 -0
  169. ff9mapkit/sps/catalog.py +88 -0
  170. ff9mapkit/sps/codec.py +264 -0
  171. ff9mapkit/sps/edit.py +184 -0
  172. ff9mapkit/sps/lint.py +58 -0
  173. ff9mapkit/sps/render.py +116 -0
  174. ff9mapkit/sps/templates.py +47 -0
  175. ff9mapkit/sps/texture.py +131 -0
  176. ff9mapkit/walkmesh_hotfixes.py +163 -0
  177. ff9mapkit/workspace/__init__.py +18 -0
  178. ff9mapkit/workspace/battledoc.py +985 -0
  179. ff9mapkit/workspace/builddoc.py +607 -0
  180. ff9mapkit/workspace/forms_qt.py +586 -0
  181. ff9mapkit/workspace/importdoc.py +665 -0
  182. ff9mapkit/workspace/mapview.py +131 -0
  183. ff9mapkit/workspace/palette.py +85 -0
  184. ff9mapkit/workspace/savedoc.py +664 -0
  185. ff9mapkit/workspace/shell.py +6907 -0
  186. ff9mapkit/workspace/style.py +105 -0
  187. ff9mapkit/workspace/tuningdialog.py +223 -0
  188. ff9mapkit-1.0.0b3.dist-info/METADATA +155 -0
  189. ff9mapkit-1.0.0b3.dist-info/RECORD +193 -0
  190. ff9mapkit-1.0.0b3.dist-info/WHEEL +5 -0
  191. ff9mapkit-1.0.0b3.dist-info/entry_points.txt +5 -0
  192. ff9mapkit-1.0.0b3.dist-info/licenses/LICENSE +31 -0
  193. ff9mapkit-1.0.0b3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,607 @@
1
+ """The Build & Deploy document for the Workspace (Phase 6b) -- the tkinter ff9_build_gui, folded in.
2
+
3
+ Pick a project file; its kind is auto-detected (:func:`..editor.jobs.detect_kind`) and the matching target
4
+ panel shows: a single field (test slot / install to game / build to a folder), a whole campaign
5
+ (deploy / build-only), a multi-campaign journey (dry-run playbook / one-shot deploy / re-apply links), or a
6
+ battle map (deploy + optional trigger field). **Check** validates in-process (structured Problems); **Build /
7
+ Deploy / Revert** stream through the shell's ``run_job`` into the Output panel. Only this view is Qt --
8
+ detection + argv are jobs.py, verdicts are editor.feedback, and the deploys are the same ``tools/deploy_*.py``
9
+ the CLI loop uses (the journey path = ``tools/deploy_journey.py``, the orchestrator above deploy_campaign).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+
16
+ from PySide6.QtWidgets import (
17
+ QButtonGroup, QCheckBox, QFileDialog, QFrame, QGroupBox, QHBoxLayout, QLabel, QLineEdit, QMessageBox,
18
+ QPushButton, QRadioButton, QScrollArea, QVBoxLayout, QWidget,
19
+ )
20
+
21
+ from ..editor import feedback as fb
22
+ from ..editor import jobs
23
+
24
+
25
+ class BuildDoc(QWidget):
26
+ """Build / deploy a field, campaign, journey, or battle map, as a Workspace document. ``run`` =
27
+ ``shell.run_job`` (streams a subprocess to Output + posts a verdict); ``problems`` =
28
+ ``shell._show_problems`` (the in-process Check verdict + problems list)."""
29
+
30
+ def __init__(self, pal, repo_root, *, run, problems):
31
+ super().__init__()
32
+ self.pal = pal
33
+ self.repo = Path(repo_root)
34
+ self.kit = self.repo / "ff9mapkit" # `-m ff9mapkit build` cwd (local pkg shadows)
35
+ self._run = run
36
+ self._problems = problems
37
+ self.kind = "field"
38
+ self.plan = None # the campaign plan when kind == "campaign"
39
+ self.manifest = None # the journey manifest when kind == "journey"
40
+ self.field_id = None
41
+ self.field_name = None
42
+ self.mod_folder, self.worktree_id = jobs.detect_deploy_target(self.repo)
43
+ self.game_mod = jobs.detect_game_mod()
44
+ self._build_ui()
45
+ self._render_kind()
46
+
47
+ # ------------------------------------------------------------------ UI
48
+ def _build_ui(self):
49
+ # SCROLL the body: five target group boxes + the New-Game box stack tall, so a short window would
50
+ # cram them and inflate the central minimum height (blocking the bottom Output dock from growing).
51
+ outer = QVBoxLayout(self)
52
+ outer.setContentsMargins(0, 0, 0, 0)
53
+ scroll = QScrollArea()
54
+ scroll.setWidgetResizable(True)
55
+ scroll.setFrameShape(QFrame.Shape.NoFrame)
56
+ inner = QWidget()
57
+ v = QVBoxLayout(inner)
58
+ v.setContentsMargins(14, 14, 14, 14)
59
+ v.setSpacing(10)
60
+ row = QHBoxLayout()
61
+ row.addWidget(QLabel("Project file:"))
62
+ self.path = QLineEdit()
63
+ self.path.setPlaceholderText("a .field.toml, campaign.toml, journeys.toml, or battle.toml")
64
+ self.path.textChanged.connect(self._on_path)
65
+ browse = QPushButton("Browse…")
66
+ browse.clicked.connect(self.browse)
67
+ row.addWidget(self.path, 1)
68
+ row.addWidget(browse)
69
+ v.addLayout(row)
70
+
71
+ self.status = QLabel("Pick a field, campaign, journey, or battle file.")
72
+ self.status.setWordWrap(True)
73
+ self.status.setStyleSheet(f"color:{self.pal['muted']};")
74
+ v.addWidget(self.status)
75
+
76
+ v.addWidget(self._field_box())
77
+ v.addWidget(self._campaign_box())
78
+ v.addWidget(self._journey_box())
79
+ v.addWidget(self._battle_box())
80
+ v.addWidget(self._newgame_box())
81
+
82
+ btns = QHBoxLayout()
83
+ self.chk = QPushButton("Check logic")
84
+ self.chk.clicked.connect(self.on_check)
85
+ self.go = QPushButton("Build / Deploy")
86
+ self.go.clicked.connect(self.on_go)
87
+ self.rev = QPushButton("Revert test deploy")
88
+ self.rev.clicked.connect(self.on_revert)
89
+ btns.addWidget(self.chk)
90
+ btns.addWidget(self.go)
91
+ btns.addWidget(self.rev)
92
+ btns.addStretch(1)
93
+ v.addLayout(btns)
94
+ v.addStretch(1)
95
+ scroll.setWidget(inner)
96
+ outer.addWidget(scroll)
97
+
98
+ def _field_box(self):
99
+ box = QGroupBox("Build to (field)")
100
+ gv = QVBoxLayout(box)
101
+ self.tg = QButtonGroup(self)
102
+ tid = self.worktree_id or 4003
103
+ self.rb_test = QRadioButton(f"Test slot {tid} — quick + reversible; play via F6 → Warp"
104
+ + (" (or New Game → hut door)" if tid == 4003 else ""))
105
+ self.rb_test.setChecked(True)
106
+ self.rb_game = QRadioButton(f"Install to game (shipping mod folder): {self.game_mod}"
107
+ if self.game_mod else "Install to game — (game install not found)")
108
+ if not self.game_mod:
109
+ self.rb_game.setEnabled(False)
110
+ of = QHBoxLayout()
111
+ self.rb_other = QRadioButton("Build only — to a folder:")
112
+ self.other = QLineEdit()
113
+ ob = QPushButton("Browse…")
114
+ ob.clicked.connect(self.browse_other)
115
+ of.addWidget(self.rb_other)
116
+ of.addWidget(self.other, 1)
117
+ of.addWidget(ob)
118
+ for rb in (self.rb_test, self.rb_game, self.rb_other):
119
+ self.tg.addButton(rb)
120
+ rb.toggled.connect(self._update_dest)
121
+ self.other.textChanged.connect(self._update_dest)
122
+ gv.addWidget(self.rb_test)
123
+ gv.addWidget(self.rb_game)
124
+ gv.addLayout(of)
125
+ self.dest = QLabel("")
126
+ self.dest.setWordWrap(True)
127
+ self.dest.setStyleSheet(f"color:{self.pal['accent']};")
128
+ gv.addWidget(self.dest)
129
+ self.field_box = box
130
+ return box
131
+
132
+ def _campaign_box(self):
133
+ box = QGroupBox("Deploy campaign")
134
+ cv = QVBoxLayout(box)
135
+ self.cg = QButtonGroup(self)
136
+ self.rb_camp_deploy = QRadioButton("Deploy to game (reversible)")
137
+ self.rb_camp_deploy.setChecked(True)
138
+ self.rb_camp_build = QRadioButton("Build only — compile every member to the campaign's dist/")
139
+ self.cg.addButton(self.rb_camp_deploy)
140
+ self.cg.addButton(self.rb_camp_build)
141
+ self.wire_newgame = QCheckBox("Wire New Game entry (experimental — off = reach the chain via F6 → Warp)")
142
+ cv.addWidget(self.rb_camp_deploy)
143
+ cv.addWidget(self.rb_camp_build)
144
+ cv.addWidget(self.wire_newgame)
145
+ self.campaign_box = box
146
+ return box
147
+
148
+ def _journey_box(self):
149
+ box = QGroupBox("Deploy journey")
150
+ jv = QVBoxLayout(box)
151
+ self.jg = QButtonGroup(self)
152
+ self.rb_jour_preview = QRadioButton("Preview deploy playbook (dry-run — no game files touched)")
153
+ self.rb_jour_preview.setChecked(True)
154
+ self.rb_jour_apply = QRadioButton("Deploy journey to game (one-shot: campaigns → links → hub, reversible)")
155
+ self.rb_jour_links = QRadioButton("Re-apply cross-campaign links only (after a campaign re-deploy)")
156
+ for rb in (self.rb_jour_preview, self.rb_jour_apply, self.rb_jour_links):
157
+ self.jg.addButton(rb)
158
+ rb.toggled.connect(self._update_journey_hint)
159
+ jv.addWidget(rb)
160
+ # Single mod folder: merge the whole journey into ONE FolderNames entry (one-shot deploy only)
161
+ self.cb_single_folder = QCheckBox("Single mod folder — merge the whole journey into ONE FolderNames "
162
+ "entry (instead of one folder per campaign)")
163
+ self.cb_single_folder.setToolTip("Cleaner one-folder install. Trade-off: re-deploying re-merges the "
164
+ "whole journey — you lose cheap per-campaign re-deploy.")
165
+ jv.addWidget(self.cb_single_folder)
166
+ # New-Game landing: meaningful only for the one-shot deploy (single-owner) -> disabled otherwise
167
+ self.ng_group = QGroupBox("New Game landing (one-shot deploy — single-owner)")
168
+ ngv = QVBoxLayout(self.ng_group)
169
+ self.ngg = QButtonGroup(self)
170
+ self.rb_ng_none = QRadioButton("Don't wire New Game — reach the hub via F6 → Warp")
171
+ self.rb_ng_none.setChecked(True)
172
+ self.rb_ng_hub = QRadioButton("Wire New Game → the hub menu (pick the journey at Mognet; seamless)")
173
+ self.rb_ng_entry = QRadioButton("Wire New Game → straight into the opening (no menu; keeps the real FMV)")
174
+ self.rb_ng_entry.setToolTip("Single-journey arc only — a multi-journey hub has no single opening to land in.")
175
+ for rb in (self.rb_ng_none, self.rb_ng_hub, self.rb_ng_entry):
176
+ self.ngg.addButton(rb)
177
+ ngv.addWidget(rb)
178
+ self.ng_group.setEnabled(False)
179
+ jv.addWidget(self.ng_group)
180
+ self.journey_hint = QLabel("")
181
+ self.journey_hint.setWordWrap(True)
182
+ self.journey_hint.setStyleSheet(f"color:{self.pal['muted']};")
183
+ jv.addWidget(self.journey_hint)
184
+ self.journey_box = box
185
+ return box
186
+
187
+ def _newgame_box(self):
188
+ # always-visible: point New Game straight at a deployed field id (the hub-less single destination).
189
+ box = QGroupBox("New Game entry (skip the hub — land straight on a field)")
190
+ gv = QVBoxLayout(box)
191
+ row = QHBoxLayout()
192
+ row.addWidget(QLabel("Field id:"))
193
+ self.newgame_id = QLineEdit()
194
+ self.newgame_id.setFixedWidth(90)
195
+ self.newgame_id.setPlaceholderText("4100")
196
+ self.set_ng = QPushButton("Point New Game here")
197
+ self.set_ng.clicked.connect(self.on_set_newgame)
198
+ self.rev_ng = QPushButton("Revert New Game")
199
+ self.rev_ng.clicked.connect(self.on_revert_newgame)
200
+ row.addWidget(self.newgame_id)
201
+ row.addWidget(self.set_ng)
202
+ row.addWidget(self.rev_ng)
203
+ row.addStretch(1)
204
+ gv.addLayout(row)
205
+ hint = QLabel("Single-owner: CREATES the field-70 override from stock (opening FMV preserved) and "
206
+ "replaces the current New-Game landing (skips any World Hub) — works even on a clean "
207
+ "install or a fresh region fork. The field must already be DEPLOYED/registered. Relaunch "
208
+ "to test.")
209
+ hint.setWordWrap(True)
210
+ hint.setStyleSheet(f"color:{self.pal['muted']};")
211
+ gv.addWidget(hint)
212
+ self.newgame_box = box
213
+ return box
214
+
215
+ def _battle_box(self):
216
+ box = QGroupBox("Deploy battle map")
217
+ bv = QVBoxLayout(box)
218
+ self.battle_dest = QLabel(f"This worktree's mod folder: {self.mod_folder}")
219
+ self.battle_dest.setStyleSheet(f"color:{self.pal['muted']};")
220
+ bv.addWidget(self.battle_dest)
221
+ tf = QHBoxLayout()
222
+ tf.addWidget(QLabel("Trigger field (optional):"))
223
+ self.trigger = QLineEdit()
224
+ self.trigger.setFixedWidth(90)
225
+ tf.addWidget(self.trigger)
226
+ self.trigger_hint = QLabel("repoint a deployed field's encounter at the minted scene (mint only).")
227
+ self.trigger_hint.setWordWrap(True)
228
+ self.trigger_hint.setStyleSheet(f"color:{self.pal['muted']};")
229
+ tf.addWidget(self.trigger_hint, 1)
230
+ bv.addLayout(tf)
231
+ self.battle_box = box
232
+ return box
233
+
234
+ # ------------------------------------------------------------------ kind detection + rendering
235
+ def crumb_label(self):
236
+ """A short 'you are deploying X' label for the breadcrumb when the Build & Deploy tab is active --
237
+ the detected kind + the target file name (or a no-target hint)."""
238
+ p = self.path.text().strip().strip('"')
239
+ return f"{self.kind} · {Path(p).name}" if p else "no build target"
240
+
241
+ def set_target(self, path):
242
+ """Point the doc at a project file (the shell calls this when a campaign/field opens, so Build &
243
+ Deploy is pre-aimed at what you're working on)."""
244
+ self.path.setText(str(path))
245
+
246
+ def _on_path(self, _text=None):
247
+ path = self.path.text().strip().strip('"')
248
+ kind, payload = ("field", None)
249
+ if path and Path(path).is_file():
250
+ kind, payload = jobs.detect_kind(path)
251
+ self.kind = kind
252
+ self.plan = payload if kind == "campaign" else None
253
+ self.manifest = payload if kind == "journey" else None
254
+ if kind == "field":
255
+ self.field_id, self.field_name = jobs.field_id_name(path) if path else (None, None)
256
+ if self.field_id is not None and not self.newgame_id.text().strip():
257
+ self.newgame_id.setText(str(self.field_id)) # convenience: prefill the New-Game target once
258
+ self._render_kind()
259
+
260
+ def _render_kind(self):
261
+ self.field_box.setVisible(self.kind == "field")
262
+ self.campaign_box.setVisible(self.kind == "campaign")
263
+ self.journey_box.setVisible(self.kind == "journey")
264
+ self.battle_box.setVisible(self.kind == "battle")
265
+ if self.kind == "campaign" and self.plan is not None:
266
+ ids = [m.new_id for m in self.plan.members]
267
+ rng = f"{min(ids)}-{max(ids)}" if ids else "?"
268
+ self.status.setText(f"Campaign '{self.plan.name}': {len(self.plan.members)} fields "
269
+ f"(ids {rng}) → {self.plan.mod_folder}")
270
+ self.go.setText("Build / Deploy campaign")
271
+ self.rev.setText("Revert campaign")
272
+ self.rb_camp_deploy.setText(f"Deploy to game (reversible) → {self.plan.mod_folder}")
273
+ elif self.kind == "journey" and self.manifest is not None:
274
+ m = self.manifest
275
+ hub_id = m.hub.get("id") if m.hub else None
276
+ name = (m.hub.get("name") if m.hub else None) or Path(self.path.text().strip()).stem
277
+ self.status.setText(f"Journey '{name}': {len(m.journeys)} journey(s), hub field {hub_id} "
278
+ "→ each campaign stacks into its own mod folder.")
279
+ self.go.setText("Build / Deploy journey")
280
+ self.rev.setText("Revert journey")
281
+ self._update_journey_hint()
282
+ elif self.kind == "battle":
283
+ deployed = jobs.detect_deployed_fields(self.mod_folder)
284
+ avail = ("deployed: " + ", ".join(f"{i} ({n})" for i, n in deployed) + " — ") if deployed \
285
+ else "no fields deployed here yet — "
286
+ self.trigger_hint.setText(avail + "repoint a deployed field's encounter at the minted scene "
287
+ "so you can fight it now (mint only; blank otherwise).")
288
+ self.status.setText(f"Battle map: {Path(self.path.text().strip()).name} → {self.mod_folder}")
289
+ self.go.setText("Build / Deploy battle")
290
+ self.rev.setText("Revert battle")
291
+ else:
292
+ self.go.setText("Build / Deploy")
293
+ self.rev.setText("Revert test deploy")
294
+ p = self.path.text().strip()
295
+ if p and self.field_id is not None:
296
+ self.status.setText(f"Field: {self.field_name or Path(p).stem} (its own id: {self.field_id})"
297
+ f" — {Path(p).name}")
298
+ elif p:
299
+ self.status.setText(f"Field project: {Path(p).name}")
300
+ else:
301
+ self.status.setText("Pick a field, campaign, journey, or battle file.")
302
+ self._update_dest()
303
+
304
+ def _update_dest(self, *_):
305
+ if self.kind != "field":
306
+ return
307
+ tid = self.worktree_id or 4003
308
+ own = self.field_id if self.field_id is not None else "?"
309
+ if self.rb_test.isChecked():
310
+ msg = (f"→ deploys to field {tid} in {self.mod_folder} (this worktree's test slot; reversible). "
311
+ f"Your field's own id ({own}) is overridden — reach it via F6 → Warp to {tid}.")
312
+ elif self.rb_game.isChecked():
313
+ where = self.game_mod or "(game install not found)"
314
+ msg = f"→ installs at field {own} (the field's OWN id) in {where} — overwrites any field {own} there."
315
+ else:
316
+ folder = self.other.text().strip() or "(pick a folder)"
317
+ msg = f"→ builds field {own} into {folder} — no game change."
318
+ self.dest.setText(msg)
319
+
320
+ def _journey_newgame_mode(self) -> str:
321
+ """The selected New-Game landing for the one-shot deploy: ``"hub"`` / ``"entry"`` / ``"none"``."""
322
+ if self.rb_ng_hub.isChecked():
323
+ return "hub"
324
+ if self.rb_ng_entry.isChecked():
325
+ return "entry"
326
+ return "none"
327
+
328
+ def _update_journey_hint(self, *_):
329
+ if self.kind != "journey":
330
+ return
331
+ apply_on = self.rb_jour_apply.isChecked()
332
+ if self.rb_jour_preview.isChecked():
333
+ msg = ("→ lints the manifest + prints the ordered deploy playbook. No game files are touched — "
334
+ "safe to run anytime; review the steps, then switch to 'Deploy journey to game'.")
335
+ elif self.rb_jour_links.isChecked():
336
+ msg = ("→ re-applies ONLY the cross-campaign link .eb remaps (run after a campaign re-deploy "
337
+ "wholesale-replaces its folder and wipes the links). The campaigns must already be deployed.")
338
+ elif self.cb_single_folder.isChecked():
339
+ msg = ("→ one-shot, SINGLE FOLDER: build every campaign + the hub, MERGE them into one stacked mod "
340
+ "folder (one Memoria.ini entry), apply the cross-campaign links, optional New Game — one "
341
+ "unified revert. Cleaner install; re-deploying re-merges the whole journey.")
342
+ else:
343
+ msg = ("→ one-shot: each campaign → its own stacked folder, the cross-campaign links, then the hub "
344
+ "field — one unified revert. You then stack the folders in Memoria.ini and relaunch once.")
345
+ self.cb_single_folder.setEnabled(apply_on)
346
+ self.ng_group.setEnabled(apply_on)
347
+ # "straight into the opening" needs a single-journey manifest (a multi-journey hub has no single opening)
348
+ single = self.manifest is not None and len(self.manifest.journeys) == 1
349
+ self.rb_ng_entry.setEnabled(apply_on and single)
350
+ if not single and self.rb_ng_entry.isChecked():
351
+ self.rb_ng_none.setChecked(True)
352
+ self.journey_hint.setText(msg)
353
+
354
+ # ------------------------------------------------------------------ pickers
355
+ def browse(self):
356
+ f, _ = QFileDialog.getOpenFileName(
357
+ self, "Pick a field.toml, campaign.toml, journeys.toml, or battle.toml", self.path.text().strip(),
358
+ "Field / campaign / journey / battle (*.toml);;All files (*)")
359
+ if f:
360
+ self.path.setText(f)
361
+
362
+ def browse_other(self):
363
+ d = QFileDialog.getExistingDirectory(self, "Output folder")
364
+ if d:
365
+ self.other.setText(d)
366
+ self.rb_other.setChecked(True)
367
+
368
+ # ------------------------------------------------------------------ helpers
369
+ def _confirm(self, title, text):
370
+ return QMessageBox.question(self, title, text) == QMessageBox.StandardButton.Yes
371
+
372
+ def _warn(self, title, text):
373
+ QMessageBox.warning(self, title, text)
374
+
375
+ def _info(self, title, text):
376
+ QMessageBox.information(self, title, text)
377
+
378
+ def _picked(self):
379
+ f = self.path.text().strip().strip('"')
380
+ if not f or not Path(f).is_file():
381
+ self._warn("No file", "Pick a .field.toml, campaign.toml, journeys.toml, or battle.toml first.")
382
+ return None
383
+ return f
384
+
385
+ def _busy(self, b):
386
+ for w in (self.chk, self.go, self.rev, self.set_ng, self.rev_ng):
387
+ w.setEnabled(not b)
388
+
389
+ def _stream(self, argv, *, cwd, subject, ok_headline, ok_next=""):
390
+ self._busy(True)
391
+ if not self._run(argv, cwd=cwd, subject=subject, ok_headline=ok_headline, ok_next=ok_next,
392
+ on_finished=lambda _c: self._busy(False)):
393
+ self._busy(False) # a job was already running; nothing started
394
+
395
+ # ------------------------------------------------------------------ Check (in-process, structured)
396
+ def on_check(self):
397
+ f = self._picked()
398
+ if not f:
399
+ return
400
+ if self.kind == "campaign":
401
+ self._check_campaign(f)
402
+ elif self.kind == "journey":
403
+ self._check_journey(f)
404
+ elif self.kind == "battle":
405
+ self._check_battle(f)
406
+ else:
407
+ self._check_field(f)
408
+
409
+ def _verdict(self, errs, warns, *, subject, clean):
410
+ self._problems(fb.classify(errs, warns, subject=subject, clean_headline=clean),
411
+ fb.problems(errs, warns))
412
+
413
+ def _check_field(self, field):
414
+ try:
415
+ from ..build import FieldProject, lint_logic, validate
416
+ p = FieldProject.load(field)
417
+ self._verdict(validate(p), lint_logic(p), subject=f"Check {Path(field).name}",
418
+ clean=f"{Path(field).name} — no problems")
419
+ except Exception as e: # noqa: BLE001
420
+ self._verdict([f"{type(e).__name__}: {e}"], [], subject="Check", clean="")
421
+
422
+ def _check_campaign(self, path):
423
+ try:
424
+ from ..campaign import lint_campaign, load_campaign
425
+ plan = load_campaign(path)
426
+ errs, warns = lint_campaign(plan, Path(path).parent)
427
+ self._verdict(errs, warns, subject=f"Campaign lint ({plan.name})", clean=f"{plan.name} — no problems")
428
+ except Exception as e: # noqa: BLE001
429
+ self._verdict([f"{type(e).__name__}: {e}"], [], subject="Campaign lint", clean="")
430
+
431
+ def _check_journey(self, path):
432
+ try:
433
+ from ..journey import lint_manifest, load_journeys
434
+ m = load_journeys(path) # re-load from disk (the file may have changed)
435
+ errs, warns = lint_manifest(m)
436
+ name = (m.hub.get("name") if m.hub else None) or Path(path).stem
437
+ self._verdict(errs, warns, subject=f"Journey lint ({name})", clean=f"{name} — no problems")
438
+ except Exception as e: # noqa: BLE001
439
+ self._verdict([f"{type(e).__name__}: {e}"], [], subject="Journey lint", clean="")
440
+
441
+ def _check_battle(self, battle):
442
+ try:
443
+ from ..battle.build import BattleProject, validate_battle
444
+ p = BattleProject.load(battle)
445
+ self._verdict(validate_battle(p), [], subject=f"Check {Path(battle).name}",
446
+ clean=f"{Path(battle).name} — no problems")
447
+ except Exception as e: # noqa: BLE001
448
+ self._verdict([f"{type(e).__name__}: {e}"], [], subject="Battle check", clean="")
449
+
450
+ # ------------------------------------------------------------------ Build / Deploy
451
+ def on_go(self):
452
+ f = self._picked()
453
+ if not f:
454
+ return
455
+ if self.kind == "campaign":
456
+ self._go_campaign(f)
457
+ elif self.kind == "journey":
458
+ self._go_journey(f)
459
+ elif self.kind == "battle":
460
+ self._go_battle(f)
461
+ else:
462
+ self._go_field(f)
463
+
464
+ def _go_field(self, field):
465
+ if self.rb_test.isChecked():
466
+ tid = self.worktree_id or 4003
467
+ reach = ("New Game → walk to the hut door (or F6 → Warp)" if tid == 4003
468
+ else f"F6 → Warp to field {tid}")
469
+ if self._confirm(f"Deploy to test field {tid}",
470
+ f"Build and deploy this field to the test slot {tid} ({self.mod_folder})? "
471
+ "It replaces whatever is there now (reversible)."):
472
+ self._stream(jobs.deploy_field_argv(self.repo, field), cwd=self.repo,
473
+ subject=f"Deploy to test field {tid}",
474
+ ok_headline=f"Deployed to test field {tid} ({self.mod_folder})",
475
+ ok_next=f"In-game: {reach}.")
476
+ elif self.rb_game.isChecked():
477
+ if self._confirm("Install to game",
478
+ f"Build this field into the game mod folder?\n\n{self.game_mod}\n\n"
479
+ "Writes the field at its real id (may overwrite a field with the same id)."):
480
+ self._stream(jobs.build_argv(field, str(self.game_mod)), cwd=self.kit,
481
+ subject="Install to game", ok_headline=f"Built into {self.game_mod}")
482
+ else:
483
+ out = self.other.text().strip()
484
+ if not out:
485
+ return self._warn("No folder", "Pick an output folder.")
486
+ self._stream(jobs.build_argv(field, out), cwd=self.kit, subject="Build",
487
+ ok_headline=f"Built into {out}")
488
+
489
+ def _go_campaign(self, path):
490
+ if self.rb_camp_build.isChecked():
491
+ self._stream(jobs.build_campaign_argv(path), cwd=self.kit, subject="Build campaign",
492
+ ok_headline=f"Built campaign {self.plan.name}")
493
+ return
494
+ wire = self.wire_newgame.isChecked()
495
+ route = ("It also wires New Game to enter the chain (experimental)." if wire
496
+ else "Reach each screen in-game via F6 → Warp.")
497
+ if self._confirm("Deploy campaign",
498
+ f"Reversibly install campaign '{self.plan.name}' ({len(self.plan.members)} fields) "
499
+ f"into:\n\n{self.plan.mod_folder}\n\n{route}"):
500
+ ids = [m.new_id for m in self.plan.members]
501
+ entry = self.plan.members[0].new_id if self.plan.members else (min(ids) if ids else "?")
502
+ self._stream(jobs.deploy_campaign_argv(self.repo, path, wire_newgame=wire), cwd=self.repo,
503
+ subject="Deploy campaign",
504
+ ok_headline=f"Deployed campaign '{self.plan.name}' → {self.plan.mod_folder}",
505
+ ok_next=f"Relaunch once (new DictionaryPatch), then F6 → Warp → {entry} to walk the chain.")
506
+
507
+ def _go_journey(self, path):
508
+ if self.rb_jour_preview.isChecked(): # dry-run: print the playbook, no game writes -> no confirm
509
+ self._stream(jobs.deploy_journey_argv(self.repo, path), cwd=self.repo,
510
+ subject="Journey deploy playbook (dry-run)",
511
+ ok_headline="Printed the journey deploy playbook (no game files touched)",
512
+ ok_next="Review the ordered steps above, then choose 'Deploy journey to game' to run them.")
513
+ return
514
+ if self.rb_jour_links.isChecked():
515
+ if self._confirm("Re-apply cross-campaign links",
516
+ "Re-apply ONLY the cross-campaign link .eb rewrites?\n\nRun this after re-deploying "
517
+ "a campaign — deploy_campaign wholesale-replaces its folder, wiping the boundary "
518
+ "links. The campaigns must already be deployed."):
519
+ self._stream(jobs.deploy_journey_argv(self.repo, path, apply_links=True), cwd=self.repo,
520
+ subject="Re-apply journey links",
521
+ ok_headline="Re-applied the cross-campaign links",
522
+ ok_next="Relaunch and playtest the campaign boundary.")
523
+ return
524
+ mode = self._journey_newgame_mode()
525
+ single = self.cb_single_folder.isChecked()
526
+ name = (self.manifest.hub.get("name") if self.manifest and self.manifest.hub else None) or Path(path).stem
527
+ njourneys = len(self.manifest.journeys) if self.manifest else "?"
528
+ route = {"hub": "New Game will land on the hub MENU (single-owner — replaces the current New-Game target).",
529
+ "entry": "New Game will land STRAIGHT in the opening field, no menu (single-owner — replaces the "
530
+ "current target; keeps the real opening FMV).",
531
+ "none": "New Game is left UNCHANGED — reach the hub via F6 → Warp."}[mode]
532
+ layout = ("MERGED into ONE stacked mod folder (a single FolderNames entry)" if single
533
+ else "every campaign into its own stacked mod folder")
534
+ folders_note = ("Reversible via one unified revert. You then add the ONE merged folder to Memoria.ini "
535
+ "(remove the journey's old per-campaign folders) and relaunch once." if single else
536
+ "Reversible via one unified revert. You must then STACK the folders in Memoria.ini and "
537
+ "relaunch once.")
538
+ if self._confirm("Deploy journey",
539
+ f"Deploy journey '{name}' ({njourneys} journey(s)) in one shot — {layout}, the "
540
+ f"cross-campaign links, then the hub field?\n\n{route}\n\n{folders_note}"):
541
+ reach = {"hub": "New Game → the hub menu", "entry": "New Game → straight into the opening",
542
+ "none": "F6 → Warp to the hub"}[mode]
543
+ stackmsg = (f"Add the ONE merged folder to Memoria.ini [Mod] FolderNames (drop the old per-campaign "
544
+ f"ones), relaunch once, then {reach}. Playtest." if single else
545
+ f"Stack every campaign + hub folder in Memoria.ini [Mod] FolderNames, relaunch once, "
546
+ f"then {reach}. Playtest.")
547
+ self._stream(jobs.deploy_journey_argv(self.repo, path, apply=True, newgame=mode, single_folder=single),
548
+ cwd=self.repo, subject="Deploy journey",
549
+ ok_headline=f"Deployed journey '{name}'" + (" (single folder)" if single else ""),
550
+ ok_next=stackmsg)
551
+
552
+ def _go_battle(self, battle):
553
+ trig = self.trigger.text().strip()
554
+ if trig and not trig.isdigit():
555
+ return self._warn("Bad trigger field", "Trigger field must be a field id number (or blank).")
556
+ tmsg = (f"\n\nAlso repoint field {trig}'s encounter at the minted scene." if trig else "")
557
+ if self._confirm("Deploy battle map",
558
+ f"Build and deploy this battle map into:\n\n{self.mod_folder}\n\n"
559
+ "Replaces any prior deploy of the same map (reversible). A minted scene or a "
560
+ "BattlePatch line needs one relaunch." + tmsg):
561
+ self._stream(jobs.deploy_battle_argv(self.repo, battle, trigger=trig or None), cwd=self.repo,
562
+ subject="Deploy battle map",
563
+ ok_headline=f"Deployed battle map → {self.mod_folder}",
564
+ ok_next="A minted scene / BattlePatch line needs one relaunch; a texture/FBX override "
565
+ "loads on the next battle.")
566
+
567
+ # ------------------------------------------------------------------ New Game entry (hub-less)
568
+ def on_set_newgame(self):
569
+ fid = self.newgame_id.text().strip()
570
+ if not fid.isdigit():
571
+ return self._warn("Bad field id", "Enter the numeric field id New Game should land on "
572
+ "(e.g. a deployed slice's entry, 4100).")
573
+ if self._confirm("Point New Game here",
574
+ f"Point New Game straight at field {fid}?\n\nThis CREATES the field-70 override from "
575
+ "stock (the opening FMV is preserved) and REPLACES the current New-Game landing "
576
+ "(single-owner), skipping any World Hub. Works even on a clean install / a fresh fork. "
577
+ "The field must already be deployed/registered; relaunch the game to test."):
578
+ self._stream(jobs.newgame_from_stock_argv(self.repo, fid), cwd=self.repo, subject="Set New Game entry",
579
+ ok_headline=f"New Game now lands on field {fid}",
580
+ ok_next="Relaunch the game, then New Game. Undo with 'Revert New Game'.")
581
+
582
+ def on_revert_newgame(self):
583
+ argv = jobs.revert_newgame_argv(self.repo) # most-recent New-Game revert (from-stock OR retarget)
584
+ if argv is None or not Path(argv[-1]).exists():
585
+ return self._info("Nothing to revert", "No New-Game change to undo yet.")
586
+ if self._confirm("Revert New Game", "Restore the previous New-Game landing?"):
587
+ self._stream(argv, cwd=self.repo, subject="Revert New Game",
588
+ ok_headline="Reverted the New-Game retarget",
589
+ ok_next="Relaunch to load the restored New-Game landing.")
590
+
591
+ # ------------------------------------------------------------------ Revert
592
+ def on_revert(self):
593
+ if self.kind == "battle":
594
+ argv, what = jobs.revert_battle_argv(self.repo), "battle"
595
+ elif self.kind == "campaign":
596
+ argv, what = jobs.revert_campaign_argv(self.repo), "campaign"
597
+ elif self.kind == "journey":
598
+ argv = jobs.revert_journey_argv(self.repo) # the MOST RECENT journey revert (full or links-only)
599
+ what = ("journey links" if argv and Path(argv[-1]).name == "revert_journey_links.py" else "journey")
600
+ else:
601
+ argv, what = jobs.revert_field_argv(self.repo), "test field"
602
+ if argv is None or not Path(argv[-1]).exists():
603
+ return self._info("Nothing to revert", f"No {what} deploy to undo yet.")
604
+ if self._confirm(f"Revert {what}", f"Restore the game to before the last {what} deploy?"):
605
+ self._stream(argv, cwd=self.repo, subject=f"Revert {what}",
606
+ ok_headline=f"Reverted the last {what} deploy",
607
+ ok_next="Relaunch the game to load the restored state.")