Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
c35fad5
feat(mhfu): the brain rule's conditions, effects and signal, its blac…
sp00ktober Oct 9, 2026
c756020
Merge phase 2 into phase 3
sp00ktober Oct 9, 2026
12d27ee
feat(mhfu-port): conditions, effects, counters, signals and no-play p…
sp00ktober Oct 10, 2026
7181e7b
docs(ports): conditions, effects, counters, signals and the Zinogre's…
sp00ktober Oct 10, 2026
1969332
feat(studio): the graph view takes names and sides
sp00ktober Oct 10, 2026
bcc447b
test(studio): the seam-cap test builds more blocks than SEAM_RULES
sp00ktober Oct 10, 2026
9dfc4fe
feat(studio): effects, counters, signals and sides in the Behaviour dock
sp00ktober Oct 10, 2026
dd6fc97
feat(ports): the Zinogre's idle paths wait for combat through an in_c…
sp00ktober Oct 10, 2026
21d940d
feat(framework): the enraged and calmed monster events
sp00ktober Oct 10, 2026
cfa4781
feat(framework): rule conditions, effects, signals and the board in t…
sp00ktober Oct 10, 2026
1d06fe2
feat(framework): mhfu_port passes the rule fields, port:var and port:…
sp00ktober Oct 10, 2026
10e7b45
Merge the phase 3 compiler and the Zinogre demo
sp00ktober Oct 10, 2026
82f6a7a
Merge the phase 3 Behaviour dock blocks
sp00ktober Oct 10, 2026
6650ebd
Merge the phase 3 brain, bindings and mhfu_port
sp00ktober Oct 10, 2026
8889802
feat(mhfu-port): behaviour.names is the one place a graph's names are…
sp00ktober Oct 10, 2026
058f831
test(studio): the tests follow the merged Zinogre; every real kind th…
sp00ktober Oct 10, 2026
e39944a
feat(mhfu-port): any_time and idle say they wait for own moves; the R…
sp00ktober Oct 10, 2026
3588bb1
feat(mhfu-port): the rule table lists every no-play path first, as th…
sp00ktober Oct 10, 2026
840bbfc
fix(framework): a move on the hub carrier quiets the AI script's wake
sp00ktober Oct 10, 2026
e8bd7ee
fix(framework): counters run before the rules that play, and every pa…
sp00ktober Oct 10, 2026
23997e3
Merge the phase 3 live fixes: no-play rules first, the hub wake, per-…
sp00ktober Oct 10, 2026
c1588f4
test(studio): the Zinogre's ranks follow the no-play-first table; the…
sp00ktober Oct 10, 2026
13d6816
Merge the studio's no-play-first badges
sp00ktober Oct 10, 2026
653c913
Merge branch 'bx/fix-graph' into bx/fix-rules
sp00ktober Oct 13, 2026
e1aacf7
test(studio): a new counter or flag name leaves every node's size
sp00ktober Oct 13, 2026
7111167
fix(ports): the Zinogre's lower rows clear the counter_is above them
sp00ktober Oct 13, 2026
a962cd9
docs(mhfu-port): the migrate grid's comment gives the tallest block, 224
sp00ktober Oct 13, 2026
bcfa4ea
Merge the Behaviour tab, compact nodes and the property bar
sp00ktober Oct 13, 2026
32c6371
refactor(studio): the effect violet is an Ink, in the theme
sp00ktober Oct 13, 2026
ec1ccf9
Merge the trigger outline and the scoped rows
sp00ktober Oct 13, 2026
33be747
Merge editing inside a trigger's rows
sp00ktober Oct 13, 2026
8924617
Merge All rules in order, focus and the references' tips
sp00ktober Oct 13, 2026
aaf6654
Merge the shorter Behaviour section of the README
sp00ktober Oct 13, 2026
8e22627
Merge the brain reader
sp00ktober Oct 13, 2026
19c1aab
feat(mhfu): fire a board signal from a debugger
sp00ktober Oct 13, 2026
d4ceb50
Merge bx/p1-reader into bx/p2-signal
sp00ktober Oct 13, 2026
fe189f6
Merge Watch the game
sp00ktober Oct 13, 2026
d38f024
feat(studio): a slot's signal is the rule's board index, and a rule t…
sp00ktober Oct 13, 2026
0b25e2c
feat(studio): Fire raises a signal trigger in the running game, at th…
sp00ktober Oct 13, 2026
20d5343
fix(studio): the header's buttons keep their size while the watch sta…
sp00ktober Oct 13, 2026
0d8c6c6
test(studio): the header's buttons keep their size while not watching
sp00ktober Oct 13, 2026
f673f83
Merge Watch the game
sp00ktober Oct 13, 2026
778bab5
test(studio): the Fire test counts the brain's reads after the last o…
sp00ktober Oct 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 20 additions & 10 deletions apps/studio/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,16 +90,26 @@ shows the steps (click one to pick it). Picking or playing anything else starts
they normally start.

