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.
- ff9mapkit/__init__.py +18 -0
- ff9mapkit/__main__.py +36 -0
- ff9mapkit/_animdb.py +2994 -0
- ff9mapkit/_animdb_all.py +14125 -0
- ff9mapkit/_fieldtable.py +1516 -0
- ff9mapkit/_fieldtext.py +845 -0
- ff9mapkit/_held_poses.py +44 -0
- ff9mapkit/_itemdb.py +65 -0
- ff9mapkit/_modeldb.py +725 -0
- ff9mapkit/_narrowmap_data.py +10 -0
- ff9mapkit/_npcparams.py +634 -0
- ff9mapkit/_regen_animdb.py +72 -0
- ff9mapkit/_regen_animdb_all.py +66 -0
- ff9mapkit/_regen_fieldtable.py +95 -0
- ff9mapkit/_regen_fieldtext.py +66 -0
- ff9mapkit/_regen_modeldb.py +67 -0
- ff9mapkit/_regen_npcparams.py +123 -0
- ff9mapkit/_regen_scenedb.py +57 -0
- ff9mapkit/_scenedb.py +869 -0
- ff9mapkit/abilities.py +225 -0
- ff9mapkit/animations.py +120 -0
- ff9mapkit/archetypes.py +218 -0
- ff9mapkit/areatitle.py +76 -0
- ff9mapkit/battle/__init__.py +21 -0
- ff9mapkit/battle/abilityfeatures.py +294 -0
- ff9mapkit/battle/actiondelta.py +441 -0
- ff9mapkit/battle/aiauthor.py +305 -0
- ff9mapkit/battle/ailint.py +140 -0
- ff9mapkit/battle/aipatch.py +175 -0
- ff9mapkit/battle/battleai.py +148 -0
- ff9mapkit/battle/battlecsv.py +390 -0
- ff9mapkit/battle/battlepatch.py +395 -0
- ff9mapkit/battle/build.py +558 -0
- ff9mapkit/battle/camera_codec.py +332 -0
- ff9mapkit/battle/camera_data.py +128 -0
- ff9mapkit/battle/characterdelta.py +789 -0
- ff9mapkit/battle/event_data.py +72 -0
- ff9mapkit/battle/extract.py +540 -0
- ff9mapkit/battle/fbx.py +223 -0
- ff9mapkit/battle/reskin.py +149 -0
- ff9mapkit/battle/scene_codec.py +314 -0
- ff9mapkit/battle/scene_data.py +369 -0
- ff9mapkit/battle/scenelint.py +125 -0
- ff9mapkit/battle/seqasm.py +131 -0
- ff9mapkit/battle/seqauthor.py +220 -0
- ff9mapkit/battle/seqcodec.py +300 -0
- ff9mapkit/battle/seqdis.py +106 -0
- ff9mapkit/battle/seqpatch.py +137 -0
- ff9mapkit/battle_bgm.py +133 -0
- ff9mapkit/binutils.py +60 -0
- ff9mapkit/build.py +5445 -0
- ff9mapkit/campaign.py +1276 -0
- ff9mapkit/catalog.py +316 -0
- ff9mapkit/chain.py +358 -0
- ff9mapkit/cli.py +3114 -0
- ff9mapkit/config.py +360 -0
- ff9mapkit/content/__init__.py +13 -0
- ff9mapkit/content/areatitle.py +36 -0
- ff9mapkit/content/ate.py +118 -0
- ff9mapkit/content/camera.py +123 -0
- ff9mapkit/content/chest.py +186 -0
- ff9mapkit/content/choice.py +163 -0
- ff9mapkit/content/conductor.py +217 -0
- ff9mapkit/content/cutscene.py +290 -0
- ff9mapkit/content/encounter.py +41 -0
- ff9mapkit/content/entry_settle.py +50 -0
- ff9mapkit/content/equipment.py +93 -0
- ff9mapkit/content/event.py +191 -0
- ff9mapkit/content/gateway.py +101 -0
- ff9mapkit/content/inventory.py +59 -0
- ff9mapkit/content/itemdata.py +644 -0
- ff9mapkit/content/itemtext.py +168 -0
- ff9mapkit/content/jump.py +114 -0
- ff9mapkit/content/ladder.py +633 -0
- ff9mapkit/content/movement.py +53 -0
- ff9mapkit/content/music.py +97 -0
- ff9mapkit/content/npc.py +348 -0
- ff9mapkit/content/object.py +340 -0
- ff9mapkit/content/onentry.py +135 -0
- ff9mapkit/content/party.py +111 -0
- ff9mapkit/content/pathfind.py +138 -0
- ff9mapkit/content/platform.py +314 -0
- ff9mapkit/content/player.py +168 -0
- ff9mapkit/content/prop.py +75 -0
- ff9mapkit/content/region.py +340 -0
- ff9mapkit/content/reinit.py +59 -0
- ff9mapkit/content/savepoint.py +90 -0
- ff9mapkit/content/shop.py +178 -0
- ff9mapkit/content/sps_trigger.py +66 -0
- ff9mapkit/content/startup.py +71 -0
- ff9mapkit/content/synthesis.py +106 -0
- ff9mapkit/content/text.py +183 -0
- ff9mapkit/content/textcarry.py +290 -0
- ff9mapkit/content/verbatim.py +86 -0
- ff9mapkit/content/walkmesh_hotfix.py +38 -0
- ff9mapkit/data/__init__.py +48 -0
- ff9mapkit/data/_regen_provenance.py +142 -0
- ff9mapkit/data/provenance/blank.es.patch +1 -0
- ff9mapkit/data/provenance/blank.fr.patch +1 -0
- ff9mapkit/data/provenance/blank.gr.patch +1 -0
- ff9mapkit/data/provenance/blank.it.patch +1 -0
- ff9mapkit/data/provenance/blank.jp.patch +1 -0
- ff9mapkit/data/provenance/blank.uk.patch +1 -0
- ff9mapkit/data/provenance/blank.us.patch +1 -0
- ff9mapkit/data/provenance/manifest.json +65 -0
- ff9mapkit/data/provenance/region_template.patch +1 -0
- ff9mapkit/data/reference_arcs.toml +89 -0
- ff9mapkit/data/region_catalog.toml +593 -0
- ff9mapkit/deploystack.py +358 -0
- ff9mapkit/dialogue.py +803 -0
- ff9mapkit/eb/__init__.py +12 -0
- ff9mapkit/eb/_exprtable.py +59 -0
- ff9mapkit/eb/_membertable.py +38 -0
- ff9mapkit/eb/_optables.py +537 -0
- ff9mapkit/eb/_regen_optables.py +76 -0
- ff9mapkit/eb/cmdasm.py +323 -0
- ff9mapkit/eb/disasm.py +332 -0
- ff9mapkit/eb/edit.py +439 -0
- ff9mapkit/eb/exprasm.py +158 -0
- ff9mapkit/eb/model.py +178 -0
- ff9mapkit/eb/opcodes.py +463 -0
- ff9mapkit/eblint.py +177 -0
- ff9mapkit/editor/__init__.py +20 -0
- ff9mapkit/editor/app.py +950 -0
- ff9mapkit/editor/battle_forms.py +240 -0
- ff9mapkit/editor/breadcrumb.py +89 -0
- ff9mapkit/editor/dialogs.py +116 -0
- ff9mapkit/editor/feedback.py +208 -0
- ff9mapkit/editor/forms.py +632 -0
- ff9mapkit/editor/graphview.py +350 -0
- ff9mapkit/editor/jobs.py +342 -0
- ff9mapkit/editor/model.py +243 -0
- ff9mapkit/editor/picker.py +120 -0
- ff9mapkit/editor/theme.py +212 -0
- ff9mapkit/eventscan.py +1441 -0
- ff9mapkit/extract.py +2279 -0
- ff9mapkit/flags.py +693 -0
- ff9mapkit/forkreport.py +1383 -0
- ff9mapkit/hub.py +477 -0
- ff9mapkit/idgated.py +101 -0
- ff9mapkit/infohub.py +580 -0
- ff9mapkit/items.py +63 -0
- ff9mapkit/itemstats.py +346 -0
- ff9mapkit/journey.py +1902 -0
- ff9mapkit/keyitems.py +93 -0
- ff9mapkit/logic_add.py +632 -0
- ff9mapkit/logic_edit.py +728 -0
- ff9mapkit/logic_map.py +526 -0
- ff9mapkit/pack.py +175 -0
- ff9mapkit/playerswap.py +231 -0
- ff9mapkit/prop_archetypes.py +228 -0
- ff9mapkit/provision.py +282 -0
- ff9mapkit/refarc.py +825 -0
- ff9mapkit/save.py +337 -0
- ff9mapkit/save_items.py +1673 -0
- ff9mapkit/scene/__init__.py +11 -0
- ff9mapkit/scene/arena.py +63 -0
- ff9mapkit/scene/bgart.py +140 -0
- ff9mapkit/scene/bgi.py +732 -0
- ff9mapkit/scene/bgs.py +174 -0
- ff9mapkit/scene/bgx.py +185 -0
- ff9mapkit/scene/cam.py +345 -0
- ff9mapkit/scene/guide.py +311 -0
- ff9mapkit/scene/paint.py +506 -0
- ff9mapkit/scene/placeholder.py +107 -0
- ff9mapkit/sjbinary.py +285 -0
- ff9mapkit/sps/__init__.py +17 -0
- ff9mapkit/sps/author.py +294 -0
- ff9mapkit/sps/catalog.py +88 -0
- ff9mapkit/sps/codec.py +264 -0
- ff9mapkit/sps/edit.py +184 -0
- ff9mapkit/sps/lint.py +58 -0
- ff9mapkit/sps/render.py +116 -0
- ff9mapkit/sps/templates.py +47 -0
- ff9mapkit/sps/texture.py +131 -0
- ff9mapkit/walkmesh_hotfixes.py +163 -0
- ff9mapkit/workspace/__init__.py +18 -0
- ff9mapkit/workspace/battledoc.py +985 -0
- ff9mapkit/workspace/builddoc.py +607 -0
- ff9mapkit/workspace/forms_qt.py +586 -0
- ff9mapkit/workspace/importdoc.py +665 -0
- ff9mapkit/workspace/mapview.py +131 -0
- ff9mapkit/workspace/palette.py +85 -0
- ff9mapkit/workspace/savedoc.py +664 -0
- ff9mapkit/workspace/shell.py +6907 -0
- ff9mapkit/workspace/style.py +105 -0
- ff9mapkit/workspace/tuningdialog.py +223 -0
- ff9mapkit-1.0.0b3.dist-info/METADATA +155 -0
- ff9mapkit-1.0.0b3.dist-info/RECORD +193 -0
- ff9mapkit-1.0.0b3.dist-info/WHEEL +5 -0
- ff9mapkit-1.0.0b3.dist-info/entry_points.txt +5 -0
- ff9mapkit-1.0.0b3.dist-info/licenses/LICENSE +31 -0
- 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
|