Skip to content

Commit a5013d3

Browse files
authored
Merge pull request #67 from MHFU-ModKit/moveset/rage
Rage and the tail-cut gate as behaviour blocks
2 parents fb1e255 + 9e0eb47 commit a5013d3

33 files changed

Lines changed: 1184 additions & 77 deletions

‎apps/studio/README.md‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,9 @@ move's `after`. The palette is `mhfu_port.behaviour.KINDS`. Watch the game, in t
9999
the running game once a second while the tab is shown and marks each frame and trigger with how
100100
often the game fired its rules (`7×`; a frame lights up when its count rises): the game must run
101101
this port's rules as Play in game sends them, and the header says when it does not. On a
102-
Signal trigger, Fire raises that signal in the running game, once Watch the game has found it: the
103-
rules' counts then show whether their conditions held. It is no edit and has no undo step. The tab
102+
Signal trigger, Fire raises that signal in the running game, once Watch the game has found it; on
103+
Enraged or Calmed it asks the game to enrage or calm the monster. The rules' counts then show
104+
whether their conditions held. It is no edit and has no undo step. The tab
104105
keeps its own docks; Findings stays open across tabs.
105106

106107
Effect blocks (violet) change the monster's counters and flags when their path fires. A path may
@@ -111,6 +112,11 @@ back. An On signal block fires when Lua raises that name: `port:fire("name")`; `
111112
reads a counter or flag and `port:var("name", v)` sets it. Hunter side ticks any of front, left,
112113
right, behind.
113114

115+
Enrage and Calm down are effect blocks too (`port:enrage()` and `port:calm()` do the same from
116+
Lua). The Natural rage box in the Behaviour tab turns the monster's own anger off, so only those
117+
rage it. In the Parts dock, a severable part has Severable below HP %: its cut works only once the
118+
monster's HP is under that percent, 0 at any HP.
119+
114120
## Tests
115121

116122
`uv run pytest apps/studio` runs offscreen; the Qt tests skip without the `qt` group, and the

‎apps/studio/src/mhfu_studio/monster/behaviour.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,23 @@ def start(p: Param, taken: Collection[str] = ()) -> object:
302302
# edits
303303

304304

305+
def natural_rage(m: Manifest) -> bool:
306+
"""Whether the monster rages by its own anger (`[behaviour] natural_rage`)."""
307+
return m.behaviour.natural_rage
308+
309+
310+
def set_natural_rage(doc: Edits, on: bool) -> str:
311+
"""The monster's own rage on or off; off, only an Enrage block (or Lua) rages it."""
312+
if natural_rage(doc.manifest) == on:
313+
return f"natural rage already {'on' if on else 'off'}"
314+
315+
def change(m: Manifest) -> None:
316+
m.behaviour.natural_rage = on
317+
318+
doc.edit(change)
319+
return f"natural rage {'on' if on else 'off'}"
320+
321+
305322
def add_block(doc: Edits, kind: str, at: Point) -> str:
306323
"""A block of `kind` at `at`, with valid values."""
307324
if kind not in KINDS:

‎apps/studio/src/mhfu_studio/monster/panels/behaviour.py‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,12 @@ def __init__(self, ws: MonsterWorkspace, studio: Studio) -> None:
137137
on=studio.act("spread nodes", lambda: ws.edit("", self._spread)),
138138
icon="ph.arrows-out-simple",
139139
)
140+
self.rage = kit.check(
141+
"Natural rage",
142+
tip="On: the monster rages as the game has it, when its anger builds. Off: only an"
143+
" Enrage block or a script rages it.",
144+
on=self._natural_rage,
145+
)
140146
self.watch = kit.check(
141147
"Watch the game",
142148
tip="Marks every rule with how often the running game has fired it, read once a"
@@ -177,7 +183,13 @@ def __init__(self, ws: MonsterWorkspace, studio: Studio) -> None:
177183
side = QHBoxLayout(page)
178184
side.setContentsMargins(10, 8, 10, 8)
179185
side.setSpacing(10)
180-
side.addWidget(self.outline)
186+
left = QWidget()
187+
col = QVBoxLayout(left)
188+
col.setContentsMargins(0, 0, 0, 0)
189+
col.setSpacing(6)
190+
col.addWidget(self.outline, 1)
191+
col.addWidget(self.rage) # not in the header: with Watch and Fire it needs 1586 px
192+
side.addWidget(left)
181193
main = QWidget()
182194
side.addWidget(main, 1)
183195
lay = QVBoxLayout(main)
@@ -213,6 +225,13 @@ def _spread(self) -> str:
213225
raise ManifestError("no port manifest open")
214226
return graph.spread(doc)
215227

228+
def _natural_rage(self, on: bool) -> None:
229+
doc = self.ws.doc
230+
if doc is not None:
231+
self.studio.act(
232+
"natural rage", lambda: self.ws.edit("", lambda: graph.set_natural_rage(doc, on))
233+
)()
234+
216235
def _scoped(self, key: str) -> None:
217236
"""A row of the outline picked: that trigger's rules, framed to fit."""
218237
self.ws.scope = key
@@ -596,6 +615,7 @@ def sync(self) -> None:
596615
for o in graph.open_rules(m):
597616
self._opens.setdefault(model.trigger_key(m, o), []).append(o)
598617
self._scope = None
618+
kit.put(self.rage, graph.natural_rage(m))
599619
block = self.ws.take_block()
600620
if block in m.behaviour.blocks:
601621
self._reach(block)

‎apps/studio/src/mhfu_studio/monster/panels/parts.py‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,16 @@ def __init__(self, ws: MonsterWorkspace, studio: Studio) -> None:
132132
"Name", tip="Names the picked part in your manifest", on=self._name_part
133133
)
134134
self.name_row = kit.row(self.part_name, self.name_button)
135-
for w in (self.summary, self.orphans, self.parts, self.name_row):
135+
self.sever = kit.integer(
136+
tip="The picked part can be cut off only once the monster's HP is under this percent."
137+
" 0: at any HP.",
138+
lo=0,
139+
hi=100,
140+
on=self._sever_below,
141+
)
142+
self.sever_form = kit.Form()
143+
self.sever_form.row("Severable below HP %", self.sever)
144+
for w in (self.summary, self.orphans, self.parts, self.name_row, self.sever_form):
136145
parts.body.addWidget(w)
137146
lay.addWidget(parts)
138147

@@ -338,6 +347,17 @@ def run() -> None:
338347

339348
self._act("name part", run)
340349

350+
def _sever_below(self, pct: int) -> None:
351+
part = self.ws.selected_part
352+
353+
def run() -> None:
354+
sess = self._session()
355+
if part is not None:
356+
msg = f"part {part}: cut only below {pct}% HP" if pct else f"part {part}: any HP"
357+
self.ws.edit(msg, lambda: sess.set_sever_below(part, pct))
358+
359+
self._act("sever below", run)
360+
341361
def _pick_volume(self, index: object) -> None:
342362
if isinstance(index, int):
343363
pick = None if self.ws.selected_volume == index else index
@@ -582,6 +602,11 @@ def _parts(
582602
if naming and ws.selected_part is not None:
583603
self.name_button.setText(f"Name part {ws.selected_part}")
584604
kit.put(self.part_name, ws.part_name_buf)
605+
part = ws.selected_part
606+
cuts = sess is not None and part is not None and sess.severable(part)
607+
self.sever_form.setVisible(cuts)
608+
if sess is not None and part is not None and cuts:
609+
kit.put(self.sever, sess.sever_below(part) or 0)
585610

586611
def _volumes(self, sess: PartSession | None, port: bool) -> None:
587612
ws = self.ws

‎apps/studio/src/mhfu_studio/monster/parts.py‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,23 +154,45 @@ def over_capacity(self) -> int:
154154
"""Volumes that would not fit in place at runtime."""
155155
return 0 if self.capacity is None else max(0, len(self.m.hurtboxes) - self.capacity)
156156

157+
def severable(self, index: int) -> bool:
158+
found = self.part(index)
159+
return found is not None and found[1].severable
160+
161+
def sever_below(self, index: int) -> int | None:
162+
found = self.part(index)
163+
return None if found is None else found[1].sever_below
164+
165+
def set_sever_below(self, index: int, pct: int | None) -> None:
166+
"""The HP percent under which part `index` can be cut off; 0 or none: at any HP. The
167+
loader refuses one on a part that is not severable, or a second."""
168+
found = self.part(index)
169+
if found is None:
170+
raise ManifestError(f"part {index} has no name yet: name it first")
171+
name, want = found[0], pct or None
172+
if want != found[1].sever_below:
173+
self.doc.edit(lambda m: setattr(m.parts[name], "sever_below", want))
174+
157175
def name_part(
158176
self,
159177
index: int,
160178
name: str,
161179
label: str = "",
162-
severable: bool = False,
180+
severable: bool | None = None,
163181
hitzone_row: int | None = None,
164182
) -> None:
165-
"""Name accumulator `index`, replacing its old name."""
183+
"""Name accumulator `index`, replacing its old name; `severable` (and its HP percent)
184+
stays as it was unless given."""
166185
name = check_name(name)
167186
clash = self.m.parts.get(name)
168187
if clash is not None and clash.index != index:
169188
raise ManifestError(f"part {name!r} already names slot {clash.index}")
189+
old = self.part(index)
190+
flag = bool(old and old[1].severable) if severable is None else severable
191+
pct = old[1].sever_below if old and flag else None
170192

171193
def apply(m: Manifest) -> None:
172194
m.parts = {n: p for n, p in m.parts.items() if p.index != index}
173-
m.parts[name] = Part(index, hitzone_row, severable, label)
195+
m.parts[name] = Part(index, hitzone_row, flag, label, pct)
174196

175197
self.doc.edit(apply)
176198

‎apps/studio/src/mhfu_studio/monster/rule_game.py‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,17 @@ def _signal(s: Reader, cfg: int, m: Manifest, t: Trigger) -> str:
9797
return f"raised signal {t.name} in the game (board signal {index})"
9898

9999

100-
FIRE: dict[str, Writer] = {"on_signal": _signal}
100+
def _enrage(s: Reader, cfg: int, m: Manifest, t: Trigger) -> str:
101+
live.set_rage(s, cfg, True)
102+
return "asked the game to enrage the monster; nothing happens if it already is"
103+
104+
105+
def _calm(s: Reader, cfg: int, m: Manifest, t: Trigger) -> str:
106+
live.set_rage(s, cfg, False)
107+
return "asked the game to calm the monster; nothing happens if it already is"
108+
109+
110+
FIRE: dict[str, Writer] = {"on_signal": _signal, "on_enraged": _enrage, "on_calmed": _calm}
101111
"""What Fire can raise in the running game, by the trigger's kind, and how: the one table. A
102112
trigger with no entry has no Fire button."""
103113

‎apps/studio/tests/monster/test_behaviour.py‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -366,12 +366,17 @@ def test_the_palette_offers_every_kind(monkeypatch: pytest.MonkeyPatch) -> None:
366366
def test_the_zinogre_reads(ports: Path) -> None:
367367
m = manifest.load(ports / "zinogre.toml")
368368
r = B.read(m)
369-
assert len(m.behaviour.blocks) == 36 and len(r.paths) == 16
370-
assert sorted(n for ns in r.priority.values() for n in ns) == list(range(1, 17))
369+
assert len(m.behaviour.blocks) == 39 and len(r.paths) == 17
370+
assert sorted(n for ns in r.priority.values() for n in ns) == list(range(1, 18))
371371
assert r.loose == [] and r.refused == {} and r.capped is None
372-
assert [p.blocks[-1] for p in r.paths if p.play is None] == ["b15", "b17", "b23"]
373-
assert [p.play is None for p in r.paths] == [True] * 3 + [False] * 13, "bookkeeping runs first"
372+
assert [p.blocks[-1] for p in r.paths if p.play is None] == ["b15", "b17", "b23", "b39"]
373+
assert [p.play is None for p in r.paths] == [True] * 4 + [False] * 13, "bookkeeping runs first"
374374
assert model.names(m, "var") == ["flinches", "in_combat"]
375+
enrage = r.paths[3] # while charge_up plays: 120 frames, wait 250, enrage
376+
assert (enrage.blocks, enrage.during) == (("b37", "b38", "b39"), "charge_up")
377+
assert m.behaviour.blocks["b39"].kind == "enrage" and B.natural_rage(m)
378+
charge = r.paths[13] # the 2nd flinch, from idle: charge up
379+
assert (charge.blocks[-1], charge.play) == ("b27", "charge_up")
375380
assert min(b.at[0] for b in m.behaviour.blocks.values()) == model.LEFT
376381
assert {w.dst for w in B.wires(m) if w.dst_port == "play"} >= {"move:flinch_head"}
377382
assert len(B.spots(m)) == len(m.moves)

‎apps/studio/tests/monster/test_parts.py‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,52 @@ def test_name_part(doc):
3535
s.name_part(3, "left wing")
3636

3737

38+
def test_a_rename_keeps_the_part_severable(doc):
39+
d = doc()
40+
s = P.PartSession(d)
41+
s.name_part(3, "tail", severable=True)
42+
s.name_part(3, "stump")
43+
assert d.manifest.parts["stump"].severable and list(d.manifest.parts) == ["stump"]
44+
s.name_part(3, "tail", severable=False)
45+
assert not d.manifest.parts["tail"].severable
46+
47+
48+
def test_sever_below(doc):
49+
d = doc("\n[parts.tail]\nindex = 3\nseverable = true\n[parts.head]\nindex = 1\n")
50+
s = P.PartSession(d)
51+
assert s.severable(3) and not s.severable(1) and s.sever_below(3) is None
52+
before = d.manifest
53+
s.set_sever_below(3, 50)
54+
assert d.manifest.parts["tail"].sever_below == 50 and s.sever_below(3) == 50
55+
s.set_sever_below(3, 50)
56+
d.undo()
57+
assert d.manifest == before, "the same value is no second step"
58+
s.set_sever_below(3, 50)
59+
s.set_sever_below(3, 0)
60+
assert d.manifest.parts["tail"].sever_below is None
61+
s.set_sever_below(3, 25)
62+
s.name_part(3, "stump")
63+
assert d.manifest.parts["stump"].sever_below == 25, "a rename keeps it"
64+
s.name_part(3, "stump", severable=False)
65+
assert d.manifest.parts["stump"].sever_below is None
66+
67+
68+
def test_sever_below_refusals(doc):
69+
d = doc("\n[parts.tail]\nindex = 3\nseverable = true\n[parts.head]\nindex = 1\n")
70+
s = P.PartSession(d)
71+
with pytest.raises(ManifestError, match="sever_below goes with severable"):
72+
s.set_sever_below(1, 50)
73+
with pytest.raises(ManifestError, match="part 5 has no name yet"):
74+
s.set_sever_below(5, 50)
75+
with pytest.raises(ManifestError, match="HP percent, 1..100"):
76+
s.set_sever_below(3, 101)
77+
s.name_part(4, "wing", severable=True)
78+
s.set_sever_below(3, 50)
79+
with pytest.raises(ManifestError, match="the gate holds one"):
80+
s.set_sever_below(4, 30)
81+
assert d.manifest.parts["wing"].sever_below is None
82+
83+
3884
def test_grid(doc):
3985
d = doc(GRID)
4086
s = P.PartSession(d)

‎apps/studio/tests/monster/test_rule_game.py‎

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@
1010
from typing import Any
1111

1212
import pytest
13-
from mhfu.live.brain import UNLIMITED, Brain, Slot
13+
from mhfu import addresses as a
14+
from mhfu.live.brain import RAGE_OFF, RAGE_ON, UNLIMITED, Brain, Slot
1415
from mhfu_port import behaviour as model
15-
from mhfu_port.behaviour import Block
16+
from mhfu_port.behaviour import Block, Trigger
1617
from mhfu_port.manifest import Manifest, ManifestError
1718
from mhfu_studio.monster import rule_game as rg
1819
from mhfu_studio.monster.document import PortDocument
@@ -351,3 +352,28 @@ def test_the_game_may_be_written_to_only_when_its_brain_is_found_and_runs_these_
351352
m.behaviour.blocks["b4"].params["frames"] = 31
352353
game.poll(m, dt=1.0)
353354
assert game.watch.blocker() == rg.OTHER_BUILD
355+
356+
357+
class Board:
358+
"""A session whose memory records the bytes written."""
359+
360+
def __init__(self) -> None:
361+
self.mem = self
362+
self.writes: list[tuple[int, int]] = []
363+
364+
def write_u8(self, address: int, value: int) -> None:
365+
self.writes.append((address, value))
366+
367+
368+
@pytest.mark.parametrize(
369+
("kind", "byte"), [("on_enraged", RAGE_ON), ("on_calmed", RAGE_OFF)], ids=["enrage", "calm"]
370+
)
371+
def test_enraged_and_calmed_write_the_boards_rage_byte(m: Manifest, kind: str, byte: int) -> None:
372+
s = Board()
373+
said = rg.FIRE[kind](s, CFG, m, Trigger(kind, "event", kind)) # type: ignore[arg-type]
374+
assert s.writes == [(CFG + int(a.EM_BLOCK.BOARD) + int(a.EM_BOARD.RAGE), byte)]
375+
assert said.startswith("asked the game to ")
376+
377+
378+
def test_fire_has_an_entry_for_a_signal_enraged_and_calmed_only() -> None:
379+
assert set(rg.FIRE) == {"on_signal", "on_enraged", "on_calmed"}

‎apps/studio/tests/monster/ui/test_behaviour.py‎

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -338,13 +338,14 @@ def test_the_zinogre_opens(
338338
ws.all_view = B.CANVAS # a loaded document starts in order
339339
p = make(qtbot, ws)
340340
m = zin.manifest
341-
assert len(m.behaviour.blocks) == 36 and len(p.view._shown) == 36 + len(m.moves)
341+
assert len(m.behaviour.blocks) == 39 and len(p.view._shown) == 39 + len(m.moves)
342342
plays = sorted(badge(p, i) for i in m.behaviour.blocks if badge(p, i))
343-
assert plays == sorted(f"#{k}" for k in range(1, 17))
343+
assert plays == sorted(f"#{k}" for k in range(1, 18))
344344
for k, path in enumerate(B.read(m).paths, 1): # a path that plays nothing has its place too
345345
assert badge(p, path.blocks[-1]) == rank(k)
346-
assert [badge(p, i) for i in ("b15", "b17", "b23")] == [rank(1), rank(2), rank(3)]
347-
assert badge(p, "b4") == rank(4) and badge(p, "b36") == rank(16)
346+
assert [badge(p, i) for i in ("b15", "b17", "b23", "b39")] == [rank(k) for k in (1, 2, 3, 4)]
347+
assert badge(p, "b4") == rank(5) and badge(p, "b27") == rank(14)
348+
assert badge(p, "b36") == rank(17) and p.rage.isChecked()
348349
assert bar_on(p, "b23").controls["counter"].get_value() == "flinches"
349350
assert options(p, "b15", "flag") == ["flinches", "in_combat"]
350351
assert all(p.view._shown[i].view.opacity() == 1.0 for i in m.behaviour.blocks)
@@ -995,6 +996,29 @@ def test_every_real_kind_draws_and_edits(
995996
assert doc(ws).manifest == drawn
996997

997998

999+
def test_natural_rage_starts_on(panel: BehaviourPanel) -> None:
1000+
assert panel.rage.isChecked() and B.natural_rage(panel.ws.manifest)
1001+
1002+
1003+
def test_natural_rage_stays_out_of_the_header(panel: BehaviourPanel) -> None:
1004+
"""With Watch and Fire in it the header makes the window 1586 px wide: wider than a laptop."""
1005+
assert panel.rage.parent() is not panel.fit.parent()
1006+
1007+
1008+
def test_natural_rage_is_one_undo_step(panel: BehaviourPanel, ws: MonsterWorkspace) -> None:
1009+
before = doc(ws).manifest
1010+
panel.rage.click()
1011+
assert doc(ws).manifest.behaviour.natural_rage is False and not panel.rage.isChecked()
1012+
assert "off" in ws.message
1013+
doc(ws).undo()
1014+
ws.refresh()
1015+
panel.sync()
1016+
assert doc(ws).manifest == before and panel.rage.isChecked()
1017+
panel.rage.click()
1018+
panel.rage.click()
1019+
assert doc(ws).manifest.behaviour.natural_rage is True
1020+
1021+
9981022
# ---- focus ----------------------------------------------------------------------------------- #
9991023

10001024

0 commit comments

Comments
 (0)