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.
Files changed (193) hide show
  1. ff9mapkit/__init__.py +18 -0
  2. ff9mapkit/__main__.py +36 -0
  3. ff9mapkit/_animdb.py +2994 -0
  4. ff9mapkit/_animdb_all.py +14125 -0
  5. ff9mapkit/_fieldtable.py +1516 -0
  6. ff9mapkit/_fieldtext.py +845 -0
  7. ff9mapkit/_held_poses.py +44 -0
  8. ff9mapkit/_itemdb.py +65 -0
  9. ff9mapkit/_modeldb.py +725 -0
  10. ff9mapkit/_narrowmap_data.py +10 -0
  11. ff9mapkit/_npcparams.py +634 -0
  12. ff9mapkit/_regen_animdb.py +72 -0
  13. ff9mapkit/_regen_animdb_all.py +66 -0
  14. ff9mapkit/_regen_fieldtable.py +95 -0
  15. ff9mapkit/_regen_fieldtext.py +66 -0
  16. ff9mapkit/_regen_modeldb.py +67 -0
  17. ff9mapkit/_regen_npcparams.py +123 -0
  18. ff9mapkit/_regen_scenedb.py +57 -0
  19. ff9mapkit/_scenedb.py +869 -0
  20. ff9mapkit/abilities.py +225 -0
  21. ff9mapkit/animations.py +120 -0
  22. ff9mapkit/archetypes.py +218 -0
  23. ff9mapkit/areatitle.py +76 -0
  24. ff9mapkit/battle/__init__.py +21 -0
  25. ff9mapkit/battle/abilityfeatures.py +294 -0
  26. ff9mapkit/battle/actiondelta.py +441 -0
  27. ff9mapkit/battle/aiauthor.py +305 -0
  28. ff9mapkit/battle/ailint.py +140 -0
  29. ff9mapkit/battle/aipatch.py +175 -0
  30. ff9mapkit/battle/battleai.py +148 -0
  31. ff9mapkit/battle/battlecsv.py +390 -0
  32. ff9mapkit/battle/battlepatch.py +395 -0
  33. ff9mapkit/battle/build.py +558 -0
  34. ff9mapkit/battle/camera_codec.py +332 -0
  35. ff9mapkit/battle/camera_data.py +128 -0
  36. ff9mapkit/battle/characterdelta.py +789 -0
  37. ff9mapkit/battle/event_data.py +72 -0
  38. ff9mapkit/battle/extract.py +540 -0
  39. ff9mapkit/battle/fbx.py +223 -0
  40. ff9mapkit/battle/reskin.py +149 -0
  41. ff9mapkit/battle/scene_codec.py +314 -0
  42. ff9mapkit/battle/scene_data.py +369 -0
  43. ff9mapkit/battle/scenelint.py +125 -0
  44. ff9mapkit/battle/seqasm.py +131 -0
  45. ff9mapkit/battle/seqauthor.py +220 -0
  46. ff9mapkit/battle/seqcodec.py +300 -0
  47. ff9mapkit/battle/seqdis.py +106 -0
  48. ff9mapkit/battle/seqpatch.py +137 -0
  49. ff9mapkit/battle_bgm.py +133 -0
  50. ff9mapkit/binutils.py +60 -0
  51. ff9mapkit/build.py +5445 -0
  52. ff9mapkit/campaign.py +1276 -0
  53. ff9mapkit/catalog.py +316 -0
  54. ff9mapkit/chain.py +358 -0
  55. ff9mapkit/cli.py +3114 -0
  56. ff9mapkit/config.py +360 -0
  57. ff9mapkit/content/__init__.py +13 -0
  58. ff9mapkit/content/areatitle.py +36 -0
  59. ff9mapkit/content/ate.py +118 -0
  60. ff9mapkit/content/camera.py +123 -0
  61. ff9mapkit/content/chest.py +186 -0
  62. ff9mapkit/content/choice.py +163 -0
  63. ff9mapkit/content/conductor.py +217 -0
  64. ff9mapkit/content/cutscene.py +290 -0
  65. ff9mapkit/content/encounter.py +41 -0
  66. ff9mapkit/content/entry_settle.py +50 -0
  67. ff9mapkit/content/equipment.py +93 -0
  68. ff9mapkit/content/event.py +191 -0
  69. ff9mapkit/content/gateway.py +101 -0
  70. ff9mapkit/content/inventory.py +59 -0
  71. ff9mapkit/content/itemdata.py +644 -0
  72. ff9mapkit/content/itemtext.py +168 -0
  73. ff9mapkit/content/jump.py +114 -0
  74. ff9mapkit/content/ladder.py +633 -0
  75. ff9mapkit/content/movement.py +53 -0
  76. ff9mapkit/content/music.py +97 -0
  77. ff9mapkit/content/npc.py +348 -0
  78. ff9mapkit/content/object.py +340 -0
  79. ff9mapkit/content/onentry.py +135 -0
  80. ff9mapkit/content/party.py +111 -0
  81. ff9mapkit/content/pathfind.py +138 -0
  82. ff9mapkit/content/platform.py +314 -0
  83. ff9mapkit/content/player.py +168 -0
  84. ff9mapkit/content/prop.py +75 -0
  85. ff9mapkit/content/region.py +340 -0
  86. ff9mapkit/content/reinit.py +59 -0
  87. ff9mapkit/content/savepoint.py +90 -0
  88. ff9mapkit/content/shop.py +178 -0
  89. ff9mapkit/content/sps_trigger.py +66 -0
  90. ff9mapkit/content/startup.py +71 -0
  91. ff9mapkit/content/synthesis.py +106 -0
  92. ff9mapkit/content/text.py +183 -0
  93. ff9mapkit/content/textcarry.py +290 -0
  94. ff9mapkit/content/verbatim.py +86 -0
  95. ff9mapkit/content/walkmesh_hotfix.py +38 -0
  96. ff9mapkit/data/__init__.py +48 -0
  97. ff9mapkit/data/_regen_provenance.py +142 -0
  98. ff9mapkit/data/provenance/blank.es.patch +1 -0
  99. ff9mapkit/data/provenance/blank.fr.patch +1 -0
  100. ff9mapkit/data/provenance/blank.gr.patch +1 -0
  101. ff9mapkit/data/provenance/blank.it.patch +1 -0
  102. ff9mapkit/data/provenance/blank.jp.patch +1 -0
  103. ff9mapkit/data/provenance/blank.uk.patch +1 -0
  104. ff9mapkit/data/provenance/blank.us.patch +1 -0
  105. ff9mapkit/data/provenance/manifest.json +65 -0
  106. ff9mapkit/data/provenance/region_template.patch +1 -0
  107. ff9mapkit/data/reference_arcs.toml +89 -0
  108. ff9mapkit/data/region_catalog.toml +593 -0
  109. ff9mapkit/deploystack.py +358 -0
  110. ff9mapkit/dialogue.py +803 -0
  111. ff9mapkit/eb/__init__.py +12 -0
  112. ff9mapkit/eb/_exprtable.py +59 -0
  113. ff9mapkit/eb/_membertable.py +38 -0
  114. ff9mapkit/eb/_optables.py +537 -0
  115. ff9mapkit/eb/_regen_optables.py +76 -0
  116. ff9mapkit/eb/cmdasm.py +323 -0
  117. ff9mapkit/eb/disasm.py +332 -0
  118. ff9mapkit/eb/edit.py +439 -0
  119. ff9mapkit/eb/exprasm.py +158 -0
  120. ff9mapkit/eb/model.py +178 -0
  121. ff9mapkit/eb/opcodes.py +463 -0
  122. ff9mapkit/eblint.py +177 -0
  123. ff9mapkit/editor/__init__.py +20 -0
  124. ff9mapkit/editor/app.py +950 -0
  125. ff9mapkit/editor/battle_forms.py +240 -0
  126. ff9mapkit/editor/breadcrumb.py +89 -0
  127. ff9mapkit/editor/dialogs.py +116 -0
  128. ff9mapkit/editor/feedback.py +208 -0
  129. ff9mapkit/editor/forms.py +632 -0
  130. ff9mapkit/editor/graphview.py +350 -0
  131. ff9mapkit/editor/jobs.py +342 -0
  132. ff9mapkit/editor/model.py +243 -0
  133. ff9mapkit/editor/picker.py +120 -0
  134. ff9mapkit/editor/theme.py +212 -0
  135. ff9mapkit/eventscan.py +1441 -0
  136. ff9mapkit/extract.py +2279 -0
  137. ff9mapkit/flags.py +693 -0
  138. ff9mapkit/forkreport.py +1383 -0
  139. ff9mapkit/hub.py +477 -0
  140. ff9mapkit/idgated.py +101 -0
  141. ff9mapkit/infohub.py +580 -0
  142. ff9mapkit/items.py +63 -0
  143. ff9mapkit/itemstats.py +346 -0
  144. ff9mapkit/journey.py +1902 -0
  145. ff9mapkit/keyitems.py +93 -0
  146. ff9mapkit/logic_add.py +632 -0
  147. ff9mapkit/logic_edit.py +728 -0
  148. ff9mapkit/logic_map.py +526 -0
  149. ff9mapkit/pack.py +175 -0
  150. ff9mapkit/playerswap.py +231 -0
  151. ff9mapkit/prop_archetypes.py +228 -0
  152. ff9mapkit/provision.py +282 -0
  153. ff9mapkit/refarc.py +825 -0
  154. ff9mapkit/save.py +337 -0
  155. ff9mapkit/save_items.py +1673 -0
  156. ff9mapkit/scene/__init__.py +11 -0
  157. ff9mapkit/scene/arena.py +63 -0
  158. ff9mapkit/scene/bgart.py +140 -0
  159. ff9mapkit/scene/bgi.py +732 -0
  160. ff9mapkit/scene/bgs.py +174 -0
  161. ff9mapkit/scene/bgx.py +185 -0
  162. ff9mapkit/scene/cam.py +345 -0
  163. ff9mapkit/scene/guide.py +311 -0
  164. ff9mapkit/scene/paint.py +506 -0
  165. ff9mapkit/scene/placeholder.py +107 -0
  166. ff9mapkit/sjbinary.py +285 -0
  167. ff9mapkit/sps/__init__.py +17 -0
  168. ff9mapkit/sps/author.py +294 -0
  169. ff9mapkit/sps/catalog.py +88 -0
  170. ff9mapkit/sps/codec.py +264 -0
  171. ff9mapkit/sps/edit.py +184 -0
  172. ff9mapkit/sps/lint.py +58 -0
  173. ff9mapkit/sps/render.py +116 -0
  174. ff9mapkit/sps/templates.py +47 -0
  175. ff9mapkit/sps/texture.py +131 -0
  176. ff9mapkit/walkmesh_hotfixes.py +163 -0
  177. ff9mapkit/workspace/__init__.py +18 -0
  178. ff9mapkit/workspace/battledoc.py +985 -0
  179. ff9mapkit/workspace/builddoc.py +607 -0
  180. ff9mapkit/workspace/forms_qt.py +586 -0
  181. ff9mapkit/workspace/importdoc.py +665 -0
  182. ff9mapkit/workspace/mapview.py +131 -0
  183. ff9mapkit/workspace/palette.py +85 -0
  184. ff9mapkit/workspace/savedoc.py +664 -0
  185. ff9mapkit/workspace/shell.py +6907 -0
  186. ff9mapkit/workspace/style.py +105 -0
  187. ff9mapkit/workspace/tuningdialog.py +223 -0
  188. ff9mapkit-1.0.0b3.dist-info/METADATA +155 -0
  189. ff9mapkit-1.0.0b3.dist-info/RECORD +193 -0
  190. ff9mapkit-1.0.0b3.dist-info/WHEEL +5 -0
  191. ff9mapkit-1.0.0b3.dist-info/entry_points.txt +5 -0
  192. ff9mapkit-1.0.0b3.dist-info/licenses/LICENSE +31 -0
  193. ff9mapkit-1.0.0b3.dist-info/top_level.txt +1 -0
