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
ff9mapkit/journey.py ADDED
@@ -0,0 +1,1902 @@
1
+ """The multi-campaign journey ASSEMBLER (overworld's lane) -- one level above ``campaign.py``.
2
+
3
+ A **journey** is a complete playable arc = **one or more chained campaigns**, picked at the World Hub
4
+ (memory ``project-ff9-world-hub``; design ``docs/JOURNEYS.md``). A ``campaign.toml`` (``import-chain``) is a
5
+ connected slice of fields; a journey sits one level up: it names an ordered set of campaigns, says where the
6
+ player STARTS, seeds the starting story state, and defines how each campaign HANDS OFF to the next. The
7
+ World Hub is a journey selector -- this module turns a ``journeys.toml`` registry into (a) the namespace
8
+ guarantee that makes the whole thing buildable + (b) the hub field that selects + warps into each journey.
9
+
10
+ **Why this is the hard part (docs/JOURNEYS.md ``§8``):** EventDB / SceneData / the ``gEventGlobal`` flag
11
+ heap are GLOBAL -- distinct ids + non-overlapping flag windows are required even across mod folders. A single
12
+ campaign's ``assign_ids`` keeps its own members disjoint; only the *journey* layer can guarantee disjointness
13
+ ACROSS every campaign of every journey that ships together (the hub offers them all, so they're all
14
+ registered at launch). That cross-campaign guarantee is this module's whole job.
15
+
16
+ The schema unifies overworld's proven single-field hub journeys with the editor_gui handoff's multi-campaign
17
+ shape -- ONE ``journeys.toml`` whose ``[[journey]]`` rows are EITHER::
18
+
19
+ [[journey]] # BARE single-field journey (overworld's proven floor: Dali, Treno)
20
+ id = "treno"
21
+ name = "Treno, City of Nobles"
22
+ entry = 4501 # a real/forked field id the hub warps straight into
23
+ set_scenario = 7550 # optional hub-side beat seed
24
+
25
+ [[journey]] # MULTI-CAMPAIGN journey (the assembler's job)
26
+ id = "escape_ice"
27
+ name = "Escape to the Ice Cavern"
28
+ campaigns = ["evil_forest", "ice_cavern"] # ORDERED folder names (each holds a campaign.toml)
29
+ entry = { campaign = "evil_forest", field = "EVF_START" } # member NAME (preferred) or raw id
30
+ [journey.seed] # == the story_flags New-Game capstone (NOT a parallel mechanism)
31
+ scenario = 0
32
+ party = ["Zidane", "Vivi"]
33
+ [[journey.link]] # how one campaign hands off to the next (the cross-campaign warp)
34
+ from = { campaign = "evil_forest", field = "EVF_EXIT" } # the boundary member (an out-of-chain seam)
35
+ to = { campaign = "ice_cavern", field = "IC_ENT" } # the next campaign's entry member
36
+
37
+ plus the shared ``[hub]`` presentation table (:mod:`ff9mapkit.hub`). ``gen-hub`` builds ONLY the bare rows
38
+ (it rejects multi-campaign ones); ``assemble-journey`` resolves BOTH (a bare row is the degenerate
39
+ zero-campaign journey -- just warp to ``entry``) and folds :func:`ff9mapkit.hub.render_hub_field_toml` in as
40
+ its hub-emit step, so one renderer serves both paths.
41
+
42
+ This session ships the OFFLINE core (model + resolution + lint + hub emit) -- the namespace guarantee, fully
43
+ unit-testable with no game install. The in-game deploy ORCHESTRATION (``deploy_journey``: build each
44
+ campaign at its band, realize each link as a live warp, seed the entry, deploy the hub, wire New Game) layers
45
+ on top of the existing ``tools/deploy_campaign.py`` + ``retarget_newgame_warp.py`` and is the next, in-game
46
+ step (Hard Constraint §2: I can't see the running game, so deploys are human-verified).
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ import re
52
+ import tomllib
53
+ from dataclasses import dataclass, field
54
+ from pathlib import Path
55
+
56
+ from . import campaign as _campaign
57
+ from . import hub as _hub
58
+ from .flags import CHOICE_SCRATCH_FLOOR, FIRST_SAFE_FLAG
59
+
60
+ SCENARIO_MAX = 32767
61
+ ID_LO, ID_HI = 4000, 32767 # custom field-id band (Int16 cap; CLAUDE.md §3)
62
+ _SLUG_RE = re.compile(r"^[A-Za-z0-9_]+$") # a journey id slug -> hub-choice key + seed namespace
63
+
64
+
65
+ class JourneyError(ValueError):
66
+ """A journeys.toml / assembler problem (caught + printed by the CLI)."""
67
+
68
+
69
+ # ---------------------------------------------------------------- the parsed model
70
+ @dataclass
71
+ class JourneyRef:
72
+ """A reference to a field. For a journey INSIDE a campaign: ``campaign`` is the folder name and ``field``
73
+ is a member NAME (preferred) or a raw global id. For a BARE single-field journey: ``campaign`` is None and
74
+ ``field`` is the real/forked field id the hub warps straight into."""
75
+ campaign: "str | None"
76
+ field: "str | int"
77
+
78
+
79
+ @dataclass
80
+ class JourneyLink:
81
+ """A cross-campaign hand-off: the boundary member ``src_field`` in ``src_campaign`` is realized as a live
82
+ warp into ``dst``. This is an explicit OVERRIDE row; ALL other cross-campaign warps auto-wire from the real
83
+ ``.eb`` seams at deploy (:func:`auto_seam_links`), so a journey needs NO link rows and the wired set is the
84
+ full connectivity GRAPH, not N-1."""
85
+ src_campaign: str
86
+ src_field: str # the boundary member name (handoff schema: from.field, alias from.seam)
87
+ dst: JourneyRef
88
+ dst_entrance: int = 0 # arrival entrance in the next campaign's entry field (to.entrance; default 0)
89
+
90
+
91
+ @dataclass
92
+ class JourneySeed:
93
+ """The journey's starting story-state -- the story_flags New-Game capstone, verbatim (NOT a parallel seed
94
+ mechanism). ``raw`` is the whole ``[journey.seed]`` table so the inventory/equipment passthrough the
95
+ capstone supports survives untouched; ``scenario`` + ``party`` are pulled out for lint + the hub seed."""
96
+ scenario: "int | None" = None
97
+ party: list = field(default_factory=list)
98
+ raw: dict = field(default_factory=dict)
99
+
100
+ @property
101
+ def is_empty(self) -> bool:
102
+ return self.scenario is None and not self.party and not self.raw
103
+
104
+
105
+ @dataclass
106
+ class Journey:
107
+ """One ``[[journey]]`` row, normalized. ``campaigns`` empty => a BARE single-field journey (``entry.field``
108
+ is a real id, no links). Otherwise a multi-campaign arc: ``entry`` lands inside the first campaign and
109
+ ``links`` chain the rest."""
110
+ id: str
111
+ name: str
112
+ campaigns: list # ordered folder names; [] => bare single-field journey
113
+ entry: JourneyRef
114
+ seed: JourneySeed
115
+ links: list # [JourneyLink]
116
+ set_scenario: "int | None" = None # bare-row hub-side beat seed (the proven single-field lever)
117
+ entrance: "int | None" = None # arrival entrance into the entry field (frames the entry camera)
118
+ tuning: dict = field(default_factory=dict) # [journey.tuning]: mod-GLOBAL player/ability CSV deltas (the same
119
+ # blocks a field.toml carries), injected into the entry member at deploy
120
+ exits: tuple = () # DECLARED intended-boundary field ids: a forked field's warp to one of
121
+ # these is the arc's edge (a deliberate exit to vanilla / a not-yet-forked
122
+ # next zone), NOT a bug -> the leak lint stays quiet about it.
123
+
124
+ @property
125
+ def is_bare(self) -> bool:
126
+ return not self.campaigns
127
+
128
+ @property
129
+ def hub_scenario(self) -> "int | None":
130
+ """The beat the hub seeds before warping in: the seed's scenario if present, else the bare-row
131
+ ``set_scenario``. (For a multi-campaign journey the seed is applied as the full capstone on the entry
132
+ field; the hub still seeds the scenario so the F6/select path lands on the right beat.)"""
133
+ return self.seed.scenario if self.seed.scenario is not None else self.set_scenario
134
+
135
+
136
+ @dataclass
137
+ class JourneyManifest:
138
+ """A whole ``journeys.toml``: the ``[hub]`` presentation table (raw dict; :mod:`ff9mapkit.hub` owns its
139
+ schema) + the parsed journeys + the manifest path (its parent is the project root the campaign folders are
140
+ relative to). ``flags`` = the top-level ``[[flag]]`` table: JOURNEY-GLOBAL named story flags (the whole-game
141
+ tier -- shared across EVERY campaign, propagated to every member by name; cross-campaign story state)."""
142
+ hub: dict
143
+ journeys: list # [Journey]
144
+ path: Path
145
+ flags: list = field(default_factory=list) # [{name, index}] journey-global named flags (cross-campaign)
146
+
147
+ @property
148
+ def root(self) -> Path:
149
+ return self.path.parent
150
+
151
+
152
+ # ---------------------------------------------------------------- the resolved plan
153
+ @dataclass
154
+ class ResolvedJourney:
155
+ """A journey resolved into the global namespace: the entry field id the hub warps into, each campaign's
156
+ member id list (for disjointness reporting) + assigned flag window, and each link's resolved src/dst global
157
+ ids. A pure derived view over the manifest + the loaded campaign plans -- the assembler's deploy step
158
+ consumes this; lint produces its findings from the same resolution."""
159
+ journey: Journey
160
+ entry_id: int
161
+ campaign_ids: dict # folder -> [member new_ids]
162
+ flag_windows: dict # folder -> (lo, hi, per_field)
163
+ flag_high: int # high-water flag index (exclusive) within this journey
164
+ links: list # [{src_campaign, src_field, src_id, dst_campaign, dst_field, dst_id}]
165
+ text_block_windows: dict = None # folder -> base custom mesID of its disjoint text-block window
166
+
167
+
168
+ # ---------------------------------------------------------------- loading (pure, tk-free)
169
+ def _ref_from(value, *, what: str) -> JourneyRef:
170
+ """Parse an ``entry`` / link endpoint value: a bare int (-> a campaign-less ref) or an inline table
171
+ ``{campaign, field}``. Raises :class:`JourneyError` on a malformed table (the structural floor)."""
172
+ if isinstance(value, dict):
173
+ if "campaign" not in value or "field" not in value:
174
+ raise JourneyError(f"{what} table needs both 'campaign' and 'field' (got {sorted(value)})")
175
+ return JourneyRef(campaign=str(value["campaign"]), field=value["field"])
176
+ try:
177
+ return JourneyRef(campaign=None, field=int(value))
178
+ except (TypeError, ValueError):
179
+ raise JourneyError(f"{what} must be a field id (int) or a {{campaign, field}} table (got {value!r})")
180
+
181
+
182
+ def _link_from(raw: dict, jid: str) -> JourneyLink:
183
+ """Parse one ``[[journey.link]]`` row. ``from`` names the boundary member (key ``field`` preferred, alias
184
+ ``seam`` for the handoff schema); ``to`` is the next campaign's entry ref."""
185
+ if "from" not in raw or "to" not in raw:
186
+ raise JourneyError(f"journey {jid!r}: a [[journey.link]] needs both 'from' and 'to'")
187
+ frm = raw["from"]
188
+ if not isinstance(frm, dict) or "campaign" not in frm:
189
+ raise JourneyError(f"journey {jid!r}: link 'from' must be a {{campaign, field}} table")
190
+ # the boundary member: `field` (preferred) or `seam` (handoff alias) -- both name the source member
191
+ src_field = frm.get("field", frm.get("seam"))
192
+ if src_field is None:
193
+ raise JourneyError(f"journey {jid!r}: link 'from' needs 'field' (the boundary member; alias 'seam')")
194
+ to = raw["to"]
195
+ entrance = int(to["entrance"]) if isinstance(to, dict) and "entrance" in to else 0
196
+ return JourneyLink(src_campaign=str(frm["campaign"]), src_field=str(src_field),
197
+ dst=_ref_from(to, what=f"journey {jid!r} link 'to'"), dst_entrance=entrance)
198
+
199
+
200
+ def _seed_from(raw) -> JourneySeed:
201
+ if raw is None:
202
+ return JourneySeed()
203
+ if not isinstance(raw, dict):
204
+ raise JourneyError(f"[journey.seed] must be a table (got {type(raw).__name__})")
205
+ sc = raw.get("scenario")
206
+ party = list(raw.get("party", []))
207
+ return JourneySeed(scenario=int(sc) if sc is not None else None, party=party, raw=dict(raw))
208
+
209
+
210
+ def _tuning_from(raw) -> dict:
211
+ """Normalize a ``[journey.tuning]`` table -> ``{block: [rows]}`` (a single inline table is coerced to a
212
+ 1-list, like a field.toml block). Structural only; unknown block names + bad rows are :func:`lint_manifest`'s
213
+ job (so ``load_journeys`` stays a pure parse). ``None`` -> ``{}``."""
214
+ if raw is None:
215
+ return {}
216
+ if not isinstance(raw, dict):
217
+ raise JourneyError(f"[journey.tuning] must be a table (got {type(raw).__name__})")
218
+ return {k: (v if isinstance(v, list) else [v]) for k, v in raw.items()}
219
+
220
+
221
+ def load_journeys(path) -> JourneyManifest:
222
+ """Parse a ``journeys.toml`` into a :class:`JourneyManifest`. Raises :class:`JourneyError` on a STRUCTURAL
223
+ problem (not a manifest; a journey missing ``id``/``entry``; a multi-campaign row whose ``entry`` isn't a
224
+ ``{campaign, field}`` table; a malformed link). Semantic checks (campaigns exist, id/flag disjointness,
225
+ links resolve, ranges) are :func:`lint_manifest`'s job so the CLI prints them all at once. Pure + tk-free
226
+ (mirrors :func:`ff9mapkit.campaign.load_campaign`) -- unit-testable with no game install."""
227
+ p = Path(path)
228
+ with open(p, "rb") as fh:
229
+ data = tomllib.load(fh)
230
+ if "hub" not in data and "journey" not in data:
231
+ raise JourneyError(f"{p}: not a journeys manifest (no [hub] table and no [[journey]] rows)")
232
+
233
+ journeys = []
234
+ for i, j in enumerate(data.get("journey", [])):
235
+ if "id" not in j:
236
+ raise JourneyError(f"[[journey]] #{i}: missing required key 'id' (the stable slug)")
237
+ jid = str(j["id"])
238
+ if "entry" not in j:
239
+ raise JourneyError(f"journey {jid!r}: missing required key 'entry' (the New-Game landing field)")
240
+ campaigns = [str(c) for c in j.get("campaigns", [])]
241
+ entry = _ref_from(j["entry"], what=f"journey {jid!r} entry")
242
+ # consistency: a multi-campaign journey's entry must name a campaign; a bare row's must not.
243
+ if campaigns and entry.campaign is None:
244
+ raise JourneyError(f"journey {jid!r}: has campaigns but entry is a bare field id -- a "
245
+ f"multi-campaign entry must be {{campaign = \"<folder>\", field = \"<member>\"}}")
246
+ if not campaigns and entry.campaign is not None:
247
+ raise JourneyError(f"journey {jid!r}: entry names campaign {entry.campaign!r} but the journey "
248
+ f"lists no 'campaigns' -- add it to campaigns, or use a bare entry = <id>")
249
+ links = [_link_from(lk, jid) for lk in j.get("link", [])]
250
+ if not campaigns and links:
251
+ raise JourneyError(f"journey {jid!r}: a bare single-field journey can't have [[journey.link]]s "
252
+ f"(links chain campaigns; this journey has none)")
253
+ sc = j.get("set_scenario")
254
+ ent = j.get("entrance")
255
+ try:
256
+ exits = tuple(int(x) for x in (j.get("exits") or []))
257
+ except (TypeError, ValueError):
258
+ raise JourneyError(f"journey {jid!r}: 'exits' must be a list of field ids (ints)")
259
+ journeys.append(Journey(
260
+ id=jid, name=str(j.get("name") or _hub._humanize(jid)), campaigns=campaigns, entry=entry,
261
+ seed=_seed_from(j.get("seed")), links=links,
262
+ set_scenario=int(sc) if sc is not None else None,
263
+ entrance=int(ent) if ent is not None else None,
264
+ tuning=_tuning_from(j.get("tuning")), exits=exits))
265
+
266
+ flags = [{"name": str(f.get("name", "")), "index": f.get("index")}
267
+ for f in (data.get("flag", []) or []) if isinstance(f, dict)]
268
+ return JourneyManifest(hub=dict(data.get("hub", {})), journeys=journeys, path=p, flags=flags)
269
+
270
+
271
+ # ---------------------------------------------------------------- resolution
272
+ def _campaign_path(root, folder) -> Path:
273
+ return Path(root) / folder / "campaign.toml"
274
+
275
+
276
+ def load_campaign_plans(manifest: JourneyManifest) -> dict:
277
+ """Load every campaign folder referenced by the manifest exactly once: ``folder -> (CampaignPlan, dir)``.
278
+ Raises :class:`JourneyError` if a referenced folder has no readable ``campaign.toml`` (the prerequisite
279
+ docs/JOURNEYS.md §7 -- you must fork the campaigns first)."""
280
+ plans: dict = {}
281
+ for j in manifest.journeys:
282
+ for folder in j.campaigns:
283
+ if folder in plans:
284
+ continue
285
+ cpath = _campaign_path(manifest.root, folder)
286
+ if not cpath.is_file():
287
+ raise JourneyError(f"campaign folder {folder!r}: no campaign.toml at {cpath} -- fork it first "
288
+ f"(`ff9mapkit import-chain <seed> --out {folder}`; docs/JOURNEYS.md §7)")
289
+ try:
290
+ plans[folder] = (_campaign.load_campaign(cpath), cpath.parent)
291
+ except (_campaign.CampaignError, tomllib.TOMLDecodeError, OSError) as e:
292
+ raise JourneyError(f"campaign folder {folder!r}: {e}")
293
+ return plans
294
+
295
+
296
+ # The scaffold placeholders a freshly-built / just-reconciled journey leaves for the human to fill: an entry
297
+ # (ENTRY_MEMBER) or a link boundary the reconcile couldn't auto-detect (BOUNDARY_MEMBER source / ARRIVAL_MEMBER
298
+ # target). They are NOT errors in the data -- they are "fill me" markers, so resolve SKIPS them (the rest of
299
+ # the journey still resolves) and lint reports them as actionable "not filled yet", not "bad member name".
300
+ UNFILLED_PLACEHOLDERS = frozenset({"ENTRY_MEMBER", "BOUNDARY_MEMBER", "ARRIVAL_MEMBER"})
301
+
302
+
303
+ def _is_unfilled(fieldref) -> bool:
304
+ return isinstance(fieldref, str) and fieldref in UNFILLED_PLACEHOLDERS
305
+
306
+
307
+ def _member_id(plan: "_campaign.CampaignPlan", fieldref, *, what: str) -> int:
308
+ """Resolve a member NAME (preferred) or a raw id against a campaign's members -> the global field id.
309
+ A name must match a member; a raw int passes through (lint flags a raw id that isn't a member)."""
310
+ by_name = {m.name: m for m in plan.members}
311
+ if isinstance(fieldref, str) and fieldref in by_name:
312
+ return by_name[fieldref].new_id
313
+ if _is_unfilled(fieldref):
314
+ raise JourneyError(f"{what}: still the {fieldref!r} placeholder -- fill it with a real member name "
315
+ f"('Fill entry from forks' couldn't auto-detect it; pick the member by hand)")
316
+ try:
317
+ return int(fieldref)
318
+ except (TypeError, ValueError):
319
+ raise JourneyError(f"{what}: {fieldref!r} is neither a member name nor a field id")
320
+
321
+
322
+ def _flag_windows(journey: Journey, plans: dict) -> "tuple[dict, int]":
323
+ """Lay each campaign of a journey end-to-end in the safe flag band: campaign k gets
324
+ ``len(members) * flags_per_field`` bits starting where k-1 ended, from :data:`FIRST_SAFE_FLAG`. Campaigns
325
+ in ONE journey run together (you can be mid-arc across a boundary), so their windows must not overlap.
326
+ Returns ``({folder: (lo, hi, per_field)}, high_water_exclusive)``. (Different journeys are mutually
327
+ exclusive -- one New Game = one journey -- so their windows MAY reuse the same band; lint only requires a
328
+ journey's own total to fit below the choice scratch.)"""
329
+ windows: dict = {}
330
+ cur = FIRST_SAFE_FLAG
331
+ for folder in journey.campaigns:
332
+ plan, _ = plans[folder]
333
+ span = max(1, len(plan.members)) * plan.flags_per_field
334
+ windows[folder] = (cur, cur + span - 1, plan.flags_per_field)
335
+ cur += span
336
+ return windows, cur
337
+
338
+
339
+ # A journey's campaigns stack together, so their dialogue text blocks (donor mesIDs) must not collide -- the
340
+ # engine serves ONE folder's field/<block>.mes per mesID. Give each campaign a DISJOINT window of CUSTOM mesIDs
341
+ # laid end-to-end from this base (well above real FF9 mesIDs + the kit default 1073); each block is registered
342
+ # via a DictionaryPatch MessageFile line (build_mod). build_campaign(text_block_base=) does the per-campaign remap.
343
+ TEXT_BLOCK_BASE = 20000
344
+
345
+ def _text_block_windows(journey: Journey, plans: dict) -> dict:
346
+ """``{folder: base_mesID}`` -- each campaign's disjoint custom text-block window base, laid end-to-end from
347
+ :data:`TEXT_BLOCK_BASE`. A campaign's span is its member count (at most one distinct block per member), so
348
+ windows never overlap. Mirrors :func:`_flag_windows`; the cross-campaign text-shadow cure."""
349
+ windows: dict = {}
350
+ cur = TEXT_BLOCK_BASE
351
+ for folder in journey.campaigns:
352
+ plan, _ = plans[folder]
353
+ windows[folder] = cur
354
+ cur += max(1, len(plan.members))
355
+ return windows
356
+
357
+
358
+ def auto_seam_links(campaigns, plain, *, exclude_members=frozenset()) -> list:
359
+ """EVERY cross-campaign warp the forked ``.eb`` seams imply, as resolved link dicts -- so a journey needs NO
360
+ ``[[journey.link]]`` rows: the deploy retargets each so a forked region's warps stay in-fork (leak-proof),
361
+ derived from the real game connectivity, not the listed order. ``plain`` = ``{folder: CampaignPlan}``;
362
+ ``exclude_members`` = ``(campaign, member)`` pairs an explicit ``[[journey.link]]`` already controls (an
363
+ author override takes the whole member). A FIELD seam self-describes (its ``to_real`` -> the sibling that
364
+ forks it; order-INDEPENDENT); a world-map exit, which names no destination, falls back to the listed order
365
+ (its campaign -> the NEXT campaign's entry). PURE."""
366
+ conn = campaign_connectivity(campaigns, plain)
367
+ by_real = {c: {m.real_id: m for m in plain[c].members if m.real_id} for c in campaigns if c in plain}
368
+ new_id = {c: {m.name: m.new_id for m in plain[c].members} for c in campaigns if c in plain}
369
+ out, warp_seen = [], set()
370
+ for a in campaigns: # (1) FIELD seams -> ONE link per distinct Field(to_real)
371
+ rec = conn.get(a) # warp (else a member warping to N fields would leave
372
+ if not rec: # N-1 of them UN-retargeted -> leaks)
373
+ continue
374
+ for b, seams in rec["to"].items():
375
+ for frm, to_real, _k in seams:
376
+ sid = new_id[a].get(frm) # guard: an orphaned seam's `frm` may be a stringified real id
377
+ if sid is None or (a, frm) in exclude_members or (a, frm, to_real) in warp_seen:
378
+ continue # (not a member) -> skip, don't KeyError the whole deploy/lint
379
+ arr = by_real[b].get(to_real)
380
+ if arr is None:
381
+ continue # one Field(to_real) -> ONE place (shared donor: first b wins)
382
+ warp_seen.add((a, frm, to_real))
383
+ out.append({"src_campaign": a, "src_field": frm, "src_id": sid,
384
+ "dst_campaign": b, "dst_field": arr.name, "dst_id": arr.new_id, "dst_entrance": 0})
385
+ wired = {(d["src_campaign"], d["dst_campaign"]) for d in out}
386
+ for a, b in zip(campaigns, campaigns[1:]): # (2) world-map exits -> fall back to the listed order
387
+ rec = conn.get(a)
388
+ if rec and rec.get("worldmap") and (a, b) not in wired and a in plain and b in plain:
389
+ wm = rec["worldmap"][0][0]
390
+ sid = new_id[a].get(wm) # same guard for a non-member world-map seam source
391
+ if sid is None or (a, wm) in exclude_members:
392
+ continue
393
+ out.append({"src_campaign": a, "src_field": wm, "src_id": sid,
394
+ "dst_campaign": b, "dst_field": plain[b].entry_name,
395
+ "dst_id": new_id[b][plain[b].entry_name], "dst_entrance": 0})
396
+ return out
397
+
398
+
399
+ def resolve_journey(journey: Journey, plans: dict) -> ResolvedJourney:
400
+ """Resolve a journey into the global namespace using the pre-loaded campaign plans (see
401
+ :func:`load_campaign_plans`): the entry field id, per-campaign member id lists, assigned flag windows, and
402
+ the cross-campaign links. Links are the explicit ``[[journey.link]]`` OVERRIDES + every other cross-campaign
403
+ warp AUTO-DERIVED from the real ``.eb`` seams (:func:`auto_seam_links`) -- so a journey deploys leak-proof
404
+ with no link rows. PURE over the manifest + plans (no game install)."""
405
+ if journey.is_bare:
406
+ return ResolvedJourney(journey=journey, entry_id=int(journey.entry.field),
407
+ campaign_ids={}, flag_windows={}, flag_high=FIRST_SAFE_FLAG, links=[],
408
+ text_block_windows={})
409
+
410
+ entry_plan, _ = plans[journey.entry.campaign]
411
+ entry_id = _member_id(entry_plan, journey.entry.field, what=f"journey {journey.id!r} entry")
412
+ campaign_ids = {f: [m.new_id for m in plans[f][0].members] for f in journey.campaigns}
413
+ flag_windows, flag_high = _flag_windows(journey, plans)
414
+ text_block_windows = _text_block_windows(journey, plans)
415
+
416
+ links, override_members = [], set()
417
+ for lk in journey.links: # explicit links are OVERRIDES (the author takes the member)
418
+ if _is_unfilled(lk.src_field) or _is_unfilled(lk.dst.field):
419
+ continue # an un-filled FILL/BOUNDARY scaffold row -> skip (lint flags)
420
+ src_plan, _ = plans[lk.src_campaign]
421
+ dst_plan, _ = plans[lk.dst.campaign]
422
+ override_members.add((lk.src_campaign, lk.src_field))
423
+ links.append({
424
+ "src_campaign": lk.src_campaign, "src_field": lk.src_field,
425
+ "src_id": _member_id(src_plan, lk.src_field, what=f"journey {journey.id!r} link from"),
426
+ "dst_campaign": lk.dst.campaign, "dst_field": lk.dst.field,
427
+ "dst_id": _member_id(dst_plan, lk.dst.field, what=f"journey {journey.id!r} link to"),
428
+ "dst_entrance": lk.dst_entrance})
429
+ plain = {f: plans[f][0] for f in journey.campaigns if f in plans}
430
+ links.extend(auto_seam_links(journey.campaigns, plain, exclude_members=override_members))
431
+ return ResolvedJourney(journey=journey, entry_id=entry_id, campaign_ids=campaign_ids,
432
+ flag_windows=flag_windows, flag_high=flag_high, links=links,
433
+ text_block_windows=text_block_windows)
434
+
435
+
436
+ # ---------------------------------------------------------------- lint (the namespace guarantee)
437
+ def _member_has_seam(plan: "_campaign.CampaignPlan", name: str) -> bool:
438
+ """True if member ``name`` has an out-of-chain SEAM (a scripted/overworld/menu/portal exit) -- the
439
+ boundary that a link realizes as a cross-campaign warp. The graph derives seams_by from the plan."""
440
+ g = _campaign.campaign_graph(plan)
441
+ node = g.by_name.get(name)
442
+ return bool(node and node.seams)
443
+
444
+
445
+ def lint_manifest(manifest: JourneyManifest, *, deep: bool = True) -> "tuple[list, list]":
446
+ """Validate a whole ``journeys.toml`` offline. Returns ``(errors, warnings)`` -- errors abort assembly,
447
+ warnings are advisory (mirrors :func:`ff9mapkit.campaign.lint_campaign`). Covers docs/JOURNEYS.md §4.7:
448
+ campaigns exist + parse + pass campaign-lint; the GLOBAL id-disjointness guarantee (§8 -- the whole job);
449
+ per-journey flag windows fit; links resolve to real members + boundaries; entry valid; seed range-checked.
450
+ ``deep=False`` skips the per-campaign :func:`lint_campaign` recursion (structure only) for speed."""
451
+ errors, warnings = [], []
452
+
453
+ if not manifest.journeys:
454
+ # an empty SELECTOR hub ([hub] + no rows yet) is a valid in-progress scaffold -> a warning, not a hard
455
+ # error (the build/assemble path still rejects it at validate_hub). Only a hub-less manifest errors.
456
+ (warnings if manifest.hub else errors).append(
457
+ "no [[journey]] rows yet -- add a journey (GUI: 'Add journey...') before deploying")
458
+ if not manifest.hub:
459
+ warnings.append("no [hub] table -- the journey graph lints, but `assemble-journey` can't emit the "
460
+ "hub field without it (add a [hub] block: name/id/borrow_bg/camera).")
461
+
462
+ # (a) journey id slugs: valid tokens, unique
463
+ seen: set = set()
464
+ for i, j in enumerate(manifest.journeys):
465
+ if not j.id or not _SLUG_RE.match(j.id):
466
+ errors.append(f"journey #{i}: id {j.id!r} must be a token (A-Z, 0-9, _) -- the hub-choice key")
467
+ elif j.id in seen:
468
+ errors.append(f"journey id {j.id!r} is duplicated -- ids must be unique")
469
+ seen.add(j.id)
470
+
471
+ # (a2) two menu rows warping to the SAME bare entry field -> almost always a typo (a copy-pasted row).
472
+ entry_seen: dict = {}
473
+ for j in manifest.journeys:
474
+ if j.is_bare and isinstance(j.entry.field, int):
475
+ if j.entry.field in entry_seen:
476
+ warnings.append(f"journeys {entry_seen[j.entry.field]!r} and {j.id!r} both warp to field "
477
+ f"{j.entry.field} -- two menu rows to the same destination (likely a copy-paste)")
478
+ else:
479
+ entry_seen[j.entry.field] = j.id
480
+
481
+ # (b) load every referenced campaign (folder exists + parses). Bare journeys reference none.
482
+ try:
483
+ plans = load_campaign_plans(manifest)
484
+ except JourneyError as e:
485
+ errors.append(str(e))
486
+ return errors, warnings # can't resolve ids without the plans -- stop here
487
+
488
+ # (c) per-campaign lint (structure/flags/art) -- prefix each finding with its folder. Hand each campaign
489
+ # the journey-GLOBAL flag names so a member gating on a cross-campaign flag RESOLVES (lint == build).
490
+ jflags = manifest_flag_names(manifest) if manifest.flags else {}
491
+ if deep:
492
+ for folder, (plan, cdir) in plans.items():
493
+ try:
494
+ cerr, cwarn = _campaign.lint_campaign(plan, cdir, in_journey=True, extra_flag_names=jflags)
495
+ except (_campaign.CampaignError, ValueError) as e:
496
+ errors.append(f"campaign {folder!r}: {e}")
497
+ continue
498
+ errors.extend(f"campaign {folder!r}: {e}" for e in cerr)
499
+ warnings.extend(f"campaign {folder!r}: {w}" for w in cwarn)
500
+
501
+ # (d) THE GLOBAL ID-DISJOINTNESS GUARANTEE (docs/JOURNEYS.md §8): every field the assembler REGISTERS -- a
502
+ # campaign member or the hub -- must have a globally unique id (one EventDB/SceneData namespace). A bare
503
+ # entry only REFERENCES an installed field (the hub warps to it), so it must not collide with a
504
+ # registered field, but two bare journeys MAY warp to the SAME destination (e.g. New Game vs New Game+).
505
+ owner: dict = {} # global field id -> a human label of who REGISTERS it
506
+ def _claim(fid: int, label: str):
507
+ if not isinstance(fid, int):
508
+ return
509
+ if fid in owner and owner[fid] != label:
510
+ errors.append(f"field id {fid} is claimed by BOTH {owner[fid]} and {label} -- EventDB/SceneData "
511
+ f"are global; give them disjoint id bands (re-fork a campaign with a different "
512
+ f"`import-chain --id-base`, or re-point a bare journey).")
513
+ else:
514
+ owner.setdefault(fid, label)
515
+ for folder, (plan, _) in plans.items():
516
+ for m in plan.members:
517
+ _claim(m.new_id, f"campaign {folder!r} member {m.name!r}")
518
+ # the [hub] field id is ALSO registered -- it renders alongside every campaign, so a hub/member collision is
519
+ # the same global-EventDB black screen. Claim it (before the bare-entry check, so a bare-vs-hub clash shows).
520
+ if manifest.hub.get("id") is not None:
521
+ try:
522
+ _claim(int(manifest.hub["id"]), "the [hub] field")
523
+ except (TypeError, ValueError):
524
+ errors.append(f"[hub] id {manifest.hub.get('id')!r} must be a field id (int)")
525
+ bare_ids: list = []
526
+ for j in manifest.journeys:
527
+ if j.is_bare:
528
+ try:
529
+ fid = int(j.entry.field)
530
+ except (TypeError, ValueError):
531
+ errors.append(f"journey {j.id!r}: bare entry {j.entry.field!r} must be a field id (int)")
532
+ continue
533
+ bare_ids.append(fid)
534
+ if fid in owner: # a bare entry collides with a REGISTERED field (member / hub)
535
+ errors.append(f"field id {fid} is claimed by BOTH {owner[fid]} and journey {j.id!r} (bare entry) "
536
+ f"-- a campaign member / the hub registers it; re-point this journey.")
537
+ # NB: NOT claimed into `owner` -- two bare journeys may legally warp to the same installed field
538
+ # (the duplicate-destination case is the (a2) warning above, not a hard error).
539
+
540
+ # (e) id band range (every registered id + every bare entry in the custom band)
541
+ for fid, label in sorted(list(owner.items()) + [(b, f"a bare entry") for b in bare_ids]):
542
+ if not (ID_LO <= fid <= ID_HI):
543
+ errors.append(f"{label}: field id {fid} out of band -- custom ids are {ID_LO}-{ID_HI} "
544
+ f"(the live fldMapNo is Int16, so a higher id registers but is unreachable)")
545
+
546
+ # (f) per-journey resolution: entry, flag windows, links, seed
547
+ for j in manifest.journeys:
548
+ _lint_journey(j, plans, errors, warnings)
549
+
550
+ # (g) JOURNEY-GLOBAL flags (manifest [[flag]], the whole-game shared tier): each a unique name + a unique
551
+ # index in the safe band AND ABOVE every journey's campaign windows (so it can't collide with any
552
+ # member's auto once-flag). Propagated to every member by name at build (build_campaign extra_flag_names).
553
+ if manifest.flags:
554
+ def _jflag_high(j):
555
+ cur = FIRST_SAFE_FLAG
556
+ for folder in j.campaigns:
557
+ e = plans.get(folder)
558
+ plan = e[0] if isinstance(e, tuple) else e
559
+ if plan is not None:
560
+ cur += max(1, len(plan.members)) * plan.flags_per_field
561
+ return cur
562
+ max_high = max((_jflag_high(j) for j in manifest.journeys if not j.is_bare), default=FIRST_SAFE_FLAG)
563
+ seen_n, seen_i = set(), set()
564
+ for f in manifest.flags:
565
+ nm = str(f.get("name", "")).strip()
566
+ if not nm:
567
+ errors.append("journey-global [[flag]]: a flag needs a name")
568
+ continue
569
+ if nm in seen_n:
570
+ errors.append(f"journey-global flag {nm!r} is declared twice")
571
+ seen_n.add(nm)
572
+ idx = f.get("index")
573
+ if not (isinstance(idx, int) and not isinstance(idx, bool)):
574
+ errors.append(f"journey-global flag {nm!r} needs an integer index")
575
+ continue
576
+ if not (FIRST_SAFE_FLAG <= idx < CHOICE_SCRATCH_FLOOR):
577
+ errors.append(f"journey-global flag {nm!r} index {idx} is outside the safe band "
578
+ f"[{FIRST_SAFE_FLAG}, {CHOICE_SCRATCH_FLOOR})")
579
+ elif idx < max_high:
580
+ errors.append(f"journey-global flag {nm!r} index {idx} falls inside a campaign's flag window "
581
+ f"(< {max_high}) -- put journey-global flags ABOVE every campaign (>= {max_high}).")
582
+ if idx in seen_i:
583
+ errors.append(f"journey-global flag index {idx} is used by two flags")
584
+ seen_i.add(idx)
585
+
586
+ # (g2) CROSS-TIER explicit-flag disjointness: every campaign's shared [[flag]] + the journey-global [[flag]]
587
+ # are ABSOLUTE bits in the one global array, so two declaring the SAME index alias (set one -> the other
588
+ # reads set). The per-tier lints only check within-tier; this catches campaign-vs-campaign AND
589
+ # campaign-vs-journey collisions (e.g. a campaign flag and a journey flag both left at the 8512 default).
590
+ owner_of: dict = {}
591
+ def _claim_flag(idx, who):
592
+ if not (isinstance(idx, int) and not isinstance(idx, bool)):
593
+ return
594
+ if idx in owner_of and owner_of[idx] != who:
595
+ errors.append(f"shared-flag index {idx} is declared by BOTH {owner_of[idx]} and {who} -- they alias "
596
+ f"the same global gEventGlobal bit; give each shared flag a distinct index.")
597
+ else:
598
+ owner_of.setdefault(idx, who)
599
+ for folder, (plan, _) in plans.items():
600
+ for f in (getattr(plan, "flags", None) or []):
601
+ _claim_flag(f.get("index"), f"campaign {folder!r} flag {f.get('name')!r}")
602
+ for f in manifest.flags:
603
+ _claim_flag(f.get("index"), f"journey-global flag {f.get('name')!r}")
604
+
605
+ return errors, warnings
606
+
607
+
608
+ def manifest_flag_names(manifest: JourneyManifest) -> dict:
609
+ """``{normalized name: index}`` for the journey-GLOBAL ``[[flag]]`` table -- the names propagated to every
610
+ campaign member at build (:func:`ff9mapkit.campaign.build_campaign` ``extra_flag_names``) so ANY field can
611
+ gate on a cross-campaign flag. Normalized like the campaign name map; ``{}`` on a malformed table."""
612
+ from .flags import collect_flag_defs
613
+ try:
614
+ return collect_flag_defs({"flag": manifest.flags})
615
+ except ValueError:
616
+ return {}
617
+
618
+
619
+ def shared_flag_reservation(manifest: JourneyManifest) -> "tuple[int, set]":
620
+ """``(floor, reserved)`` for picking a NEW journey-global flag index in the GUI: ``floor`` = above every
621
+ journey's campaign windows (so it can't hit a member's auto once-flag), ``reserved`` = every campaign's
622
+ already-used shared-flag index (so a journey flag can't alias a campaign-shared one). Best-effort -- a
623
+ campaign that doesn't load is skipped (lint is the authoritative net)."""
624
+ from . import campaign as _campaign
625
+ floor, reserved = FIRST_SAFE_FLAG, set()
626
+ for j in manifest.journeys:
627
+ cur = FIRST_SAFE_FLAG
628
+ for folder in j.campaigns:
629
+ try:
630
+ plan = _campaign.load_campaign(manifest.root / folder / "campaign.toml")
631
+ except Exception: # noqa: BLE001 -- unreadable member -> skip (lint flags it)
632
+ continue
633
+ cur += max(1, len(plan.members)) * plan.flags_per_field
634
+ for f in (plan.flags or []):
635
+ if isinstance(f.get("index"), int) and not isinstance(f.get("index"), bool):
636
+ reserved.add(f["index"])
637
+ floor = max(floor, cur)
638
+ return floor, reserved
639
+
640
+
641
+ _FLAG_HEADER_MARK = "# Journey-global story flags" # our rendered header line; stripped on re-write (no accumulation)
642
+
643
+
644
+ def render_manifest_flags(flags) -> str:
645
+ """The journey-GLOBAL ``[[flag]]`` block(s) for ``journeys.toml`` (whole-game shared named flags). Empty
646
+ list -> ``""`` (no block)."""
647
+ if not flags:
648
+ return ""
649
+ out = [f"{_FLAG_HEADER_MARK} -- shared across EVERY campaign (cross-campaign state); the bit is global, the name "
650
+ "lets any field gate by it."]
651
+ for f in flags:
652
+ out += ["[[flag]]", f'name = "{f["name"]}"', f"index = {int(f['index'])}", ""]
653
+ return "\n".join(out).rstrip() + "\n"
654
+
655
+
656
+ def set_manifest_flags(path, flags) -> None:
657
+ """Write the journey-GLOBAL ``[[flag]]`` table into ``journeys.toml`` -- replacing any existing top-level
658
+ ``[[flag]]`` blocks, preserving the rest of the file (the GUI text-edits journeys.toml; there's no full
659
+ re-render). ``flags`` = ``[{name, index}]``, validated like a campaign's shared flags (safe band, unique)."""
660
+ from . import campaign as _campaign
661
+ cleaned = _campaign.validate_shared_flags(flags)
662
+ p = Path(path)
663
+ lines = p.read_text(encoding="utf-8").splitlines()
664
+ kept, i, n = [], 0, len(lines)
665
+ while i < n: # strip every existing top-level [[flag]] block + our header
666
+ if lines[i].lstrip().startswith(_FLAG_HEADER_MARK): # our own rendered header comment (don't accumulate it)
667
+ i += 1
668
+ continue
669
+ if lines[i].strip() == "[[flag]]":
670
+ i += 1
671
+ while i < n and lines[i].strip() and not lines[i].lstrip().startswith("["):
672
+ i += 1 # its key lines (name/index), up to the next table / blank
673
+ if i < n and not lines[i].strip():
674
+ i += 1 # swallow one separator blank line
675
+ continue
676
+ kept.append(lines[i])
677
+ i += 1
678
+ body = "\n".join(kept).rstrip("\n")
679
+ block = render_manifest_flags(cleaned)
680
+ new = (body + "\n\n" + block) if block else (body + "\n")
681
+ p.write_text(new if new.endswith("\n") else new + "\n", encoding="utf-8", newline="\n")
682
+
683
+
684
+ def _lint_journey(j: Journey, plans: dict, errors: list, warnings: list) -> None:
685
+ """Per-journey semantic checks (entry resolves, flag window fits, links resolve to real members +
686
+ boundaries, the chain is connected, seed in range). Appends to the shared errors/warnings lists."""
687
+ # entry
688
+ if j.is_bare:
689
+ if j.set_scenario is not None and not (0 <= j.set_scenario <= SCENARIO_MAX):
690
+ errors.append(f"journey {j.id!r}: set_scenario {j.set_scenario} out of range (0-{SCENARIO_MAX})")
691
+ else:
692
+ for folder in j.campaigns:
693
+ if folder not in plans: # already errored in load_campaign_plans, but be defensive
694
+ return
695
+ entry_plan = plans[j.entry.campaign][0]
696
+ if j.entry.campaign not in j.campaigns:
697
+ errors.append(f"journey {j.id!r}: entry campaign {j.entry.campaign!r} is not in this journey's "
698
+ f"campaigns {j.campaigns}")
699
+ elif isinstance(j.entry.field, str) and j.entry.field not in {m.name for m in entry_plan.members}:
700
+ errors.append(f"journey {j.id!r}: entry field {j.entry.field!r} is not a member of campaign "
701
+ f"{j.entry.campaign!r}")
702
+ elif isinstance(j.entry.field, int) and j.entry.field not in {m.new_id for m in entry_plan.members}:
703
+ # a raw int entry that resolves to no member is a hard error (same as a bad NAME): it would flow into
704
+ # plan.entry_field_id and `deploy_journey --newgame entry` would wire an unreachable New-Game target.
705
+ errors.append(f"journey {j.id!r}: entry id {j.entry.field} is not a member of campaign "
706
+ f"{j.entry.campaign!r} -- prefer a member NAME (stable across re-id)")
707
+
708
+ # flag windows fit below the choice scratch
709
+ _, high = _flag_windows(j, plans)
710
+ if high > CHOICE_SCRATCH_FLOOR:
711
+ errors.append(f"journey {j.id!r}: campaigns need {high - FIRST_SAFE_FLAG} flags "
712
+ f"({FIRST_SAFE_FLAG}..{high - 1}) -- past the choice-scratch floor "
713
+ f"{CHOICE_SCRATCH_FLOOR}. Fewer members, smaller flags_per_field, or split the arc.")
714
+
715
+ # links: resolve + boundary + connectivity
716
+ names_by = {f: {m.name for m in plans[f][0].members} for f in j.campaigns}
717
+ for lk in j.links:
718
+ if lk.src_campaign not in j.campaigns:
719
+ errors.append(f"journey {j.id!r}: link from campaign {lk.src_campaign!r} not in this journey")
720
+ continue
721
+ if lk.dst.campaign not in j.campaigns:
722
+ errors.append(f"journey {j.id!r}: link to campaign {lk.dst.campaign!r} not in this journey")
723
+ continue
724
+ if _is_unfilled(lk.src_field) or _is_unfilled(lk.dst.field):
725
+ # an OBSOLETE leftover placeholder (a legacy file) -- cross-campaign links auto-wire from the
726
+ # real seams now, so it's not needed. Warn (don't block); resolve skips it.
727
+ warnings.append(f"journey {j.id!r}: a leftover {lk.src_field if _is_unfilled(lk.src_field) else lk.dst.field!r} "
728
+ f"placeholder on the {lk.src_campaign!r} -> {lk.dst.campaign!r} link -- delete the "
729
+ f"row; cross-campaign warps auto-wire at deploy from the real .eb connectivity.")
730
+ elif lk.src_field not in names_by[lk.src_campaign]:
731
+ errors.append(f"journey {j.id!r}: link source {lk.src_field!r} is not a member of "
732
+ f"{lk.src_campaign!r}")
733
+ elif not _member_has_seam(plans[lk.src_campaign][0], lk.src_field):
734
+ warnings.append(f"journey {j.id!r}: link source {lk.src_campaign!r}/{lk.src_field!r} has no "
735
+ f"out-of-chain seam -- it's not a boundary, so there's nothing to retarget "
736
+ f"into the next campaign (the assembler will inject a fresh warp instead).")
737
+ dstf = lk.dst.field
738
+ if not _is_unfilled(lk.src_field) and not _is_unfilled(dstf) \
739
+ and isinstance(dstf, str) and dstf not in names_by[lk.dst.campaign]:
740
+ errors.append(f"journey {j.id!r}: link target {dstf!r} is not a member of {lk.dst.campaign!r}")
741
+
742
+ # connectivity: every campaign reachable from the entry over the AUTO-WIRED + override links
743
+ plain = {f: plans[f][0] for f in j.campaigns if f in plans}
744
+ _lint_chain_connectivity(j, errors, warnings, plain=plain)
745
+ # LEAK check (single- AND multi-campaign): a forked field whose carried Field()/door warps the player to a
746
+ # field NO journey campaign forks -> an exit into the un-forked real game. A SCRIPTED (forced cutscene/ATE)
747
+ # one SOFTLOCKS; a PORTAL (walk-out door) is more often the arc's intended edge. A target DECLARED in the
748
+ # journey's `exits = [...]` is an intended boundary -> stays quiet. Sibling-aware via campaign_connectivity.
749
+ conn = campaign_connectivity(j.campaigns, plain)
750
+ _ids = lambda s: ",".join(str(t) for t in sorted(s)[:8]) + (" ..." if len(s) > 8 else "")
751
+ for folder in j.campaigns:
752
+ ext = [(tid, knd) for _f, tid, knd in (conn.get(folder) or {}).get("external", [])
753
+ if knd in ("scripted", "portal") and tid not in j.exits]
754
+ forced = {tid for tid, knd in ext if knd == "scripted"}
755
+ doors = {tid for tid, knd in ext if knd == "portal"}
756
+ if forced:
757
+ warnings.append(f"journey {j.id!r}: campaign {folder!r} has a FORCED warp to un-forked field(s) "
758
+ f"{_ids(forced)} -- a carried cutscene/ATE Field() exits the journey into the real "
759
+ f"game; a grey/unskippable one SOFTLOCKS the player. Fork those in, redirect the "
760
+ f"warp, or declare it intended via the journey's `exits = [...]`.")
761
+ if doors:
762
+ warnings.append(f"journey {j.id!r}: campaign {folder!r} has a walk-out door to un-forked field(s) "
763
+ f"{_ids(doors)} -- likely the arc's edge; fork the next zone or declare it intended "
764
+ f"via `exits = [...]`.")
765
+
766
+ # seed range (== story_flags capstone; deeper item/party validation is story_flags' at apply-time)
767
+ if j.seed.scenario is not None and not (0 <= j.seed.scenario <= SCENARIO_MAX):
768
+ errors.append(f"journey {j.id!r}: [journey.seed] scenario {j.seed.scenario} out of range "
769
+ f"(0-{SCENARIO_MAX})")
770
+ for pc in j.seed.party:
771
+ if not isinstance(pc, str) or not pc.strip():
772
+ errors.append(f"journey {j.id!r}: [journey.seed] party entries must be character names (got {pc!r})")
773
+ if j.is_bare and [p for p in j.seed.party if isinstance(p, str) and p.strip().lower() != "zidane"]:
774
+ warnings.append(f"journey {j.id!r}: [journey.seed] party is set but this is a BARE single-field journey "
775
+ f"-- the party seed is injected into a MULTI-campaign entry's .eb at deploy, so it WON'T "
776
+ f"take effect for a bare row (only the seed scenario applies, hub-side). Put the base "
777
+ f"party on the entry FIELD's own [party]/[startup] (the Editor tab), or make this a "
778
+ f"multi-campaign journey.")
779
+ if j.seed.raw.get("inventory") is not None or j.seed.raw.get("start_inventory") is not None \
780
+ or j.seed.raw.get("equipment") is not None:
781
+ warnings.append(f"journey {j.id!r}: [journey.seed] inventory/equipment map to the MOD-GLOBAL New-Game "
782
+ f"CSVs (read once at New Game, SHARED across every journey of the hub) -- clean only "
783
+ f"for a single-journey hub, and shadowed under the campaigns' --no-warp deploy unless "
784
+ f"promoted to the highest folder. For PER-JOURNEY items, add an `[[on_entry]] "
785
+ f"items = [[\"Potion\", 5]] gil = 200 flag = <N>` block to the entry member's "
786
+ f"field.toml -- scripted, once-gated, baked into the entry fork's own .eb (no global "
787
+ f"leak). scenario/party already seed cleanly that way.")
788
+
789
+ # [journey.tuning] -- mod-global player/ability CSV deltas injected into the entry member at deploy
790
+ if j.tuning:
791
+ from .battle.build import _PLAYER_CSV_KEYS, player_csv_problems
792
+ unknown = [k for k in j.tuning if k not in _PLAYER_CSV_KEYS]
793
+ if unknown:
794
+ errors.append(f"journey {j.id!r}: [journey.tuning] unknown block(s) {unknown} -- valid blocks: "
795
+ f"{', '.join(_PLAYER_CSV_KEYS)}")
796
+ errors += [f"journey {j.id!r}: [journey.tuning] {p}" # structural lint (install-free; names resolve at build)
797
+ for p in player_csv_problems({k: v for k, v in j.tuning.items() if k in _PLAYER_CSV_KEYS})]
798
+ warnings.append(f"journey {j.id!r}: [journey.tuning] writes MOD-GLOBAL player/ability CSVs (read once at "
799
+ f"New Game, ONE set per mod) -- clean for a single-journey hub; in a MULTI-journey hub "
800
+ f"every journey shares them (highest-folder-wins), so per-journey tuning can't be isolated.")
801
+ if j.is_bare:
802
+ warnings.append(f"journey {j.id!r}: [journey.tuning] is set but this is a BARE single-field journey -- "
803
+ f"it's injected into a MULTI-campaign entry member's field.toml at deploy, so it WON'T "
804
+ f"apply to a bare row. Put the deltas on the entry FIELD's own field.toml, or make this "
805
+ f"a multi-campaign journey.")
806
+
807
+
808
+ def _lint_chain_connectivity(j: Journey, errors: list, warnings: list, *, plain=None) -> None:
809
+ """A journey's campaigns must be reachable from the entry campaign -- over the explicit ``[[journey.link]]``
810
+ OVERRIDES *and* the warps AUTO-WIRED from the real ``.eb`` seams (``plain`` = ``{folder: CampaignPlan}``).
811
+ An unreachable campaign means the game's real warps don't connect it in this set (a wrong region/entry).
812
+ NO link-count check: the journey wires the full connectivity GRAPH, so >N-1 links is normal + faithful."""
813
+ if len(j.campaigns) <= 1:
814
+ return
815
+ adj: dict = {c: set() for c in j.campaigns}
816
+ for lk in j.links: # explicit overrides
817
+ if lk.src_campaign in adj and lk.dst.campaign in adj and not _is_unfilled(lk.src_field):
818
+ adj[lk.src_campaign].add(lk.dst.campaign)
819
+ if plain: # + the auto-derived cross-campaign warps (the real graph)
820
+ for d in auto_seam_links(j.campaigns, plain):
821
+ adj[d["src_campaign"]].add(d["dst_campaign"])
822
+ reached, stack = {j.entry.campaign}, [j.entry.campaign]
823
+ while stack:
824
+ for nxt in adj.get(stack.pop(), ()):
825
+ if nxt not in reached:
826
+ reached.add(nxt)
827
+ stack.append(nxt)
828
+ unreachable = [c for c in j.campaigns if c not in reached]
829
+ if unreachable:
830
+ warnings.append(f"journey {j.id!r}: campaign(s) {unreachable} unreachable from the entry campaign "
831
+ f"{j.entry.campaign!r} via [[journey.link]]s -- the game's real warps don't connect them "
832
+ f"in this set (a wrong region/entry, or they join via an order-only world-map hop).")
833
+ # NB: NO link-count check. The journey wires the REAL connectivity GRAPH (every cross-campaign warp), so more
834
+ # than N-1 links is normal + faithful (you can walk between regions both ways, as in the game). Reachability
835
+ # above is the real test -- a missing connection shows up as an unreachable campaign, not a wrong count.
836
+
837
+
838
+ # ---------------------------------------------------------------- hub fold-in (reuse hub.py's renderer)
839
+ def manifest_to_hub_spec(manifest: JourneyManifest) -> "_hub.HubSpec":
840
+ """Resolve every journey (bare + multi-campaign) to its global entry id + hub-side scenario seed and build
841
+ the :class:`ff9mapkit.hub.HubSpec` -- so the assembler's hub-emit step IS gen-hub's renderer
842
+ (:func:`ff9mapkit.hub.render_hub_field_toml`), one source of truth. Raises :class:`JourneyError` if there's
843
+ no ``[hub]`` table (nothing to render into)."""
844
+ if not manifest.hub:
845
+ raise JourneyError("no [hub] table in the manifest -- can't emit a hub field (add a [hub] block).")
846
+ plans = load_campaign_plans(manifest)
847
+ hub_journeys = []
848
+ for j in manifest.journeys:
849
+ rj = resolve_journey(j, plans)
850
+ hub_journeys.append(_hub.Journey(id=j.id, name=j.name, entry=rj.entry_id,
851
+ set_scenario=j.hub_scenario, entrance=j.entrance))
852
+ return _hub.hubspec_from_table(manifest.hub, hub_journeys)
853
+
854
+
855
+ def generate_hub(journeys_path, out_path=None, *, extract_camera=False, game=None, force=False) -> dict:
856
+ """Load a ``journeys.toml``, lint it, and emit the hub ``field.toml`` (resolving bare + multi-campaign
857
+ journeys alike). Returns ``{path, spec, errors, warnings, extracted}``. Raises :class:`JourneyError` on a
858
+ lint error. The existing build/deploy path then compiles the emitted hub field. (gen-hub is the bare-only
859
+ twin; this is the full assembler's hub step.)
860
+
861
+ ``extract_camera`` (needs the install + UnityPy): auto-provision the hub's backdrop camera from ``[hub]
862
+ borrow_field`` into the gitignored workspace cache and point the emitted ``[camera] borrow`` at it -- so a
863
+ journey assemble/deploy "just works" without a manual extract step (the same lever as ``gen-hub
864
+ --extract-camera``)."""
865
+ manifest = load_journeys(journeys_path)
866
+ errors, warnings = lint_manifest(manifest)
867
+ if errors:
868
+ raise JourneyError("journeys.toml lint failed:\n - " + "\n - ".join(errors))
869
+ spec = manifest_to_hub_spec(manifest)
870
+ herr, hwarn = _hub.validate_hub(spec)
871
+ if herr:
872
+ raise JourneyError("hub validation failed:\n - " + "\n - ".join(herr))
873
+ out_path = Path(out_path) if out_path else (manifest.root / "hub.field.toml")
874
+ if out_path.is_dir():
875
+ out_path = out_path / "hub.field.toml"
876
+ extracted = None
877
+ if extract_camera:
878
+ extracted = _hub.extract_camera_into_spec(spec, out_path.parent, game=game, force=force)
879
+ text = _hub.render_hub_field_toml(spec, source=manifest.path.name)
880
+ out_path.write_text(text, encoding="utf-8", newline="\n")
881
+ return {"path": out_path, "spec": spec, "errors": errors, "warnings": list(warnings) + list(hwarn),
882
+ "extracted": extracted}
883
+
884
+
885
+ # ---------------------------------------------------------------- read-only resolved view
886
+ def _toml_str(s) -> str:
887
+ """Escape a value for a double-quoted TOML string (backslash + quote)."""
888
+ return str(s).replace("\\", "\\\\").replace('"', '\\"')
889
+
890
+
891
+ def render_journey_row(jid: str, name: str, entry: int, *, scenario=None, entrance=None) -> str:
892
+ """One bare ``[[journey]]`` block -- a World-Hub menu row that warps into an ALREADY-INSTALLED field
893
+ (``entry``). Pure text (no game). Raises :class:`JourneyError` on a bad slug / non-int entry."""
894
+ jid = str(jid).strip()
895
+ if not _SLUG_RE.match(jid):
896
+ raise JourneyError(f"journey id {jid!r} must be a slug (A-Z, 0-9, _) -- it's the hub-choice key")
897
+ try:
898
+ entry = int(entry)
899
+ except (TypeError, ValueError):
900
+ raise JourneyError(f"entry {entry!r} must be a field id (the installed field the hub warps into)")
901
+ L = ["[[journey]]",
902
+ f'id = "{_toml_str(jid)}"',
903
+ f'name = "{_toml_str(name) or jid}"',
904
+ f"entry = {entry} # the installed field this menu row warps into (>= 4000)"]
905
+ if scenario is not None:
906
+ L.append(f"set_scenario = {int(scenario)} # seed this story beat before warping in")
907
+ if entrance is not None:
908
+ L.append(f"entrance = {int(entrance)}")
909
+ return "\n".join(L) + "\n"
910
+
911
+
912
+ _JOURNEY_HDR = re.compile(r"\s*\[\[\s*journey\s*\]\]\s*(#.*)?$") # a TOP-LEVEL [[journey]] (not journey.link)
913
+
914
+
915
+ def remove_journey_row(text: str, jid: str) -> str:
916
+ """Remove the ``[[journey]]`` block whose ``id`` is ``jid`` from a journeys.toml's TEXT. A block runs from
917
+ its ``[[journey]]`` header to the next ``[[journey]]`` header (or EOF), carrying any ``[journey.seed]`` /
918
+ ``[[journey.link]]`` sub-tables. Preserves the ``[hub]`` table + every other journey + comments. Raises
919
+ :class:`JourneyError` if no journey with that id is present."""
920
+ lines = text.splitlines()
921
+ starts = [i for i, ln in enumerate(lines) if _JOURNEY_HDR.match(ln)]
922
+ for k, s in enumerate(starts):
923
+ end = starts[k + 1] if k + 1 < len(starts) else len(lines)
924
+ bid = None
925
+ for ln in lines[s:end]:
926
+ m = re.match(r'\s*id\s*=\s*"([^"]*)"', ln)
927
+ if m:
928
+ bid = m.group(1)
929
+ break
930
+ if bid == jid:
931
+ del lines[s:end]
932
+ while lines and not lines[-1].strip(): # don't leave a trailing blank pile-up at EOF
933
+ lines.pop()
934
+ return ("\n".join(lines) + "\n") if lines else "\n"
935
+ raise JourneyError(f"no journey {jid!r} to remove in this manifest")
936
+
937
+
938
+ def render_journey_seed(*, scenario=None, party=None) -> str:
939
+ """One ``[journey.seed]`` sub-table -- the destination-side story_flags capstone (the beat + base party a
940
+ journey starts at). Pure text; returns ``""`` when neither is set. ``party`` = character names (the build
941
+ drops ``Zidane``, whom New Game already seeds in slot 0)."""
942
+ party = [str(p).strip() for p in (party or []) if str(p).strip()]
943
+ if scenario is None and not party:
944
+ return ""
945
+ L = ["[journey.seed] # the story-state capstone this journey starts at"]
946
+ if scenario is not None:
947
+ L.append(f"scenario = {int(scenario)} # the story beat to seed on entry")
948
+ if party:
949
+ arr = ", ".join(f'"{_toml_str(p)}"' for p in party)
950
+ L.append(f"party = [{arr}] # the base party; applied to a MULTI-campaign entry's .eb at deploy")
951
+ return "\n".join(L) + "\n"
952
+
953
+
954
+ def render_journey_tuning(tuning) -> str:
955
+ """Render a journey's ``[journey.tuning]`` as ``[[journey.tuning.<block>]]`` array-of-tables text (the KNOWN
956
+ player/ability CSV blocks, in canonical order; unknown keys dropped -- they only lint-warn). Pure text;
957
+ ``""`` when nothing is set."""
958
+ from .battle.build import _PLAYER_CSV_KEYS
959
+ from .editor.model import _fmt_value
960
+ out: list = []
961
+ for block in _PLAYER_CSV_KEYS:
962
+ for row in (tuning.get(block) or []):
963
+ if not isinstance(row, dict):
964
+ continue
965
+ out.append(f"[[journey.tuning.{block}]]")
966
+ for key, val in row.items():
967
+ out.append(f"{key} = {_fmt_value(val)}")
968
+ out.append("")
969
+ return ("\n".join(out).rstrip("\n") + "\n") if out else ""
970
+
971
+
972
+ _SEED_HDR = re.compile(r"\s*\[\s*journey\.seed\s*\]\s*(#.*)?$")
973
+ _TUNING_HDR = re.compile(r"\s*\[\[?\s*journey\.tuning\.") # any [[journey.tuning.<block>]] row table
974
+ # A complete single-line TOML TABLE header (`[a.b]` / `[[a.b]]`) -- used to bound a sub-table strip. Excludes a
975
+ # comma so a multi-line value array's `[ "x", "y" ]` / `[1, 2]` line isn't mistaken for a header (the line-based
976
+ # scanner is otherwise blind to value context; journeys.toml has no multi-line-string fields, so that residual is
977
+ # unreachable via the kit's schema).
978
+ _TABLE_HDR = re.compile(r"""\s*\[\[?\s*['"A-Za-z0-9_][^\],]*\]\]?\s*(#.*)?$""")
979
+
980
+
981
+ def set_journey_tuning(text: str, jid: str, tuning) -> str:
982
+ """Upsert journey ``jid``'s ``[journey.tuning]`` in a journeys.toml's TEXT, preserving its other rows /
983
+ ``[journey.seed]`` / ``[[journey.link]]`` / their comments. ALL existing ``[[journey.tuning.*]]`` tables are
984
+ REPLACED by ``tuning`` (or removed when it renders empty), so any comment that sat WITHIN the old tuning
985
+ tables is regenerated, not retained. Inserted at the END of the journey block. Pure text. Raises
986
+ :class:`JourneyError` if no journey with that id is present."""
987
+ lines = text.splitlines()
988
+ starts = [i for i, ln in enumerate(lines) if _JOURNEY_HDR.match(ln)]
989
+ for k, s in enumerate(starts):
990
+ end = starts[k + 1] if k + 1 < len(starts) else len(lines)
991
+ bid = None
992
+ for ln in lines[s:end]:
993
+ m = re.match(r'\s*id\s*=\s*"([^"]*)"', ln)
994
+ if m:
995
+ bid = m.group(1)
996
+ break
997
+ if bid != jid:
998
+ continue
999
+ i = s # 1) strip EVERY [[journey.tuning.*]] table in the block
1000
+ while i < end:
1001
+ if _TUNING_HDR.match(lines[i]):
1002
+ j = i + 1
1003
+ while j < end and not _TABLE_HDR.match(lines[j]): # stop at the next real header, not a value-array line
1004
+ j += 1
1005
+ del lines[i:j]
1006
+ end -= (j - i) # recheck the line now at i (don't advance)
1007
+ else:
1008
+ i += 1
1009
+ rendered = render_journey_tuning(tuning) # 2) insert the fresh tuning at the block end
1010
+ if rendered:
1011
+ lines[end:end] = [""] + rendered.rstrip("\n").split("\n")
1012
+ out = re.sub(r"\n{3,}", "\n\n", "\n".join(lines))
1013
+ return out.rstrip("\n") + "\n"
1014
+ raise JourneyError(f"no journey {jid!r} to tune in this manifest")
1015
+
1016
+
1017
+ def set_journey_seed(text: str, jid: str, *, scenario=None, party=None) -> str:
1018
+ """Upsert journey ``jid``'s ``[journey.seed]`` in a journeys.toml's TEXT, preserving its other rows /
1019
+ sub-tables / comments. An existing seed is REPLACED in place (or REMOVED when scenario+party are both
1020
+ empty). The seed is placed before the block's first sub-table (``[journey.seed]`` / ``[[journey.link]]``),
1021
+ so it reads right after the journey's scalar rows. Pure text. Raises :class:`JourneyError` if no journey
1022
+ with that id is present."""
1023
+ party = [str(p).strip() for p in (party or []) if str(p).strip()]
1024
+ lines = text.splitlines()
1025
+ starts = [i for i, ln in enumerate(lines) if _JOURNEY_HDR.match(ln)]
1026
+ for k, s in enumerate(starts):
1027
+ end = starts[k + 1] if k + 1 < len(starts) else len(lines)
1028
+ bid = None
1029
+ for ln in lines[s:end]:
1030
+ m = re.match(r'\s*id\s*=\s*"([^"]*)"', ln)
1031
+ if m:
1032
+ bid = m.group(1)
1033
+ break
1034
+ if bid != jid:
1035
+ continue
1036
+ for i in range(s, end): # 1) strip an existing [journey.seed] (header .. next
1037
+ if _SEED_HDR.match(lines[i]): # real TABLE header / block end -- NOT a value-array line)
1038
+ j = i + 1
1039
+ while j < end and not _TABLE_HDR.match(lines[j]):
1040
+ j += 1
1041
+ del lines[i:j]
1042
+ end -= (j - i)
1043
+ break
1044
+ seed = render_journey_seed(scenario=scenario, party=party) # 2) insert the fresh seed (if any) before
1045
+ if seed: # the block's first remaining sub-table
1046
+ ins = end
1047
+ for i in range(s + 1, end):
1048
+ if _TABLE_HDR.match(lines[i]):
1049
+ ins = i
1050
+ break
1051
+ lines[ins:ins] = [""] + seed.rstrip("\n").split("\n")
1052
+ out = re.sub(r"\n{3,}", "\n\n", "\n".join(lines)) # never pile up blank lines
1053
+ return out.rstrip("\n") + "\n"
1054
+ raise JourneyError(f"no journey {jid!r} to seed in this manifest")
1055
+
1056
+
1057
+ def render_selector_hub_toml(*, hub_name="World Hub", hub_id=4600, borrow_bg=None, hub_area=None,
1058
+ borrow_field=None, journeys=None) -> str:
1059
+ """A WORLD-HUB (journey-selector) ``journeys.toml``: ``[hub]`` + one bare ``[[journey]]`` row per
1060
+ already-installed slice. New Game lands on the hub; each row is a menu choice that warps into its field.
1061
+ The hub backdrop defaults to MOGNET CENTRAL (FF9's journey nexus + a real ``borrow_field`` so
1062
+ ``deploy_journey --apply`` auto-extracts the camera). ``journeys`` = list of ``{id, name, entry,
1063
+ scenario?}``; an empty list emits a commented example to fill (in the GUI: 'Add journey...')."""
1064
+ if borrow_bg is None: # default the hub to Mognet Central (the journey nexus)
1065
+ from . import refarc as _refarc
1066
+ borrow_bg, hub_area, borrow_field = _refarc.HUB_BORROW_BG, _refarc.HUB_BORROW_AREA, _refarc.HUB_BORROW_FIELD
1067
+ L = ["# A WORLD HUB -- a journey SELECTOR. New Game lands here; each [[journey]] row below is a menu",
1068
+ "# choice that warps into an ALREADY-INSTALLED slice (a forked field / arc in its own mod folder).",
1069
+ "# Keep every journey installed at once; the hub just needs each one's {name, entry id, seed}.",
1070
+ "# Add a row per installed journey (GUI: 'Add journey...'), then deploy + point New Game at the hub.",
1071
+ "",
1072
+ "[hub]",
1073
+ f'name = "{_hub.name_token(hub_name)}" # an EVT_/FBG_ token (no spaces -- becomes the field name)',
1074
+ f"id = {int(hub_id)} # the hub field id (custom band, >= 4000)"]
1075
+ if hub_area is not None:
1076
+ L.append(f"area = {int(hub_area)} # the borrowed room's FBG area (FBG_N<area>_...)")
1077
+ else: # custom borrow_bg with no area -> the default 21 is likely wrong
1078
+ L.append("# area = 21 # SET ME: must equal the borrowed room's real FBG area (the default 21 is "
1079
+ "usually WRONG for a custom room -> black screen)")
1080
+ L.append(f'borrow_bg = "{_toml_str(borrow_bg)}" # the room whose art the hub reuses (`list-fields`)')
1081
+ if borrow_field is not None:
1082
+ L.append(f"borrow_field = {int(borrow_field)} # the real field -> `deploy_journey --apply` "
1083
+ "auto-extracts its camera")
1084
+ else:
1085
+ L.append("# borrow_field = <real field id> # uncomment so `deploy_journey --apply` auto-extracts the camera")
1086
+ L.append("")
1087
+ rows = list(journeys or [])
1088
+ if rows:
1089
+ for r in rows:
1090
+ L.append(render_journey_row(r["id"], r.get("name", r["id"]), r["entry"],
1091
+ scenario=r.get("scenario")).rstrip("\n"))
1092
+ L.append("")
1093
+ else:
1094
+ L += ["# Add a journey row per installed slice (or use the GUI 'Add journey...'). Example:",
1095
+ "# [[journey]]",
1096
+ '# id = "dali"',
1097
+ '# name = "Dali"',
1098
+ "# entry = 4100 # the installed field New Game warps into for this journey",
1099
+ "# set_scenario = 2600 # optional: seed the story beat"]
1100
+ return "\n".join(L) + "\n"
1101
+
1102
+
1103
+ # ---------------------------------------------------------------- real connectivity (the seam oracle)
1104
+ # Zones / id order are an ORGANIZING convenience, never a constraint -- a custom chain can run in any order the
1105
+ # author wants. So we derive how campaigns ACTUALLY connect from the real warps in each forked .eb (the seams the
1106
+ # fork already records), not from zone tokens or seed-id adjacency. This tells the author where a campaign really
1107
+ # hands off (which may be a non-adjacent or non-chronological sibling) without forcing the game into our pattern.
1108
+ def campaign_connectivity(folders, plans) -> dict:
1109
+ """The cross-campaign warp graph read from each forked campaign's actual ``.eb`` seams (scripted / overworld
1110
+ / portal), NOT from zones or id order. ``folders`` = the journey's campaign list; ``plans`` = a
1111
+ ``{folder: CampaignPlan}`` map (unforked folders simply absent). Returns ``{folder: {"to": {dst_folder:
1112
+ [(frm, to_real, kind), ...]}, "external": [(frm, to_real, kind), ...], "worldmap": [(frm, kind), ...]}}`` --
1113
+ where each campaign's seams land: a SIBLING campaign in this journey, an unforked real field (a leak / a
1114
+ boundary out of the journey), or the world map. PURE over the plans."""
1115
+ owner: dict = {} # real field id -> [campaigns that fork it] (a donor id MAY be
1116
+ for f in folders: # forked by >1 sibling -- distinct new_ids, same real_id)
1117
+ p = plans.get(f)
1118
+ if p is None:
1119
+ continue
1120
+ for m in p.members:
1121
+ if m.real_id:
1122
+ owner.setdefault(int(m.real_id), []).append(f)
1123
+ out: dict = {}
1124
+ for f in folders:
1125
+ p = plans.get(f)
1126
+ if p is None:
1127
+ continue
1128
+ rec = {"to": {}, "external": [], "worldmap": []}
1129
+ for s in p.seams:
1130
+ frm, kind, tr = s.get("frm"), (s.get("kind") or "scripted"), s.get("to_real")
1131
+ if tr == "WORLDMAP":
1132
+ rec["worldmap"].append((frm, kind))
1133
+ continue
1134
+ try:
1135
+ tr = int(tr)
1136
+ except (TypeError, ValueError):
1137
+ continue
1138
+ owners = owner.get(tr, [])
1139
+ siblings = [d for d in owners if d != f]
1140
+ if not owners:
1141
+ rec["external"].append((frm, tr, kind)) # lands in a field NO journey campaign forks (a leak)
1142
+ for d in siblings: # name EVERY sibling that forks the target (not just one)
1143
+ rec["to"].setdefault(d, []).append((frm, tr, kind))
1144
+ # owners == [f] only -> a seam back into the same campaign; not a cross-campaign edge
1145
+ out[f] = rec
1146
+ return out
1147
+
1148
+
1149
+ def _field_list(seams, limit=4) -> str:
1150
+ """A compact, de-duplicated, sorted list of the real field ids in a seam list: ``'200,202,206 ...'``."""
1151
+ ids = sorted({t for _, t, _ in seams})
1152
+ return ",".join(str(t) for t in ids[:limit]) + (" ..." if len(ids) > limit else "")
1153
+
1154
+
1155
+ def _kind_tag(seams) -> str:
1156
+ """The warp KIND across a seam list -- ``scripted`` (a story/cutscene Field(), maybe gated) vs ``portal`` (a
1157
+ door edge) vs mixed -- so a one-time cutscene warp isn't mistaken for a freely-walkable connection."""
1158
+ kinds = sorted({k for _, _, k in seams})
1159
+ return kinds[0] if len(kinds) == 1 else "mixed"
1160
+
1161
+
1162
+ def connection_targets(conn_rec) -> str:
1163
+ """One-line 'dst (via 204,209 scripted); other (via 55,67 scripted)' summary of a single campaign's
1164
+ ``campaign_connectivity`` record -- the siblings its seams reach + the warp kind, for a reconcile/lint hint.
1165
+ ``''`` if it reaches no sibling."""
1166
+ if not conn_rec or not conn_rec.get("to"):
1167
+ return ""
1168
+ return "; ".join(f"{dst} (via {_field_list(seams)} {_kind_tag(seams)})"
1169
+ for dst, seams in conn_rec["to"].items())
1170
+
1171
+
1172
+ def render_connectivity(folders, plans, *, wired=frozenset(), conn=None) -> "list[str]":
1173
+ """Human report lines for :func:`campaign_connectivity`: each campaign and which siblings its real seams
1174
+ reach + the warp kind, plus out-of-journey leaks (with ids). ``wired`` = the ``(src, dst)`` campaign pairs
1175
+ actually wired (from the resolved links -- explicit + auto-derived); an edge NOT in it is flagged
1176
+ ``[NOT wired]`` (rare -- a field seam always auto-wires; a non-adjacent overworld hop may not). ``conn`` = a
1177
+ precomputed map (else computed). Only campaigns with an out-of-campaign seam are listed. ``[]`` if nothing."""
1178
+ if conn is None:
1179
+ conn = campaign_connectivity(folders, plans)
1180
+ if not conn:
1181
+ return []
1182
+ rows = []
1183
+ for f in folders:
1184
+ rec = conn.get(f)
1185
+ if rec is None: # not forked yet -> omit (don't pad the report)
1186
+ continue
1187
+ bits = []
1188
+ for dst, seams in rec["to"].items():
1189
+ tag = "" if (f, dst) in wired else " [NOT wired]" # almost always wired (every field seam auto-wires)
1190
+ bits.append(f"-> {dst} (via {_field_list(seams)} {_kind_tag(seams)}){tag}")
1191
+ if rec["worldmap"]:
1192
+ bits.append(f"-> world map x{len(rec['worldmap'])}")
1193
+ if rec["external"]: # out-of-journey leaks: ALWAYS shown, with ids (leak-hunting)
1194
+ bits.append(f"-> {len(rec['external'])} leak(s) to unforked fields ({_field_list(rec['external'])})")
1195
+ if bits: # skip a terminal campaign with no out-of-campaign seams
1196
+ rows.append(f" {f}: " + " ".join(bits))
1197
+ if not rows:
1198
+ return []
1199
+ return ["real connectivity (from each forked campaign's .eb seams -- the actual warps, not zone/id order; "
1200
+ "every field warp AUTO-WIRES at deploy, leak-proof):"] + rows
1201
+
1202
+
1203
+ def render_journey_plan(manifest: JourneyManifest) -> str:
1204
+ """A human-readable view of the assembled namespace: each journey, its entry global id + hub seed, and (for
1205
+ a multi-campaign arc) its campaigns' id bands + flag windows + resolved cross-campaign links. Backs the
1206
+ `lint-journey --graph` / `assemble-journey` dry-run output. Tolerant of an un-resolvable manifest (prints
1207
+ what it can)."""
1208
+ out = [f"journeys manifest: {manifest.path.name} ({len(manifest.journeys)} journey(s))"]
1209
+ if manifest.hub:
1210
+ out.append(f" hub: {manifest.hub.get('name', '?')} (field {manifest.hub.get('id', '?')})")
1211
+ out.append("")
1212
+ try:
1213
+ plans = load_campaign_plans(manifest)
1214
+ except JourneyError as e:
1215
+ return "\n".join(out) + f"\n!! cannot resolve campaigns: {e}\n"
1216
+ _plain = {f: p for f, (p, _) in plans.items()} # {folder: CampaignPlan} for campaign_connectivity
1217
+ for j in manifest.journeys:
1218
+ rj = resolve_journey(j, plans)
1219
+ seed = f" seed scenario={j.hub_scenario}" if j.hub_scenario is not None else ""
1220
+ if j.is_bare:
1221
+ out.append(f"* {j.name} [{j.id}] -> field {rj.entry_id} (bare single-field){seed}")
1222
+ continue
1223
+ out.append(f"* {j.name} [{j.id}] -> entry field {rj.entry_id}{seed}")
1224
+ for folder in j.campaigns:
1225
+ ids = rj.campaign_ids[folder]
1226
+ lo, hi, k = rj.flag_windows[folder]
1227
+ rng = f"{min(ids)}..{max(ids)}" if ids else "(empty)"
1228
+ out.append(f" [{folder:<16}] ids {rng} ({len(ids)} fields) flags {lo}..{hi} (K={k})")
1229
+ n_override = sum(1 for lk in j.links if not _is_unfilled(lk.src_field) and not _is_unfilled(lk.dst.field))
1230
+ n_auto = len(rj.links) - n_override
1231
+ out.append(f" links: {len(rj.links)} cross-campaign warp(s) wired"
1232
+ + (f" ({n_override} override + {n_auto} auto from .eb seams)" if n_override else
1233
+ f" (all auto-derived from the real .eb seams -- no link rows)") + "; graph below")
1234
+ conn = campaign_connectivity(j.campaigns, _plain) # the real warp graph, computed once per journey
1235
+ wired = {(d["src_campaign"], d["dst_campaign"]) for d in rj.links} # explicit + auto-derived
1236
+ # the REAL connectivity from each campaign's .eb seams (zones/id order are a convenience, not a rule)
1237
+ for line in render_connectivity(j.campaigns, _plain, wired=wired, conn=conn):
1238
+ out.append(" " + line)
1239
+ if j.seed.party:
1240
+ out.append(f" party: {', '.join(j.seed.party)}")
1241
+ out.append("")
1242
+ return "\n".join(out).rstrip() + "\n"
1243
+
1244
+
1245
+ # ---------------------------------------------------------------- the deploy plan (the in-game step's brain)
1246
+ @dataclass
1247
+ class CampaignDeployStep:
1248
+ """One campaign's deploy parameters within a journey: WHERE it installs (its own stacked ``mod_folder``,
1249
+ from its campaign.toml), its id band, and the journey-assigned disjoint ``flag_base`` (passed to
1250
+ ``build_campaign(flag_base=)`` so its bits don't clobber a sibling campaign's). Each campaign needs its
1251
+ OWN folder -- ``deploy_campaign`` WHOLESALE-replaces a folder, so two campaigns sharing one would clobber."""
1252
+ folder: str
1253
+ campaign_path: Path
1254
+ mod_folder: str
1255
+ id_lo: int
1256
+ id_hi: int
1257
+ flag_base: int
1258
+ members: int
1259
+ seed_blocks: "dict | None" = None # the [journey.seed] capstone (entry campaign only; build_campaign seed=)
1260
+ text_block_base: int = 0 # disjoint custom text-block window base (0 => no remap); build_campaign(text_block_base=)
1261
+
1262
+
1263
+ @dataclass
1264
+ class LinkRewrite:
1265
+ """A cross-campaign hand-off realized as a byte-patch on the boundary member's deployed ``.eb`` (every
1266
+ language copy). ``mode`` picks how:
1267
+ * ``field_remap`` -- rewrite ``Field(seam.to_real)`` -> ``dst_id`` in place (``remap``;
1268
+ ``content.verbatim.remap_fields``, length-preserving). For a scripted/portal seam.
1269
+ * ``worldmap_inject`` -- body-replace the boundary's walk-out region handler (the one running
1270
+ ``WorldMap(loc)``) with a ``Field(dst_id)`` warp, reusing its existing map-edge zone (the elided
1271
+ world-map leg). ``dst_entrance`` = the arrival entrance set on the warp.
1272
+ * ``none`` -- not auto-wirable (no onward seam / ambiguous); ``retargetable`` is False."""
1273
+ src_campaign: str
1274
+ src_field: str
1275
+ src_id: int
1276
+ src_mod_folder: str
1277
+ eb_name: str # "EVT_<member>" -- the deployed .eb to patch (every lang copy)
1278
+ mode: str # "field_remap" | "worldmap_inject" | "none"
1279
+ remap: dict # {seam_to_real: dst_id} (field_remap only; empty otherwise)
1280
+ dst_campaign: str
1281
+ dst_field: str
1282
+ dst_id: int
1283
+ dst_entrance: int
1284
+ seam_kinds: list
1285
+ retargetable: bool
1286
+ note: str = ""
1287
+
1288
+
1289
+ @dataclass
1290
+ class JourneyDeployPlan:
1291
+ """The whole manifest's in-game deploy, derived offline: each multi-campaign journey's campaign steps +
1292
+ link rewrites, the bare journeys (already-deployed -- the hub just points at them), the hub field id (New
1293
+ Game's target), and any mod-folder clobber conflict. Consumed by ``tools/deploy_journey.py``."""
1294
+ hub_field_id: "int | None"
1295
+ campaign_steps: list # [CampaignDeployStep] (deduped across journeys, by folder)
1296
+ links: list # [LinkRewrite]
1297
+ bare_entries: list # [(journey_id, name, entry_id)]
1298
+ folder_conflicts: list # [(mod_folder, folder_a, folder_b)] -- a wholesale-replace clobber
1299
+ entry_field_id: "int | None" = None # the resolved opening entry id IF the manifest has exactly ONE
1300
+ # journey (else None) -- the "New Game -> straight into the opening,
1301
+ # no hub menu" target (deploy_journey.py --newgame entry)
1302
+ hub_folder: "str | None" = None # the DEDICATED mod folder the hub field + the New-Game override deploy
1303
+ # into (FF9CustomMap-<hub token>) -- a journey-OWNED folder the user
1304
+ # stacks HIGHEST, NOT the ambient deploy-time highest (which a journey
1305
+ # re-stack/band-collision may drop) and NOT a campaign folder (whose
1306
+ # wholesale re-deploy would wipe the override).
1307
+
1308
+
1309
+ def seed_to_field_blocks(seed: "JourneySeed | None") -> dict:
1310
+ """Translate a ``[journey.seed]`` into the story_flags New-Game capstone blocks the build already consumes
1311
+ (``startup`` / ``party`` / ``start_inventory`` / ``equipment``) -- NO new mechanism (docs/JOURNEYS.md §4.4).
1312
+ Returns only the blocks the seed sets (empty seed -> ``{}``). ``scenario`` + ``party`` are the
1313
+ **per-journey-clean** levers: they bake into the entry fork's OWN ``.eb`` (no cross-journey collision).
1314
+ ``inventory`` / ``equipment`` map to the **mod-global** New-Game CSVs (`InitialItems`/`DefaultEquipment`,
1315
+ read once at New Game, SHARED across a hub's journeys) -- clean only for a single-journey hub; for a
1316
+ multi-journey hub prefer scripted ``give_item`` on the entry (a follow-up). Party drops ``Zidane`` (New
1317
+ Game already seeds slot 0)."""
1318
+ if seed is None or seed.is_empty:
1319
+ return {}
1320
+ blocks: dict = {}
1321
+ startup: dict = {}
1322
+ if seed.scenario is not None:
1323
+ startup["scenario"] = seed.scenario
1324
+ if seed.raw.get("flags"):
1325
+ startup["flags"] = seed.raw["flags"]
1326
+ if startup:
1327
+ blocks["startup"] = startup
1328
+ add = [p for p in seed.party if str(p).strip().lower() != "zidane"]
1329
+ if add:
1330
+ blocks["party"] = {"add": add}
1331
+ inv = seed.raw.get("start_inventory", seed.raw.get("inventory"))
1332
+ if inv is not None:
1333
+ blocks["start_inventory"] = inv if isinstance(inv, dict) else {"items": inv}
1334
+ if seed.raw.get("equipment") is not None:
1335
+ blocks["equipment"] = seed.raw["equipment"]
1336
+ return blocks
1337
+
1338
+
1339
+ def tuning_to_field_blocks(tuning: dict) -> dict:
1340
+ """Translate a journey's ``[journey.tuning]`` into the mod-GLOBAL player/ability CSV blocks the FIELD build
1341
+ already emits -- the SAME keys a field.toml carries (``battle_action`` .. ``ability_feature``), so they ride
1342
+ the proven seed -> entry-member -> field-emitter channel with NO new emitter. Returns ``{"player_csv":
1343
+ {block: rows}}`` for the KNOWN blocks present, or ``{}`` (empty tuning / only unknown keys -- those are a
1344
+ lint warning). :func:`apply_seed_blocks` merges the ``player_csv`` bundle onto the entry member's raw."""
1345
+ from .battle.build import _PLAYER_CSV_KEYS # the canonical block list (don't re-enumerate)
1346
+ blocks = {k: list(tuning[k]) for k in _PLAYER_CSV_KEYS if tuning.get(k)}
1347
+ return {"player_csv": blocks} if blocks else {}
1348
+
1349
+
1350
+ def _seam_remap(src_plan: "_campaign.CampaignPlan", member_name: str, dst_id: int, *,
1351
+ dst_reals=frozenset()) -> dict:
1352
+ """Resolve a boundary member's onward seam into the cross-campaign link MODE. Returns a dict
1353
+ ``{mode, remap, kinds, retargetable, note}``. ``dst_reals`` = the REAL field ids that ARE the next campaign
1354
+ (its members' donor ids); supplying it lets a door straight INTO the next campaign be told apart from an
1355
+ incidental in-zone door. Order:
1356
+ * ``field_remap`` (PRECISE) -- the member has a ``Field()`` door whose target is in ``dst_reals`` (a real
1357
+ warp straight into the next campaign, e.g. a dungeon mouth -> the next field): patch that door to
1358
+ ``dst_id`` (``content.verbatim.remap_fields``). Beats the overworld -- the real door is the exact boundary.
1359
+ * ``worldmap_inject`` -- NO door into the next campaign, but an OVERWORLD seam (a zone's world-map exit):
1360
+ the boundary leaves to the world map, so body-REPLACE its walk-out region with a ``Field(dst_id)`` warp
1361
+ (``apply_link_rewrites``). This is the cross-zone boundary for a world-connected chain, and is NOT
1362
+ shadowed by the member's incidental in-zone ``Field()`` doors (the dali/south_gate fix).
1363
+ * ``field_remap`` (REPURPOSE) -- no overworld and exactly ONE out-of-chain ``Field()`` door (not into the
1364
+ next campaign): repurpose it to ``dst_id`` (the lone-onward-door heuristic).
1365
+ * ``none`` -- no onward seam, or several ``Field()`` doors and no overworld (ambiguous): not auto-wired.
1366
+
1367
+ NB (in-game-UNVERIFIED): the worldmap_inject-over-in-zone-doors path is a deploy-side change -- it matches the
1368
+ real game (you leave a zone via the world map) and preserves the proven Ice Cavern cases (entrance = pure
1369
+ overworld -> inject; internal exit = a lone ``Field()`` -> remap), but a both-seams boundary's wiring should
1370
+ be confirmed in a playtest (``apply_link_rewrites`` reports found=False if no walk-out region matches)."""
1371
+ g = _campaign.campaign_graph(src_plan)
1372
+ node = g.by_name.get(member_name)
1373
+ seams = node.seams if node else []
1374
+ kinds = sorted({s.get("kind") for s in seams if s.get("kind")})
1375
+ targets = sorted({s["to_real"] for s in seams if isinstance(s.get("to_real"), int)})
1376
+ into_next = [t for t in targets if t in dst_reals]
1377
+ if into_next: # a real door straight into the next campaign -> PRECISE boundary
1378
+ return {"mode": "field_remap", "remap": {into_next[0]: dst_id}, "kinds": kinds, "retargetable": True,
1379
+ "note": "" if len(into_next) == 1 else f"{len(into_next)} doors into the next campaign "
1380
+ f"{into_next}; took the first"}
1381
+ if "overworld" in kinds: # no door into the next campaign -> the world-map exit IS it
1382
+ return {"mode": "worldmap_inject", "remap": {}, "kinds": kinds, "retargetable": True,
1383
+ "note": "overworld exit -- body-replace the walk-out region with Field(dst) (elided world leg)"
1384
+ + (f"; ignores {len(targets)} in-zone Field() door(s) {targets}" if targets else "")}
1385
+ if len(targets) == 1: # a lone out-of-chain Field() door -> repurpose it to the next
1386
+ return {"mode": "field_remap", "remap": {targets[0]: dst_id}, "kinds": kinds,
1387
+ "retargetable": True, "note": ""}
1388
+ if len(targets) > 1:
1389
+ return {"mode": "none", "remap": {}, "kinds": kinds, "retargetable": False,
1390
+ "note": f"{len(targets)} Field() seam targets {targets} -- ambiguous; pick a "
1391
+ f"single-onward-seam boundary member, or split the boundary"}
1392
+ return {"mode": "none", "remap": {}, "kinds": kinds, "retargetable": False,
1393
+ "note": "no onward seam on the boundary member -- nothing to retarget into the next campaign"}
1394
+
1395
+
1396
+ def build_deploy_plan(manifest: JourneyManifest) -> JourneyDeployPlan:
1397
+ """Resolve the whole manifest into its in-game deploy plan (PURE over the manifest + campaign plans; no
1398
+ game install). Lint first (:func:`lint_manifest`) -- this assumes a clean manifest. Each multi-campaign
1399
+ journey contributes its campaigns (each at its disjoint flag window, into its own mod folder) + link
1400
+ rewrites; bare journeys are recorded as already-deployed hub targets."""
1401
+ plans = load_campaign_plans(manifest)
1402
+ steps, links, bare, conflicts = [], [], [], []
1403
+ folder_seen: dict = {} # mod_folder -> the campaign folder that claimed it
1404
+ done_folders: set = set() # campaign folders already turned into a step (dedup)
1405
+ entry_ids: list = [] # each journey's resolved entry id (for the single-journey case)
1406
+ for j in manifest.journeys:
1407
+ if j.is_bare:
1408
+ bare.append((j.id, j.name, int(j.entry.field)))
1409
+ entry_ids.append(int(j.entry.field))
1410
+ continue
1411
+ rj = resolve_journey(j, plans)
1412
+ entry_ids.append(rj.entry_id)
1413
+ for folder in j.campaigns:
1414
+ plan, _ = plans[folder]
1415
+ if folder not in done_folders:
1416
+ ids = rj.campaign_ids[folder]
1417
+ lo, _hi, _k = rj.flag_windows[folder]
1418
+ tb_base = (rj.text_block_windows or {}).get(folder, 0)
1419
+ seed_blocks = {}
1420
+ if folder == j.entry.campaign: # the entry member carries the seed AND the [tuning] CSVs
1421
+ seed_blocks = dict(seed_to_field_blocks(j.seed))
1422
+ seed_blocks.update(tuning_to_field_blocks(j.tuning))
1423
+ steps.append(CampaignDeployStep(
1424
+ folder=folder, campaign_path=_campaign_path(manifest.root, folder),
1425
+ mod_folder=plan.mod_folder, id_lo=min(ids), id_hi=max(ids), flag_base=lo,
1426
+ members=len(ids), seed_blocks=seed_blocks or None, text_block_base=tb_base))
1427
+ done_folders.add(folder)
1428
+ prior = folder_seen.get(plan.mod_folder)
1429
+ if prior is not None and prior != folder:
1430
+ conflicts.append((plan.mod_folder, prior, folder))
1431
+ folder_seen.setdefault(plan.mod_folder, folder)
1432
+ # BATCH the field_remap links by source member: many cross-campaign warps land on ONE member's .eb
1433
+ # (a cutscene hub like at_sln retargets ~14 Field()s), so merge them into a SINGLE multi-entry remap --
1434
+ # one .eb patch + one backup per member (no per-link read-after-write accumulation). worldmap_inject
1435
+ # links stay per-link (each body-replaces a distinct region).
1436
+ field_groups: dict = {} # src_field -> merged remap (insertion-ordered)
1437
+ for lk in rj.links:
1438
+ src_plan = plans[lk["src_campaign"]][0]
1439
+ dst_plan = plans[lk["dst_campaign"]][0]
1440
+ # the arrival member's DONOR id -> a boundary door that lands there is the precise cross-zone warp
1441
+ dst_real = next((m.real_id for m in dst_plan.members if m.new_id == lk["dst_id"]), None)
1442
+ sr = _seam_remap(src_plan, lk["src_field"], lk["dst_id"],
1443
+ dst_reals=frozenset({dst_real}) if dst_real else frozenset())
1444
+ if sr["mode"] == "field_remap" and sr["remap"]:
1445
+ g = field_groups.get(lk["src_field"])
1446
+ if g is None:
1447
+ g = field_groups[lk["src_field"]] = {"remap": {}, "lk": lk, "src_plan": src_plan,
1448
+ "kinds": set(), "dsts": []}
1449
+ g["remap"].update(sr["remap"]) # merge {to_real: dst_id}; distinct to_reals never collide
1450
+ g["kinds"].update(sr["kinds"])
1451
+ g["dsts"].append(lk["dst_campaign"])
1452
+ else: # worldmap_inject / none -> one LinkRewrite per link
1453
+ links.append(LinkRewrite(
1454
+ src_campaign=lk["src_campaign"], src_field=lk["src_field"], src_id=lk["src_id"],
1455
+ src_mod_folder=src_plan.mod_folder, eb_name=f"EVT_{lk['src_field']}",
1456
+ mode=sr["mode"], remap=sr["remap"],
1457
+ dst_campaign=lk["dst_campaign"], dst_field=str(lk["dst_field"]), dst_id=lk["dst_id"],
1458
+ dst_entrance=int(lk.get("dst_entrance", 0)),
1459
+ seam_kinds=sr["kinds"], retargetable=sr["retargetable"], note=sr["note"]))
1460
+ for sf, g in field_groups.items(): # one merged field_remap LinkRewrite per source member
1461
+ lk, n = g["lk"], len(g["remap"])
1462
+ dcs = sorted(set(g["dsts"]))
1463
+ links.append(LinkRewrite(
1464
+ src_campaign=lk["src_campaign"], src_field=sf, src_id=lk["src_id"],
1465
+ src_mod_folder=g["src_plan"].mod_folder, eb_name=f"EVT_{sf}",
1466
+ mode="field_remap", remap=dict(g["remap"]),
1467
+ dst_campaign=dcs[0] if len(dcs) == 1 else f"{len(dcs)} campaigns",
1468
+ dst_field=f"{n} warp(s)" if n > 1 else str(lk["dst_field"]), dst_id=lk["dst_id"],
1469
+ dst_entrance=0, seam_kinds=sorted(g["kinds"]), retargetable=True,
1470
+ note=f"{n} cross-campaign warp(s) -> {', '.join(dcs)}"))
1471
+ hub_id = int(manifest.hub["id"]) if manifest.hub.get("id") is not None else None
1472
+ single_entry = entry_ids[0] if len(manifest.journeys) == 1 else None # "straight into the opening" target
1473
+ hub_folder = None
1474
+ if hub_id is not None: # a dedicated journey-owned folder for the hub + New-Game override
1475
+ from . import hub as _hub
1476
+ base = f"FF9CustomMap-{_hub.name_token(manifest.hub.get('name', 'hub')).lower()}"
1477
+ camp = {s.mod_folder for s in steps} # keep it distinct from EVERY campaign folder (no re-deploy clobber)
1478
+ hub_folder, i = base, 1 # loop (not one-shot) so the fallback can't itself collide
1479
+ while hub_folder in camp:
1480
+ hub_folder = f"{base}-hub{'' if i == 1 else i}"
1481
+ i += 1
1482
+ return JourneyDeployPlan(hub_field_id=hub_id, campaign_steps=steps, links=links, bare_entries=bare,
1483
+ folder_conflicts=conflicts, entry_field_id=single_entry, hub_folder=hub_folder)
1484
+
1485
+
1486
+ # ---- pre-flight collision sweep (the "remove these stale folders" report, before any install) ----
1487
+
1488
+ @dataclass
1489
+ class JourneyCollisions:
1490
+ """Does this journey's FINAL set of registrations clash with the live ``Memoria.ini`` ``FolderNames`` stack?
1491
+ Computed BEFORE any install (``EventDB`` is GLOBAL across folders -- a shared id loads the wrong ``.eb`` ->
1492
+ black screen; CLAUDE.md §3).
1493
+
1494
+ ``external_*`` collide with a folder that is NOT part of this journey -- the real BLOCKER (a superseded prior
1495
+ deploy / an unrelated mod on the same band); the fix is to drop that folder from ``FolderNames``.
1496
+ ``stale_own`` are this journey's OWN folders that still hold a PRIOR deploy whose ids overlap a SIBLING's
1497
+ final band -- harmless (each is wholesale-replaced on deploy), but they would trip ``deploy_campaign``'s
1498
+ per-folder id check mid-install, which is exactly why ``deploy_journey`` relaxes THAT one check
1499
+ (``--allow-id-collision``) once this external sweep comes back clean."""
1500
+ external_ids: tuple = () # (id, my_folder, my_name, other_folder, other_kind, other_name)
1501
+ external_names: tuple = () # (kind, name, my_folder, other_folder)
1502
+ stale_own: tuple = () # (folder, (overlapping_ids, ...))
1503
+ external_folders: tuple = () # the distinct external folders that collide (the "remove these" headline)
1504
+ shared_blocks: tuple = () # (block, (mod_folder, ...)) -- text blocks shipped by >1 of THIS journey's campaigns
1505
+
1506
+ @property
1507
+ def has_blockers(self) -> bool:
1508
+ return bool(self.external_ids or self.external_names)
1509
+
1510
+
1511
+ def _journey_registrations(plan, *, dists=None, hub_name=None):
1512
+ """This journey's FINAL registrations: ``{id: (mod_folder, name)}``, ``{eb_name: mod_folder}``,
1513
+ ``{scene_name: mod_folder}``. Reads the built dists when given (authoritative, incl. FBG scene names), else
1514
+ derives ids + ``EVT_<member>`` names straight from each campaign manifest (no build -- the dry-run path).
1515
+ ``hub_name`` (the ``[hub] name``) adds the hub's own ``EVT_<token>`` to the name axis -- a BG-borrow hub
1516
+ ships its ``.eb`` but NO novel FBG scene dir (it points at the borrowed real art), so EVT is its only own
1517
+ name."""
1518
+ from . import deploystack as _DS
1519
+ ids: dict = {}
1520
+ eb: dict = {}
1521
+ scene: dict = {}
1522
+ for s in plan.campaign_steps:
1523
+ d = (dists or {}).get(s.folder)
1524
+ if d is not None: # authoritative: read what was actually built
1525
+ d = Path(d)
1526
+ for i, (_k, nm) in _DS.dictionary_ids_at(d).items():
1527
+ ids[i] = (s.mod_folder, nm)
1528
+ for nm in _DS.eb_names_at(d):
1529
+ eb[nm] = s.mod_folder
1530
+ for nm in _DS.scene_names_at(d):
1531
+ scene[nm] = s.mod_folder
1532
+ else: # cheap pre-build derivation (ids + EVT names)
1533
+ cp = _campaign.load_campaign(s.campaign_path)
1534
+ for m in cp.members:
1535
+ ids[m.new_id] = (s.mod_folder, m.name)
1536
+ eb[f"EVT_{m.name}"] = s.mod_folder
1537
+ if plan.hub_field_id is not None and plan.hub_folder: # the hub registers its own id (+ EVT name) too
1538
+ ids.setdefault(int(plan.hub_field_id), (plan.hub_folder, "hub"))
1539
+ if hub_name:
1540
+ eb.setdefault(f"EVT_{_hub.name_token(hub_name)}", plan.hub_folder)
1541
+ return ids, eb, scene
1542
+
1543
+
1544
+ def shared_text_blocks(campaign_steps, dists) -> list:
1545
+ """Text blocks (dialogue mesIDs) shipped by MORE THAN ONE of a journey's campaign dists. Each such block
1546
+ COLLIDES when the folders stack: the engine serves ONE folder's ``field/<block>.mes`` for every field that
1547
+ references that block, so the lower-priority campaigns' fields render the WRONG text (this bit a verbatim
1548
+ [[logic_edit]] dialogue rewrite -- it built into the right folder but a sibling campaign on the same block
1549
+ won the stack). Computed from the BUILT dists (``{folder: dist_dir}``), so it's independent of the
1550
+ ``Memoria.ini`` ``FolderNames`` order -- which is exactly why a per-folder / live-stack shadow check misses it.
1551
+ Returns ``[(block, (mod_folder, ...)), ...]`` sorted by block (``[]`` => disjoint, all clear)."""
1552
+ from . import deploystack as _DS
1553
+ from .config import LANGS as _LANGS
1554
+ block_folders: dict = {}
1555
+ for s in campaign_steps:
1556
+ d = (dists or {}).get(s.folder)
1557
+ if d is None:
1558
+ continue
1559
+ blocks = set().union(*(_DS.blocks_at(d, L) for L in _LANGS))
1560
+ for b in blocks:
1561
+ block_folders.setdefault(int(b), set()).add(s.mod_folder)
1562
+ return [(b, tuple(sorted(fs))) for b, fs in sorted(block_folders.items()) if len(fs) > 1]
1563
+
1564
+
1565
+ def preflight_collisions(plan, game_dir, *, dists=None, hub_name=None) -> JourneyCollisions:
1566
+ """Sweep this journey's FINAL ids/names against the live ``Memoria.ini`` ``FolderNames`` stack BEFORE any
1567
+ install. Folders this journey OWNS are excluded from the blocker set (each is wholesale-replaced); a
1568
+ leftover/superseded FOREIGN folder on the same band is the real blocker. Read-only -- touches no game files.
1569
+ Pass ``dists`` (``{folder: dist_dir}``) after the offline build for an authoritative pass (FBG names too),
1570
+ and ``hub_name`` (the ``[hub] name``) to also sweep the hub's ``EVT_<token>`` vs foreign folders."""
1571
+ from . import deploystack as _DS
1572
+ game_dir = Path(game_dir)
1573
+ ini = game_dir / "Memoria.ini"
1574
+ order = _DS.parse_folder_names(ini.read_text(encoding="utf-8", errors="ignore")) if ini.is_file() else []
1575
+ own = {s.mod_folder for s in plan.campaign_steps}
1576
+ if plan.hub_folder:
1577
+ own.add(plan.hub_folder)
1578
+ want_ids, want_eb, want_scene = _journey_registrations(plan, dists=dists, hub_name=hub_name)
1579
+ ext_ids: list = []
1580
+ ext_names: list = []
1581
+ ext_folders: set = set()
1582
+ for f in [x for x in order if x not in own]: # only FOREIGN folders are blockers
1583
+ their = _DS.dictionary_ids_at(game_dir / f)
1584
+ for i in sorted(want_ids):
1585
+ if i in their:
1586
+ mf, nm = want_ids[i]
1587
+ ok, on = their[i]
1588
+ ext_ids.append((i, mf, nm, f, ok, on))
1589
+ ext_folders.add(f)
1590
+ their_eb = _DS.eb_names_at(game_dir / f)
1591
+ for nm in sorted(want_eb):
1592
+ if nm in their_eb:
1593
+ ext_names.append(("eb", nm, want_eb[nm], f))
1594
+ ext_folders.add(f)
1595
+ their_sc = _DS.scene_names_at(game_dir / f)
1596
+ for nm in sorted(want_scene):
1597
+ if nm in their_sc:
1598
+ ext_names.append(("scene", nm, want_scene[nm], f))
1599
+ ext_folders.add(f)
1600
+ # informational: which of THIS journey's OWN folders still hold a SIBLING's band (replaced on deploy)
1601
+ own_final = set(want_ids)
1602
+ stale: list = []
1603
+ for s in plan.campaign_steps:
1604
+ live = set(_DS.dictionary_ids_at(game_dir / s.mod_folder))
1605
+ sib = sorted(i for i in live & own_final if want_ids[i][0] != s.mod_folder)
1606
+ if sib:
1607
+ stale.append((s.mod_folder, tuple(sib)))
1608
+ # text-block (mesID) collisions AMONG this journey's OWN campaigns (needs the built dists to read the .mes
1609
+ # stems). Informational, not a blocker: a shared block shows the WRONG text, it doesn't black-screen.
1610
+ shared = shared_text_blocks(plan.campaign_steps, dists) if dists else []
1611
+ return JourneyCollisions(tuple(ext_ids), tuple(ext_names), tuple(stale), tuple(sorted(ext_folders)),
1612
+ tuple(shared))
1613
+
1614
+
1615
+ def render_collision_report(col: JourneyCollisions) -> str:
1616
+ """A human-readable pre-flight report (``""`` when fully clean): names the superseded ``FolderNames``
1617
+ folders to remove (the blocker), then notes this journey's own folders that will be replaced in place."""
1618
+ from .chain import format_id_ranges
1619
+ lines: list = []
1620
+ if col.has_blockers:
1621
+ lines.append("PRE-FLIGHT COLLISION: this journey re-registers ids/names that a Memoria.ini FolderNames "
1622
+ "folder NOT part of this journey still uses. FF9DBAll.EventDB is GLOBAL across folders, so a "
1623
+ "shared id loads the WRONG .eb (-> black screen).")
1624
+ for (i, mf, nm, f, ok, on) in col.external_ids:
1625
+ lines.append(f" - id {i} ({mf} '{nm}') collides with '{f}' ({ok} '{on}')")
1626
+ for (kind, nm, mf, f) in col.external_names:
1627
+ lines.append(f" - {kind} name '{nm}' ({mf}) collides with '{f}'")
1628
+ lines.append("FIX: remove the superseded folder(s) from Memoria.ini [Mod] FolderNames -- "
1629
+ + ", ".join(col.external_folders) + " -- then re-deploy (this journey re-registers those "
1630
+ "bands itself). No game files were touched.")
1631
+ if col.stale_own:
1632
+ if lines:
1633
+ lines.append("")
1634
+ lines.append("note: these of THIS journey's OWN folders still hold a prior deploy whose ids overlap a "
1635
+ "sibling; each is wholesale-replaced on deploy, so the per-folder id check is relaxed:")
1636
+ for (f, ids) in col.stale_own:
1637
+ lines.append(f" - {f}: had id(s) {format_id_ranges(list(ids))}")
1638
+ if col.shared_blocks:
1639
+ if lines:
1640
+ lines.append("")
1641
+ lines.append("TEXT-BLOCK COLLISION: these dialogue text blocks (mesIDs) are shipped by MORE THAN ONE of "
1642
+ "this journey's campaigns. Stacked, the engine serves ONE folder's field/<block>.mes for ALL "
1643
+ "of them -- the lower-priority campaigns' fields show the WRONG text (incl. [[logic_edit]] "
1644
+ "dialogue rewrites), and no FolderNames order satisfies all of them:")
1645
+ for (b, fs) in col.shared_blocks:
1646
+ lines.append(f" - block {b}: {', '.join(fs)}")
1647
+ lines.append("FIX: a shared mesID can't satisfy all campaigns at once -- order Memoria.ini FolderNames "
1648
+ "so the campaign whose dialogue matters most for each block is HIGHEST (it wins; the others' "
1649
+ "fields on that block show its text). The full cure -- a disjoint text-block window per "
1650
+ "campaign -- needs custom mesIDs registered in MesDB (a deferred engine follow-up); see "
1651
+ "docs/KNOWN_ISSUES.md.")
1652
+ return "\n".join(lines)
1653
+
1654
+
1655
+ def render_deploy_playbook(manifest: JourneyManifest, *, hub_toml: str = "<hub.field.toml>",
1656
+ repo_rel: str = "", journeys_ref: "str | None" = None) -> str:
1657
+ """The ordered, copy-pasteable command sequence to deploy a journeys manifest in-game, built from the
1658
+ deploy plan. Each step is an EXISTING, individually revert-guarded tool (so the human applies + playtests
1659
+ incrementally -- "one change per in-game test"); the only journey-unique step is the link `.eb` remap
1660
+ (``deploy_journey.py --apply-links``). PURE text (no game touched). ``repo_rel`` prefixes campaign paths;
1661
+ ``journeys_ref`` is the manifest path as the human will type it (default: its bare name)."""
1662
+ plan = build_deploy_plan(manifest)
1663
+ pre = (repo_rel.rstrip("/") + "/") if repo_rel else ""
1664
+ jref = journeys_ref or manifest.path.name
1665
+ seeded = [s for s in plan.campaign_steps if s.seed_blocks]
1666
+ L = ["# === Journey deploy playbook (run from the repo root; apply + PLAYTEST each step in order) ===",
1667
+ "# Memoria.ini [Mod] FolderNames must STACK every folder below; the hub folder is HIGHEST.",
1668
+ f"# ONE-SHOT: `py tools/deploy_journey.py {jref} --apply` runs steps 1-3 (campaigns + links + hub) + "
1669
+ "seeds the entry + writes ONE revert. New Game is NOT touched (reach the hub via F6; add "
1670
+ "--newgame hub|entry to opt in).",
1671
+ ("# (the manual steps below do NOT apply [journey.seed] -- use --apply for a seeded journey)"
1672
+ if seeded else ""),
1673
+ ""]
1674
+ L = [x for i, x in enumerate(L) if x or i == len(L) - 1] # drop the empty seeded-note line if absent
1675
+ if plan.folder_conflicts:
1676
+ L.append("# !! MOD-FOLDER CLOBBER -- these campaigns share a folder (deploy_campaign wholesale-replaces "
1677
+ "it):")
1678
+ for mf, a, b in plan.folder_conflicts:
1679
+ L.append(f"# {a!r} and {b!r} both -> {mf!r}. Give each campaign its OWN mod_folder.")
1680
+ L.append("")
1681
+ L.append("# 1. Deploy each campaign into its own stacked folder, at its disjoint flag window (--no-warp: "
1682
+ "the hub owns New Game):")
1683
+ for s in plan.campaign_steps:
1684
+ seed_note = ""
1685
+ if s.seed_blocks:
1686
+ bits = []
1687
+ if s.seed_blocks.get("startup", {}).get("scenario") is not None:
1688
+ bits.append(f"scenario={s.seed_blocks['startup']['scenario']}")
1689
+ if s.seed_blocks.get("party", {}).get("add"):
1690
+ bits.append(f"party+={s.seed_blocks['party']['add']}")
1691
+ seed_note = f" # SEED (via --apply): {', '.join(bits)}" if bits else " # SEED (via --apply)"
1692
+ L.append(f"py tools/deploy_campaign.py {pre}{s.campaign_path.as_posix() if not pre else s.folder + '/campaign.toml'} "
1693
+ f"--apply --no-warp --mod-folder {s.mod_folder} --flag-base {s.flag_base}"
1694
+ f" # ids {s.id_lo}..{s.id_hi}{seed_note}")
1695
+ if not plan.campaign_steps:
1696
+ L.append("# (no multi-campaign journeys -- all journeys are bare single fields, already deployed)")
1697
+ L.append("")
1698
+ L.append("# 2. Wire the cross-campaign links (retarget each boundary .eb Field() exit -> the next entry):")
1699
+ wired = [lk for lk in plan.links if lk.retargetable]
1700
+ for lk in plan.links:
1701
+ dst = f"[{lk.dst_campaign}/{lk.dst_field}]"
1702
+ if lk.mode == "field_remap":
1703
+ L.append(f"# {lk.src_campaign}/{lk.src_field} (EVT, field {lk.src_id}) Field({list(lk.remap)[0]}) "
1704
+ f"-> Field({lk.dst_id}) {dst}")
1705
+ elif lk.mode == "worldmap_inject":
1706
+ L.append(f"# {lk.src_campaign}/{lk.src_field} (EVT, field {lk.src_id}) overworld exit "
1707
+ f"-> Field({lk.dst_id}) region {dst} (elided world-map leg)")
1708
+ else:
1709
+ L.append(f"# !! {lk.src_campaign}/{lk.src_field} -> {lk.dst_campaign}/{lk.dst_field}: NOT "
1710
+ f"auto-wired -- {lk.note}")
1711
+ if wired:
1712
+ L.append(f"py tools/deploy_journey.py {jref} --apply-links")
1713
+ L.append("# !! run --apply-links LAST + re-run it after ANY campaign re-deploy: deploy_campaign "
1714
+ "wholesale-replaces a folder, which WIPES the link patch.")
1715
+ elif plan.links:
1716
+ L.append("# (no auto-wirable links -- see the notes above)")
1717
+ else:
1718
+ L.append("# (no cross-campaign links)")
1719
+ L.append("")
1720
+ L.append(f"# 3. Emit + deploy the hub field into its OWN folder {plan.hub_folder!r} (reach it via F6 -> Warp; "
1721
+ "New Game stays untouched):")
1722
+ L.append(f"py -m ff9mapkit assemble-journey {jref} --out {hub_toml}")
1723
+ if plan.hub_field_id is not None:
1724
+ L.append(f"py tools/deploy_field.py {hub_toml} --id {plan.hub_field_id} --mod-folder {plan.hub_folder}")
1725
+ L.append("# OPTIONAL New-Game landing (SINGLE-OWNER -- replaces your current target; into the hub folder):")
1726
+ L.append(f"py tools/wire_newgame_from_stock.py {plan.hub_field_id} --mod-folder {plan.hub_folder}"
1727
+ f" # New Game -> the hub MENU")
1728
+ if plan.entry_field_id is not None:
1729
+ L.append(f"py tools/wire_newgame_from_stock.py {plan.entry_field_id} --mod-folder {plan.hub_folder}"
1730
+ f" # New Game -> STRAIGHT into the opening (single arc; keeps the real FMV)")
1731
+ L.append("")
1732
+ if plan.bare_entries:
1733
+ L.append("# Bare single-field journeys (already deployed elsewhere -- the hub just warps to them):")
1734
+ for jid, name, eid in plan.bare_entries:
1735
+ L.append(f"# {name!r} [{jid}] -> field {eid}")
1736
+ folders = ([plan.hub_folder] if plan.hub_folder else []) + [s.mod_folder for s in plan.campaign_steps]
1737
+ if folders:
1738
+ L.append("# Memoria.ini [Mod] FolderNames (HIGHEST first), then your video/passthrough mods below:")
1739
+ L.append("# FolderNames = " + ", ".join(f'"{f}"' for f in folders) + ', "<your other mods, e.g. Moguri>"')
1740
+ if plan.campaign_steps:
1741
+ lo = min(s.id_lo for s in plan.campaign_steps)
1742
+ hi = max(s.id_hi for s in plan.campaign_steps)
1743
+ L.append(f"# This journey uses field ids {lo}..{hi} -- REMOVE any OTHER custom-field folder that deploys "
1744
+ f"in that range (EventDB is GLOBAL -> a collision black-screens).")
1745
+ L.append("# Then RELAUNCH once (new ids register on a fresh launch) and PLAYTEST.")
1746
+ return "\n".join(L) + "\n"
1747
+
1748
+
1749
+ # The WorldMap opcode (an overworld exit). Its operand is a world-map LOCATION id (9000-9012), NOT a field
1750
+ # id -- so it can't be Field-retargeted; the elided world-map leg body-REPLACES the walk-out region instead.
1751
+ # (eb/_optables.py; ground-truthed against real fields 300/311/312.)
1752
+ _WORLDMAP_OP = 0xB6
1753
+
1754
+
1755
+ def _worldmap_warp_body(dst_id: int, entrance: int = 0) -> bytes:
1756
+ """The proven walk-out region handler body that warps ``Field(dst_id)`` -- lifted from the in-game-proven
1757
+ field-109 gateway template (:mod:`ff9mapkit.content.gateway`): its tag-2 (tread) func body, patched with
1758
+ the destination field + arrival entrance. Spliced over a boundary field's WorldMap walk-out handler, it
1759
+ reuses that region's existing map-edge zone (its tag-0 SetRegion is untouched), turning "leave to the
1760
+ world map" into "warp into the next campaign" (the elided world-map leg)."""
1761
+ import struct
1762
+ from . import data
1763
+ from .content import gateway
1764
+ tpl = bytearray(data.region_template())
1765
+ struct.pack_into("<H", tpl, gateway.REL_ENTRANCE, int(entrance) & 0xFFFF)
1766
+ struct.pack_into("<H", tpl, gateway.REL_FIELD, int(dst_id) & 0xFFFF)
1767
+ fc, fbase = tpl[1], 2
1768
+ funcs = [(tpl[fbase + i * 4] | (tpl[fbase + i * 4 + 1] << 8),
1769
+ tpl[fbase + i * 4 + 2] | (tpl[fbase + i * 4 + 3] << 8)) for i in range(fc)]
1770
+ idx = next((i for i, (t, _) in enumerate(funcs) if t == 2), None)
1771
+ if idx is None: # kit invariant: the template's warp lives in tag 2
1772
+ raise JourneyError("gateway template has no tag-2 warp func (kit invariant broken)")
1773
+ start = fbase + funcs[idx][1]
1774
+ end = fbase + funcs[idx + 1][1] if idx + 1 < fc else len(tpl)
1775
+ return bytes(tpl[start:end])
1776
+
1777
+
1778
+ def _worldmap_region_funcs(eb_bytes: bytes) -> list:
1779
+ """The walk-out region handlers that run an overworld exit: ``(entry_idx, func_tag)`` for every tag-2
1780
+ (tread) func containing a ``WorldMap`` op. These are the bodies the world-map-leg injection replaces with
1781
+ a ``Field(dst)`` warp; their entry's tag-0 SetRegion (the map-edge zone) is left intact, so the player
1782
+ crossing that same edge now warps into the next campaign instead of the world map."""
1783
+ from .eb import EbScript
1784
+ eb = EbScript.from_bytes(eb_bytes)
1785
+ hits = []
1786
+ for ei, e in enumerate(eb.entries):
1787
+ if e.empty:
1788
+ continue
1789
+ for f in e.funcs:
1790
+ if f.tag == 2 and any(i.op == _WORLDMAP_OP for i in eb.instrs(f)):
1791
+ hits.append((ei, f.tag))
1792
+ return hits
1793
+
1794
+
1795
+ def apply_link_rewrites(plan: JourneyDeployPlan, game_root, *, dry_run=False, backup_dir=None,
1796
+ mod_folder_override=None) -> list:
1797
+ """Apply each retargetable :class:`LinkRewrite` to the boundary member's DEPLOYED ``.eb`` (every language
1798
+ copy under ``<game>/<src_mod_folder>``) -- the one journey-unique in-game step. Two modes:
1799
+ * ``field_remap`` -- ``content.verbatim.remap_fields`` patches the ``Field(to_real)`` literal -> dst,
1800
+ length-preserving.
1801
+ * ``worldmap_inject`` -- ``eb.edit.replace_function_body`` swaps each WorldMap walk-out region handler
1802
+ for the proven ``Field(dst)`` warp body (the elided world-map leg), reusing the region's zone.
1803
+ ``mod_folder_override`` (the SINGLE-FOLDER deploy): every campaign's ``.eb`` lives in ONE merged folder,
1804
+ so look there instead of each link's per-campaign ``src_mod_folder``. Returns
1805
+ ``[{eb, mode, langs, regions, backups, found}]``. ``dry_run`` reports without writing; each patched file
1806
+ is backed up to ``backup_dir`` first (reversibility -- Hard Constraint §2)."""
1807
+ from .content.verbatim import remap_fields
1808
+ from .eb import edit as _edit
1809
+ game_root = Path(game_root)
1810
+ results = []
1811
+ for lk in plan.links:
1812
+ if not lk.retargetable:
1813
+ continue
1814
+ mod = game_root / (mod_folder_override or lk.src_mod_folder)
1815
+ ebs = sorted(mod.rglob(f"{lk.eb_name}.eb.bytes")) if mod.is_dir() else []
1816
+ body = _worldmap_warp_body(lk.dst_id, lk.dst_entrance) if lk.mode == "worldmap_inject" else None
1817
+ touched, backups, regions = [], [], 0
1818
+ for p in ebs:
1819
+ blob = p.read_bytes()
1820
+ if lk.mode == "field_remap":
1821
+ out = remap_fields(blob, lk.remap)
1822
+ elif lk.mode == "worldmap_inject":
1823
+ out, n = blob, 0
1824
+ for (ei, tag) in _worldmap_region_funcs(blob): # slot indices are stable across replaces
1825
+ out = _edit.replace_function_body(out, ei, tag, body)
1826
+ n += 1
1827
+ regions = n # structural -> identical across langs
1828
+ else:
1829
+ out = blob
1830
+ if out == blob: # nothing matched in this copy (wrong lang / no region)
1831
+ continue
1832
+ if not dry_run:
1833
+ if backup_dir:
1834
+ Path(backup_dir).mkdir(parents=True, exist_ok=True)
1835
+ # path-relative slug so per-lang copies (same filename, different dir) don't collide
1836
+ rel = p.relative_to(mod).as_posix().replace("/", "_")
1837
+ bk = Path(backup_dir) / f"{lk.src_mod_folder}_{rel}.preLINK"
1838
+ if not bk.exists(): # keep the ORIGINAL: a member with several auto-wired warps
1839
+ bk.write_bytes(blob) # gets multiple rewrites -- don't clobber the first backup
1840
+ backups.append((str(p), str(bk)))
1841
+ p.write_bytes(out)
1842
+ touched.append(str(p))
1843
+ results.append({"eb": lk.eb_name, "mode": lk.mode, "remap": dict(lk.remap), "dst_id": lk.dst_id,
1844
+ "langs": len(touched), "regions": regions, "files": touched, "backups": backups,
1845
+ "found": bool(touched)})
1846
+ return results
1847
+
1848
+
1849
+ def merge_dists(dist_dirs, *, out, folder_name, entry_dist=None) -> dict:
1850
+ """Union built mod dists into ONE merged dist at ``out`` -- the single-folder journey install (one stacked
1851
+ ``FolderNames`` entry instead of one per campaign). The journey GUARANTEES id / FBG-scene / EVT / text-block
1852
+ disjointness across every campaign (the §8 namespace job + the 20000+ text-block windows), so the
1853
+ per-field assets union with no clobber: ``StreamingAssets`` (FieldMaps, event scripts, ``<mesid>.mes`` text,
1854
+ mapconfig) is copied from each dist into one tree. The root ``DictionaryPatch.txt`` + ``BattlePatch.txt`` are
1855
+ CONCATENATED verbatim (NOT deduped -- ``Music: 0`` repeats legitimately per song-0 battle, and every
1856
+ FieldScene/MessageFile line is already unique by the disjointness guarantee). The one real collision is the
1857
+ FIXED-PATH start-state CSVs (``StreamingAssets/Data/...``): the New-Game seed must be ONE campaign's, so
1858
+ ``entry_dist`` is applied LAST and wins. Writes one ``ModDescription.xml`` (``InstallationPath`` =
1859
+ ``folder_name``). Pure/offline -- no game touched. Returns a summary dict."""
1860
+ import shutil # noqa: PLC0415
1861
+ out = Path(out)
1862
+ if out.exists():
1863
+ shutil.rmtree(out)
1864
+ out.mkdir(parents=True)
1865
+ dirs = [Path(d) for d in dist_dirs]
1866
+ entry = Path(entry_dist) if entry_dist is not None else None
1867
+ ordered = [d for d in dirs if d != entry] + ([entry] if entry in dirs else []) # entry LAST -> its CSVs win
1868
+ # ANY root "*Patch.txt" is an ADDITIVE Memoria patch read per-folder (DictionaryPatch / BattlePatch /
1869
+ # TextPatch / ForkDonorPatch -- the fork-fidelity donor map for Dante's off-mesh exemption etc.) -> they MUST
1870
+ # be CONCATENATED, not last-wins-copied, or every non-entry campaign's directives are lost. The pattern auto-
1871
+ # covers any future *Patch.txt, so the merge can't silently drop one (the bug ForkDonorPatch first exposed).
1872
+ def _is_patch(name: str) -> bool:
1873
+ return name.endswith("Patch.txt")
1874
+ parts: dict = {} # patch filename -> [chunks]
1875
+ for d in ordered:
1876
+ if not d.is_dir():
1877
+ continue
1878
+ for item in sorted(d.iterdir()): # EVERY top-level item:
1879
+ if item.is_file() and _is_patch(item.name): # *Patch.txt -> concatenate
1880
+ txt = item.read_text(encoding="utf-8").rstrip("\n")
1881
+ if txt.strip():
1882
+ parts.setdefault(item.name, []).append(txt)
1883
+ continue
1884
+ if item.name == "ModDescription.xml": # written once below
1885
+ continue
1886
+ dst = out / item.name # StreamingAssets/, FF9_Data/
1887
+ if item.is_dir(): # (<mesid>.mes dialogue!),
1888
+ shutil.copytree(item, dst, dirs_exist_ok=True) # disjoint assets union; CSVs -> entry (last) wins
1889
+ else:
1890
+ shutil.copyfile(item, dst) # any other root file
1891
+ for name, chunks in parts.items():
1892
+ (out / name).write_text("\n".join(chunks) + "\n", encoding="utf-8", newline="\n")
1893
+ (out / "ModDescription.xml").write_text(
1894
+ f"<Mod>\n <Name>{folder_name}</Name>\n <Author></Author>\n"
1895
+ f" <InstallationPath>{folder_name}</InstallationPath>\n <Category></Category>\n"
1896
+ f" <Description>Merged journey (single folder) by ff9mapkit</Description>\n</Mod>\n",
1897
+ encoding="utf-8", newline="\n")
1898
+ n_fields = sum(1 for line in "\n".join(parts.get("DictionaryPatch.txt", [])).splitlines()
1899
+ if line.strip().startswith("FieldScene"))
1900
+ return {"dist": str(out), "dists_merged": len(dirs), "fields": n_fields,
1901
+ "patch_files": sorted(parts),
1902
+ "battle_lines": sum(c.count("Battle:") for c in parts.get("BattlePatch.txt", []))}