Skip to content

Commit 0b25e2c

Browse files
sp00ktoberclaude
andcommitted
feat(studio): Fire raises a signal trigger in the running game, at the brain Watch the game found
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent d38f024 commit 0b25e2c

8 files changed

Lines changed: 422 additions & 54 deletions

File tree

‎apps/studio/README.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,10 @@ block's `in` or to a move's `play`; a move's `while playing` starts a rule, and
9898
move's `after`. The palette is `mhfu_port.behaviour.KINDS`. Watch the game, in the header, reads
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
101-
this port's rules as Play in game sends them, and the header says when it does not. The tab keeps
102-
its own docks; Findings stays open across tabs.
101+
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
104+
keeps its own docks; Findings stays open across tabs.
103105

104106
Effect blocks (violet) change the monster's counters and flags when their path fires. A path may
105107
end at an effect with nothing to play: it applies its effects, keeps its `#n` on that block, and

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

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
ORDER_HINT = (
6464
"Right-click a block to insert one · Delete removes · drag a frame to reorder · wire in Canvas"
6565
)
66+
FIRE_TIP = "Raises {} in the running game now; the rules' counts show whether their conditions held"
6667
SCOPED = "Wire by dragging ports in All, Canvas"
6768
UNPLACED = "A rule with no move yet is checked by nothing, so it has no place to move"
6869
CROSSED = "; a rule moves only among its own trigger's rules, the others keep their places"
@@ -112,6 +113,12 @@ def __init__(self, ws: MonsterWorkspace, studio: Studio) -> None:
112113
" frame up to raise it",
113114
on=self._add_rule,
114115
)
116+
self.fire = kit.button(
117+
"Fire",
118+
tip=FIRE_TIP,
119+
on=studio.act("fire in the game", self._fire),
120+
icon="ph.lightning",
121+
)
115122
self.mode = kit.Segmented(
116123
[(ORDER, "In order"), (CANVAS, "Canvas")],
117124
tip="All rules: each as a framed row in the order they are checked, or the free"
@@ -182,6 +189,7 @@ def __init__(self, ws: MonsterWorkspace, studio: Studio) -> None:
182189
self.hint,
183190
self.mode,
184191
self.add_rule,
192+
self.fire,
185193
self.fit,
186194
self.spread,
187195
self.watch,
@@ -516,8 +524,23 @@ def _marks(self) -> None:
516524
if self.ws.watch.lit():
517525
self.glow.start(round(rule_game.FLASH * 1000) + 50)
518526

527+
def _fire(self) -> None:
528+
"""Raises the scoped trigger in the running game; the game is not the document, so there
529+
is no undo step."""
530+
t = self._current()
531+
if t is not None:
532+
self.ws.fire(t)
533+
519534
def _status(self) -> None:
520-
"""The header's words on the watch, and the outline's totals."""
535+
"""The header's words on the watch, Fire's state, and the outline's totals."""
536+
t = self._current()
537+
self.fire.setVisible(t is not None and t.kind in rule_game.FIRE)
538+
if t is not None and t.kind in rule_game.FIRE:
539+
why = self.ws.fire_blocker(t)
540+
self.fire.setEnabled(why is None)
541+
self.fire.setToolTip(
542+
FIRE_TIP.format(triggers.title(t)) if why is None else f"Fire: {why}"
543+
)
521544
w = self.ws.watch
522545
self.live.setVisible(w.on)
523546
self.live.setText(w.status)

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

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@
1717
from typing import TYPE_CHECKING, NamedTuple
1818

1919
from mhfu.live import brain as live
20-
from mhfu.live.brain import Brain
20+
from mhfu.live.brain import Brain, Reader
2121
from mhfu_port import behaviour
22+
from mhfu_port.behaviour import Trigger
2223
from mhfu_port.manifest import Manifest, ManifestError
2324
from ppsspp_debug import DebuggerError
2425

@@ -86,6 +87,21 @@ def match(brain: Brain, m: Manifest) -> dict[int, int] | str:
8687
return out
8788

8889

90+
Writer = Callable[[Reader, int, Manifest, Trigger], str]
91+
"""Raises a trigger in the game at the brain `cfg`; says what it did."""
92+
93+
94+
def _signal(s: Reader, cfg: int, m: Manifest, t: Trigger) -> str:
95+
index = behaviour.signals(m)[t.name]
96+
live.fire_signal(s, cfg, index)
97+
return f"raised signal {t.name} in the game (board signal {index})"
98+
99+
100+
FIRE: dict[str, Writer] = {"on_signal": _signal}
101+
"""What Fire can raise in the running game, by the trigger's kind, and how: the one table. A
102+
trigger with no entry has no Fire button."""
103+
104+
89105
class Watch:
90106
"""The state of Watch the game: `fires` (rank to count; None when not watching or there is
91107
nothing to show) and `status` say what the marks show, `lit` which frames flash."""
@@ -101,6 +117,24 @@ def __init__(self, clock: Callable[[], float] = time.monotonic) -> None:
101117
self._asked = self._lost = float("-inf")
102118
self._since: int | None = None
103119

120+
@property
121+
def cfg(self) -> int | None:
122+
"""Where the brain was found in this attach; None until it is."""
123+
return self._cfg
124+
125+
def blocker(self) -> str | None:
126+
"""Why the game cannot be written to now: it is not watched, not running, its brain not
127+
found or not these rules. None when the brain is found and runs this port's rules."""
128+
if not self.on:
129+
return "turn on Watch the game first: it finds the framework in the game"
130+
if self.fires is None or self._cfg is None:
131+
return (
132+
self.status
133+
if self.status != READING
134+
else "Watch the game has not read the game yet"
135+
)
136+
return None
137+
104138
def set(self, on: bool) -> None:
105139
self.on = on
106140
self.reset()

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
)
3232
from mhfu.files import Extracted
3333
from mhfu_port import continuity, layout, sequence, slots
34+
from mhfu_port.behaviour import Trigger
3435
from mhfu_port.data import Data
3536
from mhfu_port.manifest import MOVE_ATTACKS, AttackWindow, Manifest, ManifestError, Move
3637
from mhfu_port.manifest import Clip as ManifestClip
@@ -2035,6 +2036,27 @@ def poll_game(self) -> bool:
20352036
"""One step of Watch the game, at most one read a second; True when the marks changed."""
20362037
return self.watch.poll(self.manifest, self.game_running(), self._ask_brain)
20372038

