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/campaign.py
ADDED
|
@@ -0,0 +1,1276 @@
|
|
|
1
|
+
"""import-chain (P2): fork a walked region into a campaign -- N retargeted field.tomls + a campaign.toml.
|
|
2
|
+
|
|
3
|
+
P1 (chain.py) walks the door graph and returns a GraphResult. P2 turns that into an authorable, buildable
|
|
4
|
+
campaign: it assigns each forkable member a new id (id_base + i, BFS order), forks each real field, and --
|
|
5
|
+
the load-bearing step -- RETARGETS every in-chain gateway so it points at the chain's own new id instead of
|
|
6
|
+
the live game's. Out-of-chain / scripted / overworld / menu connections are recorded in campaign.toml as
|
|
7
|
+
[[seam]]s (NOT live gateways -- a live door to a real id would warp the player back into the live game and
|
|
8
|
+
can crash, e.g. field 100). Build-all/deploy-all/flag-allocation are later phases (P3/P4/P5).
|
|
9
|
+
|
|
10
|
+
The retarget itself happens at extract._imported_content_toml's single gateway-emit site via the threaded
|
|
11
|
+
``id_remap`` kwarg; this module orchestrates the id assignment, per-member fork loop, and manifest render.
|
|
12
|
+
|
|
13
|
+
Each member lands in its OWN subdir (camera.bgx/walkmesh.bgi have fixed names and would otherwise collide):
|
|
14
|
+
<out>/IC_ENT/IC_ENT.field.toml + camera.bgx + walkmesh.bgi
|
|
15
|
+
<out>/campaign.toml (references "IC_ENT/IC_ENT.field.toml")
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
import tomllib
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from . import chain
|
|
26
|
+
# Safe GLOB story-flag allocation band -- single source of truth in ``flags`` (grounded in
|
|
27
|
+
# research/STORY_FLAGS.md §4, a 676-field census): real FF9 uses bit-flags up to 8511 (the treasure-chest
|
|
28
|
+
# bitfield 8376-8511); the choice scratch is at bit 16320+; custom flags live in [8512, 16320). The old
|
|
29
|
+
# flag_base=8300 + 64/field collided with the chest block from member index 1 onward.
|
|
30
|
+
from .flags import (CHEST_FLAG_HI, CHEST_FLAG_LO, CHOICE_SCRATCH_FLOOR, FIRST_SAFE_FLAG,
|
|
31
|
+
collect_flag_defs, resolve_project_flags)
|
|
32
|
+
|
|
33
|
+
_MAP_SEG = re.compile(r"^map\d", re.I) # the 'map<NNN>' segment of an FBG folder
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CampaignError(ValueError):
|
|
37
|
+
"""A campaign manifest / build-all problem (caught + printed by the CLI)."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Member:
|
|
42
|
+
real_id: int
|
|
43
|
+
new_id: int
|
|
44
|
+
name: str # IC_ENT, ...
|
|
45
|
+
mode: str # "borrow" (area>=10) | "native" (area<10 fork) | "editable" (blank room)
|
|
46
|
+
src_area: int
|
|
47
|
+
folder: str # ID_TO_FBG[real_id]
|
|
48
|
+
toml_rel: str # "IC_ENT/IC_ENT.field.toml"
|
|
49
|
+
needs_export: bool # member with no usable background art -> a logic-only stub
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class CampaignPlan:
|
|
54
|
+
name: str
|
|
55
|
+
mod_folder: str
|
|
56
|
+
id_base: int
|
|
57
|
+
flag_base: int
|
|
58
|
+
flags_per_field: int
|
|
59
|
+
entry_name: str
|
|
60
|
+
entry_entrance: int
|
|
61
|
+
members: "list[Member]" = field(default_factory=list)
|
|
62
|
+
edges: list = field(default_factory=list) # {frm, to, entrance, story_conditional}
|
|
63
|
+
seams: list = field(default_factory=list) # {frm, to_real, kind, note, to_member?}
|
|
64
|
+
flags: list = field(default_factory=list) # [[flag]] shared named flags: {name, index} (cross-field)
|
|
65
|
+
verbatim: bool = False # forked with --verbatim: every member ships its donor's WHOLE
|
|
66
|
+
# .eb, so a story-conditional stacked door is carried + resolved
|
|
67
|
+
# by the engine at runtime (NOT re-authored from [[edge]]s).
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def needs_export(self):
|
|
71
|
+
return [m.name for m in self.members if m.needs_export]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def member_name(folder: str, idx: int, taken: set, prefix: str = "") -> str:
|
|
75
|
+
"""Deterministic, collision-safe member name from an FBG folder. ``fbg_n05_iccv_map085_ic_ent_0`` ->
|
|
76
|
+
``IC_ENT`` (the segments after ``map<NNN>``, trailing variant digit dropped). Collisions get a zone
|
|
77
|
+
prefix then a numeric suffix. Falls back to ``<ZONE>_<idx>`` when the folder has no map segment."""
|
|
78
|
+
parts = folder.split("_")
|
|
79
|
+
mi = next((i for i, p in enumerate(parts) if _MAP_SEG.match(p)), None)
|
|
80
|
+
tail = parts[mi + 1:] if mi is not None else []
|
|
81
|
+
if tail and tail[-1].isdigit(): # drop the trailing variant index (..._0 / ..._4)
|
|
82
|
+
tail = tail[:-1]
|
|
83
|
+
base = "_".join(tail).upper() if tail else f"{chain.zone_label(folder).upper()}_{idx:03d}"
|
|
84
|
+
nm = base
|
|
85
|
+
if nm in taken:
|
|
86
|
+
nm = f"{chain.zone_label(folder).upper()}_{base}"
|
|
87
|
+
k = 2
|
|
88
|
+
while nm in taken:
|
|
89
|
+
nm = f"{base}_{k}"
|
|
90
|
+
k += 1
|
|
91
|
+
taken.add(nm)
|
|
92
|
+
# A campaign-unique PREFIX makes the deployed FBG_N<area>_<NAME> + EVT_<NAME> globally unique, so two
|
|
93
|
+
# campaigns/worktrees that fork the SAME source field don't collide on the by-NAME, highest-folder-wins
|
|
94
|
+
# scene/.eb file resolution (a stacked-FolderNames shadow that serves the WRONG fork -> torn load).
|
|
95
|
+
pfx = prefix.strip().upper().rstrip("_")
|
|
96
|
+
return f"{pfx}_{nm}" if pfx else nm
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def assign_ids(result, *, id_base: int, name_prefix: str = "", prior=None, reserved_ids=None):
|
|
100
|
+
"""(members_ids, new_id, name_of) for the FORKABLE nodes of a walk, in BFS discovery order.
|
|
101
|
+
|
|
102
|
+
Without ``prior`` (a fresh fork): ``members_ids[i] -> id_base + i``; ``name_of[real]`` is the unique
|
|
103
|
+
member name (``name_prefix`` namespaces it globally so cross-campaign/cross-worktree forks of the same
|
|
104
|
+
field don't collide on the deployed names).
|
|
105
|
+
|
|
106
|
+
With ``prior`` (a ``{source_real_id: (fork_id, member_name)}`` map from an existing campaign.toml --
|
|
107
|
+
STABLE-ID mode, the save-survives-a-re-fork path): a re-discovered donor keeps its EXACT prior fork-id +
|
|
108
|
+
name, and a net-NEW donor is APPENDED at the next id ABOVE every prior id (never reusing a prior id, so a
|
|
109
|
+
stale save can't land on the wrong field; the append-above-max rule also keeps every prior member's
|
|
110
|
+
POSITION when the caller emits members id-sorted -> its position-based flag window is stable too). Names
|
|
111
|
+
stay stable: prior names are reused verbatim and new names are disambiguated against them. ``reserved_ids``
|
|
112
|
+
(the new ids of EVERY prior member, including source-less / hand-appended ones NOT in ``prior``) are
|
|
113
|
+
protected from re-allocation so a net-new donor can never collide with one. ``prior={}`` + no
|
|
114
|
+
``reserved_ids`` reproduces the original index-based allocation byte-for-byte."""
|
|
115
|
+
from . import extract
|
|
116
|
+
members_ids = [fid for fid, info in result.nodes.items() if info.get("found")]
|
|
117
|
+
prior = prior or {}
|
|
118
|
+
taken: set = {pname for (_pid, pname) in prior.values()} # a new name can't collide with a reused one
|
|
119
|
+
used: set = {pid for (pid, _pname) in prior.values()} | set(reserved_ids or ()) # every prior id is off-limits
|
|
120
|
+
cursor = max([id_base - 1, *used]) + 1 # net-new members append above EVERY prior id
|
|
121
|
+
new_id: dict = {}
|
|
122
|
+
name_of: dict = {}
|
|
123
|
+
for i, real in enumerate(members_ids):
|
|
124
|
+
if real in prior: # re-discovered: freeze its id + name
|
|
125
|
+
new_id[real], name_of[real] = prior[real]
|
|
126
|
+
else: # net-new donor: a fresh non-colliding id
|
|
127
|
+
while cursor in used:
|
|
128
|
+
cursor += 1
|
|
129
|
+
new_id[real] = cursor
|
|
130
|
+
used.add(cursor)
|
|
131
|
+
cursor += 1
|
|
132
|
+
name_of[real] = member_name(extract.ID_TO_FBG[real], i, taken, name_prefix)
|
|
133
|
+
return members_ids, new_id, name_of
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _emit_logic_only_member(folder, member_dir, name, field_id, id_remap, live_seams, game):
|
|
137
|
+
"""An editable member whose art was never [Export]'d: still emit camera.bgx + walkmesh.bgi (offline)
|
|
138
|
+
and a logic-only field.toml (retargeted gateways, NO [[layers]]) so the campaign STRUCTURE is complete.
|
|
139
|
+
The human exports the art in-game later, then re-forks with --editable to add the repaintable layers."""
|
|
140
|
+
from . import extract
|
|
141
|
+
meta = extract.extract_field(folder, member_dir, game=game) # camera.bgx + walkmesh.bgi
|
|
142
|
+
safe_area = extract.safe_custom_area(meta["area"])
|
|
143
|
+
content_blocks, control_dir, summary = extract._content_for_import(
|
|
144
|
+
folder, game, out_dir=member_dir, name=name, id_remap=id_remap, live_seams=live_seams)
|
|
145
|
+
meta["imported_content"] = summary
|
|
146
|
+
cm = meta["camera"]
|
|
147
|
+
x, z = meta["player_start"]
|
|
148
|
+
scroll = "[camera.scroll]\nenabled = true\n" if meta["scrolling"] else ""
|
|
149
|
+
control_line = f"control_direction = {control_dir}\n" if control_dir is not None else ""
|
|
150
|
+
toml = (
|
|
151
|
+
f"# EDITABLE member (logic + camera + walkmesh) of {meta['field']} (source area {meta['area']}).\n"
|
|
152
|
+
f"# !! NEEDS ART: export this field in-game once (Memoria.ini [Export] Field=1), then re-run\n"
|
|
153
|
+
f"# `ff9mapkit import {folder} --editable` to add the repaintable layer_*.png. The gateways,\n"
|
|
154
|
+
f"# walkmesh and camera here are correct + retargeted; only the background art is missing.\n"
|
|
155
|
+
f"# Camera: pitch {cm['pitch_deg']} deg, FOV {cm['fov_deg']} deg.\n\n"
|
|
156
|
+
f"[field]\nid = {field_id}\nname = \"{name}\"\narea = {safe_area}\ntext_block = 1073\n\n"
|
|
157
|
+
f"[camera]\nborrow = \"camera.bgx\"\n{control_line}{scroll}\n"
|
|
158
|
+
f"[walkmesh]\nbgi = \"walkmesh.bgi\"\n\n"
|
|
159
|
+
f"[player]\nspawn = [{x}, {z}]\n\n"
|
|
160
|
+
f"{extract._content_section(content_blocks, x, z)}"
|
|
161
|
+
)
|
|
162
|
+
p = Path(member_dir) / f"{name}.field.toml"
|
|
163
|
+
p.write_text(toml, encoding="utf-8", newline="\n")
|
|
164
|
+
meta["field_toml"] = str(p)
|
|
165
|
+
return meta, p
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _dedup(rows, keys):
|
|
169
|
+
seen, out = set(), []
|
|
170
|
+
for r in rows:
|
|
171
|
+
k = tuple(r.get(x) for x in keys)
|
|
172
|
+
if k not in seen:
|
|
173
|
+
seen.add(k)
|
|
174
|
+
out.append(r)
|
|
175
|
+
return out
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _collect_edges_seams(result, members_ids, new_id, name_of):
|
|
179
|
+
"""Build the [[edge]] (in-chain walk-in) + [[seam]] (everything else) rows for the manifest."""
|
|
180
|
+
inchain = set(members_ids)
|
|
181
|
+
edges = []
|
|
182
|
+
for real, info in result.nodes.items():
|
|
183
|
+
if not info.get("found") or real not in inchain:
|
|
184
|
+
continue
|
|
185
|
+
# group walk-in edges by zone polygon to detect story-conditional stacked exits
|
|
186
|
+
groups: dict = {}
|
|
187
|
+
for e in info["edges"]:
|
|
188
|
+
if e["kind"] == chain.WALK_IN:
|
|
189
|
+
groups.setdefault(tuple(map(tuple, e.get("zone") or [])), []).append(e)
|
|
190
|
+
for e in info["edges"]:
|
|
191
|
+
if e["kind"] != chain.WALK_IN:
|
|
192
|
+
continue
|
|
193
|
+
to = int(e["to"])
|
|
194
|
+
if to not in inchain:
|
|
195
|
+
continue # out-of-chain -> a seam (below), not an edge
|
|
196
|
+
grp = groups[tuple(map(tuple, e.get("zone") or []))]
|
|
197
|
+
cond = bool(e.get("story_conditional")) and sum(1 for x in grp if int(x["to"]) in inchain) >= 2
|
|
198
|
+
edges.append({"frm": name_of[real], "to": name_of[to],
|
|
199
|
+
"entrance": int(e.get("entrance", 0)), "story_conditional": cond})
|
|
200
|
+
|
|
201
|
+
seams = []
|
|
202
|
+
for s in result.seams: # scripted / teleport warps
|
|
203
|
+
to = int(s["to"])
|
|
204
|
+
seams.append({"frm": name_of.get(s["from"], str(s["from"])), "to_real": to, "kind": "scripted",
|
|
205
|
+
"note": f"trigger:{s.get('trigger')}", "to_member": name_of.get(to)})
|
|
206
|
+
for real, info in result.nodes.items(): # overworld exits
|
|
207
|
+
if info.get("overworld_exits"):
|
|
208
|
+
seams.append({"frm": name_of.get(real, str(real)), "to_real": "WORLDMAP", "kind": "overworld",
|
|
209
|
+
"note": f"{len(info['overworld_exits'])} WorldMap op(s)"})
|
|
210
|
+
for p in result.portals: # out-of-scope walk-in targets
|
|
211
|
+
seams.append({"frm": name_of.get(p["from"], str(p["from"])), "to_real": int(p["to"]),
|
|
212
|
+
"kind": "portal", "note": f"zone {p.get('to_zone')}; {p.get('reason')}",
|
|
213
|
+
"to_member": name_of.get(int(p["to"]))})
|
|
214
|
+
for u in result.unforkable: # shop/menu/variant targets (no background)
|
|
215
|
+
seams.append({"frm": name_of.get(u["from"], str(u["from"])), "to_real": int(u["to"]),
|
|
216
|
+
"kind": "menu", "note": "no background (shop/menu/variant)"})
|
|
217
|
+
# dedup: a scripted warp can recur across cutscene variants; a double-door is one edge
|
|
218
|
+
return _dedup(edges, ("frm", "to", "entrance")), _dedup(seams, ("frm", "to_real", "kind"))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def write_campaign(result, out_dir, *, id_base=6000, flag_base=FIRST_SAFE_FLAG, flags_per_field=64,
|
|
222
|
+
name: str, mod_folder: str, game=None, live_seams=False,
|
|
223
|
+
entry_entrance=0, verbatim=False, swap_player=None,
|
|
224
|
+
neutralize_gestures=False, name_prefix="", prior_plan=None) -> CampaignPlan:
|
|
225
|
+
"""Fork the walk into ``out_dir``: a per-member subdir each + a top-level campaign.toml. Returns the
|
|
226
|
+
CampaignPlan. Members in area>=10 BG-borrow; area<10 members fork as a NATIVE scene (own atlas+.bgs, no
|
|
227
|
+
.bgx -- seamless, no in-game export needed). Both are fully offline; a field with no usable background
|
|
228
|
+
atlas degrades to a logic-only stub (camera+walkmesh+retargeted gateways) flagged needs_export.
|
|
229
|
+
|
|
230
|
+
``verbatim`` (the MOST faithful chain -- docs/FORK_FIDELITY.md): fork EVERY member native + VERBATIM --
|
|
231
|
+
each ships its donor's WHOLE event script (entry-0 + objects + gateways, run as-is) with the in-chain
|
|
232
|
+
``Field()`` exits retargeted to this chain's own member ids, plus the donor's whole ``.mes`` at the
|
|
233
|
+
donor's OWN registered textid (``EVENT_ID_TO_MES`` -- a valid MesDB key, so the FieldScene registers;
|
|
234
|
+
same-zone members share it harmlessly, ship identical text). The chain then plays its real logic +
|
|
235
|
+
speaks its real lines, doors wired to each other instead of back into the live game."""
|
|
236
|
+
from . import extract
|
|
237
|
+
from ._fieldtext import EVENT_ID_TO_MES
|
|
238
|
+
out = Path(out_dir)
|
|
239
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
240
|
+
# STABLE-ID mode: when re-forking on top of an existing campaign (``prior_plan``), freeze every
|
|
241
|
+
# re-discovered donor's prior fork-id + name and APPEND net-new donors above the highest prior id, so an
|
|
242
|
+
# in-fork SAVE survives the re-fork (it stores the field id + position-based story-flag window).
|
|
243
|
+
prior = {m.real_id: (m.new_id, m.name) for m in prior_plan.members if m.real_id} if prior_plan else None
|
|
244
|
+
# EVERY prior member's id is reserved (incl. source-less / hand-appended ones absent from `prior`), so a
|
|
245
|
+
# net-new donor can't collide with one even though only real donors can be re-discovered by name/id.
|
|
246
|
+
reserved = {m.new_id for m in prior_plan.members} if prior_plan else None
|
|
247
|
+
members_ids, new_id, name_of = assign_ids(result, id_base=id_base, name_prefix=name_prefix,
|
|
248
|
+
prior=prior, reserved_ids=reserved)
|
|
249
|
+
if not members_ids:
|
|
250
|
+
raise ValueError("no forkable fields in the walk -- nothing to fork (try a different seed/--zones)")
|
|
251
|
+
# Carry forward any prior member the new walk did NOT re-discover (a hand-appended out-of-band fork like a
|
|
252
|
+
# missed cross-zone cutscene field, OR a source-less blank-room/logic member) -- keep its files + id so
|
|
253
|
+
# (a) it isn't orphaned/dropped and its cross-link doesn't re-leak, and (b) other members' retargets to it
|
|
254
|
+
# still resolve. Carry only a member whose field.toml is still on disk; flag any whose files vanished
|
|
255
|
+
# (can't carry -> later members' positions/flag windows shift).
|
|
256
|
+
carried: list = []
|
|
257
|
+
carried_missing: list = []
|
|
258
|
+
if prior_plan:
|
|
259
|
+
discovered = set(members_ids)
|
|
260
|
+
for m in prior_plan.members:
|
|
261
|
+
if m.real_id and m.real_id in discovered:
|
|
262
|
+
continue # re-discovered -> re-forked below with its frozen id
|
|
263
|
+
if (out / m.toml_rel).exists():
|
|
264
|
+
if m.real_id:
|
|
265
|
+
new_id[m.real_id] = m.new_id # so re-forked members' Field(real)->fork resolve
|
|
266
|
+
name_of[m.real_id] = m.name # keep new_id/name_of CONSISTENT: a re-forked verbatim
|
|
267
|
+
# member's Field(carried) exit feeds the edge-synth `name_of[d]` below -- a carried id in
|
|
268
|
+
# new_id but absent from name_of crashes it (KeyError). real_id 0 never a Field() dest.
|
|
269
|
+
carried.append(m)
|
|
270
|
+
else:
|
|
271
|
+
carried_missing.append(m)
|
|
272
|
+
|
|
273
|
+
swap_name = None
|
|
274
|
+
if swap_player and verbatim: # the swap patches each member's verbatim donor .eb
|
|
275
|
+
from . import playerswap
|
|
276
|
+
swap_name, _ = playerswap.resolve_char(swap_player) # fail fast on a bad char before forking the chain
|
|
277
|
+
members = []
|
|
278
|
+
member_exits: dict = {} # real -> its donor .eb Field() dests (verbatim)
|
|
279
|
+
degraded: list = [] # verbatim members that fell back to declarative
|
|
280
|
+
swap_gesture_warn: dict = {} # mname -> scripted-gesture count (will glitch on swap)
|
|
281
|
+
swap_skipped: list = [] # verbatim members with no swappable player entry
|
|
282
|
+
for real in members_ids:
|
|
283
|
+
folder = extract.ID_TO_FBG[real]
|
|
284
|
+
donor = str(real) # identify the donor by ID, not the FBG folder --
|
|
285
|
+
# several field ids can SHARE one folder (the same room at a different story beat, e.g. 52/3008), so
|
|
286
|
+
# a folder-name lookup is ambiguous + DROPS the member; the id resolves its own scene + .eb exactly.
|
|
287
|
+
area, _ = extract.parse_fbg_folder(folder)
|
|
288
|
+
# verbatim ships its own native scene + the donor's whole .eb, so it forks NATIVE for any area
|
|
289
|
+
mode = "native" if verbatim else ("borrow" if area >= extract.MIN_CUSTOM_AREA else "native")
|
|
290
|
+
mname = name_of[real]
|
|
291
|
+
mdir = out / mname
|
|
292
|
+
mdir.mkdir(parents=True, exist_ok=True)
|
|
293
|
+
needs_export = False
|
|
294
|
+
try:
|
|
295
|
+
if verbatim:
|
|
296
|
+
# the donor's OWN registered textid (a valid MesDB key); shipping its .mes there is an
|
|
297
|
+
# identity override, and same-zone members share it (identical text -> harmless).
|
|
298
|
+
tb = EVENT_ID_TO_MES.get(real, 1073)
|
|
299
|
+
_meta, p = extract.write_native_project(donor, mdir, name=mname, field_id=new_id[real],
|
|
300
|
+
text_block=tb, game=game, id_remap=new_id,
|
|
301
|
+
live_seams=live_seams, verbatim=True)
|
|
302
|
+
member_exits[real] = _meta.get("imported_content", {}).get("field_exits", [])
|
|
303
|
+
if swap_name: # play as one char across the chain (per-member swap)
|
|
304
|
+
try:
|
|
305
|
+
n = extract.apply_player_swap(p, swap_name, neutralize=neutralize_gestures)
|
|
306
|
+
if n:
|
|
307
|
+
swap_gesture_warn[mname] = n
|
|
308
|
+
except playerswap.NoSwappablePlayer:
|
|
309
|
+
swap_skipped.append(mname) # no swappable player entry (e.g. a cutscene member)
|
|
310
|
+
# a real overflow/corruption ValueError is NOT caught here -> it propagates loudly
|
|
311
|
+
elif mode == "borrow":
|
|
312
|
+
_meta, p = extract.write_field_project(donor, mdir, name=mname, field_id=new_id[real],
|
|
313
|
+
game=game, id_remap=new_id, live_seams=live_seams)
|
|
314
|
+
else: # area<10: NATIVE fork (own atlas+.bgs, NO .bgx) -- seamless + fully offline (no [Export])
|
|
315
|
+
_meta, p = extract.write_native_project(donor, mdir, name=mname, field_id=new_id[real],
|
|
316
|
+
game=game, id_remap=new_id, live_seams=live_seams)
|
|
317
|
+
except RuntimeError: # a field with no usable background atlas (rare)
|
|
318
|
+
if mode == "borrow":
|
|
319
|
+
raise
|
|
320
|
+
# verbatim degrades to a logic-only stub too (loses the verbatim .eb for this one member)
|
|
321
|
+
_meta, p = _emit_logic_only_member(donor, mdir, mname, new_id[real], new_id, live_seams, game)
|
|
322
|
+
needs_export = True
|
|
323
|
+
if verbatim:
|
|
324
|
+
degraded.append(mname) # surfaced loudly in the CLI summary (NOT verbatim)
|
|
325
|
+
members.append(Member(real, new_id[real], mname, mode, area, folder,
|
|
326
|
+
f"{mname}/{p.name}", needs_export))
|
|
327
|
+
|
|
328
|
+
edges, seams = _collect_edges_seams(result, members_ids, new_id, name_of)
|
|
329
|
+
# In a verbatim chain the LIVE doors are the donor .eb's retargeted Field() exits -- which include
|
|
330
|
+
# scripted/self warps that aren't walk-in [[edge]]s. Surface every in-chain retarget as an edge so the
|
|
331
|
+
# graph/reachability reflect what was baked into the shipped .eb (else a member reachable only via a
|
|
332
|
+
# retargeted scripted warp reads as UNREACHABLE). Skip self-loops; dedup against the walk-in edges.
|
|
333
|
+
if verbatim:
|
|
334
|
+
have = {(e["frm"], e["to"]) for e in edges}
|
|
335
|
+
for real, exits in member_exits.items():
|
|
336
|
+
for d in exits:
|
|
337
|
+
if d in new_id and d != real and (name_of[real], name_of[d]) not in have:
|
|
338
|
+
edges.append({"frm": name_of[real], "to": name_of[d], "entrance": 0,
|
|
339
|
+
"story_conditional": False})
|
|
340
|
+
have.add((name_of[real], name_of[d]))
|
|
341
|
+
members.extend(carried) # keep prior forks the new walk didn't re-discover (no orphan/re-leak)
|
|
342
|
+
members.sort(key=lambda m: m.new_id) # id-sorted == position-stable: a re-discovered member keeps its index
|
|
343
|
+
# -> its position-based story-flag window (flag_base + i*K) survives too.
|
|
344
|
+
# Fresh fork: ids are id_base+i in walk order, so this is already sorted.
|
|
345
|
+
# Entry: the first-discovered (seed) member, BUT on a stable re-fork keep the PRIOR entry if that member
|
|
346
|
+
# still exists -- a changed discovery order must not silently repoint New Game / the journey entry.
|
|
347
|
+
entry_name = name_of[members_ids[0]]
|
|
348
|
+
if prior_plan and prior_plan.entry_name and any(m.name == prior_plan.entry_name for m in members):
|
|
349
|
+
entry_name = prior_plan.entry_name
|
|
350
|
+
plan = CampaignPlan(name=name, mod_folder=mod_folder, id_base=id_base, flag_base=flag_base,
|
|
351
|
+
flags_per_field=flags_per_field, entry_name=entry_name,
|
|
352
|
+
entry_entrance=entry_entrance, members=members, edges=edges, seams=seams)
|
|
353
|
+
plan.stable_ids = bool(prior_plan) # transient: re-fork reused the prior donor->id+name map
|
|
354
|
+
plan.reused_ids = sorted(r for r in members_ids if prior and r in prior) # re-discovered, frozen id
|
|
355
|
+
plan.appended_ids = sorted(r for r in members_ids if not (prior and r in prior)) # net-new this fork
|
|
356
|
+
plan.carried = [m.name for m in carried] # prior forks kept verbatim (not re-discovered)
|
|
357
|
+
plan.carried_missing = [(m.name, m.new_id) for m in carried_missing] # prior forks whose files vanished
|
|
358
|
+
plan.verbatim = bool(verbatim) # PERSISTED: gates the declarative-only stacked-door lint (the donor
|
|
359
|
+
# .eb resolves story-conditional doors itself -- nothing to re-author)
|
|
360
|
+
plan.verbatim_degraded = degraded # transient build-time signal (NOT persisted): verbatim members
|
|
361
|
+
plan.swap_player = swap_name # transient: --swap-player char applied to every member, + the
|
|
362
|
+
plan.swap_gesture_warn = swap_gesture_warn # members whose scripted gestures will glitch on the new rig,
|
|
363
|
+
plan.swap_skipped = swap_skipped # and members with no swappable player entry (left as the donor's)
|
|
364
|
+
plan.neutralized = bool(neutralize_gestures and swap_name) # those gestures were rewritten to idle (won't glitch)
|
|
365
|
+
(out / "campaign.toml").write_text(render_campaign_toml(plan), encoding="utf-8", newline="\n")
|
|
366
|
+
return plan
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _q(note: str) -> str:
|
|
370
|
+
"""A TOML-safe basic string (drop the only char that would break a quoted value)."""
|
|
371
|
+
return str(note).replace('"', "'")
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def render_campaign_toml(plan: CampaignPlan) -> str:
|
|
375
|
+
"""The campaign.toml text -- valid TOML (array-of-tables, multi-line): [campaign] header, [[field]]
|
|
376
|
+
members, [[edge]] in-chain graph, [[seam]] non-gateway connections, [initial_flags]. Parseable by
|
|
377
|
+
tomllib (so P3's build-all can load it)."""
|
|
378
|
+
L = ["# Campaign manifest emitted by ff9mapkit import-chain (P2).",
|
|
379
|
+
"# Members are forked real fields with gateways RETARGETED to this chain's own ids.",
|
|
380
|
+
"# [[edge]] = in-chain connectivity (each is a live retargeted [[gateway]] in a member toml).",
|
|
381
|
+
"# [[seam]] = scripted/overworld/menu/portal connections that are NOT live gateways -- author by hand.",
|
|
382
|
+
"",
|
|
383
|
+
"[campaign]",
|
|
384
|
+
f'name = "{plan.name}"',
|
|
385
|
+
f'mod_folder = "{plan.mod_folder}"',
|
|
386
|
+
f"id_base = {plan.id_base}",
|
|
387
|
+
f"flag_base = {plan.flag_base}",
|
|
388
|
+
f"flags_per_field = {plan.flags_per_field}",
|
|
389
|
+
f'entry_field = "{plan.entry_name}"',
|
|
390
|
+
f"entry_entrance = {plan.entry_entrance}"]
|
|
391
|
+
if plan.verbatim: # verbatim members ship the donor .eb whole -> story-conditional
|
|
392
|
+
L.append("verbatim = true # every member ships its donor's whole .eb (real logic + real doors)")
|
|
393
|
+
L += ["",
|
|
394
|
+
"# Members id-sorted. Fresh fork: id = id_base + BFS-index. Stable re-fork (--out had a prior",
|
|
395
|
+
"# campaign.toml): a re-discovered donor keeps its prior id+name; net-new donors append above the max."]
|
|
396
|
+
for m in plan.members:
|
|
397
|
+
L.append("[[field]]")
|
|
398
|
+
L.append(f'name = "{m.name}"')
|
|
399
|
+
L.append(f"source = {m.real_id}")
|
|
400
|
+
L.append(f"id = {m.new_id}")
|
|
401
|
+
L.append(f'mode = "{m.mode}"')
|
|
402
|
+
L.append(f'toml = "{m.toml_rel}"')
|
|
403
|
+
if m.needs_export:
|
|
404
|
+
L.append("needs_export = true # logic-only stub: this field had no usable background atlas")
|
|
405
|
+
L.append("")
|
|
406
|
+
L += ["# In-chain connectivity (each = a retargeted live [[gateway]] in the member toml)."]
|
|
407
|
+
for e in plan.edges:
|
|
408
|
+
L.append("[[edge]]")
|
|
409
|
+
L.append(f'from = "{e["frm"]}"')
|
|
410
|
+
L.append(f'to = "{e["to"]}"')
|
|
411
|
+
L.append(f"entrance = {e['entrance']}")
|
|
412
|
+
if e.get("story_conditional"):
|
|
413
|
+
L.append("story_conditional = true") # explicit marker -> survives load (NOT inferred from gated_by,
|
|
414
|
+
# which verbatim omits) so a DEGRADED-member lint still fires
|
|
415
|
+
if plan.verbatim:
|
|
416
|
+
# the donor .eb owns this conditional door (if(flag){A}else{B}) -- it's carried verbatim and the
|
|
417
|
+
# engine resolves it at runtime, so there is NOTHING to gate here (informational only).
|
|
418
|
+
L.append("# the donor .eb resolves this story-conditional door at runtime (informational)")
|
|
419
|
+
else:
|
|
420
|
+
L.append(f'gated_by = "" # STORY-COND stacked same-zone exit -- set requires_flag '
|
|
421
|
+
f'(suggest {plan.flag_base + 7})')
|
|
422
|
+
L.append("")
|
|
423
|
+
if plan.seams:
|
|
424
|
+
L += ["# Seams: NOT live gateways -- scripted teleports / overworld exits / menu / out-of-chain."]
|
|
425
|
+
for s in plan.seams:
|
|
426
|
+
L.append("[[seam]]")
|
|
427
|
+
L.append(f'from = "{s["frm"]}"')
|
|
428
|
+
L.append('to_real = "WORLDMAP"' if s["to_real"] == "WORLDMAP" else f"to_real = {s['to_real']}")
|
|
429
|
+
L.append(f'kind = "{s["kind"]}"')
|
|
430
|
+
if s.get("to_member"):
|
|
431
|
+
L.append(f'to_member = "{s["to_member"]}"')
|
|
432
|
+
L.append(f'note = "{_q(s["note"])}"')
|
|
433
|
+
L.append("")
|
|
434
|
+
if plan.flags:
|
|
435
|
+
L += ["# Shared NAMED flags -- members gate by NAME (requires_flag = \"<name>\"). Place ABOVE the",
|
|
436
|
+
"# per-member auto-flag blocks; indices must be in [8512, 16320), clear of real-FF9 usage."]
|
|
437
|
+
for fdef in plan.flags:
|
|
438
|
+
L += ["[[flag]]", f'name = "{fdef.get("name", "")}"', f"index = {int(fdef.get('index', 0))}", ""]
|
|
439
|
+
L += ["[initial_flags]", "# GLOB flags pre-set at campaign entry (empty by default)", ""]
|
|
440
|
+
return "\n".join(L)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
# ---- P3: load a campaign.toml + build all members into one mod ---------------------------
|
|
444
|
+
def load_campaign(path) -> CampaignPlan:
|
|
445
|
+
"""Parse a campaign.toml back into a CampaignPlan (the inverse of render_campaign_toml). Members keep
|
|
446
|
+
their FINAL ids + retargeted gateways (those live in the member field.tomls, not here)."""
|
|
447
|
+
with open(path, "rb") as fh:
|
|
448
|
+
data = tomllib.load(fh)
|
|
449
|
+
if "campaign" not in data:
|
|
450
|
+
raise CampaignError(f"{path}: not a campaign manifest (no [campaign] table)")
|
|
451
|
+
c = data["campaign"]
|
|
452
|
+
members = [Member(real_id=int(f.get("source", 0)), new_id=int(f["id"]), name=f["name"],
|
|
453
|
+
mode=f.get("mode", "borrow"), src_area=0, folder="",
|
|
454
|
+
toml_rel=f["toml"], needs_export=bool(f.get("needs_export", False)))
|
|
455
|
+
for f in data.get("field", [])]
|
|
456
|
+
return CampaignPlan(
|
|
457
|
+
name=c.get("name", "CAMPAIGN"), mod_folder=c.get("mod_folder", "FF9CustomMap"),
|
|
458
|
+
id_base=int(c.get("id_base", 4000)), flag_base=int(c.get("flag_base", FIRST_SAFE_FLAG)),
|
|
459
|
+
flags_per_field=int(c.get("flags_per_field", 64)), entry_name=c.get("entry_field", ""),
|
|
460
|
+
entry_entrance=int(c.get("entry_entrance", 0)), members=members,
|
|
461
|
+
verbatim=bool(c.get("verbatim", False)),
|
|
462
|
+
flags=list(data.get("flag", [])),
|
|
463
|
+
edges=[{"frm": e["from"], "to": e["to"], "entrance": int(e.get("entrance", 0)),
|
|
464
|
+
# the explicit marker (new) OR a legacy gated_by placeholder (back-compat with older forks)
|
|
465
|
+
"story_conditional": bool(e.get("story_conditional")) or ("gated_by" in e)}
|
|
466
|
+
for e in data.get("edge", [])],
|
|
467
|
+
# normalize seams to the in-memory shape (from -> frm), exactly as edges above; render_campaign_toml
|
|
468
|
+
# writes `from`, but _collect_edges_seams/lint/campaign_graph all key on `frm`, so a raw passthrough
|
|
469
|
+
# left loaded seams with `from` (dropping them from the resolved graph + nulling lint messages).
|
|
470
|
+
seams=[{"frm": s.get("frm", s.get("from")), "to_real": s.get("to_real"), "kind": s.get("kind"),
|
|
471
|
+
"note": s.get("note", ""), "to_member": s.get("to_member")}
|
|
472
|
+
for s in data.get("seam", [])])
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def validate_ids(plan: CampaignPlan):
|
|
476
|
+
"""The one campaign-level check P3 owns: ids non-empty, distinct, and in the custom band [4000, 32767]
|
|
477
|
+
(>=4000 per CLAUDE.md; <=32767 because the live fldMapNo is Int16 -> a higher id is unreachable).
|
|
478
|
+
Per-field schema/placement validation runs later inside build_field/validate."""
|
|
479
|
+
if not plan.members:
|
|
480
|
+
raise CampaignError("campaign has no [[field]] members to build")
|
|
481
|
+
ids = [m.new_id for m in plan.members]
|
|
482
|
+
dups = sorted({i for i in ids if ids.count(i) > 1})
|
|
483
|
+
if dups:
|
|
484
|
+
raise CampaignError(f"duplicate member ids {dups} -- EventDB/SceneData are global dicts and collide at launch")
|
|
485
|
+
bad = [i for i in ids if not (4000 <= i <= 32767)]
|
|
486
|
+
if bad:
|
|
487
|
+
raise CampaignError(f"member ids out of range {sorted(set(bad))}: must be 4000-32767 (fldMapNo is Int16)")
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def apply_seed_blocks(raw: dict, blocks: dict) -> None:
|
|
491
|
+
"""Merge story-flags capstone blocks (``startup`` / ``party`` / ``start_inventory`` / ``equipment``) AND a
|
|
492
|
+
journey ``[journey.tuning]`` bundle (``player_csv``) into a member's ``FieldProject.raw`` IN PLACE -- additive
|
|
493
|
+
(scenario replaces, flags + party-adds union, the bag / equipment lists replace, the player CSV blocks EXTEND).
|
|
494
|
+
The journey assembler's levers (:func:`ff9mapkit.journey.seed_to_field_blocks` /
|
|
495
|
+
:func:`ff9mapkit.journey.tuning_to_field_blocks` produce ``blocks``) seed a journey's ENTRY member without
|
|
496
|
+
rewriting its forked field.toml on disk, so the fork stays clean and a re-deploy is idempotent. Empty
|
|
497
|
+
``blocks`` -> no mutation (byte-identical build)."""
|
|
498
|
+
if not blocks:
|
|
499
|
+
return
|
|
500
|
+
if "startup" in blocks:
|
|
501
|
+
su = raw.setdefault("startup", {})
|
|
502
|
+
if "scenario" in blocks["startup"]:
|
|
503
|
+
su["scenario"] = blocks["startup"]["scenario"]
|
|
504
|
+
if blocks["startup"].get("flags"):
|
|
505
|
+
su["flags"] = list(su.get("flags", [])) + list(blocks["startup"]["flags"])
|
|
506
|
+
if "party" in blocks:
|
|
507
|
+
add = list(raw.setdefault("party", {}).get("add", []))
|
|
508
|
+
for m in blocks["party"].get("add", []):
|
|
509
|
+
if m not in add:
|
|
510
|
+
add.append(m)
|
|
511
|
+
raw["party"]["add"] = add
|
|
512
|
+
if "start_inventory" in blocks:
|
|
513
|
+
raw["start_inventory"] = blocks["start_inventory"]
|
|
514
|
+
if "equipment" in blocks:
|
|
515
|
+
raw["equipment"] = blocks["equipment"]
|
|
516
|
+
if "player_csv" in blocks: # [journey.tuning]: the mod-global player/ability CSV
|
|
517
|
+
for block, rows in blocks["player_csv"].items(): # blocks EXTEND the entry member's same-named blocks (a
|
|
518
|
+
raw[block] = list(raw.get(block, [])) + list(rows) # fork rarely has its own -> usually just sets them)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _remap_text_blocks(projects, base: int) -> dict:
|
|
522
|
+
"""JOURNEY cross-campaign text-shadow cure: remap this campaign's dialogue text blocks into a DISJOINT custom
|
|
523
|
+
mesID window ``[base, base + distinct_blocks)``. Each DISTINCT original block (the donor mesID, shared by
|
|
524
|
+
zone-mates) maps to ``base + idx``, so members that SHARED a block keep sharing the remapped one
|
|
525
|
+
(intra-campaign :func:`build._reconcile_mes` preserved) while no sibling campaign's window can collide. Marks
|
|
526
|
+
each field for MesDB registration (``build_mod`` emits a DictionaryPatch ``MessageFile`` line so DataPatchers'
|
|
527
|
+
FieldScene gate passes; the .mes ships at ``field/<remapped>.mes``). Returns ``{original: remapped}``."""
|
|
528
|
+
distinct = sorted({p.text_block for p in projects})
|
|
529
|
+
remap = {b: int(base) + i for i, b in enumerate(distinct)}
|
|
530
|
+
for p in projects:
|
|
531
|
+
fld = p.raw.setdefault("field", {})
|
|
532
|
+
fld["text_block"] = remap[p.text_block] # follows to the FieldScene textid + field/<id>.mes
|
|
533
|
+
fld["register_text_block"] = True
|
|
534
|
+
return remap
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def build_campaign(campaign_path, out=None, *, author="", description="", allow_artless=False,
|
|
538
|
+
flag_base=None, seed_blocks=None, text_block_base=None, extra_flag_names=None) -> dict:
|
|
539
|
+
"""Compile every member of a campaign.toml into ONE staged Memoria mod (DictionaryPatch + BattlePatch +
|
|
540
|
+
ModDescription + per-field assets), reusing build.build_mod. Returns build_mod's dict + ``plan``/``out``.
|
|
541
|
+
Does NOT deploy (P4). ``out`` defaults to ``<campaign-dir>/dist``.
|
|
542
|
+
|
|
543
|
+
``flag_base`` (the JOURNEY assembler's lever): override the campaign's own ``flag_base`` so the journey
|
|
544
|
+
can hand each of its campaigns a NON-OVERLAPPING ``gEventGlobal`` flag window (two campaigns in one
|
|
545
|
+
journey run together -- they must not clobber each other's bits; :mod:`ff9mapkit.journey`). Applied
|
|
546
|
+
before lint, so the per-member auto-flag blocks + the safe-band checks both use the override.
|
|
547
|
+
|
|
548
|
+
``seed_blocks`` (the journey ``[journey.seed]`` capstone + ``[journey.tuning]``): a dict of ``startup``/
|
|
549
|
+
``party``/``start_inventory``/``equipment``/``player_csv`` blocks merged into the ENTRY member's project
|
|
550
|
+
before build (:func:`apply_seed_blocks`) -- so the journey boots at its seeded beat/party AND its mod-global
|
|
551
|
+
player tuning (the ``.eb`` + CSV channels) without rewriting the forked entry field.toml. ``None`` -> no
|
|
552
|
+
seeding."""
|
|
553
|
+
from .build import FieldProject, build_mod
|
|
554
|
+
campaign_path = Path(campaign_path)
|
|
555
|
+
manifest_dir = campaign_path.parent
|
|
556
|
+
plan = load_campaign(campaign_path)
|
|
557
|
+
if flag_base is not None: # journey-assigned disjoint flag window
|
|
558
|
+
plan.flag_base = int(flag_base)
|
|
559
|
+
lint_errors, lint_warnings = lint_campaign(plan, manifest_dir, extra_flag_names=extra_flag_names)
|
|
560
|
+
if lint_errors:
|
|
561
|
+
raise CampaignError("campaign lint failed:\n - " + "\n - ".join(lint_errors))
|
|
562
|
+
out = Path(out) if out else (manifest_dir / "dist")
|
|
563
|
+
|
|
564
|
+
campaign_names = collect_flag_defs({"flag": plan.flags}) # shared [[flag]] names (lint already validated)
|
|
565
|
+
for nm, idx in (extra_flag_names or {}).items(): # journey-GLOBAL named flags (cross-CAMPAIGN gates);
|
|
566
|
+
campaign_names.setdefault(nm, idx) # the campaign's own name wins on a clash (journey lint forbids it)
|
|
567
|
+
projects = []
|
|
568
|
+
for i, m in enumerate(plan.members):
|
|
569
|
+
toml_path = (manifest_dir / m.toml_rel).resolve() # member subdir -> sidecars resolve via base_dir
|
|
570
|
+
if not toml_path.is_file():
|
|
571
|
+
raise CampaignError(f"member {m.name}: field.toml not found at {toml_path}")
|
|
572
|
+
if m.needs_export and not allow_artless:
|
|
573
|
+
raise CampaignError(
|
|
574
|
+
f"member {m.name} needs in-game art before build: export it once (Memoria.ini [Export] "
|
|
575
|
+
f"Field=1) + re-fork --editable, or pass --allow-artless to build it with no background.")
|
|
576
|
+
proj = FieldProject.load(toml_path, flag_names=campaign_names) # members gate by shared flag NAME
|
|
577
|
+
# Per-member once-flag base so member i's auto event/cutscene/choice flags can't alias a
|
|
578
|
+
# sibling's (the per-field-counter-resets-per-build bug). Block = [flag_base + i*K, +K), packed
|
|
579
|
+
# by build._FlagAlloc. lint_campaign asserts every block is in the provably-safe band. (A [[chest]]'s
|
|
580
|
+
# opened-flag is NOT auto-packed here -- build.validate REQUIRES every chest to pin a DEFINED safe-band
|
|
581
|
+
# flag, a named [[flag]] or index; campaign members share the flag NAME space, so name it.)
|
|
582
|
+
proj.flag_base = plan.flag_base + i * plan.flags_per_field
|
|
583
|
+
proj.flags_per_field = plan.flags_per_field # the overflow guard's per-member block width
|
|
584
|
+
# Do NOT override text_block to a per-member id. The FieldScene textid (6th DictionaryPatch token)
|
|
585
|
+
# MUST already be a key in FF9DBAll.MesDB, or DataPatchers SKIPS the whole scene registration
|
|
586
|
+
# (DataPatchers.cs:392-395 `if (!MesDB.ContainsKey(mesID)) continue;` -- verified in-game: textid
|
|
587
|
+
# 30100 -> "invalid message file ID 30100" -> the field never registers, absent from F6). Empty
|
|
588
|
+
# members ship no .mes, so they keep the kit default 1073 (a real base block in MesDB). Distinct
|
|
589
|
+
# textids only become needed -- AND valid -- once a member SHIPS its own .mes for dialogue; doing
|
|
590
|
+
# that safely (a custom .mes that registers its id in MesDB) is a follow-up, not done here.
|
|
591
|
+
projects.append(proj)
|
|
592
|
+
|
|
593
|
+
if text_block_base: # JOURNEY: a disjoint custom text-block window for this
|
|
594
|
+
_remap_text_blocks(projects, text_block_base) # campaign (the cross-campaign text-shadow cure)
|
|
595
|
+
|
|
596
|
+
# each member's per-member flag_base was set on its FieldProject above; build_script's _FlagAlloc packs
|
|
597
|
+
# that member's auto event/cutscene/choice flags into its own disjoint block (no cross-field alias). A
|
|
598
|
+
# [[chest]] opened-flag is the exception -- it is never auto-allocated; build.validate REQUIRES every chest
|
|
599
|
+
# to define a safe-band flag (a named [[flag]] is campaign-unique by name), so chests can't alias.
|
|
600
|
+
# the entry member's project (by member index) -> precise non-entry lint for the mod-global new-game blocks
|
|
601
|
+
entry_project = next((projects[i] for i, m in enumerate(plan.members) if m.name == plan.entry_name), None)
|
|
602
|
+
if seed_blocks and entry_project is not None: # the journey [journey.seed] capstone, on the entry only
|
|
603
|
+
apply_seed_blocks(entry_project.raw, seed_blocks)
|
|
604
|
+
# the seed can introduce flag NAMES (a journey-global [[flag]] or a campaign-shared one) into the entry's
|
|
605
|
+
# [startup] AFTER FieldProject.load already resolved the on-disk names -> resolve again so the seeded
|
|
606
|
+
# names become indices too (idempotent: an already-resolved int passes through). Else build.validate
|
|
607
|
+
# rejects the seeded `flags = [{flag = "<name>"}]` as "unknown flag name".
|
|
608
|
+
resolve_project_flags(entry_project.raw, extra_names=campaign_names)
|
|
609
|
+
info = build_mod(projects, out, mod_name=plan.mod_folder, author=author, description=description,
|
|
610
|
+
entry_project=entry_project)
|
|
611
|
+
# [ff9mapkit] fork-fidelity: ForkDonorPatch.txt maps each custom-id fork -> its donor real field id, so
|
|
612
|
+
# the engine's behaviors hardcoded on a real fldMapNo (off-mesh exemptions, cutscene party-shape guards,
|
|
613
|
+
# scroll player-binds -- docs/FORK_IDGATE_MAP.md) still fire for the fork. Read by the patched DataPatchers
|
|
614
|
+
# (memoria-patches/s24-fork-donor-remap); a no-op on a stock engine that doesn't read the file.
|
|
615
|
+
donor_lines = [f"{m.new_id} {m.real_id}" for m in plan.members
|
|
616
|
+
if getattr(m, "real_id", None) and m.new_id != m.real_id]
|
|
617
|
+
if donor_lines:
|
|
618
|
+
(Path(out) / "ForkDonorPatch.txt").write_text(
|
|
619
|
+
"# ff9mapkit fork-fidelity: <forkId> <donorRealId>\n" + "\n".join(donor_lines) + "\n",
|
|
620
|
+
encoding="utf-8", newline="\n")
|
|
621
|
+
info["plan"] = plan
|
|
622
|
+
info["out"] = str(Path(out).resolve())
|
|
623
|
+
info["warnings"] = list(lint_warnings) + list(info.get("warnings", []))
|
|
624
|
+
return info
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
# ---- P5: campaign lint (structural + cross-field flags) ---------------------------------
|
|
628
|
+
_CONSUME_KEYS = ("requires_flag", "requires_flag_clear") # explicit flag READS (gates)
|
|
629
|
+
_PRODUCE_KEYS = ("flag", "set_flag") # explicit flag WRITES
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _collect_flags(obj, produced: set, consumed: set):
|
|
633
|
+
"""Walk a member field.toml dict, collecting EXPLICIT GLOB flag indices (ints) under the gate/set
|
|
634
|
+
keys. Auto-allocated 'once' flags are NOT in the toml (computed at build) -> not seen here; per-member
|
|
635
|
+
auto-allocation is a deferred follow-up, so this is the explicit-flag cross-field check."""
|
|
636
|
+
if isinstance(obj, dict):
|
|
637
|
+
for k, v in obj.items():
|
|
638
|
+
if k in _CONSUME_KEYS and isinstance(v, int):
|
|
639
|
+
consumed.add(v)
|
|
640
|
+
elif k in _PRODUCE_KEYS and isinstance(v, int):
|
|
641
|
+
produced.add(v)
|
|
642
|
+
else:
|
|
643
|
+
_collect_flags(v, produced, consumed)
|
|
644
|
+
elif isinstance(obj, list):
|
|
645
|
+
for it in obj:
|
|
646
|
+
_collect_flags(it, produced, consumed)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _member_flags_from_toml(member_raw: dict):
|
|
650
|
+
produced, consumed = set(), set()
|
|
651
|
+
_collect_flags(member_raw, produced, consumed)
|
|
652
|
+
return produced, consumed
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def lint_campaign(plan: CampaignPlan, manifest_dir, *, in_journey: bool = False,
|
|
656
|
+
extra_flag_names=None) -> tuple:
|
|
657
|
+
"""Validate a campaign without building. Returns ``(errors, warnings)``; errors abort build-all,
|
|
658
|
+
warnings are advisory. Pure manifest + member-toml read (no game install). For the empty forks
|
|
659
|
+
import-chain produces, the structural checks fire and the flag checks are silent (correct).
|
|
660
|
+
|
|
661
|
+
``extra_flag_names`` (the JOURNEY tier): name->index for journey-GLOBAL ``[[flag]]``s, so a member
|
|
662
|
+
that gates on a cross-CAMPAIGN flag (set by a sibling campaign or the ``[journey.seed]``) RESOLVES
|
|
663
|
+
here instead of failing as "unknown flag name" -- mirrors ``build_campaign``'s own propagation so
|
|
664
|
+
lint and build AGREE. ``None`` -> campaign-own names only (the standalone ``lint-campaign`` path)."""
|
|
665
|
+
from collections import defaultdict
|
|
666
|
+
manifest_dir = Path(manifest_dir)
|
|
667
|
+
errors, warnings = [], []
|
|
668
|
+
names = {m.name for m in plan.members}
|
|
669
|
+
|
|
670
|
+
try: # (a) ids non-empty / distinct / in [4000,32767]
|
|
671
|
+
validate_ids(plan)
|
|
672
|
+
except CampaignError as e:
|
|
673
|
+
errors.append(str(e))
|
|
674
|
+
|
|
675
|
+
mem_names = [m.name for m in plan.members] # (a2) names distinct (they key edges/seams + the navigator)
|
|
676
|
+
name_dups = sorted({n for n in mem_names if mem_names.count(n) > 1})
|
|
677
|
+
if name_dups:
|
|
678
|
+
errors.append(f"duplicate member names {name_dups} -- names must be unique "
|
|
679
|
+
f"(edges/seams + the campaign navigator key on them)")
|
|
680
|
+
|
|
681
|
+
K = plan.flags_per_field # (a3) per-member flag blocks: in the provably-safe band,
|
|
682
|
+
for i, m in enumerate(plan.members): # clear of real-FF9's chest bitfield + the scratch
|
|
683
|
+
lo, hi = plan.flag_base + i * K, plan.flag_base + i * K + K - 1
|
|
684
|
+
if lo < FIRST_SAFE_FLAG:
|
|
685
|
+
errors.append(f"member {m.name}: flag block {lo}-{hi} dips below the safe floor "
|
|
686
|
+
f"{FIRST_SAFE_FLAG} (overlaps real-FF9 flags) -- raise [campaign] flag_base.")
|
|
687
|
+
if lo <= CHEST_FLAG_HI and hi >= CHEST_FLAG_LO:
|
|
688
|
+
errors.append(f"member {m.name}: flag block {lo}-{hi} intersects real-FF9's treasure-chest "
|
|
689
|
+
f"band {CHEST_FLAG_LO}-{CHEST_FLAG_HI} -> SAVE CORRUPTION -- set [campaign] "
|
|
690
|
+
f"flag_base = {FIRST_SAFE_FLAG}.")
|
|
691
|
+
if hi >= CHOICE_SCRATCH_FLOOR:
|
|
692
|
+
cap = (CHOICE_SCRATCH_FLOOR - plan.flag_base) // K
|
|
693
|
+
errors.append(f"member {m.name}: flag block {lo}-{hi} reaches the choice-scratch floor "
|
|
694
|
+
f"{CHOICE_SCRATCH_FLOOR} -- too many members for the band (max {cap} at this base/K).")
|
|
695
|
+
|
|
696
|
+
try: # (a4) shared [[flag]] names: valid + clear of member blocks
|
|
697
|
+
shared = collect_flag_defs({"flag": plan.flags})
|
|
698
|
+
except ValueError as ex:
|
|
699
|
+
shared, _ = {}, errors.append(f"campaign [[flag]]: {ex}")
|
|
700
|
+
block_hi = plan.flag_base + len(plan.members) * K - 1 # member auto-flag blocks span [flag_base, block_hi]
|
|
701
|
+
for nm, idx in sorted(shared.items()):
|
|
702
|
+
if plan.flag_base <= idx <= block_hi:
|
|
703
|
+
errors.append(f"shared flag {nm!r} (index {idx}) falls inside the per-member auto-flag blocks "
|
|
704
|
+
f"[{plan.flag_base}, {block_hi}] -- put shared flags ABOVE them (>= {block_hi + 1}).")
|
|
705
|
+
|
|
706
|
+
for e in plan.edges: # (b) edges resolve to members
|
|
707
|
+
if e.get("frm") not in names:
|
|
708
|
+
errors.append(f"edge from {e.get('frm')!r}: not a campaign member")
|
|
709
|
+
if e.get("to") not in names:
|
|
710
|
+
errors.append(f"edge to {e.get('to')!r}: not a campaign member")
|
|
711
|
+
if plan.members and plan.entry_name not in names:
|
|
712
|
+
errors.append(f"entry_field {plan.entry_name!r} is not a campaign member")
|
|
713
|
+
|
|
714
|
+
for s in plan.seams: # (c) seams: frm member; to_real int|WORLDMAP; to_member valid
|
|
715
|
+
tr = s.get("to_real")
|
|
716
|
+
if tr != "WORLDMAP" and not isinstance(tr, int):
|
|
717
|
+
errors.append(f"seam from {s.get('frm')!r}: to_real must be an int or 'WORLDMAP' (got {tr!r})")
|
|
718
|
+
if plan.members and s.get("frm") not in names:
|
|
719
|
+
warnings.append(f"seam from {s.get('frm')!r}: not a campaign member (stale name?)")
|
|
720
|
+
tm = s.get("to_member")
|
|
721
|
+
if tm and tm not in names:
|
|
722
|
+
warnings.append(f"seam from {s.get('frm')!r}: to_member {tm!r} is not a member (stale name?)")
|
|
723
|
+
|
|
724
|
+
# (c2) LEAK to an UN-FORKED field: a seam whose target no member forks (to_member is None) and that's a real
|
|
725
|
+
# FIELD warp -- a scripted cutscene/ATE Field() or an out-of-chain door. The verbatim fork carries that
|
|
726
|
+
# Field() un-remapped, so the player is sent OUT to the real (un-forked) game; a GREY/unskippable cutscene
|
|
727
|
+
# warp there softlocks the journey (the exact class that warped a forked Dali ATE into Qu's Marsh). Excludes
|
|
728
|
+
# 'menu' (shops return) and 'overworld'/WORLDMAP (an intended world-map boundary). SUPPRESSED inside a
|
|
729
|
+
# multi-campaign journey -- a sibling campaign's field reads as a non-member here; journey.campaign_connectivity
|
|
730
|
+
# is the sibling-aware check there.
|
|
731
|
+
if not in_journey:
|
|
732
|
+
bad = sorted({(int(s["to_real"]), s.get("kind"), str(s.get("frm"))) for s in plan.seams
|
|
733
|
+
if s.get("kind") in ("scripted", "portal")
|
|
734
|
+
and isinstance(s.get("to_real"), int) and s.get("to_member") is None})
|
|
735
|
+
forced = [(frm, tid) for tid, k, frm in bad if k == "scripted"] # carried cutscene/ATE Field() -> softlock
|
|
736
|
+
doors = [(frm, tid) for tid, k, frm in bad if k == "portal"] # walk-out door -> often the arc's edge
|
|
737
|
+
if forced:
|
|
738
|
+
shown = "; ".join(f"{frm}->{tid}" for frm, tid in forced[:6]) + (" ..." if len(forced) > 6 else "")
|
|
739
|
+
warnings.append(f"{len(forced)} FORCED leak(s) out of the campaign ({shown}) -- a carried cutscene/ATE "
|
|
740
|
+
f"Field() warps the player to an UN-FORKED field (the real game); a grey/unskippable "
|
|
741
|
+
f"one SOFTLOCKS. Fork those in (import-chain --whole-zone) or redirect the warp.")
|
|
742
|
+
if doors:
|
|
743
|
+
shown = "; ".join(f"{frm}->{tid}" for frm, tid in doors[:6]) + (" ..." if len(doors) > 6 else "")
|
|
744
|
+
warnings.append(f"{len(doors)} walk-out door(s) to un-forked field(s) ({shown}) -- likely the "
|
|
745
|
+
f"campaign's edge; fork the next zone, or accept it as the boundary.")
|
|
746
|
+
|
|
747
|
+
member_raw = {} # (e) member field.toml exists, within the campaign folder
|
|
748
|
+
for m in plan.members:
|
|
749
|
+
p = manifest_dir / m.toml_rel
|
|
750
|
+
if not _within(manifest_dir, p): # a crafted toml_rel ('../..') must not read outside
|
|
751
|
+
errors.append(f"member {m.name}: field.toml path escapes the campaign folder ({m.toml_rel})")
|
|
752
|
+
continue
|
|
753
|
+
if not p.is_file():
|
|
754
|
+
errors.append(f"member {m.name}: field.toml not found at {p}")
|
|
755
|
+
continue
|
|
756
|
+
try:
|
|
757
|
+
with open(p, "rb") as fh:
|
|
758
|
+
member_raw[m.name] = tomllib.load(fh)
|
|
759
|
+
except (OSError, tomllib.TOMLDecodeError) as ex:
|
|
760
|
+
errors.append(f"member {m.name}: field.toml unreadable ({ex})")
|
|
761
|
+
|
|
762
|
+
for m in plan.members: # (e2) explicit flags must avoid real-FF9's chest band
|
|
763
|
+
raw = member_raw.get(m.name)
|
|
764
|
+
if raw is None:
|
|
765
|
+
continue
|
|
766
|
+
prod, cons = _member_flags_from_toml(raw)
|
|
767
|
+
for idx in sorted(prod | cons):
|
|
768
|
+
if CHEST_FLAG_LO <= idx <= CHEST_FLAG_HI:
|
|
769
|
+
errors.append(f"member {m.name}: explicit flag {idx} is inside real-FF9's treasure-chest "
|
|
770
|
+
f"band {CHEST_FLAG_LO}-{CHEST_FLAG_HI} -> SAVE CORRUPTION -- use an index in "
|
|
771
|
+
f"[{FIRST_SAFE_FLAG}, {CHOICE_SCRATCH_FLOOR}).")
|
|
772
|
+
elif idx >= FIRST_SAFE_FLAG and idx >= CHOICE_SCRATCH_FLOOR:
|
|
773
|
+
warnings.append(f"member {m.name}: explicit flag {idx} is at/above the choice-scratch floor "
|
|
774
|
+
f"{CHOICE_SCRATCH_FLOOR} (engine-owned) -- pick a lower index.")
|
|
775
|
+
|
|
776
|
+
for nm in plan.needs_export: # (f) artless members
|
|
777
|
+
warnings.append(f"member {nm}: needs in-game art ([Export] Field=1) before a real build")
|
|
778
|
+
|
|
779
|
+
# (g) ungated stacked story-conditional door -- DECLARATIVE gateways only. A VERBATIM member ships the donor
|
|
780
|
+
# .eb whole, so its if(flag){A}else{B} door is carried + resolved by the engine (nothing authored to
|
|
781
|
+
# gate -> the warning would be a false positive). BUT a DEGRADED member (needs_export: a logic-only stub)
|
|
782
|
+
# re-authors its gateways declaratively even in a verbatim chain, so it still needs the gating advice.
|
|
783
|
+
degraded = set(plan.needs_export)
|
|
784
|
+
stacked = defaultdict(int)
|
|
785
|
+
for e in plan.edges:
|
|
786
|
+
frm = e.get("frm")
|
|
787
|
+
if e.get("story_conditional") and (not plan.verbatim or frm in degraded):
|
|
788
|
+
stacked[frm] += 1
|
|
789
|
+
for frm, n in stacked.items():
|
|
790
|
+
if n >= 2:
|
|
791
|
+
warnings.append(f"member {frm}: {n} stacked same-zone exits (story-conditional) -- set "
|
|
792
|
+
f"requires_flag on each in its field.toml, else the engine resolves only one")
|
|
793
|
+
|
|
794
|
+
if not errors: # (h) cross-field flag dependencies (NAME gates included)
|
|
795
|
+
# journey-GLOBAL flag names (manifest [[flag]]) are resolvable here too -- a member may gate on a
|
|
796
|
+
# cross-CAMPAIGN flag SET by a sibling campaign (or the [journey.seed]); without them the member's
|
|
797
|
+
# `requires_flag = "<journey-global>"` mis-reads as "unknown flag name" and BLOCKS the deploy (the
|
|
798
|
+
# exact lint/build disagreement that broke the journey-global tier). The campaign's own name wins.
|
|
799
|
+
jglobal = dict(extra_flag_names or {}) # name -> absolute index
|
|
800
|
+
known = {**jglobal, **shared}
|
|
801
|
+
jglobal_idx = set(jglobal.values())
|
|
802
|
+
producers, consumers = {}, []
|
|
803
|
+
for m in plan.members:
|
|
804
|
+
raw = member_raw.get(m.name)
|
|
805
|
+
if raw is None:
|
|
806
|
+
continue
|
|
807
|
+
try: # resolve member-own + shared + journey-global NAME gates ->
|
|
808
|
+
resolve_project_flags(raw, extra_names=known) # indices, so a name dependency is seen (not skipped)
|
|
809
|
+
except ValueError as ex: # a gate on a name defined NOWHERE -> the build would fail too
|
|
810
|
+
errors.append(f"member {m.name}: {ex}")
|
|
811
|
+
continue
|
|
812
|
+
prod, cons = _member_flags_from_toml(raw)
|
|
813
|
+
for idx in prod:
|
|
814
|
+
producers.setdefault(idx, set()).add(m.name)
|
|
815
|
+
for idx in cons:
|
|
816
|
+
consumers.append((idx, m.name))
|
|
817
|
+
for idx, who in consumers:
|
|
818
|
+
if idx not in producers and idx not in jglobal_idx: # a journey-global gate is set cross-campaign
|
|
819
|
+
warnings.append(f"member {who}: requires explicit flag {idx}, but no member sets it -- "
|
|
820
|
+
f"the gate is permanently locked")
|
|
821
|
+
for idx, who in sorted(producers.items()):
|
|
822
|
+
if len(who) >= 2:
|
|
823
|
+
warnings.append(f"explicit flag {idx} written by multiple members {sorted(who)} -- "
|
|
824
|
+
f"unintended cross-field coupling (use distinct indices)")
|
|
825
|
+
|
|
826
|
+
return errors, warnings
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
# ---- read-only resolved graph (the campaign-workspace view; pure, no game) ---------------
|
|
830
|
+
@dataclass
|
|
831
|
+
class GraphNode:
|
|
832
|
+
"""A member resolved into its place in the chain: its live in/out doors (to member NAMES, not raw ids)
|
|
833
|
+
+ onward seams, plus reachability/leaf flags. A pure derived view -- nothing here that isn't already
|
|
834
|
+
in the CampaignPlan."""
|
|
835
|
+
name: str
|
|
836
|
+
new_id: int
|
|
837
|
+
real_id: int
|
|
838
|
+
mode: str
|
|
839
|
+
needs_export: bool
|
|
840
|
+
is_entry: bool
|
|
841
|
+
reachable: bool # reachable from the entry via LIVE edges (seams don't count)
|
|
842
|
+
dead_end: bool # no onward connection at all (no edges, no seams)
|
|
843
|
+
out_edges: list # [{"to": name, "entrance": int, "gated": bool}]
|
|
844
|
+
in_edges: list # [{"frm": name, "entrance": int, "gated": bool}]
|
|
845
|
+
seams: list # [{"to_real": int|"WORLDMAP", "kind": str, "note": str, "to_member"}]
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
@dataclass
|
|
849
|
+
class CampaignGraph:
|
|
850
|
+
"""The whole campaign resolved for navigation/visualization: members as GraphNodes (in BFS-id order) +
|
|
851
|
+
the campaign-level findings a workspace UI surfaces (unreachable members, dead-ends, dangling edges)."""
|
|
852
|
+
entry: "str | None" # resolved entry member name (falls back to first member)
|
|
853
|
+
entry_valid: bool # plan.entry_name actually names a member
|
|
854
|
+
nodes: list # list[GraphNode], in member (id) order
|
|
855
|
+
unreachable: list # member names with no live-door path from the entry
|
|
856
|
+
dead_ends: list # member names with no onward connection
|
|
857
|
+
dangling_edges: list # [[edge]] rows whose from/to is not a member (stale manifest)
|
|
858
|
+
dangling_seams: list # [[seam]] rows whose `from` is not a member (stale manifest)
|
|
859
|
+
|
|
860
|
+
@property
|
|
861
|
+
def by_name(self) -> dict:
|
|
862
|
+
return {n.name: n for n in self.nodes}
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def _as_int(v, default=0):
|
|
866
|
+
"""``int(v)`` but tolerant -- a malformed/None value (from a hand-edited manifest) degrades to
|
|
867
|
+
``default`` instead of raising, so campaign_graph keeps its 'never choke on a stale toml' contract."""
|
|
868
|
+
try:
|
|
869
|
+
return int(v)
|
|
870
|
+
except (TypeError, ValueError):
|
|
871
|
+
return default
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def campaign_graph(plan: CampaignPlan) -> CampaignGraph:
|
|
875
|
+
"""Resolve a CampaignPlan into a navigable graph: every member with its in/out live doors (to member
|
|
876
|
+
NAMES), onward seams, and reachability from the entry. PURE over the plan -- the retargeted gateways
|
|
877
|
+
live in the member field.tomls, but the manifest's [[edge]] rows mirror them 1:1, so connectivity is
|
|
878
|
+
fully derivable here (no member-toml read, no game install). Tolerant of a stale / hand-edited
|
|
879
|
+
manifest: an edge to a non-member is recorded in ``dangling_edges`` rather than raising (that's
|
|
880
|
+
lint_campaign's job). Entry resolution mirrors deploy_campaign (explicit member name > first member)."""
|
|
881
|
+
names = [m.name for m in plan.members]
|
|
882
|
+
nameset = set(names)
|
|
883
|
+
out_by = {n: [] for n in names}
|
|
884
|
+
in_by = {n: [] for n in names}
|
|
885
|
+
seams_by = {n: [] for n in names}
|
|
886
|
+
dangling_edges, dangling_seams = [], []
|
|
887
|
+
for e in plan.edges:
|
|
888
|
+
frm, to = e.get("frm"), e.get("to")
|
|
889
|
+
gated = bool(e.get("story_conditional"))
|
|
890
|
+
ent = _as_int(e.get("entrance"))
|
|
891
|
+
if frm not in nameset or to not in nameset:
|
|
892
|
+
dangling_edges.append(dict(e))
|
|
893
|
+
continue
|
|
894
|
+
out_by[frm].append({"to": to, "entrance": ent, "gated": gated})
|
|
895
|
+
in_by[to].append({"frm": frm, "entrance": ent, "gated": gated})
|
|
896
|
+
for s in plan.seams:
|
|
897
|
+
frm = s.get("frm")
|
|
898
|
+
if frm in seams_by:
|
|
899
|
+
seams_by[frm].append({"to_real": s.get("to_real"), "kind": s.get("kind"),
|
|
900
|
+
"note": s.get("note", ""), "to_member": s.get("to_member")})
|
|
901
|
+
else: # a seam from a non-member -> surface it, don't drop it
|
|
902
|
+
dangling_seams.append(dict(s))
|
|
903
|
+
|
|
904
|
+
entry_valid = plan.entry_name in nameset
|
|
905
|
+
entry = plan.entry_name if entry_valid else (names[0] if names else None)
|
|
906
|
+
|
|
907
|
+
reached = set() # BFS from the entry over live edges only
|
|
908
|
+
if entry is not None:
|
|
909
|
+
reached.add(entry)
|
|
910
|
+
stack = [entry]
|
|
911
|
+
while stack:
|
|
912
|
+
for nxt in (oe["to"] for oe in out_by.get(stack.pop(), [])):
|
|
913
|
+
if nxt not in reached:
|
|
914
|
+
reached.add(nxt)
|
|
915
|
+
stack.append(nxt)
|
|
916
|
+
# A VERBATIM fork ships each member's WHOLE donor .eb, so its real connectivity -- story-scripted warps,
|
|
917
|
+
# per-door arrival tables, story-gated transitions -- is intact and runs in-game, but the static walk-in-edge
|
|
918
|
+
# BFS can't see it: a whole-zone fork's screens reached only by cutscene, or other-disc room variants, read
|
|
919
|
+
# as "unreachable" though every forked real field WAS reachable in the real game (FF9 has no unused fields).
|
|
920
|
+
# So don't flag verbatim members as unreachable -- it's a false-positive flood, not stranded content. (A
|
|
921
|
+
# DECLARATIVE campaign's reachability IS meaningful: its gateways are authored from these very edges.)
|
|
922
|
+
if getattr(plan, "verbatim", False):
|
|
923
|
+
reached = set(names)
|
|
924
|
+
|
|
925
|
+
nodes = []
|
|
926
|
+
for m in plan.members:
|
|
927
|
+
dead = not out_by[m.name] and not seams_by[m.name]
|
|
928
|
+
nodes.append(GraphNode(
|
|
929
|
+
name=m.name, new_id=m.new_id, real_id=m.real_id, mode=m.mode, needs_export=m.needs_export,
|
|
930
|
+
is_entry=(m.name == entry), reachable=(m.name in reached), dead_end=dead,
|
|
931
|
+
out_edges=out_by[m.name], in_edges=in_by[m.name], seams=seams_by[m.name]))
|
|
932
|
+
return CampaignGraph(
|
|
933
|
+
entry=entry, entry_valid=entry_valid, nodes=nodes,
|
|
934
|
+
unreachable=[m.name for m in plan.members if m.name not in reached],
|
|
935
|
+
dead_ends=[n.name for n in nodes if n.dead_end],
|
|
936
|
+
dangling_edges=dangling_edges, dangling_seams=dangling_seams)
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def render_graph(plan: CampaignPlan) -> str:
|
|
940
|
+
"""A human-readable view of a LOADED campaign's connectivity -- the post-fork twin of chain.render
|
|
941
|
+
(which only works on a fresh GraphResult). Each member, its live doors resolved to member names, onward
|
|
942
|
+
seams, and dead-end / unreachable / needs-export flags. Backs the `lint-campaign --graph` CLI + the
|
|
943
|
+
campaign workspace's text graph panel."""
|
|
944
|
+
g = campaign_graph(plan)
|
|
945
|
+
ids = [m.new_id for m in plan.members]
|
|
946
|
+
rng = f"{min(ids)}..{max(ids)}" if ids else "-"
|
|
947
|
+
note = "" if g.entry_valid or not plan.members else " (entry_field not a member -- using first)"
|
|
948
|
+
out = [f"campaign {plan.name} ({len(plan.members)} members, ids {rng}) "
|
|
949
|
+
f"entry: {g.entry or '(none)'} (entrance {plan.entry_entrance}){note}", ""]
|
|
950
|
+
for n in g.nodes:
|
|
951
|
+
tags = []
|
|
952
|
+
if n.is_entry:
|
|
953
|
+
tags.append("ENTRY")
|
|
954
|
+
if n.mode != "borrow":
|
|
955
|
+
tags.append(n.mode)
|
|
956
|
+
if n.needs_export:
|
|
957
|
+
tags.append("needs-export")
|
|
958
|
+
if not n.reachable:
|
|
959
|
+
tags.append("UNREACHABLE")
|
|
960
|
+
if n.dead_end:
|
|
961
|
+
tags.append("dead-end")
|
|
962
|
+
tagstr = (" [" + ", ".join(tags) + "]") if tags else ""
|
|
963
|
+
out.append(f"{n.name:<16} id={n.new_id} (was {n.real_id}){tagstr}")
|
|
964
|
+
for oe in n.out_edges:
|
|
965
|
+
out.append(f" -> {oe['to']} (entrance {oe['entrance']})" + (" [gated]" if oe["gated"] else ""))
|
|
966
|
+
for s in n.seams:
|
|
967
|
+
tgt = s["to_member"] or ("WORLDMAP" if s["to_real"] == "WORLDMAP" else s["to_real"])
|
|
968
|
+
out.append(f" ~> seam[{s['kind']}] -> {tgt}" + (f" ({s['note']})" if s.get("note") else ""))
|
|
969
|
+
if not n.out_edges and not n.seams:
|
|
970
|
+
out.append(" (no onward connections)")
|
|
971
|
+
out.append("")
|
|
972
|
+
if g.unreachable:
|
|
973
|
+
out.append("UNREACHABLE FROM ENTRY: " + ", ".join(g.unreachable))
|
|
974
|
+
if g.dangling_edges:
|
|
975
|
+
out.append("DANGLING EDGES (target not a member -- stale manifest?): "
|
|
976
|
+
+ ", ".join(f"{e.get('frm')}->{e.get('to')}" for e in g.dangling_edges))
|
|
977
|
+
if g.dangling_seams:
|
|
978
|
+
out.append("DANGLING SEAMS (from not a member -- stale manifest?): "
|
|
979
|
+
+ ", ".join(f"{s.get('frm')}->{s.get('to_real')}" for s in g.dangling_seams))
|
|
980
|
+
return "\n".join(out).rstrip() + "\n"
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
# ---- P6: mutation / creation API (author/edit a campaign WITHOUT import-chain) -----------
|
|
984
|
+
# import-chain FORKS a connected real-game region; this is the from-scratch / hand-edit twin -- create an
|
|
985
|
+
# empty campaign and add/remove/rename members + edges by hand. Every mutation re-renders campaign.toml
|
|
986
|
+
# through render_campaign_toml so the manifest stays the single round-trip-safe source of truth, and ids
|
|
987
|
+
# are next-free (never renumbered -- a renumber would have to rewrite every member's retargeted gateways).
|
|
988
|
+
def _save_plan(plan: CampaignPlan, manifest_dir) -> Path:
|
|
989
|
+
"""(Re)write campaign.toml from the in-memory plan -- the single persistence point for every mutation."""
|
|
990
|
+
p = Path(manifest_dir) / "campaign.toml"
|
|
991
|
+
p.write_text(render_campaign_toml(plan), encoding="utf-8", newline="\n")
|
|
992
|
+
return p
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def validate_shared_flags(flags) -> list:
|
|
996
|
+
"""Normalize + validate a campaign's SHARED named flags (a list of ``{name, index}``): non-empty unique
|
|
997
|
+
names, integer indices that are UNIQUE and in the provably-safe custom band ``[FIRST_SAFE_FLAG,
|
|
998
|
+
CHOICE_SCRATCH_FLOOR)`` (so a shared flag can't collide with FF9's real bits or the choice scratch).
|
|
999
|
+
Returns the cleaned list; raises :class:`CampaignError` on the first problem."""
|
|
1000
|
+
out, seen_n, seen_i = [], set(), set()
|
|
1001
|
+
for f in flags or []:
|
|
1002
|
+
nm = str((f or {}).get("name", "")).strip()
|
|
1003
|
+
if not nm:
|
|
1004
|
+
raise CampaignError("a shared flag needs a name")
|
|
1005
|
+
if nm in seen_n:
|
|
1006
|
+
raise CampaignError(f"duplicate shared-flag name {nm!r}")
|
|
1007
|
+
try:
|
|
1008
|
+
idx = int(f.get("index"))
|
|
1009
|
+
except (TypeError, ValueError):
|
|
1010
|
+
raise CampaignError(f"shared flag {nm!r} needs an integer index")
|
|
1011
|
+
if not (FIRST_SAFE_FLAG <= idx < CHOICE_SCRATCH_FLOOR):
|
|
1012
|
+
raise CampaignError(f"shared flag {nm!r} index {idx} is outside the safe band "
|
|
1013
|
+
f"[{FIRST_SAFE_FLAG}, {CHOICE_SCRATCH_FLOOR})")
|
|
1014
|
+
if idx in seen_i:
|
|
1015
|
+
raise CampaignError(f"shared-flag index {idx} is used by two flags")
|
|
1016
|
+
seen_n.add(nm)
|
|
1017
|
+
seen_i.add(idx)
|
|
1018
|
+
out.append({"name": nm, "index": idx})
|
|
1019
|
+
return out
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def set_shared_flags(plan: CampaignPlan, manifest_dir, flags) -> Path:
|
|
1023
|
+
"""Replace the campaign's SHARED ``[[flag]]`` table (cross-field named story flags every member gates by
|
|
1024
|
+
name) + re-render campaign.toml. ``flags`` = a list of ``{name, index}``; validated by
|
|
1025
|
+
:func:`validate_shared_flags`. The build already hands these names to every member (``flag_names``)."""
|
|
1026
|
+
plan.flags = validate_shared_flags(flags)
|
|
1027
|
+
return _save_plan(plan, manifest_dir)
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def _next_member_id(plan: CampaignPlan) -> int:
|
|
1031
|
+
"""Next free member id: max existing + 1 (ids needn't be contiguous; removes leave gaps), or id_base
|
|
1032
|
+
for the first member. Never renumbers existing members (that would rewrite their retargeted gateways)."""
|
|
1033
|
+
return max((m.new_id for m in plan.members), default=plan.id_base - 1) + 1
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def _subdir_of(member: Member) -> str:
|
|
1037
|
+
"""The member's on-disk subdir (the first path component of toml_rel)."""
|
|
1038
|
+
return Path(member.toml_rel).parts[0]
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
def _within(base, path) -> bool:
|
|
1042
|
+
"""True if ``path`` resolves to ``base`` itself or somewhere inside it -- the guard that keeps a
|
|
1043
|
+
crafted/stale ``toml_rel`` (``../..``) from letting a mutation rename/rmtree/read OUTSIDE the campaign."""
|
|
1044
|
+
base, path = Path(base).resolve(), Path(path).resolve()
|
|
1045
|
+
return path == base or base in path.parents
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
def _validate_member_name(name: str) -> str:
|
|
1049
|
+
"""A member name is a simple token -- it becomes an on-disk subdir + the key edges/seams reference, so
|
|
1050
|
+
no path separators / traversal / surrounding whitespace."""
|
|
1051
|
+
name = str(name)
|
|
1052
|
+
if not name or name != name.strip() or name in (".", "..") or any(c in name for c in "/\\"):
|
|
1053
|
+
raise CampaignError(f"invalid member name {name!r} (no path separators / leading-trailing space)")
|
|
1054
|
+
return name
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
def _safe_member_dir(manifest_dir, member: Member) -> Path:
|
|
1058
|
+
"""A member's subdir, RESOLVED and validated to stay within manifest_dir -- the guard before any
|
|
1059
|
+
destructive rename/rmtree (a crafted toml_rel must never reach outside the campaign folder)."""
|
|
1060
|
+
sub = Path(manifest_dir) / _subdir_of(member)
|
|
1061
|
+
if not _within(manifest_dir, sub):
|
|
1062
|
+
raise CampaignError(f"member {member.name!r}: subdir escapes the campaign folder ({member.toml_rel})")
|
|
1063
|
+
return sub.resolve()
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
def _resolve_source_id(source) -> int:
|
|
1067
|
+
"""A real field reference (an id, or a unique FBG-folder substring) -> its FIELD ID (the donor). For
|
|
1068
|
+
add_field's fork path. The ID is what disambiguates a folder SHARED by several fields (the same room at
|
|
1069
|
+
different story beats, e.g. 52/3008) -- a folder name can't, so a shared-folder substring is rejected
|
|
1070
|
+
(pass the id). Raises if it's not a single known field. (Mirrors import-chain's fork-by-id; the donor must
|
|
1071
|
+
resolve its OWN .eb, not the folder-keyed winner.)"""
|
|
1072
|
+
from . import extract
|
|
1073
|
+
try:
|
|
1074
|
+
fid = int(source)
|
|
1075
|
+
if fid in extract.ID_TO_FBG:
|
|
1076
|
+
return fid
|
|
1077
|
+
except (TypeError, ValueError):
|
|
1078
|
+
pass
|
|
1079
|
+
s = str(source).lower()
|
|
1080
|
+
hits = sorted(fid for fid, f in extract.ID_TO_FBG.items() if s in f.lower())
|
|
1081
|
+
if len(hits) == 1:
|
|
1082
|
+
return hits[0]
|
|
1083
|
+
raise CampaignError(f"source {source!r} matched {len(hits)} fields {hits[:8]} -- give a field id "
|
|
1084
|
+
f"(a shared FBG folder maps to several fields) or a unique FBG name")
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
def new_campaign(name, mod_folder, manifest_dir, *, id_base=4000, flag_base=FIRST_SAFE_FLAG,
|
|
1088
|
+
flags_per_field=64, entry_entrance=0) -> CampaignPlan:
|
|
1089
|
+
"""Create an EMPTY campaign (no members) and write its campaign.toml -- the from-scratch path that
|
|
1090
|
+
import-chain (which forks a real region) doesn't cover. Add members with :func:`add_field`. The default
|
|
1091
|
+
flag_base is the census-grounded safe floor (clear of real-FF9 chest flags); see :mod:`flags`."""
|
|
1092
|
+
if not (4000 <= id_base <= 32767):
|
|
1093
|
+
raise CampaignError(f"id_base {id_base} out of range (must be 4000-32767)")
|
|
1094
|
+
plan = CampaignPlan(name=str(name), mod_folder=str(mod_folder), id_base=int(id_base),
|
|
1095
|
+
flag_base=int(flag_base), flags_per_field=int(flags_per_field),
|
|
1096
|
+
entry_name="", entry_entrance=int(entry_entrance))
|
|
1097
|
+
Path(manifest_dir).mkdir(parents=True, exist_ok=True)
|
|
1098
|
+
_save_plan(plan, manifest_dir)
|
|
1099
|
+
return plan
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def add_field(plan: CampaignPlan, manifest_dir, *, name, source=None, game=None) -> Member:
|
|
1103
|
+
"""Add a member to a campaign + re-render campaign.toml. ``source=None`` scaffolds a BLANK room
|
|
1104
|
+
(offline, via pack.new_project -- placeholder art, walkable). A ``source`` (a real field id or a unique
|
|
1105
|
+
FBG-folder substring) FORKS that real field (needs the game install, like import-chain), retargeting
|
|
1106
|
+
any gateway that points at an existing member. The member gets the next free id (no renumber). The
|
|
1107
|
+
first member added becomes the entry if none is set yet."""
|
|
1108
|
+
from . import extract
|
|
1109
|
+
name = _validate_member_name(name)
|
|
1110
|
+
manifest_dir = Path(manifest_dir)
|
|
1111
|
+
if any(m.name == name for m in plan.members):
|
|
1112
|
+
raise CampaignError(f"member name {name!r} is already in this campaign")
|
|
1113
|
+
new_id = _next_member_id(plan)
|
|
1114
|
+
if new_id > 32767:
|
|
1115
|
+
raise CampaignError(f"next member id {new_id} exceeds 32767 (the live fldMapNo is Int16)")
|
|
1116
|
+
if source is None: # blank/template member -- fully offline
|
|
1117
|
+
from . import pack
|
|
1118
|
+
pack.new_project(name, manifest_dir, field_id=new_id, area=11)
|
|
1119
|
+
member = Member(0, new_id, name, "editable", 11, "", f"{name}/{name.lower()}.field.toml", False)
|
|
1120
|
+
else: # fork a real field -- needs the game
|
|
1121
|
+
real_id = _resolve_source_id(source) # the donor ID (disambiguates a shared FBG folder)
|
|
1122
|
+
folder = extract.ID_TO_FBG[real_id]
|
|
1123
|
+
donor = str(real_id) # fork by ID, not the (possibly shared) folder name --
|
|
1124
|
+
# so the writers ship THIS field's .eb/scene, not the folder-keyed winner (mirrors write_campaign).
|
|
1125
|
+
area, _ = extract.parse_fbg_folder(folder)
|
|
1126
|
+
mode = "borrow" if area >= extract.MIN_CUSTOM_AREA else "native"
|
|
1127
|
+
mdir = manifest_dir / name
|
|
1128
|
+
mdir.mkdir(parents=True, exist_ok=True)
|
|
1129
|
+
remap = {m.real_id: m.new_id for m in plan.members if m.real_id}
|
|
1130
|
+
remap[real_id] = new_id # so a self/back-reference retargets to this member
|
|
1131
|
+
needs_export = False
|
|
1132
|
+
try: # area<10 forks NATIVE (own atlas+.bgs, no .bgx)
|
|
1133
|
+
fork = extract.write_field_project if mode == "borrow" else extract.write_native_project
|
|
1134
|
+
_meta, p = fork(donor, mdir, name=name, field_id=new_id, game=game, id_remap=remap)
|
|
1135
|
+
except RuntimeError: # a field with no usable background atlas (rare)
|
|
1136
|
+
if mode == "borrow":
|
|
1137
|
+
raise
|
|
1138
|
+
_meta, p = _emit_logic_only_member(donor, mdir, name, new_id, remap, False, game)
|
|
1139
|
+
needs_export = True
|
|
1140
|
+
member = Member(real_id, new_id, name, mode, area, folder, f"{name}/{p.name}", needs_export)
|
|
1141
|
+
plan.members.append(member)
|
|
1142
|
+
if not plan.entry_name:
|
|
1143
|
+
plan.entry_name = name
|
|
1144
|
+
_save_plan(plan, manifest_dir)
|
|
1145
|
+
return member
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def remove_field(plan: CampaignPlan, manifest_dir, name) -> None:
|
|
1149
|
+
"""Drop a member from the campaign: remove its subdir, prune every edge/seam that referenced it, and
|
|
1150
|
+
re-point the entry if it was the removed member. Leaves an id gap (no renumber)."""
|
|
1151
|
+
import shutil
|
|
1152
|
+
manifest_dir = Path(manifest_dir)
|
|
1153
|
+
m = next((x for x in plan.members if x.name == name), None)
|
|
1154
|
+
if m is None:
|
|
1155
|
+
raise CampaignError(f"no member named {name!r}")
|
|
1156
|
+
mdir = _safe_member_dir(manifest_dir, m) # validate within manifest_dir BEFORE any mutation
|
|
1157
|
+
plan.members.remove(m)
|
|
1158
|
+
plan.edges = [e for e in plan.edges if e.get("frm") != name and e.get("to") != name]
|
|
1159
|
+
plan.seams = [s for s in plan.seams if s.get("frm") != name]
|
|
1160
|
+
for s in plan.seams:
|
|
1161
|
+
if s.get("to_member") == name:
|
|
1162
|
+
s["to_member"] = None
|
|
1163
|
+
if plan.entry_name == name:
|
|
1164
|
+
plan.entry_name = plan.members[0].name if plan.members else ""
|
|
1165
|
+
if mdir.is_dir():
|
|
1166
|
+
shutil.rmtree(mdir, ignore_errors=True)
|
|
1167
|
+
_save_plan(plan, manifest_dir)
|
|
1168
|
+
|
|
1169
|
+
|
|
1170
|
+
def rename_field(plan: CampaignPlan, manifest_dir, old, new) -> None:
|
|
1171
|
+
"""Rename a member's STRUCTURAL identity: its subdir + toml_rel + campaign.toml name, and rekey every
|
|
1172
|
+
edge/seam/entry that referenced it. Does NOT touch the field's in-game ``[field] name`` (that's the
|
|
1173
|
+
separate display name the Logic Editor owns) or the inner field.toml filename -- a structural rename
|
|
1174
|
+
only. Ids are unchanged, so no member's gateways need rewriting."""
|
|
1175
|
+
manifest_dir = Path(manifest_dir)
|
|
1176
|
+
m = next((x for x in plan.members if x.name == old), None)
|
|
1177
|
+
if m is None:
|
|
1178
|
+
raise CampaignError(f"no member named {old!r}")
|
|
1179
|
+
new = _validate_member_name(new)
|
|
1180
|
+
if old == new:
|
|
1181
|
+
return
|
|
1182
|
+
if any(x.name == new for x in plan.members):
|
|
1183
|
+
raise CampaignError(f"member name {new!r} is already in this campaign")
|
|
1184
|
+
old_dir = _safe_member_dir(manifest_dir, m) # validated within manifest_dir before the rename
|
|
1185
|
+
new_dir = (manifest_dir / new).resolve()
|
|
1186
|
+
if old_dir.is_dir() and old_dir != new_dir:
|
|
1187
|
+
if new_dir.exists():
|
|
1188
|
+
raise CampaignError(f"cannot rename onto existing path {new_dir}")
|
|
1189
|
+
old_dir.rename(new_dir)
|
|
1190
|
+
m.toml_rel = f"{new}/{Path(m.toml_rel).name}" # keep the inner filename; swap the subdir
|
|
1191
|
+
m.name = new
|
|
1192
|
+
for e in plan.edges:
|
|
1193
|
+
if e.get("frm") == old:
|
|
1194
|
+
e["frm"] = new
|
|
1195
|
+
if e.get("to") == old:
|
|
1196
|
+
e["to"] = new
|
|
1197
|
+
for s in plan.seams:
|
|
1198
|
+
if s.get("frm") == old:
|
|
1199
|
+
s["frm"] = new
|
|
1200
|
+
if s.get("to_member") == old:
|
|
1201
|
+
s["to_member"] = new
|
|
1202
|
+
if plan.entry_name == old:
|
|
1203
|
+
plan.entry_name = new
|
|
1204
|
+
_save_plan(plan, manifest_dir)
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
def set_entry(plan: CampaignPlan, manifest_dir, name, *, entrance=None) -> None:
|
|
1208
|
+
"""Set the campaign's entry member (and optionally its entrance). Validates the member exists."""
|
|
1209
|
+
if name not in {m.name for m in plan.members}:
|
|
1210
|
+
raise CampaignError(f"entry {name!r} is not a campaign member")
|
|
1211
|
+
plan.entry_name = name
|
|
1212
|
+
if entrance is not None:
|
|
1213
|
+
plan.entry_entrance = int(entrance)
|
|
1214
|
+
_save_plan(plan, manifest_dir)
|
|
1215
|
+
|
|
1216
|
+
|
|
1217
|
+
def add_edge(plan: CampaignPlan, manifest_dir, frm, to, *, entrance=0, gated=False) -> None:
|
|
1218
|
+
"""Record an in-chain connection in the graph (campaign.toml [[edge]]). NOTE: this is the graph-level
|
|
1219
|
+
reflection of connectivity -- the LIVE door is a ``[[gateway]]`` you author in the source member's
|
|
1220
|
+
field.toml (the Logic Editor). Both ends must be members."""
|
|
1221
|
+
names = {m.name for m in plan.members}
|
|
1222
|
+
if frm not in names or to not in names:
|
|
1223
|
+
raise CampaignError(f"edge {frm!r}->{to!r}: both ends must be campaign members")
|
|
1224
|
+
plan.edges.append({"frm": frm, "to": to, "entrance": int(entrance),
|
|
1225
|
+
"story_conditional": bool(gated)})
|
|
1226
|
+
_save_plan(plan, manifest_dir)
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
def remove_edge(plan: CampaignPlan, manifest_dir, frm, to) -> None:
|
|
1230
|
+
"""Remove the graph edge(s) frm->to (campaign.toml [[edge]])."""
|
|
1231
|
+
plan.edges = [e for e in plan.edges if not (e.get("frm") == frm and e.get("to") == to)]
|
|
1232
|
+
_save_plan(plan, manifest_dir)
|
|
1233
|
+
|
|
1234
|
+
|
|
1235
|
+
def _shared_flag_floor(plan: CampaignPlan) -> int:
|
|
1236
|
+
"""The lowest index a shared [[flag]] may take: just ABOVE every per-member auto-flag block (which span
|
|
1237
|
+
[flag_base, flag_base + members*K)), and never below the census-safe floor."""
|
|
1238
|
+
block_hi = plan.flag_base + len(plan.members) * plan.flags_per_field - 1
|
|
1239
|
+
return max(block_hi + 1, FIRST_SAFE_FLAG)
|
|
1240
|
+
|
|
1241
|
+
|
|
1242
|
+
def add_flag(plan: CampaignPlan, manifest_dir, name, index=None) -> dict:
|
|
1243
|
+
"""Add a shared NAMED campaign flag (a cross-field story gate) to campaign.toml's [[flag]] table; members
|
|
1244
|
+
then gate by NAME (``requires_flag = "<name>"``). ``index=None`` auto-picks the next free safe index
|
|
1245
|
+
ABOVE the per-member auto-flag blocks (inside [FIRST_SAFE_FLAG, CHOICE_SCRATCH_FLOOR)). Validates name +
|
|
1246
|
+
band; returns the new ``{name, index}``."""
|
|
1247
|
+
name = str(name).strip()
|
|
1248
|
+
if not name:
|
|
1249
|
+
raise CampaignError("a shared flag needs a name")
|
|
1250
|
+
floor = _shared_flag_floor(plan)
|
|
1251
|
+
used = {int(f.get("index", -1)) for f in plan.flags}
|
|
1252
|
+
if index is None:
|
|
1253
|
+
index = max([floor] + [i + 1 for i in used])
|
|
1254
|
+
index = int(index)
|
|
1255
|
+
if not (floor <= index < CHOICE_SCRATCH_FLOOR):
|
|
1256
|
+
raise CampaignError(f"flag index {index} must be in [{floor}, {CHOICE_SCRATCH_FLOOR}) -- above the "
|
|
1257
|
+
f"per-member auto-flag blocks, below the choice scratch")
|
|
1258
|
+
if index in used:
|
|
1259
|
+
raise CampaignError(f"flag index {index} is already used by another shared flag")
|
|
1260
|
+
plan.flags.append({"name": name, "index": index})
|
|
1261
|
+
try:
|
|
1262
|
+
collect_flag_defs({"flag": plan.flags}) # re-validate (dup name, safe band); rollback on failure
|
|
1263
|
+
except ValueError as e:
|
|
1264
|
+
plan.flags.pop()
|
|
1265
|
+
raise CampaignError(str(e))
|
|
1266
|
+
_save_plan(plan, manifest_dir)
|
|
1267
|
+
return {"name": name, "index": index}
|
|
1268
|
+
|
|
1269
|
+
|
|
1270
|
+
def remove_flag(plan: CampaignPlan, manifest_dir, name) -> None:
|
|
1271
|
+
"""Remove the shared named flag ``name`` from the campaign's [[flag]] table."""
|
|
1272
|
+
keep = [f for f in plan.flags if f.get("name") != name]
|
|
1273
|
+
if len(keep) == len(plan.flags):
|
|
1274
|
+
raise CampaignError(f"no shared flag named {name!r}")
|
|
1275
|
+
plan.flags = keep
|
|
1276
|
+
_save_plan(plan, manifest_dir)
|