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/refarc.py ADDED
@@ -0,0 +1,825 @@
1
+ """FF9 reference-arc scaffold -- the north-star planning + fork-and-test harness.
2
+
3
+ A "reference arc" lays out a span of FF9's REAL story as a chain of forkable arcs: each arc is one campaign
4
+ you produce with ``import-chain <seed> --verbatim``, and the arcs chain together as a multi-campaign JOURNEY
5
+ (:mod:`.journey`). This module reads the curated arc->seed table (``data/reference_arcs.toml`` -- the disc-1
6
+ spine, drafted from the field manifest + the in-game-proven import-chain seeds; EDIT to taste) and renders:
7
+
8
+ * a ``journeys.toml`` laying the arcs out as a multi-campaign journey (campaigns / entry / links / seed), and
9
+ * a fork PLAYBOOK -- one ``import-chain`` command per arc, with a disjoint id band + a unique FBG/EVT
10
+ name-prefix each -- so you fork each arc, fill the entry/links from the forked member names, deploy the
11
+ journey, and fidelity-test the seams incrementally toward "fork a real field -> does it play identically?".
12
+
13
+ It is NOT a one-click rebuild of FF9 (the world map is unmoddable + the narrative layer is the weak axis --
14
+ docs/FORK_FIDELITY.md): it's a PLAN you execute arc-by-arc. Pure + tk-free (mirrors :mod:`.journey` /
15
+ :mod:`.hub`) -- unit-testable with no game install.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ import tomllib
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+
24
+ from . import flags as _flags
25
+
26
+ _DATA = Path(__file__).resolve().parent / "data" / "reference_arcs.toml"
27
+
28
+ # Each arc forks into its own disjoint id band so the campaigns never collide in the GLOBAL EventDB namespace
29
+ # (the sec 8 id-disjointness guarantee the journey assembler lints). A --whole-zone fork can be large -- the
30
+ # biggest FF9 zone is Lindblum (ldbm = 124 forkable fields once shared-FBG-folder fields are counted) -- so the
31
+ # band must clear that; 200 ids/arc covers every zone with margin. 12 arcs x 200 = 6000..8400, inside the
32
+ # shipped-custom range (4000-9899, CLAUDE.md sec 3).
33
+ DEFAULT_ID_BASE = 6000
34
+ ARC_ID_SPAN = 200
35
+
36
+ # The journey assembler lays every campaign's GLOB flag window end-to-end inside ONE safe band (8512..16320 =
37
+ # 7808 bits). At import-chain's defaults (25 members x 64 flags/field) a 12-arc chain needs 19200 bits and
38
+ # OVERFLOWS -> the deploy lint hard-errors. So the fork playbook emits a SMALLER `--flags-per-field` sized so
39
+ # all arcs fit; arcs keep their full member count (the lever is the per-field reservation, not --max-fields).
40
+ SAFE_FLAG_BUDGET = _flags.CHOICE_SCRATCH_FLOOR - _flags.FIRST_SAFE_FLAG # bits the journey band has for campaigns
41
+ # 40 is an AVERAGE-arc estimate for sizing the flag budget (n_arcs * 40); the chosen flags-per-field is then
42
+ # conservative and the deploy lint checks the real TOTAL (the true backstop), not per-arc. A single arc CAN
43
+ # exceed 40 -- a split-visit catalog region tops out at 48 (l_castle's disc-1 visit), and a whole-zone region
44
+ # without `members` can be far bigger (Lindblum's full 124) -- but that only makes the per-arc estimate an
45
+ # UNDERSHOOT, which the TOTAL-budget lint still catches. NB: splitting zones into visits REDUCED the worst-case
46
+ # per-arc count (124 -> 48), so it shrank this gap rather than widening it.
47
+ MAX_FIELDS_PER_ARC = 40
48
+
49
+ # Default hub backdrop = MOGNET CENTRAL (real field 3100, FBG fbg_n56_mgnt_map810_mn_mog_0): FF9's Moogle
50
+ # journey nexus -- the thematic home for a journey selector (it's the room the project's World Hub borrows),
51
+ # and supplying the real `borrow_field` lets `deploy_journey --apply` auto-extract the hub camera. Override
52
+ # `borrow_bg`/`area`/`borrow_field` to theme the hub on any other real room (`ff9mapkit list-fields`).
53
+ HUB_BORROW_BG = "MGNT_MAP810_MN_MOG_0"
54
+ HUB_BORROW_AREA = 56
55
+ HUB_BORROW_FIELD = 3100
56
+
57
+
58
+ class RefArcError(ValueError):
59
+ """A malformed reference-arc table (missing key, duplicate key, no arcs)."""
60
+
61
+
62
+ @dataclass
63
+ class ReferenceArc:
64
+ key: str # the arc slug = the campaign FOLDER name = the --out dir = the journey member
65
+ name: str # the human label (the journey-menu / overview name)
66
+ seed: int # the REAL FF9 field id to import-chain from
67
+ zone: "str | None" = None # optional FBG token for `import-chain --zones` (default: the seed's own zone)
68
+ beat: "int | None" = None # optional ScenarioCounter to seed this arc's story state on entry
69
+ members: "list | None" = None # explicit field ids to fork (ONE story-state cluster) -> import-chain --ids;
70
+ # None = fork the whole zone (--whole-zone). The generated catalog sets this.
71
+ note: str = ""
72
+
73
+
74
+ @dataclass
75
+ class ReferenceArcSet:
76
+ title: str
77
+ arcs: list = field(default_factory=list) # list[ReferenceArc], in story order
78
+
79
+
80
+ # --------------------------------------------------------------------------- load
81
+ def load_reference_arcs(path=None) -> ReferenceArcSet:
82
+ """Parse a reference-arc table (default = the packaged disc-1 ``data/reference_arcs.toml``). Raises
83
+ :class:`RefArcError` on a missing/duplicate ``key`` or an empty table."""
84
+ p = Path(path) if path else _DATA
85
+ with open(p, "rb") as fh:
86
+ data = tomllib.load(fh)
87
+ arcs: list = []
88
+ seen: set = set()
89
+ for i, a in enumerate(data.get("arc", [])):
90
+ for req in ("key", "name", "seed"):
91
+ if req not in a:
92
+ raise RefArcError(f"[[arc]] #{i}: missing required key {req!r}")
93
+ key = str(a["key"]).strip()
94
+ if not key:
95
+ raise RefArcError(f"[[arc]] #{i}: empty 'key'")
96
+ if key in seen:
97
+ raise RefArcError(f"duplicate arc key {key!r} (each arc = a distinct campaign folder)")
98
+ seen.add(key)
99
+ members = None
100
+ if a.get("members") not in (None, "", []):
101
+ from . import chain
102
+ try:
103
+ members = chain.parse_id_ranges(a["members"]) or None
104
+ except (ValueError, TypeError) as e:
105
+ raise RefArcError(f"[[arc]] {key!r}: bad 'members' {a['members']!r} ({e})")
106
+ arcs.append(ReferenceArc(
107
+ key=key, name=str(a["name"]), seed=int(a["seed"]),
108
+ zone=(str(a["zone"]).strip() or None) if a.get("zone") else None,
109
+ beat=(int(a["beat"]) if a.get("beat") is not None else None),
110
+ members=members,
111
+ note=str(a.get("note", ""))))
112
+ if not arcs:
113
+ raise RefArcError(f"{p}: no [[arc]] rows")
114
+ return ReferenceArcSet(title=str(data.get("title") or "FF9 reference arc"), arcs=arcs)
115
+
116
+
117
+ # --------------------------------------------------------------------------- the GENERATED region catalog
118
+ # The hand-curated `data/reference_arcs.toml` above is the disc-1 JOURNEY SPINE (12 arcs, story-ordered, for
119
+ # `reference-arcs --emit` -> a chained journeys.toml). The PICKER ("Browse FF9 regions" / "Add region to arc")
120
+ # instead wants EVERY forkable zone, accurately. `generate_zone_catalog` derives that straight from the game's
121
+ # real field->zone data (extract.ID_TO_FBG), so it can't repeat the hand-draft's wrong/coarse seeds (e.g. the
122
+ # opening seeded Alexandria-100 instead of the Prima Vista). It writes a USER-LOCAL `reference/region-catalog.toml`
123
+ # (gitignored -- the friendly names come from the SE-derived manifest), which `load_region_catalog` prefers.
124
+ _REGION_DATA = Path(__file__).resolve().parent / "data" / "region_catalog.toml" # the generated all-zones catalog
125
+
126
+
127
+ def load_region_catalog() -> ReferenceArcSet:
128
+ """The catalog the region PICKER reads: the GENERATED all-zones ``data/region_catalog.toml`` if present
129
+ (accurate, derived from the game's real zones), else the hand-curated disc-1 spine (``load_reference_arcs``)
130
+ -- so a fresh checkout still works and `--regen` upgrades it. (The CLI ``reference-arcs --emit`` journey
131
+ spine keeps reading ``load_reference_arcs`` -- a separate, curated, story-ordered list.)"""
132
+ return load_reference_arcs(_REGION_DATA) if _REGION_DATA.is_file() else load_reference_arcs()
133
+
134
+
135
+ def _slug(s: str) -> str:
136
+ """A folder-safe key from a human label: 'Prima Vista' -> 'prima_vista'; '' -> ''."""
137
+ out = "".join(c if c.isalnum() else "_" for c in str(s).lower())
138
+ return "_".join(p for p in out.split("_") if p)
139
+
140
+
141
+ def _zone_area_label(fids, names) -> "str | None":
142
+ """The most common area label (the part before '/' in the manifest name) across a zone's fields --
143
+ 'Prima Vista/Cargo Room' -> 'Prima Vista'. None if the manifest isn't present."""
144
+ from collections import Counter
145
+ areas: Counter = Counter()
146
+ for f in fids:
147
+ nm = names.get(f)
148
+ if nm:
149
+ areas[nm.split("/")[0].strip()] += 1
150
+ return areas.most_common(1)[0][0] if areas else None
151
+
152
+
153
+ def generate_zone_catalog(pattern=None, *, split_visits=True, gap=None) -> ReferenceArcSet:
154
+ """Derive a region catalog from the game's REAL zones: group every forkable field by its FBG zone token
155
+ (``extract.ID_TO_FBG`` + ``chain.zone_label``), then -- because FF9 stores a place's story states as
156
+ SEPARATE id clusters sharing one zone (Alexandria town = 100-117 opening, 1850-1865 return, ...) -- split
157
+ each zone into id-gap clusters and emit one [[arc]] per cluster (``split_visits``; pass False for one arc
158
+ per whole zone). Each arc carries explicit ``members`` (the cluster's ids) so its fork scopes to that ONE
159
+ visit via ``import-chain --ids`` instead of grabbing all 48 revisit screens -- the fork-time win. The first
160
+ (lowest-id) cluster keeps the clean name + key (the disc-1 visit you fork for an opening journey); later
161
+ visits get an ``(ids N+)`` suffix + a ``_N`` key. Seed = a cluster ENTrance field (so Evil Forest picks
162
+ ef_ent 250, not the 152 cutscene) else the cluster's lowest id (the New-Game room for door-less zones).
163
+ Names come from the user-local HW manifest (else the zone token). ``pattern`` filters by zone token or area
164
+ label. Ordered by lowest field id (~ disc / story order). Accurate by construction -- no hand-drafted seeds."""
165
+ from . import extract, chain
166
+ names = extract._manifest_field_names()
167
+ zones: dict = {}
168
+ for fid, fbg in extract.ID_TO_FBG.items():
169
+ if not str(fbg).lower().startswith("fbg_n"): # skip non-background fields (field 70 = the FMV opening
170
+ continue # script, 'invalidfieldmapid') -- not a forkable zone
171
+ z = chain.zone_label(fbg)
172
+ if z and z != "?":
173
+ zones.setdefault(z, []).append(int(fid))
174
+ pat = pattern.lower().strip() if pattern else None
175
+ cgap = gap if gap is not None else chain.DEFAULT_CLUSTER_GAP
176
+ arcs, used_keys = [], set()
177
+ for z in sorted(zones, key=lambda zz: min(zones[zz])):
178
+ fids = sorted(zones[z])
179
+ area = _zone_area_label(fids, names)
180
+ if pat and pat not in z.lower() and pat not in (area or "").lower():
181
+ continue
182
+ name0 = area or z.upper()
183
+ base_key, n = _slug(area) or z, 2 # readable folder key, deduped vs other zones
184
+ while base_key in used_keys:
185
+ base_key = f"{(_slug(area) or z)}_{z}" if n == 2 else f"{(_slug(area) or z)}_{n}"
186
+ n += 1
187
+ used_keys.add(base_key)
188
+ clusters = chain.id_clusters(fids, cgap) if split_visits else [fids]
189
+ for ci, cl in enumerate(clusters):
190
+ cfids = sorted(cl)
191
+ ent = [f for f in cfids if "ENT" in extract.ID_TO_FBG[f].upper()] # an explicit ENTrance field wins
192
+ seed = ent[0] if ent else cfids[0] # else the cluster's lowest id
193
+ if ci == 0:
194
+ key, name = base_key, name0
195
+ else:
196
+ key, name = f"{base_key}_{seed}", f"{name0} (ids {seed}+)" # later visit -> suffixed
197
+ while key in used_keys:
198
+ key += "_x"
199
+ used_keys.add(key)
200
+ visit = f", visit {ci + 1}/{len(clusters)}" if len(clusters) > 1 else ""
201
+ note = (f"{len(cfids)} fields ({cfids[0]}..{cfids[-1]}); zone '{z}'{visit}; "
202
+ f"seed = {'an ENTrance field' if ent else 'the lowest id'} -- "
203
+ f"{'verify the beat / ' if ci else ''}verify if it mis-lands")
204
+ members = cfids if split_visits else None # no-split = the old whole-zone behavior (dynamic re-gather)
205
+ arcs.append(ReferenceArc(key=key, name=name, seed=seed, zone=z, members=members, note=note))
206
+ if not arcs:
207
+ raise RefArcError("no zones found (need extract.ID_TO_FBG; pattern matched nothing?)")
208
+ # distinct zones can share one manifest area label (Dali = vgdl/udft/airp; Prima Vista = tshp/bshp) -> the
209
+ # picker would show identical rows. Suffix the zone token onto any name shared across DIFFERENT zones.
210
+ name_zones: dict = {}
211
+ for a in arcs:
212
+ name_zones.setdefault(a.name, set()).add(a.zone)
213
+ for a in arcs:
214
+ if len(name_zones[a.name]) > 1:
215
+ a.name = f"{a.name} [{a.zone}]"
216
+ # Order by the visit's ENTRY seed = game-chronological order: FF9 assigned field ids in rough story/disc
217
+ # order (disc-1 zones 50-700, mid-game 1000-2500, endings 3000+), so this scatters a place's revisits to
218
+ # their real story positions (Alexandria opening 100, return 1850, ending 3000) instead of bunching every
219
+ # visit of one place together -- the right order for forking an arc in sequence. (A proxy, not a curated
220
+ # chronology; the toml is editable to nudge any region that lands out of order.)
221
+ arcs.sort(key=lambda a: a.seed)
222
+ return ReferenceArcSet(title="FF9 -- all forkable zones, split by story-state visit (generated from the game)",
223
+ arcs=arcs)
224
+
225
+
226
+ def render_arc_table_toml(arcset: ReferenceArcSet) -> str:
227
+ """Render a :class:`ReferenceArcSet` as a ``[[arc]]`` table (the inverse of :func:`load_reference_arcs`) --
228
+ used to write the generated ``region-catalog.toml`` the picker reads."""
229
+ from . import chain
230
+ L = ["# FF9 REGION CATALOG -- GENERATED from the game's real zones (one [[arc]] = one forkable FBG zone, SPLIT",
231
+ "# by story-state visit: a place's revisits are separate id clusters sharing one zone, so Alexandria",
232
+ "# opening (100-117), return (1850+) and ruined (2450+) are distinct arcs). `members` scopes each fork",
233
+ "# to that ONE visit (import-chain --ids) instead of all the revisit screens -- the fork-time win.",
234
+ "# Regenerate after a game change: `py -m ff9mapkit reference-arcs --regen`. EDIT FREELY -- correct a",
235
+ "# `seed` that mis-lands, merge/split `members`, rename, or add `beat = <ScenarioCounter>` for an arc's state.",
236
+ "",
237
+ f'title = "{_toml_str(arcset.title)}"',
238
+ ""]
239
+ for a in arcset.arcs:
240
+ L.append("[[arc]]")
241
+ L.append(f'key = "{_toml_str(a.key)}"')
242
+ L.append(f'name = "{_toml_str(a.name)}"')
243
+ L.append(f"seed = {int(a.seed)}")
244
+ if a.zone:
245
+ L.append(f'zone = "{_toml_str(a.zone)}"')
246
+ if a.members:
247
+ L.append(f'members = "{chain.format_id_ranges(a.members)}" # import-chain --ids (this visit only)')
248
+ if a.beat is not None:
249
+ L.append(f"beat = {int(a.beat)}")
250
+ if a.note:
251
+ L.append(f'note = "{_toml_str(a.note)}"')
252
+ L.append("")
253
+ return "\n".join(L) + "\n"
254
+
255
+
256
+ def regenerate_region_catalog(out=None, pattern=None, *, split_visits=True, gap=None) -> "tuple[Path, int]":
257
+ """Generate the zone catalog + write it to ``out`` (default the shipped :data:`_REGION_DATA`).
258
+ Returns ``(path, n_regions)``. ``split_visits`` (default) emits one arc per story-state cluster (the
259
+ fork-time win); ``False`` = one arc per whole zone. ``gap`` overrides the cluster id-gap threshold. The
260
+ picker then reads the file via :func:`load_region_catalog`."""
261
+ arcset = generate_zone_catalog(pattern=pattern, split_visits=split_visits, gap=gap)
262
+ p = Path(out) if out else _REGION_DATA
263
+ p.parent.mkdir(parents=True, exist_ok=True)
264
+ p.write_text(render_arc_table_toml(arcset), encoding="utf-8", newline="\n")
265
+ return p, len(arcset.arcs)
266
+
267
+
268
+ # --------------------------------------------------------------------------- per-arc fork parameters
269
+ def arc_id_base(index: int, *, base: int = DEFAULT_ID_BASE, span: int = ARC_ID_SPAN) -> int:
270
+ """The disjoint ``--id-base`` for arc ``index`` (0-based) so no two arcs share an EventDB id band."""
271
+ return base + index * span
272
+
273
+
274
+ def arc_name_prefixes(arcset: ReferenceArcSet) -> dict:
275
+ """A unique short FBG/EVT ``--name-prefix`` per arc (so two arcs forking related fields don't collide on
276
+ the by-name, highest-folder-wins scene/.eb resolution). Derived from the key, de-duplicated."""
277
+ out: dict = {}
278
+ used: set = set()
279
+ for arc in arcset.arcs:
280
+ base = "".join(c for c in arc.key if c.isalnum()).upper()[:4] or "ARC"
281
+ tag, n = base, 1
282
+ while tag in used:
283
+ n += 1
284
+ tag = f"{base[:3]}{n}"
285
+ used.add(tag)
286
+ out[arc.key] = tag
287
+ return out
288
+
289
+
290
+ def compose_region_fork(arcset: ReferenceArcSet, selected_keys) -> "tuple[str, str, int]":
291
+ """Compose one or more catalog regions into a SINGLE region-fork spec for ``import-chain`` (the GUI's
292
+ "Fork FF9 regions" catalog) -- returns ``(seeds, name_prefix, n_regions)``.
293
+
294
+ ``seeds`` = the regions' ``seed`` fields comma-joined IN CATALOG ORDER. One key = fork that region alone;
295
+ several = compose their seeds into ONE campaign. ``name_prefix`` = the region's unique tag for a single
296
+ pick, else "" (the author names a composed campaign). The fork SCOPE (which fields) comes from
297
+ :func:`compose_region_ids` (each region's ``members`` -> ``--ids``); an arc's optional ``zone``/``beat``
298
+ overrides are NOT applied here -- use the CLI fork playbook for a custom ``--zones``, and add a
299
+ ``[startup]`` beat in the editor after forking."""
300
+ keys = set(selected_keys)
301
+ sel = [a for a in arcset.arcs if a.key in keys]
302
+ if not sel:
303
+ raise RefArcError("select at least one region to fork")
304
+ seeds = ",".join(str(a.seed) for a in sel)
305
+ prefix = arc_name_prefixes(arcset)[sel[0].key] if len(sel) == 1 else ""
306
+ return seeds, prefix, len(sel)
307
+
308
+
309
+ def compose_region_ids(arcset: ReferenceArcSet, selected_keys) -> "str | None":
310
+ """The ``import-chain --ids`` member set for the selected regions (the union of each region's ``members``,
311
+ as a compact range string) -- so the Import-tab fork scopes to those story-state visits. ``None`` if ANY
312
+ selected region has no ``members`` (a hand-written whole-zone region) -> the caller falls back to
313
+ ``--whole-zone`` rather than fork a partial set."""
314
+ from . import chain
315
+ keys = set(selected_keys)
316
+ sel = [a for a in arcset.arcs if a.key in keys]
317
+ if not sel or any(not a.members for a in sel):
318
+ return None
319
+ union: set = set()
320
+ for a in sel:
321
+ union.update(a.members)
322
+ return chain.format_id_ranges(union) or None
323
+
324
+
325
+ def arc_mod_folder(tag: str) -> str:
326
+ """The stacked Memoria mod folder a forked arc deploys into. Each arc needs its OWN folder -- the journey
327
+ assembler ABORTS if two campaigns share a ``mod_folder`` (it wholesale-replaces a folder per campaign).
328
+ Derived from the arc's unique ``tag`` so the 12 folders are disjoint."""
329
+ return f"FF9CustomMap-{tag.lower()}"
330
+
331
+
332
+ def arc_flags_per_field(n_arcs: int, *, max_fields: int = MAX_FIELDS_PER_ARC, budget: int = SAFE_FLAG_BUDGET) -> int:
333
+ """A per-field GLOB flag-block width small enough that ALL ``n_arcs`` arcs' flag windows fit the safe band
334
+ (the assembler lays them end-to-end; the default 64 overflows past ~2 arcs). The largest power-of-two in
335
+ [8, 64] that fits ``n_arcs * max_fields * fpf <= budget``; floors at 8 for a very long table (the header
336
+ note warns + the deploy lint still catches a true overflow)."""
337
+ for fpf in (64, 32, 16, 8):
338
+ if max(n_arcs, 1) * max_fields * fpf <= budget:
339
+ return fpf
340
+ return 8
341
+
342
+
343
+ def fork_command(arc: ReferenceArc, *, id_base: int, tag: str, flags_per_field: int, verbatim: bool = True) -> str:
344
+ """The ``ff9mapkit import-chain`` line that forks ONE arc into its own campaign folder: ``--out <key>``, a
345
+ disjoint ``--id-base`` (EventDB id band), a unique ``--name-prefix`` (by-name FBG/.eb resolution across the
346
+ stacked folders), a unique ``--mod-folder`` (the assembler needs one folder per campaign), a
347
+ ``--flags-per-field`` sized so the chain's flag windows fit the safe band, and ``--verbatim`` for the
348
+ truest fork. ``tag`` is the arc's unique short slug driving the prefix + folder."""
349
+ from . import chain
350
+ parts = [f"py -m ff9mapkit import-chain {arc.seed}", f"--out {arc.key}"]
351
+ if arc.zone:
352
+ parts.append(f"--zones {arc.zone}")
353
+ if arc.members: # scope to ONE story-state cluster (explicit ids) -> lean + fast
354
+ parts.append(f"--ids {chain.format_id_ranges(arc.members)}")
355
+ else: # no cluster -> fork the WHOLE zone, not just the door-reachable
356
+ parts.append("--whole-zone") # slice (cutscene zones don't door-connect -- the bytes are there)
357
+ if verbatim:
358
+ parts.append("--verbatim")
359
+ parts.append(f"--id-base {id_base}")
360
+ parts.append(f"--name-prefix {tag}")
361
+ parts.append(f"--mod-folder {arc_mod_folder(tag)}")
362
+ parts.append(f"--flags-per-field {flags_per_field}")
363
+ return " ".join(parts)
364
+
365
+
366
+ def fork_playbook(arcset: ReferenceArcSet, *, id_base: int = DEFAULT_ID_BASE) -> list:
367
+ """``[(arc, command), ...]`` -- the ordered ``import-chain`` commands that fork every arc into its OWN
368
+ id band + name-prefix + mod folder + a chain-sized flag budget (so the chain deploys with no EventDB /
369
+ by-name / folder / flag-window collisions). Run them from the folder that holds the journeys.toml so each
370
+ ``--out <key>`` lands beside it."""
371
+ tags = arc_name_prefixes(arcset)
372
+ fpf = arc_flags_per_field(len(arcset.arcs))
373
+ return [(arc, fork_command(arc, id_base=arc_id_base(i, base=id_base), tag=tags[arc.key], flags_per_field=fpf))
374
+ for i, arc in enumerate(arcset.arcs)]
375
+
376
+
377
+ # --------------------------------------------------------------------------- render the journeys.toml
378
+ def _commented_block(lines: list) -> list:
379
+ return [("# " + ln).rstrip() for ln in lines]
380
+
381
+
382
+ def render_arc_journey_toml(arcset: ReferenceArcSet, *, hub_name: str = "FF9 Disc 1", hub_id: int = 4600,
383
+ borrow_bg: "str | None" = None, hub_area: "int | None" = None,
384
+ borrow_field: "int | None" = None, journey_id: str = "ff9_disc1",
385
+ journey_name: "str | None" = None, id_base: int = DEFAULT_ID_BASE) -> str:
386
+ """Render a multi-campaign ``journeys.toml`` laying the arcs out as one chained journey, with the fork
387
+ PLAYBOOK in the header and the entry/links/seed left as fill-in templates (the member names come from the
388
+ forked campaigns). The hub defaults to MOGNET CENTRAL (field 3100 -- FF9's journey nexus + a real
389
+ ``borrow_field`` so ``deploy_journey --apply`` auto-extracts the camera); pass ``borrow_bg`` to theme it on
390
+ another room (``hub_area``/``borrow_field`` then describe that room, else only the bg is emitted). Always
391
+ loads structurally; the not-yet-forked campaign folders surface as a 'fork first' note -- onboarding."""
392
+ if borrow_bg is None: # default the hub to Mognet Central (thematic + --apply-ready)
393
+ borrow_bg, hub_area, borrow_field = HUB_BORROW_BG, HUB_BORROW_AREA, HUB_BORROW_FIELD
394
+ journey_name = journey_name or arcset.title
395
+ plays = fork_playbook(arcset, id_base=id_base)
396
+ keys = [a.key for a in arcset.arcs]
397
+ fpf = arc_flags_per_field(len(arcset.arcs))
398
+ n_arcs = len(keys)
399
+ arc_s = "" if n_arcs == 1 else "s" # keep the count comments grammatical for a 1-arc start
400
+ n_links = max(n_arcs - 1, 0)
401
+ link_s = "" if n_links == 1 else "s"
402
+
403
+ L: list = []
404
+ L += _commented_block([
405
+ f"{arcset.title} -- an FF9 reference arc (the north-star fork-and-test harness).",
406
+ "",
407
+ "Each arc below is ONE campaign you fork from a REAL FF9 field, chained as a multi-campaign journey.",
408
+ "This is a PLAN, not a one-click rebuild -- fork an arc, fill its entry/links, deploy, walk it, ask",
409
+ '"does it play identically?", then move to the next arc (CLAUDE.md: fork FIDELITY, not a release).',
410
+ "",
411
+ "STEP 1 -- fork every arc (run these FROM this folder, so each --out <key> lands beside this file).",
412
+ f" Each gets its OWN id band + name-prefix + mod folder + a {fpf}-bit flag block, so each arc",
413
+ " deploys with no EventDB / by-name / folder / flag-window collisions (don't drop those flags):",
414
+ ])
415
+ for i, (arc, cmd) in enumerate(plays, 1):
416
+ L.append(f"# {i:>2}. {cmd}")
417
+ if arc.note:
418
+ L.append(f"# -- {arc.name}: {arc.note}")
419
+ L += _commented_block([
420
+ "",
421
+ "STEP 2 -- in each forked campaign.toml, note its ENTRY member name + the BOUNDARY member that exits",
422
+ " to the next arc; fill them into `entry` and the `[[journey.link]]` rows below.",
423
+ "STEP 3 -- deploy + playtest: Build & Deploy -> (this journeys.toml) -> Preview playbook, then Deploy.",
424
+ " Or: py tools/deploy_journey.py journeys.toml --apply (one-shot, reverse-order revert).",
425
+ " The one-shot AUTO-EXTRACTS the hub camera, which needs a real source field: set [hub]",
426
+ " borrow_field = <real field id> below, or supply [hub] camera = \"<your>.bgx\" yourself.",
427
+ ])
428
+ L.append("")
429
+
430
+ from . import hub as _hub
431
+ L.append("[hub]")
432
+ L.append(f'name = "{_hub.name_token(hub_name)}" # an EVT_/FBG_ token (no spaces -- the field name)')
433
+ L.append(f"id = {int(hub_id)} # the hub field id (custom band, >= 4000; NOT in an arc band)")
434
+ if hub_area is not None:
435
+ L.append(f"area = {int(hub_area)} # the borrowed room's FBG area (FBG_N<area>_...)")
436
+ else: # custom borrow_bg with no area -> the default 21 is likely wrong
437
+ L.append("# area = 21 # SET ME: must equal the borrowed room's real FBG area (the default 21 is "
438
+ "usually WRONG for a custom room -> black screen)")
439
+ L.append(f'borrow_bg = "{_toml_str(borrow_bg)}" # a real field whose art the hub reuses (`list-fields`)')
440
+ if borrow_field is not None:
441
+ L.append(f"borrow_field = {int(borrow_field)} # the real field -> `deploy_journey --apply` "
442
+ "auto-extracts its camera")
443
+ else:
444
+ L.append("# borrow_field = <real field id> # uncomment so `deploy_journey --apply` auto-extracts the camera")
445
+ L.append("")
446
+
447
+ L.append("[[journey]]")
448
+ L.append(f'id = "{_toml_str(journey_id)}" # the stable journey slug')
449
+ L.append(f'name = "{_toml_str(journey_name)}" # shown on the hub menu')
450
+ clist = ", ".join(f'"{_toml_str(k)}"' for k in keys)
451
+ L.append(f"campaigns = [{clist}]")
452
+ L.append(f'# ^ the {n_arcs} arc folder{arc_s} (fork them in STEP 1 above; order = story order).')
453
+ first = arcset.arcs[0]
454
+ L.append(f'entry = {{ campaign = "{_toml_str(first.key)}", field = "ENTRY_MEMBER" }}'
455
+ f" # CHANGE: the start member of {first.name} (real field {first.seed})")
456
+ L.append("")
457
+
458
+ L += _commented_block([
459
+ f"One link per arc boundary ({n_arcs} arc{arc_s} -> {n_links} link{link_s}). Uncomment + fill the",
460
+ "member names (the boundary member that walks OUT of arc i, and the arrival member of arc i+1):",
461
+ ])
462
+ for a, b in zip(arcset.arcs, arcset.arcs[1:]):
463
+ L.append("# [[journey.link]]")
464
+ L.append(f'# from = {{ campaign = "{_toml_str(a.key)}", field = "BOUNDARY_MEMBER" }}'
465
+ f" # {a.name} (real {a.seed})")
466
+ L.append(f'# to = {{ campaign = "{_toml_str(b.key)}", field = "ARRIVAL_MEMBER", entrance = 0 }}'
467
+ f" # {b.name} (real {b.seed})")
468
+ L.append("")
469
+
470
+ L += _commented_block([
471
+ "The New-Game starting state for this journey (the story_flags capstone). Uncomment + edit:",
472
+ ])
473
+ if first.beat is not None:
474
+ L.append("[journey.seed]")
475
+ L.append(f"scenario = {int(first.beat)} # {first.name}'s opening beat")
476
+ else:
477
+ L.append("# [journey.seed]")
478
+ L.append(f"# scenario = 0 # the ScenarioCounter for {first.name}'s start (your game knowledge)")
479
+ L.append('# party = ["Zidane"]')
480
+ return "\n".join(L) + "\n"
481
+
482
+
483
+ def _toml_str(s) -> str:
484
+ """Escape a value for a double-quoted TOML string (backslash + quote)."""
485
+ return str(s).replace("\\", "\\\\").replace('"', '\\"')
486
+
487
+
488
+ # --------------------------------------------------------------------------- parse the playbook back out
489
+ @dataclass
490
+ class ParsedFork:
491
+ key: str # the arc folder (the --out value) = the journeys.toml campaign name
492
+ seed: int # the real field id (the import-chain seed)
493
+ command: str # the canonical `import-chain <seed> --out <key> ...` (no launcher prefix)
494
+
495
+
496
+ _FORK_RE = re.compile(r"(import-chain\s+(\d+)\b.*?--out\s+(\S+).*?)\s*$")
497
+
498
+
499
+ def parse_fork_commands(text: str) -> list:
500
+ """Recover the fork PLAYBOOK from a journeys.toml's header comments: every commented
501
+ ``# .. py -m ff9mapkit import-chain <seed> ... --out <key> ...`` line -> a :class:`ParsedFork` (in file
502
+ order, de-duplicated by key). Returns ``[]`` for a file with no playbook (a hand-written journey). The
503
+ GUI uses this to offer a per-arc Fork button; ``command`` is the launcher-free tail (run it via
504
+ ``editor.jobs.fork_command_argv``)."""
505
+ out: list = []
506
+ seen: set = set()
507
+ for raw in text.splitlines():
508
+ s = raw.strip()
509
+ if not s.startswith("#") or "import-chain" not in s:
510
+ continue
511
+ m = _FORK_RE.search(s)
512
+ if not m:
513
+ continue
514
+ key = m.group(3)
515
+ if key in seen:
516
+ continue
517
+ seen.add(key)
518
+ out.append(ParsedFork(key=key, seed=int(m.group(2)), command=m.group(1).strip()))
519
+ return out
520
+
521
+
522
+ # --------------------------------------------------------------------------- STEP 2: reconcile after Fork-All
523
+ # The scaffold ships `entry = {.. field = "ENTRY_MEMBER"}` + COMMENTED `[[journey.link]]` templates. STEP 2 =
524
+ # fill the ENTRY member from the forked entry campaign + strip the (now-pointless) link templates: cross-campaign
525
+ # warps AUTO-WIRE at deploy from the real .eb seams (journey.auto_seam_links), so no link rows are written. We
526
+ # read each forked campaign.toml beside the journeys.toml to fill the entry + preview the connectivity.
527
+ @dataclass
528
+ class ReconcileNote:
529
+ level: str # "filled" (an exact fill) | "verify" (a best-guess that needs a human eyeball) | "skip"
530
+ text: str
531
+
532
+
533
+ def _unreachable_campaigns(campaigns, entry, links) -> list:
534
+ """Campaigns NOT reachable from ``entry`` over the wired links (a BFS) -- a region the real warps don't
535
+ connect (so it's the wrong region, the wrong entry, or it needs an order-only overworld hop). Pure."""
536
+ adj: dict = {c: set() for c in campaigns}
537
+ for lk in links:
538
+ if lk["src_campaign"] in adj and lk["dst_campaign"] in adj:
539
+ adj[lk["src_campaign"]].add(lk["dst_campaign"])
540
+ reached, stack = {entry}, [entry]
541
+ while stack:
542
+ for nxt in adj.get(stack.pop(), ()):
543
+ if nxt not in reached:
544
+ reached.add(nxt)
545
+ stack.append(nxt)
546
+ return [c for c in campaigns if c not in reached]
547
+
548
+
549
+ def _journey_block_range(lines, target_jidx):
550
+ """The [start, end) line span of the ``target_jidx``-th ``[[journey]]`` block (a row runs to the next
551
+ ``[[journey]]`` header or EOF; its ``[[journey.link]]`` / ``[journey.seed]`` subtables belong to it).
552
+ ``(None, None)`` if there's no such block."""
553
+ idxs = [i for i, ln in enumerate(lines) if ln.strip() == "[[journey]]"]
554
+ if target_jidx >= len(idxs):
555
+ return None, None
556
+ start = idxs[target_jidx]
557
+ end = idxs[target_jidx + 1] if target_jidx + 1 < len(idxs) else len(lines)
558
+ return start, end
559
+
560
+
561
+ _ENTRY_FIELD_RE = re.compile(r'field\s*=\s*"([^"]*)"')
562
+ _TMPL_PREFIXES = ("# [[journey.link]]", "# from = {", "# to = {", "# One link per arc boundary",
563
+ "# member names (")
564
+
565
+
566
+ def _is_link_template(stripped: str) -> bool:
567
+ return any(stripped.startswith(p) for p in _TMPL_PREFIXES)
568
+
569
+
570
+ def reconcile_arc_journey(text: str, base_dir) -> "tuple[str, list]":
571
+ """Fill a reference-arc ``journeys.toml``'s ``entry`` placeholder + its commented ``[[journey.link]]`` rows
572
+ from the REAL member names of the forked campaign folders beside it (STEP 2, automated). ``text`` is the
573
+ file content; ``base_dir`` is the folder holding it (where each ``<campaign>/campaign.toml`` was forked).
574
+
575
+ Returns ``(new_text, notes)`` -- ``notes`` is a list of :class:`ReconcileNote`. ``new_text == text`` (with a
576
+ 'skip' note) when there's nothing to do: no multi-campaign journey, the campaigns aren't forked yet, or the
577
+ links are already filled. Targets the FIRST multi-campaign journey (the reference-arc scaffold has exactly
578
+ one; a selector hub of BARE journeys needs no reconcile). Pure + tk-free -- the GUI writes the result so
579
+ the edit is one undo step; a CLI/test can call it headless."""
580
+ from . import campaign as _campaign
581
+ base = Path(base_dir)
582
+ notes: list = []
583
+ try:
584
+ data = tomllib.loads(text)
585
+ except tomllib.TOMLDecodeError as e:
586
+ return text, [ReconcileNote("skip", f"not parseable TOML ({e})")]
587
+
588
+ jrows = data.get("journey", [])
589
+ midx = next((i for i, j in enumerate(jrows) if j.get("campaigns")), None)
590
+ if midx is None:
591
+ return text, [ReconcileNote("skip", "no multi-campaign [[journey]] to reconcile "
592
+ "(bare journeys warp to a field id -- no entry member or links to fill)")]
593
+ if sum(1 for j in jrows if j.get("campaigns")) > 1:
594
+ notes.append(ReconcileNote("verify", "more than one multi-campaign journey -- reconciling only the first"))
595
+ campaigns = [str(c) for c in jrows[midx].get("campaigns", [])]
596
+ if not campaigns:
597
+ return text, [ReconcileNote("skip", "the journey lists no campaigns")]
598
+
599
+ plans: dict = {}
600
+ for k in campaigns:
601
+ cpath = base / k / "campaign.toml"
602
+ if cpath.is_file():
603
+ try:
604
+ plans[k] = _campaign.load_campaign(cpath)
605
+ except Exception as e: # noqa: BLE001 -- a bad campaign.toml -> skip it
606
+ notes.append(ReconcileNote("verify", f"campaign {k!r}: campaign.toml unreadable ({e})"))
607
+ if campaigns[0] not in plans:
608
+ notes.append(ReconcileNote("skip", f"fork the campaigns first (STEP 1) -- {campaigns[0]!r} has no "
609
+ f"campaign.toml at {base / campaigns[0] / 'campaign.toml'}"))
610
+ return text, notes
611
+
612
+ entry_member = plans[campaigns[0]].entry_name # the entry arc's REAL start member (exact)
613
+ unforked = sorted({c for c in campaigns if c not in plans})
614
+ # Fill the link rows ATOMICALLY -- only once EVERY campaign is forked (the connectivity graph needs all their
615
+ # .eb seams). A PARTIAL fill would strip the still-commented templates + a re-run would bail (has_real_link),
616
+ # so for the incremental fork-by-arc workflow we keep ALL templates until the chain is complete, then wire it
617
+ # in one pass. (Entry only needs the first campaign, so it fills early regardless.)
618
+ links_complete = not unforked
619
+ derived, stranded = [], []
620
+ if links_complete:
621
+ from . import journey as _journey
622
+ derived = _journey.auto_seam_links(campaigns, plans) # what the DEPLOY will auto-wire (preview only)
623
+ stranded = _unreachable_campaigns(campaigns, campaigns[0], derived)
624
+
625
+ # ---- text surgery on the target journey block (leave everything else, incl. the header playbook, intact)
626
+ lines = text.split("\n")
627
+ start, end = _journey_block_range(lines, midx)
628
+ if start is None:
629
+ notes.append(ReconcileNote("skip", "couldn't locate the [[journey]] block (file hand-edited?)"))
630
+ return text, notes
631
+ block = lines[start:end]
632
+
633
+ # entry: replace ONLY the placeholder (respect a real member a human already set)
634
+ changed = False
635
+ for i, ln in enumerate(block):
636
+ if ln.strip().startswith("entry ="):
637
+ m = _ENTRY_FIELD_RE.search(ln)
638
+ cur_field = m.group(1) if m else None
639
+ if cur_field == "ENTRY_MEMBER" or cur_field is None:
640
+ block[i] = f'entry = {{ campaign = "{_toml_str(campaigns[0])}", field = "{_toml_str(entry_member)}" }}'
641
+ notes.append(ReconcileNote("filled", f"entry -> {campaigns[0]}/{entry_member}"))
642
+ changed = True
643
+ else:
644
+ notes.append(ReconcileNote("skip", f"entry already set to {cur_field!r} -- left as-is"))
645
+ break
646
+
647
+ # LINKS auto-wire at DEPLOY from the real .eb connectivity (journey.auto_seam_links) -- so reconcile writes NO
648
+ # link rows. It strips the now-pointless commented templates + reports the graph the deploy will wire (the
649
+ # audit surface). An explicit [[journey.link]] is kept as an author OVERRIDE.
650
+ has_real_link = any(ln.strip() == "[[journey.link]]" for ln in block)
651
+ if not links_complete:
652
+ notes.append(ReconcileNote("verify", f"fork {unforked} first to preview the auto-wired connectivity "
653
+ f"(cross-campaign links derive at DEPLOY from the seams -- no rows to fill)"))
654
+ else:
655
+ stripped = [ln for ln in block if not _is_link_template(ln.strip())]
656
+ if len(stripped) != len(block): # drop the leftover commented templates
657
+ block, changed = stripped, True
658
+ notes.append(ReconcileNote("filled", f"{len(derived)} cross-campaign warp(s) auto-wire at DEPLOY from the "
659
+ f"real .eb connectivity -- no link rows to fill"
660
+ + ("; existing [[journey.link]] kept as overrides" if has_real_link else "")))
661
+ if stranded: # a region the real warps don't connect
662
+ notes.append(ReconcileNote("verify", f"no real warp connects {stranded} from the entry {campaigns[0]!r} "
663
+ f"-- the game doesn't link them in this set (wrong region/entry, or an "
664
+ f"order-only world-map hop). See the connectivity report."))
665
+
666
+ if not changed:
667
+ return text, notes
668
+ return "\n".join(lines[:start] + block + lines[end:]), notes
669
+
670
+
671
+ # --------------------------------------------------------------------------- grow an arc: append one region
672
+ # The reference-arc scaffold declares the WHOLE chain up front; this grows a multi-campaign journey ONE region
673
+ # at a time (the GUI's "Add region to arc") so an author can fork-a-region, playtest, then add the next -- the
674
+ # bottom-up faithful-recreation loop. It allocates the new region a DISJOINT id band + a unique name-prefix /
675
+ # mod folder (so it never collides with the already-forked arcs in the global EventDB namespace), rewrites the
676
+ # `campaigns` array + the header fork PLAYBOOK (so the Fork panel offers a Fork button for it), and drops a
677
+ # commented `[[journey.link]]` template for the new boundary (which `reconcile_arc_journey` later fills).
678
+ _CAMPAIGNS_RE = re.compile(r'^(\s*campaigns\s*=\s*\[)(.*?)(\])(\s*#.*)?$')
679
+ _PLAYBOOK_NUM_RE = re.compile(r'^#\s*\d+\.\s')
680
+ _ARC_COUNT_RE = re.compile(r'\bthe \d+ arc folders?\b') # 'folders?' -> also bumps a 1-arc start's singular
681
+ _LINK_COUNT_RE = re.compile(r'\(\d+ arcs? -> \d+ links?\)')
682
+
683
+
684
+ def _unique_tag(key, used) -> str:
685
+ """A short FBG/EVT name-prefix tag for a folder ``key`` (first 4 alnum, upper), de-duplicated against
686
+ ``used`` the SAME way :func:`arc_name_prefixes` does, so an appended region's tag can't shadow another's."""
687
+ base = "".join(c for c in str(key) if c.isalnum()).upper()[:4] or "ARC"
688
+ tag, n = base, 1
689
+ while tag in used:
690
+ n += 1
691
+ tag = f"{base[:3]}{n}"
692
+ return tag
693
+
694
+
695
+ def _existing_fork_params(text) -> tuple:
696
+ """Read the header playbook -> ``(max_id_base|None, used_tags, flags_per_field|None)``. Parses each
697
+ commented ``import-chain`` command's ``--id-base`` / ``--name-prefix`` / ``--flags-per-field`` so an
698
+ appended region can pick a band ABOVE the existing max + a fresh tag, without re-touching the forked arcs."""
699
+ max_base, tags, fpfs = None, set(), set()
700
+ for pf in parse_fork_commands(text):
701
+ cmd = pf.command
702
+ mb = re.search(r"--id-base\s+(\d+)", cmd)
703
+ if mb:
704
+ v = int(mb.group(1))
705
+ max_base = v if max_base is None else max(max_base, v)
706
+ mt = re.search(r"--name-prefix\s+(\S+)", cmd)
707
+ if mt:
708
+ tags.add(mt.group(1))
709
+ mf = re.search(r"--flags-per-field\s+(\d+)", cmd)
710
+ if mf:
711
+ fpfs.add(int(mf.group(1)))
712
+ return max_base, tags, (min(fpfs) if fpfs else None)
713
+
714
+
715
+ def _seed_marker(stripped: str) -> bool:
716
+ """True for the line that begins the ``[journey.seed]`` section (real or commented, or its lead-in comment)
717
+ -- the place an appended link template is inserted BEFORE (so it lands after the existing links)."""
718
+ return (stripped in ("[journey.seed]", "# [journey.seed]")
719
+ or stripped.startswith("# The New-Game starting state"))
720
+
721
+
722
+ def append_region_to_arc(text: str, arc: ReferenceArc, *, journey_index=None) -> "tuple[str, list]":
723
+ """Append one catalog region (``arc``) to a multi-campaign journey's chain (the GUI's incremental
724
+ "Add region to arc"). Targets the FIRST multi-campaign ``[[journey]]`` (or ``journey_index``). Allocates a
725
+ DISJOINT id band (max existing playbook band + :data:`ARC_ID_SPAN`, floored at the by-position default), a
726
+ unique name-prefix + mod folder, and the chain's flag width, then rewrites the TEXT: appends ``arc.key`` to
727
+ ``campaigns``, the ``import-chain`` line to the header playbook, and a commented ``[[journey.link]]`` template
728
+ for the new boundary (``reconcile_arc_journey`` fills it once forked). Returns ``(new_text, notes)``;
729
+ ``new_text == text`` (+ a skip note) when the region is already in the arc, there's no multi-campaign journey,
730
+ or the ``campaigns`` array isn't a single line we can grow. Pure + tk-free (the GUI writes the result)."""
731
+ notes: list = []
732
+ try:
733
+ data = tomllib.loads(text)
734
+ except tomllib.TOMLDecodeError as e:
735
+ return text, [ReconcileNote("skip", f"not parseable TOML ({e})")]
736
+ jrows = data.get("journey", [])
737
+ if journey_index is None:
738
+ journey_index = next((i for i, j in enumerate(jrows) if j.get("campaigns")), None)
739
+ if journey_index is None or journey_index >= len(jrows) or not jrows[journey_index].get("campaigns"):
740
+ return text, [ReconcileNote("skip", "no multi-campaign [[journey]] to grow "
741
+ "(create a Multi-campaign arc first, then add regions)")]
742
+ existing = [str(c) for c in jrows[journey_index].get("campaigns", [])]
743
+ if arc.key in existing:
744
+ return text, [ReconcileNote("skip", f"{arc.key!r} is already in this arc")]
745
+
746
+ # ---- allocate disjoint fork params (NEVER disturb the already-forked arcs)
747
+ max_base, used_tags, fpf = _existing_fork_params(text)
748
+ idx = len(existing) # the new region's 0-based position in the chain
749
+ band_by_index = arc_id_base(idx) # what the (idx)-th arc would get in a full scaffold
750
+ next_base = band_by_index if max_base is None else max(band_by_index, max_base + ARC_ID_SPAN)
751
+ used = set(used_tags) | {_unique_tag(k, set()) for k in existing} # dedup vs playbook tags AND folder-derived
752
+ tag = _unique_tag(arc.key, used)
753
+ if fpf is None:
754
+ fpf = arc_flags_per_field(idx + 1)
755
+ cmd = fork_command(arc, id_base=next_base, tag=tag, flags_per_field=fpf, verbatim=True)
756
+ if max_base is None and existing: # a hand-typed Multi journey has no bands to read
757
+ notes.append(ReconcileNote("verify", f"this arc has no fork playbook for its existing members "
758
+ f"({', '.join(existing)}) -- confirm none uses id band {next_base}+"))
759
+
760
+ lines = text.split("\n")
761
+
762
+ # ---- (a) append the folder to `campaigns = [...]` (+ bump the cosmetic count comments), in place
763
+ start, end = _journey_block_range(lines, journey_index)
764
+ if start is None:
765
+ return text, [ReconcileNote("skip", "couldn't locate the [[journey]] block (file hand-edited?)")]
766
+ camp_i = next((i for i in range(start, end) if _CAMPAIGNS_RE.match(lines[i])), None)
767
+ if camp_i is None:
768
+ return text, [ReconcileNote("skip", "the journey's `campaigns` isn't a single-line array to grow "
769
+ "(edit campaigns = [...] by hand, then add)")]
770
+ m = _CAMPAIGNS_RE.match(lines[camp_i])
771
+ inner = m.group(2).strip()
772
+ new_inner = (inner + ", " if inner else "") + f'"{_toml_str(arc.key)}"'
773
+ lines[camp_i] = f"{m.group(1)}{new_inner}]{m.group(4) or ''}"
774
+ n_new = len(existing) + 1
775
+ n_links = n_new - 1 # n_new >= 2 here (append needs >=1 existing) -> arc always plural
776
+ for i in range(start, end): # keep "the N arc folder(s)" / "(N arcs -> M link(s))" honest
777
+ lines[i] = _ARC_COUNT_RE.sub(f"the {n_new} arc folder{'' if n_new == 1 else 's'}", lines[i])
778
+ lines[i] = _LINK_COUNT_RE.sub(
779
+ f"({n_new} arc{'' if n_new == 1 else 's'} -> {n_links} link{'' if n_links == 1 else 's'})", lines[i])
780
+
781
+ # ---- (b) a commented [[journey.link]] template for the new boundary (prev member -> this region)
782
+ start, end = _journey_block_range(lines, journey_index)
783
+ prev = existing[-1]
784
+ tmpl = ["# [[journey.link]]",
785
+ f'# from = {{ campaign = "{_toml_str(prev)}", field = "BOUNDARY_MEMBER" }} # {prev}',
786
+ f'# to = {{ campaign = "{_toml_str(arc.key)}", field = "ARRIVAL_MEMBER", entrance = 0 }}'
787
+ f" # {arc.name} (real {arc.seed})"]
788
+ seed_i = next((i for i in range(start, end) if _seed_marker(lines[i].strip())), None)
789
+ insert_at = seed_i if seed_i is not None else end
790
+ if insert_at > 0 and lines[insert_at - 1].strip(): # no blank above -> add one (else reuse the existing gap)
791
+ tmpl = [""] + tmpl
792
+ if seed_i is not None: # inserting before the seed block -> keep a gap after
793
+ tmpl = tmpl + [""]
794
+ lines[insert_at:insert_at] = tmpl
795
+
796
+ # ---- (c) append the fork command to the header playbook (so the Fork panel offers a Fork button), AFTER the
797
+ # last command's `-- <name>: <note>` continuation line(s) so it doesn't orphan a prior arc's note
798
+ pb = [i for i, ln in enumerate(lines) if _PLAYBOOK_NUM_RE.match(ln) and "import-chain" in ln]
799
+ pb_lines = [f"# {n_new:>2}. {cmd}"]
800
+ if arc.note:
801
+ pb_lines.append(f"# -- {arc.name}: {arc.note}")
802
+ if pb:
803
+ at = pb[-1] + 1
804
+ while at < len(lines) and re.match(r"^#\s+--\s", lines[at]): # skip the prior arc's note continuation
805
+ at += 1
806
+ lines[at:at] = pb_lines
807
+ else: # no playbook (hand-typed Multi) -> seed a minimal one
808
+ hub_i = next((i for i, ln in enumerate(lines) if ln.strip() == "[hub]"), None)
809
+ block = ["# STEP 1 -- fork each region into its own campaign (run from this folder so --out lands here):",
810
+ *pb_lines, ""]
811
+ if hub_i is not None:
812
+ lines[hub_i:hub_i] = block
813
+ else:
814
+ lines = block + lines
815
+
816
+ new_text = "\n".join(lines)
817
+ try:
818
+ tomllib.loads(new_text) # belt-and-suspenders: the result must still parse
819
+ except tomllib.TOMLDecodeError as e: # pragma: no cover -- defensive
820
+ return text, [ReconcileNote("skip", f"the edit would not parse ({e}) -- left unchanged")]
821
+ notes.insert(0, ReconcileNote("filled", f"added region {arc.key!r}: id band {next_base}, prefix {tag}, "
822
+ f"folder {arc_mod_folder(tag)}"))
823
+ notes.append(ReconcileNote("verify", "fork it (Step 1 -- the Fork panel now lists it); its warps auto-wire "
824
+ "into the chain at deploy (no boundary to fill)"))
825
+ return new_text, notes