ff9mapkit/itemstats.py ADDED
@@ -0,0 +1,346 @@
1
+ """Item stat / effect catalog -- *what an FF9 item DOES* (weapon power, armor defence, equip stat bonuses,
2
+ consumable use-effect, price, type, who-can-equip). The enrichment layer over :mod:`ff9mapkit.items` (which
3
+ is names-only): it powers the Info Hub item detail + ``ff9mapkit items``.
4
+
5
+ PROVENANCE -- item STATS are game DATA, never committed (docs/PROVENANCE.md: the committed ``_*.py`` tables
6
+ hold names/ids ONLY). So this reads the numbers LIVE from YOUR OWN install and ships/commits nothing:
7
+
8
+ <install>/StreamingAssets/Data/Items/{Items,Weapons,Armors,Stats,ItemEffects}.csv
9
+
10
+ -- Memoria's editable item tables (semicolon-delimited; the very tables the engine loads). They're cached
11
+ in-memory per process. If the install/CSVs aren't reachable, every accessor returns ``None`` and callers
12
+ degrade to id+name only (the Info Hub still works offline, just without the stat lines).
13
+
14
+ Column layout is read from each file's ``# <names...>`` header legend (not hard-coded indices), so it
15
+ survives Memoria's option-driven column toggles (``#! IncludeSellingPrice`` etc.).
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass, field as _dcfield
20
+
21
+ from . import items as _items
22
+
23
+ # Element bitmask -- Stats.csv legend ("1-Fire 2-Ice 4-Thunder 8-Earth / 16-Water 32-Wind 64-Holy 128-Dark"),
24
+ # = the engine's Memoria.Data EffectElement enum (Ice=Cold, Water=Aqua, Dark=Darkness).
25
+ ELEMENTS = [(1, "Fire"), (2, "Ice"), (4, "Thunder"), (8, "Earth"),
26
+ (16, "Water"), (32, "Wind"), (64, "Holy"), (128, "Dark")]
27
+ # WeaponCategory bits (Memoria.Data.WeaponCategory): 128 "Default" is a no-op flag, omitted.
28
+ WEAPON_CATEGORY = [(1, "short-range"), (2, "long-range"), (4, "throw"), (8, "offset")]
29
+ # The 8 Items.csv type-bit columns -> a friendly slot/kind (one item may set several, e.g. Item+Usable).
30
+ TYPE_COLS = [("Weapon", "weapon"), ("Armlet", "wrist"), ("Helmet", "head"), ("Armor", "body"),
31
+ ("Accessory", "accessory"), ("Item", "item"), ("Gem", "gem"), ("Usable", "usable")]
32
+ # Items.csv per-character equip-bit columns, in order (= the engine's ItemCharacter mask).
33
+ CHARS = ["Zidane", "Vivi", "Garnet", "Steiner", "Freya", "Quina", "Eiko", "Amarant",
34
+ "Cinna", "Marcus", "Blank", "Beatrix"]
35
+
36
+
37
+ def decode_elements(mask) -> list:
38
+ """An element bitmask -> the list of element names it sets (e.g. ``5`` -> ``['Fire', 'Thunder']``)."""
39
+ try:
40
+ m = int(mask)
41
+ except (TypeError, ValueError):
42
+ return []
43
+ return [name for bit, name in ELEMENTS if m & bit]
44
+
45
+
46
+ def decode_category(mask) -> list:
47
+ try:
48
+ m = int(mask)
49
+ except (TypeError, ValueError):
50
+ return []
51
+ return [name for bit, name in WEAPON_CATEGORY if m & bit]
52
+
53
+
54
+ # BattleStatus bitmask (Memoria.Data.Battle.BattleStatus) -- a consumable's use-effect carries its status
55
+ # set here (a cure item like Phoenix Down/Antidote has Power 0 and acts ENTIRELY via this mask; the add-vs-
56
+ # remove direction is the effect's ScriptId, which we don't decode -- so we name the statuses neutrally).
57
+ STATUSES = [
58
+ (1 << 0, "Petrify"), (1 << 1, "Venom"), (1 << 2, "Virus"), (1 << 3, "Silence"),
59
+ (1 << 4, "Blind"), (1 << 5, "Trouble"), (1 << 6, "Zombie"), (1 << 7, "EasyKill"),
60
+ (1 << 8, "Death"), (1 << 9, "LowHP"), (1 << 10, "Confuse"), (1 << 11, "Berserk"),
61
+ (1 << 12, "Stop"), (1 << 13, "AutoLife"), (1 << 14, "Trance"), (1 << 15, "Defend"),
62
+ (1 << 16, "Poison"), (1 << 17, "Sleep"), (1 << 18, "Regen"), (1 << 19, "Haste"),
63
+ (1 << 20, "Slow"), (1 << 21, "Float"), (1 << 22, "Shell"), (1 << 23, "Protect"),
64
+ (1 << 24, "Heat"), (1 << 25, "Freeze"), (1 << 26, "Vanish"), (1 << 27, "Doom"),
65
+ (1 << 28, "Mini"), (1 << 29, "Reflect"), (1 << 30, "Jump"), (1 << 31, "GradualPetrify"),
66
+ ]
67
+
68
+
69
+ def decode_status(mask) -> list:
70
+ """A BattleStatus bitmask -> the list of status names it sets (e.g. ``256`` -> ``['Death']``)."""
71
+ try:
72
+ m = int(mask)
73
+ except (TypeError, ValueError):
74
+ return []
75
+ return [name for bit, name in STATUSES if m & bit]
76
+
77
+
78
+ @dataclass
79
+ class ItemStat:
80
+ """The joined stat record for one item id (the fields that apply to its type are populated; the rest
81
+ stay ``None``/empty). ``bonus``/``affinity`` carry only NON-zero entries (so an Empty bonus shows nothing)."""
82
+ id: int
83
+ name: str = ""
84
+ types: list = _dcfield(default_factory=list) # ['weapon'] / ['body'] / ['item','usable'] ...
85
+ price: int = 0
86
+ sell: int = 0
87
+ equip: list = _dcfield(default_factory=list) # character names that can equip it
88
+ abilities: list = _dcfield(default_factory=list) # raw ability tokens taught when equipped (AA:/SA:)
89
+ # weapon (WeaponId >= 0)
90
+ power: "int | None" = None
91
+ elements: list = _dcfield(default_factory=list)
92
+ category: list = _dcfield(default_factory=list)
93
+ # armor (ArmorId >= 0)
94
+ pdef: "int | None" = None
95
+ peva: "int | None" = None
96
+ mdef: "int | None" = None
97
+ meva: "int | None" = None
98
+ # equip stat bonuses + elemental affinity (Stats.csv via BonusId)
99
+ bonus: dict = _dcfield(default_factory=dict) # {'Strength': 3, ...} non-zero only
100
+ affinity: dict = _dcfield(default_factory=dict) # {'absorb': ['Fire'], 'half': [...]} non-empty only
101
+ # consumable use-effect (EffectId >= 0)
102
+ effect_power: "int | None" = None
103
+ effect_elements: list = _dcfield(default_factory=list)
104
+ effect_status: int = 0
105
+ effect_statuses: list = _dcfield(default_factory=list) # decoded status names from effect_status
106
+
107
+ @property
108
+ def is_weapon(self) -> bool:
109
+ return self.power is not None
110
+
111
+ @property
112
+ def is_armor(self) -> bool:
113
+ return self.pdef is not None
114
+
115
+ @property
116
+ def is_equippable(self) -> bool:
117
+ """True when the item occupies an equipment slot (weapon/wrist/head/body/accessory/gem) -- so an equip
118
+ stat bonus ([[equip_bonus]]) applies to it. A pure consumable (item/usable only) is not equippable."""
119
+ return any(t in ("weapon", "wrist", "head", "body", "accessory", "gem") for t in self.types)
120
+
121
+ @property
122
+ def is_consumable(self) -> bool:
123
+ """Has an effect row (an EffectId that joined) -- structural. A row can still be empty: use
124
+ :attr:`has_use_effect` to decide whether there is anything worth SHOWING."""
125
+ return self.effect_power is not None
126
+
127
+ @property
128
+ def has_use_effect(self) -> bool:
129
+ """True when the use-effect conveys something (non-zero power, an element, or a status) -- so an
130
+ all-zero effect row (e.g. a stat-bonus accessory with a dummy EffectId) shows no use-effect line."""
131
+ return bool(self.effect_power or self.effect_elements or self.effect_statuses)
132
+
133
+ def effect_desc(self) -> str:
134
+ """A neutral one-phrase description of the use-effect (``"power 10"`` / ``"status Death"`` /
135
+ ``"power 20, Fire, status Poison"``); empty when :attr:`has_use_effect` is False."""
136
+ parts = []
137
+ if self.effect_power:
138
+ parts.append(f"power {self.effect_power}")
139
+ if self.effect_elements:
140
+ parts.append("/".join(self.effect_elements))
141
+ if self.effect_statuses:
142
+ parts.append("status " + "/".join(self.effect_statuses))
143
+ return ", ".join(parts)
144
+
145
+
146
+ # ---- CSV parsing ----------------------------------------------------------------------------------
147
+ def _read_csv(path) -> tuple:
148
+ """Parse a Memoria item CSV: returns ``(name->index, [row-as-list])``. Column names come from the
149
+ file's ``#``-legend line (the first comment line whose fields include ``Id``); data rows are the
150
+ non-``#``-leading lines split on ``;`` (a trailing ``# nnn - Name`` comment cell is left as an extra
151
+ field and ignored by index access). The first data column may itself be a Comment string containing a
152
+ ``#`` (e.g. ``Bonus 0000 # Empty``) -- only a line whose first non-space char is ``#`` is a comment."""
153
+ cols: "dict | None" = None
154
+ rows: list = []
155
+ # utf-8-sig strips a leading BOM if a localized install has one (else a BOM'd first line would fail the
156
+ # `startswith("#")` legend/comment test); splitlines() handles CRLF.
157
+ for raw in path.read_text(encoding="utf-8-sig", errors="replace").splitlines():
158
+ s = raw.strip()
159
+ if not s:
160
+ continue
161
+ if s.startswith("#"):
162
+ if cols is None:
163
+ fields = [f.strip() for f in s.lstrip("#").strip().split(";")]
164
+ if "Id" in fields and len(fields) > 1:
165
+ cols = {name: i for i, name in enumerate(fields)}
166
+ continue
167
+ rows.append([c.strip() for c in raw.split(";")])
168
+ return (cols or {}), rows
169
+
170
+
171
+ def _i(row, cols, name, default=None):
172
+ """Integer cell ``name`` from ``row`` (None/default on a missing column or non-int)."""
173
+ idx = cols.get(name)
174
+ if idx is None or idx >= len(row):
175
+ return default
176
+ try:
177
+ return int(row[idx])
178
+ except (ValueError, TypeError):
179
+ return default
180
+
181
+
182
+ # ---- the in-memory join ---------------------------------------------------------------------------
183
+ _CACHE = None # None = not loaded yet; False = tried + unavailable; dict = {item_id: ItemStat}
184
+
185
+
186
+ def _load(game=None):
187
+ """Read + join the five item CSVs from the install (cached). Returns ``{id: ItemStat}`` or ``None`` if
188
+ the install / a CSV can't be read (cached as unavailable so we don't re-probe the filesystem)."""
189
+ global _CACHE
190
+ if _CACHE is not None:
191
+ return _CACHE or None
192
+ try:
193
+ from .config import find_game_path
194
+ d = find_game_path(game) / "StreamingAssets" / "Data" / "Items"
195
+ icols, irows = _read_csv(d / "Items.csv")
196
+ wcols, wrows = _read_csv(d / "Weapons.csv")
197
+ acols, arows = _read_csv(d / "Armors.csv")
198
+ scols, srows = _read_csv(d / "Stats.csv")
199
+ ecols, erows = _read_csv(d / "ItemEffects.csv")
200
+ if not (icols and irows):
201
+ raise ValueError("Items.csv had no parseable header/rows")
202
+ except (FileNotFoundError, OSError, RuntimeError, ValueError):
203
+ _CACHE = False
204
+ return None
205
+
206
+ weap = {_i(r, wcols, "Id"): r for r in wrows if _i(r, wcols, "Id") is not None}
207
+ armor = {_i(r, acols, "Id"): r for r in arows if _i(r, acols, "Id") is not None}
208
+ stat = {_i(r, scols, "Id"): r for r in srows if _i(r, scols, "Id") is not None}
209
+ effect = {_i(r, ecols, "Id"): r for r in erows if _i(r, ecols, "Id") is not None}
210
+
211
+ out: dict = {}
212
+ for r in irows:
213
+ iid = _i(r, icols, "Id")
214
+ if iid is None or iid == 255: # 255 = NoItem (the empty sentinel) -> not a real item
215
+ continue
216
+ try:
217
+ out[iid] = _build(iid, r, icols, weap, wcols, armor, acols, stat, scols, effect, ecols)
218
+ except (ValueError, IndexError, KeyError):
219
+ continue # a malformed row -> skip, don't sink the whole load
220
+ _CACHE = out or False
221
+ return out or None
222
+
223
+
224
+ def _build(iid, r, ic, weap, wc, armor, ac, stat, sc, effect, ec) -> ItemStat:
225
+ st = ItemStat(id=iid, name=_items.name_of(iid) or "")
226
+ st.types = [friendly for col, friendly in TYPE_COLS if _i(r, ic, col, 0)]
227
+ st.price = _i(r, ic, "Price", 0) or 0
228
+ st.sell = _i(r, ic, "SellingPrice", st.price // 2) or 0
229
+ st.equip = [c for c in CHARS if _i(r, ic, c, 0)]
230
+ raw_ab = r[ic["AbilityIds"]] if "AbilityIds" in ic and ic["AbilityIds"] < len(r) else ""
231
+ st.abilities = [t.strip() for t in raw_ab.split(",") if t.strip() and t.strip() != "0"]
232
+
233
+ wid = _i(r, ic, "WeaponId", -1)
234
+ if wid is not None and wid >= 0 and wid in weap:
235
+ wr = weap[wid]
236
+ st.power = _i(wr, wc, "Power", 0)
237
+ st.elements = decode_elements(_i(wr, wc, "Elements", 0))
238
+ st.category = decode_category(_i(wr, wc, "Category", 0))
239
+
240
+ aid = _i(r, ic, "ArmorId", -1)
241
+ if aid is not None and aid >= 0 and aid in armor:
242
+ ar = armor[aid]
243
+ st.pdef = _i(ar, ac, "P.Def", 0)
244
+ st.peva = _i(ar, ac, "P.Eva", 0)
245
+ st.mdef = _i(ar, ac, "M.Def", 0)
246
+ st.meva = _i(ar, ac, "M.Eva", 0)
247
+
248
+ bid = _i(r, ic, "BonusId", -1)
249
+ if bid is not None and bid in stat:
250
+ sr = stat[bid]
251
+ for col, label in (("Dexterity", "Speed"), ("Strength", "Strength"),
252
+ ("Magic", "Magic"), ("Will", "Spirit")):
253
+ v = _i(sr, sc, col, 0) or 0
254
+ if v:
255
+ st.bonus[label] = v
256
+ for col, label in (("AttackElement", "boosts"), ("GuardElement", "nullify"),
257
+ ("AbsorbElement", "absorb"), ("HalfElement", "halve"), ("WeakElement", "weak to")):
258
+ els = decode_elements(_i(sr, sc, col, 0))
259
+ if els:
260
+ st.affinity[label] = els
261
+
262
+ eid = _i(r, ic, "EffectId", -1)
263
+ if eid is not None and eid >= 0 and eid in effect:
264
+ er = effect[eid]
265
+ st.effect_power = _i(er, ec, "Power", 0)
266
+ st.effect_elements = decode_elements(_i(er, ec, "Element", 0))
267
+ st.effect_status = _i(er, ec, "Status", 0) or 0
268
+ st.effect_statuses = decode_status(st.effect_status)
269
+ return st
270
+
271
+
272
+ # ---- public API -----------------------------------------------------------------------------------
273
+ def available(game=None) -> bool:
274
+ """True if the install's item CSVs could be read (so stat enrichment is live)."""
275
+ return _load(game) is not None
276
+
277
+
278
+ def for_id(item_id, *, game=None):
279
+ """The :class:`ItemStat` for an item id, or ``None`` (unknown id, or the install isn't reachable)."""
280
+ table = _load(game)
281
+ if not table:
282
+ return None
283
+ try:
284
+ return table.get(int(item_id))
285
+ except (ValueError, TypeError):
286
+ return None
287
+
288
+
289
+ def summary(item_id, *, game=None):
290
+ """A one-line headline for an item (``"weapon - Atk 12, 320 gil"``), or ``None`` if stats aren't loaded.
291
+ Used for the Info Hub browse row + ``ff9mapkit items``."""
292
+ st = for_id(item_id, game=game)
293
+ if st is None:
294
+ return None
295
+ kind = "/".join(st.types) if st.types else "item"
296
+ bits = []
297
+ if st.is_weapon:
298
+ head = f"Atk {st.power}"
299
+ if st.elements:
300
+ head += " " + "/".join(st.elements)
301
+ bits.append(head)
302
+ if st.is_armor and (st.pdef or st.mdef):
303
+ bits.append(f"Def {st.pdef}/{st.mdef}")
304
+ if st.bonus:
305
+ bits.append(", ".join(f"{k}+{v}" for k, v in st.bonus.items()))
306
+ if st.has_use_effect:
307
+ bits.append("effect " + st.effect_desc())
308
+ if st.price:
309
+ bits.append(f"{st.price} gil")
310
+ return f"{kind} - {', '.join(bits)}" if bits else kind
311
+
312
+
313
+ def facts(item_id, *, game=None) -> list:
314
+ """``[(label, value), ...]`` for the Info Hub item-detail pane. Empty list when stats aren't loaded."""
315
+ st = for_id(item_id, game=game)
316
+ if st is None:
317
+ return []
318
+ out = [("type", "/".join(st.types) if st.types else "item"),
319
+ ("price", f"{st.price} gil (sell {st.sell})")]
320
+ if st.is_weapon:
321
+ out.append(("attack", str(st.power)))
322
+ if st.elements:
323
+ out.append(("element", "/".join(st.elements)))
324
+ if st.category:
325
+ out.append(("weapon class", "/".join(st.category)))
326
+ if st.is_armor and (st.pdef or st.mdef):
327
+ out.append(("defence", f"P.{st.pdef} M.{st.mdef}"))
328
+ if st.is_armor and (st.peva or st.meva):
329
+ out.append(("evade", f"P.{st.peva} M.{st.meva}"))
330
+ if st.bonus:
331
+ out.append(("stat bonus", ", ".join(f"{k}+{v}" for k, v in st.bonus.items())))
332
+ for label, els in st.affinity.items():
333
+ out.append((label, "/".join(els)))
334
+ if st.has_use_effect:
335
+ out.append(("use-effect", st.effect_desc()))
336
+ if st.equip:
337
+ out.append(("equippable by", ", ".join(st.equip)))
338
+ if st.abilities:
339
+ out.append(("teaches", ", ".join(st.abilities)))
340
+ return out
341
+
342
+
343
+ def _reset_cache():
344
+ """Test hook: drop the in-memory cache so a later call re-reads (e.g. after pointing at a fixture)."""
345
+ global _CACHE
346
+ _CACHE = None