The Behaviour tab edits the port's `[behaviour]` as rules, grouped by what starts them: an event, a
state between moves, or a move playing. The game checks rules in rank order, `#1` first. Rank is the
stored canvas order, each block's `(y, x)`, top first: `+ Rule` adds a rule last, dragging a rule's
frame trades places with its own trigger's rules only, and in All rules › Canvas dragging a node can
change it. In Canvas a block's `out` wires to the next block's `in` or to a move's `play`; a move's
`while playing` starts a rule, and `then` sets the move's `after`. The palette is
`mhfu_port.behaviour.KINDS`. Watch the game, in the header, reads the running game once a second
while the tab is shown and marks each frame and trigger with how often the game fired its rules
(`7×`; a frame lights up when its count rises): the game must run this port's rules as Play in game
sends them, and the header says when it does not. The tab keeps its own docks; Findings stays open
across tabs.
state between moves, or a move playing. The game checks rules in rank order, `#1` first, rules that
play nothing before all others. Rank is the stored canvas order, each block's `(y, x)`, top first:
`+ Rule` adds a rule last, dragging a rule's frame trades places with its own trigger's rules only,
and in All rules › Canvas dragging a node can change it. In Canvas a block's `out` wires to the next
block's `in` or to a move's `play`; a move's `while playing` starts a rule, and `then` sets the
move's `after`. The palette is `mhfu_port.behaviour.KINDS`. Watch the game, in the header, reads
the running game once a second while the tab is shown and marks each frame and trigger with how
often the game fired its rules (`7×`; a frame lights up when its count rises): the game must run
this port's rules as Play in game sends them, and the header says when it does not. On a
Signal trigger, Fire raises that signal in the running game, once Watch the game has found it: the
rules' counts then show whether their conditions held. It is no edit and has no undo step. The tab
keeps its own docks; Findings stays open across tabs.

Effect blocks (violet) change the monster's counters and flags when their path fires. A path may
end at an effect with nothing to play: it applies its effects, keeps its `#n` on that block, and
the scan goes on to the next path. A counter, flag or signal name is a box of the names the graph
already uses; type a new one (lowercase letters, digits, `_`, starting with a letter) or it is put
back. An On signal block fires when Lua raises that name: `port:fire("name")`; `port:var("name")`
reads a counter or flag and `port:var("name", v)` sets it. Hunter side ticks any of front, left,
right, behind.

## Tests

Expand Down
60 changes: 42 additions & 18 deletions apps/studio/src/mhfu_studio/monster/behaviour.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from __future__ import annotations

import itertools
import typing
from collections import Counter
from collections.abc import Callable, Collection, Mapping, Sequence
Expand All @@ -35,12 +36,8 @@
stored."""
INSERTABLE = ("condition", "modifier", "effect")
"""The roles of the blocks a rule holds besides its first."""
ROLE_TITLES = {
"event": "Events",
"state": "State",
"condition": "Conditions",
"modifier": "Modifiers",
}
ROLE_TITLES = {"state": "State"}
"""A palette group's title where the role in plural is not it."""


class Wire(NamedTuple):
Expand Down Expand Up @@ -208,7 +205,8 @@ class Reading(NamedTuple):

paths: list[Path]
priority: dict[str, list[int]]
"""Block id -> the 1-based places in `paths` of the paths that play from it."""
"""Block id -> the 1-based places in `paths` of the paths that end at it; a path that plays
nothing ends at its last block too."""
loose: list[str]
refused: dict[str, str]
"""Block id -> why, for the blocks on a path `compile` refuses."""
Expand Down Expand Up @@ -242,13 +240,16 @@ def main_options() -> list[tuple[str, int]]:


