diff --git a/apps/studio/README.md b/apps/studio/README.md index 22b277d..c569909 100644 --- a/apps/studio/README.md +++ b/apps/studio/README.md @@ -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 diff --git a/apps/studio/src/mhfu_studio/monster/behaviour.py b/apps/studio/src/mhfu_studio/monster/behaviour.py index 7bdd0f0..0311011 100644 --- a/apps/studio/src/mhfu_studio/monster/behaviour.py +++ b/apps/studio/src/mhfu_studio/monster/behaviour.py @@ -11,6 +11,7 @@ from __future__ import annotations +import itertools import typing from collections import Counter from collections.abc import Callable, Collection, Mapping, Sequence @@ -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): @@ -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.""" @@ -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 @@ -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 @@ -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) @@ -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 diff --git a/apps/studio/src/mhfu_studio/monster/panels/behaviour.py b/apps/studio/src/mhfu_studio/monster/panels/behaviour.py index 57e9a7e..ba7b802 100644 --- a/apps/studio/src/mhfu_studio/monster/panels/behaviour.py +++ b/apps/studio/src/mhfu_studio/monster/panels/behaviour.py @@ -33,7 +33,7 @@ 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: @@ -41,16 +41,20 @@ 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" @@ -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" @@ -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" @@ -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, @@ -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) diff --git a/apps/studio/src/mhfu_studio/monster/panels/moves.py b/apps/studio/src/mhfu_studio/monster/panels/moves.py index 328d357..27a8116 100644 --- a/apps/studio/src/mhfu_studio/monster/panels/moves.py +++ b/apps/studio/src/mhfu_studio/monster/panels/moves.py @@ -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 diff --git a/apps/studio/src/mhfu_studio/monster/panels/properties.py b/apps/studio/src/mhfu_studio/monster/panels/properties.py index 7c7c0b5..a2a9894 100644 --- a/apps/studio/src/mhfu_studio/monster/panels/properties.py +++ b/apps/studio/src/mhfu_studio/monster/panels/properties.py @@ -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, @@ -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 @@ -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 @@ -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) @@ -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: @@ -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) @@ -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) @@ -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): @@ -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() diff --git a/apps/studio/src/mhfu_studio/monster/rule_game.py b/apps/studio/src/mhfu_studio/monster/rule_game.py index 9b32386..32bcef9 100644 --- a/apps/studio/src/mhfu_studio/monster/rule_game.py +++ b/apps/studio/src/mhfu_studio/monster/rule_game.py @@ -17,8 +17,9 @@ from typing import TYPE_CHECKING, NamedTuple from mhfu.live import brain as live -from mhfu.live.brain import Brain +from mhfu.live.brain import Brain, Reader from mhfu_port import behaviour +from mhfu_port.behaviour import Trigger from mhfu_port.manifest import Manifest, ManifestError from ppsspp_debug import DebuggerError @@ -34,7 +35,7 @@ READING = "reading the game…" DOT = "·" NO_SIGNAL = -1 -"""`Slot.signal` of a rule no signal fires: a manifest of this schema has none.""" +"""`Slot.signal` of a rule no signal fires.""" class Read(NamedTuple): @@ -75,7 +76,7 @@ def match(brain: Brain, m: Manifest) -> dict[int, int] | str: s = slots.get(k) same = s is not None and ( s.on == kind(rule.on) - and s.signal == NO_SIGNAL + and s.signal == (NO_SIGNAL if rule.signal is None else rule.signal) and s.plays == bool(rule.play) and s.min_frames == rule.min_frames and s.cooldown == rule.cooldown @@ -86,6 +87,21 @@ def match(brain: Brain, m: Manifest) -> dict[int, int] | str: return out +Writer = Callable[[Reader, int, Manifest, Trigger], str] +"""Raises a trigger in the game at the brain `cfg`; says what it did.""" + + +def _signal(s: Reader, cfg: int, m: Manifest, t: Trigger) -> str: + index = behaviour.signals(m)[t.name] + live.fire_signal(s, cfg, index) + return f"raised signal {t.name} in the game (board signal {index})" + + +FIRE: dict[str, Writer] = {"on_signal": _signal} +"""What Fire can raise in the running game, by the trigger's kind, and how: the one table. A +trigger with no entry has no Fire button.""" + + class Watch: """The state of Watch the game: `fires` (rank to count; None when not watching or there is 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: self._asked = self._lost = float("-inf") self._since: int | None = None + @property + def cfg(self) -> int | None: + """Where the brain was found in this attach; None until it is.""" + return self._cfg + + def blocker(self) -> str | None: + """Why the game cannot be written to now: it is not watched, not running, its brain not + found or not these rules. None when the brain is found and runs this port's rules.""" + if not self.on: + return "turn on Watch the game first: it finds the framework in the game" + if self.fires is None or self._cfg is None: + return ( + self.status + if self.status != READING + else "Watch the game has not read the game yet" + ) + return None + def set(self, on: bool) -> None: self.on = on self.reset() diff --git a/apps/studio/src/mhfu_studio/monster/validate.py b/apps/studio/src/mhfu_studio/monster/validate.py index cde684a..98382df 100644 --- a/apps/studio/src/mhfu_studio/monster/validate.py +++ b/apps/studio/src/mhfu_studio/monster/validate.py @@ -53,7 +53,7 @@ CLIP_NAME = "clip name" #: the Moves panel's MOVE_WINDOWS, MOVE_CLIP = "move windows", "move clip" -#: the Behaviour dock +#: the Behaviour tab BEHAVIOUR = "behaviour" FOCUS = { "CLIP_SLOT_MISSING": CLIP_NAME, @@ -512,7 +512,7 @@ def _layout( def _refused(m: Manifest, lay: Layout, known: set[int] | None) -> list[Finding]: """`moves.check`'s refusal, at the path it names: a move's attack lands on its windows, a - clip in no anim on the clip, the flinch carriers on the Behaviour dock. A refused path is + clip in no anim on the clip, the flinch carriers on the Behaviour tab. A refused path is `_behaviour`'s.""" try: moves.check(m, lay, known) diff --git a/apps/studio/src/mhfu_studio/monster/workspace.py b/apps/studio/src/mhfu_studio/monster/workspace.py index 9392c39..7aadf95 100644 --- a/apps/studio/src/mhfu_studio/monster/workspace.py +++ b/apps/studio/src/mhfu_studio/monster/workspace.py @@ -31,6 +31,7 @@ ) from mhfu.files import Extracted from mhfu_port import continuity, layout, sequence, slots +from mhfu_port.behaviour import Trigger from mhfu_port.data import Data from mhfu_port.manifest import MOVE_ATTACKS, AttackWindow, Manifest, ManifestError, Move from mhfu_port.manifest import Clip as ManifestClip @@ -2035,6 +2036,27 @@ def poll_game(self) -> bool: """One step of Watch the game, at most one read a second; True when the marks changed.""" return self.watch.poll(self.manifest, self.game_running(), self._ask_brain) + def fire_blocker(self, t: Trigger) -> str | None: + """Why Fire cannot raise `t` in the game now; None when it can.""" + if self.manifest is None: + return "no port manifest open" + if t.kind not in rule_game.FIRE: + return "nothing in the studio raises this trigger" + return self.watch.blocker() + + def fire(self, t: Trigger) -> None: + """Raises `t` in the running game (`rule_game.FIRE`) at the brain Watch the game found. The + game is not the document: no undo step, and the status says what was done.""" + m, cfg, why = self.manifest, self.watch.cfg, self.fire_blocker(t) + if why is not None or m is None or cfg is None: + self.message = f"not fired: {why}" + return + try: + with self.game_session() as s: + self.message = rule_game.FIRE[t.kind](s, cfg, m, t) + except (LookupError, OSError, ValueError, DebuggerError) as e: + self.message = f"not fired: {e}" + def _ask_brain(self, cfg: int | None) -> Future[rule_game.Read]: self._pool = self._pool or ThreadPoolExecutor(1) return self._pool.submit(self.read_brain, self.game_session, cfg) diff --git a/apps/studio/src/mhfu_studio/shell/overlay.py b/apps/studio/src/mhfu_studio/shell/overlay.py index f828dca..0de65b4 100644 --- a/apps/studio/src/mhfu_studio/shell/overlay.py +++ b/apps/studio/src/mhfu_studio/shell/overlay.py @@ -30,6 +30,8 @@ class Ink(enum.Enum): SHADOW = "shadow" WARNING = "warning" ERROR = "error" + #: a behaviour effect block: a violet apart from the axes + EFFECT = "effect" RGBA = tuple[float, float, float, float] diff --git a/apps/studio/src/mhfu_studio/ui/theme.py b/apps/studio/src/mhfu_studio/ui/theme.py index 4d4b562..2d5ac39 100644 --- a/apps/studio/src/mhfu_studio/ui/theme.py +++ b/apps/studio/src/mhfu_studio/ui/theme.py @@ -54,6 +54,7 @@ def _inks(dark: bool, accent: str) -> dict[Ink, RGBA]: Ink.SHADOW: _rgba("#000000", 0.6), Ink.WARNING: _rgba("#fbbf24" if dark else "#d97706"), Ink.ERROR: _rgba("#f87171" if dark else "#dc2626"), + Ink.EFFECT: _rgba("#d170e8" if dark else "#8f24ab"), } diff --git a/apps/studio/tests/monster/conftest.py b/apps/studio/tests/monster/conftest.py index c8e60ef..82e81b3 100644 --- a/apps/studio/tests/monster/conftest.py +++ b/apps/studio/tests/monster/conftest.py @@ -2,6 +2,7 @@ # SPDX-FileCopyrightText: 2026 sp00ktober """Synthetic manifests, intel documents and PACs, and the real games and builds.""" +import typing from collections.abc import Callable, Iterator from pathlib import Path from typing import Any @@ -9,7 +10,7 @@ import pytest from mhfu.em import intel from mhfu.em.intel import HostSummary, SpeciesIntel -from mhfu_port import build, manifest, mesh +from mhfu_port import behaviour, build, manifest, mesh from mhfu_port.data import Data from mhfu_port.manifest import Manifest from mhfu_port.mesh import Part, Skinned @@ -385,3 +386,67 @@ def zinogre_toml(ports: Path, tmp_path: Path) -> Path: used = {mv.clip for mv in m.moves.values()} m.clips = {"welcome_howl": howl, **{n: c for n, c in m.clips.items() if n in used}} return manifest.save(m, tmp_path / "zinogre.toml") + + +SIDES = ("front", "left", "right", "behind") +#: small kinds of the effect role and of the var, signal and sides param types; named apart +#: from the package's own, which are none of their business +NEW_KINDS = ( + behaviour.Kind( + "t_count", + "effect", + "Test count", + ( + behaviour.Param("counter", "var", "Counter", tip="which counter"), + behaviour.Param("by", "int", "By", 1, tip="how much"), + ), + "Adds to a counter", + ), + behaviour.Kind( + "t_flag", + "effect", + "Test flag", + (behaviour.Param("flag", "var", "Flag", tip="which flag"),), + "Sets a flag", + ), + behaviour.Kind( + "t_signal", + "event", + "Test signal", + (behaviour.Param("name", "signal", "Signal", tip="which signal"),), + "A signal from Lua", + ), + behaviour.Kind( + "t_side", + "condition", + "Test side", + (behaviour.Param("sides", "sides", "Sides", choices=SIDES, tip="which sides"),), + "The hunter stands on one of these sides", + ), +) + + +@pytest.fixture +def new_kinds(monkeypatch: pytest.MonkeyPatch) -> tuple[behaviour.Kind, ...]: + """`NEW_KINDS` in `behaviour.KINDS`, the effect role in `Role`, and the loader's checks of + the new param types where the package has none yet. Ask for it before a panel is built.""" + roles = typing.get_args(behaviour.Role) + if "effect" not in roles: + monkeypatch.setattr(behaviour, "Role", typing.Literal[(*roles, "effect")]) + if not {"var", "signal", "sides"} <= set(typing.get_args(behaviour.ParamType)): + real = behaviour._check_value + + def check(p: behaviour.Param, v: object, where: str) -> None: + if p.type in ("var", "signal"): + ok = isinstance(v, str) and behaviour.ID.fullmatch(v) is not None + behaviour.need(ok, where, f"{v!r} is not a name") + elif p.type == "sides": + ok = isinstance(v, list) and bool(v) and all(k in p.choices for k in v) + behaviour.need(ok, where, f"{v!r} is not a list of sides") + else: + real(p, v, where) + + monkeypatch.setattr(behaviour, "_check_value", check) + for k in NEW_KINDS: + monkeypatch.setitem(behaviour.KINDS, k.name, k) + return NEW_KINDS diff --git a/apps/studio/tests/monster/test_behaviour.py b/apps/studio/tests/monster/test_behaviour.py index acaf2dd..e6f9511 100644 --- a/apps/studio/tests/monster/test_behaviour.py +++ b/apps/studio/tests/monster/test_behaviour.py @@ -74,6 +74,19 @@ def test_every_kind_starts_valid(d: PortDocument) -> None: assert params["distance"] == {"lo": 0.0}, "the optional far end starts as none" assert params["played_for"] == {"frames": 1} and params["hunter_moving"] == {"way": "away"} assert params["on_flinch"] == {} and params["force"] == {} and params["mode"] == {"mode": 0} + assert params["any_time"] == params["idle"] == {} and params["rage"] == {"state": "enraged"} + assert params["monster_hp"] == {"lo": 0} and params["chance"] == {"percent": 1} + assert params["part_broken"] == {"part": 0, "state": "broken"} + assert params["hunter_side"] == {"sides": ["front"]} and params["on_signal"] == { + "name": "signal1" + } + counters = [params[k] for k in ("counter_is", "counter_add", "counter_set")] + assert sorted(c["counter"] for c in counters) == ["counter1", "counter2", "counter3"] + flags = [params[k] for k in ("flag_is", "flag_set")] + assert sorted(f["flag"] for f in flags) == ["flag1", "flag2"], "a new name is never taken" + assert params["counter_add"]["by"] == 1 and params["counter_set"]["to"] == 0 + assert params["flag_is"]["state"] == params["flag_set"]["state"] == "set" + assert params["counter_is"] | {"counter": 0} == {"counter": 0, "test": "at least", "value": 1} for _ in KINDS: d.undo() assert d.manifest == before @@ -337,7 +350,9 @@ def many(m: Manifest) -> None: def test_the_palette_offers_every_kind(monkeypatch: pytest.MonkeyPatch) -> None: offered = [k for _, kinds in B.palette() for k, _, _ in kinds] assert sorted(offered) == sorted(KINDS) - assert [t for t, _ in B.palette()] == ["Events", "State", "Conditions", "Modifiers"] + titles = [t for t, _ in B.palette()] + assert titles[:4] == ["Events", "State", "Conditions", "Modifiers"] + assert titles[4:] in ([], ["Effects"]) tips = {k: (t, tip) for _, kinds in B.palette() for k, t, tip in kinds} assert tips["force"] == (KINDS["force"].title, KINDS["force"].tip) monkeypatch.setitem( @@ -351,9 +366,12 @@ def test_the_palette_offers_every_kind(monkeypatch: pytest.MonkeyPatch) -> None: def test_the_zinogre_reads(ports: Path) -> None: m = manifest.load(ports / "zinogre.toml") r = B.read(m) - assert len(m.behaviour.blocks) == 15 and len(r.paths) == 7 - assert sorted(n for ns in r.priority.values() for n in ns) == list(range(1, 8)) + assert len(m.behaviour.blocks) == 36 and len(r.paths) == 16 + assert sorted(n for ns in r.priority.values() for n in ns) == list(range(1, 17)) assert r.loose == [] and r.refused == {} and r.capped is None + assert [p.blocks[-1] for p in r.paths if p.play is None] == ["b15", "b17", "b23"] + assert [p.play is None for p in r.paths] == [True] * 3 + [False] * 13, "bookkeeping runs first" + assert model.names(m, "var") == ["flinches", "in_combat"] assert min(b.at[0] for b in m.behaviour.blocks.values()) == model.LEFT assert {w.dst for w in B.wires(m) if w.dst_port == "play"} >= {"move:flinch_head"} assert len(B.spots(m)) == len(m.moves) @@ -380,7 +398,7 @@ def test_renaming_a_move_keeps_its_paths(ports: Path) -> None: def test_a_move_in_use_is_not_deleted(ports: Path) -> None: z = PortDocument(manifest.load(ports / "zinogre.toml")) - with pytest.raises(ManifestError, match="block b12 plays it"): + with pytest.raises(ManifestError, match="block b18 plays it"): clips.drop_move(z, "flinch_head") with pytest.raises(ManifestError, match="while lunge plays"): clips.drop_move(z, "lunge") @@ -388,6 +406,85 @@ def test_a_move_in_use_is_not_deleted(ports: Path) -> None: assert "idle" not in z.manifest.moves +# ---- effects, names and sides ---------------------------------------------------------------- # + + +class Bare: + """An `Edits` over a manifest that is never validated: the new param types' checks are the + package's.""" + + def __init__(self, m: Manifest) -> None: + self.manifest = m + + def edit(self, change: Callable[[Manifest], object]) -> None: + change(self.manifest) + + +def test_the_palette_has_an_effects_group(new_kinds: tuple[Kind, ...]) -> None: + groups = dict(B.palette()) + assert list(groups) == ["Events", "State", "Conditions", "Modifiers", "Effects"] + effects = {k.name for k in KINDS.values() if k.role == "effect"} + assert effects >= {"counter_add", "flag_set", new_kinds[0].name, new_kinds[1].name} + assert {k for k, _, _ in groups["Effects"]} == effects + assert new_kinds[2].name in [k for k, _, _ in groups["Events"]] + assert new_kinds[3].name in [k for k, _, _ in groups["Conditions"]] + + +def test_new_blocks_start_with_fresh_names_and_a_side( + new_kinds: tuple[Kind, ...], make: Callable[[str], Manifest], monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setitem( + KINDS, + "t_pair", + Kind( + "t_pair", + "event", + "Pair", + (Param("a", "signal", "A"), Param("b", "signal", "B")), + "two signals", + ), + ) + d = Bare(make("")) + for kind in ("t_count", "t_count", "t_flag", "t_signal", "t_side", "t_pair", "t_signal"): + B.add_block(d, kind, (0, 0)) + got = [blk.params for blk in d.manifest.behaviour.blocks.values()] + assert got == [ + {"counter": "counter1", "by": 1}, + {"counter": "counter2", "by": 1}, + {"flag": "flag1"}, + {"name": "signal1"}, + {"sides": ["front"]}, + {"a": "signal2", "b": "signal3"}, + {"name": "signal4"}, + ] + assert B.fresh("x", {"x1", "x3"}) == "x2" + + +def test_sides_are_stored_in_the_order_of_the_choices( + new_kinds: tuple[Kind, ...], make: Callable[[str], Manifest] +) -> None: + d = Bare(make("")) + B.add_block(d, "t_side", (0, 0)) + B.set_param(d, "b1", "sides", ("behind", "front", "front")) + assert d.manifest.behaviour.blocks["b1"].params == {"sides": ["front", "behind"]} + B.set_param(d, "b1", "sides", ()) + assert d.manifest.behaviour.blocks["b1"].params == {"sides": []} # the loader refuses this + B.add_block(d, "t_flag", (0, 0)) + B.set_param(d, "b2", "flag", "armor_2") + assert d.manifest.behaviour.blocks["b2"].params == {"flag": "armor_2"} + + +def test_a_path_that_plays_nothing_ranks_on_its_last_block( + d: PortDocument, monkeypatch: pytest.MonkeyPatch +) -> None: + paths = [model.Path(("b2", "b3"), None, None), model.Path(("b1",), "stamp", "charge")] + monkeypatch.setattr(model, "paths", lambda m: paths) + monkeypatch.setattr(model, "refused", lambda m: {}) + monkeypatch.setattr(model, "loose", lambda m: []) + r = B.read(d.manifest) + assert r.priority == {"b3": [1], "b1": [2]} and r.capped is None + + # rules: what a trigger's rows edit @@ -454,6 +551,54 @@ def test_a_new_rule_for_an_event_is_checked_last_and_plays_nothing(d: PortDocume assert d.manifest == before +def test_a_new_rule_for_a_signal_carries_its_name(d: PortDocument) -> None: + d.edit( + lambda m: m.behaviour.blocks.update( + {"b2": model.Block("on_signal", (260.0, 200.0), {"name": "roar"}, play=["stamp"])} + ) + ) + B.add_rule(d, trigger(d.manifest, "on_signal:roar")) + made = d.manifest.behaviour.blocks["b3"] + assert (made.kind, made.params) == ("on_signal", {"name": "roar"}) + [rule] = B.open_rules(d.manifest) + assert model.trigger_key(d.manifest, rule) == "on_signal:roar" + B.set_play(d, "b3", None, "flinch_head") + assert [ + r.signal for r in model.compile(d.manifest) if r.on is None and r.signal is not None + ] == [0, 0] + + +def test_a_rule_that_ends_at_an_effect_is_complete_not_open(d: PortDocument) -> None: + d.edit( + lambda m: m.behaviour.blocks.update( + { + "b2": model.Block("on_noticed", (260.0, 200.0), next=["b3"]), + "b3": model.Block("counter_add", (460.0, 200.0), {"counter": "hits"}), + } + ) + ) + assert B.open_rules(d.manifest) == [] + assert [(p.blocks, p.play) for p in model.paths(d.manifest)][0] == (("b2", "b3"), None) + + +def test_a_reorder_that_no_play_first_forbids_is_refused_and_says_so(d: PortDocument) -> None: + d.edit( + lambda m: m.behaviour.blocks.update( + { + "b2": model.Block("on_noticed", (260.0, 200.0), play=["stamp"]), + "b3": model.Block("on_noticed", (260.0, 400.0), next=["b4"]), + "b4": model.Block("counter_add", (460.0, 400.0), {"counter": "hits"}), + } + ) + ) + rules = [p for _, p in trigger(d.manifest, "on_noticed").rules] + assert [p.play for p in rules] == [None, "stamp"], "the rule that plays nothing is first" + before = d.manifest + with pytest.raises(ManifestError, match="rules that play nothing go first"): + B.move_rule(d, rules, 1, 0) + assert d.manifest is before + + def test_a_new_rule_for_a_state_has_the_states_values(d: PortDocument) -> None: B.add_rule(d, trigger(d.manifest, "host_state")) assert d.manifest.behaviour.blocks["b2"].params == {"mains": [0]} @@ -647,12 +792,17 @@ def test_rules_that_share_their_first_block_cannot_be_reordered(d: PortDocument) def test_a_reorder_leaves_the_other_triggers_ranks_alone(ports: Path) -> None: z = PortDocument(manifest.load(ports / "zinogre.toml")) before = model.compile(z.manifest) + ranks = trigger(z.manifest, "during:lunge").ranks paths = [p for _, p in trigger(z.manifest, "during:lunge").rules] B.move_rule(z, paths, 0, 0) assert model.compile(z.manifest) == before, "a no-op changes nothing" B.move_rule(z, paths, 0, 1) after = model.compile(z.manifest) - assert after[0] == before[1] and after[1] == before[0] and after[2:] == before[2:] + lo, hi = ranks[0] - 1, ranks[1] - 1 + assert after[lo] == before[hi] and after[hi] == before[lo] + assert [r for k, r in enumerate(after) if k not in (lo, hi)] == [ + r for k, r in enumerate(before) if k not in (lo, hi) + ] z.undo() assert model.compile(z.manifest) == before @@ -660,14 +810,18 @@ def test_a_reorder_leaves_the_other_triggers_ranks_alone(ports: Path) -> None: def test_a_rule_made_and_taken_away_leaves_the_zinogres_rules_alone(ports: Path) -> None: z = PortDocument(manifest.load(ports / "zinogre.toml")) before = model.compile(z.manifest) + old = set(z.manifest.behaviour.blocks) + anchor = next(i for i, b in z.manifest.behaviour.blocks.items() if "flinch_head" in b.play) B.add_rule(z, trigger(z.manifest, "on_flinch")) - new = next(i for i in z.manifest.behaviour.blocks if i not in {f"b{k}" for k in range(1, 16)}) + [new] = set(z.manifest.behaviour.blocks) - old assert model.compile(z.manifest) == before, "no move yet" B.set_play(z, new, None, "flinch_head") after = model.compile(z.manifest) - assert after[:-1] == before and after[-1].play == "flinch_head" and after[-1].on == "flinch" - B.insert_block(z, "b12", "cooldown", None, "flinch_head") - B.drop_block(z, "b16" if new != "b16" else "b17") + [made] = [r for r in after if r.on == "flinch" and r.play == "flinch_head"] + assert [r for r in after if r is not made] == before + B.insert_block(z, anchor, "cooldown", None, "flinch_head") + [inserted] = set(z.manifest.behaviour.blocks) - old - {new} + B.drop_block(z, inserted) B.drop_rule(z, (new,), "flinch_head") assert model.compile(z.manifest) == before diff --git a/apps/studio/tests/monster/test_rule_game.py b/apps/studio/tests/monster/test_rule_game.py index a4b92c0..b9463e0 100644 --- a/apps/studio/tests/monster/test_rule_game.py +++ b/apps/studio/tests/monster/test_rule_game.py @@ -64,12 +64,32 @@ def brain(m: Manifest, fired: list[int], ticks: int = 100) -> Brain: CFG, ticks, tuple( - Slot(k, rg.kind(r.on), -1, True, r.min_frames, r.cooldown, n, UNLIMITED, 0) + Slot( + k, + rg.kind(r.on), + -1 if r.signal is None else r.signal, + bool(r.play), + r.min_frames, + r.cooldown, + n, + UNLIMITED, + 0, + ) for k, (r, n) in enumerate(zip(model.compile(m), fired, strict=True)) ), ) +@pytest.fixture +def ms(m: Manifest) -> Manifest: + """`m` and a rule on the signal roar and a rule on a tail cut that only counts.""" + b = m.behaviour + b.blocks["b5"] = Block("on_signal", (260.0, 480.0), {"name": "roar"}, play=["howl"]) + b.blocks["b6"] = Block("on_tail_cut", (260.0, 640.0), next=["b7"]) + b.blocks["b7"] = Block("counter_add", (460.0, 640.0), {"counter": "cuts", "by": 1}) + return m + + # ---- match ----------------------------------------------------------------------------------- # @@ -105,6 +125,26 @@ def test_fewer_slots_than_rules_is_another_build_and_more_are_not_looked_at(m: M assert rg.match(replace(good, slots=(*good.slots, extra)), m) == {1: 1, 2: 2, 3: 3} +def test_a_signal_rule_and_a_rule_that_plays_nothing_match_what_the_slots_say( + ms: Manifest, +) -> None: + rules = model.compile(ms) + sig = next(k for k, r in enumerate(rules) if r.signal is not None) + idle = next(k for k, r in enumerate(rules) if not r.play) + good = brain(ms, list(range(10, 10 + len(rules)))) + assert good.slots[sig].signal == model.signals(ms)["roar"] and not good.slots[idle].plays + assert rg.match(good, ms) == {k + 1: 10 + k for k in range(len(rules))} + for k, field, value in ( + (sig, "signal", model.signals(ms)["roar"] + 1), + (sig, "signal", -1), + (idle, "plays", True), + (0, "signal", 0), + ): + slots = list(good.slots) + slots[k] = replace(slots[k], **{field: value}) + assert rg.match(replace(good, slots=tuple(slots)), ms) == rg.OTHER_BUILD, (k, field) + + def test_a_changed_manifest_no_longer_matches(m: Manifest) -> None: good = brain(m, [1, 2, 3]) m.behaviour.blocks["b4"].params["frames"] = 31 @@ -291,3 +331,23 @@ def test_poll_says_whether_the_marks_changed(game: Game, m: Manifest) -> None: game.show(m, [1, 2, 3]) game.poll(m, dt=1.0) assert game.poll(m, dt=0.2) is False + + +def test_the_game_may_be_written_to_only_when_its_brain_is_found_and_runs_these_rules( + game: Game, m: Manifest +) -> None: + off = rg.Watch() + assert off.blocker() is not None and "Watch the game" in off.blocker() # type: ignore[operator] + assert game.watch.blocker() == "Watch the game has not read the game yet" + game.poll(m, running=False) + assert game.watch.blocker() == rg.NO_GAME + game.answer = lambda: rg.Read(None, None) + game.poll(m, running=True, dt=1.0) + game.poll(m, dt=1.0) + assert game.watch.blocker() == rg.NOT_FOUND + game.watch.reset() + game.feed(m, [1, 2, 3]) + assert game.watch.blocker() is None and game.watch.cfg == CFG + m.behaviour.blocks["b4"].params["frames"] = 31 + game.poll(m, dt=1.0) + assert game.watch.blocker() == rg.OTHER_BUILD diff --git a/apps/studio/tests/monster/test_validate.py b/apps/studio/tests/monster/test_validate.py index a007d5c..6a42326 100644 --- a/apps/studio/tests/monster/test_validate.py +++ b/apps/studio/tests/monster/test_validate.py @@ -281,7 +281,9 @@ def test_a_refused_path_is_a_finding(make): def test_more_paths_than_the_seam_holds(make): - blocks = "".join(flinch(i, "o").replace("on_flinch", "on_noticed") for i in range(40)) + blocks = "".join( + flinch(i, "o").replace("on_flinch", "on_noticed") for i in range(SEAM_RULES + 2) + ) m = make(OWN + blocks) assert len(m.behaviour.blocks) > SEAM_RULES f = found(V.validate(m, sources={1: 1}), "BEHAVIOUR_OVER_CAP") diff --git a/apps/studio/tests/monster/ui/conftest.py b/apps/studio/tests/monster/ui/conftest.py index 0c24755..fde4e0c 100644 --- a/apps/studio/tests/monster/ui/conftest.py +++ b/apps/studio/tests/monster/ui/conftest.py @@ -1,10 +1,57 @@ # SPDX-License-Identifier: GPL-3.0-or-later # SPDX-FileCopyrightText: 2026 sp00ktober -"""Every Qt test: no question blocks, and the GL context comes back (root conftest).""" +"""Every Qt test: no question blocks, and the GL context comes back (root conftest). Also the +game as the Behaviour tab's tests fake it.""" +from __future__ import annotations + +from collections.abc import Callable +from contextlib import AbstractContextManager from typing import Any import pytest +from mhfu.live.brain import UNLIMITED, Brain, Slot +from mhfu_port import behaviour as model +from mhfu_port.manifest import Manifest +from mhfu_studio.monster import rule_game as rg + +CFG = 0x1000 + + +class FakeGame: + """The game: it runs the rules of `m` as sent, fired `fired` times each; `calls` is where + each read was told the brain was.""" + + def __init__(self, m: Manifest) -> None: + self.rules = model.compile(m) + self.fired = [0] * len(self.rules) + self.ticks = 100 + self.calls: list[int | None] = [] + #: a read to answer with instead of the brain, and whether the game runs other rules + self.answer: Callable[[], rg.Read] | None = None + self.other_build = False + + def __call__( + self, attach: Callable[[], AbstractContextManager[Any]], cfg: int | None + ) -> rg.Read: + self.calls.append(cfg) + if self.answer is not None: + return self.answer() + slots = tuple( + Slot( + k, + rg.kind(r.on), + -1 if r.signal is None else r.signal, + bool(r.play), + r.min_frames, + r.cooldown + (7 if self.other_build else 0), + n, + UNLIMITED, + 0, + ) + for k, (r, n) in enumerate(zip(self.rules, self.fired, strict=True)) + ) + return rg.Read(CFG, Brain(CFG, self.ticks, slots)) @pytest.fixture(autouse=True) diff --git a/apps/studio/tests/monster/ui/test_behaviour.py b/apps/studio/tests/monster/ui/test_behaviour.py index 3868bed..2812dc0 100644 --- a/apps/studio/tests/monster/ui/test_behaviour.py +++ b/apps/studio/tests/monster/ui/test_behaviour.py @@ -5,27 +5,29 @@ from __future__ import annotations +import itertools from collections.abc import Callable from pathlib import Path from typing import Any import pytest from mhfu_port import behaviour as model -from mhfu_port.behaviour import KINDS, Block, MoveNode, Rule +from mhfu_port.behaviour import KINDS, Block, Kind, MoveNode, Rule from mhfu_port.manifest import Move from mhfu_studio.monster import behaviour as B from mhfu_studio.monster.document import PortDocument -from mhfu_studio.monster.panels.behaviour import WARN, BehaviourPanel +from mhfu_studio.monster.panels.behaviour import ROLES, WARN, BehaviourPanel from mhfu_studio.monster.panels.node_graph import DIM, FADED, GraphView, LinkSpec from mhfu_studio.monster.panels.properties import PropertyBar from mhfu_studio.monster.workspace import MonsterWorkspace +from mhfu_studio.shell.overlay import Ink from mhfu_studio.shell.studio import Studio from mhfu_studio.shell.workspace import Dock from mhfu_studio.ui import kit, theme from mhfu_studio.ui.testing import FakeWorkspace -from PySide6.QtCore import QPoint, QPointF, Qt +from PySide6.QtCore import QPoint, QPointF, QRectF, Qt from PySide6.QtTest import QTest -from PySide6.QtWidgets import QDockWidget +from PySide6.QtWidgets import QComboBox, QDockWidget, QToolButton LEFT, NONE = Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier STAMP, FLINCH, STEP = B.move_id("stamp"), B.move_id("flinch_head"), B.move_id("stamp_2") @@ -336,21 +338,46 @@ def test_the_zinogre_opens( ws.all_view = B.CANVAS # a loaded document starts in order p = make(qtbot, ws) m = zin.manifest - assert len(m.behaviour.blocks) == 15 and len(p.view._shown) == 15 + len(m.moves) + assert len(m.behaviour.blocks) == 36 and len(p.view._shown) == 36 + len(m.moves) plays = sorted(badge(p, i) for i in m.behaviour.blocks if badge(p, i)) - assert plays == sorted(f"#{k}" for k in range(1, 8)) + assert plays == sorted(f"#{k}" for k in range(1, 17)) + for k, path in enumerate(B.read(m).paths, 1): # a path that plays nothing has its place too + assert badge(p, path.blocks[-1]) == rank(k) + assert [badge(p, i) for i in ("b15", "b17", "b23")] == [rank(1), rank(2), rank(3)] + assert badge(p, "b4") == rank(4) and badge(p, "b36") == rank(16) + assert bar_on(p, "b23").controls["counter"].get_value() == "flinches" + assert options(p, "b15", "flag") == ["flinches", "in_combat"] assert all(p.view._shown[i].view.opacity() == 1.0 for i in m.behaviour.blocks) assert in_step(p, ws) wrote = {i: p.view._shown[i].view.xy_pos for i in m.behaviour.blocks} assert wrote["b12"] == [float(model.LEFT), 4.0 * model.ROW] - assert summary(p, "b12") == "head" and summary(p, "b2") == "250 \u2013 1000" - select(qtbot, p, "b12") - assert p.bar.note is not None and p.bar.note.get_value().startswith("a flinch of the head") + assert summary(p, "b18") == "head" and summary(p, "b2") == "250 \u2013 1000" + select(qtbot, p, "b18") + assert p.bar.note is not None and p.bar.note.get_value().startswith("horns break") assert p.bar.controls["part"].get_value() == 0 select(qtbot, p, "b2") assert p.bar.controls["hi"].get_value() == 1000.0 +def test_a_new_name_leaves_the_other_node_sizes_on_the_zinogre( + qtbot: Any, ws: MonsterWorkspace, zinogre_toml: Path, synthetic_pac: bytes +) -> None: + from mhfu_studio.monster.core.scene import Scene + + zin = PortDocument.open(zinogre_toml) + ws.load(Scene.from_bytes(synthetic_pac, "z", path=zinogre_toml.with_suffix(".bin")), zin) + p = make(qtbot, ws) + sizes = {i: (s.view.width, s.view.height) for i, s in p.view._shown.items()} + flag = next(i for i, b in zin.manifest.behaviour.blocks.items() if "flag" in b.params) + type_name(qtbot, p, flag, "flag", "brand_new") + assert zin.manifest.behaviour.blocks[flag].params["flag"] == "brand_new" + now = {i: (s.view.width, s.view.height) for i, s in p.view._shown.items()} + assert {i: v for i, v in now.items() if i != flag} == { + i: v for i, v in sizes.items() if i != flag + } + assert summary(p, flag).startswith("brand_new") + + def test_spread_out_is_one_step(panel: BehaviourPanel, ws: MonsterWorkspace) -> None: before = doc(ws).manifest panel.spread.click() @@ -360,6 +387,21 @@ def test_spread_out_is_one_step(panel: BehaviourPanel, ws: MonsterWorkspace) -> assert doc(ws).manifest == before +def test_no_two_nodes_of_the_zinogre_overlap( + qtbot: Any, ws: MonsterWorkspace, zinogre_toml: Path, synthetic_pac: bytes +) -> None: + from mhfu_studio.monster.core.scene import Scene + + zin = PortDocument.open(zinogre_toml) + ws.load(Scene.from_bytes(synthetic_pac, "z", path=zinogre_toml.with_suffix(".bin")), zin) + p = make(qtbot, ws) + rects = { + i: QRectF(*n.view.xy_pos, n.view.width, n.view.height) for i, n in p.view._shown.items() + } + clash = [(a, b) for a, b in itertools.combinations(rects, 2) if rects[a].intersects(rects[b])] + assert clash == [] + + def test_part_and_main_options(qtbot: Any, panel: BehaviourPanel, ws: MonsterWorkspace) -> None: doc(ws).edit(lambda m: m.behaviour.blocks.__setitem__("b4", Block("on_flinch", (0.0, 0.0)))) panel.sync() @@ -380,7 +422,9 @@ def test_the_palette_offers_every_kind(panel: BehaviourPanel) -> None: menu = panel.view.viewer.context_menus()["graph"] offered = {a.text() for sub in menu.actions() for a in sub.menu().actions()} assert offered == {k.title for k in KINDS.values()} - assert [a.text() for a in menu.actions()] == ["Events", "State", "Conditions", "Modifiers"] + titles = [a.text() for a in menu.actions()] + assert titles[:4] == ["Events", "State", "Conditions", "Modifiers"] + assert titles[4:] in ([], ["Effects"]) def create(qtbot: Any, p: BehaviourPanel, title: str, where: QPoint) -> None: @@ -754,6 +798,203 @@ def test_renaming_a_move_keeps_the_graph(panel: BehaviourPanel, ws: MonsterWorks assert in_step(panel, ws) and badge(panel, "b1") == rank(1) +# ---- effects, names, sides and paths that play nothing -------------------------------------- # + + +def test_a_path_that_plays_nothing_ranks_first_wherever_it_sits( + panel: BehaviourPanel, ws: MonsterWorkspace +) -> None: + def add(m: Any) -> None: + b = m.behaviour.blocks + b["b4"] = Block("on_enraged", (260.0, 500.0), next=["b5"]) + b["b5"] = Block("counter_add", (540.0, 500.0), {"counter": "hits"}) + + doc(ws).edit(add) + ws.sync() + panel.sync() + assert [badge(panel, n) for n in ("b1", "b2", "b3", "b5")] == [rank(2), "", "", rank(1)] + assert panel.title.text() == f"5 blocks · 2 of {model.SEAM_RULES} paths · 2 loose" + assert panel.view._shown["b5"].view.opacity() == 1.0 + assert "play nothing run before" in panel.hint.text() + + +@pytest.mark.parametrize( + ("family", "dark"), [("Ember", True), ("Ember", False), ("Moss", True), ("Moss", False)] +) +def test_the_effect_role_has_a_colour_of_its_own(qtbot: Any, family: str, dark: bool) -> None: + try: + theme.apply(theme.theme(family, dark)) + seen = {role: theme.color(ink).getRgbF()[:3] for role, ink in ROLES.items()} + finally: + theme.apply(theme.theme("Ember", True)) + assert set(seen) == {"event", "state", "condition", "modifier", "effect", "move"} + for role, rgb in seen.items(): + if role != "effect": + gap = max(abs(a - b) for a, b in zip(rgb, seen["effect"], strict=True)) + assert gap > 0.2, role + + +def test_an_effect_block_wears_the_effect_colour( + new_kinds: tuple[Kind, ...], panel: BehaviourPanel, ws: MonsterWorkspace +) -> None: + doc(ws).edit( + lambda m: m.behaviour.blocks.__setitem__( + "b4", Block("t_count", (0.0, 400.0), {"counter": "hits"}) + ) + ) + try: + for dark in (True, False): + theme.apply(theme.theme("Ember", dark)) + panel.sync() + edge = theme.color(Ink.EFFECT) + have = panel.view._shown["b4"].view.border_color + assert tuple(have[:3]) == (edge.red(), edge.green(), edge.blue()) + finally: + theme.apply(theme.theme("Ember", True)) + + +def bar_on(p: BehaviourPanel, node: str) -> PropertyBar: + """The bar showing `node`, selected from the code: it may sit off the screen.""" + p.view.select([node]) + p.sync() + return p.bar + + +def options(p: BehaviourPanel, node: str, name: str) -> list[str]: + ctl = bar_on(p, node).controls[name].ctl + return [ctl.itemText(i) for i in range(ctl.count())] + + +def type_name(qtbot: Any, p: BehaviourPanel, node: str, name: str, text: str) -> None: + ctl = bar_on(p, node).controls[name].ctl + ctl.setEditText(text) + ctl.lineEdit().editingFinished.emit() + qtbot.wait(30) + + +def test_counters_and_flags_through_the_canvas( + qtbot: Any, new_kinds: tuple[Kind, ...], panel: BehaviourPanel, ws: MonsterWorkspace +) -> None: + count, flag = new_kinds[0], new_kinds[1] + create(qtbot, panel, count.title, QPoint(300, 480)) + assert doc(ws).manifest.behaviour.blocks["b4"].params == {"counter": "counter1", "by": 1} + create(qtbot, panel, flag.title, QPoint(60, 480)) + assert doc(ws).manifest.behaviour.blocks["b5"].params == {"flag": "flag1"} + assert options(panel, "b4", "counter") == options(panel, "b5", "flag") == ["counter1", "flag1"] + type_name(qtbot, panel, "b5", "flag", "armor_hits") + assert doc(ws).manifest.behaviour.blocks["b5"].params == {"flag": "armor_hits"} + assert options(panel, "b4", "counter") == ["armor_hits", "counter1"] + assert bar_on(panel, "b5").controls["flag"].get_value() == "armor_hits" + assert in_step(panel, ws) + doc(ws).undo() + assert doc(ws).manifest.behaviour.blocks["b5"].params == {"flag": "flag1"} + doc(ws).redo() + ws.refresh() + panel.sync() + assert options(panel, "b5", "flag") == ["armor_hits", "counter1"] + kept = doc(ws).manifest + type_name(qtbot, panel, "b5", "flag", "Bad Name") + assert doc(ws).manifest is kept + assert bar_on(panel, "b5").controls["flag"].get_value() == "armor_hits" + + +def test_signals_and_sides_through_the_canvas( + qtbot: Any, new_kinds: tuple[Kind, ...], panel: BehaviourPanel, ws: MonsterWorkspace +) -> None: + signal, side = new_kinds[2], new_kinds[3] + create(qtbot, panel, signal.title, QPoint(300, 480)) + create(qtbot, panel, side.title, QPoint(60, 480)) + blocks = doc(ws).manifest.behaviour.blocks + assert blocks["b4"].params == {"name": "signal1"} and blocks["b5"].params == { + "sides": ["front"] + } + type_name(qtbot, panel, "b4", "name", "rage") + assert doc(ws).manifest.behaviour.blocks["b4"].params == {"name": "rage"} + create(qtbot, panel, signal.title, QPoint(500, 480)) + assert doc(ws).manifest.behaviour.blocks["b6"].params == {"name": "signal1"} + assert options(panel, "b4", "name") == ["rage", "signal1"] + sides = bar_on(panel, "b5").controls["sides"].ctl + picks = sides.menu().actions() + assert [a.text() for a in picks] == list(new_kinds[3].params[0].choices) + assert sides.text() == "front" + picks[3].trigger() + qtbot.wait(30) + assert doc(ws).manifest.behaviour.blocks["b5"].params == {"sides": ["front", "behind"]} + assert sides.text() == "front, behind" and in_step(panel, ws) + picks[0].trigger() + qtbot.wait(30) + assert doc(ws).manifest.behaviour.blocks["b5"].params == {"sides": ["behind"]} + kept = doc(ws).manifest + picks[3].trigger() # none left + qtbot.wait(30) + assert doc(ws).manifest is kept and "side" in ws.message + assert bar_on(panel, "b5").controls["sides"].get_value() == ("behind",) + + +def put(qtbot: Any, p: BehaviourPanel, node: str, name: str, value: Any) -> None: + """Sets a node's parameter through its widget, as a hand would.""" + ctl = bar_on(p, node).controls[name].ctl + if isinstance(ctl, QToolButton): + [act] = [a for a in ctl.menu().actions() if a.text() == value] + act.trigger() + elif isinstance(ctl, QComboBox) and ctl.isEditable(): + type_name(qtbot, p, node, name, value) + return + elif isinstance(ctl, QComboBox): + ctl.setCurrentIndex(ctl.findText(value)) + else: + ctl.setValue(value) + qtbot.wait(30) + + +#: (kind, param, what is put in the widget, what the manifest then holds) +EDITS = [ + ("counter_is", "counter", "hits_taken", "hits_taken"), + ("counter_is", "test", "below", "below"), + ("counter_is", "value", 7, 7), + ("flag_is", "flag", "armor", "armor"), + ("flag_is", "state", "clear", "clear"), + ("counter_add", "by", -3, -3), + ("counter_set", "to", 9, 9), + ("flag_set", "state", "clear", "clear"), + ("monster_hp", "lo", 10, 10), + ("monster_hp", "hi", 50, 50), + ("part_broken", "part", "head", 1), + ("part_broken", "state", "not broken", "not broken"), + ("rage", "state", "calm", "calm"), + ("hunter_side", "sides", "left", ["front", "left"]), + ("chance", "percent", 50, 50), + ("on_signal", "name", "roar", "roar"), +] + + +def test_every_real_kind_draws_and_edits( + qtbot: Any, panel: BehaviourPanel, ws: MonsterWorkspace +) -> None: + ids: dict[str, str] = {} + for n, kind in enumerate(KINDS): + ids[kind] = model.new_id(doc(ws).manifest.behaviour) + B.add_block(doc(ws), kind, (300.0 + 20 * n, 900.0 + 60 * n)) + ws.sync() + panel.sync() + assert in_step(panel, ws) + blocks = doc(ws).manifest.behaviour.blocks + for kind, i in ids.items(): + held = model.params(blocks[i]) + for p in KINDS[kind].params: + shown = bar_on(panel, i).controls[p.name].get_value() + want = held.get(p.name) + assert shown == (tuple(want) if isinstance(want, list) else want), (kind, p.name) + drawn = doc(ws).manifest + for kind, name, put_, stored in EDITS: + put(qtbot, panel, ids[kind], name, put_) + assert doc(ws).manifest.behaviour.blocks[ids[kind]].params[name] == stored, (kind, name) + assert in_step(panel, ws) + for _ in EDITS: + doc(ws).undo() + assert doc(ws).manifest == drawn + + # ---- focus ----------------------------------------------------------------------------------- # diff --git a/apps/studio/tests/monster/ui/test_fire.py b/apps/studio/tests/monster/ui/test_fire.py new file mode 100644 index 0000000..36664db --- /dev/null +++ b/apps/studio/tests/monster/ui/test_fire.py @@ -0,0 +1,253 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 sp00ktober +"""Fire on the Behaviour tab: when a trigger can be raised in the running game, why not, and the +one byte it writes. A fake reader and a fake session stand in for the game.""" + +from __future__ import annotations + +from contextlib import AbstractContextManager, nullcontext +from typing import Any + +import pytest +from mhfu import addresses as a +from mhfu_port import behaviour as model +from mhfu_port.behaviour import Block +from mhfu_port.manifest import Move +from mhfu_studio.monster import rule_game as rg +from mhfu_studio.monster.panels import triggers +from mhfu_studio.monster.panels.behaviour import BehaviourPanel +from mhfu_studio.monster.workspace import MonsterWorkspace +from mhfu_studio.shell.studio import Studio +from PySide6.QtCore import Qt +from PySide6.QtTest import QTest + +from .conftest import CFG, FakeGame + +LEFT, NONE = Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier +ROAR, FLINCH = "on_signal:roar", "on_flinch" +TIP = ( + "Raises Signal · roar in the running game now; the rules' counts show whether their" + " conditions held" +) + + +def graph(m: Any) -> None: + """A rule on the signal `bellow` (board signal 0), one on `roar` (1) and a flinch.""" + for name in ("stamp", "howl"): + m.moves[name] = Move(clip="walk") + b = m.behaviour + b.blocks["b1"] = Block("on_signal", (260.0, 0.0), {"name": "bellow"}, play=["stamp"]) + b.blocks["b2"] = Block("on_signal", (260.0, 100.0), {"name": "roar"}, play=["howl"]) + b.blocks["b3"] = Block("on_flinch", (260.0, 200.0), play=["stamp"]) + + +class Mem: + """What a session's memory is asked to write.""" + + def __init__(self) -> None: + self.writes: list[tuple[int, int]] = [] + self.fail: Exception | None = None + self.opened = 0 + + def session(self) -> AbstractContextManager[Session]: + """The game's session, as `ws.game_session` opens it.""" + self.opened += 1 + return nullcontext(Session(self)) + + def write_u8(self, address: int, value: int) -> None: + if self.fail is not None: + raise self.fail + self.writes.append((address, value)) + + +class Session: + def __init__(self, mem: Mem) -> None: + self.mem = mem + + +@pytest.fixture +def ws(workspace: MonsterWorkspace) -> MonsterWorkspace: + assert workspace.doc is not None + workspace.doc.edit(graph) + workspace.sync() + workspace.game_running = lambda: True + return workspace + + +@pytest.fixture +def game(ws: MonsterWorkspace) -> FakeGame: + g = FakeGame(ws.manifest) # type: ignore[arg-type] + ws.read_brain = g + return g + + +@pytest.fixture +def mem(ws: MonsterWorkspace) -> Mem: + m = Mem() + ws.game_session = m.session + return m + + +@pytest.fixture +def clock(ws: MonsterWorkspace) -> list[float]: + now = [100.0] + ws.watch.clock = lambda: now[0] + return now + + +@pytest.fixture +def panel( + qtbot: Any, ws: MonsterWorkspace, game: FakeGame, mem: Mem, clock: list[float] +) -> BehaviourPanel: + studio = Studio([ws]) + p = BehaviourPanel(ws, studio) + studio.listen(p.sync) + qtbot.addWidget(p) + p.resize(1100, 640) + p.show() + qtbot.waitExposed(p) + p.sync() + qtbot.wait(20) + return p + + +def scope(qtbot: Any, p: BehaviourPanel, key: str) -> None: + out = p.outline + QTest.mouseClick(out.viewport(), LEFT, NONE, out.visualItemRect(out.item_of(key)).center()) + qtbot.wait(40) + + +def watching(qtbot: Any, p: BehaviourPanel, clock: list[float]) -> None: + QTest.mouseClick(p.watch, LEFT, NONE) + + def step() -> bool: + clock[0] += 1.0 + p._tick() + return p.ws.fires is not None + + qtbot.waitUntil(step, timeout=3000) + + +def test_fire_is_there_for_a_signal_trigger_only(qtbot: Any, panel: BehaviourPanel) -> None: + assert panel.fire.isHidden() + scope(qtbot, panel, ROAR) + assert not panel.fire.isHidden() and panel.fire.text() == "Fire" + scope(qtbot, panel, FLINCH) + assert panel.fire.isHidden() + scope(qtbot, panel, "all") + assert panel.fire.isHidden() + + +def test_not_watching_is_the_first_reason(qtbot: Any, panel: BehaviourPanel) -> None: + scope(qtbot, panel, ROAR) + assert not panel.fire.isEnabled() + assert panel.fire.toolTip() == f"Fire: {panel.ws.watch.blocker()}" + assert "Watch the game" in panel.fire.toolTip() + + +def test_it_says_why_it_cannot_fire_while_watching( + qtbot: Any, panel: BehaviourPanel, game: FakeGame, clock: list[float] +) -> None: + scope(qtbot, panel, ROAR) + QTest.mouseClick(panel.watch, LEFT, NONE) + assert panel.fire.toolTip() == "Fire: Watch the game has not read the game yet" + panel.ws.game_running = lambda: False + clock[0] += 1.0 + panel._tick() + assert panel.fire.toolTip() == f"Fire: {rg.NO_GAME}" and not panel.fire.isEnabled() + + +def test_a_brain_not_found_and_another_build_disable_it_with_their_reason( + qtbot: Any, panel: BehaviourPanel, game: FakeGame, clock: list[float] +) -> None: + scope(qtbot, panel, ROAR) + game.answer = lambda: rg.Read(None, None) + QTest.mouseClick(panel.watch, LEFT, NONE) + + def step(until: str) -> bool: + clock[0] += 1.0 + panel._tick() + return panel.ws.watch.status == until + + qtbot.waitUntil(lambda: step(rg.NOT_FOUND), timeout=3000) + assert panel.fire.toolTip() == f"Fire: {rg.NOT_FOUND}" and not panel.fire.isEnabled() + QTest.mouseClick(panel.watch, LEFT, NONE) + game.answer, game.other_build = None, True + QTest.mouseClick(panel.watch, LEFT, NONE) + qtbot.waitUntil(lambda: step(rg.OTHER_BUILD), timeout=3000) + assert panel.fire.toolTip() == f"Fire: {rg.OTHER_BUILD}" and not panel.fire.isEnabled() + + +def test_found_and_running_these_rules_it_is_on_with_the_tip( + qtbot: Any, panel: BehaviourPanel, clock: list[float] +) -> None: + scope(qtbot, panel, ROAR) + watching(qtbot, panel, clock) + assert panel.fire.isEnabled() and panel.fire.toolTip() == TIP + + +def test_fire_writes_the_triggers_board_signal_where_watch_found_the_brain( + qtbot: Any, panel: BehaviourPanel, game: FakeGame, mem: Mem, clock: list[float] +) -> None: + scope(qtbot, panel, ROAR) + watching(qtbot, panel, clock) + qtbot.wait(100) # the read the last tick asked has landed on its worker + reads, doc = len(game.calls), panel.ws.doc + assert doc is not None + before = (doc.manifest, doc.can_undo()) + index = model.signals(doc.manifest)["roar"] + assert index == 1 + QTest.mouseClick(panel.fire, LEFT, NONE) + board = CFG + int(a.EM_BLOCK.BOARD) + int(a.EM_BOARD.SIGNALS) + assert mem.writes == [(board + index, 1)] + assert panel.ws.message == f"raised signal roar in the game (board signal {index})" + assert len(game.calls) == reads, "no second look for the brain" + assert (doc.manifest, doc.can_undo()) == before, "the game is not the document" + + +def test_fire_on_another_signal_writes_its_own_index( + qtbot: Any, panel: BehaviourPanel, mem: Mem, clock: list[float] +) -> None: + scope(qtbot, panel, "on_signal:bellow") + watching(qtbot, panel, clock) + QTest.mouseClick(panel.fire, LEFT, NONE) + board = CFG + int(a.EM_BLOCK.BOARD) + int(a.EM_BOARD.SIGNALS) + assert mem.writes == [(board + model.signals(panel.ws.manifest)["bellow"], 1)] # type: ignore[arg-type] + + +def test_a_click_is_one_act_and_one_session( + qtbot: Any, panel: BehaviourPanel, mem: Mem, clock: list[float] +) -> None: + scope(qtbot, panel, ROAR) + watching(qtbot, panel, clock) + told: list[int] = [] + panel.studio.listen(lambda: told.append(1)) + QTest.mouseClick(panel.fire, LEFT, NONE) + assert len(told) == 1 and mem.opened == 1 and len(mem.writes) == 1 + assert panel.studio.errors == [] + + +def test_a_disabled_fire_writes_nothing(qtbot: Any, panel: BehaviourPanel, mem: Mem) -> None: + scope(qtbot, panel, ROAR) + t = panel._current() + assert t is not None and panel.ws.fire_blocker(t) is not None + panel.ws.fire(t) + assert mem.writes == [] and mem.opened == 0 + assert panel.ws.message.startswith("not fired: ") + + +def test_a_write_that_fails_says_so( + qtbot: Any, panel: BehaviourPanel, mem: Mem, clock: list[float] +) -> None: + scope(qtbot, panel, ROAR) + watching(qtbot, panel, clock) + mem.fail = OSError("connection reset") + QTest.mouseClick(panel.fire, LEFT, NONE) + assert panel.ws.message == "not fired: connection reset" and panel.studio.errors == [] + + +def test_only_a_trigger_in_the_table_can_fire(panel: BehaviourPanel) -> None: + flinch = next(t for t in panel._triggers if t.key == FLINCH) + assert set(rg.FIRE) == {"on_signal"} and flinch.kind not in rg.FIRE + assert panel.ws.fire_blocker(flinch) == "nothing in the studio raises this trigger" + assert triggers.title(next(t for t in panel._triggers if t.key == ROAR)) == "Signal · roar" diff --git a/apps/studio/tests/monster/ui/test_marks.py b/apps/studio/tests/monster/ui/test_marks.py index e6e5a30..b694276 100644 --- a/apps/studio/tests/monster/ui/test_marks.py +++ b/apps/studio/tests/monster/ui/test_marks.py @@ -6,11 +6,9 @@ from __future__ import annotations from collections.abc import Callable -from contextlib import AbstractContextManager from typing import Any import pytest -from mhfu.live.brain import UNLIMITED, Brain, Slot from mhfu_port import behaviour as model from mhfu_port.behaviour import Block, MoveNode from mhfu_port.manifest import Manifest, Move @@ -24,8 +22,9 @@ from PySide6.QtCore import Qt from PySide6.QtTest import QTest +from .conftest import CFG, FakeGame + LEFT, NONE = Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier -CFG = 0x1000 def graph(m: Any) -> None: @@ -48,27 +47,6 @@ def graph(m: Any) -> None: b.moves[name] = MoveNode((900.0, 160.0 * k)) -class Game: - """The game: it runs the rules of `m` as sent, fired `fired` times each; `calls` is where - each read was told the brain was.""" - - def __init__(self, m: Manifest) -> None: - self.rules = model.compile(m) - self.fired = [0] * len(self.rules) - self.ticks = 100 - self.calls: list[int | None] = [] - - def __call__( - self, attach: Callable[[], AbstractContextManager[Any]], cfg: int | None - ) -> rg.Read: - self.calls.append(cfg) - slots = tuple( - Slot(k, rg.kind(r.on), -1, True, r.min_frames, r.cooldown, n, UNLIMITED, 0) - for k, (r, n) in enumerate(zip(self.rules, self.fired, strict=True)) - ) - return rg.Read(CFG, Brain(CFG, self.ticks, slots)) - - @pytest.fixture def ws(workspace: MonsterWorkspace) -> MonsterWorkspace: assert workspace.doc is not None @@ -79,8 +57,8 @@ def ws(workspace: MonsterWorkspace) -> MonsterWorkspace: @pytest.fixture -def game(ws: MonsterWorkspace) -> Game: - g = Game(ws.manifest) # type: ignore[arg-type] +def game(ws: MonsterWorkspace) -> FakeGame: + g = FakeGame(ws.manifest) # type: ignore[arg-type] ws.read_brain = g return g @@ -93,7 +71,7 @@ def clock(ws: MonsterWorkspace) -> list[float]: @pytest.fixture -def panel(qtbot: Any, ws: MonsterWorkspace, game: Game, clock: list[float]) -> BehaviourPanel: +def panel(qtbot: Any, ws: MonsterWorkspace, game: FakeGame, clock: list[float]) -> BehaviourPanel: studio = Studio([ws]) p = BehaviourPanel(ws, studio) studio.listen(p.sync) @@ -137,7 +115,7 @@ def scope(qtbot: Any, p: BehaviourPanel, key: str) -> None: qtbot.wait(40) -def test_nothing_is_marked_until_the_toggle_is_on(panel: BehaviourPanel, game: Game) -> None: +def test_nothing_is_marked_until_the_toggle_is_on(panel: BehaviourPanel, game: FakeGame) -> None: assert not panel.watch.isChecked() and panel.live.text() == "" assert not panel.ticker.isActive() assert all("×" not in t for t in titles(panel)) @@ -161,7 +139,7 @@ def test_the_toggle_starts_a_poll_once_a_second_while_the_tab_is_shown( def test_a_tab_hidden_polls_nothing( - qtbot: Any, panel: BehaviourPanel, game: Game, clock: list[float] + qtbot: Any, panel: BehaviourPanel, game: FakeGame, clock: list[float] ) -> None: watch(qtbot, panel, clock) panel.hide() @@ -174,7 +152,7 @@ def test_a_tab_hidden_polls_nothing( def test_it_reads_at_most_once_a_second_whatever_the_ticks( - qtbot: Any, panel: BehaviourPanel, game: Game, clock: list[float] + qtbot: Any, panel: BehaviourPanel, game: FakeGame, clock: list[float] ) -> None: QTest.mouseClick(panel.watch, LEFT, NONE) for _ in range(40): @@ -187,7 +165,7 @@ def test_it_reads_at_most_once_a_second_whatever_the_ticks( def test_a_trigger_view_marks_every_frame_and_quiets_what_never_fired( - qtbot: Any, panel: BehaviourPanel, game: Game, clock: list[float] + qtbot: Any, panel: BehaviourPanel, game: FakeGame, clock: list[float] ) -> None: game.fired = [0, 0, 7, 0, 0, 0] watch(qtbot, panel, clock) @@ -200,7 +178,7 @@ def test_a_trigger_view_marks_every_frame_and_quiets_what_never_fired( def test_in_order_marks_every_rule_and_the_status_says_the_tick( - qtbot: Any, panel: BehaviourPanel, game: Game, clock: list[float] + qtbot: Any, panel: BehaviourPanel, game: FakeGame, clock: list[float] ) -> None: game.fired, game.ticks = [1, 2, 3, 4, 5, 6], 4321 watch(qtbot, panel, clock) @@ -216,7 +194,7 @@ def test_in_order_marks_every_rule_and_the_status_says_the_tick( def test_a_long_title_is_cut_before_its_mark_never_through_it( - qtbot: Any, panel: BehaviourPanel, game: Game, clock: list[float] + qtbot: Any, panel: BehaviourPanel, game: FakeGame, clock: list[float] ) -> None: def note(m: Manifest) -> None: m.behaviour.blocks["b4"].label = "a note that goes on and on and on " * 6 @@ -232,7 +210,7 @@ def note(m: Manifest) -> None: def test_the_outline_shows_each_triggers_total_beside_its_ranks( - qtbot: Any, panel: BehaviourPanel, game: Game, clock: list[float] + qtbot: Any, panel: BehaviourPanel, game: FakeGame, clock: list[float] ) -> None: game.fired = [1, 2, 3, 4, 5, 6] watch(qtbot, panel, clock) @@ -250,7 +228,7 @@ def fired(key: str) -> object: def test_a_rule_that_just_fired_is_outlined_for_a_moment( - qtbot: Any, panel: BehaviourPanel, game: Game, clock: list[float] + qtbot: Any, panel: BehaviourPanel, game: FakeGame, clock: list[float] ) -> None: watch(qtbot, panel, clock) assert panel.view._glows == [] @@ -266,7 +244,7 @@ def test_a_rule_that_just_fired_is_outlined_for_a_moment( def test_counts_that_fall_are_taken_and_say_since_when( - qtbot: Any, panel: BehaviourPanel, game: Game, clock: list[float] + qtbot: Any, panel: BehaviourPanel, game: FakeGame, clock: list[float] ) -> None: game.fired = [5, 5, 5, 5, 5, 5] watch(qtbot, panel, clock) @@ -277,7 +255,7 @@ def test_counts_that_fall_are_taken_and_say_since_when( def test_a_game_running_other_rules_shows_no_marks_and_says_so( - qtbot: Any, panel: BehaviourPanel, game: Game, clock: list[float] + qtbot: Any, panel: BehaviourPanel, game: FakeGame, clock: list[float] ) -> None: game.fired = [1, 2, 3, 4, 5, 6] watch(qtbot, panel, clock) @@ -296,7 +274,7 @@ def edit(m: Manifest) -> None: def test_no_game_running_is_said_and_nothing_is_read( - qtbot: Any, panel: BehaviourPanel, game: Game, clock: list[float] + qtbot: Any, panel: BehaviourPanel, game: FakeGame, clock: list[float] ) -> None: panel.ws.game_running = lambda: False QTest.mouseClick(panel.watch, LEFT, NONE) @@ -306,7 +284,7 @@ def test_no_game_running_is_said_and_nothing_is_read( def test_the_canvas_has_no_frames_to_mark_but_still_says_how_it_goes( - qtbot: Any, panel: BehaviourPanel, game: Game, clock: list[float] + qtbot: Any, panel: BehaviourPanel, game: FakeGame, clock: list[float] ) -> None: panel.ws.all_view = "canvas" panel.sync() diff --git a/apps/studio/tests/monster/ui/test_moves.py b/apps/studio/tests/monster/ui/test_moves.py index 600225f..8748ec0 100644 --- a/apps/studio/tests/monster/ui/test_moves.py +++ b/apps/studio/tests/monster/ui/test_moves.py @@ -123,9 +123,7 @@ def test_a_pair_move_points_to_actions(panel: MovesPanel, workspace: MonsterWork assert "(1,4)" in panel.pair_note.text() -def test_the_behaviour_button_opens_the_dock( - panel: MovesPanel, workspace: MonsterWorkspace -) -> None: +def test_the_behaviour_button_opens_the_tab(panel: MovesPanel, workspace: MonsterWorkspace) -> None: panel.behaviour.click() assert workspace.take_focus() == "Behaviour" and kit.missing_tips(panel) == [] diff --git a/apps/studio/tests/monster/ui/test_properties.py b/apps/studio/tests/monster/ui/test_properties.py index 1ea2a27..0d7fb0d 100644 --- a/apps/studio/tests/monster/ui/test_properties.py +++ b/apps/studio/tests/monster/ui/test_properties.py @@ -7,8 +7,11 @@ from typing import Any +import pytest from mhfu_port.behaviour import Param from mhfu_studio.monster.panels.properties import Detail, ParamControl, Play, PropertyBar +from PySide6.QtCore import Qt +from PySide6.QtTest import QTest from PySide6.QtWidgets import QComboBox, QDoubleSpinBox, QLabel, QLineEdit, QSpinBox, QToolButton COUNT = Param("n", "int", "Times", 1, lo=1, hi=9, tip="how often") @@ -197,6 +200,161 @@ def test_a_node_without_params_or_a_note_shows_its_name_only(qtbot: Any) -> None assert texts == ["x", "own move"] +# ---- names and sides ----------------------------------------------------------------------- # + +COUNTER = Param("counter", "var", "Counter", tip="which counter") +SIGNAL = Param("name", "signal", "Signal", tip="which signal") +SIDES = Param("sides", "sides", "Sides", choices=("front", "left", "right", "behind"), tip="sides") +NAMES_IN_USE = [("armor", "armor"), ("hits", "hits")] + + +def named(**kw: Any) -> Detail: + """A node of a counter, a signal and sides.""" + base: dict[str, Any] = { + "id": "e", + "title": "Count", + "tip": "counts", + "params": (COUNTER, SIGNAL, SIDES), + "values": {"counter": "hits", "name": "rage", "sides": ("front",)}, + "options": {"counter": NAMES_IN_USE, "name": [("rage", "rage")]}, + } + return Detail(**(base | kw)) + + +def shown(qtbot: Any) -> tuple[PropertyBar, list[tuple[str, str, object]]]: + """A bar showing `named()`, and every edit it tells.""" + bar = PropertyBar() + qtbot.addWidget(bar) + bar.show_detail(named()) + got: list[tuple[str, str, object]] = [] + bar.param_changed.connect(lambda *a: got.append(a)) + return bar, got + + +def combo(bar: PropertyBar, name: str) -> QComboBox: + ctl = bar.controls[name].ctl + assert isinstance(ctl, QComboBox) + return ctl + + +def test_a_name_is_an_editable_combo_over_the_names_in_use(qtbot: Any) -> None: + bar, _ = shown(qtbot) + counter = combo(bar, "counter") + assert counter.isEditable() + assert [counter.itemText(i) for i in range(counter.count())] == ["armor", "hits"] + assert (bar.controls["counter"].get_value(), bar.controls["name"].get_value()) == ( + "hits", + "rage", + ) + assert counter.currentIndex() == 1 and counter.currentText() == "hits" + bar.show_detail(named(values={"counter": "fresh", "name": "rage"})) + assert bar.controls["counter"].get_value() == "fresh" and counter.currentIndex() == -1 + + +def test_sides_are_a_multi_select_over_the_choices(qtbot: Any) -> None: + bar, _ = shown(qtbot) + sides = bar.controls["sides"] + assert isinstance(sides.ctl, QToolButton) and sides.get_value() == ("front",) + assert [a.text() for a in sides.ctl.menu().actions()] == ["front", "left", "right", "behind"] + assert sides.ctl.text() == "front" + bar.show_detail(named(values={"sides": ("left", "behind")})) + assert sides.get_value() == ("left", "behind") and sides.ctl.text() == "left, behind" + + +def test_each_new_control_says_its_edit_once(qtbot: Any) -> None: + bar, got = shown(qtbot) + counter, signal = combo(bar, "counter"), combo(bar, "name") + counter.setCurrentIndex(0) # a pick + assert got == [("e", "counter", "armor")] + got.clear() + counter.setEditText("fresh_1") # a new name, finished by Return or by leaving + edit = counter.lineEdit() + assert edit is not None + edit.editingFinished.emit() + edit.editingFinished.emit() + assert got == [("e", "counter", "fresh_1")] + got.clear() + sig = signal.lineEdit() + assert sig is not None + sig.selectAll() + QTest.keyClicks(sig, "calm") + QTest.keyClick(sig, Qt.Key.Key_Return) + assert got == [("e", "name", "calm")] + got.clear() + sides = bar.controls["sides"].ctl + assert isinstance(sides, QToolButton) + sides.menu().actions()[2].trigger() + assert got == [("e", "sides", ("front", "right"))] + + +def test_a_name_that_is_not_a_name_goes_back_unheard(qtbot: Any) -> None: + bar, got = shown(qtbot) + counter = combo(bar, "counter") + edit = counter.lineEdit() + assert edit is not None + for bad in ("Hits", "9lives", "two words", "x-y", "", " "): + counter.setEditText(bad) + edit.editingFinished.emit() + assert bar.controls["counter"].get_value() == "hits" and counter.currentText() == "hits", ( + bad + ) + assert got == [] + + +def test_a_name_with_spaces_around_it_is_the_name(qtbot: Any) -> None: + bar, got = shown(qtbot) + counter = combo(bar, "counter") + edit = counter.lineEdit() + assert edit is not None + counter.setEditText(" fresh ") + edit.editingFinished.emit() + assert counter.currentText() == "fresh" and got == [("e", "counter", "fresh")] + + +def test_a_refused_name_goes_back_and_is_asked_again(qtbot: Any) -> None: + bar, got = shown(qtbot) + counter = combo(bar, "counter") + edit = counter.lineEdit() + assert edit is not None + for _ in range(2): + got.clear() + counter.setEditText("fresh") + edit.editingFinished.emit() + assert got == [("e", "counter", "fresh")] + bar.show_detail(named()) # the document refused it: shown as it holds it + assert bar.controls["counter"].get_value() == "hits" + + +def test_an_accepted_name_stays_and_is_an_option(qtbot: Any) -> None: + bar, _ = shown(qtbot) + counter = combo(bar, "counter") + edit = counter.lineEdit() + assert edit is not None + + def take(i: str, name: str, value: object) -> None: + opts = [("armor", "armor"), ("fresh", "fresh"), ("hits", "hits")] + bar.show_detail(named(values={"counter": value}, options={"counter": opts})) + + bar.param_changed.connect(take) + counter.setEditText("fresh") + edit.editingFinished.emit() + assert bar.controls["counter"].get_value() == "fresh" + assert counter.currentIndex() == 1 and counter.count() == 3 + + +def test_new_names_keep_what_is_picked(qtbot: Any) -> None: + bar, _ = shown(qtbot) + counter = combo(bar, "counter") + counter.setEditText("half_typ") # not finished: not held + bar.show_detail(named(options={"counter": [("hits", "hits"), ("zeta", "zeta")]})) + assert bar.controls["counter"].get_value() == "hits" and counter.count() == 2 + + +def test_an_unknown_type_raises(qtbot: Any) -> None: + with pytest.raises(ValueError, match="unknown type 'number'"): + ParamControl(Param("x", "number", "X")) # type: ignore[arg-type] + + def test_play_is_off_with_its_reason_in_the_tip_and_comes_back(qtbot: Any) -> None: bar = PropertyBar() qtbot.addWidget(bar) diff --git a/apps/studio/tests/monster/ui/test_triggers.py b/apps/studio/tests/monster/ui/test_triggers.py index c9acda5..3a9b376 100644 --- a/apps/studio/tests/monster/ui/test_triggers.py +++ b/apps/studio/tests/monster/ui/test_triggers.py @@ -149,14 +149,20 @@ def test_the_outline_lists_all_then_every_trigger_with_its_count_and_range( def test_a_trigger_with_no_rules_is_greyed_and_dashed(panel: BehaviourPanel) -> None: rowsby = cells(panel) - for key in ("on_combat_entered", "on_combat_left", "on_tail_cut", "host_state"): - assert rowsby[key][1:] == ("—", "", "none"), key - assert [k for k, c in rowsby.items() if c[3] == "none"] == [ + empty = [ "on_combat_entered", "on_combat_left", "on_tail_cut", + "on_enraged", + "on_calmed", + "on_signal", + "any_time", + "idle", "host_state", ] + for key in empty: + assert rowsby[key][1:] == ("—", "", "none"), key + assert [k for k, c in rowsby.items() if c[3] == "none"] == empty assert rowsby["host_state"][0] == "In state" @@ -291,7 +297,7 @@ def test_a_rule_with_no_move_yet_ends_in_a_placeholder_after_the_others() -> Non def test_a_rule_that_plays_nothing_by_design_ends_at_its_last_block() -> None: - plan = rows.plan(trigger(Path(("b1", "b2"), "", None)), BLOCKS, NOTES, MOVES, {}) + plan = rows.plan(trigger(Path(("b1", "b2"), None, None)), BLOCKS, NOTES, MOVES, {}) assert [n.id for n in plan.rows[0].nodes] == ["b1", "b2"] and len(plan.links) == 1 assert plan.rows[0].rank == 1 @@ -409,6 +415,24 @@ def test_a_trigger_shows_only_its_rules_in_rank_order( assert ws.scope == ALL and "b8" in shown(panel) and panel.spread.isHidden() +def test_a_rule_that_plays_nothing_is_a_row_without_a_move( + qtbot: Any, panel: BehaviourPanel, ws: MonsterWorkspace +) -> None: + def count(m: Any) -> None: + b = m.behaviour.blocks + b["b9"] = Block("on_tail_cut", (260.0, 600.0), next=["b10"], label="count the cuts") + b["b10"] = Block("counter_add", (460.0, 600.0), {"counter": "cuts"}) + + doc(ws).edit(count) + ws.sync() + panel.sync() + pick(qtbot, panel, "on_tail_cut") + assert shown(panel) == {"b9", "b10"} + assert frames(panel) == [f"{rk(1)} · count the cuts"], "a rule that plays nothing ranks first" + assert {(w.src, w.dst) for w in panel.view._links} == {("b9", "b10")} + assert panel.title.text() == f"All › Tail cut — 1 rule, {rk(1)} of 7" + + def test_a_while_trigger_draws_its_move_once_wired_into_every_row( qtbot: Any, panel: BehaviourPanel ) -> None: @@ -540,11 +564,17 @@ def test_a_trigger_has_no_free_block_menu_and_all_has_it_back( menu = panel.view.viewer.context_menus()["graph"] assert menu.actions() == [], "the rows of All take no free blocks either" to_canvas(qtbot, panel) - assert len(menu.actions()) == 4 + assert len(menu.actions()) == 5 pick(qtbot, panel, "on_flinch") assert menu.actions() == [] pick(qtbot, panel, ALL) - assert [a.text() for a in menu.actions()] == ["Events", "State", "Conditions", "Modifiers"] + assert [a.text() for a in menu.actions()] == [ + "Events", + "State", + "Conditions", + "Modifiers", + "Effects", + ] def test_wiring_in_a_scope_is_refused_with_the_hint( @@ -774,7 +804,7 @@ def test_the_insert_menu_offers_a_rules_blocks_and_puts_one_after_the_clicked( after = entry(menu, "Insert after", "Conditions", "Hunter distance") assert names(menu, "Insert after", "Conditions")[:1] == ["Played for at least"] assert {a.text() for a in menu.actions()} == {"Insert after", "Delete the rule"} - assert names(menu, "Insert after") == ["Conditions", "Modifiers"] + assert names(menu, "Insert after") == ["Conditions", "Modifiers", "Effects"] assert entry(menu, "Delete the rule") is not None before = doc(ws).manifest after.trigger() @@ -1020,6 +1050,41 @@ def test_a_drop_that_cannot_change_the_rank_is_refused_and_says_why( assert doc(ws).manifest is before and "would not change the rule's rank" in ws.message +def test_a_drop_above_a_rule_that_plays_nothing_is_refused_and_says_why( + qtbot: Any, panel: BehaviourPanel, ws: MonsterWorkspace +) -> None: + def count(m: Any) -> None: + b = m.behaviour.blocks + b["b9"] = Block("on_flinch", (260.0, 600.0), next=["b10"], label="count") + b["b10"] = Block("counter_add", (460.0, 600.0), {"counter": "hits"}) + + doc(ws).edit(count) + ws.refresh() + panel.sync() + pick(qtbot, panel, "on_flinch") + assert frames(panel)[0] == f"{rk(1)} · count", "a rule that plays nothing is first" + before = doc(ws).manifest + drag_frame(qtbot, panel, "frame:4", "frame:1", below=False) + assert doc(ws).manifest is before and "rules that play nothing go first" in ws.message + + +def test_a_signals_new_rule_is_an_on_signal_block_of_that_name( + qtbot: Any, panel: BehaviourPanel, ws: MonsterWorkspace +) -> None: + doc(ws).edit( + lambda m: m.behaviour.blocks.__setitem__( + "b9", Block("on_signal", (260.0, 600.0), {"name": "roar"}, play=["howl"]) + ) + ) + ws.refresh() + panel.sync() + pick(qtbot, panel, "on_signal:roar") + panel.add_rule.click() + qtbot.wait(60) + made = blocks(ws)["b10"] + assert (made.kind, made.params) == ("on_signal", {"name": "roar"}) + + def test_a_rule_with_no_move_cannot_be_dragged( qtbot: Any, panel: BehaviourPanel, ws: MonsterWorkspace ) -> None: @@ -1144,6 +1209,23 @@ def test_in_order_draws_every_rule_in_rank_order_and_names_its_trigger( assert panel.title.text() == "All rules \u2014 6 in the order they are checked" +def test_in_order_puts_a_rule_that_plays_nothing_first_without_a_reference( + panel: BehaviourPanel, ws: MonsterWorkspace +) -> None: + def count(m: Any) -> None: + b = m.behaviour.blocks + b["b9"] = Block("on_tail_cut", (260.0, 600.0), next=["b10"], label="count the cuts") + b["b10"] = Block("counter_add", (460.0, 600.0), {"counter": "cuts"}) + + doc(ws).edit(count) + ws.refresh() + panel.sync() + assert frames(panel)[0] == f"{rk(1)} \u00b7 Tail cut \u00b7 count the cuts" + assert f"{rk(2)} \u00b7 while charge \u00b7 first" in frames(panel) + assert {"b9", "b10"} <= shown(panel) + assert {(w.src, w.dst) for w in panel.view._links if w.src == "b10"} == set() + + def test_a_while_rule_starts_from_a_dashed_reference_with_the_count( panel: BehaviourPanel, ) -> None: diff --git a/framework/README.md b/framework/README.md index dd171f7..77278a8 100644 --- a/framework/README.md +++ b/framework/README.md @@ -119,11 +119,12 @@ and the combat entry; the call's `force` starts it at once (`mhfu.move_play(ent, The same AI step finds what a mod may answer, one AI frame after the engine's change at most, raised on the 5 Hz registry poll (`include/mhfu/monster_events.h`): `mhfu.on_bigmonster_noticed`, `_combat_entered` and `_combat_left` (the yellow eye beside the player's name), `_flinch` (with -the part), `_part_broken` and `_tail_cut`, each `fn(ev)` with the entity, the AI frame, the pair -and the part. `mhfu.move_react("flinch", ent, { entry = 78 })` plays a move in place of the -monster's flinch: the engine counts the flinch and applies the damage, then enters the move's -carrier instead of `(4,x)`, and the move plays from the next AI step. From the debugger: -`mhfu events --follow 60`. +the part), `_part_broken` (once per part, when its flinch count reaches its species' break row, +so two parts sharing a break id both raise it), `_tail_cut`, `_enraged` and `_calmed` +(ENTITY.FLAGS bit 0x20), each `fn(ev)` with the entity, the AI frame, the pair and the part. +`mhfu.move_react("flinch", ent, { entry = 78 })` plays a move in place of the monster's flinch: +the engine counts the flinch and applies the damage, then enters the move's carrier instead of +`(4,x)`, and the move plays from the next AI step. From the debugger: `mhfu events --follow 60`. ## Own moves from a manifest @@ -146,6 +147,20 @@ waits for its drop, on a model with the mesh 1 the dropped tail draws, requests, wait with it, and the events seen meanwhile reach them after it. `ports/README.md` "Moves and behaviour" has the manifest's side. +A rule also takes `conds` that must all hold (`{ "hp_below", 0, 30 }`: op, arg, value; the ops are +`mhfu.EmCond`: HP percent, broken parts, enraged or calm, the hunter's side of the monster, a +chance rolled last, a counter against a number), `effects` it applies each time it fires +(`var_add`, `var_set` on a counter), a `signal` that fires it in place of `on`, and +`no_play = true`: it plays nothing and applies its effects. Each AI frame the rules that play +nothing run first, every one that holds wherever it sits among the others, so a counter counts the +frame's events whatever plays; then the first rule that plays and holds fires, seeing the +counters as they stand. The counters (`mhfu.em_var`), signals (`mhfu.em_signal`) and the parts broken so +far are the monster's board, reset with a new monster; a port names them in its moves module's +`vars` and `signals` (`port:var`, `port:fire`). The board holds +`mhfu.addr.EM_BOARD.VARS_COUNT` counters and `SIGNALS_COUNT` signals, a rule +`mhfu.addr.EM_RULE.CONDS_COUNT` conditions and `EFFECTS_COUNT` effects. The partitions em_vhook +takes grow with the rules and moves; its log lines give their sizes. + ## Calling the game from C C calls a game function through `mhfu_call(fn, a0, ...)` (`mhfu/call.h`, word arguments only). @@ -160,7 +175,7 @@ declared API; it needs a host `c++` (else those tests skip) and `lupa`, which `u ## Status -Runs in PPSSPP and on a real PSP under PRO CFW, for MHFU EU only. The Lua API is version 3 +Runs in PPSSPP and on a real PSP under PRO CFW, for MHFU EU only. The Lua API is version 4 (`mhfu.api_version`); `mhfu.clone_combat` is experimental and can crash the game. ## Licence diff --git a/framework/include/mhfu/em_vhook.h b/framework/include/mhfu/em_vhook.h index 58343dd..8e9ce29 100644 --- a/framework/include/mhfu/em_vhook.h +++ b/framework/include/mhfu/em_vhook.h @@ -9,9 +9,12 @@ * request a pair entered on the game thread in the next AI frame, through * MHFU_ENTER_ACTION; * rules a 30 Hz brain in C: "in pair P (or own move M) for >= N frames, or on a - * monster event, player distance in [lo, hi), receding or closing -> enter - * (main, sub, mode) or play own move M'", with cooldown and budget; a rule on the - * flinch plays its move in place of the host's reaction; + * monster event or a Lua signal, player distance in [lo, hi), receding or + * closing, every condition holding -> enter (main, sub, mode) or play own move + * M', and change counters", with cooldown and budget; a rule on the flinch plays + * its move in place of the host's reaction; + * board the counters, signals and broken parts the rules and Lua share for the + * wrapped species' monster (mhfu_em_var, mhfu_em_signal); * own moves a port's moves by slot, played by the move player (mhfu/move.h) when asked, * by a rule, or after the move before them; * step a C function on every AI frame, on the game thread, before the host step; @@ -35,9 +38,13 @@ extern "C" { #endif -#define MHFU_EM_SUBS 4 /* substitution entries */ -#define MHFU_EM_RULES MHFU_EM_CFG_RULES_COUNT /* brain rules */ -#define MHFU_EM_RING 8 /* last enter-action dispatches kept */ +#define MHFU_EM_SUBS 4 /* substitution entries */ +#define MHFU_EM_RULES MHFU_EM_CFG_RULES_COUNT /* brain rules */ +#define MHFU_EM_CONDS MHFU_EM_RULE_CONDS_COUNT /* conditions a rule holds */ +#define MHFU_EM_EFFECTS MHFU_EM_RULE_EFFECTS_COUNT /* effects a rule applies */ +#define MHFU_EM_VARS MHFU_EM_BOARD_VARS_COUNT /* counters on the board */ +#define MHFU_EM_SIGNALS MHFU_EM_BOARD_SIGNALS_COUNT /* signals Lua raises */ +#define MHFU_EM_RING 8 /* last enter-action dispatches kept */ #define MHFU_EM_SUB_ANY 0xFEu /* any sub state; 0xFF is the never-matching idle value */ #define MHFU_EM_ANY_PART 0xFFu /* mhfu_em_rule_t.part: any part */ @@ -46,6 +53,15 @@ extern "C" { /* mhfu_em_rule_t.flags */ #define MHFU_EM_RULE_RECEDING 0x01 /* only while the distance grows */ #define MHFU_EM_RULE_CLOSING 0x02 /* only while the distance shrinks */ +#define MHFU_EM_RULE_NO_PLAY 0x04 /* plays nothing: applies its effects, before the rules that play */ + +/* struct EM_OP: a condition or effect. op is MHFU_EM_COND_* or MHFU_EM_EFFECT_* (index + 1), 0 + * none; arg a part (broken), a side mask (side) or a board var; value a percent or a number. */ +typedef struct { + uint8_t op; + uint8_t arg; + int16_t value; +} mhfu_em_op_t; typedef struct { uint8_t from_mask; /* bit k set: may fire from main state k */ @@ -64,7 +80,9 @@ typedef struct { * in place of the host's reaction; 0 none */ uint8_t part; /* FLINCH, PART_BROKEN: only that part's; MHFU_EM_ANY_PART */ uint8_t force; /* 1: play_move starts while the monster's notice runs (MOVE.FORCE) */ - uint8_t _pad; + uint8_t signal; /* board signal + 1 (mhfu_em_signal) that fires it, like `on`; 0 none */ + mhfu_em_op_t conds[MHFU_EM_CONDS]; /* all must hold; a chance is rolled last */ + mhfu_em_op_t effects[MHFU_EM_EFFECTS]; /* applied each time it fires */ } mhfu_em_rule_t; typedef struct { @@ -83,6 +101,8 @@ typedef struct { uint32_t tip_pairs; /* the tail tip's pairs in force (mhfu_em_tip) */ uint32_t tip_copies; /* frames the tip was posed */ uint32_t cut_waits; /* AI frames the cut held what we start */ + int32_t vars[MHFU_EM_VARS]; /* the board's counters */ + uint32_t broken; /* the board's BROKEN: bit k, part k broke */ } mhfu_em_status_t; /* 1 while a big monster's vtable is wrapped (from its spawn to the next quest_beginning). */ @@ -96,10 +116,15 @@ void mhfu_em_substitute(int slot, uint8_t from_mask, uint8_t from_sub, /* Enter (main, sub, mode) on the next AI frame; 0 while nothing is wrapped. */ int mhfu_em_request(uint8_t main_state, uint8_t sub_state, uint8_t mode); -/* Rule slot (0..MHFU_EM_RULES-1); NULL clears it. At most one rule fires an AI frame. While a - * rule on the flinch is installed, the brain owns the reaction replacement (mhfu_move_react): - * a flinch of a part a rule takes, in a frame its gates hold, enters its move's carrier instead, - * and the move plays from the next AI frame (all such rules share the first's carrier). */ +/* Rule slot (0..MHFU_EM_RULES-1); NULL clears it, and so does a rule that cannot hold: no trigger + * (from_*, on or signal), an unknown op, a board index out of range. Each AI frame, whatever else + * it does, first every rule with MHFU_EM_RULE_NO_PLAY that holds applies its effects, wherever it + * sits in slot order; then at most one rule that plays fires, the first in slot order that holds, + * seeing the counters as that left them. While a rule on the flinch that plays is + * installed, the brain owns the reaction replacement (mhfu_move_react): a flinch of a part a rule + * takes, in a frame its gates and conditions hold, enters its move's carrier instead, and the + * move plays from the next AI frame (all such rules share the first's carrier). A no-play rule on + * the flinch never replaces it. A rule has one trigger of `on` and `signal`. */ void mhfu_em_rule(int slot, const mhfu_em_rule_t *r); /* fn(entity) runs at each AI step of the wrapped species, on the game thread, before the host @@ -134,8 +159,8 @@ int mhfu_em_tip(const uint8_t (*pairs)[2], int n); * one of ours that ended on its clip, its length or a wall (forced as that one was), else a * rule's. A slot with an AFTER ends into its carrier (its back pair), so the host brain does not * cut in before the next. A slot's FORCE is the call's, never the slot's. */ -#define MHFU_EM_MOVES 16 -#define MHFU_EM_KEYS 2048 +#define MHFU_EM_MOVES MHFU_EM_MOVES_MOVES_COUNT +#define MHFU_EM_KEYS MHFU_EM_MOVES_KEYS_COUNT #define MHFU_EM_NO_MOVE 0xFFu /* struct EM_OWN in addresses.toml */ @@ -175,6 +200,33 @@ int mhfu_em_playing(void); /* The registry, or 0 before the framework's init. */ const volatile mhfu_em_moves_t *mhfu_em_moves(void); +/* --- the board --------------------------------------------------------------------------------- + * Counters and signals the rules and Lua share for the wrapped species' monster. It resets when a + * monster of the species spawns (so a spawn handler may set it) and with the quest, and the first + * monster to take an AI step owns an unclaimed one: counters 0, signals 0, broken parts 0, the + * chance's generator reseeded. Two monsters of the species share it, as they share the dwell. + * Rules change counters (effects), test them (var_* conditions) and fire on a signal; Lua reads and + * writes the counters and raises signals. A signal an AI frame takes is cleared; during the tail + * cut's wait it is held, like the events. Any thread. */ +typedef struct { + int16_t vars[MHFU_EM_VARS]; + uint8_t signals[MHFU_EM_SIGNALS]; /* 1: raised, not yet taken */ + uint8_t broken; /* bit k: part k broke on this monster */ + uint8_t _pad[3]; + uint32_t rng; /* xorshift32 state for chance */ + uint32_t entity; /* the monster it belongs to */ + uint32_t _spare; +} mhfu_em_board_t; + +/* Counter k's value; 0 for a bad k. */ +int mhfu_em_var(int k); +/* Counter k = v, cut to a s16; nothing for a bad k. */ +void mhfu_em_set_var(int k, int v); +/* Raises signal k; 1, or 0 for a bad k. */ +int mhfu_em_signal(int k); +/* The board, or 0 before the framework's init. */ +const volatile mhfu_em_board_t *mhfu_em_board(void); + /* The reaction replacement, one entry, the move player's (mhfu_move_react): an enter-action of * entity with main_state and a sub in sub_mask (bit k = sub k), in an AI frame where the entity's * byte at offset gate shares a bit with parts, enters (to_main, to_sub) instead, unless a diff --git a/framework/include/mhfu/events.h b/framework/include/mhfu/events.h index ab7f16a..a0b26e6 100644 --- a/framework/include/mhfu/events.h +++ b/framework/include/mhfu/events.h @@ -38,6 +38,8 @@ typedef enum { MHFU_EVENT_BIGMONSTER_FLINCH, MHFU_EVENT_BIGMONSTER_PART_BROKEN, MHFU_EVENT_BIGMONSTER_TAIL_CUT, + MHFU_EVENT_BIGMONSTER_ENRAGED, + MHFU_EVENT_BIGMONSTER_CALMED, MHFU_EVENT_COUNT_ } mhfu_event_id_t; diff --git a/framework/include/mhfu/monster_events.h b/framework/include/mhfu/monster_events.h index e5467b1..33d1b83 100644 --- a/framework/include/mhfu/monster_events.h +++ b/framework/include/mhfu/monster_events.h @@ -10,8 +10,13 @@ * COMBAT_ENTERED the player's yellow eye comes on for this monster: COMBAT_MODE 1, the * COMBAT_LEFT player's AWARE bit, the same section, FLAGS & 0x8, alive (EYE_UPDATE); and off * FLINCH parts flinched (ENTITY.FLINCH_MASK); the pair is the reaction entered - * PART_BROKEN ENTITY.BROKEN gains bits (BREAK_RECORD) + * PART_BROKEN a flinched part's FLINCH_PART.COUNT reaches its species' BREAK_TABLE row's, + * once per part (PART_BREAK's rule; two parts may share a BREAK_ID, so + * ENTITY.BROKEN gains no bit for the second); also ENTITY.BROKEN gaining a bit + * no part's row explains * TAIL_CUT ENTITY.SEVERED bit 0 rises + * ENRAGED ENTITY.FLAGS bit 0x20 rises: the monster enrages + * CALMED and falls: it calms down * * A monster first seen sets the baseline and raises nothing. */ #ifndef MHFU_MONSTER_EVENTS_H @@ -35,7 +40,13 @@ enum { MHFU_MONSTER_FLINCH = MHFU_MONSTER_EVENT_KIND_FLINCH, MHFU_MONSTER_PART_BROKEN = MHFU_MONSTER_EVENT_KIND_PART_BROKEN, MHFU_MONSTER_TAIL_CUT = MHFU_MONSTER_EVENT_KIND_TAIL_CUT, + MHFU_MONSTER_ENRAGED = MHFU_MONSTER_EVENT_KIND_ENRAGED, + MHFU_MONSTER_CALMED = MHFU_MONSTER_EVENT_KIND_CALMED, }; +#define MHFU_MONSTER_KINDS MHFU_MONSTER_EVENT_KIND_COUNT + +/* ENTITY.FLAGS: the monster is enraged (the ENRAGED and CALMED events, the rules' conditions) */ +#define MHFU_MONSTER_FLAG_ENRAGED 0x20u /* the kinds' names, MHFU_MONSTER_NOTICED's first, then NULL: Lua's and a manifest's `on` */ extern const char *const mhfu_monster_event_names[]; @@ -61,7 +72,7 @@ typedef struct { typedef struct { uint32_t entity; uint32_t frames; /* AI frames seen */ - uint8_t aware, combat, severed, _pad; + uint8_t aware, combat, severed, enraged; uint16_t broken; uint16_t edges; /* bit kind: what this frame raised */ } mhfu_monster_watch_t; @@ -84,7 +95,7 @@ typedef struct { typedef void (*mhfu_monster_event_cb_t)(const mhfu_monster_event_ctx_t *ctx); -/* id is one of MHFU_EVENT_BIGMONSTER_NOTICED .. _TAIL_CUT; callbacks run on the poll thread. */ +/* id is one of MHFU_EVENT_BIGMONSTER_NOTICED .. _CALMED; callbacks run on the poll thread. */ static inline mhfu_hook_rc_t mhfu_on_monster_event(mhfu_event_id_t id, mhfu_monster_event_cb_t cb, int priority, const char *owner) { return mhfu_event_on(id, (mhfu_event_fn_t)cb, priority, owner); @@ -108,8 +119,9 @@ int mhfu_monster_state(uint32_t entity, mhfu_monster_state_t *out); /* The brain's call, each AI frame of entity before the host step; stale: the host step did not * run in the frame before, so the per-frame cells are left over from an earlier one. Returns - * what this frame raised (bit kind) and the parts that flinched in *parts. */ -uint16_t mhfu_monster_events_frame(uint32_t entity, int stale, uint8_t *parts); + * what this frame raised (bit kind), the parts that flinched in *parts and the parts that broke + * in *broke (either may be NULL). */ +uint16_t mhfu_monster_events_frame(uint32_t entity, int stale, uint8_t *parts, uint8_t *broke); /* Raises what the step found, oldest first; the registry poll calls it. */ void mhfu_monster_events_drain(void); diff --git a/framework/include/mhfu/move.h b/framework/include/mhfu/move.h index 16ba8c8..99a2337 100644 --- a/framework/include/mhfu/move.h +++ b/framework/include/mhfu/move.h @@ -106,6 +106,10 @@ typedef struct { uint32_t events; /* the monster-event block (MONSTER_EVENTS) */ } mhfu_move_state_t; +/* em75's alert hub, the carrier a move rides unless it names one (moves.HUBS in mhfu-port) */ +#define MHFU_MOVE_HUB_MAIN 0 +#define MHFU_MOVE_HUB_SUB 2 + /* entry on the carrier (0,2), em75's alert hub (one dispatch, then the brain once the clip * ends; (0,1) becomes (0,2) under ENTITY+0x4B9): no back pair, no attacks, timed on part 0 */ void mhfu_move_init_spec(mhfu_move_t *mv, uint16_t entry); diff --git a/framework/lua/lib/mhfu_port.lua b/framework/lua/lib/mhfu_port.lua index 849414a..3b44ed8 100644 --- a/framework/lua/lib/mhfu_port.lua +++ b/framework/lua/lib/mhfu_port.lua @@ -14,7 +14,8 @@ -- and port:play("charge") enters the pair and latches the port's clip in one call. A move with an -- executor `entry` and no pair is the port's own: port:move("stamp") plays it through the -- framework's move player (mhfu.em_move). A port's moves and rules default to its manifest's, --- from _moves.lua (P.behaviour). +-- from _moves.lua (P.behaviour), whose `vars` and `signals` name the monster's board: +-- port:var("kills") reads a counter the rules change, port:fire("rage") raises a signal. -- -- A mod requires the library at its top level and registers a setup function, which runs at once -- and again after a library reload; registering the same name again replaces it: @@ -118,6 +119,9 @@ local EM_UNLIMITED = mhfu.EM_UNLIMITED or -1 local MAX_SUBS = 4 local MAX_RULES = mhfu.addr.EM_CFG.RULES_COUNT -- rules the seam holds local MAX_OWN = mhfu.addr.EM_MOVES.MOVES_COUNT -- own moves the framework's registry holds +local MAX_CONDS = mhfu.addr.EM_RULE.CONDS_COUNT -- conditions and effects a rule holds +local MAX_EFFECTS = mhfu.addr.EM_RULE.EFFECTS_COUNT +local MAX_SIGNALS = mhfu.addr.EM_BOARD.SIGNALS_COUNT -- the board's signals local function native_ready() return mhfu.em_installed ~= nil and mhfu.em_installed() == true @@ -200,8 +204,8 @@ function P.layout(name) end --- A port's moves and rules from mods/lib/_moves.lua, which `mhfu-port inject` generates ---- from the manifest with the clips module: { moves = {...}, rules = {...} }. Empty, logged ---- once, without it. +--- from the manifest with the clips module: { moves = {...}, rules = {...}, vars = {name = index}, +--- signals = {name = index} }. Empty, logged once, without it. function P.behaviour(name) local ok, t = pcall(require, name .. "_moves") if ok and type(t) == "table" then return t end @@ -233,6 +237,8 @@ P.is_own = is_own -- attack rides the host's whole attack timing. A move with `entry` and no `main` is -- an own move (port:move). Default: the manifest's (P.behaviour) -- rules port:rule{} specs installed with the port; default: the manifest's +-- vars, signals name -> index of the board's counters and signals (port:var, port:fire); default: +-- the manifest's -- tip { {joint, carrier}, ... }: the tail tip's joints follow the carriers until the tail -- drops (mhfu.em_tip); default: the manifest's -- @@ -246,6 +252,8 @@ function P.define(spec) clips = spec.clips or P.layout(spec.name), moves = spec.moves or made.moves or {}, tip = spec.tip or made.tip, + _vars = spec.vars or made.vars or {}, -- board counter name -> index + _signals = spec.signals or made.signals or {}, -- board signal name -> index replace = spec.replace or {}, ent = 0, clip = nil, -- currently latched executor a1, nil = hands off @@ -477,14 +485,21 @@ end --- --- `from` an own move fires while it plays, its AI frames the dwell; a rule from a pair waits --- while any own move plays. Fires at most once per entry into `from` (the pair changes when it ---- fires), then `cooldown` frames must pass; at most one rule fires an AI frame. `on = ""` ---- (a monster event: "noticed", "combat_entered", "combat_left", "flinch", "part_broken", ---- "tail_cut") fires in the AI frame the event is seen instead, `from` still gating when given; +--- fires), then `cooldown` frames must pass; at most one rule that plays fires an AI frame. `on = +--- ""` (a monster event, mhfu.MonsterEventKind) fires in the AI frame the event is seen +--- instead, `from` still gating when given; --- `part` keeps a flinch or break to that part. `on = "flinch"` plays its own move in place of --- the host's reaction. `force` plays an own move while the monster's notice runs, which would ---- otherwise wait for combat (a rule on "noticed" needs it). Up to mhfu.addr.EM_CFG.RULES_COUNT rules per port, installed when the ---- seam is live. Without the seam the rule is inert and logged as such: the 2 Hz brain is the ---- fallback. +--- otherwise wait for combat (a rule on "noticed" needs it). Up to mhfu.addr.EM_CFG.RULES_COUNT +--- rules per port, installed when the seam is live. Without the seam the rule is inert and logged +--- as such: the 2 Hz brain is the fallback. +--- +--- A rule also takes conditions that must all hold (`conds = { {"hp_below", 0, 30}, ... }`: op, +--- arg, value; mhfu.EmCond), effects it applies each time it fires (`effects = { {"var_add", 2, 1} }`), +--- a `signal` (0-based index) that fires it in place of `on`, and `no_play = true`: it plays +--- nothing (no `play`) and applies its effects: each AI frame all such rules that hold run first, +--- wherever they sit, then the first rule that plays. The numbers are board indices, which the +--- moves module's `vars` and `signals` give names. function Port:rule(spec) if #self._rules >= MAX_RULES then log("[port:%s] rule ignored: the seam holds %d", self.name, MAX_RULES) @@ -495,18 +510,31 @@ function Port:rule(spec) log("[port:%s] rule: no such move '%s'", self.name, from) return self end - if not self.moves[spec.play or ""] then + local no_play = spec.no_play and true or false + if not no_play and not self.moves[spec.play or ""] then log("[port:%s] rule: play='%s' is not a declared move", self.name, tostring(spec.play)) return self end + local label = no_play and "(no play)" or spec.play local fm = spec.from_main if type(fm) == "number" then fm = { fm } end - if from == nil and #(fm or {}) == 0 and spec.on == nil then - log("[port:%s] rule -> '%s': no `from` or `on`, ignored", self.name, spec.play) + if from == nil and #(fm or {}) == 0 and spec.on == nil and spec.signal == nil then + log("[port:%s] rule -> '%s': no `from`, `on` or `signal`, ignored", self.name, label) + return self + end + if #(spec.conds or {}) > MAX_CONDS or #(spec.effects or {}) > MAX_EFFECTS then + log("[port:%s] rule -> '%s': the seam holds %d conditions and %d effects a rule, ignored", + self.name, label, MAX_CONDS, MAX_EFFECTS) + return self + end + if spec.signal ~= nil and (spec.signal < 0 or spec.signal >= MAX_SIGNALS) then + log("[port:%s] rule -> '%s': signal %s is not 0..%d, ignored", self.name, label, + tostring(spec.signal), MAX_SIGNALS - 1) return self end self._rules[#self._rules + 1] = { - from = from, from_main = fm or {}, play = spec.play, label = spec.label, + from = from, from_main = fm or {}, play = not no_play and spec.play or nil, label = spec.label, + no_play = no_play, conds = spec.conds, effects = spec.effects, signal = spec.signal, on = spec.on, part = spec.part, force = spec.force and true or false, min_frames = spec.min_frames or 0, dist_lo = spec.dist and spec.dist[1] or 0, dist_hi = spec.dist and spec.dist[2] or 1.0e9, @@ -522,7 +550,8 @@ function Port:_seam_rule(r) local t = { from_mask = 0, from_sub = EM_ANY, mode = r.mode, min_frames = r.min_frames, dist_lo = r.dist_lo, dist_hi = r.dist_hi, receding = r.receding, closing = r.closing, cooldown = r.cooldown, count = r.count, on = r.on, - part = r.part, force = r.force } + part = r.part, force = r.force, conds = r.conds, effects = r.effects, + signal = r.signal, no_play = r.no_play } local from = r.from if type(from) == "string" then local mv = self.moves[from] @@ -532,16 +561,45 @@ function Port:_seam_rule(r) t.from_mask, t.from_sub = 1 << (from.main or 0), from.sub or EM_ANY end for _, m in ipairs(r.from_main) do t.from_mask = t.from_mask | (1 << m) end + if t.from_mask == 0 and t.from_move == nil and r.on == nil and r.signal == nil then return nil end + if r.no_play then return t end local to = self.moves[r.play] if is_own(to) then t.play_move = self._slot[r.play] else t.to_main, t.to_sub = to.main, to.sub end - if (t.from_mask == 0 and t.from_move == nil and r.on == nil) - or (is_own(to) and t.play_move == nil) or (r.on == "flinch" and not is_own(to)) then + if (is_own(to) and t.play_move == nil) or (r.on == "flinch" and not is_own(to)) then return nil end return t end +--- Counter `name` of the monster's board, which the moves module's `vars` declares: its value, +--- set to `v` first when given. 0, logged once, for a name it does not declare. +function Port:var(name, v) + local k = self._vars[name] + if k == nil or mhfu.em_var == nil then + if k == nil and not P._once["var:" .. self.name .. ":" .. tostring(name)] then + P._once["var:" .. self.name .. ":" .. tostring(name)] = true + log("[port:%s] var('%s'): not in the moves module's vars", self.name, tostring(name)) + end + return 0 + end + return mhfu.em_var(k, v) +end + +--- Raises signal `name`, which the moves module's `signals` declares: the rules on it fire in the +--- monster's next AI frame. False, logged once, for a name it does not declare. +function Port:fire(name) + local k = self._signals[name] + if k == nil or mhfu.em_signal == nil then + if k == nil and not P._once["signal:" .. self.name .. ":" .. tostring(name)] then + P._once["signal:" .. self.name .. ":" .. tostring(name)] = true + log("[port:%s] fire('%s'): not in the moves module's signals", self.name, tostring(name)) + end + return false + end + return mhfu.em_signal(k) == true +end + --- An own move's em_move table: its `after` as a slot, or as the back pair of a pair move. function Port:_seam_move(name) local mv = self.moves[name] @@ -595,16 +653,21 @@ function Port:_arm_native() local t = r and self:_seam_rule(r) if t then mhfu.em_rule(i - 1, t) - log("[port:%s] rule %d: %s%s >=%d frames d[%d,%s)%s%s -> '%s'%s", self.name, i, + log("[port:%s] rule %d: %s%s >=%d frames d[%d,%s)%s%s%s -> %s%s", self.name, i, r.on and string.format("on %s%s, ", r.on, r.part and (" part " .. r.part) or "") or "", type(r.from) == "string" and ("'" .. r.from .. "'") or string.format("main 0x%02X", t.from_mask), r.min_frames, math.floor(r.dist_lo), r.dist_hi >= 1e9 and "inf" or tostring(math.floor(r.dist_hi)), - r.receding and " receding" or "", r.closing and " closing" or "", r.play, + r.receding and " receding" or "", r.closing and " closing" or "", + r.signal and (" signal " .. r.signal) or "", + r.no_play and "no play" or ("'" .. r.play .. "'"), r.label and (" (" .. r.label .. ")") or "") else - if r then log("[port:%s] rule %d -> '%s' cannot be installed", self.name, i, r.play) end + if r then + log("[port:%s] rule %d -> %s cannot be installed", self.name, i, + r.no_play and "no play" or ("'" .. r.play .. "'")) + end mhfu.em_rule(i - 1, nil) end end diff --git a/framework/lua/meta/mhfu.d.lua b/framework/lua/meta/mhfu.d.lua index 1bb1da4..8f7b199 100644 --- a/framework/lua/meta/mhfu.d.lua +++ b/framework/lua/meta/mhfu.d.lua @@ -11,7 +11,7 @@ mhfu = {} ---The API's version, raised whenever a declaration here changes; a mod that needs something added ---in version N checks `mhfu.api_version >= N`. -mhfu.api_version = 3 +mhfu.api_version = 4 -- log, memory and world state (bind_memory.cpp) ---------------------------------------------- @@ -397,7 +397,8 @@ function mhfu.em_request(main, sub, mode) end function mhfu.em_substitute(slot, from_mask, from_sub, to_main, to_sub, count) end ---Rule slot 0..mhfu.addr.EM_CFG.RULES_COUNT-1: a brain rule the AI step checks every frame (30 Hz), ----with no Lua in the loop. +---with no Lua in the loop. A rule that cannot hold (no trigger, an op or a board index it does not +---know) is cleared and logged. ---@param slot integer ---@param rule mhfu.EmRule? nil clears the slot ---@return true @@ -460,6 +461,8 @@ mhfu.EM_UNLIMITED = -1 -- MHFU_EM_UNLIMITED ---@field tip_pairs integer the tail tip's pairs in force (`mhfu.em_tip`) ---@field tip_copies integer frames the tip was posed ---@field cut_waits integer AI frames the tail cut held requests, plays and rules for its drop +---@field vars integer[] the board's counters, by index + 1 +---@field broken integer the board's broken parts, bit k: part k ---@field req_pending integer 1 while a request waits for its AI frame ---@field req_done integer requests entered ---@field req_main integer the last request's pair @@ -704,7 +707,7 @@ function mhfu.move_block() end -- The em_vhook brain plays one, in C on the AI step, when asked (em_play), when the move before -- it ends and names it (`after`), or when a rule says (EmRule.play_move). ----Own move slot 0..15 plays `spec`; `spec.after` is the slot played when it ends on its clip, its +---Own move slot 0..mhfu.addr.EM_MOVES.MOVES_COUNT-1 plays `spec`; `spec.after` is the slot played when it ends on its clip, its ---length or a wall. False for a bad slot or no room left for its turn keys. ---@param slot integer ---@param spec mhfu.OwnMove @@ -780,8 +783,8 @@ function mhfu.on_bigmonster_combat_left(fn, priority) end ---@param priority? integer function mhfu.on_bigmonster_flinch(fn, priority) end ----Calls fn when a part of a big monster breaks; ev.part is the flinching part that broke, ev.data ----the new ENTITY.BROKEN bits. +---Calls fn when a part of a big monster breaks, once per part: ev.part is the part, ev.data its +---BREAK_ID bit in ENTITY.BROKEN (two parts may share one, so the second adds no new bit there). ---@param fn fun(ev: mhfu.MonsterEvent) ---@param priority? integer function mhfu.on_bigmonster_part_broken(fn, priority) end @@ -791,6 +794,19 @@ function mhfu.on_bigmonster_part_broken(fn, priority) end ---@param priority? integer function mhfu.on_bigmonster_tail_cut(fn, priority) end +---Calls fn when a big monster enrages (ENTITY.FLAGS bit 0x20 rises). +---@param fn fun(ev: mhfu.MonsterEvent) +---@param priority? integer +function mhfu.on_bigmonster_enraged(fn, priority) end + +---Calls fn when a big monster calms down (ENTITY.FLAGS bit 0x20 falls). +---@param fn fun(ev: mhfu.MonsterEvent) +---@param priority? integer +function mhfu.on_bigmonster_calmed(fn, priority) end + +---The monster events, as a brain rule's `on` and `mhfu.MonsterEvent.kind` name them. +---@alias mhfu.MonsterEventKind "noticed"|"combat_entered"|"combat_left"|"flinch"|"part_broken"|"tail_cut"|"enraged"|"calmed" + ---The monster-event block (struct MONSTER_EVENTS), for a debugger; 0 before the framework's init. ---@return integer function mhfu.monster_events_block() end @@ -798,14 +814,14 @@ function mhfu.monster_events_block() end ---One monster event. ---@class mhfu.MonsterEvent ---@field entity integer ----@field kind "noticed"|"combat_entered"|"combat_left"|"flinch"|"part_broken"|"tail_cut" +---@field kind mhfu.MonsterEventKind ---@field frame integer the monster's AI frame the change was seen in ---@field usec integer the emulated clock then ---@field delay integer microseconds from then to this call ---@field main integer the monster's pair then ---@field sub integer ----@field data integer flinch: the flinched parts; part_broken: the new broken bits; noticed: the aware bits; combat: 1 entered, 0 left; tail_cut: the cut count ----@field part? integer flinch, part_broken: the lowest flinched part +---@field data integer flinch: the flinched parts; part_broken: the part's BREAK_ID bit, as ENTITY.BROKEN has it; noticed: the aware bits; combat: 1 entered, 0 left; tail_cut: the cut count; enraged, calmed: 1 and 0 +---@field part? integer flinch: the lowest flinched part; part_broken: the part that broke ---A big monster now, read from its cells (the monster events' own reads): whether it noticed the ---player, is in combat, runs its notice; nil for no entity. Any thread. @@ -825,12 +841,41 @@ function mhfu.monster_state(ent) end ---@field flinched integer ENTITY.FLINCH_MASK: the parts that flinched this AI frame ---@field broken integer ENTITY.BROKEN --- brain rules on monster events (bind_em.cpp) ---------------------------------------------------- +-- brain rules on monster events, with conditions, effects and signals (bind_em.cpp) ------------- + +---A brain rule's condition: hp_at_least, hp_below (value: percent of max HP), broken, not_broken +---(arg: part), enraged, calm, side (arg: sides, 1 front, 2 the monster's left, 4 right, 8 +---behind), chance (value: percent), var_at_least, var_below, var_equal (arg: counter, value). +---@alias mhfu.EmCond "hp_at_least"|"hp_below"|"broken"|"not_broken"|"enraged"|"calm"|"side"|"chance"|"var_at_least"|"var_below"|"var_equal" + +---What a brain rule changes when it fires: var_add (saturating), var_set; arg: counter, value. +---@alias mhfu.EmEffect "var_add"|"var_set" + +---`{ op, arg, value }`, arg and value 0 when left out. +---@alias mhfu.EmCondOp [mhfu.EmCond, integer?, integer?] +---@alias mhfu.EmEffectOp [mhfu.EmEffect, integer?, integer?] ---@class mhfu.EmRule ----@field on? "noticed"|"combat_entered"|"combat_left"|"flinch"|"part_broken"|"tail_cut" fires in the AI frame the event is seen, `from_*` and the distance still gating; "flinch" plays its own move (`play_move`) in place of the host's reaction +---@field on? mhfu.MonsterEventKind fires in the AI frame the event is seen, `from_*` and the distance still gating; "flinch" plays its own move (`play_move`) in place of the host's reaction ---@field part? integer "flinch", "part_broken": only that part's (an ENTITY.FLINCH_MASK bit); default any ---@field force? boolean `play_move` starts while the monster's notice runs (a rule on "noticed" needs it) +---@field signal? integer fires in the AI frame the board's signal of this 0-based index (`mhfu.em_signal`) is taken; not with `on` +---@field conds? mhfu.EmCondOp[] all must hold, at most mhfu.addr.EM_RULE.CONDS_COUNT; a chance is rolled last +---@field effects? mhfu.EmEffectOp[] applied each time it fires, at most mhfu.addr.EM_RULE.EFFECTS_COUNT +---@field no_play? boolean plays nothing: applies its effects and counts the fire, in every AI frame before the rules that play are tried and wherever it sits among them; with `on = "flinch"` it never replaces the reaction + +---Counter k of the board the rules and Lua share for the wrapped species' monster (16 counters, +---s16, 0 for a bad k); with v, sets it first. A new monster resets the board, from its spawn on. +---@param k integer 0-based +---@param v? integer +---@return integer +function mhfu.em_var(k, v) end + +---Raises board signal k (0-based): a rule with `signal = k` fires in the next AI frame, once. False +---for a bad k. +---@param k integer +---@return boolean +function mhfu.em_signal(k) end -- village NPCs of our own (bind_npc.cpp) ------------------------------------------------------ diff --git a/framework/mods/lua_host/bind_em.cpp b/framework/mods/lua_host/bind_em.cpp index 83de40d..2059083 100644 --- a/framework/mods/lua_host/bind_em.cpp +++ b/framework/mods/lua_host/bind_em.cpp @@ -50,19 +50,50 @@ static int tbl_bool(lua_State *L, int idx, const char *k) lua_pop(L, 1); return v; } +/* the op names in addresses.toml's order: luaL_checkoption's index + 1 is the op */ +static const char *const k_conds[] = { MHFU_EM_COND_NAMES, 0 }; +static const char *const k_effects[] = { MHFU_EM_EFFECT_NAMES, 0 }; + +/* t[key] = { "op", arg, value, ... } at most `max` of them, arg and value default 0 */ +static void tbl_ops(lua_State *L, int idx, const char *key, const char *const *names, + mhfu_em_op_t *out, int max) +{ + lua_getfield(L, idx, key); + if (!lua_isnil(L, -1)) { + luaL_checktype(L, -1, LUA_TTABLE); + const int n = (int)luaL_len(L, -1); + luaL_argcheck(L, n <= max, 2, "too many entries in conds/effects"); + for (int i = 0; i < n; i++) { + lua_rawgeti(L, -1, i + 1); + luaL_checktype(L, -1, LUA_TTABLE); + lua_rawgeti(L, -1, 1); + out[i].op = (uint8_t)(luaL_checkoption(L, -1, NULL, names) + 1); + lua_rawgeti(L, -2, 2); + out[i].arg = lua_isnil(L, -1) ? 0 : (uint8_t)luaL_checkinteger(L, -1); + lua_rawgeti(L, -3, 3); + const lua_Integer v = lua_isnil(L, -1) ? 0 : luaL_checkinteger(L, -1); + luaL_argcheck(L, v >= -32768 && v <= 32767, 2, "a value past a s16"); + out[i].value = (int16_t)v; + lua_pop(L, 4); + } + } + lua_pop(L, 1); +} + int lb_em_rule(lua_State *L) { int slot = (int)luaL_checkinteger(L, 1); if (lua_isnoneornil(L, 2)) { mhfu_em_rule(slot, 0); lua_pushboolean(L, 1); return 1; } luaL_checktype(L, 2, LUA_TTABLE); - mhfu_em_rule_t r; + mhfu_em_rule_t r = {}; r.from_mask = (uint8_t)tbl_int(L, 2, "from_mask", 0); r.from_sub = (uint8_t)tbl_int(L, 2, "from_sub", MHFU_EM_SUB_ANY); r.to_main = (uint8_t)tbl_int(L, 2, "to_main", 0); r.to_sub = (uint8_t)tbl_int(L, 2, "to_sub", 0); r.mode = (uint8_t)tbl_int(L, 2, "mode", 0); r.flags = (uint8_t)((tbl_bool(L, 2, "receding") ? MHFU_EM_RULE_RECEDING : 0) - | (tbl_bool(L, 2, "closing") ? MHFU_EM_RULE_CLOSING : 0)); + | (tbl_bool(L, 2, "closing") ? MHFU_EM_RULE_CLOSING : 0) + | (tbl_bool(L, 2, "no_play") ? MHFU_EM_RULE_NO_PLAY : 0)); r.min_frames = (uint32_t)tbl_int(L, 2, "min_frames", 0); r.dist_lo = (float)tbl_num(L, 2, "dist_lo", 0.0); r.dist_hi = (float)tbl_num(L, 2, "dist_hi", 1.0e9); @@ -76,7 +107,9 @@ int lb_em_rule(lua_State *L) lua_pop(L, 1); r.part = (uint8_t)tbl_int(L, 2, "part", MHFU_EM_ANY_PART); r.force = (uint8_t)tbl_bool(L, 2, "force"); - r._pad = 0; + r.signal = (uint8_t)(tbl_int(L, 2, "signal", -1) + 1); + tbl_ops(L, 2, "conds", k_conds, r.conds, MHFU_EM_CONDS); + tbl_ops(L, 2, "effects", k_effects, r.effects, MHFU_EM_EFFECTS); mhfu_em_rule(slot, &r); lua_pushboolean(L, 1); return 1; @@ -110,6 +143,7 @@ int lb_em_status(lua_State *L) SF_INT("tip_pairs", st.tip_pairs); SF_INT("tip_copies", st.tip_copies); SF_INT("cut_waits", st.cut_waits); + SF_INT("broken", st.broken); SF_INT("req_pending", st.req_pending); SF_INT("req_done", st.req_done); SF_INT("req_main", (st.req_result >> 8) & 0xFF); @@ -135,10 +169,28 @@ int lb_em_status(lua_State *L) lua_newtable(L); for (int i = 0; i < MHFU_EM_SUBS; i++) { lua_pushinteger(L, (lua_Integer)st.sub_left[i]); lua_rawseti(L, -2, i + 1); } lua_setfield(L, -2, "sub_left"); + lua_newtable(L); + for (int i = 0; i < MHFU_EM_VARS; i++) { lua_pushinteger(L, st.vars[i]); lua_rawseti(L, -2, i + 1); } + lua_setfield(L, -2, "vars"); #undef SF_INT return 1; } +/* the board: em_var(k [, v]) reads or sets counter k; em_signal(k) raises signal k */ +int lb_em_var(lua_State *L) +{ + const int k = (int)luaL_checkinteger(L, 1); + if (!lua_isnoneornil(L, 2)) mhfu_em_set_var(k, (int)luaL_checkinteger(L, 2)); + lua_pushinteger(L, mhfu_em_var(k)); + return 1; +} + +int lb_em_signal(lua_State *L) +{ + lua_pushboolean(L, mhfu_em_signal((int)luaL_checkinteger(L, 1))); + return 1; +} + /* {{joint, carrier}, ...} */ int lb_em_tip(lua_State *L) { diff --git a/framework/mods/lua_host/bind_monster_events.cpp b/framework/mods/lua_host/bind_monster_events.cpp index 082d18c..a627fba 100644 --- a/framework/mods/lua_host/bind_monster_events.cpp +++ b/framework/mods/lua_host/bind_monster_events.cpp @@ -6,9 +6,11 @@ #include "mhfu/monster_events.h" #include "lua_host.h" -#define KINDS (MHFU_MONSTER_TAIL_CUT - MHFU_MONSTER_NOTICED + 1) +#define KINDS MHFU_MONSTER_KINDS -static int r_kind[KINDS] = {LUA_NOREF, LUA_NOREF, LUA_NOREF, LUA_NOREF, LUA_NOREF, LUA_NOREF}; +/* a handler's registry reference per kind; constant-initialized, so the PRX runs no constructor */ +struct Ref { int v = LUA_NOREF; }; +static Ref r_kind[KINDS]; static const char *const *const k_name = mhfu_monster_event_names; @@ -38,9 +40,9 @@ static void push_event(lua_State *L, const mhfu_monster_event_ctx_t *c) static void tramp(const mhfu_monster_event_ctx_t *c) { int k = c->ev.kind - MHFU_MONSTER_NOTICED; - if (k < 0 || k >= KINDS || r_kind[k] == LUA_NOREF || !mhfu_lua_enter()) return; + if (k < 0 || k >= KINDS || r_kind[k].v == LUA_NOREF || !mhfu_lua_enter()) return; lua_State *L = mhfu_lua_vm; - lua_rawgeti(L, LUA_REGISTRYINDEX, r_kind[k]); + lua_rawgeti(L, LUA_REGISTRYINDEX, r_kind[k].v); push_event(L, c); if (lua_pcall(L, 1, 0, 0) != LUA_OK) { mhfu_log("[lua_host] %s err: %s", k_name[k], @@ -55,9 +57,9 @@ static int on(lua_State *L, int kind) { int k = kind - MHFU_MONSTER_NOTICED; luaL_checktype(L, 1, LUA_TFUNCTION); - if (r_kind[k] != LUA_NOREF) luaL_unref(L, LUA_REGISTRYINDEX, r_kind[k]); + if (r_kind[k].v != LUA_NOREF) luaL_unref(L, LUA_REGISTRYINDEX, r_kind[k].v); lua_pushvalue(L, 1); - r_kind[k] = luaL_ref(L, LUA_REGISTRYINDEX); + r_kind[k].v = luaL_ref(L, LUA_REGISTRYINDEX); mhfu_event_id_t id = (mhfu_event_id_t)(MHFU_EVENT_BIGMONSTER_NOTICED + k); mhfu_hook_rc_t rc = mhfu_on_monster_event(id, tramp, (int)luaL_optinteger(L, 2, 0), MHFU_LUA_HOST_ID); @@ -72,6 +74,8 @@ int lb_on_bigmonster_combat_left(lua_State *L) { return on(L, MHFU_MONSTER_CO int lb_on_bigmonster_flinch(lua_State *L) { return on(L, MHFU_MONSTER_FLINCH); } int lb_on_bigmonster_part_broken(lua_State *L) { return on(L, MHFU_MONSTER_PART_BROKEN); } int lb_on_bigmonster_tail_cut(lua_State *L) { return on(L, MHFU_MONSTER_TAIL_CUT); } +int lb_on_bigmonster_enraged(lua_State *L) { return on(L, MHFU_MONSTER_ENRAGED); } +int lb_on_bigmonster_calmed(lua_State *L) { return on(L, MHFU_MONSTER_CALMED); } /* mhfu.MonsterState of ent, nil for none */ int lb_monster_state(lua_State *L) diff --git a/framework/mods/lua_host/bind_move.cpp b/framework/mods/lua_host/bind_move.cpp index 6c63d33..033fc06 100644 --- a/framework/mods/lua_host/bind_move.cpp +++ b/framework/mods/lua_host/bind_move.cpp @@ -37,8 +37,8 @@ static void spec_of(lua_State *L, int t, mhfu_move_t *mv) lua_pop(L, 1); if (lua_getfield(L, t, "carrier") == LUA_TTABLE) { int c = lua_gettop(L); - mv->carrier_main = (uint8_t)list_at(L, c, 1, 0); - mv->carrier_sub = (uint8_t)list_at(L, c, 2, 2); + mv->carrier_main = (uint8_t)list_at(L, c, 1, MHFU_MOVE_HUB_MAIN); + mv->carrier_sub = (uint8_t)list_at(L, c, 2, MHFU_MOVE_HUB_SUB); } lua_pop(L, 1); if (lua_getfield(L, t, "back") == LUA_TTABLE) { diff --git a/framework/src/core/em_vhook.cpp b/framework/src/core/em_vhook.cpp index 27b7932..6507043 100644 --- a/framework/src/core/em_vhook.cpp +++ b/framework/src/core/em_vhook.cpp @@ -36,12 +36,14 @@ * one of: an own move asked for, the move of a rule on the flinch (or on * the break it made) whose reaction was replaced, the AFTER of one of * ours that ended, a rule - * ("in pair P or own move M for N frames, or on event E, player at - * [lo,hi), receding -> enter Q or play own move M'", cooldown, budget); + * ("in pair P or own move M for N frames, or on event E or signal S, + * player at [lo,hi), receding, every condition holding -> enter Q or play + * own move M', change counters", cooldown, budget); the rules that play + * nothing run first, all of them, in every AI frame; * last, the rules on the flinch arm the reaction replacement for the * host step that follows. Own moves sit in a registry by slot * (mhfu_em_move) and play through the move player (move.cpp) that same - * AI frame. + * AI frame. The board (counters, signals, broken parts) is the monster's. * BUDGET slot-32 post + slot-29 one-shot: the ENTITY.ACTION_BUDGET * override; the stubs carry it, but nothing arms it. * STEP slot-29 pre: a C function per AI frame (mhfu_em_step), which may @@ -100,7 +102,9 @@ typedef struct { uint8_t from_mask, from_sub, to_main, to_sub, mode, flags; uint8_t from_move, play_move; /* own move slot + 1, 0 none (mhfu_em_rule_t) */ uint32_t min_frames, d2_lo, d2_hi, left, fired, last_fire, cooldown; - uint8_t on, part, force, _pad; /* a monster event, 0 none; its part or MHFU_EM_ANY_PART */ + uint8_t on, part, force, signal; /* a monster event, 0 none; its part or MHFU_EM_ANY_PART; + * a board signal + 1 */ + mhfu_em_op_t conds[MHFU_EM_CONDS], effects[MHFU_EM_EFFECTS]; } cfg_rule_t; typedef struct { uint8_t want_main, want_sub, armed, arm29; /* +0x00 */ @@ -164,6 +168,17 @@ static_assert(offsetof(cfg_rule_t, cooldown) == MHFU_EM_RULE_COOLDOWN, "EM_R static_assert(offsetof(cfg_rule_t, on) == MHFU_EM_RULE_ON, "EM_RULE layout"); static_assert(offsetof(cfg_rule_t, part) == MHFU_EM_RULE_PART, "EM_RULE layout"); static_assert(offsetof(cfg_rule_t, force) == MHFU_EM_RULE_FORCE, "EM_RULE layout"); +static_assert(offsetof(cfg_rule_t, signal) == MHFU_EM_RULE_SIGNAL, "EM_RULE layout"); +static_assert(offsetof(cfg_rule_t, conds) == MHFU_EM_RULE_CONDS, "EM_RULE layout"); +static_assert(offsetof(cfg_rule_t, effects) == MHFU_EM_RULE_EFFECTS, "EM_RULE layout"); +static_assert(sizeof(mhfu_em_op_t) == MHFU_EM_OP_SIZE, "EM_OP layout"); +static_assert(offsetof(mhfu_em_op_t, arg) == MHFU_EM_OP_ARG, "EM_OP layout"); +static_assert(offsetof(mhfu_em_op_t, value) == MHFU_EM_OP_VALUE, "EM_OP layout"); +static_assert(sizeof(mhfu_em_board_t) == MHFU_EM_BOARD_SIZE, "EM_BOARD layout"); +static_assert(offsetof(mhfu_em_board_t, signals) == MHFU_EM_BOARD_SIGNALS, "EM_BOARD layout"); +static_assert(offsetof(mhfu_em_board_t, broken) == MHFU_EM_BOARD_BROKEN, "EM_BOARD layout"); +static_assert(offsetof(mhfu_em_board_t, rng) == MHFU_EM_BOARD_RNG, "EM_BOARD layout"); +static_assert(offsetof(mhfu_em_board_t, entity) == MHFU_EM_BOARD_ENTITY, "EM_BOARD layout"); #define CFG_CANARY_VAL 0x5645484Bu /* 'VEHK' */ @@ -179,7 +194,7 @@ typedef struct { uint32_t copies, waits; uint32_t held_ent; /* whose events wait for the drop */ uint16_t held_edges; - uint8_t held_parts, _pad; + uint8_t held_parts, held_broke; } tail_t; static_assert(sizeof(tail_t) % 4 == 0, "zeroed word by word after the config"); @@ -188,7 +203,7 @@ static_assert(sizeof(tail_t) % 4 == 0, "zeroed word by word after the config"); * They come from the user partition, and only pointers live here. */ #define RET_INSNS 4 /* jr ra; nop, padded to 16 bytes */ #define STUBS (STUB_AI_INSNS + STUB_ACT_INSNS + STUB_EVT_INSNS + MHFU_WRAP_WORDS + RET_INSNS) -#define BLOCK_BYTES (STUBS * 4 + CFG_SIZE + sizeof(tail_t) + 128) +#define BLOCK_BYTES (STUBS * 4 + CFG_SIZE + sizeof(tail_t) + sizeof(mhfu_em_board_t) + 128) static uint32_t *g_stub_ai; static uint32_t *g_stub_act; @@ -197,6 +212,7 @@ static uint32_t *g_stub_brain; /* the OBJ_VTABLE.BRAIN wrapper (wrap.h) * static uint32_t *g_stub_ret; static em_vhook_cfg_t *g_cfgp; static volatile tail_t *T; +static volatile mhfu_em_board_t *g_board; /* after the tail: the board the rules and Lua share */ static volatile mhfu_em_moves_t *R; /* the own-move registry */ #define MOVES_MAGIC 0x564F4D45u /* 'EMOV' */ @@ -214,8 +230,6 @@ static_assert(offsetof(mhfu_em_moves_t, moves) == MHFU_EM_MOVES_MOVES, "EM_MOVES static_assert(offsetof(mhfu_em_moves_t, keys) == MHFU_EM_MOVES_KEYS, "EM_MOVES layout"); static_assert(offsetof(mhfu_em_moves_t, scratch) == MHFU_EM_MOVES_SCRATCH, "EM_MOVES layout"); static_assert(offsetof(mhfu_em_moves_t, req_force) == MHFU_EM_MOVES_REQ_FORCE, "EM_MOVES layout"); -static_assert(MHFU_EM_MOVES_MOVES_COUNT == MHFU_EM_MOVES, "EM_MOVES layout"); -static_assert(MHFU_EM_MOVES_KEYS_COUNT == MHFU_EM_KEYS, "EM_MOVES layout"); static void cfg_reset_live(void) { @@ -239,7 +253,8 @@ static void place_block(uint8_t *base) g_stub_ret = (uint32_t *)(base + (STUBS - RET_INSNS) * 4); g_cfgp = (em_vhook_cfg_t *)(base + STUBS * 4); T = (volatile tail_t *)(base + STUBS * 4 + CFG_SIZE); - for (unsigned k = 0; k < (CFG_SIZE + sizeof(tail_t)) / 4; k++) + g_board = (volatile mhfu_em_board_t *)(base + STUBS * 4 + CFG_SIZE + sizeof(tail_t)); + for (unsigned k = 0; k < (CFG_SIZE + sizeof(tail_t) + sizeof(mhfu_em_board_t)) / 4; k++) ((uint32_t *)g_cfgp)[k] = 0; cfg_reset_live(); } @@ -282,8 +297,9 @@ static int alloc_block(void) uint8_t *base = (uint8_t *)alloc_low("em_vhook", BLOCK_BYTES, &g_block); if (!base) return -1; place_block(base); - mhfu_log("[%s] block @0x%08X (outside the PRX image), cfg @0x%08X", - OWNER, (unsigned)(uintptr_t)g_stub_ai, (unsigned)(uintptr_t)g_cfgp); + mhfu_log("[%s] block @0x%08X (outside the PRX image), cfg @0x%08X, %u bytes for %d rules", + OWNER, (unsigned)(uintptr_t)g_stub_ai, (unsigned)(uintptr_t)g_cfgp, + (unsigned)BLOCK_BYTES, MHFU_EM_RULES); return 0; } @@ -293,7 +309,8 @@ static int alloc_moves(void) void *at = alloc_low("em_moves", sizeof(mhfu_em_moves_t) + 64, &uid); if (!at) return -1; place_moves(at); - mhfu_log("[%s] own moves @0x%08X", OWNER, (unsigned)(uintptr_t)R); + mhfu_log("[%s] own moves @0x%08X, %u bytes for %d moves", OWNER, (unsigned)(uintptr_t)R, + (unsigned)(sizeof(mhfu_em_moves_t) + 64), MHFU_EM_MOVES); return 0; } #endif @@ -413,35 +430,190 @@ static int gates(const cfg_rule_t *r, uint32_t ent, const volatile mhfu_move_sta return g_cfgp->ai_ticks - r->last_fire >= r->cooldown; } +/* --- the board: counters and signals the rules and Lua share for the monster ---------------- */ + +static int16_t sat16(int32_t v) { return (int16_t)(v > 32767 ? 32767 : v < -32768 ? -32768 : v); } + +static uint32_t rng_next(void) /* xorshift32; the state is never 0 */ +{ + uint32_t x = g_board->rng; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + g_board->rng = x; + return x; +} + +/* ent's board: counters, signals and parts 0, the generator reseeded; ent 0 for none yet */ +static void board_reset(uint32_t ent) +{ + volatile uint32_t *w = (volatile uint32_t *)g_board; + for (unsigned k = 0; k < sizeof(mhfu_em_board_t) / 4; k++) w[k] = 0; + g_board->entity = ent; + g_board->rng = ((ent * 0x9E3779B1u) ^ mhfu_usec()) | 1u; + for (int k = 0; k < 4; k++) rng_next(); /* a near seed gives a near first draw */ +} + +/* the signals raised since the last take, as a mask by index; cleared as taken */ +static uint16_t take_signals(void) +{ + uint16_t s = 0; + for (unsigned k = 0; k < MHFU_EM_SIGNALS; k++) { + if (!g_board->signals[k]) continue; + g_board->signals[k] = 0; + s = (uint16_t)(s | (1u << k)); + } + return s; +} + +/* --- the conditions --------------------------------------------------------------------------- */ + +#define SIDE_FRONT 1u /* mhfu_em_op_t.arg of side */ +#define SIDE_LEFT 2u /* the monster's own */ +#define SIDE_RIGHT 4u +#define SIDE_BEHIND 8u +#define SIDES (SIDE_FRONT | SIDE_LEFT | SIDE_RIGHT | SIDE_BEHIND) + +/* ENTITY.ROTATION's rows (4 floats each) are the images of the monster's axes, rebuilt from YAW + * every frame: row 2 faces forward (YAW is atan2 of its x over its z, as Entity.facing reads it) + * and row 0 is its left, the side YAW grows toward. A float index each, x then z two on. */ +#define ROW_FORWARD 8 +#define ROW_LEFT 0 + +/* The 90 degree sector of the monster's frame the hunter stands in, as a SIDE_*: the hunter's + * offset against the two rows with dot products, no trig. The rows may carry the monster's size, + * so each dot is taken over its row's length (squared, to avoid the root). */ +static unsigned side_of(uint32_t ent) +{ + const uint32_t rot = ent + MHFU_ENTITY_ROTATION; + const float dx = mhfu_mem_read_f32(MHFU_PLAYER_ENTITY + MHFU_ENTITY_TRANSLATION) + - mhfu_mem_read_f32(ent + MHFU_ENTITY_POSITION); + const float dz = mhfu_mem_read_f32(MHFU_PLAYER_ENTITY + MHFU_ENTITY_TRANSLATION + 8) + - mhfu_mem_read_f32(ent + MHFU_ENTITY_POSITION + 8); + const float fx = mhfu_mem_read_f32(rot + 4 * ROW_FORWARD); + const float fz = mhfu_mem_read_f32(rot + 4 * (ROW_FORWARD + 2)); + const float lx = mhfu_mem_read_f32(rot + 4 * ROW_LEFT); + const float lz = mhfu_mem_read_f32(rot + 4 * (ROW_LEFT + 2)); + const float f = dx * fx + dz * fz, l = dx * lx + dz * lz; + if (f * f * (lx * lx + lz * lz) >= l * l * (fx * fx + fz * fz)) + return f >= 0 ? SIDE_FRONT : SIDE_BEHIND; + return l >= 0 ? SIDE_LEFT : SIDE_RIGHT; +} + +/* HP * 100 against percent * MAX_HP; both fit 32 bits (u16 times 100, s16 times u16) */ +static int hp_cmp(uint32_t ent, int16_t percent) +{ + const int32_t hp = (int32_t)mhfu_mem_read_u16(ent + MHFU_ENTITY_HP) * 100; + const int32_t lim = (int32_t)percent * (int32_t)mhfu_mem_read_u16(ent + MHFU_ENTITY_MAX_HP); + return hp < lim ? -1 : hp > lim; +} + +/* the conditions whose arg is a counter */ +static int is_var_cond(uint8_t op) +{ + return op == MHFU_EM_COND_VAR_AT_LEAST || op == MHFU_EM_COND_VAR_BELOW + || op == MHFU_EM_COND_VAR_EQUAL; +} + +/* one condition holds on ent; an op it does not know never does */ +static int cond_ok(const mhfu_em_op_t *c, uint32_t ent) +{ + const int var = is_var_cond(c->op) && c->arg < MHFU_EM_VARS ? g_board->vars[c->arg] : 0; + switch (c->op) { + case MHFU_EM_COND_HP_AT_LEAST: return hp_cmp(ent, c->value) >= 0; + case MHFU_EM_COND_HP_BELOW: return hp_cmp(ent, c->value) < 0; + case MHFU_EM_COND_BROKEN: return c->arg < 8 && ((g_board->broken >> c->arg) & 1); + case MHFU_EM_COND_NOT_BROKEN: return c->arg < 8 && !((g_board->broken >> c->arg) & 1); + case MHFU_EM_COND_ENRAGED: return (mhfu_mem_read_u32(ent + MHFU_ENTITY_FLAGS) + & MHFU_MONSTER_FLAG_ENRAGED) != 0; + case MHFU_EM_COND_CALM: return (mhfu_mem_read_u32(ent + MHFU_ENTITY_FLAGS) + & MHFU_MONSTER_FLAG_ENRAGED) == 0; + case MHFU_EM_COND_SIDE: return (c->arg & side_of(ent)) != 0; + case MHFU_EM_COND_CHANCE: + return c->value >= 100 || (c->value > 0 && rng_next() % 100 < (uint32_t)c->value); + case MHFU_EM_COND_VAR_AT_LEAST: return var >= c->value; + case MHFU_EM_COND_VAR_BELOW: return var < c->value; + case MHFU_EM_COND_VAR_EQUAL: return var == c->value; + default: return 0; + } +} + +/* every condition of r holds on ent; the chances are rolled last, only if the rest held */ +static int conds_hold(const cfg_rule_t *r, uint32_t ent) +{ + int chance = 0; + for (int i = 0; i < MHFU_EM_CONDS; i++) { + const mhfu_em_op_t *c = &r->conds[i]; + if (c->op == MHFU_EM_COND_CHANCE) chance = 1; + else if (c->op && !cond_ok(c, ent)) return 0; + } + for (int i = 0; chance && i < MHFU_EM_CONDS; i++) + if (r->conds[i].op == MHFU_EM_COND_CHANCE && !cond_ok(&r->conds[i], ent)) return 0; + return 1; +} + +/* r's effects on the board */ +static void apply(const cfg_rule_t *r) +{ + for (int i = 0; i < MHFU_EM_EFFECTS; i++) { + const mhfu_em_op_t *e = &r->effects[i]; + if (e->arg >= MHFU_EM_VARS) continue; + if (e->op == MHFU_EM_EFFECT_VAR_ADD) + g_board->vars[e->arg] = sat16(g_board->vars[e->arg] + e->value); + else if (e->op == MHFU_EM_EFFECT_VAR_SET) + g_board->vars[e->arg] = e->value; + } +} + +/* what an AI frame saw, for the rules */ +typedef struct { + uint16_t edges; /* the monster events raised, bit kind */ + uint16_t sigs; /* the signals taken, bit index */ + uint8_t parts; /* the parts that flinched */ + uint8_t broke; /* the parts that broke */ +} seen_t; + static int part_of(const cfg_rule_t *r, uint8_t parts) { return r->part == MHFU_EM_ANY_PART || (r->part < 8 && ((parts >> r->part) & 1)); } -/* rule r fires on ent this frame; edges: what the monster events raised, parts: what flinched */ +static int quiet(const cfg_rule_t *r) { return (r->flags & MHFU_EM_RULE_NO_PLAY) != 0; } + +/* rule r fires on ent this frame, given what the frame saw. A rule on the flinch that plays goes + * through the reaction replacement. */ static int holds(const cfg_rule_t *r, uint32_t ent, const volatile mhfu_move_state_t *m, - uint16_t edges, uint8_t parts) + const seen_t *s) { - if (r->on == MHFU_MONSTER_FLINCH) return 0; /* through the reaction replacement */ - if (r->on && (!((edges >> r->on) & 1) || !part_of(r, parts))) return 0; - return gates(r, ent, m); + if (r->on == MHFU_MONSTER_FLINCH && !quiet(r)) return 0; + if (r->on && (!((s->edges >> r->on) & 1) + || !part_of(r, r->on == MHFU_MONSTER_PART_BROKEN ? s->broke : s->parts))) + return 0; + if (r->signal && (r->signal > MHFU_EM_SIGNALS || !((s->sigs >> (r->signal - 1)) & 1))) + return 0; + return gates(r, ent, m) && conds_hold(r, ent); } +/* every fire, whichever path: the budget, the clock, the effects; a rule that plays restarts the + * pair's dwell, one that plays nothing leaves it */ static void count_fire(cfg_rule_t *r) { if (r->left != MHFU_EM_UNLIMITED) r->left--; r->fired++; r->last_fire = g_cfgp->ai_ticks; g_cfgp->brain_fires++; - g_cfgp->frames = 0; + if (!quiet(r)) g_cfgp->frames = 0; + apply(r); } static int fire(cfg_rule_t *r, uint32_t ent, const volatile mhfu_move_state_t *m) { - if (r->play_move) { - if (!play_slot(ent, r->play_move - 1u, m, r->force)) return 0; - } else { - enter(ent, r->to_main, r->to_sub, r->mode); + if (!quiet(r)) { + if (r->play_move) { + if (!play_slot(ent, r->play_move - 1u, m, r->force)) return 0; + } else { + enter(ent, r->to_main, r->to_sub, r->mode); + } } count_fire(r); return 1; @@ -462,30 +634,31 @@ static const volatile mhfu_em_own_t *flinch_move(const cfg_rule_t *r) return r->on == MHFU_MONSTER_FLINCH ? rule_move(r) : 0; } -static int g_reacts; /* the brain owns the reaction replacement */ -static uint32_t g_armed; /* bit i: rule i's gates held when it was armed */ +static int g_reacts; /* the brain owns the reaction replacement */ +static uint8_t g_armed[MHFU_EM_RULES]; /* 1: rule i's gates and conditions held when armed */ /* The replacement entered the carrier in place of a flinch in the last host step: a rule on the - * break that flinch made, whose gates hold and whose own move rides that carrier, else the first - * rule armed for it whose part flinched, hands the move player its move, which the step starts. */ -static int react_take(uint32_t ent, const volatile mhfu_move_state_t *m, uint16_t edges) + * break that flinch made, whose gates and conditions hold and whose own move rides that carrier, + * else the first rule armed for it whose part flinched, hands the move player its move, which the + * step starts. */ +static int react_take(uint32_t ent, const volatile mhfu_move_state_t *m, const seen_t *s) { if (!g_reacts || !mhfu_move_react_pending(ent)) return 0; uint8_t flinched = mhfu_mem_read_u8(ent + MHFU_ENTITY_FLINCH_MASK); cfg_rule_t *first = 0; - for (int i = 0; i < MHFU_EM_RULES && ((edges >> MHFU_MONSTER_PART_BROKEN) & 1); i++) { + for (int i = 0; i < MHFU_EM_RULES && ((s->edges >> MHFU_MONSTER_PART_BROKEN) & 1); i++) { cfg_rule_t *r = &g_cfgp->rules[i]; const volatile mhfu_em_own_t *o = rule_move(r); - if (r->on == MHFU_MONSTER_PART_BROKEN && o && part_of(r, flinched) && gates(r, ent, m) + if (r->on == MHFU_MONSTER_PART_BROKEN && o && part_of(r, s->broke) && gates(r, ent, m) && o->move.carrier_main == g_cfgp->react_to_main - && o->move.carrier_sub == g_cfgp->react_to_sub) { + && o->move.carrier_sub == g_cfgp->react_to_sub && conds_hold(r, ent)) { first = r; break; } } for (int i = 0; i < MHFU_EM_RULES && !first; i++) { cfg_rule_t *r = &g_cfgp->rules[i]; - if (((g_armed >> i) & 1) && flinch_move(r) && part_of(r, flinched)) first = r; + if (g_armed[i] && flinch_move(r) && part_of(r, flinched)) first = r; } if (!first) return 1; /* the rules went meanwhile: the carrier runs as it is */ const volatile mhfu_em_own_t *o = &R->moves[first->play_move - 1]; @@ -495,28 +668,29 @@ static int react_take(uint32_t ent, const volatile mhfu_move_state_t *m, uint16_ return 1; } -/* The parts the rules on the flinch take in the host step that follows, with their gates as they - * stand now, on the first such rule's carrier. Off once no rule on the flinch is installed. */ +/* The parts the rules on the flinch take in the host step that follows, with their gates and + * conditions as they stand now (a chance is rolled now), on the first such rule's carrier. Off + * once no rule on the flinch that plays is installed. */ static void react_arm(uint32_t ent, const volatile mhfu_move_state_t *m) { int any = 0, have = 0; uint8_t parts = 0, cm = 0, cs = 0; - g_armed = 0; for (int i = 0; i < MHFU_EM_RULES; i++) { + g_armed[i] = 0; const cfg_rule_t *r = &g_cfgp->rules[i]; - if (r->on != MHFU_MONSTER_FLINCH || !r->left) continue; + if (r->on != MHFU_MONSTER_FLINCH || !r->left || quiet(r)) continue; any = 1; const volatile mhfu_em_own_t *o = flinch_move(r); if (!o || !gates(r, ent, m)) continue; + if (have && (o->move.carrier_main != cm || o->move.carrier_sub != cs)) continue; + if (!conds_hold(r, ent)) continue; if (!have) { cm = o->move.carrier_main; cs = o->move.carrier_sub; have = 1; - } else if (o->move.carrier_main != cm || o->move.carrier_sub != cs) { - continue; } parts |= r->part == MHFU_EM_ANY_PART ? 0xFF : (uint8_t)(1u << (r->part & 7)); - g_armed |= 1u << i; + g_armed[i] = 1; } if (!any) { if (g_reacts) mhfu_move_react_arm(0, 0, 0, 0); @@ -531,28 +705,42 @@ static void react_arm(uint32_t ent, const volatile mhfu_move_state_t *m) g_reacts = 1; } -/* at most one thing played or entered; 1 when a replaced reaction waits for the step */ -static int act(uint32_t ent, const volatile mhfu_move_state_t *m, uint16_t edges, uint8_t parts) +/* The rules of one kind in slot order. Those that play nothing (quiet) all fire where they hold; + * of those that play, the first that fires ends the scan. */ +static void scan(uint32_t ent, const volatile mhfu_move_state_t *m, const seen_t *s, + int quiet_rules) { + for (int i = 0; i < MHFU_EM_RULES; i++) { + cfg_rule_t *r = &g_cfgp->rules[i]; + if (!r->left || quiet(r) != quiet_rules) continue; + if (holds(r, ent, m, s) && fire(r, ent, m) && !quiet_rules) break; + } +} + +/* The rules that play nothing keep the counters and flags, so they run first, whatever the frame + * does and wherever they sit among the others: the events of the frame count, and the rules that + * play see the counters as this frame left them. Then at most one thing played or entered; 1 when + * a replaced reaction waits for the step. */ +static int act(uint32_t ent, const volatile mhfu_move_state_t *m, const seen_t *s) +{ + scan(ent, m, s, 1); + int took = 0, plays = 0; + int k = ours(m); if (R->req_ent == ent) { R->req_ent = 0; play_slot(ent, R->req_slot, m, (int)R->req_force); - return 0; - } - if (react_take(ent, m, edges)) return 1; - int k = ours(m); - if (k >= 0 && m->state == MHFU_MOVE_DONE && m->entity == ent - && (m->end == MHFU_MOVE_END_BACK || m->end == MHFU_MOVE_END_WALL) - && R->moves[k].after != MHFU_EM_NO_MOVE) { + } else if (react_take(ent, m, s)) { + took = 1; + } else if (k >= 0 && m->state == MHFU_MOVE_DONE && m->entity == ent + && (m->end == MHFU_MOVE_END_BACK || m->end == MHFU_MOVE_END_WALL) + && R->moves[k].after != MHFU_EM_NO_MOVE) { if (play_slot(ent, R->moves[k].after, m, m->move.force)) R->chained++; else R->tag_slot = MHFU_EM_NO_MOVE; /* an empty AFTER: not again */ - return 0; - } - for (int i = 0; i < MHFU_EM_RULES; i++) { - cfg_rule_t *r = &g_cfgp->rules[i]; - if (r->left && holds(r, ent, m, edges, parts) && fire(r, ent, m)) break; + } else { + plays = 1; } - return 0; + if (plays) scan(ent, m, s, 0); + return took; } /* The tail cut runs to its drop: ent is in the cut with SEVERED, TAIL_DROP has yet to run, and @@ -582,32 +770,38 @@ static void request(uint32_t ent) c->frames = 0; /* the pair just changed: its dwell restarts */ } -/* The events of the AI frames the cut waited reach the rules in the first one after it. */ -static void hold(uint32_t ent, uint16_t edges, uint8_t parts) +/* The events of the AI frames the cut waited reach the rules in the first one after it; the + * signals stay raised on the board until a frame takes them. */ +static void hold(uint32_t ent, const seen_t *s) { if (T->held_ent != ent) { T->held_edges = 0; T->held_parts = 0; + T->held_broke = 0; } T->held_ent = ent; - T->held_edges |= edges; - T->held_parts |= parts; + T->held_edges |= s->edges; + T->held_parts |= s->parts; + T->held_broke |= s->broke; } -static uint16_t held(uint32_t ent, uint8_t *parts) +/* adds what the cut held to s */ +static void held(uint32_t ent, seen_t *s) { - if (T->held_ent != ent) return 0; - uint16_t edges = T->held_edges; - *parts |= T->held_parts; + if (T->held_ent != ent) return; + s->edges |= T->held_edges; + s->parts |= T->held_parts; + s->broke |= T->held_broke; T->held_ent = 0; T->held_edges = 0; T->held_parts = 0; - return edges; + T->held_broke = 0; } static uint32_t brain(uint32_t ent) { em_vhook_cfg_t *c = g_cfgp; + if (!g_board->entity) board_reset(ent); /* unclaimed: the first monster seen owns it */ const int waits = cut_waits(ent); if (waits) T->waits++; else request(ent); @@ -621,14 +815,17 @@ static uint32_t brain(uint32_t ent) c->d2 = f32_bits(dx * dx + dz * dz); const volatile mhfu_move_state_t *m = mhfu_move_state(); if (!m || !R) return 0; - uint8_t parts; - uint16_t edges = mhfu_monster_events_frame(ent, m->entity == ent && m->skipping, &parts); + seen_t seen = {}; + seen.edges = mhfu_monster_events_frame(ent, m->entity == ent && m->skipping, &seen.parts, + &seen.broke); + g_board->broken |= seen.broke; if (waits) { - hold(ent, edges, parts); + hold(ent, &seen); return 0; } - edges |= held(ent, &parts); - if (!act(ent, m, edges, parts)) react_arm(ent, m); + seen.sigs = take_signals(); + held(ent, &seen); + if (!act(ent, m, &seen)) react_arm(ent, m); return 0; } @@ -808,6 +1005,34 @@ extern "C" int mhfu_em_request(uint8_t main_state, uint8_t sub_state, uint8_t mo return 1; } +/* Why a rule cannot hold, or NULL: the framework refuses to run what it would misread. */ +static const char *op_error(const mhfu_em_op_t *o, int cond) +{ + if (!o->op) return 0; + if (o->op > (cond ? MHFU_EM_COND_COUNT : MHFU_EM_EFFECT_COUNT)) return "an op it does not know"; + if (!cond || is_var_cond(o->op)) + return o->arg < MHFU_EM_VARS ? 0 : "a counter past the board"; + if (o->op == MHFU_EM_COND_BROKEN || o->op == MHFU_EM_COND_NOT_BROKEN) + return o->arg < 8 ? 0 : "a part past 7"; + if (o->op == MHFU_EM_COND_SIDE) return (o->arg & SIDES) ? 0 : "a side mask with no side"; + return 0; +} + +static const char *rule_error(const mhfu_em_rule_t *r) +{ + if (r->on > MHFU_MONSTER_KINDS) return "an event it cannot take"; + if (r->on == MHFU_MONSTER_FLINCH && !(r->flags & MHFU_EM_RULE_NO_PLAY) && !r->play_move) + return "the flinch plays an own move"; + if (r->signal > MHFU_EM_SIGNALS) return "a signal it cannot take"; + if (r->on && r->signal) return "one trigger, an event or a signal"; + if (!r->from_mask && !r->from_move && !r->on && !r->signal) return "no trigger"; + for (int i = 0; i < MHFU_EM_CONDS; i++) + if (const char *e = op_error(&r->conds[i], 1)) return e; + for (int i = 0; i < MHFU_EM_EFFECTS; i++) + if (const char *e = op_error(&r->effects[i], 0)) return e; + return 0; +} + /* A native brain rule; NULL clears the slot. Distances go into the block squared, * as raw f32 bits, which is what the brain compares. */ extern "C" void mhfu_em_rule(int slot, const mhfu_em_rule_t *r) @@ -815,16 +1040,16 @@ extern "C" void mhfu_em_rule(int slot, const mhfu_em_rule_t *r) if (!g_cfgp || slot < 0 || slot >= MHFU_EM_RULES) return; cfg_rule_t *c = &g_cfgp->rules[slot]; c->left = 0; /* off while we write */ - int bad_on = r && (r->on > MHFU_MONSTER_TAIL_CUT - || (r->on == MHFU_MONSTER_FLINCH && !r->play_move)); - if (!r || r->count == 0 || bad_on || (!r->from_mask && !r->from_move && !r->on)) { + const char *bad = r ? rule_error(r) : 0; + if (!r || r->count == 0 || bad) { c->from_mask = 0; c->from_move = 0; c->on = 0; - mhfu_log("[%s] rule[%d]: cleared%s", OWNER, slot, - bad_on ? " (an event it cannot take; the flinch plays an own move)" : ""); + c->signal = 0; + mhfu_log("[%s] rule[%d]: cleared%s%s", OWNER, slot, bad ? ": " : "", bad ? bad : ""); return; } + const int none = (r->flags & MHFU_EM_RULE_NO_PLAY) != 0; float lo = r->dist_lo < 0 ? 0 : r->dist_lo; float hi = r->dist_hi < 0 ? 0 : r->dist_hi; c->from_mask = r->from_mask; @@ -834,10 +1059,13 @@ extern "C" void mhfu_em_rule(int slot, const mhfu_em_rule_t *r) c->mode = r->mode; c->flags = r->flags; c->from_move = r->from_move; - c->play_move = r->play_move; + c->play_move = none ? 0 : r->play_move; c->on = r->on; c->part = r->part; c->force = r->force; + c->signal = r->signal; + for (int i = 0; i < MHFU_EM_CONDS; i++) c->conds[i] = r->conds[i]; + for (int i = 0; i < MHFU_EM_EFFECTS; i++) c->effects[i] = r->effects[i]; c->min_frames = r->min_frames; c->d2_lo = f32_bits(lo * lo); c->d2_hi = f32_bits(hi * hi); @@ -845,16 +1073,16 @@ extern "C" void mhfu_em_rule(int slot, const mhfu_em_rule_t *r) c->fired = 0; c->last_fire = 0; c->left = r->count; - mhfu_log("[%s] rule[%d]: on %s part %d, from main mask 0x%02X sub %s / own move %d, " - ">=%u frames, d in [%d,%d)%s%s -> enter (%u,%u,m%u) / own move %d, cooldown %u, x%s", + mhfu_log("[%s] rule[%d]: on %s part %d signal %d, from main mask 0x%02X sub %s / own move %d, " + ">=%u frames, d in [%d,%d)%s%s -> %s (%u,%u,m%u) / own move %d, cooldown %u, x%s", OWNER, slot, r->on ? mhfu_monster_event_names[r->on - 1] : "-", - r->part == MHFU_EM_ANY_PART ? -1 : (int)r->part, r->from_mask, + r->part == MHFU_EM_ANY_PART ? -1 : (int)r->part, (int)r->signal - 1, r->from_mask, r->from_sub == MHFU_EM_SUB_ANY ? "any" : "exact", (int)r->from_move - 1, (unsigned)r->min_frames, (int)lo, (int)hi, (r->flags & MHFU_EM_RULE_RECEDING) ? ", receding" : "", (r->flags & MHFU_EM_RULE_CLOSING) ? ", closing" : "", - r->to_main, r->to_sub, r->mode, (int)r->play_move - 1, (unsigned)r->cooldown, - r->count == MHFU_EM_UNLIMITED ? "standing" : "n"); + none ? "no play" : "enter", r->to_main, r->to_sub, r->mode, (int)r->play_move - 1, + (unsigned)r->cooldown, r->count == MHFU_EM_UNLIMITED ? "standing" : "n"); } extern "C" void mhfu_em_clear(void) @@ -863,8 +1091,12 @@ extern "C" void mhfu_em_clear(void) g_cfgp->req_pending = 0; if (R) R->req_ent = 0; for (int i = 0; i < MHFU_EM_SUBS; i++) { g_cfgp->subs[i].left = 0; g_cfgp->subs[i].from_mask = 0; } - for (int i = 0; i < MHFU_EM_RULES; i++) { g_cfgp->rules[i].left = 0; g_cfgp->rules[i].from_mask = 0; - g_cfgp->rules[i].from_move = 0; g_cfgp->rules[i].on = 0; } + for (int i = 0; i < MHFU_EM_RULES; i++) { + cfg_rule_t *r = &g_cfgp->rules[i]; + r->left = 0; + r->from_mask = r->from_move = r->on = r->signal = 0; + g_armed[i] = 0; + } if (g_reacts) mhfu_move_react_arm(0, 0, 0, 0); g_reacts = 0; T->held_ent = 0; @@ -988,6 +1220,36 @@ extern "C" void mhfu_em_status(mhfu_em_status_t *out) for (int i = 0; i < MHFU_EM_RULES; i++) { out->rule_fired[i] = g_cfgp->rules[i].fired; out->rule_left[i] = g_cfgp->rules[i].left; } for (int i = 0; i < MHFU_EM_SUBS; i++) out->sub_left[i] = g_cfgp->subs[i].left; + for (int i = 0; i < MHFU_EM_VARS; i++) out->vars[i] = g_board->vars[i]; + out->broken = g_board->broken; +} + +/* --- the board --- */ + +extern "C" int mhfu_em_var(int k) +{ + return g_board && (unsigned)k < MHFU_EM_VARS ? g_board->vars[k] : 0; +} + +extern "C" void mhfu_em_set_var(int k, int v) +{ + if (g_board && (unsigned)k < MHFU_EM_VARS) g_board->vars[k] = sat16(v); +} + +extern "C" int mhfu_em_signal(int k) +{ + if (!g_board || (unsigned)k >= MHFU_EM_SIGNALS) return 0; + g_board->signals[k] = 1; + return 1; +} + +extern "C" const volatile mhfu_em_board_t *mhfu_em_board(void) { return g_board; } + +/* A new quest: no move is ours and the board belongs to no monster yet. */ +static void quest_reset(void) +{ + R->tag_slot = MHFU_EM_NO_MOVE; + board_reset(0); } /* --- the reaction replacement: one entry, owned by the move player (move.cpp) ---------- */ @@ -1110,6 +1372,10 @@ static void on_spawn(const mhfu_monster_spawn_ctx_t *ctx) { if (!ctx) return; install_for(ctx->entity_ptr); + /* the board is the spawned monster's from now, so a spawn handler that runs after this one + * can set its counters; two monsters of the species share it, as they share the dwell */ + if (g_installed && mhfu_mem_read_u32(ctx->entity_ptr) == g_vtable) + board_reset(ctx->entity_ptr); } static void on_quest(const mhfu_event_ctx_t *ctx) @@ -1123,7 +1389,7 @@ static void on_quest(const mhfu_event_ctx_t *ctx) g_cfgp->sub_hits = g_cfgp->sub_landed = g_cfgp->brain_fires = 0; g_cfgp->muted = 0; g_cfgp->req_done = 0; - R->tag_slot = MHFU_EM_NO_MOVE; + quest_reset(); } extern "C" int mhfu_em_init(void) @@ -1166,6 +1432,9 @@ extern "C" int mhfu_em_init(void) /* the species vtable latched, as install_for's */ extern "C" void mhfu_em_host_latch(uint32_t vtable) { T->vtable = vtable; } +/* the monster spawned, as on_spawn's */ +extern "C" void mhfu_em_host_spawn(uint32_t ent) { board_reset(ent); } + /* the brain slot as its wrapper runs it: the species brain (em_host.cpp), then the tip */ extern "C" void mhfu_host_brain(uint32_t ent); extern "C" void mhfu_em_host_brain(uint32_t ent) @@ -1187,7 +1456,7 @@ extern "C" uint32_t mhfu_em_host_frame(uint32_t ent) extern "C" void mhfu_em_host_quest(void) { mhfu_em_clear(); - R->tag_slot = MHFU_EM_NO_MOVE; + quest_reset(); } /* the engine's reaction entering (main, sub) mode 2 through the slot-32 stub's replacement, diff --git a/framework/src/core/internal.h b/framework/src/core/internal.h index 59806ae..6e12ca5 100644 --- a/framework/src/core/internal.h +++ b/framework/src/core/internal.h @@ -18,6 +18,9 @@ typedef unsigned int SceSize; /* host tests compile the core's pure parts */ extern "C" { #endif +/* The emulated clock, microseconds (monster_events.cpp). */ +uint32_t mhfu_usec(void); + /* --- events (events.cpp; the world events in world_events.cpp) --- */ typedef struct { mhfu_event_fn_t fn; diff --git a/framework/src/core/monster_events.cpp b/framework/src/core/monster_events.cpp index 955a199..a1f1fa5 100644 --- a/framework/src/core/monster_events.cpp +++ b/framework/src/core/monster_events.cpp @@ -15,10 +15,10 @@ #ifndef MHFU_HOST #include #include -static uint32_t now_usec(void) { return sceKernelGetSystemTimeLow(); } +extern "C" uint32_t mhfu_usec(void) { return sceKernelGetSystemTimeLow(); } #else extern "C" uint32_t mhfu_host_usec(void); -static uint32_t now_usec(void) { return mhfu_host_usec(); } +extern "C" uint32_t mhfu_usec(void) { return mhfu_host_usec(); } #endif #define OWNER "mhfu_events" @@ -31,14 +31,15 @@ static_assert(sizeof(mhfu_monster_event_t) == MHFU_MONSTER_EVENT_SIZE, "MONSTER_ static_assert(offsetof(mhfu_monster_event_t, kind) == MHFU_MONSTER_EVENT_KIND, "MONSTER_EVENT layout"); static_assert(offsetof(mhfu_monster_event_t, data) == MHFU_MONSTER_EVENT_DATA, "MONSTER_EVENT layout"); static_assert(sizeof(mhfu_monster_watch_t) == MHFU_MONSTER_WATCH_SIZE, "MONSTER_WATCH layout"); +static_assert(offsetof(mhfu_monster_watch_t, enraged) == MHFU_MONSTER_WATCH_ENRAGED, "MONSTER_WATCH layout"); static_assert(offsetof(mhfu_monster_watch_t, edges) == MHFU_MONSTER_WATCH_EDGES, "MONSTER_WATCH layout"); static_assert(offsetof(mhfu_monster_events_t, watch) == MHFU_MONSTER_EVENTS_WATCH, "MONSTER_EVENTS layout"); static_assert(offsetof(mhfu_monster_events_t, ring) == MHFU_MONSTER_EVENTS_RING, "MONSTER_EVENTS layout"); static_assert(sizeof(mhfu_monster_events_t) == MHFU_MONSTER_EVENTS_SIZE, "MONSTER_EVENTS layout"); static_assert(MHFU_MONSTER_EVENTS_RING_COUNT == MHFU_MONSTER_EVENT_RING, "MONSTER_EVENTS layout"); static_assert(MHFU_MONSTER_EVENTS_WATCH_COUNT == MHFU_MONSTER_EVENT_WATCH, "MONSTER_EVENTS layout"); -static_assert(MHFU_EVENT_BIGMONSTER_TAIL_CUT - MHFU_EVENT_BIGMONSTER_NOTICED - == MHFU_MONSTER_TAIL_CUT - MHFU_MONSTER_NOTICED, "kind order is event order"); +static_assert(MHFU_EVENT_BIGMONSTER_CALMED - MHFU_EVENT_BIGMONSTER_NOTICED + == MHFU_MONSTER_CALMED - MHFU_MONSTER_NOTICED, "kind order is event order"); static volatile mhfu_monster_events_t *B; @@ -46,7 +47,7 @@ extern "C" const char *const mhfu_monster_event_names[] = { MHFU_MONSTER_EVENT_KIND_NAMES, 0, }; static_assert(sizeof(mhfu_monster_event_names) / sizeof(mhfu_monster_event_names[0]) - 1 - == MHFU_MONSTER_TAIL_CUT, "a name per kind"); + == MHFU_MONSTER_KINDS, "a name per kind"); /* --- the step side ----------------------------------------------------------------------- */ @@ -127,9 +128,24 @@ static uint8_t lowest(uint16_t bits) return MHFU_MONSTER_NO_PART; } -extern "C" uint16_t mhfu_monster_events_frame(uint32_t ent, int stale, uint8_t *parts) +/* The BREAK_TABLE row of the species' part, or 0: PART_BREAK walks the table to its zero row. The + * species is compared as the row has it. */ +static uint32_t break_row(uint8_t species, uint8_t part) +{ + for (int i = 0; i < MHFU_BREAK_TABLE_COUNT; i++) { + const uint32_t row = MHFU_BREAK_TABLE + (uint32_t)i * MHFU_BREAK_ROW_SIZE; + const uint8_t sp = mhfu_mem_read_u8(row + MHFU_BREAK_ROW_SPECIES); + if (!sp) break; + if (sp == species && mhfu_mem_read_u8(row + MHFU_BREAK_ROW_PART) == part) return row; + } + return 0; +} + +extern "C" uint16_t mhfu_monster_events_frame(uint32_t ent, int stale, uint8_t *parts, + uint8_t *broke) { if (parts) *parts = 0; + if (broke) *broke = 0; if (!B || !ent) return 0; int fresh; volatile mhfu_monster_watch_t *w = watch_of(ent, &fresh); @@ -138,11 +154,12 @@ extern "C" uint16_t mhfu_monster_events_frame(uint32_t ent, int stale, uint8_t * uint8_t aware = mhfu_mem_read_u8(ent + MHFU_ENTITY_AWARE); uint8_t combat = in_combat(ent, aware); uint8_t severed = (uint8_t)(mhfu_mem_read_u8(ent + MHFU_ENTITY_SEVERED) & TAIL_BIT); + uint8_t enraged = (mhfu_mem_read_u32(ent + MHFU_ENTITY_FLAGS) & MHFU_MONSTER_FLAG_ENRAGED) != 0; uint16_t broken = mhfu_mem_read_u16(ent + MHFU_ENTITY_BROKEN); uint8_t flinched = stale ? 0 : mhfu_mem_read_u8(ent + MHFU_ENTITY_FLINCH_MASK); if (parts) *parts = flinched; if (!fresh) { - uint32_t usec = now_usec(); + uint32_t usec = mhfu_usec(); uint8_t part = lowest(flinched); if (aware & ~w->aware & player_bit()) put(w, ent, MHFU_MONSTER_NOTICED, MHFU_MONSTER_NO_PART, aware, usec); @@ -150,15 +167,42 @@ extern "C" uint16_t mhfu_monster_events_frame(uint32_t ent, int stale, uint8_t * put(w, ent, combat ? MHFU_MONSTER_COMBAT_ENTERED : MHFU_MONSTER_COMBAT_LEFT, MHFU_MONSTER_NO_PART, combat, usec); if (flinched) put(w, ent, MHFU_MONSTER_FLINCH, part, flinched, usec); - if (broken & ~w->broken) - put(w, ent, MHFU_MONSTER_PART_BROKEN, part, (uint16_t)(broken & ~w->broken), usec); + /* PART_BREAK's rule: a flinched part whose count reached its row's broke, the event's + * data the row's BREAK_ID bit. The count only grows, so it is once per part */ + uint8_t broke_now = 0; + uint16_t claimed = 0; + const uint8_t species = mhfu_mem_read_u8(ent + MHFU_ENTITY_SPECIES); + for (uint8_t p = 0; flinched && p < 8; p++) { + if (!((flinched >> p) & 1)) continue; + const uint32_t row = break_row(species, p); + const uint32_t count_at = ent + MHFU_ENTITY_FLINCH_PARTS + + p * MHFU_FLINCH_PART_SIZE + MHFU_FLINCH_PART_COUNT; + if (!row || mhfu_mem_read_u8(count_at) != mhfu_mem_read_u8(row + MHFU_BREAK_ROW_COUNT)) + continue; + const uint16_t id = mhfu_mem_read_u16(row + MHFU_BREAK_ROW_BREAK_ID); + const uint16_t bit = id < 16 ? (uint16_t)(1u << id) : 0; + put(w, ent, MHFU_MONSTER_PART_BROKEN, p, bit, usec); + broke_now |= (uint8_t)(1u << p); + claimed |= bit; + } + /* a break the table does not explain */ + const uint16_t fresh_bits = (uint16_t)(broken & ~w->broken & ~claimed); + if (fresh_bits) { + put(w, ent, MHFU_MONSTER_PART_BROKEN, part, fresh_bits, usec); + if (part < 8) broke_now |= (uint8_t)(1u << part); + } + if (broke) *broke = broke_now; if (severed && !w->severed) put(w, ent, MHFU_MONSTER_TAIL_CUT, MHFU_MONSTER_NO_PART, mhfu_mem_read_u8(ent + MHFU_ENTITY_SEVER_COUNT), usec); + if (enraged != w->enraged) + put(w, ent, enraged ? MHFU_MONSTER_ENRAGED : MHFU_MONSTER_CALMED, + MHFU_MONSTER_NO_PART, enraged, usec); } w->aware = aware; w->combat = combat; w->severed = severed; + w->enraged = enraged; w->broken = broken; return w->edges; } @@ -181,9 +225,9 @@ extern "C" void mhfu_monster_events_drain(void) c.ev.data = e->data; c.ev._pad = 0; B->tail++; - if (c.ev.kind < MHFU_MONSTER_NOTICED || c.ev.kind > MHFU_MONSTER_TAIL_CUT) continue; + if (c.ev.kind < MHFU_MONSTER_NOTICED || c.ev.kind > MHFU_MONSTER_KINDS) continue; c.event_id = (mhfu_event_id_t)(MHFU_EVENT_BIGMONSTER_NOTICED + c.ev.kind - 1); - c.delay = now_usec() - c.ev.usec; + c.delay = mhfu_usec() - c.ev.usec; mhfu_event_fire(c.event_id, &c); } } diff --git a/framework/src/core/move.cpp b/framework/src/core/move.cpp index a76803f..f75f74c 100644 --- a/framework/src/core/move.cpp +++ b/framework/src/core/move.cpp @@ -251,10 +251,13 @@ static void start(uint32_t ent) { copy_words(&S->move, &S->next, sizeof(mhfu_move_t)); S->pending = 0; - int quiet = noticing(ent); + /* the notice has woken the AI script, and so has the hub's entry after a reaction: its next + * pair would cut the carrier at once. Quiet it, as the stuck end does; the hub wakes it again + * once the carrier ends */ + const int quiet = noticing(ent) + || (S->next.carrier_main == MHFU_MOVE_HUB_MAIN + && S->next.carrier_sub == MHFU_MOVE_HUB_SUB); enter(ent, S->next.carrier_main, S->next.carrier_sub, 0); - /* the notice has woken the AI script, whose next pair would cut the carrier at once: quiet it, - * as the stuck end does; the hub wakes it again once the carrier ends */ if (quiet) mhfu_mem_write_u8(ent + MHFU_ENTITY_SCRIPT_WAKE, 0); begin(ent); } @@ -418,8 +421,8 @@ extern "C" void mhfu_move_init_spec(mhfu_move_t *mv, uint16_t entry) uint8_t *b = (uint8_t *)mv; for (unsigned k = 0; k < sizeof(*mv); k++) b[k] = 0; mv->entry = entry; - mv->carrier_main = 0; - mv->carrier_sub = 2; + mv->carrier_main = MHFU_MOVE_HUB_MAIN; + mv->carrier_sub = MHFU_MOVE_HUB_SUB; mv->back_main = MHFU_MOVE_NO_PAIR; } diff --git a/framework/tests/em_host.cpp b/framework/tests/em_host.cpp index 9bfd0ca..4c326b9 100644 --- a/framework/tests/em_host.cpp +++ b/framework/tests/em_host.cpp @@ -17,8 +17,13 @@ static uint32_t g_calls[64][5]; static int g_n; static float g_clip_end = 100; +/* BREAK_TABLE, where the game has it */ +static uint8_t g_breaks[MHFU_BREAK_TABLE_COUNT * MHFU_BREAK_ROW_SIZE]; + static uint8_t *at(uint32_t a, uint32_t n) { + if (a >= MHFU_BREAK_TABLE && a + n <= MHFU_BREAK_TABLE + sizeof(g_breaks)) + return g_breaks + (a - MHFU_BREAK_TABLE); if (a >= BASE && a + n <= BASE + SIZE) return g_mem + (a - BASE); if (a >= MHFU_PLAYER_ENTITY && a + n <= MHFU_PLAYER_ENTITY + sizeof(g_hunter)) return g_hunter + (a - MHFU_PLAYER_ENTITY); @@ -95,10 +100,20 @@ void mhfu_host_brain(uint32_t e) } void host_attach(uint32_t to, uint32_t from) { g_attach[0] = to; g_attach[1] = from; } +/* row i of BREAK_TABLE: the species' part breaks at its count-th flinch, setting bit id */ +void host_break_row(int i, uint8_t species, uint8_t part, uint8_t count, uint16_t id) +{ + uint8_t *r = g_breaks + (unsigned)i * MHFU_BREAK_ROW_SIZE; + r[MHFU_BREAK_ROW_SPECIES] = species; + r[MHFU_BREAK_ROW_PART] = part; + r[MHFU_BREAK_ROW_COUNT] = count; + memcpy(r + MHFU_BREAK_ROW_BREAK_ID, &id, 2); +} uint8_t *host_mem(void) { return g_mem; } uint8_t *host_hunter(void) { return g_hunter; } void host_reset(float clip_end) { + memset(g_breaks, 0, sizeof(g_breaks)); memset(g_mem, 0, sizeof(g_mem)); memset(g_hunter, 0, sizeof(g_hunter)); g_clip_end = clip_end; diff --git a/framework/tests/move_host.cpp b/framework/tests/move_host.cpp index 6db9fb8..712829f 100644 --- a/framework/tests/move_host.cpp +++ b/framework/tests/move_host.cpp @@ -20,8 +20,13 @@ static int g_n; static int g_lands = 1; /* enter-action writes the pair cells */ static float g_clip_end = 228; /* what the executor installs on every part */ +/* BREAK_TABLE, where the game has it */ +static uint8_t g_breaks[MHFU_BREAK_TABLE_COUNT * MHFU_BREAK_ROW_SIZE]; + static uint8_t *at(uint32_t a, uint32_t n) { + if (a >= MHFU_BREAK_TABLE && a + n <= MHFU_BREAK_TABLE + sizeof(g_breaks)) + return g_breaks + (a - MHFU_BREAK_TABLE); return (a >= BASE && a + n <= BASE + SIZE) ? g_mem + (a - BASE) : 0; } @@ -131,9 +136,19 @@ int host_raised(mhfu_monster_event_ctx_t *out) return n; } +/* row i of BREAK_TABLE: the species' part breaks at its count-th flinch, setting bit id */ +void host_break_row(int i, uint8_t species, uint8_t part, uint8_t count, uint16_t id) +{ + uint8_t *r = g_breaks + (unsigned)i * MHFU_BREAK_ROW_SIZE; + r[MHFU_BREAK_ROW_SPECIES] = species; + r[MHFU_BREAK_ROW_PART] = part; + r[MHFU_BREAK_ROW_COUNT] = count; + memcpy(r + MHFU_BREAK_ROW_BREAK_ID, &id, 2); +} uint8_t *host_mem(void) { return g_mem; } void host_set(int lands, float clip_end) { + memset(g_breaks, 0, sizeof(g_breaks)); g_lands = lands; g_clip_end = clip_end; g_n = 0; g_step = 0; g_nraised = 0; g_cut = 0; memset(g_react, 0, sizeof(g_react)); } diff --git a/framework/tests/test_em_brain.py b/framework/tests/test_em_brain.py index aa30e76..e7bad88 100644 --- a/framework/tests/test_em_brain.py +++ b/framework/tests/test_em_brain.py @@ -1,11 +1,13 @@ # SPDX-License-Identifier: MIT # SPDX-FileCopyrightText: 2026 sp00ktober """em_vhook's brain on the host with the real move player: own moves played by slot when asked, -after the move before, or by a rule; the rules entering pairs; the tail cut holding them for its -drop; and the tail tip after the species brain. One AI frame per call (mhfu_em_host_frame: the -brain, then the C step), against em_host.cpp's engine.""" +after the move before, or by a rule; the rules entering pairs; their conditions, effects and +signals on the board; the tail cut holding them for its drop; and the tail tip after the species +brain. One AI frame per call (mhfu_em_host_frame: the brain, then the C step), against +em_host.cpp's engine.""" import ctypes +import math import struct from collections.abc import Callable @@ -22,7 +24,18 @@ UNLIMITED = 0xFFFF_FFFF BACK, PAIR = 2, 3 PLAYING, DONE = 2, 4 -NOTICED, FLINCH, BROKEN, TAIL = 1, 4, 5, 6 +KIND = a.MONSTER_EVENT_KIND.number +NOTICED, FLINCH, BROKEN, TAIL = ( + KIND("noticed"), + KIND("flinch"), + KIND("part_broken"), + KIND("tail_cut"), +) +ENRAGED, CALMED = KIND("enraged"), KIND("calmed") +ENTERED = KIND("combat_entered") +NO_PLAY = 4 # MHFU_EM_RULE_NO_PLAY +RULES = a.EM_CFG.RULES.count or 0 +MOVES = a.EM_MOVES.MOVES.count or 0 class Move(ctypes.Structure): @@ -45,6 +58,18 @@ class Move(ctypes.Structure): ] +class Op(ctypes.Structure): + _fields_ = [("op", ctypes.c_uint8), ("arg", ctypes.c_uint8), ("value", ctypes.c_int16)] + + +def cond(name: str, arg: int = 0, value: int = 0) -> Op: + return Op(a.EM_COND.number(name), arg, value) + + +def effect(name: str, arg: int = 0, value: int = 0) -> Op: + return Op(a.EM_EFFECT.number(name), arg, value) + + class Rule(ctypes.Structure): _fields_ = [ ("from_mask", ctypes.c_uint8), @@ -63,7 +88,28 @@ class Rule(ctypes.Structure): ("on", ctypes.c_uint8), ("part", ctypes.c_uint8), ("force", ctypes.c_uint8), - ("_pad", ctypes.c_uint8), + ("signal", ctypes.c_uint8), + ("conds", Op * (a.EM_RULE.CONDS.count or 0)), + ("effects", Op * (a.EM_RULE.EFFECTS.count or 0)), + ] + + +class Status(ctypes.Structure): + """mhfu_em_status_t.""" + + _fields_ = [ + *[(k, ctypes.c_uint32) for k in ("installed", "ai_ticks", "act_enters", "last_pair")], + ("frames", ctypes.c_uint32), + ("dist", ctypes.c_float), + *[(k, ctypes.c_uint32) for k in ("sub_hits", "sub_landed", "sub_last_in", "brain_fires")], + *[(k, ctypes.c_uint32) for k in ("req_pending", "req_done", "req_result", "ring_idx")], + ("ring", ctypes.c_uint32 * 8), + ("rule_fired", ctypes.c_uint32 * RULES), + ("rule_left", ctypes.c_uint32 * RULES), + ("sub_left", ctypes.c_uint32 * 4), + *[(k, ctypes.c_uint32) for k in ("events_muted", "tip_pairs", "tip_copies", "cut_waits")], + ("vars", ctypes.c_int32 * (a.EM_BOARD.VARS.count or 0)), + ("broken", ctypes.c_uint32), ] @@ -148,12 +194,49 @@ def flinch(self, parts: int, sub: int = 1) -> None: self.frame() self.poke(a.ENTITY.FLINCH_MASK, "B", 0) - def rule(self, slot: int, **kw: float) -> None: + def rule( + self, + slot: int, + conds: list[Op] | None = None, + effects: list[Op] | None = None, + signal: int | None = None, + no_play: bool = False, + **kw: float, + ) -> None: + """Rule `slot`; `signal` is the board index (the C field holds it plus one).""" r = Rule(from_sub=0xFE, dist_hi=1.0e9, count=UNLIMITED, part=ANY) for k, v in kw.items(): setattr(r, k, v) + for k, o in enumerate(conds or []): + r.conds[k] = o + for k, o in enumerate(effects or []): + r.effects[k] = o + r.signal = 0 if signal is None else signal + 1 + r.flags |= NO_PLAY if no_play else 0 self.lib.mhfu_em_rule(slot, ctypes.byref(r)) + def var(self, k: int, v: int | None = None) -> int: + if v is not None: + self.lib.mhfu_em_set_var(k, v) + return int(self.lib.mhfu_em_var(k)) + + def board(self, field: str, fmt: str = "B", at: int = 0) -> int: + """A field of the board; `at` the byte offset into it, for an array.""" + base = int(self.lib.mhfu_em_board()) + getattr(a.EM_BOARD, field) + at + return int(struct.unpack_from("<" + fmt, ctypes.string_at(base, 4))[0]) + + def go(self, pair: tuple[int, int] = (1, 3)) -> list[tuple[int, int]]: + """One AI frame from `pair`: the pairs it entered.""" + self.pair = pair + self.calls() + self.frame() + return self.entered() + + def status(self) -> "Status": + st = Status() + self.lib.mhfu_em_status(ctypes.byref(st)) + return st + @pytest.fixture(scope="module") def lib(host_lib: Callable[..., ctypes.CDLL]) -> ctypes.CDLL: @@ -175,6 +258,19 @@ def lib(host_lib: Callable[..., ctypes.CDLL]) -> ctypes.CDLL: lib.mhfu_em_host_reaction.argtypes = [ctypes.c_uint32, ctypes.c_uint8, ctypes.c_uint8] lib.mhfu_em_host_brain.argtypes = [ctypes.c_uint32] lib.mhfu_em_host_latch.argtypes = [ctypes.c_uint32] + lib.mhfu_em_host_spawn.argtypes = [ctypes.c_uint32] + lib.mhfu_em_var.argtypes = [ctypes.c_int] + lib.mhfu_em_set_var.argtypes = [ctypes.c_int, ctypes.c_int] + lib.mhfu_em_signal.argtypes = [ctypes.c_int] + lib.mhfu_em_board.restype = ctypes.c_void_p + lib.mhfu_em_status.argtypes = [ctypes.c_void_p] + lib.host_break_row.argtypes = [ + ctypes.c_int, + ctypes.c_uint8, + ctypes.c_uint8, + ctypes.c_uint8, + ctypes.c_uint16, + ] lib.mhfu_em_tip.argtypes = [ctypes.c_void_p, ctypes.c_int] return lib @@ -183,6 +279,7 @@ def lib(host_lib: Callable[..., ctypes.CDLL]) -> ctypes.CDLL: def g(lib: ctypes.CDLL) -> Game: lib.host_reset(ctypes.c_float(CLIP_END)) lib.mhfu_em_init() + lib.mhfu_em_host_spawn(ENT) game = Game(lib) game.pair = (1, 3) game.hunter_at(500.0, 0.0) @@ -587,3 +684,538 @@ def test_a_one_mesh_model_leaves_the_cut_as_before(g: Game) -> None: cut(g, meshes=1) g.frame() assert g.entered() == [(3, 6)] + + +# --- conditions, effects, signals and the board + +PLAY = {"from_mask": 1 << 1, "to_main": 3, "to_sub": 6} # from the fixture's pair, enter (3, 6) +VARS = a.EM_BOARD.VARS.count or 0 +SIGNALS = a.EM_BOARD.SIGNALS.count or 0 + + +def hp(g: Game, now: int, full: int = 1000) -> None: + g.poke(a.ENTITY.MAX_HP, "H", full) + g.poke(a.ENTITY.HP, "H", now) + + +def test_hp_conditions(g: Game) -> None: + g.rule(0, **PLAY, conds=[cond("hp_below", 0, 30)]) + hp(g, 300) + assert g.go() == [] # 30 percent is not below 30 + hp(g, 299) + assert g.go() == [(3, 6)] + g.rule(0, **PLAY, conds=[cond("hp_at_least", 0, 30)]) + assert g.go() == [] + hp(g, 300) + assert g.go() == [(3, 6)] + hp(g, 65535, 65535) # the products stay within 32 bits + g.rule(0, **PLAY, conds=[cond("hp_at_least", 0, 100)]) + assert g.go() == [(3, 6)] + + +def test_broken_conditions_follow_the_latch(g: Game) -> None: + g.rule(0, **PLAY, conds=[cond("broken", 2)]) + g.rule(1, **PLAY | {"to_sub": 7}, conds=[cond("not_broken", 2)]) + assert g.go() == [(3, 7)] + g.poke(a.ENTITY.FLINCH_MASK, "B", 0b100) + g.poke(a.ENTITY.BROKEN, "H", 1) + g.pair = (3, 7) + g.frame() # part 2 broke + g.poke(a.ENTITY.FLINCH_MASK, "B", 0) + assert g.board("BROKEN") == 0b100 + assert g.go() == [(3, 6)] # it stays latched after the flinch + + +def test_a_break_without_a_flinching_part_latches_nothing(g: Game) -> None: + g.frame() + g.poke(a.ENTITY.BROKEN, "H", 1) + g.frame() + assert g.board("BROKEN") == 0 + + +def test_enraged_and_calm(g: Game) -> None: + g.rule(0, **PLAY, conds=[cond("enraged")]) + g.rule(1, **PLAY | {"to_sub": 7}, conds=[cond("calm")]) + assert g.go() == [(3, 7)] + g.poke(a.ENTITY.FLAGS, "I", 0x20 | 0x8000) + assert g.go() == [(3, 6)] + + +def face(g: Game, yaw: float, forward: float = 1.0, left: float = 1.0) -> None: + """ENTITY.ROTATION as the engine builds it from YAW: row 2 forward, row 0 the monster's left, + each row scaled as given.""" + t = math.radians(yaw) + rows = [0.0] * 12 + rows[0], rows[2] = left * math.cos(t), -left * math.sin(t) + rows[5] = 1.0 + rows[8], rows[10] = forward * math.sin(t), forward * math.cos(t) + g.poke(a.ENTITY.ROTATION, "12f", *rows) + + +SIDES = {"front": 1, "left": 2, "right": 4, "behind": 8} + + +def side_at(g: Game, x: float, z: float, yaw: float = 0.0, forward: float = 1.0) -> str: + """The sector the hunter at (x, z) stands in, by which single-side rule fires.""" + face(g, yaw, forward) + g.hunter_at(x, z) + sides = [] + for name, mask in SIDES.items(): + g.rule(0, **PLAY, conds=[cond("side", mask)]) + if g.go(): + sides.append(name) + assert len(sides) == 1 + return sides[0] + + +@pytest.mark.parametrize( + ("yaw", "x", "z", "want"), + [ + (0, 0, 500, "front"), + (0, 500, 0, "left"), # YAW grows toward +x, the monster's left + (0, -500, 0, "right"), + (0, 0, -500, "behind"), + (90, 500, 0, "front"), + (90, 0, -500, "left"), + (90, 0, 500, "right"), + (90, -500, 0, "behind"), + (0, 500, 400, "left"), # the sectors are 90 degrees + (0, 400, 500, "front"), + (180, 0, -500, "front"), + ], +) +def test_side_sectors(g: Game, yaw: float, x: float, z: float, want: str) -> None: + assert side_at(g, x, z, yaw) == want + + +def test_side_ignores_the_rows_scale(g: Game) -> None: + assert side_at(g, 500, 400, forward=3.0) == "left" + assert side_at(g, 400, 500, forward=0.25) == "front" + + +def test_side_masks_combine(g: Game) -> None: + face(g, 0) + g.rule(0, **PLAY, conds=[cond("side", SIDES["front"] | SIDES["behind"])]) + for x, z, want in ((0, 500, True), (0, -500, True), (500, 0, False), (-500, 0, False)): + g.hunter_at(x, z) + assert bool(g.go()) is want + + +def test_chance_of_all_and_none(g: Game) -> None: + g.rule(0, **PLAY, conds=[cond("chance", 0, 100)]) + assert all(g.go() for _ in range(20)) + g.rule(0, **PLAY, conds=[cond("chance", 0, 0)]) + assert not any(g.go() for _ in range(20)) + + +def test_chance_is_a_fraction(g: Game) -> None: + g.rule(0, **PLAY, conds=[cond("chance", 0, 50)]) + assert 120 < sum(bool(g.go()) for _ in range(400)) < 280 + + +def test_chance_is_rolled_last(g: Game) -> None: + """Only a rule whose other conditions and gates hold rolls its chance.""" + hp(g, 500) + g.rule(0, **PLAY, conds=[cond("chance", 0, 50), cond("hp_below", 0, 1)]) + g.rule(1, **PLAY, cooldown=10_000, conds=[cond("chance", 0, 50)]) + g.frame() # seeds the board + rng = g.board("RNG", "I") + g.frames(10) + assert g.board("RNG", "I") == rng + g.hunter_at(5000.0, 0.0) + g.rule(1, **PLAY, dist_hi=100.0, conds=[cond("chance", 0, 50)]) + g.frames(10) + assert g.board("RNG", "I") == rng + + +@pytest.mark.parametrize( + ("op", "var", "want"), + [ + ("var_at_least", 1, False), + ("var_at_least", 2, True), + ("var_at_least", 3, True), + ("var_below", 2, False), + ("var_below", 1, True), + ("var_below", -1, True), + ("var_equal", 2, True), + ("var_equal", 3, False), + ], +) +def test_var_conditions(g: Game, op: str, var: int, want: bool) -> None: + g.rule(0, **PLAY, conds=[cond(op, 3, 2 if var > 0 else 0)]) + g.var(3, var) + assert bool(g.go()) is want + + +def test_every_condition_must_hold(g: Game) -> None: + hp(g, 100) + g.rule(0, **PLAY, conds=[cond("hp_below", 0, 50), Op(0), cond("var_equal", 0, 1)]) + assert g.go() == [] + g.var(0, 1) + assert g.go() == [(3, 6)] + hp(g, 900) + assert g.go() == [] + + +def test_effects_run_each_fire(g: Game) -> None: + g.rule(0, **PLAY, effects=[effect("var_add", 1, 5), effect("var_set", 2, 9)]) + for _ in range(3): + g.go() + assert (g.var(1), g.var(2)) == (15, 9) + g.rule(0, **PLAY, count=1, effects=[effect("var_add", 1, 1)]) + for _ in range(3): + g.go() + assert g.var(1) == 16 + + +def test_var_add_saturates(g: Game) -> None: + g.rule(0, **PLAY, effects=[effect("var_add", 0, 20000)]) + g.go() + g.go() + assert g.var(0) == 32767 + g.rule(0, **PLAY, effects=[effect("var_add", 0, -20000)]) + for _ in range(4): + g.go() + assert g.var(0) == -32768 + + +def test_a_play_that_fails_applies_no_effect(g: Game) -> None: + g.rule(0, **PLAY | {"play_move": 5}, effects=[effect("var_add", 0, 1)]) # an empty slot + g.go() + assert g.var(0) == 0 + + +def test_a_flinch_take_applies_effects(g: Game) -> None: + g.own(0, 59) + g.rule(0, on=FLINCH, part=0, play_move=1, effects=[effect("var_add", 0, 1)]) + g.frame() + g.flinch(0b001) + assert g.entered() == [(0, 2)] and g.var(0) == 1 + + +def test_a_flinch_rule_keeps_its_conditions(g: Game) -> None: + g.own(0, 59) + g.rule(0, on=FLINCH, play_move=1, conds=[cond("var_at_least", 0, 1)]) + g.frame() + g.flinch(0b001) + assert g.entered() == [(4, 1)] # the counter is 0: the host's reaction + g.var(0, 1) + g.frame() + g.flinch(0b001) + assert g.entered() == [(0, 2)] + + +def test_a_flinch_rule_with_a_chance_decides_before_the_flinch(g: Game) -> None: + g.own(0, 59) + g.rule(0, on=FLINCH, part=0, play_move=1, conds=[cond("chance", 0, 100)]) + g.rule(1, on=FLINCH, part=1, play_move=1, conds=[cond("chance", 0, 0)]) + g.frame() + g.flinch(0b010) + assert g.entered() == [(4, 1)] # part 1's rule never rolls a hit + g.frames(2) + g.flinch(0b001) + assert g.entered() == [(0, 2)] + + +def test_a_no_play_rule_goes_on_to_the_next(g: Game) -> None: + g.rule(0, **PLAY, no_play=True, effects=[effect("var_add", 0, 1)]) + g.rule(1, **PLAY) + assert g.go() == [(3, 6)] and g.var(0) == 1 + + +def test_a_no_play_rule_does_not_stop_the_scan_or_restart_the_dwell(g: Game) -> None: + g.rule(0, **PLAY, no_play=True, effects=[effect("var_add", 0, 1)]) + g.rule(1, **PLAY, min_frames=3) + g.frames(3) + assert g.entered() == [] + g.frame() # the pair has stood 3 frames, however often rule 0 fired + assert g.entered() == [(3, 6)] and g.var(0) == 4 + + +def test_a_no_play_rule_counts_its_fires(g: Game) -> None: + g.rule(0, **PLAY, no_play=True, count=2, effects=[effect("var_add", 0, 1)]) + g.frames(5) + st = g.status() + assert g.var(0) == 2 and (st.rule_fired[0], st.rule_left[0]) == (2, 0) + g.rule(1, **PLAY, no_play=True, cooldown=3, effects=[effect("var_add", 1, 1)]) + g.frames(9) + assert g.var(1) == 3 + + +def test_a_no_play_rule_takes_no_play_target(g: Game) -> None: + g.own(0, 46) + g.rule(0, **PLAY, no_play=True, play_move=1) + g.frames(2) + assert g.entered() == [] and g.lib.mhfu_em_playing() == -1 + + +def test_a_no_play_flinch_rule_leaves_the_reaction(g: Game) -> None: + g.rule(0, on=FLINCH, no_play=True, effects=[effect("var_add", 0, 1)]) + g.frame() + g.flinch(0b001) + assert g.entered() == [(4, 1)] and g.var(0) == 1 + g.flinch(0b100) + assert g.var(0) == 2 + + +def test_a_no_play_flinch_rule_counts_a_replaced_flinch(g: Game) -> None: + g.own(0, 59) + g.rule(0, on=FLINCH, no_play=True, effects=[effect("var_add", 0, 1)]) + g.rule(1, on=FLINCH, part=0, play_move=1) + g.frame() + g.flinch(0b001) + assert g.entered() == [(0, 2)] and g.var(0) == 1 + + +def test_a_no_play_rule_counts_while_a_request_takes_the_frame(g: Game) -> None: + g.rule(0, on=NOTICED, no_play=True, effects=[effect("var_add", 0, 1)]) + g.frame() + g.lib.mhfu_em_request(3, 6, 1) + g.poke(a.ENTITY.AWARE, "B", 1) + g.frame() + assert (3, 6) in g.entered() and g.var(0) == 1 + + +def test_a_counter_then_a_rule_on_it_in_one_frame(g: Game) -> None: + """on flinch +1, then at two flinches a rule plays: the scan runs in slot order.""" + g.rule(0, on=FLINCH, no_play=True, effects=[effect("var_add", 0, 1)]) + g.rule(1, from_mask=1 << 4, to_main=3, to_sub=6, conds=[cond("var_at_least", 0, 2)]) + g.frame() + g.flinch(0b001) + assert g.var(0) == 1 and (3, 6) not in g.entered() + g.flinch(0b001) + assert g.var(0) == 2 and (3, 6) in g.entered() + + +def test_a_counter_runs_before_the_rules_that_play_wherever_it_sits(g: Game) -> None: + g.rule(0, from_mask=1 << 4, to_main=3, to_sub=6, conds=[cond("var_at_least", 0, 2)]) + g.rule(1, on=FLINCH, no_play=True, effects=[effect("var_add", 0, 1)]) + g.frame() + g.flinch(0b001) + assert g.var(0) == 1 and (3, 6) not in g.entered() + g.flinch(0b001) + assert g.var(0) == 2 and (3, 6) in g.entered() # counted this frame, the rule above saw it + + +def test_a_playing_rule_does_not_starve_the_rules_that_count(g: Game) -> None: + """A re-notice raises noticed and combat_entered in one frame: the rule on the notice plays, + and the counter on combat_entered below it in the slots still counts.""" + g.rule(0, on=NOTICED, **{"to_main": 3, "to_sub": 6}) + g.rule(1, on=ENTERED, no_play=True, effects=[effect("var_set", 0, 1)]) + g.poke(a.ENTITY.FLAGS, "I", 0x8) # the eye's own condition + g.frame() + g.poke(a.ENTITY.AWARE, "B", 1) + g.poke(a.ENTITY.COMBAT_MODE, "B", 1) + g.frame() + assert (3, 6) in g.entered() and g.var(0) == 1 + + +def test_a_break_that_flinches_is_counted_whatever_plays(g: Game) -> None: + g.rule(0, on=BROKEN, to_main=3, to_sub=6) + g.rule(1, on=FLINCH, no_play=True, effects=[effect("var_add", 0, 1)]) + g.frame() + g.poke(a.ENTITY.FLINCH_MASK, "B", 0b001) + g.poke(a.ENTITY.BROKEN, "H", 1) + g.frame() + assert (3, 6) in g.entered() and g.var(0) == 1 + + +def test_a_signal_fires_once(g: Game) -> None: + g.rule(0, signal=2, to_main=3, to_sub=6) + g.frame() + assert g.entered() == [] + assert g.lib.mhfu_em_signal(2) == 1 + g.frame() + assert g.entered() == [(3, 6)] + g.frame() + assert g.entered() == [] + + +def test_a_signal_is_taken_even_when_its_gates_fail(g: Game) -> None: + g.rule(0, signal=0, dist_hi=100.0, to_main=3, to_sub=6) + g.lib.mhfu_em_signal(0) + g.frame() + assert g.board("SIGNALS") == 0 + g.hunter_at(50.0, 0.0) + g.frame() + assert g.entered() == [] + + +def test_a_signal_reaches_every_rule_on_it(g: Game) -> None: + g.rule(0, signal=1, no_play=True, effects=[effect("var_add", 0, 1)]) + g.rule(1, signal=1, to_main=3, to_sub=6) + g.rule(2, signal=1, to_main=3, to_sub=7) + g.lib.mhfu_em_signal(1) + g.frame() + assert g.entered() == [(3, 6)] and g.var(0) == 1 + + +def test_a_signal_with_gates_and_conditions(g: Game) -> None: + g.rule(0, signal=0, **PLAY, conds=[cond("var_at_least", 0, 1)]) + g.lib.mhfu_em_signal(0) + assert g.go() == [] + g.var(0, 1) + g.lib.mhfu_em_signal(0) + assert g.go() == [(3, 6)] + g.lib.mhfu_em_signal(0) + assert g.go((2, 2)) == [] # not in its pair + + +def test_the_cut_holds_a_signal(g: Game) -> None: + g.rule(0, signal=1, to_main=3, to_sub=6) + cut(g) + g.lib.mhfu_em_signal(1) + g.frames(2) + g.lib.mhfu_em_signal(1) + g.frames(2) + assert g.entered() == [] and g.board("SIGNALS", at=1) == 1 # held on the board + drop(g) + g.frame() + assert g.entered() == [(3, 6)] + g.frame() + assert g.entered() == [] + + +def test_the_cut_holds_a_counter(g: Game) -> None: + g.rule(0, from_mask=0xFF, no_play=True, effects=[effect("var_add", 0, 1)]) + cut(g) + g.frames(3) + assert g.var(0) == 0 + drop(g) + g.frame() + assert g.var(0) == 1 + + +ENT2 = BASE + 0x2000 + + +def test_the_board_belongs_to_one_monster(g: Game) -> None: + g.var(3, 7) + g.frame() + assert g.var(3) == 7 and g.board("ENTITY", "I") == ENT # its spawn started the board + g.lib.mhfu_em_signal(5) + g.poke(a.ENTITY.FLINCH_MASK, "B", 1) + g.poke(a.ENTITY.BROKEN, "H", 1) + g.frame() + g.poke(a.ENTITY.FLINCH_MASK, "B", 0) + assert g.var(3) == 7 and g.board("BROKEN") == 1 + g.lib.mhfu_em_signal(5) + g.lib.mhfu_em_host_frame(ENT2) # a second monster of the species shares it + assert g.var(3) == 7 and g.board("ENTITY", "I") == ENT + g.lib.mhfu_em_host_spawn(ENT2) # a new monster starts it afresh + assert (g.var(3), g.board("SIGNALS", at=5), g.board("BROKEN")) == (0, 0, 0) + assert g.board("ENTITY", "I") == ENT2 and g.board("RNG", "I") != 0 + + +def test_the_first_monster_seen_owns_an_unclaimed_board(g: Game) -> None: + g.lib.mhfu_em_host_quest() # no monster owns it + g.var(3, 7) + g.frame() + assert g.var(3) == 0 and g.board("ENTITY", "I") == ENT + + +def test_the_quest_clears_the_board_and_reseeds_the_roll(g: Game) -> None: + g.rule(0, **PLAY, conds=[cond("chance", 0, 50)]) + seed = g.board("RNG", "I") + for _ in range(5): + g.go() + assert g.board("RNG", "I") != seed + g.var(1, 4) + g.lib.mhfu_em_host_quest() + g.frame() + assert g.var(1) == 0 and g.board("RNG", "I") == seed + + +def test_the_status_carries_the_board(g: Game) -> None: + g.frame() + g.var(2, -5) + g.poke(a.ENTITY.FLINCH_MASK, "B", 0b10) + g.poke(a.ENTITY.BROKEN, "H", 1) + g.frame() + st = g.status() + assert (st.vars[2], st.broken) == (-5, 0b10) + + +def test_a_bad_board_index_is_ignored(g: Game) -> None: + g.var(VARS, 5) + g.var(-1, 5) + assert g.var(VARS) == 0 and g.var(-1) == 0 + assert g.lib.mhfu_em_signal(SIGNALS) == 0 and g.lib.mhfu_em_signal(-1) == 0 + g.var(0, 40_000) + assert g.var(0) == 32767 + + +def test_enraged_and_calmed_reach_their_rules(g: Game) -> None: + g.rule(0, on=ENRAGED, to_main=3, to_sub=6) + g.rule(1, on=CALMED, to_main=3, to_sub=7) + g.frame() + g.poke(a.ENTITY.FLAGS, "I", 0x20) + g.frame() + assert g.entered() == [(3, 6)] + g.frame() + assert g.entered() == [] # an edge, not a level + g.poke(a.ENTITY.FLAGS, "I", 0) + g.frame() + assert g.entered() == [(3, 7)] + + +def test_a_monster_first_seen_enraged_raises_nothing(g: Game) -> None: + g.poke(a.ENTITY.FLAGS, "I", 0x20) + g.rule(0, on=ENRAGED, to_main=3, to_sub=6) + g.frames(2) + assert g.entered() == [] + + +@pytest.mark.parametrize( + "bad", + [ + {"conds": [Op(99)]}, + {"effects": [Op(99)]}, + {"conds": [cond("var_at_least", VARS)]}, + {"effects": [effect("var_add", VARS, 1)]}, + {"conds": [cond("side", 0)]}, + {"conds": [cond("broken", 8)]}, + {"signal": SIGNALS}, + {"signal": 0, "on": NOTICED}, + {"from_mask": 0}, + ], +) +def test_a_rule_that_cannot_hold_is_refused(g: Game, bad: dict[str, object]) -> None: + g.rule(0, **{**PLAY, **bad}) # type: ignore[arg-type] + assert g.go() == [] and g.status().rule_left[0] == 0 + + +def test_the_last_rule_and_move_slots_work(g: Game) -> None: + assert RULES > 32 and MOVES > 32 # past a 32-bit mask + assert g.own(MOVES - 1, 59) and not g.own(MOVES, 59) + g.rule(RULES - 1, on=FLINCH, part=0, play_move=MOVES) + g.frame() + g.flinch(0b001) + assert g.entered() == [(0, 2)] + g.frame() + assert ("X", ENT, 59, 0, 0) in g.calls() and g.lib.mhfu_em_playing() == MOVES - 1 + + +def flinch_part(g: Game, part: int, count: int) -> None: + g.poke(a.ENTITY.FLINCH_PARTS + part * a.FLINCH_PART.size + a.FLINCH_PART.COUNT, "B", count) + + +def test_the_second_foreleg_break_reaches_its_rule(g: Game) -> None: + """Both forelegs break at their first flinch with one BREAK_ID: ENTITY.BROKEN gains no bit for + the second, and only the part that reached its count counts as broken.""" + g.lib.host_break_row(0, 0x4B, 4, 1, 1) + g.lib.host_break_row(1, 0x4B, 6, 1, 1) + g.poke(a.ENTITY.SPECIES, "B", 0x4B) + g.rule(0, on=BROKEN, part=4, to_main=3, to_sub=6) + g.rule(1, on=BROKEN, part=6, to_main=3, to_sub=7) + g.frame() + flinch_part(g, 4, 1) + g.poke(a.ENTITY.FLINCH_MASK, "B", 1 << 4) + g.poke(a.ENTITY.BROKEN, "H", 0b10) + g.frame() + assert g.entered() == [(3, 6)] and g.board("BROKEN") == 1 << 4 + flinch_part(g, 4, 2) + flinch_part(g, 6, 1) + g.poke(a.ENTITY.FLINCH_MASK, "B", (1 << 4) | (1 << 6)) + g.frame() + assert g.entered() == [(3, 7)] and g.board("BROKEN") == (1 << 4) | (1 << 6) diff --git a/framework/tests/test_lua_api.py b/framework/tests/test_lua_api.py index 1427196..c7cb53a 100644 --- a/framework/tests/test_lua_api.py +++ b/framework/tests/test_lua_api.py @@ -1,6 +1,7 @@ # SPDX-License-Identifier: MIT # SPDX-FileCopyrightText: 2026 sp00ktober import importlib.util +import re from pathlib import Path import pytest @@ -66,3 +67,20 @@ def test_the_declaration_parses() -> None: api = tool.parse((FRAMEWORK / "lua" / "meta" / "mhfu.d.lua").read_text(encoding="utf-8")) assert api.version >= 1 assert "on_bigmonster_action" in api.funcs + + +def test_the_name_lists_follow_the_addresses() -> None: + """The rule ops and monster events are named in addresses.toml; the declaration lists them + for completion, and a name added there must be added here.""" + from mhfu import addresses + + text = (FRAMEWORK / "lua" / "meta" / "mhfu.d.lua").read_text(encoding="utf-8") + + def names(alias: str) -> tuple[str, ...]: + found = re.search(rf"^---@alias {re.escape(alias)} (.+)$", text, re.M) + assert found, alias + return tuple(re.findall(r'"(\w+)"', found[1])) + + assert names("mhfu.EmCond") == addresses.EM_COND.names + assert names("mhfu.EmEffect") == addresses.EM_EFFECT.names + assert names("mhfu.MonsterEventKind") == addresses.MONSTER_EVENT_KIND.names diff --git a/framework/tests/test_mhfu_port.py b/framework/tests/test_mhfu_port.py index 305adcf..d47b2a2 100644 --- a/framework/tests/test_mhfu_port.py +++ b/framework/tests/test_mhfu_port.py @@ -589,3 +589,77 @@ def test_a_new_moves_module_relinks_the_port(lua: Any) -> None: def test_tip_on_latch(lua: Any) -> None: memory(lua) lua.execute(TIP) + + +CONDITIONS = """ +local SEAM = { rules = {}, vars = {}, signals = {} } +function mhfu.em_installed() return true end +function mhfu.em_substitute() return true end +function mhfu.em_rule(slot, r) SEAM.rules[slot] = r; return true end +function mhfu.em_var(k, v) if v ~= nil then SEAM.vars[k] = v end; return SEAM.vars[k] or 0 end +function mhfu.em_signal(k) SEAM.signals[#SEAM.signals + 1] = k; return true end +package.loaded.z_moves = { + moves = { stamp = { entry = 46 }, lunge = { main = 1, sub = 4 } }, + rules = { + { play = "stamp", on = "flinch", part = 0, effects = { { "var_add", 2, 1 } }, + conds = { { "hp_below", 0, 30 }, { "chance", 0, 50 } } }, + { no_play = true, on = "flinch", effects = { { "var_add", 0, 1 } } }, + { play = "lunge", signal = 1, from_main = { 0, 1 }, conds = { { "var_at_least", 0, 2 } } }, + { no_play = true, from_main = { 0 }, effects = { { "var_set", 3, 0 } }, cooldown = 30 }, + { no_play = true, effects = { { "var_add", 0, 1 } } }, + { play = "stamp", signal = 99 }, + { play = "stamp", on = "enraged", conds = { {}, {}, {}, {}, {} } }, + }, + vars = { kills = 0, rage = 2 }, signals = { go = 1 }, +} +local P = require("mhfu_port") +local z = P.define{ name = "z", species = 75 } +z.ent = ENT +ticks(1) + +-- the fields pass through; a rule with no play target is the one that plays nothing +local r = SEAM.rules +assert(r[0].on == "flinch" and r[0].play_move == 0 and r[0].part == 0 and not r[0].no_play) +assert(r[0].conds[1][1] == "hp_below" and r[0].conds[1][3] == 30 and r[0].conds[2][1] == "chance") +assert(r[0].effects[1][1] == "var_add" and r[0].effects[1][2] == 2) +assert(r[1].no_play == true and r[1].on == "flinch") +assert(r[1].play_move == nil and r[1].to_main == nil) +assert(r[1].effects[1][2] == 0) +assert(r[2].signal == 1 and r[2].from_mask == 3 and r[2].to_main == 1 and r[2].to_sub == 4) +assert(r[3].no_play and r[3].from_mask == 1 and r[3].cooldown == 30) +assert(r[4] == nil and count("no `from`, `on` or `signal`") == 1, "a rule with no trigger") +assert(r[5] == nil and r[6] == nil and count("signal 99 is not 0%.%.") == 1) +assert(count("holds %d+ conditions") == 1, "more conditions than a rule holds") +assert(count("rule 2: on flinch, main 0x00") == 1 and count("%-> no play") == 2) +assert(count("signal 1 %-> 'lunge'") == 1) + +-- the board by name +assert(z:var("rage") == 0 and z:var("rage", 5) == 5 and SEAM.vars[2] == 5 and z:var("rage") == 5) +assert(z:var("nope") == 0 and z:var("nope") == 0) +assert(z:fire("go") == true and SEAM.signals[1] == 1) +assert(z:fire("nope") == false and z:fire("nope") == false) +ticks(1) +assert(count("var%('nope'%)") == 1 and count("fire%('nope'%)") == 1, "each said once") + +-- a framework without the board +mhfu.em_var, mhfu.em_signal = nil, nil +assert(z:var("rage") == 0 and z:fire("go") == false) + +-- a hand-written port names its own +local h = P.define{ + name = "h", species = 75, moves = { stamp = { entry = 46 } }, + vars = { n = 4 }, signals = { s = 7 }, + rules = { { no_play = true, signal = 7, effects = { { "var_add", 4, 1 } } } }, +} +h.ent = ENT +mhfu.em_var = function(k) return k end +mhfu.em_signal = function(k) return k == 7 end +ticks(2) +assert(h:var("n") == 4 and h:fire("s") == true) +assert(SEAM.rules[0].signal == 7 and SEAM.rules[0].no_play) +""" + + +def test_rule_conditions_and_the_board(lua: Any) -> None: + memory(lua) + lua.execute(CONDITIONS) diff --git a/framework/tests/test_monster_events.py b/framework/tests/test_monster_events.py index 791da3d..21d488f 100644 --- a/framework/tests/test_monster_events.py +++ b/framework/tests/test_monster_events.py @@ -14,6 +14,7 @@ BASE = 0x09000000 # move_host.cpp's memory; noaddr ENT = BASE + 0x1000 NOTICED, ENTERED, LEFT, FLINCH, BROKEN, TAIL = range(1, 7) +ENRAGED, CALMED = a.MONSTER_EVENT_KIND.number("enraged"), a.MONSTER_EVENT_KIND.number("calmed") class Event(ctypes.Structure): @@ -44,7 +45,19 @@ def lib(host_lib: Callable[..., ctypes.CDLL]) -> ctypes.CDLL: ) lib.host_mem.restype = ctypes.c_void_p lib.host_set.argtypes = [ctypes.c_int, ctypes.c_float] - lib.mhfu_monster_events_frame.argtypes = [ctypes.c_uint32, ctypes.c_int, ctypes.c_void_p] + lib.mhfu_monster_events_frame.argtypes = [ + ctypes.c_uint32, + ctypes.c_int, + ctypes.c_void_p, + ctypes.c_void_p, + ] + lib.host_break_row.argtypes = [ + ctypes.c_int, + ctypes.c_uint8, + ctypes.c_uint8, + ctypes.c_uint8, + ctypes.c_uint16, + ] lib.mhfu_monster_events_frame.restype = ctypes.c_uint16 lib.mhfu_monster_events.restype = ctypes.c_void_p return lib @@ -59,11 +72,21 @@ def poke(self, off: int, fmt: str, *values: int) -> None: struct.pack_into("<" + fmt, self.mem, ENT - BASE + off, *values) def frame(self, stale: int = 0) -> tuple[int, int]: - """(edges, parts) of one frame.""" - parts = ctypes.c_uint8() - edges = self.lib.mhfu_monster_events_frame(ENT, stale, ctypes.byref(parts)) + """(edges, parts) of one frame; self.broke the parts that broke in it.""" + parts, broke = ctypes.c_uint8(), ctypes.c_uint8() + edges = self.lib.mhfu_monster_events_frame( + ENT, stale, ctypes.byref(parts), ctypes.byref(broke) + ) + self.broke = broke.value return int(edges), parts.value + def flinch_part(self, part: int, count: int) -> None: + """A flinch of `part` that took its flinch count to `count`.""" + self.poke( + a.ENTITY.FLINCH_PARTS + part * a.FLINCH_PART.size + a.FLINCH_PART.COUNT, "B", count + ) + self.poke(a.ENTITY.FLINCH_MASK, "B", 1 << part) + def raised(self) -> list[tuple[int, int, int, int]]: """(kind, part, data, frame) of each event the poll raises.""" self.lib.mhfu_monster_events_drain() @@ -85,6 +108,7 @@ def g(lib: ctypes.CDLL) -> Game: def test_first_sight_raises_nothing(g: Game) -> None: g.poke(a.ENTITY.AWARE, "B", 1) + g.poke(a.ENTITY.FLAGS, "I", 0x8 | 0x20) g.poke(a.ENTITY.BROKEN, "H", 4) g.lib.mhfu_monster_events_host_quest() g.frame() @@ -134,6 +158,68 @@ def test_break_and_tail_once(g: Game) -> None: assert g.raised() == [(FLINCH, 0, 1, 2), (BROKEN, 0, 4, 2), (TAIL, 0xFF, 1, 2)] +def test_enraged_and_calmed_once(g: Game) -> None: + g.poke(a.ENTITY.FLAGS, "I", 0x8 | 0x20) + g.frame() + g.frame() + g.poke(a.ENTITY.FLAGS, "I", 0x8) + g.frame() + assert g.raised() == [(ENRAGED, 0xFF, 1, 2), (CALMED, 0xFF, 0, 4)] + + +def test_parts_sharing_a_break_id_each_break(g: Game) -> None: + """em75's forelegs break at their first flinch with one BREAK_ID, so ENTITY.BROKEN gains a bit + for the first only; the head breaks at its second, with another.""" + g.lib.host_break_row(0, 0x4B, 4, 1, 1) + g.lib.host_break_row(1, 0x4B, 6, 1, 1) + g.lib.host_break_row(2, 0x4B, 0, 2, 2) + g.poke(a.ENTITY.SPECIES, "B", 0x4B) + g.flinch_part(4, 1) + g.poke(a.ENTITY.BROKEN, "H", 0b10) + g.frame() + assert g.broke == 1 << 4 + g.flinch_part(6, 1) # BROKEN has the bit already + g.frame() + assert g.broke == 1 << 6 + g.flinch_part(0, 1) # the head's first flinch breaks nothing + g.frame() + assert g.broke == 0 + g.flinch_part(0, 2) + g.poke(a.ENTITY.BROKEN, "H", 0b110) + g.frame() + assert g.broke == 1 << 0 + kinds = [(r[0], r[1], r[2]) for r in g.raised() if r[0] == BROKEN] + assert kinds == [(BROKEN, 4, 0b10), (BROKEN, 6, 0b10), (BROKEN, 0, 0b100)] + + +def test_a_part_breaks_once(g: Game) -> None: + g.lib.host_break_row(0, 0x4B, 6, 1, 1) + g.poke(a.ENTITY.SPECIES, "B", 0x4B) + g.flinch_part(6, 1) + g.frame() + for count in (2, 3): # later flinches pass the row's count + g.flinch_part(6, count) + g.frame() + assert [r[0] for r in g.raised()].count(BROKEN) == 1 + + +def test_another_species_row_or_a_stale_frame_breaks_nothing(g: Game) -> None: + g.lib.host_break_row(0, 0x4C, 6, 1, 1) + g.poke(a.ENTITY.SPECIES, "B", 0x4B) + g.flinch_part(6, 1) + g.frame() + g.lib.host_break_row(1, 0x4B, 6, 1, 1) + g.frame(stale=1) # the host step did not run: the mask is left over + assert BROKEN not in [r[0] for r in g.raised()] + + +def test_a_break_the_table_does_not_explain_is_still_raised(g: Game) -> None: + g.poke(a.ENTITY.FLINCH_MASK, "B", 0b100) + g.poke(a.ENTITY.BROKEN, "H", 0b100) + g.frame() + assert g.broke == 0b100 and g.raised()[-1][:3] == (BROKEN, 2, 0b100) + + def test_a_full_ring_drops(g: Game) -> None: for k in range(40): g.poke(a.ENTITY.FLINCH_MASK, "B", 1 + k % 2) diff --git a/framework/tests/test_move.py b/framework/tests/test_move.py index bbb3365..dd3f7f4 100644 --- a/framework/tests/test_move.py +++ b/framework/tests/test_move.py @@ -432,6 +432,16 @@ def test_the_script_keeps_its_wake_in_combat(g: Game) -> None: assert g.peek(a.ENTITY.SCRIPT_WAKE, "B") == (1,) +def test_the_script_is_quieted_on_the_hub_in_combat(g: Game) -> None: + """The hub's entry after a reaction wakes the script, which would cut the carrier at once.""" + notice(g, 1) + g.poke(a.ENTITY.SCRIPT_WAKE, "B", 1) + g.play(46, carrier_sub=2) # (0, 2) + g.frame() + assert g.calls()[0] == ("E", ENT, 0, 2, 0) + assert g.peek(a.ENTITY.SCRIPT_WAKE, "B") == (0,) + + def test_the_wait_has_an_end(g: Game) -> None: notice(g, 0) g.play(46) diff --git a/packages/mhfu-port/src/mhfu_port/behaviour.py b/packages/mhfu-port/src/mhfu_port/behaviour.py index ff18c4d..7904d56 100644 --- a/packages/mhfu-port/src/mhfu_port/behaviour.py +++ b/packages/mhfu-port/src/mhfu_port/behaviour.py @@ -3,8 +3,10 @@ """A port's behaviour graph: blocks on a canvas, wired into paths, compiled to the rule table. A path starts at an event or state block no other block feeds, or at a block a move lists in -`during`, walks `next` and plays the moves a reached block lists in `play`. One path is one -`Rule`, in canvas order: higher is checked first. `KINDS` is the schema of a block; the loader, +`during`, walks `next` and plays the moves a reached block lists in `play`; one that ends at an +effect block with neither `next` nor `play` plays nothing and only applies its effects. One path +is one `Rule`: the no-play paths first, then the playing ones, each group in canvas order, higher +first. That is the order the brain runs them in. `KINDS` is the schema of a block; the loader, the compiler and the studio all read it. This module sits under `manifest`: the names the manifest and the graph share are defined here. @@ -47,13 +49,27 @@ def toml(key: str) -> dict[str, str]: """The events a rule's `part` narrows.""" MAIN_STATES = range(8) """A big monster's behaviour main states.""" +IDLE_MAIN = 0 +"""The main state the base monster idles in.""" PARTS = range(hitzone.PART_MASK + 1) SEAM_RULES: int = addresses.EM_CFG.RULES.count or 0 """Rules the framework's native brain seam holds.""" +SEAM_CONDS: int = addresses.EM_RULE.CONDS.count or 0 +SEAM_EFFECTS: int = addresses.EM_RULE.EFFECTS.count or 0 +"""Conditions and effects one rule holds.""" +BOARD_VARS: int = addresses.EM_BOARD.VARS.count or 0 +BOARD_SIGNALS: int = addresses.EM_BOARD.SIGNALS.count or 0 +"""Counters and flags, and signals, one monster's board holds.""" +SIDES = ("front", "left", "right", "behind") +"""The hunter's sectors of the monster's facing; bit k of a side mask is `SIDES[k]`.""" +S16 = (-0x8000, 0x7FFF) +"""A counter's range.""" UNLIMITED_DIST = 1.0e9 -Role = Literal["event", "state", "condition", "modifier"] -ParamType = Literal["int", "float", "choice", "part", "mains"] +Role = Literal["event", "state", "condition", "modifier", "effect"] +ParamType = Literal["int", "float", "choice", "part", "mains", "var", "signal", "sides"] +NAMES: tuple[ParamType, ...] = ("var", "signal") +"""The param types that hold a name (`ID`): a counter or flag, and a signal.""" @dataclass(frozen=True) @@ -62,7 +78,8 @@ class Param: name: str type: ParamType - """`part` is a body part index; `mains` a non-empty list of main states.""" + """`part` is a body part index; `mains` a non-empty list of main states; `var` and `signal` + a name (`ID`) and `sides` a non-empty list from `choices`.""" title: str default: object = None """None: required unless `optional`.""" @@ -106,6 +123,8 @@ class Kind: ), "part_broken": ("On part break", "A part of the monster breaks", ""), "tail_cut": ("On tail cut", "The monster's tail is cut off", "tail cut off"), + "enraged": ("On enraged", "The monster becomes enraged", "becomes enraged"), + "calmed": ("On calmed", "The monster calms down", "calms down"), } _PART = Param( "part", "part", "Of part", optional=True, tip="Only a flinch or break of this part; else any" @@ -128,6 +147,22 @@ def _span(lo: object, hi: object) -> str: return "any distance" if not lo else f"from {_n(lo)}" +def _share(lo: object, hi: object) -> str: + if hi is not None: + return f"{_n(lo)} \u2013 {_n(hi)} %" + return f"{_n(lo)} % or more" if lo else "any HP" + + +_TESTS = {"at least": "var_at_least", "below": "var_below", "exactly": "var_equal"} +"""A counter test -> its condition op.""" +_BROKEN = {"broken": "broken", "not broken": "not_broken"} +_RAGE = {"enraged": "enraged", "calm": "calm"} +"""A choice -> its condition op.""" +_FLAG = {"set": 1, "clear": 0} +"""A flag state -> its value.""" +_NAME_TIP = "A lowercase name: letters, digits and _" + + def _event(name: Event) -> Kind: words = name.replace("_", " ") title, tip, says = _EVENT_WORDS.get(name, (f"On {words}", f"The monster event {words}", words)) @@ -146,6 +181,35 @@ def _event(name: Event) -> Kind: k.name: k for k in ( *map(_event, EVENTS), + Kind( + "on_signal", + "event", + "On signal", + ( + Param( + "name", "signal", "Signal", tip=_NAME_TIP + "; a mod raises it with port:fire" + ), + ), + "A mod raises this signal", + summary=lambda v: str(v["name"]), + by="name", + ), + Kind( + "any_time", + "state", + "Any time", + (), + "Fires every AI frame in any main state of the base monster, while no own move plays", + summary=lambda _: "between moves", + ), + Kind( + "idle", + "state", + "Idle", + (), + "Fires every AI frame the base monster idles, while no own move plays", + summary=lambda _: "between moves", + ), Kind( "host_state", "state", @@ -253,6 +317,149 @@ def _event(name: Event) -> Kind: "The mode the base monster's action is entered with", summary=lambda v: f"mode {v['mode']}", ), + Kind( + "monster_hp", + "condition", + "Monster HP", + ( + Param( + "lo", "int", "At least %", 0, lo=0, hi=100, tip="Its HP share is this or more" + ), + Param( + "hi", + "int", + "Below %", + optional=True, + lo=0, + hi=100, + tip="Its HP share is under this; else any", + ), + ), + "The monster's HP, as a share of its maximum", + summary=lambda v: _share(v["lo"], v.get("hi")), + ), + Kind( + "part_broken", + "condition", + "Part broken", + ( + Param("part", "part", "Part", tip="The body part"), + Param( + "state", + "choice", + "State", + "broken", + choices=tuple(_BROKEN), + tip="Whether the part has broken on this monster", + ), + ), + "A body part has, or has not, broken on this monster", + summary=lambda v: f"{v['part']} {v['state']}", + ), + Kind( + "rage", + "condition", + "Rage", + ( + Param( + "state", + "choice", + "State", + "enraged", + choices=tuple(_RAGE), + tip="Whether the monster is enraged", + ), + ), + "The monster is enraged or calm", + summary=lambda v: str(v["state"]), + ), + Kind( + "hunter_side", + "condition", + "Hunter side", + ( + Param( + "sides", + "sides", + "Sides", + choices=SIDES, + tip="Any of these sides of the monster; its left is its own", + ), + ), + "The hunter is on one of these sides of the monster", + summary=lambda v: ", ".join(v["sides"]), + ), + Kind( + "chance", + "condition", + "Chance", + (Param("percent", "int", "Percent", lo=1, hi=100, tip="How often it holds"),), + "Holds this often; rolled last, once every other block holds", + summary=lambda v: f"{_n(v['percent'])} %", + ), + Kind( + "counter_is", + "condition", + "Counter is", + ( + Param("counter", "var", "Counter", tip=_NAME_TIP), + Param( + "test", + "choice", + "Test", + "at least", + choices=tuple(_TESTS), + tip="How the counter compares with the value", + ), + Param("value", "int", "Value", 1, lo=S16[0], hi=S16[1], tip="The number"), + ), + "A counter the rules and mods share, against a number; it starts at 0", + summary=lambda v: f"{v['counter']} {v['test']} {v['value']}", + ), + Kind( + "flag_is", + "condition", + "Flag is", + ( + Param("flag", "var", "Flag", tip=_NAME_TIP), + Param("state", "choice", "State", "set", choices=tuple(_FLAG), tip="Set or clear"), + ), + "A flag the rules and mods share is set; it starts clear", + summary=lambda v: f"{v['flag']} is {v['state']}", + ), + Kind( + "counter_add", + "effect", + "Add to counter", + ( + Param("counter", "var", "Counter", tip=_NAME_TIP), + Param("by", "int", "By", 1, lo=S16[0], hi=S16[1], tip="Added; negative takes away"), + ), + "When the path fires, adds to a counter; it stops at the 16-bit limits", + summary=lambda v: f"{v['counter']} {v['by']:+}", + ), + Kind( + "counter_set", + "effect", + "Set counter", + ( + Param("counter", "var", "Counter", tip=_NAME_TIP), + Param("to", "int", "To", 0, lo=S16[0], hi=S16[1], tip="The new value"), + ), + "When the path fires, sets a counter", + summary=lambda v: f"{v['counter']} to {v['to']}", + ), + Kind( + "flag_set", + "effect", + "Set flag", + ( + Param("flag", "var", "Flag", tip=_NAME_TIP), + Param("state", "choice", "State", "set", choices=tuple(_FLAG), tip="Set or clear"), + ), + "When the path fires, sets or clears a flag", + summary=lambda v: f"{v['flag']} to {v['state']}", + ), ) } """The schema of a block, by kind name.""" @@ -290,20 +497,26 @@ class Behaviour: moves: dict[str, MoveNode] = field(default_factory=dict) +Op = tuple[str, int, int] +"""A condition or effect: its op name (`addresses.EM_COND` / `EM_EFFECT`), arg and value.""" + + @dataclass class Rule: """A trigger the native seam evaluates every frame: when the live pair is `from_move`'s or in `from_main` and has stood `min_frames` (or own move `from_move` has played that long), the - hunter is within `dist` and receding or closing as asked, play `play`, a pair or an own move; - then wait `cooldown` frames, at most `count` times (None: unlimited). A pair rule waits while - an own move plays. + hunter is within `dist` and receding or closing as asked, and every one of `conds` holds, + play `play`, a pair or an own move, and apply `effects`; then wait `cooldown` frames, at most + `count` times (None: unlimited). A pair rule waits while an own move plays. With no `play` + the rule only applies its effects and the scan goes on. With `on`, the rule fires on that monster event instead (`part`: only the flinch or break of - that part), under the same distance, cooldown and count; `on = "flinch"` plays its move in - place of the host's flinch. An own move asked while the monster's notice runs waits for - combat (else it cuts off the "!" and the roar) unless `force`.""" + that part), or on Lua signal `signal` (a board index), under the same distance, cooldown and + count; `on = "flinch"` plays its move in place of the host's flinch. An own move asked while + the monster's notice runs waits for combat (else it cuts off the "!" and the roar) unless + `force`. An op's arg is a part, a side mask or a board var index.""" - play: str + play: str | None on: Event | None = None part: int | None = None from_move: str | None = field(default=None, metadata=toml("from")) @@ -316,16 +529,20 @@ class Rule: cooldown: int = 0 count: int | None = None force: bool = False + conds: list[Op] = field(default_factory=list) + effects: list[Op] = field(default_factory=list) + signal: int | None = None label: str = "" @dataclass(frozen=True) class Path: - """A walk through the graph that plays a move.""" + """A walk through the graph that plays a move, or ends at an effect block.""" blocks: tuple[str, ...] """Block ids in order; never empty.""" - play: str + play: str | None + """None: it ends at an effect block and plays nothing.""" during: str | None """The move whose while-playing port feeds `blocks[0]`; None: `blocks[0]` is an event or state block with no input.""" @@ -338,8 +555,9 @@ class Path: def event_of(kind: str) -> Event | None: - """The monster event an event block fires on; None for any other kind.""" - return kind.removeprefix(_ON) if KINDS[kind].role == "event" else None + """The monster event an event block fires on; None for any other kind, a signal block's too.""" + event = kind.removeprefix(_ON) + return event if KINDS[kind].role == "event" and event in EVENTS else None def new_id(b: Behaviour) -> str: @@ -404,6 +622,12 @@ def _check_value(p: Param, v: object, w: str) -> None: need(v in p.choices, w, f"expected one of {want}, got {v!r}") elif p.type == "part": need(_int(v) and v in PARTS, w, f"{v!r} is not a part") + elif p.type in NAMES: + ok = isinstance(v, str) and ID.fullmatch(v) is not None + need(ok, w, f"{v!r} is not a name: a lowercase letter, then letters, digits, _") + elif p.type == "sides": + ok = isinstance(v, list) and bool(v) and all(s in p.choices for s in v) + need(ok, w, f"{v!r} is not a list of sides, from " + ", ".join(p.choices)) else: ok = isinstance(v, list) and bool(v) and all(_int(k) and k in MAIN_STATES for k in v) need(ok, w, f"{v!r} is not a list of main states, 0..{MAIN_STATES[-1]}") @@ -488,13 +712,16 @@ def sources(m: Manifest) -> list[tuple[str, str | None]]: def paths(m: Manifest) -> list[Path]: - """Every complete path, in priority order.""" + """Every complete path in the order the brain runs them: those that play nothing, then the + rest, each in canvas order.""" b = m.behaviour out: list[Path] = [] def walk(trail: tuple[str, ...], during: str | None) -> None: blk = b.blocks[trail[-1]] out.extend(Path(trail, mv, during) for mv in blk.play) + if KINDS[blk.kind].role == "effect" and not blk.play and not blk.next: + out.append(Path(trail, None, during)) for n in blk.next: if n not in trail: # a cycle is `validate`'s to refuse walk((*trail, n), during) @@ -567,15 +794,23 @@ def triggers(m: Manifest) -> list[Trigger]: def _priority(m: Manifest, p: Path) -> tuple[Any, ...]: - """Higher on the canvas first: each block's `(y, x)` in order, then the played move's.""" + """No-play paths first, then higher on the canvas: each block's `(y, x)` in order, then the + played move's.""" b = m.behaviour def row(at: tuple[float, float]) -> tuple[float, float]: return at[1], at[0] - node = b.moves.get(p.play) + node = b.moves.get(p.play) if p.play is not None else None move = row(node.at) if node is not None else (math.inf, math.inf) - return tuple(row(b.blocks[i].at) for i in p.blocks), move, p.blocks, p.play, p.during or "" + return ( + p.play is not None, + tuple(row(b.blocks[i].at) for i in p.blocks), + move, + p.blocks, + p.play or "", + p.during or "", + ) def loose(m: Manifest) -> list[str]: @@ -593,6 +828,8 @@ def _dist(p: dict[str, Any]) -> dict[str, Any]: _APPLY: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = { + "any_time": lambda p: {"from_main": list(MAIN_STATES)}, + "idle": lambda p: {"from_main": [IDLE_MAIN]}, "host_state": lambda p: {"from_main": list(p["mains"])}, "played_for": lambda p: {"min_frames": p["frames"]}, "distance": _dist, @@ -605,30 +842,119 @@ def _dist(p: dict[str, Any]) -> dict[str, Any]: """A block kind's fields of the `Rule`; an event block sets `on` and `part` itself.""" +def _hp(p: dict[str, Any], _: dict[str, int]) -> list[Op]: + lo, hi = p["lo"], p.get("hi") + if hi is not None and lo >= hi: + raise ManifestError("hp needs lo < hi") + return ([("hp_at_least", 0, lo)] if lo else []) + ([] if hi is None else [("hp_below", 0, hi)]) + + +def _sides(p: dict[str, Any], _: dict[str, int]) -> list[Op]: + return [("side", sum(1 << SIDES.index(s) for s in set(p["sides"])), 0)] + + +_CONDS: dict[str, Callable[[dict[str, Any], dict[str, int]], list[Op]]] = { + "monster_hp": _hp, + "part_broken": lambda p, _: [(_BROKEN[p["state"]], p["part"], 0)], + "rage": lambda p, _: [(_RAGE[p["state"]], 0, 0)], + "hunter_side": _sides, + "chance": lambda p, _: [("chance", 0, p["percent"])], + "counter_is": lambda p, v: [(_TESTS[p["test"]], v[p["counter"]], p["value"])], + "flag_is": lambda p, v: [ + ("var_at_least" if _FLAG[p["state"]] else "var_below", v[p["flag"]], 1) + ], +} +_EFFECTS: dict[str, Callable[[dict[str, Any], dict[str, int]], list[Op]]] = { + "counter_add": lambda p, v: [("var_add", v[p["counter"]], p["by"])], + "counter_set": lambda p, v: [("var_set", v[p["counter"]], p["to"])], + "flag_set": lambda p, v: [("var_set", v[p["flag"]], _FLAG[p["state"]])], +} +"""A block kind's ops, given its params and the vars' indices; each may repeat on a path.""" +REPEATS = frozenset(_CONDS) | frozenset(_EFFECTS) +_SIGNAL = "on_signal" + + +def names(m: Manifest, type_: str) -> list[str]: + """Every name a `type_` param (`var` or `signal`) of any block holds, sorted: a block of no + known kind or with no such value adds none. The one place names are collected.""" + found: set[str] = set() + for blk in m.behaviour.blocks.values(): + if (kind := KINDS.get(blk.kind)) is None: + continue + held = params(blk) + found |= { + held[p.name] + for p in kind.params + if p.type == type_ and isinstance(held.get(p.name), str) + } + return sorted(found) + + +def _indices(m: Manifest, type_: str) -> dict[str, int]: + return {name: i for i, name in enumerate(names(m, type_))} + + +def vars(m: Manifest) -> dict[str, int]: + """Every counter and flag the graph names -> its index on the monster's board, by name.""" + return _indices(m, "var") + + +def signals(m: Manifest) -> dict[str, int]: + """Every signal the graph waits for -> its index on the monster's board, by name.""" + return _indices(m, "signal") + + +def _ops(blocks: list[Block], table: dict[str, Any], v: dict[str, int], w: str) -> list[Op]: + out: list[Op] = [] + for blk in blocks: + if blk.kind in table: + try: + out += table[blk.kind](params(blk), v) + except ManifestError as e: + raise ManifestError(f"{w}: {e}") from None + return out + + def _rule(m: Manifest, p: Path) -> Rule: - w = "behaviour path " + " > ".join(p.blocks) + f" plays {p.play}" + w = ( + "behaviour path " + + " > ".join(p.blocks) + + (f" plays {p.play}" if p.play is not None else " plays nothing") + ) blocks = [m.behaviour.blocks[i] for i in p.blocks] kinds = [blk.kind for blk in blocks] for k in kinds: - need(kinds.count(k) == 1, w, f"has two {KINDS[k].title!r} blocks") - events = [blk for blk in blocks if KINDS[blk.kind].role == "event"] - need(len(events) <= 1, w, "has more than one event block") + need(k in REPEATS or kinds.count(k) == 1, w, f"has two {KINDS[k].title!r} blocks") + for role in ("event", "state"): + need([KINDS[k].role for k in kinds].count(role) <= 1, w, f"has more than one {role} block") kw: dict[str, Any] = {"play": p.play, "from_move": p.during} for blk in blocks: - if (event := event_of(blk.kind)) is not None: + if blk.kind == _SIGNAL: + kw["signal"] = signals(m)[params(blk)["name"]] + elif (event := event_of(blk.kind)) is not None: kw |= {"on": event, "part": blk.params.get("part")} - else: + elif blk.kind not in REPEATS: kw |= _APPLY[blk.kind](params(blk)) + v = vars(m) + kw |= {"conds": _ops(blocks, _CONDS, v, w), "effects": _ops(blocks, _EFFECTS, v, w)} if labels := [blk.label for blk in blocks if blk.label]: kw["label"] = "; ".join(labels) r = Rule(**kw) lo, hi = r.dist need(0 <= lo < hi, w, "dist needs 0 <= lo < hi") - need(r.from_move != r.play, w, "from and play are the same move") - if m.moves[r.play].own: - need(r.mode == 0, w, "mode is a pair's: an own move enters its carrier") + need(len(r.conds) <= SEAM_CONDS, w, f"{len(r.conds)} conditions, a rule holds {SEAM_CONDS}") + need( + len(r.effects) <= SEAM_EFFECTS, w, f"{len(r.effects)} effects, a rule holds {SEAM_EFFECTS}" + ) + if r.play is None: + need(bool(r.effects), w, "plays nothing and has no effect block") + need(not (r.force or r.mode), w, "force and mode go with a move to play") else: - need(r.on != "flinch", w, "on = flinch plays an own move, in place of the host's reaction") + need(r.from_move != r.play, w, "from and play are the same move") + if m.moves[r.play].own: + need(r.mode == 0, w, "mode is a pair's: an own move enters its carrier") + else: + need(r.on != "flinch", w, "on = flinch plays an own move, in place of the reaction") return r @@ -650,6 +976,11 @@ def compile(m: Manifest) -> list[Rule]: """One rule per path, in priority order; `ManifestError` for a bad path.""" rules = [_rule(m, p) for p in paths(m)] need(len(rules) <= SEAM_RULES, "behaviour", f"{len(rules)} paths, the seam holds {SEAM_RULES}") + for what, found, cap in ( + ("counters and flags", vars(m), BOARD_VARS), + ("signals", signals(m), BOARD_SIGNALS), + ): + need(len(found) <= cap, "behaviour", f"{len(found)} {what}, the board holds {cap}") return rules @@ -683,12 +1014,18 @@ def drop_move(m: Manifest, move: str) -> None: ROW, COLUMN, LEFT = 200, 280, 400 """The canvas grid `migrate` lays chains on: a rule a row, a block a column from `LEFT`. The -studio draws a block up to 241 by 174 and a move 355 by 77, so these leave a gap; a move that -a rule starts from sits left of `LEFT`, at x = 0.""" +studio draws a block 241 wide and up to 224 high (`counter_is`) and a move 355 by 77: a column +leaves a gap, a row under a `counter_is` does not. A move that a rule starts from sits left of +`LEFT`, at x = 0.""" + + +_SINCE_V1 = frozenset({"conds", "effects", "signal"}) +"""The `Rule` fields a schema 1 rule does not have.""" def _legacy(raw: dict[str, Any], where: str) -> Rule: - names = {f.metadata.get("toml", f.name): f.name for f in dataclasses.fields(Rule)} + fields = (f for f in dataclasses.fields(Rule) if f.name not in _SINCE_V1) + names = {f.metadata.get("toml", f.name): f.name for f in fields} unknown = sorted(set(raw) - set(names)) need(not unknown, where, f"unknown key(s) {', '.join(unknown)}") need("play" in raw, where, "play: missing") @@ -739,6 +1076,7 @@ def migrate(rules: list[dict[str, Any]]) -> Behaviour: for row, raw in enumerate(rules): where = f"rule[{row}]" r = _legacy(raw, where) + play: str = raw["play"] # `_legacy` saw it chain = _chain(r, where) ids = [] for col, (kind, params) in enumerate(chain): @@ -747,12 +1085,12 @@ def migrate(rules: list[dict[str, Any]]) -> Behaviour: for here, there in itertools.pairwise(ids): b.blocks[here].next.append(there) b.blocks[ids[0]].label = r.label - b.blocks[ids[-1]].play.append(r.play) + b.blocks[ids[-1]].play.append(play) if r.from_move is not None: from_row.setdefault(r.from_move, row) b.moves.setdefault(r.from_move, MoveNode((0.0, 0.0))).during.append(ids[0]) - play_row.setdefault(r.play, row) - b.moves.setdefault(r.play, MoveNode((0.0, 0.0))) + play_row.setdefault(play, row) + b.moves.setdefault(play, MoveNode((0.0, 0.0))) width = max(width, len(chain)) for name, node in b.moves.items(): if name in from_row: diff --git a/packages/mhfu-port/src/mhfu_port/moves.py b/packages/mhfu-port/src/mhfu_port/moves.py index 628daf3..d7c34e7 100644 --- a/packages/mhfu-port/src/mhfu_port/moves.py +++ b/packages/mhfu-port/src/mhfu_port/moves.py @@ -2,7 +2,9 @@ # SPDX-FileCopyrightText: 2026 sp00ktober """A port's moves and rules as the game runs them: `_moves.lua`, written with the clips module from the same build. `mhfu_port.lua`'s `P.define` reads it when a mod gives no `moves` -or `rules`. +or `rules`. A rule carries its `conds` and `effects` as `{ op, arg, value }` with the board +indices resolved, its `signal` index and, for a path that plays nothing, `no_play`; `vars` and +`signals` name those indices for `port:var` and `port:fire`. A pair move goes as the runtime paints it on its host pair. An own move goes as the move player takes it (`mhfu.move_play`): its clip's executor entry and, for `turn = "clip"`, its turn keys, @@ -215,11 +217,13 @@ def _own_move(m: Manifest, name: str, layout: Layout) -> dict[str, Any]: def _rule(r: Rule) -> dict[str, Any]: - out: dict[str, Any] = {"play": r.play} + out: dict[str, Any] = {"no_play": True} if r.play is None else {"play": r.play} if r.on is not None: out["on"] = r.on if r.part is not None: out["part"] = r.part + if r.signal is not None: + out["signal"] = r.signal if r.from_move is not None: out["from"] = r.from_move if r.from_main: @@ -238,6 +242,9 @@ def _rule(r: Rule) -> dict[str, Any]: out["count"] = r.count if r.force: out["force"] = True + for k in ("conds", "effects"): + if ops := getattr(r, k): + out[k] = [list(op) for op in ops] if r.label: out["label"] = r.label return out @@ -293,6 +300,9 @@ def lua( out += [" },", " rules = {"] out += [f" {lua_value(_rule(r))}," for r in behaviour.compile(m)] out += [" },"] + for k, names in (("vars", behaviour.vars(m)), ("signals", behaviour.signals(m))): + if names: + out.append(f" {k} = {lua_value(names)},") if tip is not None: out.append(f" tip = {lua_value(tip.pairs)},") out += ["}", ""] diff --git a/packages/mhfu-port/tests/test_behaviour.py b/packages/mhfu-port/tests/test_behaviour.py index 9344ce5..5704696 100644 --- a/packages/mhfu-port/tests/test_behaviour.py +++ b/packages/mhfu-port/tests/test_behaviour.py @@ -4,8 +4,10 @@ from pathlib import Path import pytest +from mhfu import addresses from mhfu_port import behaviour as B from mhfu_port import manifest as M +from mhfu_port import sequence from mhfu_port.behaviour import Block, ManifestError, MoveNode, Rule PORTS = Path(__file__).parents[3] / "ports" @@ -30,8 +32,8 @@ """ -def blk(kind, x=0, y=0, *, to=(), play=(), label="", **params): - return Block(kind, (x, y), params, list(to), list(play), label) +def blk(kind, x=0, y=0, *, nxt=(), play=(), label="", **params): + return Block(kind, (x, y), params, list(nxt), list(play), label) def graph(blocks, moves=None, extra=""): @@ -49,8 +51,8 @@ def plays(m): def test_kinds_are_the_events(): events = {k for k, v in B.KINDS.items() if v.role == "event"} - assert events == {f"on_{e}" for e in M.EVENTS} - assert [B.event_of(k) for k in ("on_flinch", "force")] == ["flinch", None] + assert events == {f"on_{e}" for e in M.EVENTS} | {"on_signal"} + assert [B.event_of(k) for k in ("on_flinch", "on_signal", "force")] == ["flinch", None, None] titles = {k: B.KINDS[f"on_{k}"].title for k in M.EVENTS} assert titles == { "noticed": "On notice", @@ -59,6 +61,8 @@ def test_kinds_are_the_events(): "flinch": "On flinch", "part_broken": "On part break", "tail_cut": "On tail cut", + "enraged": "On enraged", + "calmed": "On calmed", } for event in M.EVENTS: has_part = bool(B.KINDS[f"on_{event}"].params) @@ -67,7 +71,8 @@ def test_kinds_are_the_events(): def test_every_kind_has_a_rule_field(): rest = {k for k, v in B.KINDS.items() if v.role != "event"} - assert rest == set(B._APPLY) + assert rest == set(B._APPLY) | set(B._CONDS) | set(B._EFFECTS) + assert not set(B._APPLY) & B.REPEATS def _sink(way): @@ -95,7 +100,8 @@ def test_kinds_cover_the_rule(): if getattr(away, f.name) != getattr(base, f.name) or getattr(closer, f.name) != getattr(base, f.name) } - assert moved == {f.name for f in dataclasses.fields(Rule)} - {"from_move", "label"} + ops = {"conds", "effects", "signal"} + assert moved == {f.name for f in dataclasses.fields(Rule)} - {"from_move", "label"} - ops def test_params_dont_shadow_a_field(): @@ -125,6 +131,11 @@ def test_defaults_fill_in(): ("on_flinch", {"part": 3}, "part 3"), ("on_part_broken", {"part": 0}, "head"), ("on_tail_cut", {}, "tail cut off"), + ("on_enraged", {}, "becomes enraged"), + ("on_calmed", {}, "calms down"), + ("on_signal", {"name": "roar"}, "roar"), + ("any_time", {}, "between moves"), + ("idle", {}, "between moves"), ("host_state", {"mains": [1, 4]}, "states 1, 4"), ("played_for", {"frames": 15}, "15 frames or more"), ("played_for", {"frames": 1}, "1 frame or more"), @@ -137,6 +148,21 @@ def test_defaults_fill_in(): ("limit", {"times": 3}, "3 times"), ("force", {}, "plays at once"), ("mode", {"mode": 3}, "mode 3"), + ("monster_hp", {"lo": 30, "hi": 50}, "30 \u2013 50 %"), + ("monster_hp", {"lo": 30}, "30 % or more"), + ("monster_hp", {}, "any HP"), + ("part_broken", {"part": 0}, "head broken"), + ("part_broken", {"part": 0, "state": "not broken"}, "head not broken"), + ("rage", {}, "enraged"), + ("hunter_side", {"sides": ["front", "left"]}, "front, left"), + ("chance", {"percent": 30}, "30 %"), + ("counter_is", {"counter": "hits"}, "hits at least 1"), + ("counter_is", {"counter": "hits", "test": "below", "value": 3}, "hits below 3"), + ("flag_is", {"flag": "in_combat"}, "in_combat is set"), + ("counter_add", {"counter": "hits"}, "hits +1"), + ("counter_add", {"counter": "hits", "by": -3}, "hits -3"), + ("counter_set", {"counter": "hits", "to": 0}, "hits to 0"), + ("flag_set", {"flag": "in_combat", "state": "clear"}, "in_combat to clear"), ] @@ -175,7 +201,7 @@ def test_part_labels_name_the_part_first_then_number_the_rest(): def test_sources_are_the_roots_of_the_paths(): m = graph( { - "b1": blk("on_noticed", to=["b2"]), + "b1": blk("on_noticed", nxt=["b2"]), "b2": blk("force", play=["m"]), "b3": blk("host_state", mains=[1], play=["n"]), "b4": blk("played_for", frames=5, play=["n"]), @@ -191,16 +217,25 @@ def test_triggers_list_every_kind_and_group_the_zinogre_by_what_starts_it(): m = M.load(PORTS / "zinogre.toml") ts = B.triggers(m) events = [t.key for t in ts if t.section == "event"] - assert events == [k for k, v in B.KINDS.items() if v.role == "event"] - assert [t.key for t in ts if t.section == "state"] == ["host_state"] + assert events == [ + *(k for k, v in B.KINDS.items() if v.role == "event" and not v.by), + "on_signal:roar", # a signal is a trigger per name + ] + assert [t.key for t in ts if t.section == "state"] == [ + k for k, v in B.KINDS.items() if v.role == "state" + ] held = {t.key: t.ranks for t in ts if t.ranks} assert held == { - "on_noticed": (7,), - "on_flinch": (5,), - "on_part_broken": (6,), - "during:lunge": (1, 2), - "during:lunge_stop": (3,), - "during:dash": (4,), + "on_combat_entered": (1,), + "on_combat_left": (2,), + "on_flinch": (3,), + "during:lunge": (4, 5), + "during:lunge_stop": (6,), + "during:dash": (7,), + "on_noticed": (8,), + "on_part_broken": (9, 10, 11, 12), + "idle": (13, 14, 15), + "on_signal:roar": (16,), } assert [t.key for t in ts if t.section == "move"] == [ "during:lunge", @@ -208,7 +243,7 @@ def test_triggers_list_every_kind_and_group_the_zinogre_by_what_starts_it(): "during:dash", ] by_rank = sorted((r, p) for t in ts for r, p in t.rules) - assert [p for _, p in by_rank] == B.paths(m) and [r for r, _ in by_rank] == list(range(1, 8)) + assert [p for _, p in by_rank] == B.paths(m) and [r for r, _ in by_rank] == list(range(1, 17)) def test_a_flinch_of_any_part_is_one_trigger(): @@ -253,11 +288,25 @@ def test_a_kind_with_by_has_a_trigger_per_value_in_use(monkeypatch): assert named == [("on_x:a", "a", (2,)), ("on_x:c", "c", (1, 3))] +def test_each_signal_is_a_trigger_of_its_own(): + m = graph( + { + "b1": blk("on_signal", 0, 0, name="roar", play=["m"]), + "b2": blk("on_signal", 0, 100, name="calm", play=["n"]), + "b3": blk("on_signal", 0, 200, name="roar", play=["n"]), + } + ) + named = [(t.key, t.name, t.ranks) for t in B.triggers(m) if t.kind == "on_signal"] + assert named == [("on_signal:calm", "calm", (2,)), ("on_signal:roar", "roar", (1, 3))] + [bare] = [t for t in B.triggers(graph({})) if t.kind == "on_signal"] + assert (bare.key, bare.name, bare.rules) == ("on_signal", "", ()) + + def test_a_shared_block_gives_each_path_to_its_own_trigger(): m = graph( { - "b1": blk("on_noticed", 0, 0, to=["b3"]), - "b2": blk("played_for", 0, 100, frames=5, to=["b3"]), + "b1": blk("on_noticed", 0, 0, nxt=["b3"]), + "b2": blk("played_for", 0, 100, frames=5, nxt=["b3"]), "b3": blk("cooldown", 0, 200, frames=9, play=["n"]), }, {"m": MoveNode((0, 0), ["b2"])}, @@ -267,7 +316,7 @@ def test_a_shared_block_gives_each_path_to_its_own_trigger(): def test_a_chain_is_a_path(): - m = graph({"b1": blk("on_noticed", to=["b2"]), "b2": blk("force", play=["m"])}) + m = graph({"b1": blk("on_noticed", nxt=["b2"]), "b2": blk("force", play=["m"])}) assert B.paths(m) == [B.Path(("b1", "b2"), "m", None)] assert B.compile(m) == [Rule("m", on="noticed", force=True)] @@ -275,7 +324,7 @@ def test_a_chain_is_a_path(): def test_fan_out_and_play_lists(): m = graph( { - "b1": blk("on_noticed", to=["b2", "b3"]), + "b1": blk("on_noticed", nxt=["b2", "b3"]), "b2": blk("force", play=["m", "n"]), "b3": blk("limit", times=1, play=["o"]), } @@ -285,15 +334,15 @@ def test_fan_out_and_play_lists(): def test_a_block_that_plays_and_feeds(): - m = graph({"b1": blk("on_noticed", to=["b2"], play=["m"]), "b2": blk("force", play=["n"])}) + m = graph({"b1": blk("on_noticed", nxt=["b2"], play=["m"]), "b2": blk("force", play=["n"])}) assert [(p.blocks, p.play) for p in B.paths(m)] == [(("b1",), "m"), (("b1", "b2"), "n")] def test_fan_in(): m = graph( { - "b1": blk("on_noticed", to=["b3"]), - "b2": blk("host_state", y=100, to=["b3"], mains=[1]), + "b1": blk("on_noticed", nxt=["b3"]), + "b2": blk("host_state", y=100, nxt=["b3"], mains=[1]), "b3": blk("cooldown", x=200, frames=9, play=["m"]), } ) @@ -303,9 +352,9 @@ def test_fan_in(): def test_diamond(): m = graph( { - "b1": blk("on_noticed", to=["b2", "b3"]), - "b2": blk("force", x=100, y=0, to=["b4"]), - "b3": blk("limit", x=100, y=100, times=1, to=["b4"]), + "b1": blk("on_noticed", nxt=["b2", "b3"]), + "b2": blk("force", x=100, y=0, nxt=["b4"]), + "b3": blk("limit", x=100, y=100, times=1, nxt=["b4"]), "b4": blk("cooldown", x=200, frames=9, play=["m"]), } ) @@ -332,14 +381,14 @@ def test_one_block_in_two_moves(): def test_a_fed_event_is_not_a_source(): - m = graph({"b1": blk("host_state", mains=[1], to=["b2"]), "b2": blk("on_noticed", play=["m"])}) + m = graph({"b1": blk("host_state", mains=[1], nxt=["b2"]), "b2": blk("on_noticed", play=["m"])}) assert [p.blocks for p in B.paths(m)] == [("b1", "b2")] def test_labels_join(): m = graph( { - "b1": blk("on_noticed", to=["b2", "b3"], label="a"), + "b1": blk("on_noticed", nxt=["b2", "b3"], label="a"), "b2": blk("force", play=["m"], label="b"), "b3": blk("limit", times=2, play=["m"]), } @@ -376,7 +425,7 @@ def test_left_wins_a_row(): def test_later_blocks_and_moves_break_ties(): m = graph( { - "b1": blk("on_noticed", to=["b2", "b3"]), + "b1": blk("on_noticed", nxt=["b2", "b3"]), "b2": blk("force", y=50, play=["m"]), "b3": blk("limit", y=10, times=1, play=["n"]), "b4": blk("on_combat_left", x=500, play=["m", "n"]), @@ -399,7 +448,7 @@ def test_later_blocks_and_moves_break_ties(): def test_loose_is_a_finding_not_an_error(): m = graph( { - "b1": blk("played_for", frames=3, to=["b2"]), + "b1": blk("played_for", frames=3, nxt=["b2"]), "b2": blk("cooldown", frames=3, play=["m"]), "b3": blk("on_noticed"), "b4": blk("on_combat_left", play=["n"]), @@ -415,23 +464,25 @@ def test_loose_is_a_finding_not_an_error(): def test_a_path_with_a_kind_twice(): - m = graph({"b1": blk("on_noticed", to=["b2"]), "b2": blk("force", to=["b3"])}) + m = graph({"b1": blk("on_noticed", nxt=["b2"]), "b2": blk("force", nxt=["b3"])}) m.behaviour.blocks["b3"] = blk("force", play=["m"]) with pytest.raises(ManifestError, match="b1 > b2 > b3 plays m: has two 'Right away' blocks"): B.compile(m) def test_a_path_with_two_events(): - m = graph({"b1": blk("on_noticed", to=["b2"]), "b2": blk("on_flinch", play=["o"])}) + m = graph({"b1": blk("on_noticed", nxt=["b2"]), "b2": blk("on_flinch", play=["o"])}) with pytest.raises(ManifestError, match="more than one event"): B.compile(m) def _chain(*blocks, play="m", moves=None): + """The blocks wired in a line, the last playing `play` (None: it plays nothing).""" ids = [f"b{i}" for i in range(1, len(blocks) + 1)] for here, there in zip(ids, ids[1:], strict=False): blocks[ids.index(here)].next.append(there) - blocks[-1].play.append(play) + if play is not None: + blocks[-1].play.append(play) return graph(dict(zip(ids, blocks, strict=True)), moves) @@ -456,7 +507,7 @@ def test_path_errors(m, why): def test_refused_names_each_block_of_a_bad_path(): - m = graph({"b1": blk("on_noticed", to=["b2"]), "b2": blk("force", to=["b3"])}) + m = graph({"b1": blk("on_noticed", nxt=["b2"]), "b2": blk("force", nxt=["b3"])}) m.behaviour.blocks["b3"] = blk("force", play=["m"]) m.behaviour.blocks["b4"] = blk("on_flinch", play=["o"]) why = {i: "has two 'Right away' blocks" for i in ("b1", "b2", "b3")} @@ -467,7 +518,7 @@ def test_refused_names_each_block_of_a_bad_path(): def test_a_block_keeps_the_first_reason(): - m = graph({"b1": blk("on_noticed", to=["b2", "b3"], play=["m"])}) + m = graph({"b1": blk("on_noticed", nxt=["b2", "b3"], play=["m"])}) m.behaviour.blocks["b2"] = blk("distance", lo=5, hi=5, play=["m"]) m.behaviour.blocks["b3"] = blk("mode", mode=1, play=["o"]) assert B.refused(m) == { @@ -528,6 +579,346 @@ def test_drop_move(): assert B.uses(m, "m") == [] +# conditions and effects + +CONDS = [ + ("monster_hp", {"lo": 20}, [("hp_at_least", 0, 20)]), + ("monster_hp", {"hi": 30}, [("hp_below", 0, 30)]), + ("monster_hp", {"lo": 20, "hi": 30}, [("hp_at_least", 0, 20), ("hp_below", 0, 30)]), + ("monster_hp", {}, []), + ("part_broken", {"part": 4}, [("broken", 4, 0)]), + ("part_broken", {"part": 4, "state": "not broken"}, [("not_broken", 4, 0)]), + ("rage", {}, [("enraged", 0, 0)]), + ("rage", {"state": "calm"}, [("calm", 0, 0)]), + ("hunter_side", {"sides": ["front"]}, [("side", 1, 0)]), + ("hunter_side", {"sides": ["behind", "left", "left"]}, [("side", 10, 0)]), + ("hunter_side", {"sides": list(B.SIDES)}, [("side", 15, 0)]), + ("chance", {"percent": 25}, [("chance", 0, 25)]), + ("counter_is", {"counter": "n"}, [("var_at_least", 0, 1)]), + ("counter_is", {"counter": "n", "test": "below", "value": -3}, [("var_below", 0, -3)]), + ("counter_is", {"counter": "n", "test": "exactly", "value": 2}, [("var_equal", 0, 2)]), + ("flag_is", {"flag": "n"}, [("var_at_least", 0, 1)]), + ("flag_is", {"flag": "n", "state": "clear"}, [("var_below", 0, 1)]), +] +EFFECTS = [ + ("counter_add", {"counter": "n"}, [("var_add", 0, 1)]), + ("counter_add", {"counter": "n", "by": -2}, [("var_add", 0, -2)]), + ("counter_set", {"counter": "n"}, [("var_set", 0, 0)]), + ("counter_set", {"counter": "n", "to": 7}, [("var_set", 0, 7)]), + ("flag_set", {"flag": "n"}, [("var_set", 0, 1)]), + ("flag_set", {"flag": "n", "state": "clear"}, [("var_set", 0, 0)]), +] + + +@pytest.mark.parametrize(("kind", "params", "ops"), CONDS) +def test_a_condition_compiles(kind, params, ops): + r = B.compile(_chain(blk("on_noticed"), blk(kind, **params)))[0] + assert (r.conds, r.effects, r.signal) == (ops, [], None) + + +@pytest.mark.parametrize(("kind", "params", "ops"), EFFECTS) +def test_an_effect_compiles(kind, params, ops): + r = B.compile(_chain(blk("on_noticed"), blk(kind, **params)))[0] + assert (r.conds, r.effects, r.play) == ([], ops, "m") + + +def test_every_op_of_the_contract_is_made(): + assert {op for _, _, ops in CONDS for op, _, _ in ops} == set(addresses.EM_COND.names) + assert {op for _, _, ops in EFFECTS for op, _, _ in ops} == set(addresses.EM_EFFECT.names) + + +def test_idle_and_any_time_are_main_states(): + assert B.compile(_chain(blk("idle")))[0].from_main == [B.IDLE_MAIN] == [0] + assert B.compile(_chain(blk("any_time")))[0].from_main == list(B.MAIN_STATES) + + +def test_idle_and_any_time_say_they_wait_for_own_moves(): + for k in ("any_time", "idle"): + assert B.KINDS[k].summary({}) == "between moves" and "no own move plays" in B.KINDS[k].tip + + +def test_one_state_block_a_path(): + m = _chain(blk("idle"), blk("host_state", mains=[1])) + with pytest.raises(ManifestError, match="b1 > b2 plays m: has more than one state block"): + B.compile(m) + with pytest.raises(ManifestError, match="has two 'Idle' blocks"): + B.compile(_chain(blk("idle"), blk("idle"))) + + +def _repeat(n, kind, **params): + return _chain(blk("on_noticed"), *[blk(kind, **params) for _ in range(n)]) + + +def test_conditions_and_effects_repeat_within_the_caps(): + r = B.compile(_repeat(B.SEAM_CONDS, "chance", percent=50))[0] + assert len(r.conds) == B.SEAM_CONDS == addresses.EM_RULE.CONDS.count + r = B.compile(_repeat(B.SEAM_EFFECTS, "counter_add", counter="n"))[0] + assert len(r.effects) == B.SEAM_EFFECTS == addresses.EM_RULE.EFFECTS.count + + +def test_the_caps_are_refused(): + n = B.SEAM_CONDS + 1 + with pytest.raises(ManifestError, match=f"{n} conditions, a rule holds {B.SEAM_CONDS}"): + B.compile(_repeat(n, "chance", percent=50)) + n = B.SEAM_EFFECTS + 1 + with pytest.raises(ManifestError, match=f"{n} effects, a rule holds {B.SEAM_EFFECTS}"): + B.compile(_repeat(n, "counter_add", counter="n")) + + +def test_the_caps_count_ops_not_blocks(): + three = _chain(blk("on_noticed"), blk("monster_hp", lo=5, hi=9), blk("chance", percent=5)) + assert len(B.compile(three)[0].conds) == 3 + blocks = [blk("monster_hp", lo=5, hi=9)] + blocks += [blk("chance", percent=5) for _ in range(B.SEAM_CONDS - 1)] + with pytest.raises(ManifestError, match=f"{B.SEAM_CONDS + 1} conditions"): + B.compile(_chain(blk("on_noticed"), *blocks)) + + +def test_the_rule_fields_stay_once_a_path(): + m = _chain(blk("on_noticed"), blk("played_for", frames=1), blk("played_for", frames=2)) + with pytest.raises(ManifestError, match="has two 'Played for at least' blocks"): + B.compile(m) + + +@pytest.mark.parametrize(("lo", "hi"), [(30, 30), (40, 30), (0, 0)]) +def test_monster_hp_needs_lo_under_hi(lo, hi): + m = _chain(blk("on_noticed"), blk("monster_hp", lo=lo, hi=hi)) + M.check(m) + with pytest.raises(ManifestError, match="b1 > b2 plays m: hp needs lo < hi"): + B.compile(m) + assert B.refused(m) == {"b1": "hp needs lo < hi", "b2": "hp needs lo < hi"} + + +@pytest.mark.parametrize( + ("kind", "params", "why"), + [ + ("counter_add", {"counter": "Bad"}, "is not a name"), + ("counter_add", {"counter": "1up"}, "is not a name"), + ("counter_add", {"counter": "a b"}, "is not a name"), + ("counter_add", {"counter": ""}, "is not a name"), + ("counter_add", {"counter": 3}, "is not a name"), + ("counter_add", {}, "counter: missing"), + ("flag_is", {"flag": "no-way"}, "is not a name"), + ("on_signal", {"name": "Roar!"}, "is not a name"), + ("on_signal", {}, "name: missing"), + ("hunter_side", {"sides": []}, "is not a list of sides"), + ("hunter_side", {"sides": ["up"]}, "is not a list of sides"), + ("hunter_side", {"sides": "front"}, "is not a list of sides"), + ("hunter_side", {}, "sides: missing"), + ("chance", {"percent": 0}, "0 is under 1"), + ("chance", {"percent": 101}, "101 is over 100"), + ("monster_hp", {"lo": 101}, "101 is over 100"), + ("monster_hp", {"hi": -1}, "-1 is under 0"), + ("part_broken", {"part": 99}, "is not a part"), + ("part_broken", {"part": 1, "state": "maybe"}, "expected one of"), + ("counter_is", {"counter": "n", "value": 40000}, "40000 is over 32767"), + ("counter_is", {"counter": "n", "test": "above"}, "expected one of"), + ("counter_add", {"counter": "n", "by": 1.5}, "expected an integer"), + ], +) +def test_bad_params(kind, params, why): + with pytest.raises(ManifestError, match=why): + M.check(graph({"b1": blk(kind, **params)})) + + +# no-play paths + + +def test_a_path_ending_at_an_effect_plays_nothing(): + m = _chain(blk("on_flinch"), blk("counter_add", counter="n"), play=None) + assert B.paths(m) == [B.Path(("b1", "b2"), None, None)] + assert B.compile(m) == [Rule(None, on="flinch", effects=[("var_add", 0, 1)])] + assert B.loose(m) == [] and B.refused(m) == {} + + +def test_an_effect_that_plays_or_feeds_ends_no_path_of_its_own(): + m = _chain(blk("idle"), blk("counter_set", counter="n")) + assert [p.play for p in B.paths(m)] == ["m"] + m = _chain(blk("idle"), blk("counter_set", counter="n"), blk("chance", percent=9), play=None) + assert B.paths(m) == [] and B.loose(m) == ["b1", "b2", "b3"] + m.behaviour.blocks["b3"] = blk("counter_add", counter="n") + m.behaviour.blocks["b2"].next = ["b3"] + assert [p.blocks for p in B.paths(m)] == [("b1", "b2", "b3")] + assert B.compile(m)[0].effects == [("var_set", 0, 0), ("var_add", 0, 1)] + + +def test_a_block_that_plays_and_ends_a_no_play_path_makes_both(): + m = graph( + { + "b1": blk("on_flinch", nxt=["b2", "b3"]), + "b2": blk("counter_add", x=1, counter="n", play=["m"]), + "b3": blk("counter_add", x=2, counter="n"), + } + ) + assert [(p.blocks, p.play) for p in B.paths(m)] == [(("b1", "b3"), None), (("b1", "b2"), "m")] + + +def test_a_non_effect_end_without_a_move_is_loose(): + m = _chain(blk("on_flinch"), blk("force"), play=None) + assert B.paths(m) == [] and B.loose(m) == ["b1", "b2"] + + +def test_a_move_can_feed_a_no_play_path(): + m = graph({"b1": blk("flag_set", flag="seen")}, {"m": MoveNode((0, 0), ["b1"])}) + assert B.paths(m) == [B.Path(("b1",), None, "m")] + assert B.compile(m) == [Rule(None, from_move="m", effects=[("var_set", 0, 1)])] + + +def test_a_no_play_path_waits_and_counts_like_any(): + m = _chain( + blk("on_flinch"), + blk("cooldown", frames=30), + blk("limit", times=2), + blk("counter_add", counter="n"), + play=None, + ) + assert [(r.play, r.cooldown, r.count) for r in B.compile(m)] == [(None, 30, 2)] + + +def test_a_no_play_path_refuses_force_and_mode(): + for extra in (blk("force"), blk("mode", mode=1)): + m = _chain(blk("on_flinch"), extra, blk("counter_add", counter="n"), play=None) + M.check(m) + with pytest.raises(ManifestError, match="plays nothing: force and mode go with a move"): + B.compile(m) + assert set(B.refused(m)) == {"b1", "b2", "b3"} + + +def test_a_rule_that_plays_nothing_needs_an_effect(): + m = _chain(blk("on_flinch"), blk("chance", percent=5), play=None) + with pytest.raises(ManifestError, match="plays nothing and has no effect block"): + B._rule(m, B.Path(("b1", "b2"), None, None)) + + +def test_no_play_paths_come_first_whatever_the_canvas(): + m = graph( + { + "b1": blk("on_noticed", y=0, play=["m"]), + "b2": blk("on_flinch", y=500, nxt=["b3"]), + "b3": blk("counter_add", x=300, y=500, counter="n"), + "b4": blk("on_tail_cut", y=-50, nxt=["b5"]), + "b5": blk("flag_set", x=300, y=-50, flag="f"), + "b6": blk("on_combat_left", y=900, play=["n"]), + } + ) + assert [(p.blocks, p.play) for p in B.paths(m)] == [ + (("b4", "b5"), None), + (("b2", "b3"), None), + (("b1",), "m"), + (("b6",), "n"), + ] + assert [r.play for r in B.compile(m)] == [None, None, "m", "n"] + + +def test_no_play_paths_sort_with_the_rest(): + m = graph( + { + "b1": blk("on_noticed", y=100, play=["m"]), + "b2": blk("on_flinch", y=0, nxt=["b3"]), + "b3": blk("counter_add", x=300, y=0, counter="n"), + "b4": blk("on_tail_cut", y=200, play=["n"]), + } + ) + assert plays(m) == [None, "m", "n"] + + +# the board + + +def test_vars_and_signals_number_by_sorted_name(): + m = graph( + { + "b1": blk("on_signal", y=0, name="roar", play=["m"]), + "b2": blk("on_signal", y=50, name="alarm", play=["n"]), + "b3": blk("counter_is", y=100, counter="zeta", nxt=["b4"]), + "b4": blk("counter_add", x=300, y=100, counter="alpha"), + "b5": blk("flag_set", y=500, flag="mid"), + } + ) + assert B.signals(m) == {"alarm": 0, "roar": 1} + assert B.vars(m) == {"alpha": 0, "mid": 1, "zeta": 2}, "a loose block's name counts" + assert [r.signal for r in B.compile(m)] == [1, 0] + assert B.vars(graph({})) == B.signals(graph({})) == {} + + +def test_names_are_every_value_of_the_type_sorted(monkeypatch): + kind = B.Kind("t_boss", "effect", "Boss", (B.Param("flag", "var", "Flag", "boss"),)) + monkeypatch.setitem(B.KINDS, "t_boss", kind) + m = graph( + { + "b1": blk("counter_add", counter="hits"), + "b2": blk("flag_set", flag="armor"), + "b3": blk("counter_is", counter="hits"), + "b4": blk("t_boss"), + "b5": blk("on_signal", name="rage"), + "b6": blk("hunter_side", sides=["front"]), + "b7": blk("no_such_kind", name="x"), + } + ) + assert B.names(m, "var") == ["armor", "boss", "hits"], "a default counts" + assert B.names(m, "signal") == ["rage"] + assert B.names(m, "sides") == B.names(m, "int") == B.names(graph({}), "var") == [] + assert B.vars(m) == {"armor": 0, "boss": 1, "hits": 2} and B.signals(m) == {"rage": 0} + + +def test_a_name_is_one_index_everywhere(): + m = _chain( + blk("on_flinch"), + blk("counter_is", counter="b", test="below", value=3), + blk("counter_add", counter="a"), + blk("counter_set", counter="b", to=1), + play=None, + ) + r = B.compile(m)[0] + assert B.vars(m) == {"a": 0, "b": 1} + assert (r.conds, r.effects) == ( + [("var_below", 1, 3)], + [("var_add", 0, 1), ("var_set", 1, 1)], + ) + + +def test_a_signal_rule(): + m = _chain(blk("on_signal", name="roar")) + assert B.compile(m) == [Rule("m", signal=0)] + with pytest.raises(ManifestError, match="more than one event block"): + B.compile(_chain(blk("on_signal", name="roar"), blk("on_flinch"))) + + +def test_the_board_holds_so_many(): + def many(n, kind, key, play=None): + blocks = {} + for i in range(n): + blocks[f"e{i}"] = blk("on_flinch", y=i, nxt=[f"c{i}"]) + blocks[f"c{i}"] = blk(kind, x=300, y=i, **{key: f"v{i}"}, play=[play] if play else []) + return graph(blocks) + + assert len(B.vars(many(B.BOARD_VARS, "counter_add", "counter"))) == B.BOARD_VARS == 16 + B.compile(many(B.BOARD_VARS, "counter_add", "counter")) + n = B.BOARD_VARS + 1 + with pytest.raises(ManifestError, match=f"{n} counters and flags, the board holds 16"): + B.compile(many(n, "flag_set", "flag")) + sigs = { + f"s{i}": blk("on_signal", y=i, name=f"s{i}", play=["m"]) for i in range(B.BOARD_SIGNALS) + } + B.compile(graph(sigs)) + sigs["more"] = blk("on_signal", y=99, name="more", play=["m"]) + with pytest.raises(ManifestError, match=f"{B.BOARD_SIGNALS + 1} signals, the board holds 16"): + B.compile(graph(sigs)) + + +def test_the_new_blocks_survive_a_round_trip(): + m = graph( + { + "b1": blk("on_signal", name="roar", nxt=["b2"]), + "b2": blk("hunter_side", x=300, sides=["left", "behind"], nxt=["b3"]), + "b3": blk("counter_is", x=600, counter="n", test="exactly", value=-4, nxt=["b4"]), + "b4": blk("counter_add", x=900, counter="n", by=2, play=["m"]), + } + ) + back = M.loads(M.dumps(m)) + assert back == m and B.compile(back) == B.compile(m) + + # schema 1 V1 = ( @@ -652,6 +1043,13 @@ def test_migrate_refuses_what_it_cannot_carry(rules, why): B.migrate(rules) +def test_migrate_has_no_conds_effects_or_signal(): + for key, value in (("conds", [["chance", 0, 5]]), ("effects", []), ("signal", 0)): + rule = {"play": "m", "on": "noticed", key: value} + with pytest.raises(ManifestError, match=rf"rule\[0\]: unknown key\(s\) {key}"): + B.migrate([rule]) + + def test_migrate_nothing(): assert B.migrate([]) == B.Behaviour() @@ -659,7 +1057,17 @@ def test_migrate_nothing(): # the ports -ZINOGRE = [ +ZINOGRE = [ # the paths that play nothing, then the rest + Rule( + None, + on="combat_entered", + effects=[("var_set", 1, 1)], + label="combat start -> set in_combat", + ), + Rule( + None, on="combat_left", effects=[("var_set", 1, 0)], label="combat end -> clear in_combat" + ), + Rule(None, on="flinch", effects=[("var_add", 0, 1)], label="count the flinches"), Rule( "lunge_stop", from_move="lunge", @@ -685,26 +1093,67 @@ def test_migrate_nothing(): dist=(0.0, 800.0), label="the hunter close during the dash -> stamp", ), - Rule( - "flinch_head", - on="flinch", - part=0, - label="a flinch of the head: his own tumble, never the host's (4,x)", - ), - Rule("break_howl", on="part_broken", label="a part breaks"), Rule( "notice_howl", on="noticed", force=True, label="the notice: his howl at once, forced, so it does not wait for combat", ), + Rule("flinch_head", on="part_broken", part=0, label="horns break -> head flinch"), + Rule("topple_left", on="part_broken", part=4, label="left foreleg breaks -> topple left"), + Rule("topple_right", on="part_broken", part=6, label="right foreleg breaks -> topple right"), + Rule("break_howl", on="part_broken", label="a part breaks"), + Rule( + "charge_up", + from_main=[0], + conds=[("var_at_least", 1, 1), ("var_at_least", 0, 2)], + effects=[("var_set", 0, 0)], + label="2nd flinch -> charge up", + ), + Rule( + "dash", + from_main=[0], + dist=(1500.0, B.UNLIMITED_DIST), + cooldown=300, + conds=[("var_at_least", 1, 1)], + label="hunter far -> dash", + ), + Rule( + "notice_howl", + from_main=[0], + conds=[("var_at_least", 1, 1), ("hp_below", 0, 30)], + count=1, + label="below 30% HP -> howl", + ), + Rule("notice_howl", signal=0, label="a mod's roar -> howl"), ] def test_zinogre_compiles_to_its_rules(): m = M.load(PORTS / "zinogre.toml") assert B.compile(m) == ZINOGRE - assert B.loose(m) == [] + assert B.loose(m) == [] and B.refused(m) == {} + assert B.vars(m) == {"flinches": 0, "in_combat": 1} and B.signals(m) == {"roar": 0} + old = [r for r in ZINOGRE if r.from_move] + assert len(old) == 4 + assert all(not r.conds and not r.effects and r.signal is None for r in old) + assert len(ZINOGRE) <= B.SEAM_RULES + idle = [r for r in ZINOGRE if r.from_main == [0]] + assert len(idle) == 3 and all(("var_at_least", 1, 1) in r.conds for r in idle) + notice = next(i for i, r in enumerate(ZINOGRE) if r.on == "noticed") + assert notice < ZINOGRE.index(idle[0]), "the notice scans before the idle paths" + quiet = [i for i, r in enumerate(ZINOGRE) if r.play is None] + assert quiet == [0, 1, 2], "the rules that play nothing run first" + + +def test_zinogre_topples_end_in_the_stamp(): + m = M.load(PORTS / "zinogre.toml") + for side in ("left", "right"): + steps = sequence.chain(m, f"topple_{side}") + assert steps == [f"topple_{side}", f"topple_{side}_2", f"topple_{side}_3", "stamp"] + assert all(m.moves[n].own and not m.moves[n].steer.walls for n in steps[:-1]) + assert m.moves[steps[1]].length == 78 and m.moves[steps[1]].carrier is None + assert sum(mv.own for mv in m.moves.values()) <= M.OWN_MOVES def test_brute_has_no_rules(): diff --git a/packages/mhfu-port/tests/test_continuity.py b/packages/mhfu-port/tests/test_continuity.py index c60cd42..30571ba 100644 --- a/packages/mhfu-port/tests/test_continuity.py +++ b/packages/mhfu-port/tests/test_continuity.py @@ -130,6 +130,17 @@ def test_the_flinch_roll_parts_chain_crossed(zinogre): assert dict(continuity.fits_after(zinogre, 103))[108] < continuity.GOOD +@pytest.mark.parametrize("side", ["left", "right"]) +def test_the_topple_steps_meet(zinogre, side): + """Fall, lie, get up: each step starts where the last ended, and the last ends upright.""" + m = manifest.load(PORTS / "zinogre.toml") + ids = [m.clips[m.moves[f"topple_{side}{k}"].clip].source for k in ("", "_2", "_3")] + for a, b in zip(ids, ids[1:], strict=False): + assert dict(continuity.fits_after(zinogre, a))[b] < continuity.GOOD, (a, b) + idle = m.clips["idle"].source + assert dict(continuity.fits_after(zinogre, ids[-1]))[idle] < continuity.GOOD + + def test_the_whole_moveset_ranks_fast(zinogre): import time diff --git a/packages/mhfu-port/tests/test_moves.py b/packages/mhfu-port/tests/test_moves.py index 716ef73..4b55d8e 100644 --- a/packages/mhfu-port/tests/test_moves.py +++ b/packages/mhfu-port/tests/test_moves.py @@ -191,8 +191,9 @@ def test_pool(): def test_manifest_limits(): - many = "".join(f"[moves.o{i}]\nanim = {i}\n" for i in range(17)) - with pytest.raises(ManifestError, match="17 own moves, the framework"): + n = manifest.OWN_MOVES + 1 + many = "".join(f"[moves.o{i}]\nanim = {i}\n" for i in range(n)) + with pytest.raises(ManifestError, match=f"{n} own moves, the framework"): _m(many) @@ -217,6 +218,13 @@ def test_zinogre(data): "flinch_head": 101, "break_howl": 112, "notice_howl": 2, + "topple_left": 103, + "topple_left_2": 118, + "topple_left_3": 108, + "topple_right": 104, + "topple_right_2": 117, + "topple_right_3": 107, + "charge_up": 18, } assert b.layout.frames[46] == 228 and 6 in moves.records(data.fu, 75) assert f'curve = "{b.layout.turns[46].lua()}"' in text @@ -224,8 +232,16 @@ def test_zinogre(data): assert stamp + ", carrier = { 0, 2 }" in text assert 'after = "dash_stop"' in text and 'turn = "fixed", total = 90.0, frames = 50' in text assert '{ play = "stamp", from = "dash", min_frames = 10' in text - assert '{ play = "flinch_head", on = "flinch", part = 0' in text + assert '{ play = "flinch_head", on = "part_broken", part = 0' in text + assert '{ play = "topple_left", on = "part_broken", part = 4' in text + assert '{ no_play = true, on = "flinch", effects = { { "var_add", 0, 1 } }' in text + assert 'conds = { { "var_at_least", 1, 1 }, { "var_at_least", 0, 2 } }, ' in text + assert 'effects = { { "var_set", 0, 0 } }' in text and "signal = 0" in text + assert '{ no_play = true, on = "combat_entered", effects = { { "var_set", 1, 1 } }' in text + assert 'conds = { { "var_at_least", 1, 1 }, { "hp_below", 0, 30 } }' in text + assert " vars = { flinches = 0, in_combat = 1 },\n signals = { roar = 0 },\n tip = " in text assert '{ play = "notice_howl", on = "noticed", force = true' in text + assert "length = 78, steer = { walls = false" in text and 'after = "topple_left_2"' in text def test_lua_key(): @@ -247,6 +263,57 @@ def test_events_and_force_reach_the_module(): ] +def _board() -> manifest.Manifest: + def blk(kind, y, nxt=(), play=(), **params): + return behaviour.Block(kind, (0.0, y), params, list(nxt), list(play)) + + m = _m() + m.behaviour = behaviour.Behaviour( + { + "b1": blk("on_flinch", 0, ["b2"]), + "b2": blk("counter_add", 0, counter="hits", by=2), + "b3": blk("idle", 100, ["b4"]), + "b4": blk("counter_is", 100, ["b5"], counter="hits", value=3), + "b5": blk("flag_set", 100, play=["stamp"], flag="end"), + "b6": blk("on_signal", 200, ["b7"], name="roar"), + "b7": blk("hunter_side", 200, ["b8"], sides=["left", "behind"]), + "b8": blk("monster_hp", 200, play=["dash"], lo=10, hi=60), + } + ) + return m + + +def test_the_board_reaches_the_module(): + body = _body(moves.lua(_board(), LAYOUT, {6})) + assert body[-6:] == [ + "rules = {", + '{ no_play = true, on = "flinch", effects = { { "var_add", 1, 2 } } },', + '{ play = "stamp", from_main = { 0 }, conds = { { "var_at_least", 1, 3 } }, ' + 'effects = { { "var_set", 0, 1 } } },', + '{ play = "dash", signal = 0, conds = { { "side", 10, 0 }, { "hp_at_least", 0, 10 }, ' + '{ "hp_below", 0, 60 } } },', + 'vars = { ["end"] = 0, hits = 1 },', + "signals = { roar = 0 },", + ] + + +def test_a_module_without_the_board_has_no_tables(): + body = _body(moves.lua(_m(), LAYOUT, {6})) + assert not any(line.startswith(("vars", "signals")) for line in body) + assert not any("conds" in line or "effects" in line or "no_play" in line for line in body) + + +def test_the_board_is_checked_with_the_rest(): + m = _board() + for i in range(behaviour.BOARD_SIGNALS): + name = f"s{i}" + m.behaviour.blocks[name] = behaviour.Block( + "on_signal", (0.0, 400.0 + i), {"name": name}, [], ["dash"] + ) + n = behaviour.BOARD_SIGNALS + 1 + assert moves.problems(m, LAYOUT, {6}) == [f"behaviour: {n} signals, the board holds 16"] + + def test_problems_lists_every_one(): bad = OWN.replace("frame = 56\nend = 80", "frame = 228\nend = 240") found = moves.problems(_m(bad), layout.Layout({20: 20, 9: 9}), {7}) diff --git a/packages/mhfu/src/mhfu/addresses.toml b/packages/mhfu/src/mhfu/addresses.toml index 78b9473..4e0e4b2 100644 --- a/packages/mhfu/src/mhfu/addresses.toml +++ b/packages/mhfu/src/mhfu/addresses.toml @@ -893,7 +893,7 @@ VALID = { offset = 0x48, type = "u32", doc = "1 once written; 0 while it is rewr [struct.EM_MOVES] doc = "em_vhook's own-move registry in partition memory (em_vhook.cpp): a port's own moves by slot, played by the move player on the AI step that asks" -size = 0x1738 +size = 0x2138 [struct.EM_MOVES.fields] MAGIC = { offset = 0x00, type = "u32", doc = "0x564F4D45 (EMOV)" } REQ_ENT = { offset = 0x04, type = "ptr", doc = "a play asked for (mhfu_em_play): the monster, taken at its next AI step; 0 none" } @@ -903,10 +903,10 @@ TAG_STARTED = { offset = 0x10, type = "u32", doc = "MOVE_STATE.STARTED of that m PLAYS = { offset = 0x14, type = "u32", doc = "moves started from here: asked, by a rule or by AFTER" } CHAINED = { offset = 0x18, type = "u32", doc = "of them by AFTER" } KEY_TOP = { offset = 0x1C, type = "u32", doc = "KEYS in use" } -MOVES = { offset = 0x20, type = "EM_OWN[16]", doc = "the moves by slot" } -KEYS = { offset = 0x520, type = "u16[2048]", doc = "every move's turn keys, YAW every 2 clip frames" } -SCRATCH = { offset = 0x1520, type = "STEER_SPEC", doc = "the steering handed to the move player for the move it plays next" } -REQ_FORCE = { offset = 0x1734, type = "u32", doc = "1: the play asked for starts while the monster's notice runs (MOVE.FORCE)" } +MOVES = { offset = 0x20, type = "EM_OWN[48]", doc = "the moves by slot" } +KEYS = { offset = 0xF20, type = "u16[2048]", doc = "every move's turn keys, YAW every 2 clip frames" } +SCRATCH = { offset = 0x1F20, type = "STEER_SPEC", doc = "the steering handed to the move player for the move it plays next" } +REQ_FORCE = { offset = 0x2134, type = "u32", doc = "1: the play asked for starts while the monster's notice runs (MOVE.FORCE)" } [struct.FLINCH_PART] doc = "one part's flinch state (ENTITY.FLINCH_PARTS)" @@ -934,7 +934,7 @@ size = 0x14 ENTITY = { offset = 0x00, type = "ptr", doc = "the monster" } FRAME = { offset = 0x04, type = "u32", doc = "its MONSTER_WATCH.FRAMES the change was seen in: noticed the engine's own AI frame, the others the one after" } USEC = { offset = 0x08, type = "u32", doc = "the emulated clock then (sceKernelGetSystemTimeLow)" } -KIND = { offset = 0x0C, type = "u8", doc = "1 noticed, 2 combat entered, 3 combat left, 4 flinch, 5 part broken, 6 tail cut" } +KIND = { offset = 0x0C, type = "u8", doc = "1 noticed, 2 combat entered, 3 combat left, 4 flinch, 5 part broken, 6 tail cut, 7 enraged, 8 calmed" } PART = { offset = 0x0D, type = "u8", doc = "flinch, part broken: the lowest part in ENTITY.FLINCH_MASK; else 0xFF" } MAIN = { offset = 0x0E, type = "u8", doc = "ENTITY.MAIN_STATE then: for a flinch, the reaction the engine entered" } SUB = { offset = 0x0F, type = "u8", doc = "ENTITY.SUB_STATE then" } @@ -949,6 +949,7 @@ FRAMES = { offset = 0x4, type = "u32", doc = "its AI frames the step saw" } AWARE = { offset = 0x8, type = "u8", doc = "ENTITY.AWARE last frame" } COMBAT = { offset = 0x9, type = "u8", doc = "1 when the player's yellow eye was on for it last frame" } SEVERED = { offset = 0xA, type = "u8", doc = "ENTITY.SEVERED bit 0 last frame" } +ENRAGED = { offset = 0xB, type = "u8", doc = "ENTITY.FLAGS bit 0x20 last frame" } BROKEN = { offset = 0xC, type = "u16", doc = "ENTITY.BROKEN last frame" } EDGES = { offset = 0xE, type = "u16", doc = "bit KIND: what the last frame raised" } @@ -964,15 +965,15 @@ WATCH = { offset = 0x10, type = "MONSTER_WATCH[2]", doc = "the monsters followed RING = { offset = 0x30, type = "MONSTER_EVENT[32]", doc = "the records" } [struct.EM_RULE] -doc = "one brain rule in em_vhook's config block (em_vhook.cpp cfg_rule_t): a trigger and what it plays, evaluated each AI frame in C" -size = 0x28 +doc = "one brain rule in em_vhook's config block (em_vhook.cpp cfg_rule_t): a trigger, its conditions, what it plays and what it changes, evaluated each AI frame in C" +size = 0x40 [struct.EM_RULE.fields] FROM_MASK = { offset = 0x00, type = "u8", doc = "bit k: may fire while the live pair's main is k; 0 with FROM_MOVE 0: no pair gate" } FROM_SUB = { offset = 0x01, type = "u8", doc = "with FROM_MASK: the exact sub, or 0xFE any" } TO_MAIN = { offset = 0x02, type = "u8", doc = "the pair entered when PLAY_MOVE is 0" } TO_SUB = { offset = 0x03, type = "u8", doc = "with TO_MAIN" } MODE = { offset = 0x04, type = "u8", doc = "ENTER_ACTION mode for TO_MAIN, TO_SUB" } -FLAGS = { offset = 0x05, type = "u8", doc = "1 only while the hunter recedes, 2 only while he closes" } +FLAGS = { offset = 0x05, type = "u8", doc = "1 only while the hunter recedes, 2 only while they close, 4 plays nothing: applies its EFFECTS and the scan goes on" } FROM_MOVE = { offset = 0x06, type = "u8", doc = "own move slot + 1 that must be playing (its AI frames the dwell); 0 none" } PLAY_MOVE = { offset = 0x07, type = "u8", doc = "own move slot + 1 played instead of entering TO_MAIN, TO_SUB; 0 none" } MIN_FRAMES = { offset = 0x08, type = "u32", doc = "AI frames the pair (or FROM_MOVE) must have stood" } @@ -985,18 +986,54 @@ COOLDOWN = { offset = 0x20, type = "u32", doc = "AI ticks between two fires" } ON = { offset = 0x24, type = "u8", doc = "the monster event that fires it (MONSTER_EVENT.KIND), in the AI frame it is seen; 4, the flinch, replaces the host's reaction; 0 none" } PART = { offset = 0x25, type = "u8", doc = "ON 4 or 5: only a flinch or break of this part (ENTITY.FLINCH_MASK bit); 0xFF any" } FORCE = { offset = 0x26, type = "u8", doc = "1: PLAY_MOVE starts while the monster's notice runs (MOVE.FORCE)" } +SIGNAL = { offset = 0x27, type = "u8", doc = "a Lua signal (EM_BOARD.SIGNALS index + 1) that fires it in the AI frame it is seen, like ON; 0 none" } +CONDS = { offset = 0x28, type = "EM_OP[4]", doc = "conditions that must all hold (OP an EM_COND, 0 none), CHANCE rolled last" } +EFFECTS = { offset = 0x38, type = "EM_OP[2]", doc = "what it changes when it fires (OP an EM_EFFECT, 0 none)" } + +[struct.EM_OP] +doc = "one condition or effect of a brain rule (EM_RULE.CONDS, EM_RULE.EFFECTS)" +size = 0x4 +[struct.EM_OP.fields] +OP = { offset = 0x0, type = "u8", doc = "EM_COND or EM_EFFECT index + 1; 0 none" } +ARG = { offset = 0x1, type = "u8", doc = "a part (broken), a side mask (side: 1 front, 2 left, 4 right, 8 behind) or an EM_BOARD.VARS index" } +VALUE = { offset = 0x2, type = "s16", doc = "a percent (hp_*, chance) or a number (var_*)" } + +[struct.EM_BOARD] +doc = "em_vhook's blackboard after its config block: what rules and Lua share for the wrapped species' monster; a new monster resets it" +size = 0x40 +[struct.EM_BOARD.fields] +VARS = { offset = 0x00, type = "s16[16]", doc = "counters and flags, by the index the moves module's `vars` names; var_add saturates" } +SIGNALS = { offset = 0x20, type = "u8[16]", doc = "1: Lua raised the signal (mhfu_em_signal) since the brain's last AI frame, which takes it" } +BROKEN = { offset = 0x30, type = "u8", doc = "bit k: part k broke on this monster (its part_broken events)" } +RNG = { offset = 0x34, type = "u32", doc = "xorshift32 state for CHANCE" } +ENTITY = { offset = 0x38, type = "ptr", doc = "the monster it belongs to" } + +[struct.EM_BLOCK] +doc = "em_vhook's partition block from its config block on (em_vhook.cpp place_block): the config block, the tail's map and the cut's wait, then the board; the block's address moves with the PRX build, a debugger finds it by EM_CFG.CANARY" +size = 0xD68 +[struct.EM_BLOCK.fields] +CFG = { offset = 0x00, type = "EM_CFG", doc = "the config block" } +BOARD = { offset = 0xD08, type = "EM_BOARD", doc = "the board, after the tail: sizeof(tail_t) is 0x28" } + +[enum.EM_COND] +doc = "a brain rule's condition, EM_OP.OP - 1: hp_at_least/hp_below HP percent of MAX_HP against VALUE; broken/not_broken part ARG in EM_BOARD.BROKEN; enraged/calm ENTITY.FLAGS 0x20; side the hunter in a sector of the monster's facing in mask ARG (90 degrees each, left the monster's own); chance VALUE percent; var_* EM_BOARD.VARS[ARG] against VALUE" +names = ["hp_at_least", "hp_below", "broken", "not_broken", "enraged", "calm", "side", "chance", "var_at_least", "var_below", "var_equal"] + +[enum.EM_EFFECT] +doc = "what a brain rule changes when it fires, EM_OP.OP - 1: var_add adds VALUE to EM_BOARD.VARS[ARG], var_set sets it" +names = ["var_add", "var_set"] [struct.EM_CFG] doc = "em_vhook's config block in partition memory, which its stubs read by the offsets in em_vhook_stubs.h; listed here: the AI tick count, the canary and the rules" -size = 0x360 +size = 0xCE0 [struct.EM_CFG.fields] AI_TICKS = { offset = 0x10, type = "u32", doc = "AI ticks of the wrapped species since the quest began; a rule's LAST_FIRE is on this clock" } CANARY = { offset = 0x1C, type = "u32", doc = "0x5645484B ('VEHK'), written once at init and never again: how a debugger finds the block, which sits in partition memory at an address that moves with the PRX build" } -RULES = { offset = 0xE0, type = "EM_RULE[16]", doc = "the brain's rules: their count is the capacity a port's manifest and mhfu_port.lua read" } +RULES = { offset = 0xE0, type = "EM_RULE[48]", doc = "the brain's rules: their count is the capacity a port's manifest and mhfu_port.lua read" } [enum.MONSTER_EVENT_KIND] doc = "the framework's monster events (MONSTER_EVENT.KIND), a manifest rule's on" -names = ["noticed", "combat_entered", "combat_left", "flinch", "part_broken", "tail_cut"] +names = ["noticed", "combat_entered", "combat_left", "flinch", "part_broken", "tail_cut", "enraged", "calmed"] [enum.MOVE_END] doc = "MOVE_STATE.END, why the move player's move ended: its clip ended (the carrier handed off); the clip or LENGTH ended and the back pair was entered; the pair changed under it; stopped; replaced; the enter-action did not land the carrier; another dispatch replaced the clip; a wall ahead; a class-2 wall (the stuck pair entered); a reaction mhfu_move_react replaces started its move" diff --git a/packages/mhfu/src/mhfu/live/brain.py b/packages/mhfu/src/mhfu/live/brain.py index 08df231..dc7fc10 100644 --- a/packages/mhfu/src/mhfu/live/brain.py +++ b/packages/mhfu/src/mhfu/live/brain.py @@ -86,9 +86,10 @@ def _live(mem: Memory, entity: int) -> bool: def _board() -> int | None: """The board's ENTITY word in the block; None for a build with no board.""" - board = a.table().structs.get("EM_BOARD") - at = getattr(a.EM_CFG, "BOARD", None) - return None if board is None or at is None else int(at) + int(board.ENTITY) + structs = a.table().structs + if "EM_BLOCK" not in structs or "EM_BOARD" not in structs: + return None + return int(a.EM_BLOCK.BOARD) + int(a.EM_BOARD.ENTITY) def _owned(mem: Memory, cfg: int) -> bool: @@ -142,3 +143,16 @@ def _decode(index: int, r: bytes) -> Slot | None: return Slot( index, on, signal, not r[rule.FLAGS] & NO_PLAY, min_frames, cooldown, fired, left, last ) + + +def _board_at(cfg: int, field: int) -> int: + return cfg + int(a.EM_BLOCK.BOARD) + field + + +def fire_signal(s: Reader, cfg: int, index: int) -> None: + """Raises board signal `index` (its place in `behaviour.signals`), as Lua's `em_signal` does; + the brain clears it as it fires the rules.""" + n = a.EM_BOARD.SIGNALS.count or 0 + if not 0 <= index < n: + raise ValueError(f"signal {index}: the board holds {n}") + s.mem.write_u8(_board_at(cfg, int(a.EM_BOARD.SIGNALS)) + index, 1) diff --git a/packages/mhfu/tests/live/test_brain.py b/packages/mhfu/tests/live/test_brain.py index 2ed35ae..8261ffc 100644 --- a/packages/mhfu/tests/live/test_brain.py +++ b/packages/mhfu/tests/live/test_brain.py @@ -19,6 +19,7 @@ """The ENTITY word's place in the block: the build's, else a made-up one.""" RULES = int(a.EM_CFG.RULES) EVENT = a.MONSTER_EVENT_KIND.number("flinch") +needs_board = pytest.mark.skipif(brain._board() is None, reason="this build has no board") needs_signal = pytest.mark.skipif( not hasattr(a.EM_RULE, "SIGNAL"), reason="this build has no signals" ) @@ -139,3 +140,21 @@ def test_a_read_is_one_request() -> None: mem = memory(block({"r0": {"ON": EVENT, "LEFT": brain.UNLIMITED}})) brain.read(session(mem), BLOCK) assert mem.reads == [(BLOCK, RULES + (a.EM_CFG.RULES.count or 0) * a.EM_RULE.step)] + + +@needs_board +def test_a_signal_is_one_byte_of_the_board() -> None: + mem = memory(block()) + before = mem.read(BLOCK, len(block())) + brain.fire_signal(session(mem), BLOCK, 3) + after = mem.read(BLOCK, len(block())) + at = int(a.EM_BLOCK.BOARD) + int(a.EM_BOARD.SIGNALS) + 3 + assert [k for k in range(len(before)) if before[k] != after[k]] == [at] and after[at] == 1 + + +@needs_board +def test_a_signal_the_board_lacks_is_refused() -> None: + mem = memory(block()) + for bad in (-1, a.EM_BOARD.SIGNALS.count or 0): + with pytest.raises(ValueError, match="the board holds"): + brain.fire_signal(session(mem), BLOCK, bad) diff --git a/ports/README.md b/ports/README.md index fe64a0b..f390483 100644 --- a/ports/README.md +++ b/ports/README.md @@ -93,7 +93,8 @@ walls = true # a wall ahead ends it (the moves a path that gets here plays) and `label`. A move on the canvas is `[behaviour.moves.]`: `at` and `during`, the blocks fed while it plays. The kinds and their params are `mhfu_port.behaviour.KINDS`, which the studio's palette shows: events (`on_noticed`, -`on_flinch`, ...), the base monster's state, conditions and modifiers. +`on_flinch`, `on_signal`, ...), the base monster's state (`idle`, `any_time`, `host_state`), +conditions, modifiers and effects. ```toml [behaviour.blocks.b1] # the head flinches: play flinch_head in place of the host's flinch @@ -121,10 +122,51 @@ during = ["b2"] A path starts at an event or state block nothing feeds, or at a block a move lists in `during`, follows `next` and plays the moves in each `play` it reaches; fan-out and fan-in make more paths. -Each path is one rule the framework checks every AI frame in C, and one fires a frame; a pair rule -waits while an own move plays. Higher on the canvas is checked first. A path holds one event and -at most one block of a kind; a block on no complete path is loose, which loads but does nothing. -A cooldown waits after the path fired; a limit caps how often. +Each path is one rule the framework checks every AI frame in C, and one playing path fires a frame; +a pair rule waits while an own move plays. Among the paths that play, higher on the canvas is +checked first. A path holds one event, one state and at most one block of a kind, but for +conditions and effects (below); a block on no complete path is loose, which loads but does +nothing. A cooldown waits after the path fired; a limit caps how often. + +### Conditions, effects, counters and signals + +| Role | Kinds | +|---|---| +| condition | `monster_hp` (share of max HP, from `lo`, below `hi`), `part_broken`, `rage`, `hunter_side` (front, left, right, behind; its left is the monster's own), `chance` (rolled last), `counter_is`, `flag_is` | +| effect | `counter_add`, `counter_set`, `flag_set`: applied each time the path fires | +| event | `on_signal`: a mod raised it | + +Condition and effect blocks may repeat on a path: a rule holds 4 conditions and 2 effects +(`monster_hp` with both bounds is two conditions). All conditions hold, or it does not fire. + +A path that ends at an effect block with no `next` and no `play` plays nothing: it applies its +effects and counts as fired (cooldown and limit work). The rule table lists every no-play path +first, then the playing paths, each group by canvas order (higher first), and the framework runs +it in that order every AI frame: all the no-play paths, then the first playing path that holds. A +path's rank (the studio's `#n`) is its place in the table, so a no-play path outranks every +playing path wherever it sits on the canvas. On `on_flinch` a no-play path never replaces the +host's reaction. Its `force` and `mode` have nothing to act on, so a no-play path refuses them. +`any_time` and `idle` are pair gates: a path from them waits while an own move plays, and fires in +the frames between moves. + +Counters and flags are named in the blocks (`[a-z][a-z0-9_]*`) and made on first use: 16 a +monster, signed 16 bits, 0 at first and again for a new monster (a flag is a counter that is 0 or +1). Signals are named the same, 16 a monster. `_moves.lua` carries each rule's `conds`, +`effects` (`{ op, arg, value }`, the board indices resolved), `signal` and `no_play`, and the +top-level `vars` and `signals` tables that number the names by sorted name. In a mod: + +```lua +local n = port:var("flinches") -- the counter's value +port:var("flinches", 0) -- set it +port:fire("roar") -- raise a signal: true if the port has it +``` + +A signal fires its rules in the AI frame the brain takes it, once. An unknown name is logged once. + +A flinch rule that plays a move is armed ahead of the flinch: its conditions are judged in the +AI frame before the host step that flinches, so a counter it tests misses the flinch about to be +counted, in any order. The Zinogre counts flinches on a no-play rule and charges up from idle +once `flinches` is 2, not on the flinch itself. An event fires in the AI frame it is seen; `part` narrows a flinch or a break to one part (`[parts]` names them; em75's: 0 head, 1 neck, 2 body, 3 tail, 4/6 the left/right foreleg, 5/7 the @@ -151,6 +193,18 @@ port with them; `mhfu move play --own NAME [--force]` plays one of its own moves `framework/lua/examples/ported_brute.lua` loads the Brute Tigrex in place of a Giadrome; `ported_zinogre.lua` the Zinogre with its manifest's moves and behaviour. +The Zinogre's paths, after the lunge and dash chains (higher is checked first): + +1. The notice plays `notice_howl` at once, before anything that idles. +2. Combat start sets the flag `in_combat`, combat end clears it; both play nothing. +3. A break of the head plays `flinch_head`; of the left foreleg `topple_left`; of the right + `topple_right`; of any part `break_howl`. A topple is a sequence: fall, lie helpless, get up, + then `stamp` (`after`). +4. A flinch adds 1 to `flinches` and plays nothing. +5. Idle, in combat: with `flinches` at least 2, set it to 0 and play `charge_up`; with the + hunter 1500 away, play `dash`, then wait 300; below 30% HP, play `notice_howl` once. +6. The signal `roar` plays `notice_howl`. + ## Status | Manifest | Donor | Rides | diff --git a/ports/zinogre.toml b/ports/zinogre.toml index 9b700f2..407590b 100644 --- a/ports/zinogre.toml +++ b/ports/zinogre.toml @@ -631,6 +631,63 @@ label = "the howl the moment he notices you, on the roar pair the notice enters" [moves.notice_howl.steer] walls = false +[moves.topple_left] +clip = "flinch_roll_left_part_1" +after = "topple_left_2" +label = "falls to his left" + +[moves.topple_left.steer] +walls = false + +[moves.topple_left_2] +clip = "on_ground_helpless_right" +after = "topple_left_3" +length = 78 +label = "lies helpless, two loops of the clip" + +[moves.topple_left_2.steer] +walls = false + +[moves.topple_left_3] +clip = "flinch_roll_right_part_2" +after = "stamp" +label = "gets up, then the stamp" + +[moves.topple_left_3.steer] +walls = false + +[moves.topple_right] +clip = "flinch_roll_right_part_1" +after = "topple_right_2" +label = "falls to his right" + +[moves.topple_right.steer] +walls = false + +[moves.topple_right_2] +clip = "on_ground_helpless_left" +after = "topple_right_3" +length = 78 +label = "lies helpless, two loops of the clip" + +[moves.topple_right_2.steer] +walls = false + +[moves.topple_right_3] +clip = "flinch_roll_left_part_2" +after = "stamp" +label = "gets up, then the stamp" + +[moves.topple_right_3.steer] +walls = false + +[moves.charge_up] +clip = "crouch_charge_up" +label = "crouches and charges up" + +[moves.charge_up.steer] +walls = false + [parts.head] index = 0 hitzone_row = 0 @@ -917,49 +974,279 @@ play = [ ] [behaviour.blocks.b12] -kind = "on_flinch" +kind = "on_noticed" at = [ 400.0, 800.0, ] +next = [ + "b13", +] +label = "the notice: his howl at once, forced, so it does not wait for combat" + +[behaviour.blocks.b13] +kind = "force" +at = [ + 680.0, + 800.0, +] +play = [ + "notice_howl", +] + +[behaviour.blocks.b14] +kind = "on_combat_entered" +at = [ + 400.0, + 1000.0, +] +next = [ + "b15", +] +label = "combat start -> set in_combat" + +[behaviour.blocks.b15] +kind = "flag_set" +at = [ + 680.0, + 1000.0, +] +flag = "in_combat" + +[behaviour.blocks.b16] +kind = "on_combat_left" +at = [ + 400.0, + 1200.0, +] +next = [ + "b17", +] +label = "combat end -> clear in_combat" + +[behaviour.blocks.b17] +kind = "flag_set" +at = [ + 680.0, + 1200.0, +] +flag = "in_combat" +state = "clear" + +[behaviour.blocks.b18] +kind = "on_part_broken" +at = [ + 400.0, + 1400.0, +] part = 0 play = [ "flinch_head", ] -label = "a flinch of the head: his own tumble, never the host's (4,x)" +label = "horns break -> head flinch" -[behaviour.blocks.b13] +[behaviour.blocks.b19] kind = "on_part_broken" at = [ 400.0, - 1000.0, + 1600.0, +] +part = 4 +play = [ + "topple_left", +] +label = "left foreleg breaks -> topple left" + +[behaviour.blocks.b20] +kind = "on_part_broken" +at = [ + 400.0, + 1800.0, +] +part = 6 +play = [ + "topple_right", +] +label = "right foreleg breaks -> topple right" + +[behaviour.blocks.b21] +kind = "on_part_broken" +at = [ + 400.0, + 2000.0, ] play = [ "break_howl", ] label = "a part breaks" -[behaviour.blocks.b14] -kind = "on_noticed" +[behaviour.blocks.b22] +kind = "on_flinch" at = [ 400.0, - 1200.0, + 2200.0, ] next = [ - "b15", + "b23", ] -label = "the notice: his howl at once, forced, so it does not wait for combat" +label = "count the flinches" -[behaviour.blocks.b15] -kind = "force" +[behaviour.blocks.b23] +kind = "counter_add" at = [ 680.0, - 1200.0, + 2200.0, +] +counter = "flinches" +by = 1 + +[behaviour.blocks.b24] +kind = "idle" +at = [ + 400.0, + 2400.0, +] +next = [ + "b25", +] +label = "2nd flinch -> charge up" + +[behaviour.blocks.b25] +kind = "flag_is" +at = [ + 680.0, + 2400.0, +] +flag = "in_combat" +next = [ + "b26", +] + +[behaviour.blocks.b26] +kind = "counter_is" +at = [ + 960.0, + 2400.0, +] +counter = "flinches" +test = "at least" +value = 2 +next = [ + "b27", +] + +[behaviour.blocks.b27] +kind = "counter_set" +at = [ + 1240.0, + 2400.0, +] +counter = "flinches" +to = 0 +play = [ + "charge_up", +] + +[behaviour.blocks.b28] +kind = "idle" +at = [ + 400.0, + 2640.0, +] +next = [ + "b29", +] +label = "hunter far -> dash" + +[behaviour.blocks.b29] +kind = "flag_is" +at = [ + 680.0, + 2640.0, +] +flag = "in_combat" +next = [ + "b30", +] + +[behaviour.blocks.b30] +kind = "distance" +at = [ + 960.0, + 2640.0, +] +lo = 1500.0 +next = [ + "b31", +] + +[behaviour.blocks.b31] +kind = "cooldown" +at = [ + 1240.0, + 2640.0, ] +frames = 300 +play = [ + "dash", +] + +[behaviour.blocks.b32] +kind = "idle" +at = [ + 400.0, + 2840.0, +] +next = [ + "b33", +] +label = "below 30% HP -> howl" + +[behaviour.blocks.b33] +kind = "flag_is" +at = [ + 680.0, + 2840.0, +] +flag = "in_combat" +next = [ + "b34", +] + +[behaviour.blocks.b34] +kind = "monster_hp" +at = [ + 960.0, + 2840.0, +] +hi = 30 +next = [ + "b35", +] + +[behaviour.blocks.b35] +kind = "limit" +at = [ + 1240.0, + 2840.0, +] +times = 1 play = [ "notice_howl", ] +[behaviour.blocks.b36] +kind = "on_signal" +at = [ + 400.0, + 3040.0, +] +name = "roar" +play = [ + "notice_howl", +] +label = "a mod's roar -> howl" + [behaviour.moves.lunge] at = [ 0.0, @@ -994,20 +1281,38 @@ during = [ "b10", ] -[behaviour.moves.flinch_head] +[behaviour.moves.notice_howl] at = [ 1520.0, 800.0, ] +[behaviour.moves.flinch_head] +at = [ + 1520.0, + 1400.0, +] + +[behaviour.moves.topple_left] +at = [ + 1520.0, + 1600.0, +] + +[behaviour.moves.topple_right] +at = [ + 1520.0, + 1800.0, +] + [behaviour.moves.break_howl] at = [ 1520.0, - 1000.0, + 2000.0, ] -[behaviour.moves.notice_howl] +[behaviour.moves.charge_up] at = [ 1520.0, - 1200.0, + 2400.0, ]