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