2039+
def fire_blocker(self, t: Trigger) -> str | None:
2040+
"""Why Fire cannot raise `t` in the game now; None when it can."""
2041+
if self.manifest is None:
2042+
return "no port manifest open"
2043+
if t.kind not in rule_game.FIRE:
2044+
return "nothing in the studio raises this trigger"
2045+
return self.watch.blocker()
2046+
2047+
def fire(self, t: Trigger) -> None:
2048+
"""Raises `t` in the running game (`rule_game.FIRE`) at the brain Watch the game found. The
2049+
game is not the document: no undo step, and the status says what was done."""
2050+
m, cfg, why = self.manifest, self.watch.cfg, self.fire_blocker(t)
2051+
if why is not None or m is None or cfg is None:
2052+
self.message = f"not fired: {why}"
2053+
return
2054+
try:
2055+
with self.game_session() as s:
2056+
self.message = rule_game.FIRE[t.kind](s, cfg, m, t)
2057+
except (LookupError, OSError, ValueError, DebuggerError) as e:
2058+
self.message = f"not fired: {e}"
2059+
20382060
def _ask_brain(self, cfg: int | None) -> Future[rule_game.Read]:
20392061
self._pool = self._pool or ThreadPoolExecutor(1)
20402062
return self._pool.submit(self.read_brain, self.game_session, cfg)

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,3 +331,23 @@ def test_poll_says_whether_the_marks_changed(game: Game, m: Manifest) -> None:
331331
game.show(m, [1, 2, 3])
332332
game.poll(m, dt=1.0)
333333
assert game.poll(m, dt=0.2) is False
334+
335+
336+
def test_the_game_may_be_written_to_only_when_its_brain_is_found_and_runs_these_rules(
337+
game: Game, m: Manifest
338+
) -> None:
339+
off = rg.Watch()
340+
assert off.blocker() is not None and "Watch the game" in off.blocker() # type: ignore[operator]
341+
assert game.watch.blocker() == "Watch the game has not read the game yet"
342+
game.poll(m, running=False)
343+
assert game.watch.blocker() == rg.NO_GAME
344+
game.answer = lambda: rg.Read(None, None)
345+
game.poll(m, running=True, dt=1.0)
346+
game.poll(m, dt=1.0)
347+
assert game.watch.blocker() == rg.NOT_FOUND
348+
game.watch.reset()
349+
game.feed(m, [1, 2, 3])
350+
assert game.watch.blocker() is None and game.watch.cfg == CFG
351+
m.behaviour.blocks["b4"].params["frames"] = 31
352+
game.poll(m, dt=1.0)
353+
assert game.watch.blocker() == rg.OTHER_BUILD

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

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,57 @@
11
# SPDX-License-Identifier: GPL-3.0-or-later
22
# SPDX-FileCopyrightText: 2026 sp00ktober
3-
"""Every Qt test: no question blocks, and the GL context comes back (root conftest)."""
3+
"""Every Qt test: no question blocks, and the GL context comes back (root conftest). Also the
4+
game as the Behaviour tab's tests fake it."""
45

6+
from __future__ import annotations
7+
8+
from collections.abc import Callable
9+
from contextlib import AbstractContextManager
510
from typing import Any
611

712
import pytest
13+
from mhfu.live.brain import UNLIMITED, Brain, Slot
14+
from mhfu_port import behaviour as model
15+
from mhfu_port.manifest import Manifest
16+
from mhfu_studio.monster import rule_game as rg
17+
18+
CFG = 0x1000
19+
20+
21+
class FakeGame:
22+
"""The game: it runs the rules of `m` as sent, fired `fired` times each; `calls` is where
23+
each read was told the brain was."""
24+
25+
def __init__(self, m: Manifest) -> None:
26+
self.rules = model.compile(m)
27+
self.fired = [0] * len(self.rules)
28+
self.ticks = 100
29+
self.calls: list[int | None] = []
30+
#: a read to answer with instead of the brain, and whether the game runs other rules
31+
self.answer: Callable[[], rg.Read] | None = None
32+
self.other_build = False
33+
34+
def __call__(
35+
self, attach: Callable[[], AbstractContextManager[Any]], cfg: int | None
36+
) -> rg.Read:
37+
self.calls.append(cfg)
38+
if self.answer is not None:
39+
return self.answer()
40+
slots = tuple(
41+
Slot(
42+
k,
43+
rg.kind(r.on),
44+
-1 if r.signal is None else r.signal,
45+
bool(r.play),
46+
r.min_frames,
47+
r.cooldown + (7 if self.other_build else 0),
48+
n,
49+
UNLIMITED,
50+
0,
51+
)
52+
for k, (r, n) in enumerate(zip(self.rules, self.fired, strict=True))
53+
)
54+
return rg.Read(CFG, Brain(CFG, self.ticks, slots))
855

956

1057
@pytest.fixture(autouse=True)

0 commit comments

Comments
 (0)