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
@@ -0,0 +1,350 @@
1
+ """A visual node-link MAP of a campaign's field graph -- a tk Canvas view + a tk-free layout core.
2
+
3
+ The Campaign Editor's left navigator shows the chain as a TREE; this is the same connectivity as a
4
+ MAP: members are nodes, live gateways are arrows (dashed when story-gated), onward seams are dashed
5
+ stubs, with the same entry / unreachable / dead-end / needs-art cues as the tree. :func:`compute_layout`
6
+ is PURE over a :class:`campaign.CampaignGraph` (no tk) so the placement is unit-testable headless;
7
+ :class:`GraphView` wraps a scrollable Canvas that draws a layout and turns clicks into open-member
8
+ calls. Nothing here that isn't already derivable from the plan via ``campaign.campaign_graph``.
9
+
10
+ Layout: top-down BFS levels from the entry (depth 0 at the top), each level centred horizontally;
11
+ unreachable members are packed into a row below the reachable band. Edge endpoints are clipped to the
12
+ node borders so an arrow touches the rectangle regardless of the two nodes' relative positions.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+
18
+
19
+ @dataclass
20
+ class LaidNode:
21
+ name: str
22
+ new_id: int
23
+ mode: str
24
+ x: float # top-left
25
+ y: float
26
+ w: float
27
+ h: float
28
+ is_entry: bool
29
+ reachable: bool
30
+ dead_end: bool
31
+ needs_export: bool
32
+
33
+ @property
34
+ def cx(self) -> float:
35
+ return self.x + self.w / 2
36
+
37
+ @property
38
+ def cy(self) -> float:
39
+ return self.y + self.h / 2
40
+
41
+
42
+ @dataclass
43
+ class LaidEdge:
44
+ frm: str
45
+ to: str
46
+ gated: bool
47
+ entrance: int
48
+ x1: float # on the frm border
49
+ y1: float
50
+ x2: float # on the to border
51
+ y2: float
52
+
53
+
54
+ @dataclass
55
+ class LaidSeam:
56
+ frm: str
57
+ label: str
58
+ nx: float # stub start (the member's right edge)
59
+ ny: float
60
+ x: float # label anchor
61
+ y: float
62
+
63
+
64
+ @dataclass
65
+ class GraphLayout:
66
+ nodes: list # list[LaidNode]
67
+ edges: list # list[LaidEdge]
68
+ seams: list # list[LaidSeam]
69
+ width: float
70
+ height: float
71
+
72
+ @property
73
+ def by_name(self) -> dict:
74
+ return {n.name: n for n in self.nodes}
75
+
76
+
77
+ def _clip(cx, cy, hw, hh, tx, ty):
78
+ """The point on the border of the rect (centre cx,cy; half-extents hw,hh) along the ray toward (tx,ty)."""
79
+ dx, dy = tx - cx, ty - cy
80
+ if dx == 0 and dy == 0:
81
+ return cx, cy + hh
82
+ sx = hw / abs(dx) if dx else float("inf")
83
+ sy = hh / abs(dy) if dy else float("inf")
84
+ t = min(sx, sy)
85
+ return cx + dx * t, cy + dy * t
86
+
87
+
88
+ def compute_layout(graph, *, node_w=160, node_h=50, hgap=38, vgap=72, margin=30, seam_gap=24):
89
+ """Lay a CampaignGraph out top-down in BFS levels from the entry; unreachable members go in a row
90
+ below. PURE -- returns a GraphLayout of absolute coords (y by depth, each level centred). An empty
91
+ campaign yields a small empty canvas."""
92
+ nodes_by_name = {n.name: n for n in graph.nodes}
93
+ order = [n.name for n in graph.nodes] # member/id order == deterministic tie-break
94
+ if not order:
95
+ return GraphLayout([], [], [], width=margin * 2 + node_w, height=margin * 2 + node_h)
96
+
97
+ out_by = {n.name: [oe["to"] for oe in n.out_edges] for n in graph.nodes}
98
+ depth = {} # BFS depth from the entry over live edges
99
+ if graph.entry in nodes_by_name:
100
+ depth[graph.entry] = 0
101
+ queue = [graph.entry]
102
+ while queue:
103
+ cur = queue.pop(0)
104
+ for nxt in out_by.get(cur, []):
105
+ if nxt not in depth:
106
+ depth[nxt] = depth[cur] + 1
107
+ queue.append(nxt)
108
+ max_reach = max(depth.values(), default=-1)
109
+ for nm in order: # unreachable -> a row below the reachable band
110
+ depth.setdefault(nm, max_reach + 1)
111
+
112
+ levels = {}
113
+ for nm in order: # group by depth, preserving member order in-row
114
+ levels.setdefault(depth[nm], []).append(nm)
115
+ widest = max(len(v) for v in levels.values())
116
+ total_w = widest * node_w + (widest - 1) * hgap
117
+
118
+ laid = {}
119
+ for d in sorted(levels):
120
+ row = levels[d]
121
+ row_w = len(row) * node_w + (len(row) - 1) * hgap
122
+ start_x = margin + (total_w - row_w) / 2
123
+ y = margin + d * (node_h + vgap)
124
+ for i, nm in enumerate(row):
125
+ src = nodes_by_name[nm]
126
+ laid[nm] = LaidNode(name=nm, new_id=src.new_id, mode=src.mode,
127
+ x=start_x + i * (node_w + hgap), y=y, w=node_w, h=node_h,
128
+ is_entry=src.is_entry, reachable=src.reachable,
129
+ dead_end=src.dead_end, needs_export=src.needs_export)
130
+
131
+ edges = []
132
+ for n in graph.nodes:
133
+ a = laid[n.name]
134
+ for oe in n.out_edges:
135
+ b = laid.get(oe["to"])
136
+ if b is None:
137
+ continue
138
+ x1, y1 = _clip(a.cx, a.cy, a.w / 2, a.h / 2, b.cx, b.cy)
139
+ x2, y2 = _clip(b.cx, b.cy, b.w / 2, b.h / 2, a.cx, a.cy)
140
+ edges.append(LaidEdge(frm=n.name, to=oe["to"], gated=oe["gated"],
141
+ entrance=oe["entrance"], x1=x1, y1=y1, x2=x2, y2=y2))
142
+
143
+ seams = []
144
+ for n in graph.nodes:
145
+ a = laid[n.name]
146
+ for i, s in enumerate(n.seams):
147
+ tgt = s.get("to_member") or ("WORLDMAP" if s.get("to_real") == "WORLDMAP" else s.get("to_real"))
148
+ sy = a.cy + (i - (len(n.seams) - 1) / 2) * 18
149
+ seams.append(LaidSeam(frm=n.name, label=f"{s.get('kind')} -> {tgt}",
150
+ nx=a.x + a.w, ny=sy, x=a.x + a.w + seam_gap, y=sy))
151
+
152
+ nlev = max(levels) + 1
153
+ width = margin * 2 + total_w
154
+ if seams:
155
+ width = max(width, max(s.x for s in seams) + 180)
156
+ height = margin * 2 + nlev * node_h + (nlev - 1) * vgap
157
+ return GraphLayout(nodes=list(laid.values()), edges=edges, seams=seams, width=width, height=height)
158
+
159
+
160
+ def _round_rect(canvas, x1, y1, x2, y2, r, **kw):
161
+ """A rounded rectangle as a smoothed polygon (Tk has no native rounded rect)."""
162
+ pts = [x1 + r, y1, x2 - r, y1, x2, y1, x2, y1 + r, x2, y2 - r, x2, y2,
163
+ x2 - r, y2, x1 + r, y2, x1, y2, x1, y2 - r, x1, y1 + r, x1, y1]
164
+ return canvas.create_polygon(pts, smooth=True, **kw)
165
+
166
+
167
+ class GraphView:
168
+ """A scrollable Canvas rendering a campaign's node-link map. Single-click a node to highlight it
169
+ (+ a status line); double-click to open it (``on_open(name)``). Wheel / drag (middle button) pan."""
170
+
171
+ def __init__(self, parent, palette, *, on_open=None):
172
+ import tkinter as tk
173
+ from tkinter import ttk
174
+
175
+ self.pal = palette
176
+ self.on_open = on_open
177
+ self._layout = None
178
+ self._graph = None
179
+ self._current = None
180
+
181
+ wrap = ttk.Frame(parent)
182
+ wrap.pack(fill="both", expand=True)
183
+ self.canvas = tk.Canvas(wrap, background=palette["surface"], highlightthickness=0)
184
+ vs = ttk.Scrollbar(wrap, orient="vertical", command=self.canvas.yview)
185
+ hs = ttk.Scrollbar(wrap, orient="horizontal", command=self.canvas.xview)
186
+ self.canvas.configure(yscrollcommand=vs.set, xscrollcommand=hs.set)
187
+ self.canvas.grid(row=0, column=0, sticky="nsew")
188
+ vs.grid(row=0, column=1, sticky="ns")
189
+ hs.grid(row=1, column=0, sticky="we")
190
+ wrap.rowconfigure(0, weight=1)
191
+ wrap.columnconfigure(0, weight=1)
192
+
193
+ self.status = ttk.Label(parent, anchor="w", padding=(8, 4), foreground=palette["muted"],
194
+ text="Open a campaign to see its map. "
195
+ "green=entry · red=unreachable · amber=needs art · dashed=gated/seam")
196
+ self.status.pack(fill="x")
197
+
198
+ self.canvas.bind("<Button-1>", self._click)
199
+ self.canvas.bind("<Double-Button-1>", self._double)
200
+ self.canvas.bind("<ButtonPress-2>", lambda e: self.canvas.scan_mark(e.x, e.y))
201
+ self.canvas.bind("<B2-Motion>", lambda e: self.canvas.scan_dragto(e.x, e.y, gain=1))
202
+ self.canvas.bind("<MouseWheel>",
203
+ lambda e: self.canvas.yview_scroll(-1 if e.delta > 0 else 1, "units"))
204
+ self.canvas.bind("<Shift-MouseWheel>",
205
+ lambda e: self.canvas.xview_scroll(-1 if e.delta > 0 else 1, "units"))
206
+
207
+ # ------------------------------------------------------------------ public
208
+ def render(self, graph, current=None):
209
+ """Lay out + draw a CampaignGraph; ``current`` highlights the open member."""
210
+ self._graph = graph
211
+ self._current = current
212
+ self._layout = compute_layout(graph)
213
+ self._draw()
214
+
215
+ def highlight(self, name):
216
+ """Mark ``name`` as the open member (re-draws; campaigns are small)."""
217
+ if self._layout is None:
218
+ return
219
+ self._current = name
220
+ self._draw()
221
+ self._set_status(name)
222
+
223
+ def clear(self):
224
+ self._graph = self._layout = self._current = None
225
+ self.canvas.delete("all")
226
+
227
+ # ------------------------------------------------------------------ drawing
228
+ def _draw(self):
229
+ c, pal, lay = self.canvas, self.pal, self._layout
230
+ c.delete("all")
231
+ if lay is None:
232
+ return
233
+ c.configure(scrollregion=(0, 0, lay.width, lay.height))
234
+ for e in lay.edges: # edges under nodes
235
+ kw = dict(fill=pal["muted"], width=2, arrow="last", arrowshape=(11, 13, 5))
236
+ if e.gated:
237
+ kw["dash"] = (5, 3)
238
+ c.create_line(e.x1, e.y1, e.x2, e.y2, **kw)
239
+ for s in lay.seams:
240
+ c.create_line(s.nx, s.ny, s.x, s.y, fill=pal["muted"], dash=(2, 3))
241
+ c.create_text(s.x + 4, s.y, text="~ " + s.label, anchor="w",
242
+ fill=pal["muted"], font=("Segoe UI", 9))
243
+ for n in lay.nodes:
244
+ self._node(n)
245
+
246
+ def _node(self, n):
247
+ c, pal = self.canvas, self.pal
248
+ if not n.reachable:
249
+ outline, width = pal["error"], 2
250
+ elif n.needs_export:
251
+ outline, width = pal["warn"], 2
252
+ elif n.is_entry:
253
+ outline, width = pal["success"], 2
254
+ else:
255
+ outline, width = pal["border"], 1
256
+ current = (n.name == self._current)
257
+ fill = pal["accent"] if current else pal["surface_btn"]
258
+ tcol = pal["accent_fg"] if current else pal["text"]
259
+ tag = f"node::{n.name}"
260
+ _round_rect(c, n.x, n.y, n.x + n.w, n.y + n.h, r=11, fill=fill,
261
+ outline=outline, width=width, tags=("node", tag))
262
+ c.create_text(n.cx, n.y + 17, text=n.name, fill=tcol,
263
+ font=("Segoe UI", 10, "bold"), tags=("node", tag))
264
+ sub = f"id {n.new_id}" + ("" if n.mode == "borrow" else f" · {n.mode}")
265
+ sub_col = pal["accent_fg"] if current else pal["muted"]
266
+ c.create_text(n.cx, n.y + 34, text=sub, fill=sub_col, font=("Segoe UI", 9), tags=("node", tag))
267
+
268
+ # ------------------------------------------------------------------ interaction
269
+ def _node_at(self, ev):
270
+ x, y = self.canvas.canvasx(ev.x), self.canvas.canvasy(ev.y)
271
+ for item in self.canvas.find_overlapping(x - 1, y - 1, x + 1, y + 1):
272
+ for t in self.canvas.gettags(item):
273
+ if t.startswith("node::"):
274
+ return t[len("node::"):]
275
+ return None
276
+
277
+ def _click(self, ev):
278
+ name = self._node_at(ev)
279
+ if name:
280
+ self.highlight(name)
281
+
282
+ def _double(self, ev):
283
+ name = self._node_at(ev)
284
+ if name and self.on_open:
285
+ self.on_open(name)
286
+
287
+ def _set_status(self, name):
288
+ node = (self._graph.by_name.get(name) if self._graph else None)
289
+ if node is None:
290
+ return
291
+ flags = []
292
+ if node.is_entry:
293
+ flags.append("entry")
294
+ if not node.reachable:
295
+ flags.append("UNREACHABLE")
296
+ elif node.dead_end:
297
+ flags.append("dead-end")
298
+ if node.needs_export:
299
+ flags.append("needs art")
300
+ tail = (" · " + ", ".join(flags)) if flags else ""
301
+ self.status.configure(
302
+ text=f"{node.name} · id {node.new_id} ({node.mode}) · "
303
+ f"{len(node.out_edges)} out / {len(node.in_edges)} in / {len(node.seams)} seam(s)"
304
+ f"{tail} — double-click to edit")
305
+
306
+
307
+ def _smoke():
308
+ """Headless-ish self-test: pure layout asserts (no display) + a Tk render+click if a display exists."""
309
+ from .. import campaign
310
+ M = campaign.Member
311
+ members = [M(300, 30100, "ENT", "borrow", 11, "", "ENT/ent.field.toml", False),
312
+ M(301, 30101, "COR", "borrow", 11, "", "COR/cor.field.toml", False),
313
+ M(302, 30102, "LOST", "borrow", 11, "", "LOST/lost.field.toml", False)]
314
+ plan = campaign.CampaignPlan(name="ICE", mod_folder="M", id_base=30100,
315
+ flag_base=campaign.FIRST_SAFE_FLAG, flags_per_field=64,
316
+ entry_name="ENT", entry_entrance=0, members=members,
317
+ edges=[{"frm": "ENT", "to": "COR", "entrance": 2}],
318
+ seams=[{"frm": "COR", "to_real": "WORLDMAP", "kind": "overworld",
319
+ "note": "", "to_member": None}])
320
+ g = campaign.campaign_graph(plan)
321
+ lay = compute_layout(g)
322
+ by = lay.by_name
323
+ assert by["ENT"].y < by["COR"].y, "entry above its child"
324
+ assert by["LOST"].y > by["COR"].y, "unreachable below the reachable band"
325
+ assert len(lay.edges) == 1 and len(lay.seams) == 1
326
+ print(f"graphview pure smoke ok: {len(lay.nodes)} nodes, {len(lay.edges)} edge(s), "
327
+ f"{len(lay.seams)} seam(s), canvas {lay.width:.0f}x{lay.height:.0f}")
328
+
329
+ import tkinter as tk
330
+ from .theme import apply_theme
331
+ root = tk.Tk()
332
+ root.withdraw()
333
+ pal = apply_theme(root)
334
+ opened = []
335
+ gv = GraphView(root, pal, on_open=opened.append)
336
+ gv.render(g, current="ENT")
337
+ items = gv.canvas.find_withtag("node::COR")
338
+ assert items, "COR drawn"
339
+ gv.highlight("COR")
340
+ assert gv._current == "COR"
341
+ gv._double(type("E", (), {"x": 0, "y": 0})()) # no node at (0,0) -> no open
342
+ assert opened == []
343
+ print("graphview tk smoke ok: rendered + highlighted")
344
+ root.destroy()
345
+
346
+
347
+ if __name__ == "__main__":
348
+ import sys
349
+ if "--smoke" in sys.argv:
350
+ _smoke()