def options(m: Manifest, kind: str) -> dict[str, list[tuple[str, object]]]:
"""The (label, value) choices of `kind`'s parts and main states, by param."""
"""The (label, value) choices of `kind`'s parts and main states, and the names in use, by
param."""
out: dict[str, list[tuple[str, object]]] = {}
for p in KINDS[kind].params:
if p.type == "part":
out[p.name] = list(part_options(m, p.optional))
elif p.type == "mains":
out[p.name] = list(main_options())
elif p.type in behaviour.NAMES:
out[p.name] = [(n, n) for n in behaviour.names(m, p.type)]
return out


Expand All @@ -271,19 +272,31 @@ def insertable() -> list[tuple[str, list[tuple[str, str, str]]]]:
return palette(INSERTABLE)


def start(p: Param) -> object:
"""A new block's value for `p`: the default, else the least, else the first choice; none
for an optional param."""
def fresh(stem: str, taken: Collection[str]) -> str:
"""The first of `stem1`, `stem2`, ... not in `taken`."""
return next(f"{stem}{k}" for k in itertools.count(1) if f"{stem}{k}" not in taken)


def start(p: Param, taken: Collection[str] = ()) -> object:
"""A new block's value for `p`: the default, else a name not in `taken`, the first main
state or side, the least, or the first choice; none for an optional param."""
if p.optional:
return None
if p.default is not None:
return p.default
if p.type == "mains":
kind: str = p.type
if kind == "mains":
return [0]
if kind == "sides":
return list(p.choices[:1])
if kind == "var":
return fresh(p.name, taken)
if kind == "signal":
return fresh(kind, taken)
if p.choices:
return p.choices[0]
lo = 0 if p.lo is None else p.lo
return float(lo) if p.type == "float" else int(lo)
return float(lo) if kind == "float" else int(lo)


# edits
Expand All @@ -293,8 +306,15 @@ def add_block(doc: Edits, kind: str, at: Point) -> str:
"""A block of `kind` at `at`, with valid values."""
if kind not in KINDS:
raise ManifestError(f"{kind!r} is not a kind of block")
params = {p.name: v for p in KINDS[kind].params if (v := start(p)) is not None}
i = behaviour.new_id(doc.manifest.behaviour)
m = doc.manifest
taken = {n for p in KINDS[kind].params for n in behaviour.names(m, p.type)}
params: dict[str, object] = {}
for p in KINDS[kind].params:
if (v := start(p, taken)) is not None:
params[p.name] = v
if isinstance(v, str):
taken.add(v)
i = behaviour.new_id(m.behaviour)

def add(m: Manifest) -> None:
m.behaviour.blocks[i] = Block(kind, _at(at), params)
Expand Down Expand Up @@ -417,11 +437,15 @@ def change(m: Manifest) -> None:
def _norm(p: Param, value: object) -> object:
if value is None:
return None
if p.type == "mains":
kind: str = p.type
if kind == "mains":
return sorted({int(k) for k in typing.cast(Sequence[int], value)})
if p.type in ("int", "part"):
if kind == "sides":
picked = set(typing.cast(Sequence[str], value))
return [c for c in p.choices if c in picked]
if kind in ("int", "part"):
return int(typing.cast(int, value))
if p.type == "float":
if kind == "float":
return float(typing.cast(float, value))
return value

Expand Down
35 changes: 31 additions & 4 deletions apps/studio/src/mhfu_studio/monster/panels/behaviour.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,24 +33,28 @@
from mhfu_studio.monster.panels.node_graph import FrameSpec, GraphView, LinkSpec, NodeSpec
from mhfu_studio.monster.panels.properties import Detail, Play, Plays, PropertyBar
from mhfu_studio.monster.panels.widgets import NoScene
from mhfu_studio.shell.overlay import Ink
from mhfu_studio.shell.overlay import Color, Ink
from mhfu_studio.ui import kit, theme

if TYPE_CHECKING:
from mhfu_studio.monster.workspace import MonsterWorkspace
from mhfu_studio.shell.studio import Studio

