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/infohub.py
ADDED
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
"""The Info Hub spine -- a UI-agnostic discovery API over the kit's reference catalogs + named archetypes.
|
|
2
|
+
|
|
3
|
+
This is the reusable CORE every Info Hub frontend sits on -- a standalone viewer today, the Campaign
|
|
4
|
+
Editor suite tomorrow, even a Blender panel -- so the expensive part (the data logic) is written and
|
|
5
|
+
tested once, independent of any UI. It answers three authoring questions:
|
|
6
|
+
|
|
7
|
+
* ``browse(query)`` -> WHAT exists by this name? (search every catalog + archetype table at once)
|
|
8
|
+
* ``detail(entry)`` -> WHAT is this exactly? (model, animations, composite parts, the line to author it)
|
|
9
|
+
* ``snippet(entry)`` -> HOW do I drop it into a ``field.toml``? (the ``[[npc]]`` / ``[[prop]]`` block)
|
|
10
|
+
|
|
11
|
+
Pure-offline + provenance-clean: it reads only the baked catalogs (:mod:`catalog`) and the curated
|
|
12
|
+
archetype tables -- no game install. Game-dependent extras stay OUT of the spine and arrive via hooks:
|
|
13
|
+
:func:`detail` takes an optional ``usage_fn`` for "where does this appear in real FF9?", and a frontend
|
|
14
|
+
wires its own in-game *preview* by feeding the selection back to the gallery builders. Everything here is
|
|
15
|
+
a dataclass -- trivially rendered by Tkinter, a web view, or the CLI, and JSON-serializable.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass, field as _dc_field
|
|
22
|
+
from typing import Callable, Optional
|
|
23
|
+
|
|
24
|
+
from . import archetypes as _arch
|
|
25
|
+
from . import catalog as _cat
|
|
26
|
+
from . import prop_archetypes as _props
|
|
27
|
+
from .content.npc import PRESETS as _CHAR_PRESETS # vivi / zidane -- explicit, byte-golden
|
|
28
|
+
|
|
29
|
+
# the kinds the Info Hub indexes, listed in browse priority order (curated/named first, raw + reference last)
|
|
30
|
+
KINDS = ("archetype", "creature", "composite", "prop", "model", "item", "scene", "storyflag", "sps_template")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Entry:
|
|
35
|
+
"""One browsable result -- the unit a frontend lists and acts on. ``name`` is what you type to use it;
|
|
36
|
+
``model`` is the GEO model behind it (when any); ``ident`` is the numeric id for a model/item/scene."""
|
|
37
|
+
kind: str
|
|
38
|
+
name: str
|
|
39
|
+
model: Optional[str] = None
|
|
40
|
+
summary: str = ""
|
|
41
|
+
ident: Optional[int] = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Detail:
|
|
46
|
+
"""The rich record for one entry -- everything an authoring detail pane shows."""
|
|
47
|
+
name: str
|
|
48
|
+
kind: str
|
|
49
|
+
model: Optional[str] = None
|
|
50
|
+
model_id: Optional[int] = None
|
|
51
|
+
facts: list = _dc_field(default_factory=list) # [(label, value)] -- generic key facts
|
|
52
|
+
movement: Optional[dict] = None # {stand,walk,run,left,right} iff NPC-ready
|
|
53
|
+
anims: list = _dc_field(default_factory=list) # [(action, anim_id)] -- the full gesture list
|
|
54
|
+
parts: list = _dc_field(default_factory=list) # composite parts [(model_name, pose, dx, dz)]
|
|
55
|
+
aliases: list = _dc_field(default_factory=list) # other names mapping to the same model
|
|
56
|
+
locations: Optional[list] = None # [(field_id, name)] iff a usage_fn was supplied
|
|
57
|
+
snippet: str = ""
|
|
58
|
+
preview_png: Optional[str] = None # a rendered preview image path (SPS effects)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ----------------------------------------------------------------- helpers ---
|
|
62
|
+
def _model_of_archetype(name) -> Optional[str]:
|
|
63
|
+
"""The GEO model NAME an archetype/creature maps to (or None). Cheap -- a direct model lookup, NOT
|
|
64
|
+
``archetypes.resolve`` (which also scans every animation to build the movement set)."""
|
|
65
|
+
key = str(name).strip().lower()
|
|
66
|
+
if key in _CHAR_PRESETS:
|
|
67
|
+
model = _CHAR_PRESETS[key][0]
|
|
68
|
+
else:
|
|
69
|
+
spec = _arch.ARCHETYPES.get(key) or _arch.CREATURES.get(key)
|
|
70
|
+
model = spec["model"] if spec else None
|
|
71
|
+
m = _cat.model(model) if model is not None else None
|
|
72
|
+
return m.name if m else None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
_DESC_CACHE: Optional[dict] = None
|
|
76
|
+
_DESC_RE = re.compile(r'^\s*"([a-z0-9_]+)"\s*:\s*[\{\[].*?#\s*(.+)$')
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _descriptions() -> dict:
|
|
80
|
+
"""``{name: short description}`` parsed from the archetype/prop source comments (built once). The rich
|
|
81
|
+
"what is it" text already lives in trailing comments (shelf -> 'a Dali ... shelf / box'; cask ->
|
|
82
|
+
'a "CaSK" / barrel'); this surfaces it for SEARCH + display without migrating it into the data."""
|
|
83
|
+
global _DESC_CACHE
|
|
84
|
+
if _DESC_CACHE is None:
|
|
85
|
+
d: dict = {}
|
|
86
|
+
for mod in (_arch, _props):
|
|
87
|
+
try:
|
|
88
|
+
src = open(mod.__file__, encoding="utf-8").read()
|
|
89
|
+
except OSError:
|
|
90
|
+
continue
|
|
91
|
+
for line in src.splitlines():
|
|
92
|
+
mm = _DESC_RE.match(line)
|
|
93
|
+
if mm:
|
|
94
|
+
d.setdefault(mm.group(1), mm.group(2).strip())
|
|
95
|
+
_DESC_CACHE = d
|
|
96
|
+
return _DESC_CACHE
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _build_entries() -> list:
|
|
100
|
+
"""Every indexed :class:`Entry`, in :data:`KINDS` order (built once, then cached). Each summary folds
|
|
101
|
+
in the comment DESCRIPTION + (for raw models) the friendly archetype names that use it, so search
|
|
102
|
+
matches what a thing IS ('box' -> shelf, 'zidane' -> the ZDN model), not just its cryptic GEO token."""
|
|
103
|
+
desc = _descriptions()
|
|
104
|
+
by_model = _model_names_index()
|
|
105
|
+
out = []
|
|
106
|
+
for name in sorted(set(_CHAR_PRESETS) | set(_arch.ARCHETYPES)): # archetypes (playable + NPC types)
|
|
107
|
+
mname = _model_of_archetype(name)
|
|
108
|
+
m = _cat.model(mname) if mname else None
|
|
109
|
+
role = m.kind if m else "npc"
|
|
110
|
+
out.append(Entry("archetype", name, mname, f"{role} NPC -- {desc.get(name) or mname or '?'}",
|
|
111
|
+
m.id if m else None))
|
|
112
|
+
for name in sorted(_arch.CREATURES): # creatures (GEO_MON field objects)
|
|
113
|
+
mname = _arch.CREATURES[name]["model"]
|
|
114
|
+
m = _cat.model(mname)
|
|
115
|
+
out.append(Entry("creature", name, mname, f"monster -- {desc.get(name) or mname}", m.id if m else None))
|
|
116
|
+
for name in sorted(_props.PROP_COMPOSITES): # composites (multi-part set pieces)
|
|
117
|
+
d = desc.get(name) or f"{len(_props.PROP_COMPOSITES[name])} parts"
|
|
118
|
+
out.append(Entry("composite", name, None, f"set piece -- {d}"))
|
|
119
|
+
for name in sorted(_props.PROP_ARCHETYPES): # props (single static set-dressing)
|
|
120
|
+
spec = _props.PROP_ARCHETYPES[name]
|
|
121
|
+
m = _cat.model(_cat.resolve_model(spec["model"]))
|
|
122
|
+
gname = m.name if m else spec["model"]
|
|
123
|
+
out.append(Entry("prop", name, gname, f"prop -- {desc.get(name) or gname}", m.id if m else None))
|
|
124
|
+
for m in _cat.all_models(): # raw models (anything by GEO name)
|
|
125
|
+
friendly = by_model.get(m.name, [])
|
|
126
|
+
extra = (" -- " + ", ".join(friendly)) if friendly else ""
|
|
127
|
+
out.append(Entry("model", m.name, m.name, f"{m.kind} model ({m.form}){extra}", m.id))
|
|
128
|
+
from . import itemstats as _istats # live stats from YOUR install (or None)
|
|
129
|
+
for iid, nm in _cat.items(): # items
|
|
130
|
+
out.append(Entry("item", nm, None, _istats.summary(iid) or f"item #{iid}", iid))
|
|
131
|
+
for nm, sid in _cat.battle_scenes(): # battle scenes (encounters)
|
|
132
|
+
out.append(Entry("scene", nm, None, f"battle scene #{sid}", sid))
|
|
133
|
+
out += _storyflag_entries() # FF9 story-flag registry (reference)
|
|
134
|
+
out += _sps_template_entries() # Tier-2 [[sps]] creator presets
|
|
135
|
+
return out
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _sps_template_entries() -> list:
|
|
139
|
+
"""The curated ``[[sps]]`` effect templates (``sps.templates``) as a static, install-free kind -- the
|
|
140
|
+
names + descriptions list with no install; the detail-pane PREVIEW renders the donor effect lazily
|
|
141
|
+
(install-gated). The entry's ``model`` stashes the donor ``field:sps`` for that preview."""
|
|
142
|
+
from .sps import templates as _tpl
|
|
143
|
+
return [Entry("sps_template", name, f"{field}:{sid}", f"effect template -- {desc}", sid)
|
|
144
|
+
for name, desc, field, sid in _tpl.list_templates()]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
_ENTRY_CACHE: Optional[list] = None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _all_entries() -> list:
|
|
151
|
+
"""The indexed entries (lazily built once; the catalogs are static)."""
|
|
152
|
+
global _ENTRY_CACHE
|
|
153
|
+
if _ENTRY_CACHE is None:
|
|
154
|
+
_ENTRY_CACHE = _build_entries()
|
|
155
|
+
return _ENTRY_CACHE
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
_MODEL_NAMES_CACHE: Optional[dict] = None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _model_names_index() -> dict:
|
|
162
|
+
"""``{model_name: [names...]}`` -- every archetype/creature/prop name grouped by its GEO model, built
|
|
163
|
+
ONCE so :func:`_aliases_for` is an O(1) lookup instead of re-scanning every archetype per detail."""
|
|
164
|
+
global _MODEL_NAMES_CACHE
|
|
165
|
+
if _MODEL_NAMES_CACHE is None:
|
|
166
|
+
idx: dict = {}
|
|
167
|
+
for n in set(_CHAR_PRESETS) | set(_arch.ARCHETYPES) | set(_arch.CREATURES):
|
|
168
|
+
mn = _model_of_archetype(n)
|
|
169
|
+
if mn:
|
|
170
|
+
idx.setdefault(mn, []).append(n)
|
|
171
|
+
for n, spec in _props.PROP_ARCHETYPES.items():
|
|
172
|
+
m = _cat.model(_cat.resolve_model(spec["model"]))
|
|
173
|
+
if m:
|
|
174
|
+
idx.setdefault(m.name, []).append(n)
|
|
175
|
+
_MODEL_NAMES_CACHE = {k: sorted(v) for k, v in idx.items()}
|
|
176
|
+
return _MODEL_NAMES_CACHE
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _aliases_for(name, model_name) -> list:
|
|
180
|
+
"""Other archetype/creature/prop names on the same GEO model (so a detail pane can show 'also: dagger,
|
|
181
|
+
garnets_mother') -- an O(1) lookup into the cached :func:`_model_names_index`."""
|
|
182
|
+
if not model_name:
|
|
183
|
+
return []
|
|
184
|
+
return [n for n in _model_names_index().get(model_name, []) if n != name]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ------------------------------------------------------ story-flag registry ---
|
|
188
|
+
# The FF9 story-flag REGISTRY (flags.py) browsable as a reference kind: named engine vars, reserved bit
|
|
189
|
+
# regions, the census story clusters, the scenario-milestone table, and the safe custom band. Always
|
|
190
|
+
# available + install-free (flags.py is pure). Distinct from the campaign 'flag' kind (a campaign's own
|
|
191
|
+
# [[flag]] gates) -- this is FF9's built-in story state, for "what bit / scenario is X?".
|
|
192
|
+
_STORYFLAG_SUBLABEL = {"var": "story var", "RESERVED": "RESERVED region", "region": "bit region",
|
|
193
|
+
"story": "story cluster", "scenario": "scenario milestone", "band": "safe custom band"}
|
|
194
|
+
_STORYFLAG_TIER = {"a": "engine-grounded", "b": "empirical (census)", "c": "uncertain",
|
|
195
|
+
"a/b": "engine + census"}
|
|
196
|
+
_STORYFLAG_ROWS_CACHE: Optional[dict] = None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _storyflag_rows() -> dict:
|
|
200
|
+
"""``{display_name: (sub, raw_name, location, meaning, tier)}`` from ``flags.registry_rows()`` (built
|
|
201
|
+
once). Scenario rows display as 'Beat (value)' so they're unique + searchable by beat AND value."""
|
|
202
|
+
global _STORYFLAG_ROWS_CACHE
|
|
203
|
+
if _STORYFLAG_ROWS_CACHE is None:
|
|
204
|
+
from . import flags as _flags
|
|
205
|
+
rows: dict = {}
|
|
206
|
+
for sub, name, loc, meaning, tier in _flags.registry_rows():
|
|
207
|
+
disp = f"{meaning} ({name})" if sub == "scenario" else name
|
|
208
|
+
rows[disp] = (sub, name, loc, meaning, tier)
|
|
209
|
+
_STORYFLAG_ROWS_CACHE = rows
|
|
210
|
+
return _STORYFLAG_ROWS_CACHE
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _storyflag_entries() -> list:
|
|
214
|
+
out = []
|
|
215
|
+
for disp, (sub, name, loc, meaning, tier) in _storyflag_rows().items():
|
|
216
|
+
label = _STORYFLAG_SUBLABEL.get(sub, sub)
|
|
217
|
+
ident = int(name) if sub == "scenario" else None
|
|
218
|
+
out.append(Entry("storyflag", disp, None, f"{label} · {loc} · {meaning}", ident))
|
|
219
|
+
return out
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ----------------------------------------------------------- campaign layer ---
|
|
223
|
+
def _campaign_entries(plan) -> list:
|
|
224
|
+
"""Field entries (kind='field') for the members of a campaign -- the ADDITIVE layer browse/detail
|
|
225
|
+
expose when a frontend passes ``campaign_context``. Lets the Info Hub search 'the fields in THIS
|
|
226
|
+
campaign' alongside the static catalogs. Duck-typed (anything with a ``.members`` list of objects
|
|
227
|
+
carrying ``name``/``new_id``/``mode``) so the spine never imports campaign.py at module load."""
|
|
228
|
+
out = []
|
|
229
|
+
for m in getattr(plan, "members", None) or []:
|
|
230
|
+
nm = getattr(m, "name", None)
|
|
231
|
+
if not nm:
|
|
232
|
+
continue
|
|
233
|
+
nid = getattr(m, "new_id", None)
|
|
234
|
+
mode = getattr(m, "mode", "") or ""
|
|
235
|
+
out.append(Entry("field", nm, None, f"campaign field #{nid} ({mode})", nid))
|
|
236
|
+
for fdef in getattr(plan, "flags", None) or []: # shared NAMED story flags (cross-field gates)
|
|
237
|
+
nm = fdef.get("name") if isinstance(fdef, dict) else None
|
|
238
|
+
if nm:
|
|
239
|
+
idx = fdef.get("index")
|
|
240
|
+
out.append(Entry("flag", str(nm), None, f"campaign story flag (bit {idx})", idx))
|
|
241
|
+
return out
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# -------------------------------------------------------------------- SPS layer ---
|
|
245
|
+
# Field particle effects (fire/smoke/magic) browsable as the ADDITIVE 'sps' kind a frontend exposes via
|
|
246
|
+
# ``sps_context`` -- a {label: sps_dir} map of the OPEN project's carried ``sps/`` sidecars (the effects a
|
|
247
|
+
# native fork ships + can ``[[sps_edit]]``). Install-FREE: it reads the project's own staged ``.sps``/
|
|
248
|
+
# ``spt.tcb`` files, never the install (the spine stays game-free). Each entry stashes its ``.sps`` path in
|
|
249
|
+
# ``Entry.model`` so :func:`detail` is self-contained. -> [[project-ff9-sps-authoring]], docs/SPS.md.
|
|
250
|
+
def _sps_dir_map(sps_context) -> dict:
|
|
251
|
+
"""Normalise ``sps_context`` to ``{label: Path}``. Accepts a {label: dir} map, a single dir path, or a
|
|
252
|
+
list of (label, dir) / dirs. Drops anything that isn't a real directory."""
|
|
253
|
+
from pathlib import Path
|
|
254
|
+
out: dict = {}
|
|
255
|
+
if sps_context is None:
|
|
256
|
+
return out
|
|
257
|
+
items = sps_context.items() if isinstance(sps_context, dict) else (
|
|
258
|
+
[(None, sps_context)] if isinstance(sps_context, (str, Path)) else list(sps_context))
|
|
259
|
+
for label, d in items:
|
|
260
|
+
if d is None:
|
|
261
|
+
label, d = None, label # a bare list of dirs
|
|
262
|
+
p = Path(d)
|
|
263
|
+
if p.is_dir():
|
|
264
|
+
out[str(label) if label is not None else p.parent.name] = p
|
|
265
|
+
return out
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _sps_entries(sps_context) -> list:
|
|
269
|
+
"""``Entry(kind='sps')`` for every ``<id>.sps.bytes`` in the open project's carried ``sps/`` sidecars.
|
|
270
|
+
The entry's ``model`` holds the ``.sps`` file path (so detail/preview need no re-resolution)."""
|
|
271
|
+
dirs = _sps_dir_map(sps_context)
|
|
272
|
+
multi = len(dirs) > 1
|
|
273
|
+
out = []
|
|
274
|
+
for label, d in dirs.items():
|
|
275
|
+
for f in sorted(d.glob("*.sps.bytes")):
|
|
276
|
+
stem = f.name[: -len(".sps.bytes")]
|
|
277
|
+
try:
|
|
278
|
+
sid = int(stem)
|
|
279
|
+
except ValueError:
|
|
280
|
+
continue
|
|
281
|
+
name = f"{label}:{sid}" if multi else str(sid)
|
|
282
|
+
out.append(Entry("sps", name, str(f), f"SPS effect #{sid} (carried by {label})", sid))
|
|
283
|
+
return out
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
_SPS_PREVIEW_DIR = None
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _sps_preview_png(sps, tcb, key: str) -> Optional[str]:
|
|
290
|
+
"""Render a single representative frame to a cached PNG (dark-flattened so it's visible on any pane) and
|
|
291
|
+
return its path, or ``None`` if rendering isn't possible (no Pillow). Local imports keep the spine light."""
|
|
292
|
+
global _SPS_PREVIEW_DIR
|
|
293
|
+
try:
|
|
294
|
+
import re as _re
|
|
295
|
+
import tempfile
|
|
296
|
+
from pathlib import Path
|
|
297
|
+
from .sps import render as _render
|
|
298
|
+
if _SPS_PREVIEW_DIR is None:
|
|
299
|
+
_SPS_PREVIEW_DIR = Path(tempfile.gettempdir()) / "ff9mapkit_sps_preview"
|
|
300
|
+
_SPS_PREVIEW_DIR.mkdir(parents=True, exist_ok=True)
|
|
301
|
+
from PIL import Image # noqa: PLC0415
|
|
302
|
+
frame = _render.render_frame(sps, tcb, sps.frame_count // 2, scale=4) # a developed mid frame
|
|
303
|
+
flat = Image.new("RGBA", frame.size, (34, 34, 34, 255)) # dark tile -> always visible
|
|
304
|
+
flat.alpha_composite(frame)
|
|
305
|
+
dest = _SPS_PREVIEW_DIR / (_re.sub(r"[^0-9A-Za-z_.-]", "_", key) + ".png")
|
|
306
|
+
flat.convert("RGB").save(dest)
|
|
307
|
+
return str(dest)
|
|
308
|
+
except Exception: # noqa: BLE001 -- preview is best-effort; never break detail
|
|
309
|
+
return None
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _sps_template_detail(entry: Entry) -> Detail:
|
|
313
|
+
"""A ``[[sps]]`` template: facts + a rendered preview of the donor effect it clones (install-gated; the
|
|
314
|
+
listing stays install-free, only this lazy detail reads the install)."""
|
|
315
|
+
from .sps import catalog as _spscat, codec as _spscodec
|
|
316
|
+
d = Detail(name=entry.name, kind="sps_template", model=None, model_id=entry.ident, snippet=snippet(entry))
|
|
317
|
+
field, _, sid = (entry.model or "").partition(":")
|
|
318
|
+
d.facts = [("kind", "SPS effect template"), ("use", f'template = "{entry.name}"'), ("clones", f"{field} #{sid}")]
|
|
319
|
+
try:
|
|
320
|
+
rows = _spscat.list_field_sps(field)
|
|
321
|
+
ent = next((r for r in rows if r.sps_id == int(sid)), None)
|
|
322
|
+
if ent is not None:
|
|
323
|
+
sps = _spscat.load_sps(ent)
|
|
324
|
+
d.facts += [(k, v) for k, v in _spscat.effect_facts(sps) if k != "kind"] # keep the template's kind
|
|
325
|
+
tcb = _spscat.load_tcb(field)
|
|
326
|
+
if tcb is not None:
|
|
327
|
+
d.preview_png = _sps_preview_png(sps, tcb, f"tpl_{entry.name}")
|
|
328
|
+
except Exception: # noqa: BLE001 -- install-gated preview; degrade to facts-only offline
|
|
329
|
+
pass
|
|
330
|
+
return d
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _sps_detail(entry: Entry) -> Detail:
|
|
334
|
+
"""Decode one carried effect (``entry.model`` is its ``.sps`` path) -> facts + a rendered preview + a
|
|
335
|
+
``[[sps_edit]]`` snippet. Reads the sibling ``spt.tcb.bytes`` for the texture."""
|
|
336
|
+
from pathlib import Path
|
|
337
|
+
from .sps import catalog as _spscat, codec as _spscodec
|
|
338
|
+
d = Detail(name=entry.name, kind="sps", model=None, model_id=entry.ident, snippet=snippet(entry))
|
|
339
|
+
path = Path(entry.model) if entry.model else None
|
|
340
|
+
if path is None or not path.is_file():
|
|
341
|
+
d.facts = [("kind", "SPS field effect"), ("id", str(entry.ident)), ("note", "bin not found")]
|
|
342
|
+
return d
|
|
343
|
+
try:
|
|
344
|
+
sps = _spscodec.parse(path.read_bytes())
|
|
345
|
+
except Exception as ex: # noqa: BLE001
|
|
346
|
+
d.facts = [("kind", "SPS field effect"), ("id", str(entry.ident)), ("error", str(ex))]
|
|
347
|
+
return d
|
|
348
|
+
d.facts = _spscat.effect_facts(sps)
|
|
349
|
+
tcb_path = path.parent / "spt.tcb.bytes"
|
|
350
|
+
if tcb_path.is_file():
|
|
351
|
+
d.preview_png = _sps_preview_png(sps, tcb_path.read_bytes(), f"{path.parent.name}_{entry.ident}")
|
|
352
|
+
return d
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
# --------------------------------------------------------------- public API ---
|
|
356
|
+
def browse(query: str = "", kinds=None, limit=200, campaign_context=None, sps_context=None) -> list:
|
|
357
|
+
"""Search every catalog + archetype table at once. ``query`` = a case-insensitive substring of an
|
|
358
|
+
entry's name / model / SUMMARY (the summary folds in the comment description + friendly names, so you
|
|
359
|
+
can search by what a thing IS); ``kinds`` restricts to a subset of :data:`KINDS`; ``limit`` caps the
|
|
360
|
+
result (curated/named kinds come first) or ``None`` for no cap. The Info Hub's 'grab anything by name'.
|
|
361
|
+
|
|
362
|
+
When ``campaign_context`` (a campaign.CampaignPlan) is given, that campaign's member fields are ALSO
|
|
363
|
+
searchable as kind='field' entries, listed FIRST; with no context the result is exactly as before.
|
|
364
|
+
When ``sps_context`` (a {label: sps_dir} map of the open project's carried ``sps/`` sidecars) is given,
|
|
365
|
+
the project's SPS effects are searchable as kind='sps' entries (install-free)."""
|
|
366
|
+
q = (query or "").strip().lower()
|
|
367
|
+
field_entries = _campaign_entries(campaign_context) if campaign_context is not None else []
|
|
368
|
+
sps_entries = _sps_entries(sps_context) if sps_context is not None else []
|
|
369
|
+
if kinds:
|
|
370
|
+
want = set(kinds)
|
|
371
|
+
else:
|
|
372
|
+
want = set(KINDS) | ({"field", "flag"} if campaign_context is not None else set()) \
|
|
373
|
+
| ({"sps"} if sps_entries else set())
|
|
374
|
+
# no context -> iterate the cached list directly (no copy), preserving today's behavior exactly
|
|
375
|
+
extra = field_entries + sps_entries
|
|
376
|
+
entries = (extra + _all_entries()) if extra else _all_entries()
|
|
377
|
+
out = []
|
|
378
|
+
for e in entries:
|
|
379
|
+
if e.kind not in want:
|
|
380
|
+
continue
|
|
381
|
+
if q and q not in e.name.lower() and not (e.model and q in e.model.lower()) \
|
|
382
|
+
and q not in e.summary.lower():
|
|
383
|
+
continue
|
|
384
|
+
out.append(e)
|
|
385
|
+
if limit is not None and len(out) >= limit:
|
|
386
|
+
break
|
|
387
|
+
return out
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def find(name, kind=None) -> Optional[Entry]:
|
|
391
|
+
"""The first :class:`Entry` whose name matches ``name`` exactly (case-insensitive), optionally of a
|
|
392
|
+
given ``kind`` -- for callers that have a name but no Entry (e.g. resolving a `field.toml` value)."""
|
|
393
|
+
key = str(name).strip().lower()
|
|
394
|
+
for e in _all_entries():
|
|
395
|
+
if e.name.lower() == key and (kind is None or e.kind == kind):
|
|
396
|
+
return e
|
|
397
|
+
return None
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def snippet(entry: Entry) -> str:
|
|
401
|
+
"""The ``field.toml`` block to author this entry (a frontend's 'copy / insert'). Placeables get a
|
|
402
|
+
``[[npc]]`` / ``[[prop]]`` block with a ``pos = [0, 0]`` placeholder; item/scene get the line they're
|
|
403
|
+
used in."""
|
|
404
|
+
e = entry
|
|
405
|
+
if e.kind in ("archetype", "creature"):
|
|
406
|
+
return f'[[npc]]\narchetype = "{e.name}"\npos = [0, 0]'
|
|
407
|
+
if e.kind in ("prop", "composite"):
|
|
408
|
+
return f'[[prop]]\nprop = "{e.name}"\npos = [0, 0]'
|
|
409
|
+
if e.kind == "model":
|
|
410
|
+
m = _cat.model(e.ident) if e.ident is not None else _cat.model(e.model)
|
|
411
|
+
if m and m.group == "ACC":
|
|
412
|
+
return f'[[prop]]\nmodel = "{m.name}"\npos = [0, 0]'
|
|
413
|
+
return f'[[npc]]\nmodel = "{e.model}"\npos = [0, 0]'
|
|
414
|
+
if e.kind == "item":
|
|
415
|
+
return f'give_item = [{e.ident}, 1] # {e.name} -- e.g. an [[event]] reward'
|
|
416
|
+
if e.kind == "scene":
|
|
417
|
+
return f'[encounter]\nscene = {e.ident} # {e.name}'
|
|
418
|
+
if e.kind == "sps_template": # a creator preset -> a ready [[sps]] block
|
|
419
|
+
return (f'[[sps]]\nid = 5000\ntemplate = "{e.name}"\n'
|
|
420
|
+
f'pos = [0, 0] # [x, z] -- auto-grounded onto the floor')
|
|
421
|
+
if e.kind == "sps": # a carried field effect -> a re-skin starter block
|
|
422
|
+
return (f'[[sps_edit]]\nsps = {e.ident} # re-skin this effect over its carried texture\n'
|
|
423
|
+
f'kind = "tint"\nmul = [0, 0, 512] # recolour the whole ramp (256 == identity; this -> blue)\n'
|
|
424
|
+
f'# other kinds: recolor_ramp / scale / reposition -- see docs/SPS.md')
|
|
425
|
+
if e.kind == "field": # a campaign member -- not a paste-able toml block
|
|
426
|
+
return f"# campaign field: {e.name} (id {e.ident})"
|
|
427
|
+
if e.kind == "flag": # a shared named story flag -> the gate line
|
|
428
|
+
return f'requires_flag = "{e.name}"'
|
|
429
|
+
if e.kind == "storyflag": # the FF9 registry -> a reference / authoring hint
|
|
430
|
+
from . import flags as _flags
|
|
431
|
+
sub = _storyflag_rows().get(e.name, ("",))[0]
|
|
432
|
+
if sub == "band":
|
|
433
|
+
return (f'[[flag]]\nname = "my_flag"\nindex = {_flags.FIRST_SAFE_FLAG} '
|
|
434
|
+
f'# a custom story flag in the safe band')
|
|
435
|
+
if sub == "scenario":
|
|
436
|
+
return f'ff9mapkit save-edit <SavedData_ww.dat> --scenario {e.ident} # jump to this story beat'
|
|
437
|
+
loc = _storyflag_rows().get(e.name, ("", "", "?"))[2]
|
|
438
|
+
return f"# {e.name} ({loc}) -- FF9 engine state, reference only (do not allocate here)"
|
|
439
|
+
return e.name
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _field_detail(entry: Entry, plan) -> Detail:
|
|
443
|
+
"""Detail for a campaign member (kind='field'): its place in the chain -- id/source/mode, the live
|
|
444
|
+
doors it leads to + is entered from, onward seams, and entry/reachability/needs-export flags. Resolved
|
|
445
|
+
through :func:`campaign.campaign_graph` (lazy import -- the spine stays campaign-free at module load)."""
|
|
446
|
+
d = Detail(name=entry.name, kind="field", model=None, model_id=entry.ident, snippet=snippet(entry))
|
|
447
|
+
d.facts = [("kind", "campaign field"), ("id", str(entry.ident))]
|
|
448
|
+
if plan is None:
|
|
449
|
+
return d
|
|
450
|
+
from . import campaign as _camp
|
|
451
|
+
node = _camp.campaign_graph(plan).by_name.get(entry.name)
|
|
452
|
+
if node is None:
|
|
453
|
+
return d
|
|
454
|
+
d.facts = [("kind", "campaign field"), ("id", str(node.new_id)),
|
|
455
|
+
("source", str(node.real_id)), ("mode", node.mode)]
|
|
456
|
+
if node.is_entry:
|
|
457
|
+
d.facts.append(("role", "campaign entry"))
|
|
458
|
+
if node.needs_export:
|
|
459
|
+
d.facts.append(("needs_export", "yes -- export this field's art in-game"))
|
|
460
|
+
if not node.reachable:
|
|
461
|
+
d.facts.append(("reachable", "NO -- no live-door path from the entry"))
|
|
462
|
+
if node.dead_end:
|
|
463
|
+
d.facts.append(("dead_end", "no onward connection"))
|
|
464
|
+
for oe in node.out_edges:
|
|
465
|
+
d.facts.append(("door", f"-> {oe['to']} (entrance {oe['entrance']})"
|
|
466
|
+
+ (" [gated]" if oe["gated"] else "")))
|
|
467
|
+
for ie in node.in_edges:
|
|
468
|
+
d.facts.append(("entered_from", f"<- {ie['frm']} (entrance {ie['entrance']})"))
|
|
469
|
+
for s in node.seams:
|
|
470
|
+
tgt = s["to_member"] or ("WORLDMAP" if s["to_real"] == "WORLDMAP" else s["to_real"])
|
|
471
|
+
d.facts.append((f"seam:{s['kind']}", f"-> {tgt}"))
|
|
472
|
+
return d
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def detail(entry: Entry, usage_fn: Optional[Callable] = None, campaign_context=None, sps_context=None) -> Detail:
|
|
476
|
+
"""Resolve an :class:`Entry` to its full :class:`Detail`. ``usage_fn(model_id) -> [(field_id, name),
|
|
477
|
+
...]`` is an optional hook a frontend passes to add 'where it appears in real FF9' (the spine stays
|
|
478
|
+
install-free -- field-usage needs the game); errors from it degrade to ``locations = None``. When the
|
|
479
|
+
entry is a campaign member (kind='field') and ``campaign_context`` (a CampaignPlan) is given, the
|
|
480
|
+
detail is the member's place in the chain (doors/seams/reachability)."""
|
|
481
|
+
e = entry
|
|
482
|
+
if e.kind == "sps_template": # a creator preset (decode + preview the donor)
|
|
483
|
+
return _sps_template_detail(e)
|
|
484
|
+
if e.kind == "sps": # a carried field effect (decode + preview)
|
|
485
|
+
return _sps_detail(e)
|
|
486
|
+
if e.kind == "field":
|
|
487
|
+
return _field_detail(e, campaign_context)
|
|
488
|
+
if e.kind == "flag": # a shared named story flag (cross-field gate)
|
|
489
|
+
d = Detail(name=e.name, kind="flag", model=None, model_id=e.ident, snippet=snippet(e))
|
|
490
|
+
d.facts = [("kind", "campaign story flag"), ("index", str(e.ident)),
|
|
491
|
+
("gate", f'requires_flag = "{e.name}"'), ("set", f'set_flag = ["{e.name}", 1]')]
|
|
492
|
+
return d
|
|
493
|
+
if e.kind == "storyflag": # an FF9 story-flag registry entry (reference)
|
|
494
|
+
sub, name, loc, meaning, tier = _storyflag_rows().get(e.name, ("", e.name, "", e.summary, ""))
|
|
495
|
+
d = Detail(name=e.name, kind="storyflag", model=None, model_id=None, snippet=snippet(e))
|
|
496
|
+
d.facts = [("kind", _STORYFLAG_SUBLABEL.get(sub, sub)), ("location", loc),
|
|
497
|
+
("confidence", _STORYFLAG_TIER.get(tier, tier))]
|
|
498
|
+
if meaning:
|
|
499
|
+
d.facts.append(("meaning", meaning))
|
|
500
|
+
if sub == "RESERVED":
|
|
501
|
+
d.facts.append(("note", "reserved -- a mod must NOT allocate flags here"))
|
|
502
|
+
elif sub == "band":
|
|
503
|
+
d.facts.append(("note", "allocate your custom story flags in this band"))
|
|
504
|
+
return d
|
|
505
|
+
d = Detail(name=e.name, kind=e.kind, model=e.model, model_id=e.ident, snippet=snippet(e))
|
|
506
|
+
dsc = _descriptions().get(e.name)
|
|
507
|
+
if e.kind == "composite":
|
|
508
|
+
d.parts = [((_cat.model(mid).name if _cat.model(mid) else str(mid)), pose, dx, dz)
|
|
509
|
+
for mid, pose, dx, dz in _props.resolve_composite(e.name)]
|
|
510
|
+
d.facts = [("kind", "composite set piece"), ("parts", str(len(d.parts)))]
|
|
511
|
+
if dsc:
|
|
512
|
+
d.facts.append(("desc", dsc))
|
|
513
|
+
return d
|
|
514
|
+
if e.kind == "item":
|
|
515
|
+
from . import itemstats as _istats # live stat join from YOUR install
|
|
516
|
+
d.facts = [("kind", "item"), ("id", str(e.ident))] + _istats.facts(e.ident)
|
|
517
|
+
return d
|
|
518
|
+
if e.kind == "scene":
|
|
519
|
+
d.facts = [("kind", "battle scene"), ("id", str(e.ident))]
|
|
520
|
+
return d
|
|
521
|
+
# archetype / creature / prop / model -- everything model-backed
|
|
522
|
+
m = _cat.model(e.ident) if e.ident is not None else (_cat.model(e.model) if e.model else None)
|
|
523
|
+
if m:
|
|
524
|
+
d.model, d.model_id = m.name, m.id
|
|
525
|
+
d.facts = [("kind", e.kind), ("model", m.name), ("role", m.kind), ("form", m.form)]
|
|
526
|
+
if e.kind == "prop":
|
|
527
|
+
d.facts.append(("pose", str(_props.resolve(e.name)[1])))
|
|
528
|
+
if dsc:
|
|
529
|
+
d.facts.append(("desc", dsc))
|
|
530
|
+
d.anims = _cat.animation_actions(m.id)
|
|
531
|
+
d.movement = _cat.npc_anims(m.id) or None
|
|
532
|
+
d.aliases = _aliases_for(e.name, m.name)
|
|
533
|
+
if usage_fn is not None:
|
|
534
|
+
try:
|
|
535
|
+
d.locations = list(usage_fn(m.id))
|
|
536
|
+
except Exception:
|
|
537
|
+
d.locations = None
|
|
538
|
+
return d
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
_PLACEABLE = ("archetype", "creature", "composite", "prop", "model")
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _place_lines(entry, x, z) -> list:
|
|
545
|
+
"""The ``[[npc]]`` / ``[[prop]]`` block placing one entry at world (x, z) on a preview field."""
|
|
546
|
+
e, pos = entry, f"pos = [{x}, {z}]"
|
|
547
|
+
if e.kind in ("archetype", "creature"):
|
|
548
|
+
return ["", "[[npc]]", f'archetype = "{e.name}"', pos]
|
|
549
|
+
if e.kind in ("prop", "composite"):
|
|
550
|
+
return ["", "[[prop]]", f'prop = "{e.name}"', pos]
|
|
551
|
+
if e.kind == "model":
|
|
552
|
+
m = _cat.model(e.ident) if e.ident is not None else _cat.model(e.model)
|
|
553
|
+
if m and m.group == "ACC":
|
|
554
|
+
return ["", "[[prop]]", f'model = "{m.name}"', pos]
|
|
555
|
+
return ["", "[[npc]]", f'model = "{e.model}"', pos]
|
|
556
|
+
return []
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def preview_field_toml(entries, art_dir, *, screens: int = 3) -> Optional[str]:
|
|
560
|
+
"""Build a deployable arena ``field.toml`` that PLACES the given entries -- a gallery of your selection,
|
|
561
|
+
so a frontend deploys it + F6-reloads to see them LIVE on the debug checkerboard. Writes the arena art
|
|
562
|
+
into ``art_dir`` and returns the toml; returns ``None`` if nothing is placeable (items/scenes are not
|
|
563
|
+
field objects). Game-free -- only the caller's deploy touches the install."""
|
|
564
|
+
from .scene import arena as _arena
|
|
565
|
+
placeable = [e for e in (entries or []) if e.kind in _PLACEABLE]
|
|
566
|
+
if not placeable:
|
|
567
|
+
return None
|
|
568
|
+
n = len(placeable)
|
|
569
|
+
meta = _arena.build_arena(art_dir, screens=max(screens, n))
|
|
570
|
+
half = meta["quad"][1][0]
|
|
571
|
+
margin = 700
|
|
572
|
+
xs = [round(-(half - margin) + 2 * (half - margin) * i / max(1, n - 1)) for i in range(n)]
|
|
573
|
+
zs = [z for _, z in meta["quad"]]
|
|
574
|
+
z_lo, z_hi = min(zs), max(zs)
|
|
575
|
+
row_z, spawn_z = (z_lo + z_hi) // 2, z_hi - 150
|
|
576
|
+
lines = [f"# Info Hub preview -- {', '.join(e.name for e in placeable)}. F6 -> Reload field to see it."]
|
|
577
|
+
lines += _arena.arena_scene_lines(meta, spawn_z=spawn_z, name="PREVIEW")
|
|
578
|
+
for e, x in zip(placeable, xs):
|
|
579
|
+
lines += _place_lines(e, x, row_z)
|
|
580
|
+
return "\n".join(lines)
|
ff9mapkit/items.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Author-facing item catalog -- give an item by NAME instead of a numeric id.
|
|
2
|
+
|
|
3
|
+
``give_item = ["Potion", 1]`` instead of ``[236, 1]``. Backed by :mod:`ff9mapkit._itemdb` (FF9 item
|
|
4
|
+
id <-> name, from Memoria's open-source ``RegularItem`` enum). Names match case / spacing / hyphen
|
|
5
|
+
insensitively, so ``"Potion"``, ``"potion"``, ``"Hi-Potion"``, ``"phoenix down"`` all resolve. A
|
|
6
|
+
numeric id (int or digit string) passes through, validated to 0-255.
|
|
7
|
+
|
|
8
|
+
Usage::
|
|
9
|
+
|
|
10
|
+
from ff9mapkit import items
|
|
11
|
+
items.resolve("Potion") # -> 236
|
|
12
|
+
items.resolve("hi-potion") # -> 237
|
|
13
|
+
items.resolve(236) # -> 236 (raw id passes through)
|
|
14
|
+
items.name_of(236) # -> "Potion"
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import difflib
|
|
20
|
+
|
|
21
|
+
from ._itemdb import ITEMS
|
|
22
|
+
|
|
23
|
+
# normalized-name -> id (lowercased, alphanumerics only -> "Hi-Potion"/"hi potion" both match HiPotion)
|
|
24
|
+
_BY_NAME = {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _norm(s) -> str:
|
|
28
|
+
return "".join(c for c in str(s).lower() if c.isalnum())
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
for _id, _name in ITEMS.items():
|
|
32
|
+
_BY_NAME[_norm(_name)] = _id
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def resolve(name_or_id) -> int:
|
|
36
|
+
"""Resolve an item NAME or id to its numeric id. An int / digit-string passes through (validated
|
|
37
|
+
0-255); a name is matched case/space/hyphen-insensitively. Raises ValueError (with near-miss
|
|
38
|
+
suggestions) on an unknown name or out-of-range id."""
|
|
39
|
+
if isinstance(name_or_id, bool):
|
|
40
|
+
raise ValueError("item cannot be a boolean")
|
|
41
|
+
if isinstance(name_or_id, int):
|
|
42
|
+
if not 0 <= name_or_id <= 255:
|
|
43
|
+
raise ValueError(f"item id {name_or_id} out of range (0-255)")
|
|
44
|
+
return name_or_id
|
|
45
|
+
s = str(name_or_id).strip()
|
|
46
|
+
if s.isdigit():
|
|
47
|
+
return resolve(int(s))
|
|
48
|
+
key = _norm(s)
|
|
49
|
+
if key in _BY_NAME:
|
|
50
|
+
return _BY_NAME[key]
|
|
51
|
+
hints = [ITEMS[_BY_NAME[h]] for h in difflib.get_close_matches(key, list(_BY_NAME), n=6, cutoff=0.4)]
|
|
52
|
+
extra = f" Did you mean: {', '.join(hints)}?" if hints else " Run `ff9mapkit items` to list them."
|
|
53
|
+
raise ValueError(f"unknown item {name_or_id!r}.{extra}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def name_of(item_id: int):
|
|
57
|
+
"""Canonical name for an id (236 -> 'Potion'), or None."""
|
|
58
|
+
return ITEMS.get(int(item_id))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def all_items() -> list:
|
|
62
|
+
"""Sorted ``[(id, name), ...]`` (for the CLI / docs)."""
|
|
63
|
+
return sorted(ITEMS.items())
|