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/catalog.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""The Info Hub: a unified, read-only view over the kit's baked FF9 reference catalogs.
|
|
2
|
+
|
|
3
|
+
Where the two authoring pillars are *spatial* (Blender -> ``scene.toml``) and *logic* (the editor ->
|
|
4
|
+
``field.toml``), this is the *library* pillar -- the shared game-object data that lives outside any one
|
|
5
|
+
field: which **models**, **animations**, **items**, **battle scenes**, and **fields** the engine knows
|
|
6
|
+
about. It is pure-Python identifier data baked from Memoria's open-source tables (no game bytes, no
|
|
7
|
+
install needed); see ``docs/PROVENANCE.md``.
|
|
8
|
+
|
|
9
|
+
The headline feature is the **model -> animations** join. FF9 has no standalone "NPC" object: an NPC
|
|
10
|
+
is a model id + animation ids placed inline in a field. A model name ``GEO_<group>_<form>_<token>`` and
|
|
11
|
+
an animation name ``ANH_<group>_<form>_<token>_<action>`` share a (group, token); so a model's gestures
|
|
12
|
+
are the anims with the same (group, token). Verified end-to-end: model id 8 = ``GEO_MAIN_F0_VIV``, and
|
|
13
|
+
``animations_for_model(8)`` yields idle=148 / walk=571 / run=419 / turn_l=917 / turn_r=918 -- exactly
|
|
14
|
+
the kit's built-in ``vivi`` preset.
|
|
15
|
+
|
|
16
|
+
Usage::
|
|
17
|
+
|
|
18
|
+
from ff9mapkit import catalog
|
|
19
|
+
catalog.models("npc", group="NPC") # browse townsfolk models
|
|
20
|
+
m = catalog.model(8) # Model(id=8, name='GEO_MAIN_F0_VIV', token='VIV', ...)
|
|
21
|
+
catalog.animations_for_model("GEO_NPC_F0_BAR") # {action: anim_id} a model can play
|
|
22
|
+
catalog.battle_scenes("alex") # encounter ids by region
|
|
23
|
+
catalog.search("vivi") # cross-kind discovery
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import difflib
|
|
29
|
+
from typing import NamedTuple, Optional
|
|
30
|
+
|
|
31
|
+
from ._animdb_all import ANIMATIONS
|
|
32
|
+
from ._fieldtable import FBG_TO_EVT, FIELD_BY_ID
|
|
33
|
+
from ._itemdb import ITEMS
|
|
34
|
+
from ._modeldb import MODELS
|
|
35
|
+
from ._scenedb import SCENES
|
|
36
|
+
from .animations import TOKENS as _CHAR_ALIAS # friendly playable name -> token (vivi -> VIV)
|
|
37
|
+
|
|
38
|
+
# GEO/ANH group code -> human label (the model's role).
|
|
39
|
+
GROUP_KIND = {
|
|
40
|
+
"MAIN": "playable",
|
|
41
|
+
"NPC": "npc",
|
|
42
|
+
"MON": "monster",
|
|
43
|
+
"ACC": "object",
|
|
44
|
+
"SUB": "sub-character",
|
|
45
|
+
"WEP": "weapon",
|
|
46
|
+
}
|
|
47
|
+
# form-code first letter -> the pose family the model belongs to.
|
|
48
|
+
FORM_KIND = {"F": "field", "B": "battle", "W": "world"}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Model(NamedTuple):
|
|
52
|
+
"""One actor/field model. ``id`` is what ``SetModel()`` takes; ``token`` ties it to its anims."""
|
|
53
|
+
id: int
|
|
54
|
+
name: str # GEO_<group>_<form>_<token>
|
|
55
|
+
group: str # MAIN / NPC / MON / ACC / SUB / WEP
|
|
56
|
+
form: str # F0 / B3 / W0 ...
|
|
57
|
+
token: str # VIV / BAR / EGG / 000 ...
|
|
58
|
+
kind: str # playable / npc / monster / object / sub-character / weapon
|
|
59
|
+
field: bool # True for field-form (F*) models -- the ones you place as a field NPC
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _parse_geo(name: str):
|
|
63
|
+
p = name.split("_") # GEO ACC F0 EGG
|
|
64
|
+
grp = p[1] if len(p) > 1 else ""
|
|
65
|
+
form = p[2] if len(p) > 2 else ""
|
|
66
|
+
token = p[3] if len(p) > 3 else ""
|
|
67
|
+
return grp, form, token
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _model_info(mid: int, name: str) -> Model:
|
|
71
|
+
grp, form, token = _parse_geo(name)
|
|
72
|
+
return Model(mid, name, grp, form, token,
|
|
73
|
+
GROUP_KIND.get(grp, "other"), form[:1] == "F")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------- models -----
|
|
77
|
+
def all_models() -> list:
|
|
78
|
+
"""Every model as a :class:`Model`, sorted by name (so groups cluster)."""
|
|
79
|
+
return [_model_info(mid, MODELS[mid]) for mid in sorted(MODELS, key=lambda i: (MODELS[i], i))]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def model(name_or_id) -> Optional[Model]:
|
|
83
|
+
"""Look up one model by id (int / digit-string) or exact GEO name (case-insensitive). None if
|
|
84
|
+
unknown. (For a name with a typo, use :func:`resolve_model`, which suggests near-misses.)"""
|
|
85
|
+
if isinstance(name_or_id, bool):
|
|
86
|
+
return None
|
|
87
|
+
if isinstance(name_or_id, int) or (isinstance(name_or_id, str) and name_or_id.strip().isdigit()):
|
|
88
|
+
mid = int(name_or_id)
|
|
89
|
+
return _model_info(mid, MODELS[mid]) if mid in MODELS else None
|
|
90
|
+
key = str(name_or_id).strip().upper()
|
|
91
|
+
for mid, nm in MODELS.items():
|
|
92
|
+
if nm.upper() == key:
|
|
93
|
+
return _model_info(mid, nm)
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def models(query=None, *, group=None, field_only=False) -> list:
|
|
98
|
+
"""Filtered model list. ``query`` = substring of the GEO name or token, OR a friendly playable name
|
|
99
|
+
('vivi'/'dagger' -> its token); ``group`` = a group code ('NPC') or kind label ('npc');
|
|
100
|
+
``field_only`` keeps field-form models (the ones you place as a field NPC)."""
|
|
101
|
+
grp = (group or "").upper()
|
|
102
|
+
grp_kind = (group or "").lower()
|
|
103
|
+
q = (query or "").lower()
|
|
104
|
+
alias = _CHAR_ALIAS.get(q) # 'vivi' -> 'VIV' so a friendly name finds the model
|
|
105
|
+
out = []
|
|
106
|
+
for m in all_models():
|
|
107
|
+
if field_only and not m.field:
|
|
108
|
+
continue
|
|
109
|
+
if group and m.group != grp and m.kind != grp_kind:
|
|
110
|
+
continue
|
|
111
|
+
if q and q not in m.name.lower() and q not in m.token.lower() and not (alias and m.token == alias):
|
|
112
|
+
continue
|
|
113
|
+
out.append(m)
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def resolve_model(name_or_id) -> int:
|
|
118
|
+
"""Resolve a model NAME or id to its numeric id (what ``SetModel`` wants). Raises ValueError with
|
|
119
|
+
near-miss suggestions on an unknown name / out-of-table id."""
|
|
120
|
+
if isinstance(name_or_id, bool):
|
|
121
|
+
raise ValueError("model cannot be a boolean")
|
|
122
|
+
m = model(name_or_id)
|
|
123
|
+
if m:
|
|
124
|
+
return m.id
|
|
125
|
+
if isinstance(name_or_id, int) or str(name_or_id).strip().isdigit():
|
|
126
|
+
raise ValueError(f"model id {int(name_or_id)} not in the GEO table")
|
|
127
|
+
names = {nm.upper(): nm for nm in MODELS.values()}
|
|
128
|
+
hints = difflib.get_close_matches(str(name_or_id).strip().upper(), list(names), n=6, cutoff=0.4)
|
|
129
|
+
extra = f" Did you mean: {', '.join(names[h] for h in hints)}?" if hints else \
|
|
130
|
+
" Run `ff9mapkit models` to browse them."
|
|
131
|
+
raise ValueError(f"unknown model {name_or_id!r}.{extra}")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ------------------------------------------------------------ animations -----
|
|
135
|
+
def animation_name(anim_id) -> Optional[str]:
|
|
136
|
+
"""The full anim name for an id (7302 -> 'ANH_MAIN_F0_VIV_TALK_3_1'), or None for an unknown
|
|
137
|
+
or non-numeric id (honors the 'or None' contract instead of raising on e.g. a bad string)."""
|
|
138
|
+
try:
|
|
139
|
+
return ANIMATIONS.get(int(anim_id))
|
|
140
|
+
except (TypeError, ValueError):
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _split_anh(name: str):
|
|
145
|
+
"""(group, form, token, action_lower) for an ``ANH_..`` name, or None."""
|
|
146
|
+
p = name.split("_") # ANH MAIN F0 VIV TALK 3 1
|
|
147
|
+
if len(p) < 5 or p[0] != "ANH":
|
|
148
|
+
return None
|
|
149
|
+
return p[1], p[2], p[3], "_".join(p[4:]).lower()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _form_rank(form: str):
|
|
153
|
+
"""Sort key preferring field forms (F*, by number) over battle/world -- the field gesture wins."""
|
|
154
|
+
if form[:1] == "F" and form[1:].isdigit():
|
|
155
|
+
return (0, int(form[1:]))
|
|
156
|
+
return (1, 0)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def animations_for_model(name_or_id) -> dict:
|
|
160
|
+
"""``{action_label: anim_id}`` -- the gestures a model can play, by the (group, token) join.
|
|
161
|
+
|
|
162
|
+
Field forms are preferred when an action exists in more than one form (the field clip is what an
|
|
163
|
+
on-field NPC uses); ties break to the smaller id. Returns ``{}`` for a model with no matching anims
|
|
164
|
+
(e.g. a numbered battle-only token). Standard movement actions appear as idle/walk/run/turn_l/turn_r.
|
|
165
|
+
"""
|
|
166
|
+
m = model(name_or_id)
|
|
167
|
+
if not m or not m.token:
|
|
168
|
+
return {}
|
|
169
|
+
best = {} # action -> (form_rank, id)
|
|
170
|
+
for aid, nm in ANIMATIONS.items():
|
|
171
|
+
s = _split_anh(nm)
|
|
172
|
+
if not s or s[0] != m.group or s[2] != m.token:
|
|
173
|
+
continue
|
|
174
|
+
rank = (_form_rank(s[1]), aid)
|
|
175
|
+
if s[3] not in best or rank < best[s[3]]:
|
|
176
|
+
best[s[3]] = rank
|
|
177
|
+
return {action: rank_id[1] for action, rank_id in best.items()}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def animation_actions(name_or_id) -> list:
|
|
181
|
+
"""Sorted ``[(action_label, anim_id), ...]`` for a model (for display / the CLI)."""
|
|
182
|
+
return sorted(animations_for_model(name_or_id).items())
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# the five field-NPC animation slots the injector drives (``content.npc.ANIM_ORDER``) and the join
|
|
186
|
+
# action each is resolved from. The engine plays a clip by NAME, so any id naming the right clip works.
|
|
187
|
+
NPC_SLOT_ACTION = {"stand": "idle", "walk": "walk", "run": "run", "left": "turn_l", "right": "turn_r"}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def npc_anims(name_or_id, *, use_catalog: bool = True) -> dict:
|
|
191
|
+
"""``{stand, walk, run, left, right}`` animation ids to place a model as a field NPC -- the Info
|
|
192
|
+
Hub's payoff: ANY model becomes ready to drop in.
|
|
193
|
+
|
|
194
|
+
Each movement slot the field engine drives is resolved from the model's OWN gestures
|
|
195
|
+
(:func:`animations_for_model`) with graceful fallbacks (missing run -> walk, missing turn -> idle),
|
|
196
|
+
so a slot never holds a foreign clip. For ``GEO_MAIN_F0_VIV`` this reproduces the built-in ``vivi``
|
|
197
|
+
preset (by clip name). Returns ``{}`` for a model with no field gestures (a battle-only / effect
|
|
198
|
+
model) -- give explicit ``anims`` for those.
|
|
199
|
+
|
|
200
|
+
For a model in the baked per-model catalog (:data:`ff9mapkit._npcparams.NPC_PARAMS` -- 156 GEO_NPC/MON
|
|
201
|
+
rigs), the REAL clips that rig uses as an NPC are returned verbatim (the most faithful set -- e.g. the
|
|
202
|
+
moogle's exact 2904/2927/2907/2923/2911, not the by-name join's near-miss). Off-catalog models (incl.
|
|
203
|
+
party GEO_MAIN, so the vivi preset stays) keep the gesture-name resolution below. ``use_catalog=False``
|
|
204
|
+
forces the by-NAME gesture resolution (used by the archetype-gallery completeness guard, which asks
|
|
205
|
+
"does this model auto-resolve by GESTURE NAME" -- a stricter bar than "has real clips in the catalog")."""
|
|
206
|
+
if use_catalog:
|
|
207
|
+
from ._npcparams import NPC_PARAMS
|
|
208
|
+
try:
|
|
209
|
+
mid = resolve_model(name_or_id)
|
|
210
|
+
except (ValueError, TypeError): # not a known model -> fall through to by-name (-> {})
|
|
211
|
+
mid = None
|
|
212
|
+
if mid is not None and mid in NPC_PARAMS:
|
|
213
|
+
return dict(NPC_PARAMS[mid]["anims"])
|
|
214
|
+
a = animations_for_model(name_or_id)
|
|
215
|
+
if not a:
|
|
216
|
+
return {}
|
|
217
|
+
|
|
218
|
+
def pick(*actions):
|
|
219
|
+
for act in actions:
|
|
220
|
+
if act in a:
|
|
221
|
+
return a[act]
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
stand = pick("idle", "walk", "run")
|
|
225
|
+
if stand is None: # nothing standable -> not a usable field-NPC model
|
|
226
|
+
return {}
|
|
227
|
+
return {
|
|
228
|
+
"stand": stand,
|
|
229
|
+
"walk": pick("walk", "run", "idle") or stand,
|
|
230
|
+
"run": pick("run", "walk", "idle") or stand,
|
|
231
|
+
"left": pick("turn_l", "turn_r", "idle") or stand,
|
|
232
|
+
"right": pick("turn_r", "turn_l", "idle") or stand,
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ----------------------------------------------------------- battle scenes ---
|
|
237
|
+
def _is_model_bucket(name) -> bool:
|
|
238
|
+
"""A ``BSC_B3_*`` name is the MODEL bucket (176 entries) -- a model holder, NOT a fightable encounter.
|
|
239
|
+
Picking one as a field's random-battle scene crashes in-game (``InitBattleScene`` null-ref; see the
|
|
240
|
+
``scene_name`` note + ``eb/opcodes.py``). The two scene namespaces are disjoint (this published BSC_ table
|
|
241
|
+
vs the install's region-coded loadable set), so there's no install cross-ref -- this name rule IS the guard."""
|
|
242
|
+
return bool(name) and str(name).upper().startswith("BSC_B3_")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def battle_scenes(query=None, *, include_model_bucket=False) -> list:
|
|
246
|
+
"""``[(name, id), ...]`` sorted by name; ``query`` filters by name substring (case-insensitive). The
|
|
247
|
+
``BSC_B3_*`` MODEL bucket (176 non-fightable model holders that crash as an encounter) is EXCLUDED by
|
|
248
|
+
default -- it's not a pickable encounter; pass ``include_model_bucket=True`` for the raw table."""
|
|
249
|
+
q = (query or "").lower()
|
|
250
|
+
return sorted((nm, sid) for nm, sid in SCENES.items()
|
|
251
|
+
if (include_model_bucket or not _is_model_bucket(nm)) and (not q or q in nm.lower()))
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def is_model_bucket_scene(scene_id) -> bool:
|
|
255
|
+
"""True if an encounter id resolves to a ``BSC_B3_*`` model-bucket name (not fightable) -- the build/Check
|
|
256
|
+
lint uses this to flag a hand-authored or mis-picked ``[encounter] scene``."""
|
|
257
|
+
return _is_model_bucket(scene_name(scene_id))
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
_SCENE_BY_ID = None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def scene_name(scene_id) -> "str | None":
|
|
264
|
+
"""The published BSC_ name for an encounter id, or None if the id isn't in the table (the table is the
|
|
265
|
+
full published name<->id map, NOT a guarantee the scene's DATA loads -- e.g. the BSC_B3_* model bucket)."""
|
|
266
|
+
global _SCENE_BY_ID
|
|
267
|
+
if _SCENE_BY_ID is None:
|
|
268
|
+
_SCENE_BY_ID = {}
|
|
269
|
+
for nm, sid in SCENES.items():
|
|
270
|
+
_SCENE_BY_ID.setdefault(sid, nm) # first name wins if an id has aliases
|
|
271
|
+
return _SCENE_BY_ID.get(int(scene_id))
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def resolve_scene(name_or_id) -> int:
|
|
275
|
+
"""Resolve a battle-scene NAME (BSC_..) or id to its numeric encounter id. A raw id passes through
|
|
276
|
+
unchanged (the table isn't exhaustive of every valid id). Raises ValueError on an unknown name."""
|
|
277
|
+
if isinstance(name_or_id, bool):
|
|
278
|
+
raise ValueError("scene cannot be a boolean")
|
|
279
|
+
if isinstance(name_or_id, int) or str(name_or_id).strip().isdigit():
|
|
280
|
+
return int(name_or_id)
|
|
281
|
+
key = str(name_or_id).strip().upper()
|
|
282
|
+
by_name = {nm.upper(): sid for nm, sid in SCENES.items()}
|
|
283
|
+
if key in by_name:
|
|
284
|
+
return by_name[key]
|
|
285
|
+
hints = difflib.get_close_matches(key, list(by_name), n=6, cutoff=0.4)
|
|
286
|
+
extra = f" Did you mean: {', '.join(hints)}?" if hints else " Run `ff9mapkit scenes` to browse them."
|
|
287
|
+
raise ValueError(f"unknown battle scene {name_or_id!r}.{extra}")
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# -------------------------------------------------- items / fields (thin) ----
|
|
291
|
+
def items(query=None) -> list:
|
|
292
|
+
"""``[(id, name), ...]`` (excludes the NoItem sentinel); ``query`` filters by name substring."""
|
|
293
|
+
q = (query or "").lower()
|
|
294
|
+
return sorted((i, n) for i, n in ITEMS.items() if n != "NoItem" and (not q or q in n.lower()))
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def fields(query=None) -> list:
|
|
298
|
+
"""``[(fbg_folder, field_id, evt_name), ...]`` for EVERY field (id-keyed, so the ~142 fields that SHARE a
|
|
299
|
+
background folder with another -- the same room at a different story beat -- BOTH appear, each with its own
|
|
300
|
+
event); ``query`` filters by fbg/evt substring."""
|
|
301
|
+
q = (query or "").lower()
|
|
302
|
+
out = [(fbg, fid, evt) for fid, (fbg, evt) in FIELD_BY_ID.items()
|
|
303
|
+
if not q or q in fbg.lower() or q in evt.lower()]
|
|
304
|
+
return sorted(out, key=lambda r: (r[0], r[1]))
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# ----------------------------------------------------------- cross-kind ------
|
|
308
|
+
def search(query: str) -> dict:
|
|
309
|
+
"""Discovery across every catalog: ``{'models': [...], 'items': [...], 'scenes': [...],
|
|
310
|
+
'fields': [...]}`` matching ``query`` (substring). The Info Hub's "grab anything by name"."""
|
|
311
|
+
return {
|
|
312
|
+
"models": models(query),
|
|
313
|
+
"items": items(query),
|
|
314
|
+
"scenes": battle_scenes(query),
|
|
315
|
+
"fields": fields(query),
|
|
316
|
+
}
|
ff9mapkit/chain.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""import-chain (P1, read-only): walk FF9's walkable-door graph out from a seed field, across zones.
|
|
2
|
+
|
|
3
|
+
Single-field ``import`` extracts one field's gateway edges but leaves each ``to`` pointing back into the
|
|
4
|
+
live game. ``import-chain`` follows those edges: from a seed it walks the connected region of real fields,
|
|
5
|
+
classifies every connection (walk-in gateway / scripted teleport / overworld exit -- see
|
|
6
|
+
:func:`ff9mapkit.eventscan.scan_all_warps`), bounds the walk (zones, hops, a hard field cap), and renders
|
|
7
|
+
the graph for scoping. Emitting ``campaign.toml`` + the per-field forks (with edges retargeted among the
|
|
8
|
+
chain's own new ids) is P2; this module is P1: discovery + ``--dry-run``.
|
|
9
|
+
|
|
10
|
+
The walk is PURE: it takes ``scan_fn(id)`` and ``zone_fn(id)`` callbacks, so it unit-tests on a synthetic
|
|
11
|
+
graph with no game install. The CLI wires the real :class:`ff9mapkit.extract.EventBundle`-backed scan.
|
|
12
|
+
|
|
13
|
+
Grounded in a live byte-survey (2026-06-09): ~41% of real connectivity is scripted (not walk-in), every
|
|
14
|
+
warp target is a literal (FF9 never computes a warp id), WorldMap operands are overworld LOCATION ids
|
|
15
|
+
(9000-9012) not fields, and only ~2.9% of region exits are story-conditional (stacked same-zone doors)."""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections import OrderedDict, deque
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
WALK_IN = "walk_in"
|
|
23
|
+
SCRIPTED = "scripted"
|
|
24
|
+
OVERWORLD = "overworld_exit"
|
|
25
|
+
|
|
26
|
+
DEFAULT_DENYLIST = frozenset({100}) # field 100 (Alexandria) crashes in this setup -- CLAUDE.md §5
|
|
27
|
+
DEFAULT_MAX_FIELDS = 25
|
|
28
|
+
# Generous: within a --zones scope, zones + max_fields are the real bound; a tight hop cap would sever a
|
|
29
|
+
# long linear dungeon (Ice Cavern is 13 screens deep). Lower it for an unscoped --cross-zones sweep.
|
|
30
|
+
DEFAULT_MAX_HOPS = 20
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def zone_label(folder) -> str:
|
|
34
|
+
"""Zone token of an FBG folder: ``'fbg_n05_iccv_map085_ic_ent_0'`` -> ``'iccv'``. None/odd -> ``'?'``."""
|
|
35
|
+
if not folder:
|
|
36
|
+
return "?"
|
|
37
|
+
parts = str(folder).split("_")
|
|
38
|
+
return parts[2] if len(parts) >= 3 else str(folder)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# --------------------------------------------------------------------------- field-id clustering / ranges
|
|
42
|
+
# FF9 stores "same place, different story state" as SEPARATE field ids that share the background art -- a
|
|
43
|
+
# revisited zone's ids cluster by visit, separated by big id gaps (Alexandria town: 100-117 opening, 1850-1865
|
|
44
|
+
# return, 2450-2457 ruined, 3000 ending). These helpers split a zone by those gaps (so a region fork can scope
|
|
45
|
+
# to ONE visit instead of the whole 48-screen zone) and parse/format the compact id-range string the catalog
|
|
46
|
+
# stores in `members` and import-chain reads from `--ids`.
|
|
47
|
+
DEFAULT_CLUSTER_GAP = 120 # sits in the dead band (98, 135) measured from ID_TO_FBG: the largest WITHIN-visit
|
|
48
|
+
# gap is 98 (evft 152->250, an orphaned cutscene screen) and the smallest BETWEEN-
|
|
49
|
+
# visit gap is 135 (alxc 1866->2001) -- so 120 keeps each visit whole AND splits all
|
|
50
|
+
# distinct visits (margins ~22/15 ids; widen/narrow via --gap if fields are added)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def id_clusters(ids, gap: int = DEFAULT_CLUSTER_GAP) -> list:
|
|
54
|
+
"""Split a sorted-unique list of field ids into clusters wherever consecutive ids jump by more than
|
|
55
|
+
``gap`` -- each cluster is one story-state visit of a revisited zone. ``[]`` for no ids. PURE."""
|
|
56
|
+
seq = sorted({int(x) for x in ids})
|
|
57
|
+
if not seq:
|
|
58
|
+
return []
|
|
59
|
+
out, cur = [], [seq[0]]
|
|
60
|
+
for a, b in zip(seq, seq[1:]):
|
|
61
|
+
if b - a > gap:
|
|
62
|
+
out.append(cur)
|
|
63
|
+
cur = [b]
|
|
64
|
+
else:
|
|
65
|
+
cur.append(b)
|
|
66
|
+
out.append(cur)
|
|
67
|
+
return out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def format_id_ranges(ids) -> str:
|
|
71
|
+
"""Render a set of ids as a compact, sorted range string: ``[100..117, 150, 200..202]`` ->
|
|
72
|
+
``'100-117,150,200-202'``. ``''`` for empty. The inverse of :func:`parse_id_ranges`."""
|
|
73
|
+
seq = sorted({int(x) for x in ids})
|
|
74
|
+
if not seq:
|
|
75
|
+
return ""
|
|
76
|
+
spans, start, prev = [], seq[0], seq[0]
|
|
77
|
+
for n in seq[1:]:
|
|
78
|
+
if n == prev + 1:
|
|
79
|
+
prev = n
|
|
80
|
+
continue
|
|
81
|
+
spans.append((start, prev))
|
|
82
|
+
start = prev = n
|
|
83
|
+
spans.append((start, prev))
|
|
84
|
+
return ",".join(f"{a}-{b}" if a != b else f"{a}" for a, b in spans)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def parse_id_ranges(spec) -> list:
|
|
88
|
+
"""Parse a compact id-range string (``'100-117,150,200-202'``) -> a sorted-unique ``list[int]``. Accepts
|
|
89
|
+
spaces, an already-list/tuple of ints, or an empty value (-> ``[]``). Raises ``ValueError`` on a malformed
|
|
90
|
+
token or a reversed range. The inverse of :func:`format_id_ranges`."""
|
|
91
|
+
if spec is None or spec == "":
|
|
92
|
+
return []
|
|
93
|
+
if isinstance(spec, (list, tuple, set)):
|
|
94
|
+
return sorted({int(x) for x in spec})
|
|
95
|
+
out: set = set()
|
|
96
|
+
for tok in str(spec).replace(" ", "").split(","):
|
|
97
|
+
if not tok:
|
|
98
|
+
continue
|
|
99
|
+
if "-" in tok.lstrip("-"): # a range 'A-B' (lstrip guards a leading '-' that isn't a sep)
|
|
100
|
+
a, _, b = tok.partition("-")
|
|
101
|
+
lo, hi = int(a), int(b)
|
|
102
|
+
if hi < lo:
|
|
103
|
+
raise ValueError(f"reversed id range {tok!r} (low-high)")
|
|
104
|
+
out.update(range(lo, hi + 1))
|
|
105
|
+
else:
|
|
106
|
+
out.add(int(tok))
|
|
107
|
+
return sorted(out)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass
|
|
111
|
+
class GraphResult:
|
|
112
|
+
"""Outcome of a walk. ``nodes`` is an insertion-ordered (BFS discovery order) id -> info map; that
|
|
113
|
+
order is also the id-assignment order P2 will use (``campaign_id_base + i``)."""
|
|
114
|
+
|
|
115
|
+
nodes: "OrderedDict" # id -> {zone, found, edges, overworld_exits, encounter, music, hop}
|
|
116
|
+
portals: list # edges NOT followed (zone boundary / stop-at / denylist / max-hops)
|
|
117
|
+
seams: list # scripted teleport edges not followed (author by hand)
|
|
118
|
+
unforkable: list # edges to targets with no FBG/background (shop/menu, variant, cutscene-only)
|
|
119
|
+
seeds: list
|
|
120
|
+
allowed_zones: object # set | None (None = any zone)
|
|
121
|
+
truncated: bool # hit max_fields with more queued
|
|
122
|
+
remaining: int # fields still queued when truncated
|
|
123
|
+
bounds: dict
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def walk(seed_ids, scan_fn, zone_fn, *, forkable_fn=None, max_hops=DEFAULT_MAX_HOPS, zones=None,
|
|
127
|
+
stop_at=None, max_fields=DEFAULT_MAX_FIELDS, follow_scripted=False, denylist=DEFAULT_DENYLIST,
|
|
128
|
+
stop_at_zone_boundary=True, restrict_ids=None) -> GraphResult:
|
|
129
|
+
"""Bounded BFS over the door graph.
|
|
130
|
+
|
|
131
|
+
``scan_fn(field_id)`` -> ``{found: bool, edges: [{to, kind, entrance?, zone?, story_conditional?,
|
|
132
|
+
trigger?}], overworld_exits: [...], encounter, music}`` (``found=False`` for an id with no scannable
|
|
133
|
+
``.eb`` -- world/special field -- terminating that branch). ``zone_fn(field_id)`` -> zone label.
|
|
134
|
+
``forkable_fn(field_id)`` -> True if the target is a real WALKABLE field (has a background in the
|
|
135
|
+
FBG table); False for a shop/menu / variant / cutscene-only id that has no room to fork. A non-forkable
|
|
136
|
+
target is recorded in ``unforkable`` and not followed -- BEFORE the zone test, so a shop door reads as
|
|
137
|
+
'menu/no-bg', not a bogus zone-boundary portal. Defaults to always-forkable (pure-graph unit tests).
|
|
138
|
+
|
|
139
|
+
Scope: if ``zones`` is given, only targets in those zones are followed; otherwise, with
|
|
140
|
+
``stop_at_zone_boundary`` (default) the walk stays within the seed's own zone(s). ``restrict_ids`` (an
|
|
141
|
+
EXPLICIT id set, e.g. import-chain ``--ids``) bounds the walk to exactly those fields regardless of zone --
|
|
142
|
+
an edge to a field OUTSIDE the set is recorded as a portal, never followed (so one story-state cluster
|
|
143
|
+
doesn't leak its same-zone sibling visits). Either way an edge rejected SOLELY for being out of scope is
|
|
144
|
+
recorded as a PORTAL (so you see where the region connects). Scripted edges are recorded as SEAMS and not
|
|
145
|
+
followed unless ``follow_scripted``. ``max_fields`` is a hard cap with a loud ``truncated`` flag rather
|
|
146
|
+
than silently forking the whole game."""
|
|
147
|
+
forkable_fn = forkable_fn or (lambda fid: True)
|
|
148
|
+
seeds = [int(s) for s in (seed_ids if isinstance(seed_ids, (list, tuple, set)) else [seed_ids])]
|
|
149
|
+
stop_at = {int(x) for x in (stop_at or ())}
|
|
150
|
+
deny = {int(x) for x in (denylist or ())}
|
|
151
|
+
restrict = {int(x) for x in restrict_ids} if restrict_ids is not None else None
|
|
152
|
+
if zones is not None:
|
|
153
|
+
allowed = set(zones)
|
|
154
|
+
elif stop_at_zone_boundary:
|
|
155
|
+
allowed = {zone_fn(s) for s in seeds}
|
|
156
|
+
else:
|
|
157
|
+
allowed = None
|
|
158
|
+
|
|
159
|
+
nodes: "OrderedDict" = OrderedDict()
|
|
160
|
+
portals: list = []
|
|
161
|
+
seams: list = []
|
|
162
|
+
unforkable: list = []
|
|
163
|
+
visited: set = set()
|
|
164
|
+
q: deque = deque()
|
|
165
|
+
for s in seeds:
|
|
166
|
+
if s not in visited:
|
|
167
|
+
visited.add(s)
|
|
168
|
+
q.append((s, 0))
|
|
169
|
+
|
|
170
|
+
truncated = False
|
|
171
|
+
while q:
|
|
172
|
+
fid, hop = q.popleft()
|
|
173
|
+
if len(nodes) >= max_fields:
|
|
174
|
+
truncated = True
|
|
175
|
+
break
|
|
176
|
+
node = scan_fn(fid)
|
|
177
|
+
info = {
|
|
178
|
+
"zone": zone_fn(fid), "found": bool(node.get("found")),
|
|
179
|
+
"edges": node.get("edges", []), "overworld_exits": node.get("overworld_exits", []),
|
|
180
|
+
"encounter": node.get("encounter"), "music": node.get("music"), "hop": hop,
|
|
181
|
+
}
|
|
182
|
+
nodes[fid] = info
|
|
183
|
+
if not info["found"]:
|
|
184
|
+
continue
|
|
185
|
+
walkin_targets = {int(e["to"]) for e in info["edges"] if e["kind"] == WALK_IN}
|
|
186
|
+
for e in info["edges"]:
|
|
187
|
+
to = int(e["to"])
|
|
188
|
+
kind = e["kind"]
|
|
189
|
+
if kind == OVERWORLD:
|
|
190
|
+
continue # never a graph edge (it's an overworld loc id)
|
|
191
|
+
tzone = zone_fn(to)
|
|
192
|
+
if kind == SCRIPTED and not follow_scripted:
|
|
193
|
+
if to not in walkin_targets: # don't double-report a connection that's
|
|
194
|
+
seams.append({"from": fid, "to": to, "entrance": e.get("entrance"), # also a real door
|
|
195
|
+
"trigger": e.get("trigger"), "to_zone": tzone})
|
|
196
|
+
continue
|
|
197
|
+
if to in visited:
|
|
198
|
+
continue
|
|
199
|
+
if not forkable_fn(to): # shop/menu / variant / no-background id:
|
|
200
|
+
unforkable.append({"from": fid, "to": to}) # can't fork a room -- classify before zone test
|
|
201
|
+
continue
|
|
202
|
+
reason = None
|
|
203
|
+
if restrict is not None and to not in restrict:
|
|
204
|
+
reason = "not-in-set" # --ids: outside the explicit cluster -> a portal
|
|
205
|
+
elif to in stop_at:
|
|
206
|
+
reason = "stop-at"
|
|
207
|
+
elif allowed is not None and tzone not in allowed:
|
|
208
|
+
reason = f"zone:{tzone}"
|
|
209
|
+
elif to in deny:
|
|
210
|
+
reason = "denylist"
|
|
211
|
+
elif hop + 1 > max_hops:
|
|
212
|
+
reason = "max-hops"
|
|
213
|
+
if reason:
|
|
214
|
+
portals.append({"from": fid, "to": to, "kind": kind, "to_zone": tzone, "reason": reason})
|
|
215
|
+
continue
|
|
216
|
+
visited.add(to)
|
|
217
|
+
q.append((to, hop + 1))
|
|
218
|
+
|
|
219
|
+
# when truncated we broke right after popping an unprocessed field, so it counts as unexplored too
|
|
220
|
+
return GraphResult(
|
|
221
|
+
nodes=nodes, portals=portals, seams=seams, unforkable=unforkable, seeds=seeds,
|
|
222
|
+
allowed_zones=allowed, truncated=truncated, remaining=(len(q) + 1 if truncated else 0),
|
|
223
|
+
bounds={"max_hops": max_hops, "max_fields": max_fields, "zones": list(zones) if zones else None,
|
|
224
|
+
"follow_scripted": follow_scripted, "stop_at_zone_boundary": stop_at_zone_boundary,
|
|
225
|
+
"restrict_ids": sorted(restrict) if restrict is not None else None},
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def zone_coverage(result: GraphResult, zone_members_fn) -> dict:
|
|
230
|
+
"""How much of each touched zone the walk actually forked: ``{zone: (reached, total, [unreached_ids])}``.
|
|
231
|
+
``zone_members_fn(zone)`` -> the set of ALL forkable field ids in that zone (the static FBG table). A low
|
|
232
|
+
ratio flags an ISOLATED seed -- e.g. Evil Forest seeded at 152 forks 1 of 13 (the rest aren't door-reachable
|
|
233
|
+
from that screen). PURE: the membership comes from a callback so :mod:`chain` stays game-free. Only zones the
|
|
234
|
+
walk forked at least one field in are reported (the seed's own zone(s) + any followed neighbour)."""
|
|
235
|
+
forked_by_zone: dict = {}
|
|
236
|
+
for fid, info in result.nodes.items():
|
|
237
|
+
if info.get("found"):
|
|
238
|
+
forked_by_zone.setdefault(info["zone"], set()).add(int(fid))
|
|
239
|
+
out: dict = {}
|
|
240
|
+
for zone, forked in forked_by_zone.items():
|
|
241
|
+
total = {int(x) for x in (zone_members_fn(zone) or ())}
|
|
242
|
+
unreached = sorted(total - forked)
|
|
243
|
+
out[zone] = (len(forked & total) if total else len(forked), len(total), unreached)
|
|
244
|
+
return out
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def render_coverage(coverage: dict) -> list:
|
|
248
|
+
"""Lines flagging under-forked zones (reached < total) -- the 'isolated seed' hint. ``[]`` if every touched
|
|
249
|
+
zone is fully covered (e.g. a --whole-zone fork)."""
|
|
250
|
+
lines: list = []
|
|
251
|
+
for zone, (reached, total, unreached) in sorted(coverage.items()):
|
|
252
|
+
if total and reached < total:
|
|
253
|
+
shown = ", ".join(map(str, unreached[:12])) + (" ..." if len(unreached) > 12 else "")
|
|
254
|
+
lines.append(f" zone {zone}: forked {reached} of {total} forkable fields -- {len(unreached)} "
|
|
255
|
+
f"NOT door-reachable from the seed ({shown}). Try --whole-zone (or a connected seed).")
|
|
256
|
+
return lines
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _walkin_summary(info):
|
|
260
|
+
"""('->' destinations with x{n} stacking, story_conditional?) for a node's walk-in edges."""
|
|
261
|
+
counts: "OrderedDict" = OrderedDict()
|
|
262
|
+
cond = False
|
|
263
|
+
for e in info["edges"]:
|
|
264
|
+
if e["kind"] != WALK_IN:
|
|
265
|
+
continue
|
|
266
|
+
counts[e["to"]] = counts.get(e["to"], 0) + 1
|
|
267
|
+
cond = cond or bool(e.get("story_conditional"))
|
|
268
|
+
parts = [f"{to}(x{n})" if n > 1 else str(to) for to, n in counts.items()]
|
|
269
|
+
return parts, cond
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _dedup(rows, keys):
|
|
273
|
+
seen, out = set(), []
|
|
274
|
+
for r in rows:
|
|
275
|
+
k = tuple(r.get(x) for x in keys)
|
|
276
|
+
if k not in seen:
|
|
277
|
+
seen.add(k)
|
|
278
|
+
out.append(r)
|
|
279
|
+
return out
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def render(result: GraphResult, label_fn=None, coverage=None) -> str:
|
|
283
|
+
"""A human-readable scoping report for ``--dry-run``: per-zone node lists, inter-zone portals,
|
|
284
|
+
scripted seams, overworld exits, and a blast-radius line. ``label_fn(id)`` -> a display name. ``coverage``
|
|
285
|
+
(from :func:`zone_coverage`) adds an 'isolated seed' hint when a touched zone is under-forked."""
|
|
286
|
+
label_fn = label_fn or (lambda i: "")
|
|
287
|
+
b = result.bounds
|
|
288
|
+
zstr = ",".join(b["zones"]) if b["zones"] else ("seed-zone" if b["stop_at_zone_boundary"] else "any")
|
|
289
|
+
out = [f"import-chain from {', '.join(map(str, result.seeds))} zones={zstr} "
|
|
290
|
+
f"max-hops={b['max_hops']} max-fields={b['max_fields']} follow-scripted={b['follow_scripted']}",
|
|
291
|
+
""]
|
|
292
|
+
|
|
293
|
+
by_zone: "OrderedDict" = OrderedDict()
|
|
294
|
+
for fid, info in result.nodes.items():
|
|
295
|
+
by_zone.setdefault(info["zone"], []).append((fid, info))
|
|
296
|
+
for z, items in by_zone.items():
|
|
297
|
+
out.append(f"ZONE {z} - {len(items)} field(s)")
|
|
298
|
+
for fid, info in items:
|
|
299
|
+
lbl = label_fn(fid) or ""
|
|
300
|
+
if not info["found"]:
|
|
301
|
+
out.append(f" {fid:<5} {lbl} [no script / world field]")
|
|
302
|
+
continue
|
|
303
|
+
parts, cond = _walkin_summary(info)
|
|
304
|
+
arrow = ("-> " + ", ".join(parts)) if parts else "(no walk-in exits)"
|
|
305
|
+
tags = []
|
|
306
|
+
if info.get("encounter"):
|
|
307
|
+
tags.append(f"enc:{info['encounter']['scenes'][0]}")
|
|
308
|
+
if info.get("music") is not None:
|
|
309
|
+
tags.append(f"music:{info['music']}")
|
|
310
|
+
if info.get("overworld_exits"):
|
|
311
|
+
tags.append(f"wm:{len(info['overworld_exits'])}")
|
|
312
|
+
if cond:
|
|
313
|
+
tags.append("STORY-COND")
|
|
314
|
+
out.append(f" {fid:<5} {lbl:<30} {arrow}" + ((" " + " ".join(tags)) if tags else ""))
|
|
315
|
+
out.append("")
|
|
316
|
+
|
|
317
|
+
portals = _dedup(result.portals, ("from", "to", "reason"))
|
|
318
|
+
if portals:
|
|
319
|
+
out.append("PORTALS (edges out of scope -- where this region connects onward):")
|
|
320
|
+
for p in portals:
|
|
321
|
+
out.append(f" {p['from']} -> {p['to']:<5} [{p.get('to_zone','?')}] ({p['kind']}; {p['reason']})")
|
|
322
|
+
out.append("")
|
|
323
|
+
|
|
324
|
+
seams = _dedup(result.seams, ("from", "to"))
|
|
325
|
+
if seams:
|
|
326
|
+
out.append("SCRIPTED SEAMS (teleports -- not followed; author by hand):")
|
|
327
|
+
for s in seams:
|
|
328
|
+
ent = s.get("entrance")
|
|
329
|
+
ent_str = str(ent) if isinstance(ent, int) and 0 <= ent <= 999 else "?" # best-effort in cutscenes
|
|
330
|
+
out.append(f" {s['from']} -> {s['to']:<5} [{s.get('to_zone','?')}] "
|
|
331
|
+
f"trigger:{s['trigger']} entrance:{ent_str}")
|
|
332
|
+
out.append("")
|
|
333
|
+
|
|
334
|
+
unfork = _dedup(result.unforkable, ("to",))
|
|
335
|
+
if unfork:
|
|
336
|
+
out.append("MENU / NON-FIELD TARGETS (no background in the FBG table -- shops/menus, variants, "
|
|
337
|
+
"cutscene-only; not forkable as rooms):")
|
|
338
|
+
for u in unfork:
|
|
339
|
+
out.append(f" {u['from']} -> {u['to']:<5} {label_fn(u['to']) or ''}")
|
|
340
|
+
out.append("")
|
|
341
|
+
|
|
342
|
+
wm = [fid for fid, info in result.nodes.items() if info.get("overworld_exits")]
|
|
343
|
+
if wm:
|
|
344
|
+
out.append("OVERWORLD EXITS (screens that leave to the world map): " + ", ".join(map(str, wm)))
|
|
345
|
+
out.append("")
|
|
346
|
+
|
|
347
|
+
cov_lines = render_coverage(coverage) if coverage else []
|
|
348
|
+
if cov_lines:
|
|
349
|
+
out.append("UNDER-FORKED ZONES (fields in the zone the seed can't door-reach -- the bytes are there):")
|
|
350
|
+
out.extend(cov_lines)
|
|
351
|
+
out.append("")
|
|
352
|
+
|
|
353
|
+
nwalk = sum(1 for info in result.nodes.values() for e in info["edges"] if e["kind"] == WALK_IN)
|
|
354
|
+
status = (f"TRUNCATED at max-fields={b['max_fields']} ({result.remaining}+ more queued -- "
|
|
355
|
+
f"raise --max-fields or narrow --zones)") if result.truncated else "complete"
|
|
356
|
+
out.append(f"BLAST RADIUS: {len(result.nodes)} fields, {len(by_zone)} zone(s), {nwalk} walk-in edges, "
|
|
357
|
+
f"{len(seams)} scripted seams, {len(unfork)} menu/non-field, {len(portals)} portals. [{status}]")
|
|
358
|
+
return "\n".join(out)
|