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
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Form specs for the Battle document (ENCOUNTER-FIRST), reusing the field editor's tk-free spec machinery
|
|
2
|
+
(:mod:`ff9mapkit.editor.forms` -- the :class:`~ff9mapkit.editor.forms.Field` dataclass, the field kinds, and
|
|
3
|
+
``build_entity`` / ``entity_to_values``).
|
|
4
|
+
|
|
5
|
+
A ``battle.toml`` is authored as an ENCOUNTER, not a loose enemy (the engine itself enforces this: a scene IS
|
|
6
|
+
a formation, and per-slot edits only apply once ``[scene] monster_count`` composes the formation). The three
|
|
7
|
+
specs here mirror that:
|
|
8
|
+
|
|
9
|
+
* :data:`BATTLEMAP_SPEC` -- ``[battlemap]``: the map's identity (the BBG slot it ships as, its geometry, and
|
|
10
|
+
the mint/repoint scene wiring).
|
|
11
|
+
* :data:`SCENE_SPEC` -- ``[scene]``: the FORMATION (how many enemies, the opening camera, the AP reward).
|
|
12
|
+
* :data:`ENEMY_SPEC` -- ``[[scene.enemy]]``: one formation SLOT's enemy -- identity & stats, element/status
|
|
13
|
+
affinities, rewards, placement, and a model re-skin.
|
|
14
|
+
|
|
15
|
+
The scalar stats are int fields; the element/status/drop/flags lists are :data:`~forms.STRLIST` (the same
|
|
16
|
+
comma-separated name list ``[party]`` uses); placement is a :data:`~forms.COORD`. The player-side CSV tuning
|
|
17
|
+
tables (``[[battle_action]]`` / ``[[status]]`` / ``[[character]]`` / ...) are a SEPARATE, scene-independent
|
|
18
|
+
spec set (now wired -- see :data:`PLAYER_TABLES`); they don't belong on a per-enemy slot. The high-level AI
|
|
19
|
+
branch (:data:`AI_PHASE_SPEC`) and the SAME-LENGTH constant patches (:data:`AI_PATCH_SPEC` /
|
|
20
|
+
:data:`SEQ_PATCH_SPEC`, the "cite an offset" tier) have flat specs here; the length-CHANGING authoring
|
|
21
|
+
(``ai_function`` / ``ai_insert`` / ``seq_replace`` / ``seq_insert``) stays CLI-only (the disassemble/splice tier).
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from .forms import COORD, FLOAT, INT, OPTINT, STR, STRLIST, Field
|
|
26
|
+
|
|
27
|
+
# [[scene.ai_phase]] -- a high-level "enrage below X% stat" AI branch (aiauthor.apply_ai_phases GENERATES the
|
|
28
|
+
# HP-threshold branch + splices it before the function's Attack). Flat: the attack-index variable is inferred,
|
|
29
|
+
# so the author only gives the entry/function + the threshold + the two attack indices.
|
|
30
|
+
AI_PHASE_SPEC = [
|
|
31
|
+
Field("entry", "Enemy AI entry", INT,
|
|
32
|
+
"the .eb AI entry for the enemy type (often 1 + the slot's type; `battle-ai <donor>` lists them)"),
|
|
33
|
+
Field("tag", "AI function", INT, "which AI function has the single Attack — the readout's 'Enrage-able' line "
|
|
34
|
+
"shows it (usually 5, the per-turn attack executor; 1=Main, 6=Counter, 7=ATB, 9=Dying)"),
|
|
35
|
+
Field("stat", "Threshold stat", STR, "hp / mp / at (default hp)"),
|
|
36
|
+
Field("below", "Enrage below", FLOAT, "a unit fraction 1/N: 0.5 = half, 0.25 = quarter, 0.2 = a fifth (default 0.5)"),
|
|
37
|
+
Field("then", "Attack when below", INT, "enemy_attack[] index used WHILE below the threshold (the enrage move)"),
|
|
38
|
+
Field("else", "Attack when above", INT, "enemy_attack[] index used while ABOVE the threshold (the normal move)"),
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
# [[scene.ai_patch]] -- a SAME-LENGTH enemy-AI constant patch (aipatch.apply_ai_patches): rewrite ONE numeric
|
|
42
|
+
# literal in the AI bytecode in place -- an HP threshold a phase compares, the attack index a turn selects, a
|
|
43
|
+
# `Wait` count -- with no byte movement. Addressed by BYTE OFFSET (from `battle-ai --sites`, or the form's
|
|
44
|
+
# "Browse sites…" picker) + an OLD-value guard so a stale offset fails LOUD instead of corrupting a byte. The
|
|
45
|
+
# bytecode is language-identical, so one patch hits every language's eb.
|
|
46
|
+
AI_PATCH_SPEC = [
|
|
47
|
+
Field("at", "Offset", INT, "the byte offset of the AI constant — use 'Browse sites…' (or `battle-ai --sites`)"),
|
|
48
|
+
Field("old", "Current value", INT, "the value the eb has there NOW — the guard (Browse sites… fills it)"),
|
|
49
|
+
Field("new", "New value", INT, "the value to write (must fit the SAME byte width — a literal patch can't widen)"),
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
# [[scene.seq_patch]] -- a SAME-LENGTH raw17 attack-CHOREOGRAPHY operand patch (seqpatch.apply_seq_patches):
|
|
53
|
+
# retime a `Wait` / `MoveTo*` frame count, swap an `Anim` code or a `SetCamera` id, in place. Byte OFFSET (from
|
|
54
|
+
# `battle-seq --sites`, or "Browse sites…") + an OLD guard; `seq` is the optional owning attack/sub index (a
|
|
55
|
+
# cross-check the picker pre-fills, NOT required). raw17 is language-independent, so the patch applies once.
|
|
56
|
+
SEQ_PATCH_SPEC = [
|
|
57
|
+
Field("at", "Offset", INT, "the byte offset of the sequence operand — use 'Browse sites…' (or `battle-seq --sites`)"),
|
|
58
|
+
Field("old", "Current value", INT, "the value the raw17 has there NOW — the guard (Browse sites… fills it)"),
|
|
59
|
+
Field("new", "New value", INT, "the value to write (must fit the SAME field width + signedness)"),
|
|
60
|
+
Field("seq", "Owning attack", OPTINT, "optional cross-check: the attack/sub index that owns this operand"),
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
# [battlemap] -- the map identity (validate_battle: bbg is required + must look like BBG_B013; scene_id needs
|
|
64
|
+
# scene_name; scene_id (mint) and repoint_scene are mutually exclusive; char_tint/shadow are cosmetic).
|
|
65
|
+
BATTLEMAP_SPEC = [
|
|
66
|
+
Field("bbg", "Background slot", STR,
|
|
67
|
+
"the BBG_* slot this map ships as, e.g. BBG_B013 (= the forked slot to OVERRIDE that real map)"),
|
|
68
|
+
Field("fbx", "Geometry (.fbx)", STR, "the FBX geometry file in this folder (default <bbg>.fbx)"),
|
|
69
|
+
Field("repoint_scene", "Repoint scene id", OPTINT,
|
|
70
|
+
"point an EXISTING battle scene's background at this map (mutually exclusive with a mint)"),
|
|
71
|
+
Field("scene_id", "Mint scene id", OPTINT, "advanced: a NEW battle-scene id to mint (needs a scene name)"),
|
|
72
|
+
Field("scene_name", "Mint scene name", STR, "advanced: the new scene's name (pair with the mint id)"),
|
|
73
|
+
Field("char_tint", "Char tint (r, g, b)", STRLIST,
|
|
74
|
+
"RGB the engine lights party/enemies with on this map (0-255 each; default 128, 128, 128)"),
|
|
75
|
+
Field("shadow", "Shadow", OPTINT, "shadow intensity 0-255 (default 32)"),
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
# [scene] -- the FORMATION. monster_count is the keystone: it recomposes every pattern and unlocks per-slot
|
|
79
|
+
# editing, so it reads first in the form. `flags` are the encounter RULES (header scene_flags); the camera_*
|
|
80
|
+
# floats nudge the OPENING-camera pose (raw17, in place). The AI / sequence edits live in their own sibling
|
|
81
|
+
# tables ([[scene.ai_phase]] / [[scene.ai_patch]] / [[scene.seq_patch]]), not on this form; full camera
|
|
82
|
+
# keyframes + the length-changing ai_*/seq_* authoring stay CLI-only.
|
|
83
|
+
SCENE_SPEC = [
|
|
84
|
+
Field("monster_count", "Monster count", OPTINT,
|
|
85
|
+
"how many enemies spawn (1-4) -- SET THIS to compose the formation + unlock per-slot edits"),
|
|
86
|
+
Field("camera", "Camera", OPTINT, "opening camera: 0-2 = a fixed PSX pose, >=3 = random"),
|
|
87
|
+
Field("ap", "AP reward", OPTINT, "the gameplay AP this fight awards"),
|
|
88
|
+
Field("pattern", "Pattern", OPTINT, "which formation pattern to tune (default 0)"),
|
|
89
|
+
Field("flags", "Encounter rules", STRLIST,
|
|
90
|
+
"scene RULES (any of): back_attack, preemptive, no_escape, no_exp -- absent keeps the donor's"),
|
|
91
|
+
Field("camera_yaw", "Camera yaw °", FLOAT, "rotate the opening camera by this many degrees (+/-, default 0)"),
|
|
92
|
+
Field("camera_pitch", "Camera pitch °", FLOAT, "tilt the opening camera by this many degrees (+/-, default 0)"),
|
|
93
|
+
Field("camera_zoom", "Camera zoom ×", FLOAT, "magnify the opening camera (>1 zooms IN, <1 zooms OUT; 1.0 = unchanged)"),
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
# [[scene.enemy]] -- one formation slot's enemy. Stats are per-TYPE: two slots sharing a type share ALL stats.
|
|
97
|
+
ENEMY_SPEC = [
|
|
98
|
+
Field("slot", "Slot", INT, "the formation slot 0-3 (required)"),
|
|
99
|
+
Field("type", "Type", OPTINT, "the enemy TYPE to place here (must already exist in the scene)"),
|
|
100
|
+
# identity & stats (all per-type, 0-255 unless noted)
|
|
101
|
+
Field("hp", "HP", OPTINT, "max HP (0-65535)"),
|
|
102
|
+
Field("mp", "MP", OPTINT, "max MP (0-65535)"),
|
|
103
|
+
Field("speed", "Speed", OPTINT, "0-255"),
|
|
104
|
+
Field("strength", "Strength", OPTINT, "0-255"),
|
|
105
|
+
Field("magic", "Magic", OPTINT, "0-255"),
|
|
106
|
+
Field("spirit", "Spirit", OPTINT, "0-255"),
|
|
107
|
+
Field("level", "Level", OPTINT, "0-255 (drives variance, steal, Level-N spells)"),
|
|
108
|
+
Field("category", "Category", OPTINT, "race/killer/flight/undead category bits (0-255)"),
|
|
109
|
+
Field("hit_rate", "Hit rate", OPTINT, "physical accuracy (0-255)"),
|
|
110
|
+
Field("phys_def", "Phys. defence", OPTINT, "0-255"),
|
|
111
|
+
Field("phys_evade", "Phys. evade", OPTINT, "0-255"),
|
|
112
|
+
Field("mag_def", "Mag. defence", OPTINT, "0-255"),
|
|
113
|
+
Field("mag_evade", "Mag. evade", OPTINT, "0-255"),
|
|
114
|
+
Field("blue_magic", "Blue magic id", OPTINT, "the Quina Eat / Blue-magic learn id"),
|
|
115
|
+
# affinities (element / status NAME lists)
|
|
116
|
+
Field("null", "Null elements", STRLIST, "elements this enemy is IMMUNE to, e.g. Fire, Ice"),
|
|
117
|
+
Field("absorb", "Absorb elements", STRLIST, "elements this enemy ABSORBS (heals from)"),
|
|
118
|
+
Field("half", "Halve elements", STRLIST, "elements this enemy takes HALF from"),
|
|
119
|
+
Field("weak", "Weak elements", STRLIST, "elements this enemy is WEAK to"),
|
|
120
|
+
Field("resist_status", "Resist status", STRLIST, "statuses this enemy resists, e.g. Poison, Sleep"),
|
|
121
|
+
Field("auto_status", "Auto status", STRLIST, "statuses always active on this enemy"),
|
|
122
|
+
Field("initial_status", "Initial status", STRLIST, "statuses on this enemy at battle start"),
|
|
123
|
+
# rewards
|
|
124
|
+
Field("gil", "Gil", OPTINT, "gil awarded (0-65535)"),
|
|
125
|
+
Field("exp", "EXP", OPTINT, "EXP awarded (0-65535)"),
|
|
126
|
+
Field("drop", "Drops (4 items)", STRLIST, 'win items: 4 entries (name/id; "none" for an empty slot)'),
|
|
127
|
+
Field("steal", "Steals (4 items)", STRLIST, 'stealable items: 4 entries (name/id; "none" for empty)'),
|
|
128
|
+
Field("win_card", "Win card", OPTINT, "the Tetra Master card id awarded"),
|
|
129
|
+
Field("flags", "Flags", STRLIST, "behaviour flags: die_atk, die_dmg, non_dying_boss"),
|
|
130
|
+
# placement
|
|
131
|
+
Field("pos", "Position (x, z)", COORD, "where this enemy stands in the formation"),
|
|
132
|
+
Field("y", "Height (y)", OPTINT, "vertical placement offset"),
|
|
133
|
+
Field("rot", "Rotation", OPTINT, "facing rotation"),
|
|
134
|
+
# re-skin (visual transplant)
|
|
135
|
+
Field("model", "Re-skin model id", OPTINT, "advanced: borrow another enemy TYPE's model + animations"),
|
|
136
|
+
Field("model_scene", "Re-skin donor scene", STR, "advanced: a donor battle scene to borrow a model from"),
|
|
137
|
+
Field("model_type", "Re-skin donor type", OPTINT, "advanced: which type in the donor scene to borrow"),
|
|
138
|
+
Field("ai_entry", "AI entry", OPTINT, "advanced: explicit AI entry for this slot (needs monster_count)"),
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ===== PLAYER / ABILITY tuning ==========================================================================
|
|
143
|
+
# Mod-GLOBAL CSV deltas a battle.toml may ALSO carry (the same blocks a field.toml can -- see
|
|
144
|
+
# ``ff9mapkit.battle.build.player_csv_problems`` / ``_emit_player_data``), so a battle fork tunes the PARTY
|
|
145
|
+
# that fights it in the SAME deployable doc. Each spec is FLAT over the existing field kinds; the FIRST field
|
|
146
|
+
# is the row "selector" (the tree label). The values name->id + base-CSV merge happens at build (which has the
|
|
147
|
+
# install); these specs only shape the override. Nested / multiline / list tables -- ``[[learn]]`` (sub-tables),
|
|
148
|
+
# ``[[ability_feature]]`` (a code body), ``[[status_set]]`` / ``[[magic_sword_set]]`` (offline list bundles) --
|
|
149
|
+
# stay build-supported + hand-authorable; they're out of the v1 forms (a later sub-increment).
|
|
150
|
+
|
|
151
|
+
# [[character]] -> BaseStats.csv (per-character base stats). The canonical CHARACTER_FIELDS keys.
|
|
152
|
+
CHARACTER_SPEC = [
|
|
153
|
+
Field("character", "Character", STR, "name (Zidane..Beatrix) or a 0-11 id"),
|
|
154
|
+
Field("strength", "Strength", OPTINT, "base Strength 0-255"),
|
|
155
|
+
Field("magic", "Magic", OPTINT, "base Magic 0-255"),
|
|
156
|
+
Field("dexterity", "Dexterity", OPTINT, "base Dexterity 0-255"),
|
|
157
|
+
Field("will", "Will (Spirit)", OPTINT, "base Will / Spirit 0-255"),
|
|
158
|
+
Field("gems", "Magic stones", OPTINT, "starting Gems / magic-stone count"),
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
# [[battle_action]] -> Actions.csv (rebalance a shared player ability). The common scalar levers; the
|
|
162
|
+
# targeting BOOLEANS + vfx ids stay hand-authorable (a delta omits an unchecked bool, which can't express
|
|
163
|
+
# "turn this OFF", so the form would silently fail to override it -- see build_entity's BOOL rule).
|
|
164
|
+
BATTLE_ACTION_SPEC = [
|
|
165
|
+
Field("action", "Ability", STR, "name (e.g. Fire) or a 0-191 id"),
|
|
166
|
+
Field("power", "Power", OPTINT, "base damage/heal power"),
|
|
167
|
+
Field("element", "Elements", STRLIST, "element names, e.g. Fire, Ice (sets the element bitmask)"),
|
|
168
|
+
Field("rate", "Rate / accuracy", OPTINT, "the action's hit/status rate"),
|
|
169
|
+
Field("mp", "MP cost", OPTINT, "MP the ability costs"),
|
|
170
|
+
Field("category", "Category", OPTINT, "ability category bits (0-255)"),
|
|
171
|
+
Field("type", "Type", OPTINT, "ability type (0-255)"),
|
|
172
|
+
Field("status_index", "Status set", OPTINT, "the StatusSets.csv row this action inflicts/cures"),
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
# [[status]] -> StatusData.csv (retune an ailment).
|
|
176
|
+
STATUS_SPEC = [
|
|
177
|
+
Field("status", "Status", STR, "name (e.g. Poison) or a 0-32 id"),
|
|
178
|
+
Field("tick", "Per-tick effect", OPTINT, "OprCount: the per-tick magnitude 0-255"),
|
|
179
|
+
Field("duration", "Duration", OPTINT, "ContiCount: 0 = until cured (0-65535)"),
|
|
180
|
+
Field("clear_on_apply", "Clears on apply", STRLIST, "statuses applying this one CLEARS, e.g. Sleep"),
|
|
181
|
+
Field("immunity_provided", "Grants immunity to", STRLIST, "statuses this one blocks while active"),
|
|
182
|
+
]
|
|
183
|
+
|
|
184
|
+
# [[ability_gem]] -> AbilityGems.csv (the support-ability gem COST).
|
|
185
|
+
ABILITY_GEM_SPEC = [
|
|
186
|
+
Field("ability", "Support ability", STR, "name (e.g. Auto-Haste / AutoHaste) or a 0-63 id"),
|
|
187
|
+
Field("gems", "Gem cost", OPTINT, "magic stones to equip it"),
|
|
188
|
+
]
|
|
189
|
+
|
|
190
|
+
# [[character_param]] -> CharacterParameters.csv (per-character menu/row/preset wiring).
|
|
191
|
+
CHARACTER_PARAM_SPEC = [
|
|
192
|
+
Field("character", "Character", STR, "name (Zidane..Beatrix) or a 0-11 id"),
|
|
193
|
+
Field("row", "Front/back row", OPTINT, "0 = front, 1 = back (0-255)"),
|
|
194
|
+
Field("win_pose", "Win pose", OPTINT, "victory-pose id (0-255)"),
|
|
195
|
+
Field("category", "Category", OPTINT, "category bits (0-255)"),
|
|
196
|
+
Field("menu_type", "Menu preset", STR, "a CharacterPresetId name (e.g. Steiner) or a 0-19 id"),
|
|
197
|
+
Field("equipment_set", "Equipment set", OPTINT, "which equipment set governs this character (0-255)"),
|
|
198
|
+
Field("serial_formula", "Serial formula", STR, "advanced: the serial-stat formula string"),
|
|
199
|
+
Field("name_keyword", "Name keyword", STR, "advanced: the name-resolution keyword string"),
|
|
200
|
+
]
|
|
201
|
+
|
|
202
|
+
# [[command_set]] -> CommandSets.csv (re-point a character's battle-menu command SLOTS to BattleCommandIds).
|
|
203
|
+
COMMAND_SET_SPEC = [
|
|
204
|
+
Field("preset", "Character preset", STR, "a CharacterPresetId name (e.g. Zidane) or a 0-19 id"),
|
|
205
|
+
Field("attack", "Attack", OPTINT, "BattleCommandId 0-47"),
|
|
206
|
+
Field("defend", "Defend", OPTINT, "BattleCommandId 0-47"),
|
|
207
|
+
Field("ability1", "Ability 1", OPTINT, "BattleCommandId 0-47"),
|
|
208
|
+
Field("ability2", "Ability 2", OPTINT, "BattleCommandId 0-47"),
|
|
209
|
+
Field("item", "Item", OPTINT, "BattleCommandId 0-47"),
|
|
210
|
+
Field("change", "Change", OPTINT, "BattleCommandId 0-47"),
|
|
211
|
+
Field("attack_trance", "Attack (Trance)", OPTINT, "BattleCommandId 0-47"),
|
|
212
|
+
Field("defend_trance", "Defend (Trance)", OPTINT, "BattleCommandId 0-47"),
|
|
213
|
+
Field("ability1_trance", "Ability 1 (Trance)", OPTINT, "BattleCommandId 0-47"),
|
|
214
|
+
Field("ability2_trance", "Ability 2 (Trance)", OPTINT, "BattleCommandId 0-47"),
|
|
215
|
+
Field("item_trance", "Item (Trance)", OPTINT, "BattleCommandId 0-47"),
|
|
216
|
+
Field("change_trance", "Change (Trance)", OPTINT, "BattleCommandId 0-47"),
|
|
217
|
+
]
|
|
218
|
+
|
|
219
|
+
# [[leveling]] -> Leveling.csv (the per-level growth curve; WHOLE-FILE re-emit, patched by level).
|
|
220
|
+
LEVELING_SPEC = [
|
|
221
|
+
Field("level", "Level", INT, "1-99 (the level this row tunes)"),
|
|
222
|
+
Field("exp", "EXP to next", OPTINT, "experience to the NEXT level (UInt32)"),
|
|
223
|
+
Field("bonus_hp", "Bonus HP", OPTINT, "HP grows BonusHP*Strength/50 (UInt16)"),
|
|
224
|
+
Field("bonus_mp", "Bonus MP", OPTINT, "MP grows BonusMP*Magic/100 (UInt16)"),
|
|
225
|
+
]
|
|
226
|
+
|
|
227
|
+
# the v1 "Party & abilities" tree branch: ordered (key, label, spec, selector_key, default_entry).
|
|
228
|
+
PLAYER_TABLES = [
|
|
229
|
+
("character", "Character stats", CHARACTER_SPEC, "character", {"character": "Zidane"}),
|
|
230
|
+
("battle_action", "Ability rebalance", BATTLE_ACTION_SPEC, "action", {"action": "Fire"}),
|
|
231
|
+
("status", "Status ailment", STATUS_SPEC, "status", {"status": "Poison"}),
|
|
232
|
+
("ability_gem", "Ability gem cost", ABILITY_GEM_SPEC, "ability", {"ability": "Auto-Haste"}),
|
|
233
|
+
("character_param", "Character params", CHARACTER_PARAM_SPEC, "character", {"character": "Zidane"}),
|
|
234
|
+
("command_set", "Battle command set", COMMAND_SET_SPEC, "preset", {"preset": "Zidane"}),
|
|
235
|
+
("leveling", "Leveling curve", LEVELING_SPEC, "level", {"level": 1}),
|
|
236
|
+
]
|
|
237
|
+
PLAYER_SPECS = {k: spec for (k, _l, spec, _s, _d) in PLAYER_TABLES}
|
|
238
|
+
PLAYER_LABEL = {k: lbl for (k, lbl, _sp, _s, _d) in PLAYER_TABLES}
|
|
239
|
+
PLAYER_SELECTOR = {k: sel for (k, _l, _sp, sel, _d) in PLAYER_TABLES}
|
|
240
|
+
PLAYER_DEFAULT = {k: dict(d) for (k, _l, _sp, _s, d) in PLAYER_TABLES}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""A clickable breadcrumb -- the GUI's "you are here" across the game-data hierarchy.
|
|
2
|
+
|
|
3
|
+
The kit's data forms a containment hierarchy -- a JOURNEY (a playable arc) contains CAMPAIGNS, a
|
|
4
|
+
campaign contains FIELDS, a field contains OBJECTS (NPCs/gateways/events/the player & party). Today
|
|
5
|
+
that depth is split across windows; this widget renders the full resolved path as one line of
|
|
6
|
+
clickable segments (``◆ Dali Arc ▸ ▣ Dali chain ▸ ● DALI_INN ▸ ▸ NPC: Innkeeper``) so a user
|
|
7
|
+
always reads, in plain words, where they are -- and clicking any ancestor segment navigates up to it.
|
|
8
|
+
|
|
9
|
+
Same discipline as :mod:`.theme` / :mod:`.feedback`: the data layer (``Crumb`` + :func:`trail`) is
|
|
10
|
+
tk-FREE and unit-testable; the only Tk lives in :class:`Breadcrumb`, which imports tkinter lazily.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
# the four containment-spine levels, outermost -> innermost, with a leading glyph (matches the navigator
|
|
18
|
+
# badges). BATTLE / SAVE are the two OFF-spine doc families -- a battle.toml is a referenced SIBLING of a
|
|
19
|
+
# field, a save doc is ORTHOGONAL game state -- so the breadcrumb can name them on their own tabs too.
|
|
20
|
+
JOURNEY, CAMPAIGN, FIELD, OBJECT = "journey", "campaign", "field", "object"
|
|
21
|
+
HUB = "hub" # the journeys.toml root (the CONTAINER of journeys, above a journey)
|
|
22
|
+
BATTLE, SAVE = "battle", "save"
|
|
23
|
+
GLYPH = {HUB: "⌂", JOURNEY: "◆", CAMPAIGN: "▣", FIELD: "●", OBJECT: "▸", BATTLE: "⚔", SAVE: "◈"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Crumb:
|
|
28
|
+
"""One breadcrumb segment: its hierarchy ``level``, the ``label`` shown, and a ``key`` the click
|
|
29
|
+
handler uses to navigate (a member name for a field, a tree iid for an object, a sentinel for the
|
|
30
|
+
journey/campaign roots)."""
|
|
31
|
+
|
|
32
|
+
level: str
|
|
33
|
+
label: str
|
|
34
|
+
key: str = ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def trail(journey=None, campaign=None, field=None, obj_label=None, obj_key="") -> list:
|
|
38
|
+
"""Build the ordered :class:`Crumb` list from whatever levels are currently resolved (each is
|
|
39
|
+
optional; an unopened level is simply omitted, so the trail grows as the user drills in)."""
|
|
40
|
+
out = []
|
|
41
|
+
if journey:
|
|
42
|
+
out.append(Crumb(JOURNEY, journey, "@journey"))
|
|
43
|
+
if campaign:
|
|
44
|
+
out.append(Crumb(CAMPAIGN, campaign, "@campaign"))
|
|
45
|
+
if field:
|
|
46
|
+
out.append(Crumb(FIELD, field, field)) # key = the campaign member name
|
|
47
|
+
if obj_label:
|
|
48
|
+
out.append(Crumb(OBJECT, obj_label, obj_key)) # key = the editor tree iid (e.g. "npc:2")
|
|
49
|
+
return out
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Breadcrumb:
|
|
53
|
+
"""A one-line clickable path bar themed from a palette dict. ``on_navigate(crumb)`` fires when an
|
|
54
|
+
ANCESTOR segment is clicked (the leaf -- where you already are -- is inert)."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, parent, palette, *, on_navigate=None):
|
|
57
|
+
import tkinter as tk
|
|
58
|
+
|
|
59
|
+
self.pal = palette
|
|
60
|
+
self.on_navigate = on_navigate
|
|
61
|
+
self.frame = tk.Frame(parent, background=palette["surface"],
|
|
62
|
+
highlightthickness=1, highlightbackground=palette["border"])
|
|
63
|
+
self._empty = "No campaign open -- Open a campaign.toml to navigate it."
|
|
64
|
+
self.set([])
|
|
65
|
+
|
|
66
|
+
def set(self, crumbs):
|
|
67
|
+
"""Render ``crumbs`` (a :func:`trail` list); an empty list shows a muted placeholder."""
|
|
68
|
+
import tkinter as tk
|
|
69
|
+
|
|
70
|
+
for w in self.frame.winfo_children():
|
|
71
|
+
w.destroy()
|
|
72
|
+
if not crumbs:
|
|
73
|
+
tk.Label(self.frame, text=self._empty, background=self.pal["surface"],
|
|
74
|
+
foreground=self.pal["muted"], font=("Segoe UI", 10)).pack(side="left", padx=10, pady=5)
|
|
75
|
+
return
|
|
76
|
+
last = len(crumbs) - 1
|
|
77
|
+
for i, c in enumerate(crumbs):
|
|
78
|
+
if i:
|
|
79
|
+
tk.Label(self.frame, text="▸", background=self.pal["surface"],
|
|
80
|
+
foreground=self.pal["muted"], font=("Segoe UI", 10)).pack(side="left", padx=1)
|
|
81
|
+
leaf = (i == last)
|
|
82
|
+
lbl = tk.Label(self.frame, text=f"{GLYPH.get(c.level, '')} {c.label}",
|
|
83
|
+
background=self.pal["surface"],
|
|
84
|
+
foreground=self.pal["text"] if leaf else self.pal["accent"],
|
|
85
|
+
font=("Segoe UI", 10, "bold") if leaf else ("Segoe UI", 10),
|
|
86
|
+
cursor="arrow" if leaf else "hand2", padx=5, pady=4)
|
|
87
|
+
lbl.pack(side="left", pady=1)
|
|
88
|
+
if not leaf and self.on_navigate is not None:
|
|
89
|
+
lbl.bind("<Button-1>", lambda _e, cc=c: self.on_navigate(cc))
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Small THEMED modal input dialogs -- a drop-in for :mod:`tkinter.simpledialog`.
|
|
2
|
+
|
|
3
|
+
tkinter's ``simpledialog`` builds its Toplevel from CLASSIC tk widgets with OS-default colours, so it
|
|
4
|
+
ignores the app's ttk theme and renders light against the dark editor. These replacements use ttk
|
|
5
|
+
widgets on a Toplevel whose background matches the themed app, so an input prompt looks native to the
|
|
6
|
+
editor. :func:`ask_string` returns the entered text (or ``None`` if cancelled); :func:`ask_integer`
|
|
7
|
+
parses + range-checks an int, re-prompting inline on a bad value. The dialog is a small class
|
|
8
|
+
(mirroring :mod:`.picker`) so it's headless-testable -- ``ask_*`` just wrap it with ``wait_window``.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import tkinter as tk
|
|
13
|
+
from tkinter import ttk
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _InputDialog:
|
|
17
|
+
def __init__(self, parent, title, prompt, *, initial="", integer=False, minvalue=None, maxvalue=None):
|
|
18
|
+
self.integer = integer
|
|
19
|
+
self.minvalue, self.maxvalue = minvalue, maxvalue
|
|
20
|
+
self.result = None
|
|
21
|
+
|
|
22
|
+
win = self.win = tk.Toplevel(parent)
|
|
23
|
+
win.title(title)
|
|
24
|
+
win.transient(parent)
|
|
25
|
+
win.resizable(False, False)
|
|
26
|
+
try: # match the themed app (a bare Toplevel is OS-gray)
|
|
27
|
+
win.configure(background=parent.winfo_toplevel()["background"])
|
|
28
|
+
except tk.TclError:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
ttk.Label(win, text=prompt, wraplength=380, justify="left").pack(anchor="w", padx=14, pady=(14, 6))
|
|
32
|
+
self.var = tk.StringVar(value="" if initial is None else str(initial))
|
|
33
|
+
ent = self.ent = ttk.Entry(win, textvariable=self.var, width=46)
|
|
34
|
+
ent.pack(fill="x", padx=14)
|
|
35
|
+
self.err = ttk.Label(win, text="", foreground="#ff6b6b", wraplength=380, justify="left")
|
|
36
|
+
self.err.pack(anchor="w", padx=14, pady=(2, 0))
|
|
37
|
+
|
|
38
|
+
bar = ttk.Frame(win, padding=12)
|
|
39
|
+
bar.pack(fill="x")
|
|
40
|
+
ttk.Button(bar, text="OK", style="Accent.TButton", command=self._ok).pack(side="right")
|
|
41
|
+
ttk.Button(bar, text="Cancel", command=self._cancel).pack(side="right", padx=6)
|
|
42
|
+
ent.bind("<Return>", lambda e: self._ok())
|
|
43
|
+
ent.bind("<Escape>", lambda e: self._cancel())
|
|
44
|
+
ent.focus_set()
|
|
45
|
+
ent.select_range(0, "end")
|
|
46
|
+
win.grab_set()
|
|
47
|
+
|
|
48
|
+
def _ok(self):
|
|
49
|
+
v = self.var.get().strip()
|
|
50
|
+
if self.integer:
|
|
51
|
+
try:
|
|
52
|
+
n = int(v)
|
|
53
|
+
except ValueError:
|
|
54
|
+
self.err.config(text="Enter a whole number.")
|
|
55
|
+
return
|
|
56
|
+
if self.minvalue is not None and n < self.minvalue:
|
|
57
|
+
self.err.config(text=f"Must be at least {self.minvalue}.")
|
|
58
|
+
return
|
|
59
|
+
if self.maxvalue is not None and n > self.maxvalue:
|
|
60
|
+
self.err.config(text=f"Must be at most {self.maxvalue}.")
|
|
61
|
+
return
|
|
62
|
+
self.result = n
|
|
63
|
+
else:
|
|
64
|
+
self.result = v # the caller treats "" as cancel (if not name: ...)
|
|
65
|
+
self.win.destroy()
|
|
66
|
+
|
|
67
|
+
def _cancel(self):
|
|
68
|
+
self.result = None
|
|
69
|
+
self.win.destroy()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def ask_string(parent, title, prompt, *, initial=""):
|
|
73
|
+
"""Modal themed text prompt. Returns the entered string, or None if cancelled."""
|
|
74
|
+
dlg = _InputDialog(parent, title, prompt, initial=initial)
|
|
75
|
+
parent.wait_window(dlg.win)
|
|
76
|
+
return dlg.result
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def ask_integer(parent, title, prompt, *, initial=None, minvalue=None, maxvalue=None):
|
|
80
|
+
"""Modal themed integer prompt (re-prompts inline on a non-int / out-of-range). Returns int or None."""
|
|
81
|
+
dlg = _InputDialog(parent, title, prompt, initial=initial, integer=True,
|
|
82
|
+
minvalue=minvalue, maxvalue=maxvalue)
|
|
83
|
+
parent.wait_window(dlg.win)
|
|
84
|
+
return dlg.result
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _smoke():
|
|
88
|
+
"""Headless self-test: build the dialog, drive _ok/_cancel, and check string + int parsing/ranging."""
|
|
89
|
+
from .theme import apply_theme
|
|
90
|
+
root = tk.Tk()
|
|
91
|
+
root.withdraw()
|
|
92
|
+
apply_theme(root)
|
|
93
|
+
|
|
94
|
+
d = _InputDialog(root, "t", "name?", initial="seed")
|
|
95
|
+
assert d.var.get() == "seed"
|
|
96
|
+
d.var.set("boss_dead"); d._ok()
|
|
97
|
+
assert d.result == "boss_dead", d.result
|
|
98
|
+
|
|
99
|
+
bad = _InputDialog(root, "t", "n?", integer=True, minvalue=4000, maxvalue=32767)
|
|
100
|
+
bad.var.set("nope"); bad._ok()
|
|
101
|
+
assert bad.result is None and bad.err["text"], "a non-int stays open with an error"
|
|
102
|
+
bad.var.set("10"); bad._ok()
|
|
103
|
+
assert bad.result is None, "below minvalue stays open"
|
|
104
|
+
bad.var.set("5000"); bad._ok()
|
|
105
|
+
assert bad.result == 5000, bad.result
|
|
106
|
+
|
|
107
|
+
cx = _InputDialog(root, "t", "x?"); cx._cancel()
|
|
108
|
+
assert cx.result is None
|
|
109
|
+
print("dialogs smoke ok: string + integer (parse/range) + cancel")
|
|
110
|
+
root.destroy()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
if __name__ == "__main__":
|
|
114
|
+
import sys
|
|
115
|
+
if "--smoke" in sys.argv:
|
|
116
|
+
_smoke()
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""A shared *result* surface for the GUI apps: a verdict banner + a structured problems list.
|
|
2
|
+
|
|
3
|
+
The kit's apps used to dump raw subprocess/traceback text into a scrolling log, leaving the user to
|
|
4
|
+
read tea leaves for "did it work, and what do I do next?". This module replaces that with two pieces:
|
|
5
|
+
|
|
6
|
+
* a :class:`Verdict` -- a one-line outcome (ok / passed-with-warnings / failed / running) plus an
|
|
7
|
+
optional next-action line ("Relaunch once, then F6 -> Warp -> 2640"), rendered as a coloured banner;
|
|
8
|
+
* a flat list of :class:`Problem` rows (errors + warnings), rendered as a compact, colour-coded,
|
|
9
|
+
selectable list -- the structured replacement for ``ERROR ...`` / ``warn ...`` log spam.
|
|
10
|
+
|
|
11
|
+
Following the same discipline as :mod:`.theme` / :mod:`.forms` / :mod:`.model`, the data layer
|
|
12
|
+
(``Verdict``/``Problem`` + the ``classify``/``from_returncode``/``problems`` builders) is **tk-FREE**
|
|
13
|
+
and unit-testable on a headless machine; the only Tk lives in :class:`FeedbackPanel`, which imports
|
|
14
|
+
tkinter lazily in ``__init__`` so importing this module never needs a display. The panel takes a
|
|
15
|
+
palette dict from :func:`.theme.apply_theme`, so it matches whatever app hosts it.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
|
|
22
|
+
# --- the four outcome levels (also the problem severities, minus "ok"/"running") -----------------
|
|
23
|
+
OK = "ok"
|
|
24
|
+
WARN = "warn"
|
|
25
|
+
ERROR = "error"
|
|
26
|
+
RUNNING = "running"
|
|
27
|
+
|
|
28
|
+
# glyphs read fine in Segoe UI (the themed default font); kept ASCII-safe-ish for any console echo.
|
|
29
|
+
_GLYPH = {OK: "✓", WARN: "⚠", ERROR: "✕", RUNNING: "…"} # ✓ ⚠ ✕ …
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Problem:
|
|
34
|
+
"""One row in the problems list: an error or a warning, with an optional location label."""
|
|
35
|
+
|
|
36
|
+
severity: str # ERROR | WARN
|
|
37
|
+
message: str
|
|
38
|
+
where: str = "" # optional: a field/member/line the problem belongs to (for a future jump-to)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Verdict:
|
|
43
|
+
"""A one-line outcome to show in the banner."""
|
|
44
|
+
|
|
45
|
+
level: str # OK | WARN | ERROR | RUNNING
|
|
46
|
+
headline: str
|
|
47
|
+
next_action: str = "" # the single most useful next step (e.g. an in-game warp), shown under the banner
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _n(count: int, word: str) -> str:
|
|
51
|
+
"""``2, 'error' -> '2 errors'`` / ``1, 'warning' -> '1 warning'`` (naive English pluralisation)."""
|
|
52
|
+
return f"{count} {word}" + ("" if count == 1 else "s")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def classify(errors, warnings, *, subject="", clean_headline=None, next_action="") -> Verdict:
|
|
56
|
+
"""Turn two message lists into a :class:`Verdict`.
|
|
57
|
+
|
|
58
|
+
``subject`` prefixes the headline ("Build", "Check", "Campaign lint"). Errors win over warnings:
|
|
59
|
+
any error -> a failed verdict; warnings only -> passed-with-warnings; neither -> ``clean_headline``
|
|
60
|
+
(default "<subject> -- all clear")."""
|
|
61
|
+
ne, nw = len(errors), len(warnings)
|
|
62
|
+
subj = subject.strip()
|
|
63
|
+
if ne:
|
|
64
|
+
tail = _n(ne, "problem") + (f", {_n(nw, 'warning')}" if nw else "") + " to fix"
|
|
65
|
+
head = f"{subj} -- {tail}" if subj else tail
|
|
66
|
+
return Verdict(ERROR, head, next_action)
|
|
67
|
+
if nw:
|
|
68
|
+
head = f"{subj} -- passed with {_n(nw, 'warning')}" if subj else f"passed with {_n(nw, 'warning')}"
|
|
69
|
+
return Verdict(WARN, head, next_action)
|
|
70
|
+
head = clean_headline or (f"{subj} -- all clear" if subj else "all clear")
|
|
71
|
+
return Verdict(OK, head, next_action)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def from_returncode(code, *, subject="", ok_headline=None, ok_next="", fail_hint="") -> Verdict:
|
|
75
|
+
"""A :class:`Verdict` for a subprocess result (the import/deploy shell-outs that have no structured
|
|
76
|
+
error list -- only an exit code + a streamed log). ``code == 0`` -> ok; anything else -> failed,
|
|
77
|
+
pointing the user at the streamed details."""
|
|
78
|
+
subj = subject.strip()
|
|
79
|
+
if code == 0:
|
|
80
|
+
return Verdict(OK, ok_headline or (f"{subj} -- done" if subj else "done"), ok_next)
|
|
81
|
+
head = f"{subj} -- failed (exit {code})" if subj else f"failed (exit {code})"
|
|
82
|
+
return Verdict(ERROR, head, fail_hint or "See the details below.")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def problems(errors=(), warnings=()) -> list:
|
|
86
|
+
"""Flatten ``(errors, warnings)`` string lists into a severity-tagged :class:`Problem` list
|
|
87
|
+
(errors first, then warnings -- the natural read order)."""
|
|
88
|
+
rows = [Problem(ERROR, str(m)) for m in errors]
|
|
89
|
+
rows += [Problem(WARN, str(m)) for m in warnings]
|
|
90
|
+
return rows
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# --- the Tk widget (lazy import keeps the data layer above headless-importable) ------------------
|
|
94
|
+
class FeedbackPanel:
|
|
95
|
+
"""A coloured verdict banner + a structured problems list, themed from a palette dict.
|
|
96
|
+
|
|
97
|
+
Construct it on a ttk parent and ``.frame.pack(...)`` it where the old log used to dominate. Drive
|
|
98
|
+
it from the UI thread: ``running(headline)`` when a job starts, then ``show(verdict, problems)``
|
|
99
|
+
when it finishes. ``on_select(problem)`` (optional) fires when a problem row is clicked -- the seam
|
|
100
|
+
a future unified shell will use to jump to the offending node.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(self, parent, palette, *, on_select=None):
|
|
104
|
+
import tkinter as tk
|
|
105
|
+
from tkinter import ttk
|
|
106
|
+
|
|
107
|
+
self.pal = palette
|
|
108
|
+
self.on_select = on_select
|
|
109
|
+
self._rows: list = []
|
|
110
|
+
|
|
111
|
+
self.frame = ttk.Frame(parent)
|
|
112
|
+
|
|
113
|
+
# the banner: a coloured status stripe + a glyph + the headline, and a next-action line beneath.
|
|
114
|
+
self._banner = tk.Frame(self.frame, background=palette["surface"],
|
|
115
|
+
highlightthickness=1, highlightbackground=palette["border"])
|
|
116
|
+
self._stripe = tk.Frame(self._banner, width=4, background=palette["muted"])
|
|
117
|
+
self._stripe.pack(side="left", fill="y")
|
|
118
|
+
inner = tk.Frame(self._banner, background=palette["surface"])
|
|
119
|
+
inner.pack(side="left", fill="both", expand=True, padx=10, pady=7)
|
|
120
|
+
self._glyph = tk.Label(inner, text="", background=palette["surface"], foreground=palette["muted"],
|
|
121
|
+
font=("Segoe UI", 13, "bold"))
|
|
122
|
+
self._glyph.pack(side="left", padx=(0, 8))
|
|
123
|
+
headwrap = tk.Frame(inner, background=palette["surface"])
|
|
124
|
+
headwrap.pack(side="left", fill="x", expand=True)
|
|
125
|
+
self._headline = tk.Label(headwrap, text="", background=palette["surface"],
|
|
126
|
+
foreground=palette["text"], font=("Segoe UI", 11, "bold"),
|
|
127
|
+
anchor="w", justify="left")
|
|
128
|
+
self._headline.pack(fill="x", anchor="w")
|
|
129
|
+
self._next = tk.Label(headwrap, text="", background=palette["surface"],
|
|
130
|
+
foreground=palette["accent"], font=("Segoe UI", 10), anchor="w",
|
|
131
|
+
justify="left", wraplength=560)
|
|
132
|
+
# _next is packed only when there's a next-action string.
|
|
133
|
+
|
|
134
|
+
# the problems list: a compact tree (severity glyph + message), colour-coded, selectable.
|
|
135
|
+
self._plist_wrap = ttk.Frame(self.frame)
|
|
136
|
+
self._plist = ttk.Treeview(self._plist_wrap, show="tree", selectmode="browse", height=5)
|
|
137
|
+
self._plist.column("#0", width=560, stretch=True)
|
|
138
|
+
self._plist.pack(side="left", fill="both", expand=True)
|
|
139
|
+
psb = ttk.Scrollbar(self._plist_wrap, orient="vertical", command=self._plist.yview)
|
|
140
|
+
psb.pack(side="right", fill="y")
|
|
141
|
+
self._plist.configure(yscrollcommand=psb.set)
|
|
142
|
+
self._plist.tag_configure(ERROR, foreground=palette["error"])
|
|
143
|
+
self._plist.tag_configure(WARN, foreground=palette["warn"])
|
|
144
|
+
self._plist.bind("<<TreeviewSelect>>", self._on_row_select)
|
|
145
|
+
|
|
146
|
+
# both pieces start hidden; show() / running() reveal them.
|
|
147
|
+
|
|
148
|
+
# -- public API (call on the UI thread) --
|
|
149
|
+
def running(self, headline="Working…"):
|
|
150
|
+
"""Show a neutral 'in progress' banner and clear any prior problems."""
|
|
151
|
+
self._set_banner(Verdict(RUNNING, headline))
|
|
152
|
+
self._set_problems([])
|
|
153
|
+
|
|
154
|
+
def show(self, verdict, problem_rows=()):
|
|
155
|
+
"""Render a finished :class:`Verdict` + its (possibly empty) :class:`Problem` rows."""
|
|
156
|
+
self._set_banner(verdict)
|
|
157
|
+
self._set_problems(list(problem_rows))
|
|
158
|
+
|
|
159
|
+
def clear(self):
|
|
160
|
+
"""Hide the banner + problems entirely (back to the resting state)."""
|
|
161
|
+
self._banner.pack_forget()
|
|
162
|
+
self._plist_wrap.pack_forget()
|
|
163
|
+
|
|
164
|
+
# -- internals --
|
|
165
|
+
def _color(self, level):
|
|
166
|
+
return {OK: self.pal["success"], WARN: self.pal["warn"], ERROR: self.pal["error"],
|
|
167
|
+
RUNNING: self.pal["muted"]}.get(level, self.pal["muted"])
|
|
168
|
+
|
|
169
|
+
def _set_banner(self, verdict):
|
|
170
|
+
col = self._color(verdict.level)
|
|
171
|
+
self._stripe.configure(background=col)
|
|
172
|
+
self._glyph.configure(text=_GLYPH.get(verdict.level, ""), foreground=col)
|
|
173
|
+
self._headline.configure(text=verdict.headline)
|
|
174
|
+
if verdict.next_action:
|
|
175
|
+
self._next.configure(text=verdict.next_action)
|
|
176
|
+
self._next.pack(fill="x", anchor="w", pady=(2, 0))
|
|
177
|
+
else:
|
|
178
|
+
self._next.pack_forget()
|
|
179
|
+
if not self._banner.winfo_ismapped():
|
|
180
|
+
kw = {"fill": "x", "padx": 10, "pady": (8, 4)}
|
|
181
|
+
if self._plist_wrap.winfo_ismapped(): # keep the banner above an already-shown problems list
|
|
182
|
+
kw["before"] = self._plist_wrap
|
|
183
|
+
self._banner.pack(**kw)
|
|
184
|
+
|
|
185
|
+
def _set_problems(self, rows):
|
|
186
|
+
self._rows = rows
|
|
187
|
+
self._plist.delete(*self._plist.get_children())
|
|
188
|
+
if not rows:
|
|
189
|
+
self._plist_wrap.pack_forget()
|
|
190
|
+
return
|
|
191
|
+
for i, p in enumerate(rows):
|
|
192
|
+
label = f"{_GLYPH.get(p.severity, '')} {p.message}"
|
|
193
|
+
if p.where:
|
|
194
|
+
label += f" ({p.where})"
|
|
195
|
+
self._plist.insert("", "end", iid=str(i), text=label, tags=(p.severity,))
|
|
196
|
+
# size the list to its contents (capped), so a single problem isn't a tall empty box.
|
|
197
|
+
self._plist.configure(height=max(2, min(len(rows), 8)))
|
|
198
|
+
if not self._plist_wrap.winfo_ismapped():
|
|
199
|
+
self._plist_wrap.pack(fill="both", expand=True, padx=10, pady=(0, 6))
|
|
200
|
+
|
|
201
|
+
def _on_row_select(self, _evt=None):
|
|
202
|
+
if not self.on_select:
|
|
203
|
+
return
|
|
204
|
+
sel = self._plist.selection()
|
|
205
|
+
if sel and sel[0].isdigit():
|
|
206
|
+
idx = int(sel[0])
|
|
207
|
+
if 0 <= idx < len(self._rows):
|
|
208
|
+
self.on_select(self._rows[idx])
|