#: a node's colour by role; a move has a role of its own
ROLES = {
ROLES: dict[str, Color] = {
"event": Ink.AXIS_X,
"state": Ink.AXIS_Y,
"condition": Ink.AXIS_Z,
"modifier": Ink.WARNING,
"effect": Ink.EFFECT,
"move": Ink.SELECTION,
}
MOVE_ROLE = "move"
WARN = chr(0x26A0)
HINT = "Right-click adds a block · drag from a port to wire · higher blocks are checked first"
HINT = (
"Right-click adds a block · drag from a port to wire · higher blocks are checked first,"
" but paths that play nothing run before all of them"
)
#: over a trigger's rows, and what a wire dragged there is told
SCOPED_HINT = (
"+ Rule adds a rule · right-click a block to insert one · Delete removes · drag a frame to"
Expand All @@ -59,6 +63,7 @@
ORDER_HINT = (
"Right-click a block to insert one · Delete removes · drag a frame to reorder · wire in Canvas"
)
FIRE_TIP = "Raises {} in the running game now; the rules' counts show whether their conditions held"
SCOPED = "Wire by dragging ports in All, Canvas"
UNPLACED = "A rule with no move yet is checked by nothing, so it has no place to move"
CROSSED = "; a rule moves only among its own trigger's rules, the others keep their places"
Expand Down Expand Up @@ -108,6 +113,12 @@ def __init__(self, ws: MonsterWorkspace, studio: Studio) -> None:
" frame up to raise it",
on=self._add_rule,
)
self.fire = kit.button(
"Fire",
tip=FIRE_TIP,
on=studio.act("fire in the game", self._fire),
icon="ph.lightning",
)
self.mode = kit.Segmented(
[(ORDER, "In order"), (CANVAS, "Canvas")],
tip="All rules: each as a framed row in the order they are checked, or the free"
Expand Down Expand Up @@ -178,6 +189,7 @@ def __init__(self, ws: MonsterWorkspace, studio: Studio) -> None:
self.hint,
self.mode,
self.add_rule,
self.fire,
self.fit,
self.spread,
self.watch,
Expand Down Expand Up @@ -512,8 +524,23 @@ def _marks(self) -> None:
if self.ws.watch.lit():
self.glow.start(round(rule_game.FLASH * 1000) + 50)

def _fire(self) -> None:
"""Raises the scoped trigger in the running game; the game is not the document, so there
is no undo step."""
t = self._current()
if t is not None:
self.ws.fire(t)

def _status(self) -> None:
"""The header's words on the watch, and the outline's totals."""
"""The header's words on the watch, Fire's state, and the outline's totals."""
t = self._current()
self.fire.setVisible(t is not None and t.kind in rule_game.FIRE)
if t is not None and t.kind in rule_game.FIRE:
why = self.ws.fire_blocker(t)
self.fire.setEnabled(why is None)
self.fire.setToolTip(
FIRE_TIP.format(triggers.title(t)) if why is None else f"Fire: {why}"
)
w = self.ws.watch
self.live.setText(w.status)
self.live.setToolTip(w.status)
Expand Down
2 changes: 1 addition & 1 deletion apps/studio/src/mhfu_studio/monster/panels/moves.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
made from the clip on screen, renamed, deleted, its fields and steer edited, and played in the
running game. Its attack windows are drawn on the Timeline. The moves that follow one another are
a sequence, grouped in the table and edited under the picked move. What plays them, and when, is
the Behaviour dock's graph."""
the Behaviour tab's graph."""

from __future__ import annotations

Expand Down
52 changes: 46 additions & 6 deletions apps/studio/src/mhfu_studio/monster/panels/properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from dataclasses import dataclass, field
from typing import Any, cast

from mhfu_port.behaviour import Param
from mhfu_port.behaviour import ID, NAMES, Param
from PySide6.QtCore import QObject, QSignalBlocker, Signal
from PySide6.QtWidgets import (
QComboBox,
Expand All @@ -30,6 +30,9 @@
from mhfu_studio.ui import kit

Option = tuple[str, object]
NUMBERS = ("int", "float")
#: a multi-select over the options
PICKS = ("mains", "sides")
NONE = "none"
#: where a number field with no bound ends
_FAR = 10**9
Expand All @@ -44,6 +47,9 @@ class ParamControl(QObject):
def __init__(self, param: Param | None, options: Sequence[Option] = ()) -> None:
super().__init__()
self.optional = param is not None and param.optional
self._names = param is not None and param.type in NAMES
#: a name combo's committed text; what its edit box shows may be ahead of it
self._held = ""
self._vals: list[object] = []
self._opts: list[Option] = []
self._built = False
Expand All @@ -59,9 +65,9 @@ def _make(self, param: Param | None) -> QWidget:
line.setMinimumWidth(160)
line.editingFinished.connect(self._changed)
return line
if param.type in ("int", "float"):
if param.type in NUMBERS:
return self._number(param)
if param.type == "mains":
if param.type in PICKS:
button = QToolButton()
button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
button.setMinimumWidth(120)
Expand All @@ -71,7 +77,18 @@ def _make(self, param: Param | None) -> QWidget:
return button
combo = QComboBox()
combo.setMinimumWidth(120)
combo.currentIndexChanged.connect(self._changed)
if param.type in NAMES:
combo.setEditable(True)
combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
combo.setCompleter(cast(Any, None)) # no inline completion: a name may start another
combo.currentIndexChanged.connect(self._commit)
edit = combo.lineEdit()
if edit is not None:
edit.editingFinished.connect(self._commit)
elif param.type in ("choice", "part"):
combo.currentIndexChanged.connect(self._changed)
else:
raise ValueError(f"parameter {param.name!r}: unknown type {param.type!r}")
return combo

def _number(self, param: Param) -> QWidget:
Expand Down Expand Up @@ -105,6 +122,8 @@ def get_value(self) -> object:
return None
return int(ctl.value()) if isinstance(ctl, QSpinBox) else float(ctl.value())
if isinstance(ctl, QComboBox):
if self._names:
return ctl.currentText()
return (
self._vals[ctl.currentIndex()]
if 0 <= ctl.currentIndex() < len(self._vals)
Expand All @@ -121,6 +140,12 @@ def set_value(self, value: object) -> None:
ctl.setText("" if value is None else str(value))
elif isinstance(ctl, QSpinBox | QDoubleSpinBox):
ctl.setValue(cast(Any, ctl.minimum() if value is None else value))
elif isinstance(ctl, QComboBox) and self._names:
text = "" if value is None else str(value)
ctl.setCurrentIndex(ctl.findText(text)) # -1 clears the edit box
if ctl.currentText() != text: # a name the options lack still shows
ctl.setEditText(text)
self._held = text
elif isinstance(ctl, QComboBox):
if value not in self._vals: # a value the options lack still shows
self._vals.append(value)
Expand Down Expand Up @@ -149,9 +174,13 @@ def set_options(self, options: Sequence[Option]) -> None:
built = self._built
if built and list(options) == self._opts:
return
keep = self.get_value() if built else None
keep = (self._held if self._names else self.get_value()) if built else None
self._built, self._opts = True, list(options)
if isinstance(ctl, QComboBox):
if isinstance(ctl, QComboBox) and self._names:
with QSignalBlocker(ctl):
ctl.clear()
ctl.addItems([str(v) for _, v in self._opts])
elif isinstance(ctl, QComboBox):
none = self.optional and all(v is not None for _, v in self._opts)
self._vals = ([None] if none else []) + [v for _, v in self._opts]
with QSignalBlocker(ctl):
Expand Down Expand Up @@ -179,6 +208,17 @@ def _summary(self) -> None:
text = ", ".join(names) if 0 < len(names) <= 2 else f"{len(names)} picked"
cast(QToolButton, self.ctl).setText(text if names else (NONE if self.optional else "-"))

def _commit(self, *_: object) -> None:
"""A name combo's pick or typed text: a name that is not one is put back, unheard."""
text = cast(QComboBox, self.ctl).currentText().strip()
if text == self._held:
return
if ID.fullmatch(text) is None:
self.set_value(self._held)
return
self.set_value(text)
self._changed()

def _changed(self, *_: object) -> None:
if isinstance(self.ctl, QToolButton):
self._summary()
Expand Down
Loading
Loading