From c35fad5dfaa02d6b05969233c9a8f0cfb06ace0f Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:45:15 +0200 Subject: [PATCH 01/27] feat(mhfu): the brain rule's conditions, effects and signal, its blackboard, and 48 rules and own moves The contract the framework and mhfu_port build against: EM_RULE grows CONDS, EFFECTS and SIGNAL (0x40), EM_OP, EM_BOARD, the EM_COND and EM_EFFECT enums, the enraged and calmed monster events and MONSTER_WATCH.ENRAGED. Co-Authored-By: Claude Opus 5.5 --- packages/mhfu/src/mhfu/addresses.toml | 54 +++++++++++++++++++++------ 1 file changed, 42 insertions(+), 12 deletions(-) diff --git a/packages/mhfu/src/mhfu/addresses.toml b/packages/mhfu/src/mhfu/addresses.toml index 0e85125..5ecea13 100644 --- a/packages/mhfu/src/mhfu/addresses.toml +++ b/packages/mhfu/src/mhfu/addresses.toml @@ -892,7 +892,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" } @@ -902,10 +902,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)" @@ -933,7 +933,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" } @@ -948,6 +948,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" } @@ -963,15 +964,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" } @@ -984,16 +985,45 @@ 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" } + +[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 rules" -size = 0x360 +size = 0xCE0 [struct.EM_CFG.fields] -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" From 12d27ee0f2ef30c7ce415f440d6af1aa2fc68000 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:11:15 +0200 Subject: [PATCH 02/27] feat(mhfu-port): conditions, effects, counters, signals and no-play paths in the behaviour graph New blocks (any_time, idle, on_signal, monster_hp, part_broken, rage, hunter_side, chance, counter_is, flag_is, counter_add, counter_set, flag_set) compile to a rule's conds, effects and signal, a path ending at an effect plays nothing, and the moves module gains vars and signals. The Zinogre gets the topple sequences, charge_up and the owner's example paths. Co-Authored-By: Claude Sonnet 5.5 --- packages/mhfu-port/src/mhfu_port/behaviour.py | 357 ++++++++++++++-- packages/mhfu-port/src/mhfu_port/moves.py | 14 +- packages/mhfu-port/tests/test_behaviour.py | 402 ++++++++++++++++-- packages/mhfu-port/tests/test_continuity.py | 11 + packages/mhfu-port/tests/test_moves.py | 71 +++- ports/zinogre.toml | 251 ++++++++++- 6 files changed, 1030 insertions(+), 76 deletions(-) diff --git a/packages/mhfu-port/src/mhfu_port/behaviour.py b/packages/mhfu-port/src/mhfu_port/behaviour.py index 7a51563..dd9b96d 100644 --- a/packages/mhfu-port/src/mhfu_port/behaviour.py +++ b/packages/mhfu-port/src/mhfu_port/behaviour.py @@ -3,9 +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, -the compiler and the studio all read it. +`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`, in canvas order: higher is checked first. `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 +48,25 @@ 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"] @dataclass(frozen=True) @@ -62,7 +75,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`.""" @@ -93,12 +107,24 @@ class Kind: "flinch": ("On flinch", "The monster flinches; its move replaces the base monster's flinch"), "part_broken": ("On part break", "A part of the monster breaks"), "tail_cut": ("On tail cut", "The monster's tail is cut off"), + "enraged": ("On enraged", "The monster becomes enraged"), + "calmed": ("On calmed", "The monster calms down"), } _PART = Param( "part", "part", "Of part", optional=True, tip="Only a flinch or break of this part; else any" ) +_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 = _EVENT_WORDS.get(name, (f"On {words}", f"The monster event {words}")) @@ -109,6 +135,21 @@ 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", + ), + Kind( + "any_time", "state", "At any time", (), "Fires in every main state of the base monster" + ), + Kind("idle", "state", "Base monster idle", (), "Fires while the base monster idles"), Kind( "host_state", "state", @@ -208,6 +249,139 @@ def _event(name: Event) -> Kind: ), "The mode the base monster's action is entered with", ), + 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", + ), + 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", + ), + Kind( + "rage", + "condition", + "Rage", + ( + Param( + "state", + "choice", + "State", + "enraged", + choices=tuple(_RAGE), + tip="Whether the monster is enraged", + ), + ), + "The monster is enraged or calm", + ), + 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", + ), + 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", + ), + 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", + ), + 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", + ), + 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", + ), + 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", + ), + 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", + ), ) } """The schema of a block, by kind name.""" @@ -245,20 +419,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")) @@ -271,16 +451,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.""" @@ -293,8 +477,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: @@ -330,6 +515,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 ("var", "signal"): + 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]}") @@ -415,6 +606,8 @@ def paths(m: Manifest) -> 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) @@ -431,9 +624,15 @@ def _priority(m: Manifest, p: Path) -> tuple[Any, ...]: 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 ( + 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]: @@ -451,6 +650,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, @@ -463,30 +664,109 @@ 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) -> dict[str, int]: + found = { + blk.params[p.name] + for blk in m.behaviour.blocks.values() + for p in KINDS[blk.kind].params + if p.type == type_ and p.name in blk.params + } + return {name: i for i, name in enumerate(sorted(found))} + + +def vars(m: Manifest) -> dict[str, int]: + """Every counter and flag the graph names -> its index on the monster's board, by name.""" + return _names(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 _names(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 @@ -508,6 +788,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 @@ -545,8 +830,13 @@ def drop_move(m: Manifest, move: str) -> None: 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") @@ -597,6 +887,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): @@ -605,12 +896,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 7e25c43..fbaeae8 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(): @@ -119,7 +125,7 @@ def test_defaults_fill_in(): 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)] @@ -127,7 +133,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"]), } @@ -137,15 +143,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"]), } ) @@ -155,9 +161,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"]), } ) @@ -184,14 +190,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"]), } @@ -228,7 +234,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"]), @@ -251,7 +257,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"]), @@ -267,23 +273,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) @@ -308,7 +316,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")} @@ -319,7 +327,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) == { @@ -380,6 +388,301 @@ 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_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 'Base monster 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", "b2"), "m"), (("b1", "b3"), None)] + + +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_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_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 = ( @@ -504,6 +807,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() @@ -537,13 +847,33 @@ def test_migrate_nothing(): dist=(0.0, 800.0), label="the hunter close during the dash -> stamp", ), + 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(None, on="flinch", effects=[("var_add", 0, 1)], label="count the flinches"), Rule( - "flinch_head", - on="flinch", - part=0, - label="a flinch of the head: his own tumble, never the host's (4,x)", + "charge_up", + from_main=[0], + conds=[("var_at_least", 0, 2)], + effects=[("var_set", 0, 0)], + label="2nd flinch -> charge up", ), - Rule("break_howl", on="part_broken", label="a part breaks"), + Rule( + "dash", + from_main=[0], + dist=(1500.0, B.UNLIMITED_DIST), + cooldown=300, + label="hunter far -> dash", + ), + Rule( + "notice_howl", + from_main=[0], + conds=[("hp_below", 0, 30)], + count=1, + label="below 30% HP -> howl", + ), + Rule("notice_howl", signal=0, label="a mod's roar -> howl"), Rule( "notice_howl", on="noticed", @@ -556,7 +886,21 @@ def test_migrate_nothing(): 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} and B.signals(m) == {"roar": 0} + old = ZINOGRE[: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 + + +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..1775bf8 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,14 @@ 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", 0, 2 } }, effects = { { "var_set", 0, 0 } }' in text + assert 'conds = { { "hp_below", 0, 30 } }' in text and "signal = 0" in text + assert " vars = { flinches = 0 },\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 +261,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/ports/zinogre.toml b/ports/zinogre.toml index 9b700f2..65f5c78 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,7 +974,7 @@ play = [ ] [behaviour.blocks.b12] -kind = "on_flinch" +kind = "on_part_broken" at = [ 400.0, 800.0, @@ -926,7 +983,7 @@ 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] kind = "on_part_broken" @@ -934,27 +991,185 @@ at = [ 400.0, 1000.0, ] +part = 4 +play = [ + "topple_left", +] +label = "left foreleg breaks -> topple left" + +[behaviour.blocks.b14] +kind = "on_part_broken" +at = [ + 400.0, + 1200.0, +] +part = 6 +play = [ + "topple_right", +] +label = "right foreleg breaks -> topple right" + +[behaviour.blocks.b15] +kind = "on_part_broken" +at = [ + 400.0, + 1400.0, +] play = [ "break_howl", ] label = "a part breaks" -[behaviour.blocks.b14] +[behaviour.blocks.b16] +kind = "on_flinch" +at = [ + 400.0, + 1600.0, +] +next = [ + "b17", +] +label = "count the flinches" + +[behaviour.blocks.b17] +kind = "counter_add" +at = [ + 680.0, + 1600.0, +] +counter = "flinches" +by = 1 + +[behaviour.blocks.b18] +kind = "idle" +at = [ + 400.0, + 1800.0, +] +next = [ + "b19", +] +label = "2nd flinch -> charge up" + +[behaviour.blocks.b19] +kind = "counter_is" +at = [ + 680.0, + 1800.0, +] +counter = "flinches" +test = "at least" +value = 2 +next = [ + "b20", +] + +[behaviour.blocks.b20] +kind = "counter_set" +at = [ + 960.0, + 1800.0, +] +counter = "flinches" +to = 0 +play = [ + "charge_up", +] + +[behaviour.blocks.b21] +kind = "idle" +at = [ + 400.0, + 2000.0, +] +next = [ + "b22", +] +label = "hunter far -> dash" + +[behaviour.blocks.b22] +kind = "distance" +at = [ + 680.0, + 2000.0, +] +lo = 1500.0 +next = [ + "b23", +] + +[behaviour.blocks.b23] +kind = "cooldown" +at = [ + 960.0, + 2000.0, +] +frames = 300 +play = [ + "dash", +] + +[behaviour.blocks.b24] +kind = "idle" +at = [ + 400.0, + 2200.0, +] +next = [ + "b25", +] +label = "below 30% HP -> howl" + +[behaviour.blocks.b25] +kind = "monster_hp" +at = [ + 680.0, + 2200.0, +] +hi = 30 +next = [ + "b26", +] + +[behaviour.blocks.b26] +kind = "limit" +at = [ + 960.0, + 2200.0, +] +times = 1 +play = [ + "notice_howl", +] + +[behaviour.blocks.b27] +kind = "on_signal" +at = [ + 400.0, + 2400.0, +] +name = "roar" +play = [ + "notice_howl", +] +label = "a mod's roar -> howl" + +[behaviour.blocks.b28] kind = "on_noticed" at = [ 400.0, - 1200.0, + 2600.0, ] next = [ - "b15", + "b29", ] label = "the notice: his howl at once, forced, so it does not wait for combat" -[behaviour.blocks.b15] +[behaviour.blocks.b29] kind = "force" at = [ 680.0, - 1200.0, + 2600.0, ] play = [ "notice_howl", @@ -1000,14 +1215,32 @@ at = [ 800.0, ] -[behaviour.moves.break_howl] +[behaviour.moves.topple_left] at = [ 1520.0, 1000.0, ] -[behaviour.moves.notice_howl] +[behaviour.moves.topple_right] at = [ 1520.0, 1200.0, ] + +[behaviour.moves.break_howl] +at = [ + 1520.0, + 1400.0, +] + +[behaviour.moves.charge_up] +at = [ + 1520.0, + 1800.0, +] + +[behaviour.moves.notice_howl] +at = [ + 1520.0, + 2200.0, +] From 7181e7b32d598c1769e714368feda3f1828e4ee1 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:11:17 +0200 Subject: [PATCH 03/27] docs(ports): conditions, effects, counters, signals and the Zinogre's paths Co-Authored-By: Claude Sonnet 5.5 --- ports/README.md | 55 +++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/ports/README.md b/ports/README.md index fe64a0b..a0c00e6 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 @@ -122,9 +123,44 @@ 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. +waits while an own move plays. 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, counts as fired (cooldown and limit work) and the scan goes on to the next rule. On +`on_flinch` it never replaces the host's reaction. A path that plays still stops the scan. Its +`force` and `mode` have nothing to act on, so a no-play path refuses them. + +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 decides when the reaction is armed, before that frame's effects +run, so a counter it tests misses the flinch being counted. 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 +187,17 @@ 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. 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`). +2. A flinch adds 1 to `flinches` and plays nothing. +3. Idle with `flinches` at least 2 sets it to 0 and plays `charge_up`. +4. Idle with the hunter 1500 away plays `dash`, then waits 300; idle below 30% HP plays + `notice_howl` once. +5. The signal `roar` plays `notice_howl`; the notice plays it at once. + ## Status | Manifest | Donor | Rides | From 19693320ea1cbffb96ab509f061d79afda3b6601 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:12:09 +0200 Subject: [PATCH 04/27] feat(studio): the graph view takes names and sides var and signal params are an editable combo over the names in use: a typed name must match behaviour.ID, else it goes back unheard. sides is a multi-select like mains. Co-Authored-By: Claude Sonnet 5.5 --- .../mhfu_studio/monster/panels/node_graph.py | 63 ++++++-- .../tests/monster/ui/test_node_graph.py | 134 ++++++++++++++++++ 2 files changed, 188 insertions(+), 9 deletions(-) diff --git a/apps/studio/src/mhfu_studio/monster/panels/node_graph.py b/apps/studio/src/mhfu_studio/monster/panels/node_graph.py index 747a712..6ae660a 100644 --- a/apps/studio/src/mhfu_studio/monster/panels/node_graph.py +++ b/apps/studio/src/mhfu_studio/monster/panels/node_graph.py @@ -17,6 +17,7 @@ from dataclasses import dataclass, field from typing import Any, Protocol, cast +from mhfu_port.behaviour import ID from NodeGraphQt import BaseNode, NodeBaseWidget, NodeGraph, Port from NodeGraphQt.qgraphics.node_base import NodeItem from PySide6.QtCore import QRectF, QSignalBlocker, Qt, QTimer, Signal @@ -40,7 +41,13 @@ Option = tuple[str, object] #: a palette group: its title, and its kinds as (kind, title, tip) PaletteGroup = tuple[str, Sequence[tuple[str, str, str]]] -KINDS = ("int", "float", "choice", "part", "mains") +NUMBERS = ("int", "float") +#: a multi-select over the options +PICKS = ("mains", "sides") +#: an editable combo over the names in use; a new name must match `mhfu_port.behaviour.ID` +NAMES = ("var", "signal") +#: the parameter types the view draws +KINDS = (*NUMBERS, "choice", "part", *PICKS, *NAMES) #: the reserved name of a node's note widget; no parameter is called this NOTE = "@note" NONE = "none" @@ -75,9 +82,10 @@ class NodeSpec: `options[name]` is a combo's or a multi-select's list of (label, value), else the parameter's `choices`; an option of value `None` is the optional combo's "none" under the - owner's own label. `values[name]` is a number, a value of the list, `None` for "none" - (an optional parameter), or for `mains` the values picked. `label` is the one free-text - note; `tip` the node's tooltip; `dim` draws it faded. + owner's own label. A name's options are the names in use, each as (name, name). `values[name]` + is a number, a value of the list, a name, `None` for "none" (an optional parameter), or for + `mains` and `sides` the values picked. `label` is the one free-text note; `tip` the node's + tooltip; `dim` draws it faded. """ id: str @@ -172,6 +180,9 @@ def __init__( parent, NOTE if param is None else param.name, "" if param is None else param.title ) 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 @@ -189,9 +200,9 @@ def _make(self, param: ParamLike | None) -> QWidget: line.setMinimumWidth(120) line.editingFinished.connect(self.on_value_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) @@ -201,7 +212,18 @@ def _make(self, param: ParamLike | None) -> QWidget: return button combo = QComboBox() combo.setMinimumWidth(120) - combo.currentIndexChanged.connect(self.on_value_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.on_value_changed) + else: + raise ValueError(f"parameter {param.name!r}: unknown type {param.type!r}") return combo def _number(self, param: ParamLike) -> QWidget: @@ -235,6 +257,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) @@ -251,6 +275,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) @@ -280,9 +310,13 @@ def set_options(self, options: Sequence[Option]) -> bool: built = self._built if built and list(options) == self._opts: return False - 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): @@ -311,6 +345,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.on_value_changed() + def on_value_changed(self, *_: object) -> None: if isinstance(self._ctl, QToolButton): self._summary() diff --git a/apps/studio/tests/monster/ui/test_node_graph.py b/apps/studio/tests/monster/ui/test_node_graph.py index 832ad3e..f2e9409 100644 --- a/apps/studio/tests/monster/ui/test_node_graph.py +++ b/apps/studio/tests/monster/ui/test_node_graph.py @@ -40,6 +40,9 @@ class P: OP = P("op", "choice", "Compare", choices=("below", "above"), tip="which way") PART = P("part", "part", "Part", optional=True, tip="a part") MAINS = P("mains", "mains", "Mains", optional=True, tip="mains") +COUNTER = P("counter", "var", "Counter", tip="which counter") +SIGNAL = P("name", "signal", "Signal", tip="which signal") +SIDES = P("sides", "sides", "Sides", choices=("front", "left", "right", "behind"), tip="sides") ROLES = {"trigger": Ink.HOT, "condition": Ink.AXIS_Z, "move": Ink.AXIS_X} @@ -680,3 +683,134 @@ def test_the_canvas_takes_a_colour_and_frames_nodes(qtbot: Any) -> None: node = view._shown["m"].view assert abs(centre.x() - (node.scenePos().x() + node.boundingRect().width() / 2)) < 120 view.frame(["nope"]) + + +# ---- names and sides ----------------------------------------------------------------------- # + + +def named() -> list[NodeSpec]: + """A node of a counter, a signal and sides.""" + return [ + NodeSpec( + "e", + "Count", + "effect", + (0, 0), + ("in",), + ("out",), + params=(COUNTER, SIGNAL, SIDES), + values={"counter": "hits", "name": "rage", "sides": ("front",)}, + options={ + "counter": [("armor", "armor"), ("hits", "hits")], + "name": [("rage", "rage")], + }, + ) + ] + + +def edits(rec: Rec) -> list[tuple[str, object]]: + """The (param, value) of every `param_changed` by now.""" + return [(a[1], a[2]) for n, a in rec.got() if n == "param_changed"] + + +def test_a_name_is_an_editable_combo_over_the_names_in_use(qtbot: Any) -> None: + view = make(qtbot, named(), []) + counter, signal = field(view, "e", "counter"), field(view, "e", "name") + assert isinstance(counter._ctl, QComboBox) and counter._ctl.isEditable() + assert [counter._ctl.itemText(i) for i in range(counter._ctl.count())] == ["armor", "hits"] + assert (counter.get_value(), signal.get_value()) == ("hits", "rage") + assert counter._ctl.currentIndex() == 1 and counter._ctl.currentText() == "hits" + view.show([replace(named()[0], values={"counter": "fresh", "name": "rage"})], []) + assert counter.get_value() == "fresh" and counter._ctl.currentIndex() == -1 # not an option + + +def test_sides_are_a_multi_select_over_the_choices(qtbot: Any) -> None: + view = make(qtbot, named(), []) + sides = field(view, "e", "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" + view.show([replace(named()[0], values={"sides": ("left", "behind")})], []) + assert sides.get_value() == ("left", "behind") and sides._ctl.text() == "left, behind" + + +def test_each_new_widget_says_its_edit_once(qtbot: Any) -> None: + view = make(qtbot, named(), []) + rec = Rec(view, qtbot) + counter, signal = field(view, "e", "counter")._ctl, field(view, "e", "name")._ctl + counter.setCurrentIndex(0) # a pick + assert edits(rec) == [("counter", "armor")] + counter.setEditText("fresh_1") # a new name, finished by Return or by leaving + counter.lineEdit().editingFinished.emit() + counter.lineEdit().editingFinished.emit() + assert edits(rec) == [("counter", "fresh_1")] + signal.lineEdit().selectAll() + QTest.keyClicks(signal.lineEdit(), "calm") + QTest.keyClick(signal.lineEdit(), Qt.Key.Key_Return) + assert edits(rec) == [("name", "calm")] + field(view, "e", "sides")._ctl.menu().actions()[2].trigger() + assert edits(rec) == [("sides", ("front", "right"))] + + +def test_a_name_that_is_not_a_name_goes_back_unheard(qtbot: Any) -> None: + view = make(qtbot, named(), []) + rec = Rec(view, qtbot) + f = field(view, "e", "counter") + for bad in ("Hits", "9lives", "two words", "x-y", "", " "): + f._ctl.setEditText(bad) + f._ctl.lineEdit().editingFinished.emit() + assert f.get_value() == "hits" and f._ctl.currentText() == "hits", bad + assert rec.got() == [] + + +def test_a_name_with_spaces_around_it_is_the_name(qtbot: Any) -> None: + view = make(qtbot, named(), []) + rec = Rec(view, qtbot) + f = field(view, "e", "counter") + f._ctl.setEditText(" fresh ") + f._ctl.lineEdit().editingFinished.emit() + assert f._ctl.currentText() == "fresh" + assert edits(rec) == [("counter", "fresh")] + + +def test_a_refused_name_goes_back_and_is_asked_again(qtbot: Any) -> None: + view = make(qtbot, named(), []) + rec = Rec(view, qtbot) + f = field(view, "e", "counter") + for _ in range(2): + f._ctl.setEditText("fresh") + f._ctl.lineEdit().editingFinished.emit() + assert edits(rec) == [("counter", "fresh")] + assert f.get_value() == "hits" + + +def test_an_accepted_name_stays_and_is_an_option(qtbot: Any) -> None: + view = make(qtbot, named(), []) + f = field(view, "e", "counter") + + def take(i: str, name: str, value: object) -> None: + opts = [("armor", "armor"), ("fresh", "fresh"), ("hits", "hits")] + spec = named()[0] + view.show( + [replace(spec, values={**spec.values, name: value}, options={"counter": opts})], [] + ) + + view.param_changed.connect(take) + f._ctl.setEditText("fresh") + f._ctl.lineEdit().editingFinished.emit() + qtbot.wait(20) + assert f.get_value() == "fresh" and f._ctl.currentIndex() == 1 and f._ctl.count() == 3 + + +def test_new_names_keep_what_is_picked(qtbot: Any) -> None: + view = make(qtbot, named(), []) + f = field(view, "e", "counter") + f._ctl.setEditText("half_typ") # not finished: not held + view.show([replace(named()[0], options={"counter": [("hits", "hits"), ("zeta", "zeta")]})], []) + assert f.get_value() == "hits" and f._ctl.count() == 2 + + +def test_an_unknown_type_raises(qtbot: Any) -> None: + view = make(qtbot) + with pytest.raises(ValueError, match="unknown type 'number'"): + view.show([replace(nodes()[0], params=(P("x", "number"),))], []) From bcc447ba38bd6ea370fa048b9ad22bb74080d119 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:12:11 +0200 Subject: [PATCH 05/27] test(studio): the seam-cap test builds more blocks than SEAM_RULES Co-Authored-By: Claude Sonnet 5.5 --- apps/studio/tests/monster/test_validate.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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") From 9dfc4fe33379e846908806db4cfe62b65e42754e Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:12:13 +0200 Subject: [PATCH 06/27] feat(studio): effects, counters, signals and sides in the Behaviour dock The effect role has its own colour and palette group; name combos offer every name the graph uses; a new block gets a fresh name or a front side; a path that plays nothing ranks on its last block. README lines. Co-Authored-By: Claude Sonnet 5.5 --- apps/studio/README.md | 8 ++ .../src/mhfu_studio/monster/behaviour.py | 69 +++++++--- .../mhfu_studio/monster/panels/behaviour.py | 17 ++- apps/studio/tests/monster/conftest.py | 67 ++++++++- apps/studio/tests/monster/test_behaviour.py | 109 ++++++++++++++- .../studio/tests/monster/ui/test_behaviour.py | 129 +++++++++++++++++- 6 files changed, 371 insertions(+), 28 deletions(-) diff --git a/apps/studio/README.md b/apps/studio/README.md index 0510410..9bf485d 100644 --- a/apps/studio/README.md +++ b/apps/studio/README.md @@ -98,6 +98,14 @@ rank (higher on the canvas is checked first), `⚠` a path the game refuses (the faded a block on no complete path. Delete removes blocks and their links; a move is deleted in Moves. Every gesture is one undo step. +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 `uv run pytest apps/studio` runs offscreen; the Qt tests skip without the `qt` group, and the diff --git a/apps/studio/src/mhfu_studio/monster/behaviour.py b/apps/studio/src/mhfu_studio/monster/behaviour.py index 03cacf2..8cbe5f2 100644 --- a/apps/studio/src/mhfu_studio/monster/behaviour.py +++ b/apps/studio/src/mhfu_studio/monster/behaviour.py @@ -11,8 +11,9 @@ from __future__ import annotations +import itertools import typing -from collections.abc import Callable, Mapping, Sequence +from collections.abc import Callable, Collection, Mapping, Sequence from typing import Any, NamedTuple, Protocol from mhfu_port import behaviour, sequence @@ -27,12 +28,8 @@ IN, OUT = "in", "out" PLAY, WHILE, THEN = "play", "while playing", "then" """A move node's ports: the input, then the two outputs.""" -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): @@ -158,7 +155,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.""" @@ -208,19 +206,43 @@ def palette() -> list[tuple[str, list[tuple[str, str, str]]]]: return [g for g in groups if g[1]] -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 names(m: Manifest, type_: str) -> list[str]: + """Every name a `type_` param (`var`, `signal`) of any block holds, sorted: what a name + combo offers.""" + found: set[str] = set() + for blk in m.behaviour.blocks.values(): + held = behaviour.params(blk) + for p in KINDS[blk.kind].params: + if p.type == type_ and isinstance(held.get(p.name), str): + found.add(held[p.name]) + return sorted(found) + + +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 @@ -230,8 +252,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 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) @@ -354,11 +383,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 0518cf0..3ab752e 100644 --- a/apps/studio/src/mhfu_studio/monster/panels/behaviour.py +++ b/apps/studio/src/mhfu_studio/monster/panels/behaviour.py @@ -19,9 +19,9 @@ from mhfu_studio.monster import behaviour as graph from mhfu_studio.monster.panels.common import kind -from mhfu_studio.monster.panels.node_graph import GraphView, LinkSpec, NodeSpec +from mhfu_studio.monster.panels.node_graph import NAMES, GraphView, LinkSpec, NodeSpec 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: @@ -29,18 +29,25 @@ 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, "move": Ink.SELECTION, } +#: the effect role's violet, for a dark theme and a light one: no `Ink` is one +EFFECT: dict[bool, Color] = {True: (0.82, 0.44, 0.91, 1.0), False: (0.56, 0.14, 0.67, 1.0)} MOVE_ROLE = "move" WARN = chr(0x26A0) HINT = "Right-click adds a block · drag from a port to wire · higher blocks are checked first" +def roles() -> dict[str, Color]: + """`ROLES` and the effect's violet, for the current theme.""" + return {**ROLES, "effect": EFFECT[theme.current().dark]} + + class BehaviourPanel(kit.Panel): def __init__(self, ws: MonsterWorkspace, studio: Studio) -> None: super().__init__(scroll=False) @@ -161,7 +168,7 @@ def sync(self) -> None: return if theme.current() is not self._theme: self._theme = theme.current() - self.view.set_roles(ROLES) + self.view.set_roles(roles()) self.view.set_canvas(theme.current().view) if self._drawn is not m: self._drawn = m @@ -191,6 +198,8 @@ def _blocks(m: Manifest, r: graph.Reading) -> list[NodeSpec]: options[p.name] = list(graph.part_options(m, p.optional)) elif p.type == "mains": options[p.name] = list(graph.main_options()) + elif p.type in NAMES: + options[p.name] = [(n, n) for n in graph.names(m, p.type)] out.append( NodeSpec( i, 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 427ef15..d9c6a70 100644 --- a/apps/studio/tests/monster/test_behaviour.py +++ b/apps/studio/tests/monster/test_behaviour.py @@ -11,7 +11,7 @@ import pytest from mhfu_port import behaviour as model from mhfu_port import manifest -from mhfu_port.behaviour import KINDS, Kind, Param, Rule +from mhfu_port.behaviour import KINDS, Block, Kind, Param, Rule from mhfu_port.manifest import Manifest, ManifestError from mhfu_studio.monster import behaviour as B from mhfu_studio.monster import clips @@ -337,7 +337,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( @@ -386,3 +388,106 @@ def test_a_move_in_use_is_not_deleted(ports: Path) -> None: clips.drop_move(z, "lunge") clips.drop_move(z, "idle") 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"] + [count, flag] = new_kinds[:2] + assert groups["Effects"] == [(k.name, k.title, k.tip) for k in (count, flag)] + 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_names_are_collected_across_the_graph( + new_kinds: tuple[Kind, ...], make: Callable[[str], Manifest], monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setitem( + KINDS, + "t_boss", + Kind("t_boss", "effect", "Boss", (Param("flag", "var", "Flag", "boss"),), "a default"), + ) + m = make("") + for i, (kind, params) in enumerate( + [ + ("t_count", {"counter": "hits"}), + ("t_flag", {"flag": "armor"}), + ("t_count", {"counter": "hits"}), + ("t_boss", {}), + ("t_signal", {"name": "rage"}), + ("t_side", {"sides": ["front"]}), + ] + ): + m.behaviour.blocks[f"b{i}"] = Block(kind, (0.0, 100.0 * i), params) + assert B.names(m, "var") == ["armor", "boss", "hits"] + assert B.names(m, "signal") == ["rage"] + assert B.names(m, "sides") == [] and B.names(m, "int") == [] + + +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 diff --git a/apps/studio/tests/monster/ui/test_behaviour.py b/apps/studio/tests/monster/ui/test_behaviour.py index e110a3d..92dc1be 100644 --- a/apps/studio/tests/monster/ui/test_behaviour.py +++ b/apps/studio/tests/monster/ui/test_behaviour.py @@ -11,11 +11,11 @@ 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 EFFECT, WARN, BehaviourPanel, roles from mhfu_studio.monster.panels.node_graph import GraphView, LinkSpec from mhfu_studio.monster.workspace import MonsterWorkspace from mhfu_studio.shell.studio import Studio @@ -265,7 +265,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: @@ -555,3 +557,124 @@ def test_renaming_a_move_keeps_the_graph(panel: BehaviourPanel, ws: MonsterWorks panel.sync() assert doc(ws).manifest.behaviour.blocks["b1"].play == ["stomp"] 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_has_its_place( + panel: BehaviourPanel, 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: []) + panel._drawn = None + panel.sync() + assert [badge(panel, n) for n in ("b1", "b2", "b3")] == [rank(2), "", rank(1)] + assert panel.title.text() == f"3 blocks · 2 of {model.SEAM_RULES} paths" + assert panel.view._shown["b2"].view.opacity() == 1.0 + + +@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(EFFECT[dark]) + 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 options(p: BehaviourPanel, node: str, name: str) -> list[str]: + ctl = field(p, node, 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 = field(p, node, 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 field(panel, "b5", "flag").get_value() == "armor_hits" and 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 and field(panel, "b5", "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 = field(panel, "b5", "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 field(panel, "b5", "sides").get_value() == ("behind",) From dd6fc9795568f29898095582099368c002162a4d Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:14:37 +0200 Subject: [PATCH 07/27] feat(ports): the Zinogre's idle paths wait for combat through an in_combat flag Combat start sets the flag, combat end clears it, each idle path requires it, and the notice path scans before them. Co-Authored-By: Claude Sonnet 5.5 --- packages/mhfu-port/tests/test_behaviour.py | 32 ++- packages/mhfu-port/tests/test_moves.py | 8 +- ports/README.md | 13 +- ports/zinogre.toml | 224 ++++++++++++++------- 4 files changed, 183 insertions(+), 94 deletions(-) diff --git a/packages/mhfu-port/tests/test_behaviour.py b/packages/mhfu-port/tests/test_behaviour.py index fbaeae8..fad6bc5 100644 --- a/packages/mhfu-port/tests/test_behaviour.py +++ b/packages/mhfu-port/tests/test_behaviour.py @@ -847,6 +847,21 @@ def test_migrate_nothing(): dist=(0.0, 800.0), label="the hunter close during the dash -> stamp", ), + Rule( + "notice_howl", + on="noticed", + force=True, + label="the notice: his howl at once, forced, so it does not wait for combat", + ), + 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("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"), @@ -855,7 +870,7 @@ def test_migrate_nothing(): Rule( "charge_up", from_main=[0], - conds=[("var_at_least", 0, 2)], + conds=[("var_at_least", 1, 1), ("var_at_least", 0, 2)], effects=[("var_set", 0, 0)], label="2nd flinch -> charge up", ), @@ -864,22 +879,17 @@ def test_migrate_nothing(): 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=[("hp_below", 0, 30)], + 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"), - Rule( - "notice_howl", - on="noticed", - force=True, - label="the notice: his howl at once, forced, so it does not wait for combat", - ), ] @@ -887,10 +897,14 @@ def test_zinogre_compiles_to_its_rules(): m = M.load(PORTS / "zinogre.toml") assert B.compile(m) == ZINOGRE assert B.loose(m) == [] and B.refused(m) == {} - assert B.vars(m) == {"flinches": 0} and B.signals(m) == {"roar": 0} + assert B.vars(m) == {"flinches": 0, "in_combat": 1} and B.signals(m) == {"roar": 0} old = ZINOGRE[: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" def test_zinogre_topples_end_in_the_stamp(): diff --git a/packages/mhfu-port/tests/test_moves.py b/packages/mhfu-port/tests/test_moves.py index 1775bf8..4b55d8e 100644 --- a/packages/mhfu-port/tests/test_moves.py +++ b/packages/mhfu-port/tests/test_moves.py @@ -235,9 +235,11 @@ def test_zinogre(data): 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", 0, 2 } }, effects = { { "var_set", 0, 0 } }' in text - assert 'conds = { { "hp_below", 0, 30 } }' in text and "signal = 0" in text - assert " vars = { flinches = 0 },\n signals = { roar = 0 },\n tip = " 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 diff --git a/ports/README.md b/ports/README.md index a0c00e6..41e878d 100644 --- a/ports/README.md +++ b/ports/README.md @@ -189,14 +189,15 @@ port with them; `mhfu move play --own NAME [--force]` plays one of its own moves The Zinogre's paths, after the lunge and dash chains (higher is checked first): -1. A break of the head plays `flinch_head`; of the left foreleg `topple_left`; of the right +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`). -2. A flinch adds 1 to `flinches` and plays nothing. -3. Idle with `flinches` at least 2 sets it to 0 and plays `charge_up`. -4. Idle with the hunter 1500 away plays `dash`, then waits 300; idle below 30% HP plays - `notice_howl` once. -5. The signal `roar` plays `notice_howl`; the notice plays it at once. +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 diff --git a/ports/zinogre.toml b/ports/zinogre.toml index 65f5c78..a463a50 100644 --- a/ports/zinogre.toml +++ b/ports/zinogre.toml @@ -974,22 +974,82 @@ play = [ ] [behaviour.blocks.b12] -kind = "on_part_broken" +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 = "horns break -> head flinch" -[behaviour.blocks.b13] +[behaviour.blocks.b19] kind = "on_part_broken" at = [ 400.0, - 1000.0, + 1600.0, ] part = 4 play = [ @@ -997,11 +1057,11 @@ play = [ ] label = "left foreleg breaks -> topple left" -[behaviour.blocks.b14] +[behaviour.blocks.b20] kind = "on_part_broken" at = [ 400.0, - 1200.0, + 1800.0, ] part = 6 play = [ @@ -1009,66 +1069,77 @@ play = [ ] label = "right foreleg breaks -> topple right" -[behaviour.blocks.b15] +[behaviour.blocks.b21] kind = "on_part_broken" at = [ 400.0, - 1400.0, + 2000.0, ] play = [ "break_howl", ] label = "a part breaks" -[behaviour.blocks.b16] +[behaviour.blocks.b22] kind = "on_flinch" at = [ 400.0, - 1600.0, + 2200.0, ] next = [ - "b17", + "b23", ] label = "count the flinches" -[behaviour.blocks.b17] +[behaviour.blocks.b23] kind = "counter_add" at = [ 680.0, - 1600.0, + 2200.0, ] counter = "flinches" by = 1 -[behaviour.blocks.b18] +[behaviour.blocks.b24] kind = "idle" at = [ 400.0, - 1800.0, + 2400.0, ] next = [ - "b19", + "b25", ] label = "2nd flinch -> charge up" -[behaviour.blocks.b19] -kind = "counter_is" +[behaviour.blocks.b25] +kind = "flag_is" at = [ 680.0, - 1800.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 = [ - "b20", + "b27", ] -[behaviour.blocks.b20] +[behaviour.blocks.b27] kind = "counter_set" at = [ - 960.0, - 1800.0, + 1240.0, + 2400.0, ] counter = "flinches" to = 0 @@ -1076,77 +1147,99 @@ play = [ "charge_up", ] -[behaviour.blocks.b21] +[behaviour.blocks.b28] kind = "idle" at = [ 400.0, - 2000.0, + 2600.0, ] next = [ - "b22", + "b29", ] label = "hunter far -> dash" -[behaviour.blocks.b22] -kind = "distance" +[behaviour.blocks.b29] +kind = "flag_is" at = [ 680.0, - 2000.0, + 2600.0, +] +flag = "in_combat" +next = [ + "b30", +] + +[behaviour.blocks.b30] +kind = "distance" +at = [ + 960.0, + 2600.0, ] lo = 1500.0 next = [ - "b23", + "b31", ] -[behaviour.blocks.b23] +[behaviour.blocks.b31] kind = "cooldown" at = [ - 960.0, - 2000.0, + 1240.0, + 2600.0, ] frames = 300 play = [ "dash", ] -[behaviour.blocks.b24] +[behaviour.blocks.b32] kind = "idle" at = [ 400.0, - 2200.0, + 2800.0, ] next = [ - "b25", + "b33", ] label = "below 30% HP -> howl" -[behaviour.blocks.b25] -kind = "monster_hp" +[behaviour.blocks.b33] +kind = "flag_is" at = [ 680.0, - 2200.0, + 2800.0, +] +flag = "in_combat" +next = [ + "b34", +] + +[behaviour.blocks.b34] +kind = "monster_hp" +at = [ + 960.0, + 2800.0, ] hi = 30 next = [ - "b26", + "b35", ] -[behaviour.blocks.b26] +[behaviour.blocks.b35] kind = "limit" at = [ - 960.0, - 2200.0, + 1240.0, + 2800.0, ] times = 1 play = [ "notice_howl", ] -[behaviour.blocks.b27] +[behaviour.blocks.b36] kind = "on_signal" at = [ 400.0, - 2400.0, + 3000.0, ] name = "roar" play = [ @@ -1154,27 +1247,6 @@ play = [ ] label = "a mod's roar -> howl" -[behaviour.blocks.b28] -kind = "on_noticed" -at = [ - 400.0, - 2600.0, -] -next = [ - "b29", -] -label = "the notice: his howl at once, forced, so it does not wait for combat" - -[behaviour.blocks.b29] -kind = "force" -at = [ - 680.0, - 2600.0, -] -play = [ - "notice_howl", -] - [behaviour.moves.lunge] at = [ 0.0, @@ -1209,38 +1281,38 @@ during = [ "b10", ] -[behaviour.moves.flinch_head] +[behaviour.moves.notice_howl] at = [ 1520.0, 800.0, ] -[behaviour.moves.topple_left] +[behaviour.moves.flinch_head] at = [ 1520.0, - 1000.0, + 1400.0, ] -[behaviour.moves.topple_right] +[behaviour.moves.topple_left] at = [ 1520.0, - 1200.0, + 1600.0, ] -[behaviour.moves.break_howl] +[behaviour.moves.topple_right] at = [ 1520.0, - 1400.0, + 1800.0, ] -[behaviour.moves.charge_up] +[behaviour.moves.break_howl] at = [ 1520.0, - 1800.0, + 2000.0, ] -[behaviour.moves.notice_howl] +[behaviour.moves.charge_up] at = [ 1520.0, - 2200.0, + 2400.0, ] From 21d940d682d0f793e9e8f5a27b70ed6983caef37 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:15:59 +0200 Subject: [PATCH 08/27] feat(framework): the enraged and calmed monster events ENTITY.FLAGS bit 0x20 rising and falling raise two more monster events, from the MONSTER_WATCH.ENRAGED byte, as the lua_host binds (mhfu.on_bigmonster_enraged, _calmed) and a brain rule's `on` takes them. The event list is the generated count, not a copy. Co-Authored-By: Claude Sonnet 5.5 --- framework/include/mhfu/events.h | 2 ++ framework/include/mhfu/monster_events.h | 12 ++++++++-- framework/lua/meta/mhfu.d.lua | 17 ++++++++++++-- .../mods/lua_host/bind_monster_events.cpp | 16 +++++++++----- framework/src/core/internal.h | 3 +++ framework/src/core/monster_events.cpp | 22 ++++++++++++------- framework/tests/test_monster_events.py | 11 ++++++++++ 7 files changed, 65 insertions(+), 18 deletions(-) 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..c3a729c 100644 --- a/framework/include/mhfu/monster_events.h +++ b/framework/include/mhfu/monster_events.h @@ -12,6 +12,8 @@ * FLINCH parts flinched (ENTITY.FLINCH_MASK); the pair is the reaction entered * PART_BROKEN ENTITY.BROKEN gains bits (BREAK_RECORD) * 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 +37,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 +69,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 +92,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); diff --git a/framework/lua/meta/mhfu.d.lua b/framework/lua/meta/mhfu.d.lua index 1bb1da4..1cd1f7a 100644 --- a/framework/lua/meta/mhfu.d.lua +++ b/framework/lua/meta/mhfu.d.lua @@ -791,6 +791,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,13 +811,13 @@ 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 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; enraged, calmed: 1 and 0 ---@field part? integer flinch, part_broken: the lowest flinched part ---A big monster now, read from its cells (the monster events' own reads): whether it noticed the 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/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..072a34c 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 ----------------------------------------------------------------------- */ @@ -138,11 +139,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); @@ -155,10 +157,14 @@ extern "C" uint16_t mhfu_monster_events_frame(uint32_t ent, int stale, uint8_t * 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 +187,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/tests/test_monster_events.py b/framework/tests/test_monster_events.py index 791da3d..9459421 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): @@ -85,6 +86,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 +136,15 @@ 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_a_full_ring_drops(g: Game) -> None: for k in range(40): g.poke(a.ENTITY.FLINCH_MASK, "B", 1 + k % 2) From cfa47819bf26471648cf2bd2608635b2846edbd6 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:16:09 +0200 Subject: [PATCH 09/27] feat(framework): rule conditions, effects, signals and the board in the brain A brain rule takes conditions (HP percent, broken parts, enraged or calm, the hunter's side by dot products of ENTITY.ROTATION's rows, a chance rolled last, counters), effects on a counter (var_add saturating, var_set), a Lua signal in place of an event, and no_play: it applies its effects and the scan goes on, in every AI frame. Every fire path applies the effects, the flinch reaction's take too. The board (counters, signals, broken parts, the chance's generator) belongs to the monster: reset at its spawn and the quest, signals held during the tail cut's wait. 48 rules and 48 own moves, from the generated counts (the armed rules are a byte each, not a 32-bit mask); the partitions grow by 5056 bytes and log their sizes. mhfu.em_rule takes the new fields, mhfu.em_var and mhfu.em_signal read and raise the board; API version 4. Co-Authored-By: Claude Sonnet 5.5 --- framework/include/mhfu/em_vhook.h | 77 +++- framework/lua/meta/mhfu.d.lua | 42 +- framework/mods/lua_host/bind_em.cpp | 58 ++- framework/src/core/em_vhook.cpp | 380 +++++++++++++++--- framework/tests/test_em_brain.py | 589 +++++++++++++++++++++++++++- framework/tests/test_lua_api.py | 18 + 6 files changed, 1073 insertions(+), 91 deletions(-) diff --git a/framework/include/mhfu/em_vhook.h b/framework/include/mhfu/em_vhook.h index 58343dd..0d3e67a 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 and the scan goes on */ + +/* 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,14 @@ 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. At most one rule that plays + * fires an AI frame; one with MHFU_EM_RULE_NO_PLAY applies its effects and the scan goes on to the + * next, at every AI frame whatever else the frame did. 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 +158,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 +199,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/lua/meta/mhfu.d.lua b/framework/lua/meta/mhfu.d.lua index 1cd1f7a..be371c6 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 @@ -838,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, counts the fire and the scan goes on to the next rule, in every AI frame; 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/src/core/em_vhook.cpp b/framework/src/core/em_vhook.cpp index 27b7932..a9b3f24 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); a rule that plays + * nothing applies its effects and the scan goes on, 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' */ @@ -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,180 @@ 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; + } +} + 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; edges: what the monster events raised, parts: what flinched, + * sigs: the signals taken. 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) + uint16_t edges, uint8_t parts, uint16_t sigs) { - if (r->on == MHFU_MONSTER_FLINCH) return 0; /* through the reaction replacement */ + if (r->on == MHFU_MONSTER_FLINCH && !quiet(r)) return 0; if (r->on && (!((edges >> r->on) & 1) || !part_of(r, parts))) return 0; - return gates(r, ent, m); + if (r->signal && (r->signal > MHFU_EM_SIGNALS || !((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,12 +624,13 @@ 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. */ + * 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, uint16_t edges) { if (!g_reacts || !mhfu_move_react_pending(ent)) return 0; @@ -478,14 +641,14 @@ static int react_take(uint32_t ent, const volatile mhfu_move_state_t *m, uint16_ 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) && 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 +658,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 +695,40 @@ static void react_arm(uint32_t ent, const volatile mhfu_move_state_t *m) g_reacts = 1; } +/* The rules in slot order: the first that plays and holds fires and ends the scan; one that plays + * nothing fires and the scan goes on. plays 0: the frame already did its one thing, so only the + * rules that play nothing are tried (and no chance is rolled for the others). */ +static void scan(uint32_t ent, const volatile mhfu_move_state_t *m, uint16_t edges, uint8_t parts, + uint16_t sigs, int plays) +{ + for (int i = 0; i < MHFU_EM_RULES; i++) { + cfg_rule_t *r = &g_cfgp->rules[i]; + if (!r->left || (!plays && !quiet(r))) continue; + if (holds(r, ent, m, edges, parts, sigs) && fire(r, ent, m) && !quiet(r)) break; + } +} + /* 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) +static int act(uint32_t ent, const volatile mhfu_move_state_t *m, uint16_t edges, uint8_t parts, + uint16_t sigs) { + 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, edges)) { + 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; + scan(ent, m, edges, parts, sigs, plays); + return took; } /* The tail cut runs to its drop: ent is in the cut with SEVERED, TAIL_DROP has yet to run, and @@ -582,7 +758,8 @@ 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. */ +/* 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, uint16_t edges, uint8_t parts) { if (T->held_ent != ent) { @@ -608,6 +785,7 @@ static uint16_t held(uint32_t ent, uint8_t *parts) 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); @@ -623,12 +801,14 @@ static uint32_t brain(uint32_t ent) if (!m || !R) return 0; uint8_t parts; uint16_t edges = mhfu_monster_events_frame(ent, m->entity == ent && m->skipping, &parts); + if ((edges >> MHFU_MONSTER_PART_BROKEN) & 1) g_board->broken |= parts; if (waits) { hold(ent, edges, parts); return 0; } + const uint16_t sigs = take_signals(); edges |= held(ent, &parts); - if (!act(ent, m, edges, parts)) react_arm(ent, m); + if (!act(ent, m, edges, parts, sigs)) react_arm(ent, m); return 0; } @@ -808,6 +988,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 +1023,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 +1042,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 +1056,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 +1074,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 +1203,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 +1355,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 +1372,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 +1415,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 +1439,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/tests/test_em_brain.py b/framework/tests/test_em_brain.py index aa30e76..77dd0f9 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,17 @@ 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") +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 +57,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 +87,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 +193,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 +257,12 @@ 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.mhfu_em_tip.argtypes = [ctypes.c_void_p, ctypes.c_int] return lib @@ -183,6 +271,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 +676,491 @@ 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_rule_before_its_counter_plays_in_the_next_frame(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) + g.flinch(0b001) + assert g.var(0) == 2 and (3, 6) not in g.entered() + g.frame() + assert (3, 6) in g.entered() + + +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 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 From 1d06fe252a34fc5277dd47e3520642f87c7512de Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:16:15 +0200 Subject: [PATCH 10/27] feat(framework): mhfu_port passes the rule fields, port:var and port:fire _seam_rule hands conds, effects, signal and no_play to the seam, and a rule that plays nothing needs no play target. A port names its board's counters and signals in the moves module's `vars` and `signals`; port:var(name [, v]) and port:fire(name) resolve them and log an unknown name once. The rule and move caps and the new limits come from the generated counts. Co-Authored-By: Claude Sonnet 5.5 --- framework/README.md | 24 +++++-- framework/lua/lib/mhfu_port.lua | 100 ++++++++++++++++++++++++------ framework/tests/test_mhfu_port.py | 74 ++++++++++++++++++++++ 3 files changed, 173 insertions(+), 25 deletions(-) diff --git a/framework/README.md b/framework/README.md index c4e4f14..cfecdd1 100644 --- a/framework/README.md +++ b/framework/README.md @@ -119,11 +119,11 @@ 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`, `_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 +146,18 @@ 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 rules" 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, applies its effects, and the scan goes on to the next rule, in +every AI frame. 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 +172,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/lua/lib/mhfu_port.lua b/framework/lua/lib/mhfu_port.lua index 849414a..338a57e 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,20 @@ 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`), applies its effects and the scan goes on to the next rule. 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 +509,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 +549,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 +560,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 +652,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/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) From 88898024a941612d25ad6949655d424bce945c85 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:33:32 +0200 Subject: [PATCH 11/27] feat(mhfu-port): behaviour.names is the one place a graph's names are collected vars and signals number its result; it never raises. Co-Authored-By: Claude Sonnet 5.5 --- packages/mhfu-port/src/mhfu_port/behaviour.py | 30 ++++++++++++------- packages/mhfu-port/tests/test_behaviour.py | 20 +++++++++++++ 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/packages/mhfu-port/src/mhfu_port/behaviour.py b/packages/mhfu-port/src/mhfu_port/behaviour.py index dd9b96d..97b6c02 100644 --- a/packages/mhfu-port/src/mhfu_port/behaviour.py +++ b/packages/mhfu-port/src/mhfu_port/behaviour.py @@ -696,24 +696,34 @@ def _sides(p: dict[str, Any], _: dict[str, int]) -> list[Op]: _SIGNAL = "on_signal" -def _names(m: Manifest, type_: str) -> dict[str, int]: - found = { - blk.params[p.name] - for blk in m.behaviour.blocks.values() - for p in KINDS[blk.kind].params - if p.type == type_ and p.name in blk.params - } - return {name: i for i, name in enumerate(sorted(found))} +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 _names(m, "var") + 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 _names(m, "signal") + return _indices(m, "signal") def _ops(blocks: list[Block], table: dict[str, Any], v: dict[str, int], w: str) -> list[Op]: diff --git a/packages/mhfu-port/tests/test_behaviour.py b/packages/mhfu-port/tests/test_behaviour.py index fad6bc5..eb622d5 100644 --- a/packages/mhfu-port/tests/test_behaviour.py +++ b/packages/mhfu-port/tests/test_behaviour.py @@ -625,6 +625,26 @@ def test_vars_and_signals_number_by_sorted_name(): 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"), From 058f831134db7bf119411b51987c2699747c2f64 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:33:34 +0200 Subject: [PATCH 12/27] test(studio): the tests follow the merged Zinogre; every real kind through the dock The studio's name collector is gone: the dock and add_block call behaviour.names. Co-Authored-By: Claude Sonnet 5.5 --- .../src/mhfu_studio/monster/behaviour.py | 14 +--- .../mhfu_studio/monster/panels/behaviour.py | 2 +- apps/studio/tests/monster/test_behaviour.py | 53 +++++------- .../studio/tests/monster/ui/test_behaviour.py | 80 +++++++++++++++++-- 4 files changed, 98 insertions(+), 51 deletions(-) diff --git a/apps/studio/src/mhfu_studio/monster/behaviour.py b/apps/studio/src/mhfu_studio/monster/behaviour.py index 8cbe5f2..34006b9 100644 --- a/apps/studio/src/mhfu_studio/monster/behaviour.py +++ b/apps/studio/src/mhfu_studio/monster/behaviour.py @@ -206,18 +206,6 @@ def palette() -> list[tuple[str, list[tuple[str, str, str]]]]: return [g for g in groups if g[1]] -def names(m: Manifest, type_: str) -> list[str]: - """Every name a `type_` param (`var`, `signal`) of any block holds, sorted: what a name - combo offers.""" - found: set[str] = set() - for blk in m.behaviour.blocks.values(): - held = behaviour.params(blk) - for p in KINDS[blk.kind].params: - if p.type == type_ and isinstance(held.get(p.name), str): - found.add(held[p.name]) - return sorted(found) - - 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) @@ -253,7 +241,7 @@ def add_block(doc: Edits, kind: str, at: Point) -> str: if kind not in KINDS: raise ManifestError(f"{kind!r} is not a kind of block") m = doc.manifest - taken = {n for p in KINDS[kind].params for n in names(m, p.type)} + 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: diff --git a/apps/studio/src/mhfu_studio/monster/panels/behaviour.py b/apps/studio/src/mhfu_studio/monster/panels/behaviour.py index 3ab752e..5c18bf5 100644 --- a/apps/studio/src/mhfu_studio/monster/panels/behaviour.py +++ b/apps/studio/src/mhfu_studio/monster/panels/behaviour.py @@ -199,7 +199,7 @@ def _blocks(m: Manifest, r: graph.Reading) -> list[NodeSpec]: elif p.type == "mains": options[p.name] = list(graph.main_options()) elif p.type in NAMES: - options[p.name] = [(n, n) for n in graph.names(m, p.type)] + options[p.name] = [(n, n) for n in model.names(m, p.type)] out.append( NodeSpec( i, diff --git a/apps/studio/tests/monster/test_behaviour.py b/apps/studio/tests/monster/test_behaviour.py index d9c6a70..b9c19fb 100644 --- a/apps/studio/tests/monster/test_behaviour.py +++ b/apps/studio/tests/monster/test_behaviour.py @@ -11,7 +11,7 @@ import pytest from mhfu_port import behaviour as model from mhfu_port import manifest -from mhfu_port.behaviour import KINDS, Block, Kind, Param, Rule +from mhfu_port.behaviour import KINDS, Kind, Param, Rule from mhfu_port.manifest import Manifest, ManifestError from mhfu_studio.monster import behaviour as B from mhfu_studio.monster import clips @@ -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 @@ -353,9 +366,11 @@ 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 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) @@ -382,7 +397,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") @@ -407,37 +422,13 @@ def edit(self, change: Callable[[Manifest], object]) -> None: 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"] - [count, flag] = new_kinds[:2] - assert groups["Effects"] == [(k.name, k.title, k.tip) for k in (count, flag)] + 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_names_are_collected_across_the_graph( - new_kinds: tuple[Kind, ...], make: Callable[[str], Manifest], monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setitem( - KINDS, - "t_boss", - Kind("t_boss", "effect", "Boss", (Param("flag", "var", "Flag", "boss"),), "a default"), - ) - m = make("") - for i, (kind, params) in enumerate( - [ - ("t_count", {"counter": "hits"}), - ("t_flag", {"flag": "armor"}), - ("t_count", {"counter": "hits"}), - ("t_boss", {}), - ("t_signal", {"name": "rage"}), - ("t_side", {"sides": ["front"]}), - ] - ): - m.behaviour.blocks[f"b{i}"] = Block(kind, (0.0, 100.0 * i), params) - assert B.names(m, "var") == ["armor", "boss", "hits"] - assert B.names(m, "signal") == ["rage"] - assert B.names(m, "sides") == [] and B.names(m, "int") == [] - - def test_new_blocks_start_with_fresh_names_and_a_side( new_kinds: tuple[Kind, ...], make: Callable[[str], Manifest], monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/apps/studio/tests/monster/ui/test_behaviour.py b/apps/studio/tests/monster/ui/test_behaviour.py index 92dc1be..5d1dd91 100644 --- a/apps/studio/tests/monster/ui/test_behaviour.py +++ b/apps/studio/tests/monster/ui/test_behaviour.py @@ -24,7 +24,7 @@ from mhfu_studio.ui.testing import FakeWorkspace from PySide6.QtCore import QPoint, QPointF, 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") @@ -227,16 +227,20 @@ def test_the_zinogre_opens( ws.load(Scene.from_bytes(synthetic_pac, "z", path=zinogre_toml.with_suffix(".bin")), zin) 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 field(p, "b23", "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] - note = field(p, "b12", "@note").get_value() - assert note.startswith("a flinch of the head") - assert field(p, "b12", "part").get_value() == 0 + note = field(p, "b18", "@note").get_value() + assert note.startswith("horns break") + assert field(p, "b18", "part").get_value() == 0 assert field(p, "b2", "hi").get_value() == 1000.0 @@ -678,3 +682,67 @@ def test_signals_and_sides_through_the_canvas( qtbot.wait(30) assert doc(ws).manifest is kept and "side" in ws.message assert field(panel, "b5", "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 = field(p, node, 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 = field(panel, i, 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 From e39944ac07006416f325ac2613a3566699cee450 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:33:31 +0200 Subject: [PATCH 13/27] feat(mhfu-port): any_time and idle say they wait for own moves; the README on no-play order Co-Authored-By: Claude Sonnet 5.5 --- packages/mhfu-port/src/mhfu_port/behaviour.py | 14 ++++++++++++-- packages/mhfu-port/tests/test_behaviour.py | 9 ++++++++- ports/README.md | 16 ++++++++++------ 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/packages/mhfu-port/src/mhfu_port/behaviour.py b/packages/mhfu-port/src/mhfu_port/behaviour.py index 97b6c02..3d3d8e3 100644 --- a/packages/mhfu-port/src/mhfu_port/behaviour.py +++ b/packages/mhfu-port/src/mhfu_port/behaviour.py @@ -147,9 +147,19 @@ def _event(name: Event) -> Kind: "A mod raises this signal", ), Kind( - "any_time", "state", "At any time", (), "Fires in every main state of the base monster" + "any_time", + "state", + "Any time (between moves)", + (), + "Fires every AI frame in any main state of the base monster, while no own move plays", + ), + Kind( + "idle", + "state", + "Base monster idle (between moves)", + (), + "Fires every AI frame the base monster idles, while no own move plays", ), - Kind("idle", "state", "Base monster idle", (), "Fires while the base monster idles"), Kind( "host_state", "state", diff --git a/packages/mhfu-port/tests/test_behaviour.py b/packages/mhfu-port/tests/test_behaviour.py index eb622d5..4b10c07 100644 --- a/packages/mhfu-port/tests/test_behaviour.py +++ b/packages/mhfu-port/tests/test_behaviour.py @@ -441,11 +441,18 @@ def test_idle_and_any_time_are_main_states(): 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 "between moves" in B.KINDS[k].title 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 'Base monster idle' blocks"): + with pytest.raises( + ManifestError, match="has two 'Base monster idle \\(between moves\\)' blocks" + ): B.compile(_chain(blk("idle"), blk("idle"))) diff --git a/ports/README.md b/ports/README.md index 41e878d..1913a6f 100644 --- a/ports/README.md +++ b/ports/README.md @@ -140,9 +140,12 @@ Condition and effect blocks may repeat on a path: a rule holds 4 conditions and (`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, counts as fired (cooldown and limit work) and the scan goes on to the next rule. On -`on_flinch` it never replaces the host's reaction. A path that plays still stops the scan. Its -`force` and `mode` have nothing to act on, so a no-play path refuses them. +effects and counts as fired (cooldown and limit work). The framework runs every no-play path each +AI frame first, whatever its place on the canvas; then the playing paths from the highest, and +the first that holds plays and ends the scan. So canvas order ranks the playing paths only. 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 @@ -158,9 +161,10 @@ 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 decides when the reaction is armed, before that frame's effects -run, so a counter it tests misses the flinch being counted. The Zinogre counts flinches on a -no-play rule and charges up from idle once `flinches` is 2, not on the flinch itself. +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 From 3588bb1071399ada842b94187c38625e0cc8d794 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:36:46 +0200 Subject: [PATCH 14/27] feat(mhfu-port): the rule table lists every no-play path first, as the brain runs them Co-Authored-By: Claude Sonnet 5.5 --- packages/mhfu-port/src/mhfu_port/behaviour.py | 12 +++-- packages/mhfu-port/tests/test_behaviour.py | 49 ++++++++++++++----- ports/README.md | 24 ++++----- 3 files changed, 57 insertions(+), 28 deletions(-) diff --git a/packages/mhfu-port/src/mhfu_port/behaviour.py b/packages/mhfu-port/src/mhfu_port/behaviour.py index 3d3d8e3..e0a8246 100644 --- a/packages/mhfu-port/src/mhfu_port/behaviour.py +++ b/packages/mhfu-port/src/mhfu_port/behaviour.py @@ -5,8 +5,9 @@ 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 that ends at an effect block with neither `next` nor `play` plays nothing and only applies its effects. One path -is one `Rule`, in canvas order: higher is checked first. `KINDS` is the schema of a block; the -loader, the compiler and the studio all read it. +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. """ @@ -601,7 +602,8 @@ def validate(m: Manifest) -> 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 fed = {n for blk in b.blocks.values() for n in blk.next} listed = {n for node in b.moves.values() for n in node.during} @@ -628,7 +630,8 @@ def walk(trail: tuple[str, ...], during: str | None) -> None: 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]: @@ -637,6 +640,7 @@ def row(at: tuple[float, float]) -> tuple[float, float]: 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 ( + p.play is not None, tuple(row(b.blocks[i].at) for i in p.blocks), move, p.blocks, diff --git a/packages/mhfu-port/tests/test_behaviour.py b/packages/mhfu-port/tests/test_behaviour.py index 4b10c07..33e1a78 100644 --- a/packages/mhfu-port/tests/test_behaviour.py +++ b/packages/mhfu-port/tests/test_behaviour.py @@ -561,7 +561,7 @@ def test_a_block_that_plays_and_ends_a_no_play_path_makes_both(): "b3": blk("counter_add", x=2, counter="n"), } ) - assert [(p.blocks, p.play) for p in B.paths(m)] == [(("b1", "b2"), "m"), (("b1", "b3"), None)] + 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(): @@ -601,6 +601,26 @@ def test_a_rule_that_plays_nothing_needs_an_effect(): 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( { @@ -848,7 +868,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", @@ -880,20 +910,10 @@ def test_migrate_nothing(): force=True, label="the notice: his howl at once, forced, so it does not wait for combat", ), - 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("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(None, on="flinch", effects=[("var_add", 0, 1)], label="count the flinches"), Rule( "charge_up", from_main=[0], @@ -925,13 +945,16 @@ def test_zinogre_compiles_to_its_rules(): assert B.compile(m) == ZINOGRE assert B.loose(m) == [] and B.refused(m) == {} assert B.vars(m) == {"flinches": 0, "in_combat": 1} and B.signals(m) == {"roar": 0} - old = ZINOGRE[:4] + 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(): diff --git a/ports/README.md b/ports/README.md index 1913a6f..f390483 100644 --- a/ports/README.md +++ b/ports/README.md @@ -122,11 +122,11 @@ 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, 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. +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 @@ -140,12 +140,14 @@ Condition and effect blocks may repeat on a path: a rule holds 4 conditions and (`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 framework runs every no-play path each -AI frame first, whatever its place on the canvas; then the playing paths from the highest, and -the first that holds plays and ends the scan. So canvas order ranks the playing paths only. 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. +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 From 840bbfca255a118b9d279a122876546fbba52d94 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:39:43 +0200 Subject: [PATCH 15/27] fix(framework): a move on the hub carrier quiets the AI script's wake The hub's entry after a reaction sets SCRIPT_WAKE, and the script's next pair cut the carrier of a move started in that frame at 0 AI frames. start() quieted the wake only during the notice; it now quiets it for any move whose carrier is the hub (MHFU_MOVE_HUB_*, the default carrier). Co-Authored-By: Claude Sonnet 5.5 --- framework/include/mhfu/move.h | 4 ++++ framework/mods/lua_host/bind_move.cpp | 4 ++-- framework/src/core/move.cpp | 13 ++++++++----- framework/tests/test_move.py | 10 ++++++++++ 4 files changed, 24 insertions(+), 7 deletions(-) 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/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/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/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) From e8bd7ee175fc771a4798effd24e6d23b14bb6476 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:39:44 +0200 Subject: [PATCH 16/27] fix(framework): counters run before the rules that play, and every part's break is an event B1: each AI frame, the rules that play nothing all run first, wherever they sit, then the first rule that plays; a playing rule no longer starves a counter of the frame's events (a re-notice's combat_entered, a flinch that breaks a part), and it sees the counters as that frame left them. B3: a part's break is its FLINCH_PART.COUNT reaching its species' BREAK_TABLE row's, once per part, with the row's BREAK_ID bit as data; two parts sharing an id both raise it. The board latches, and part_broken rules test, the part that broke rather than every flinched part. A bit ENTITY.BROKEN gains that no row explains is still raised. Co-Authored-By: Claude Sonnet 5.5 --- framework/README.md | 17 +++-- framework/include/mhfu/em_vhook.h | 9 +-- framework/include/mhfu/monster_events.h | 10 ++- framework/lua/lib/mhfu_port.lua | 5 +- framework/lua/meta/mhfu.d.lua | 10 +-- framework/src/core/em_vhook.cpp | 95 +++++++++++++++---------- framework/src/core/monster_events.cpp | 44 +++++++++++- framework/tests/em_host.cpp | 15 ++++ framework/tests/move_host.cpp | 15 ++++ framework/tests/test_em_brain.py | 61 +++++++++++++++- framework/tests/test_monster_events.py | 83 +++++++++++++++++++-- 11 files changed, 294 insertions(+), 70 deletions(-) diff --git a/framework/README.md b/framework/README.md index 7b03903..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`, `_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`. +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 @@ -150,8 +151,10 @@ A rule also takes `conds` that must all hold (`{ "hp_below", 0, 30 }`: op, arg, `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, applies its effects, and the scan goes on to the next rule, in -every AI frame. The counters (`mhfu.em_var`), signals (`mhfu.em_signal`) and the parts broken so +`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 diff --git a/framework/include/mhfu/em_vhook.h b/framework/include/mhfu/em_vhook.h index 0d3e67a..8e9ce29 100644 --- a/framework/include/mhfu/em_vhook.h +++ b/framework/include/mhfu/em_vhook.h @@ -53,7 +53,7 @@ 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 and the scan goes on */ +#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. */ @@ -117,9 +117,10 @@ void mhfu_em_substitute(int slot, uint8_t from_mask, uint8_t from_sub, 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, and so does a rule that cannot hold: no trigger - * (from_*, on or signal), an unknown op, a board index out of range. At most one rule that plays - * fires an AI frame; one with MHFU_EM_RULE_NO_PLAY applies its effects and the scan goes on to the - * next, at every AI frame whatever else the frame did. While a rule on the flinch that plays is + * (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 diff --git a/framework/include/mhfu/monster_events.h b/framework/include/mhfu/monster_events.h index c3a729c..33d1b83 100644 --- a/framework/include/mhfu/monster_events.h +++ b/framework/include/mhfu/monster_events.h @@ -10,7 +10,10 @@ * 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 @@ -116,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/lua/lib/mhfu_port.lua b/framework/lua/lib/mhfu_port.lua index 338a57e..3b44ed8 100644 --- a/framework/lua/lib/mhfu_port.lua +++ b/framework/lua/lib/mhfu_port.lua @@ -497,8 +497,9 @@ end --- 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`), applies its effects and the scan goes on to the next rule. The numbers are ---- board indices, which the moves module's `vars` and `signals` give names. +--- 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) diff --git a/framework/lua/meta/mhfu.d.lua b/framework/lua/meta/mhfu.d.lua index be371c6..8f7b199 100644 --- a/framework/lua/meta/mhfu.d.lua +++ b/framework/lua/meta/mhfu.d.lua @@ -783,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 @@ -820,8 +820,8 @@ function mhfu.monster_events_block() end ---@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; enraged, calmed: 1 and 0 ----@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. @@ -862,7 +862,7 @@ function mhfu.monster_state(ent) end ---@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, counts the fire and the scan goes on to the next rule, in every AI frame; with `on = "flinch"` it never replaces the reaction +---@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. diff --git a/framework/src/core/em_vhook.cpp b/framework/src/core/em_vhook.cpp index a9b3f24..6507043 100644 --- a/framework/src/core/em_vhook.cpp +++ b/framework/src/core/em_vhook.cpp @@ -38,8 +38,8 @@ * ours that ended, a rule * ("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); a rule that plays - * nothing applies its effects and the scan goes on, in every AI frame; + * 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 @@ -194,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"); @@ -565,6 +565,14 @@ static void apply(const cfg_rule_t *r) } } +/* 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)); @@ -572,15 +580,17 @@ static int part_of(const cfg_rule_t *r, uint8_t parts) static int quiet(const cfg_rule_t *r) { return (r->flags & MHFU_EM_RULE_NO_PLAY) != 0; } -/* rule r fires on ent this frame; edges: what the monster events raised, parts: what flinched, - * sigs: the signals taken. A rule on the flinch that plays goes through the reaction - * replacement. */ +/* 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, uint16_t sigs) + const seen_t *s) { if (r->on == MHFU_MONSTER_FLINCH && !quiet(r)) return 0; - if (r->on && (!((edges >> r->on) & 1) || !part_of(r, parts))) return 0; - if (r->signal && (r->signal > MHFU_EM_SIGNALS || !((sigs >> (r->signal - 1)) & 1))) 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); } @@ -631,15 +641,15 @@ static uint8_t g_armed[MHFU_EM_RULES]; /* 1: rule i's gates and conditions h * 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, uint16_t edges) +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 && conds_hold(r, ent)) { first = r; @@ -695,29 +705,31 @@ static void react_arm(uint32_t ent, const volatile mhfu_move_state_t *m) g_reacts = 1; } -/* The rules in slot order: the first that plays and holds fires and ends the scan; one that plays - * nothing fires and the scan goes on. plays 0: the frame already did its one thing, so only the - * rules that play nothing are tried (and no chance is rolled for the others). */ -static void scan(uint32_t ent, const volatile mhfu_move_state_t *m, uint16_t edges, uint8_t parts, - uint16_t sigs, int plays) +/* 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 || (!plays && !quiet(r))) continue; - if (holds(r, ent, m, edges, parts, sigs) && fire(r, ent, m) && !quiet(r)) break; + if (!r->left || quiet(r) != quiet_rules) continue; + if (holds(r, ent, m, s) && fire(r, ent, m) && !quiet_rules) break; } } -/* 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, - uint16_t sigs) +/* 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); - } else if (react_take(ent, m, edges)) { + } 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) @@ -727,7 +739,7 @@ static int act(uint32_t ent, const volatile mhfu_move_state_t *m, uint16_t edges } else { plays = 1; } - scan(ent, m, edges, parts, sigs, plays); + if (plays) scan(ent, m, s, 0); return took; } @@ -760,26 +772,30 @@ static void request(uint32_t ent) /* 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, uint16_t edges, uint8_t parts) +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) @@ -799,16 +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); - if ((edges >> MHFU_MONSTER_PART_BROKEN) & 1) g_board->broken |= 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; } - const uint16_t sigs = take_signals(); - edges |= held(ent, &parts); - if (!act(ent, m, edges, parts, sigs)) react_arm(ent, m); + seen.sigs = take_signals(); + held(ent, &seen); + if (!act(ent, m, &seen)) react_arm(ent, m); return 0; } diff --git a/framework/src/core/monster_events.cpp b/framework/src/core/monster_events.cpp index 072a34c..a1f1fa5 100644 --- a/framework/src/core/monster_events.cpp +++ b/framework/src/core/monster_events.cpp @@ -128,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); @@ -152,8 +167,31 @@ 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); 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 77dd0f9..e7bad88 100644 --- a/framework/tests/test_em_brain.py +++ b/framework/tests/test_em_brain.py @@ -32,6 +32,7 @@ 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 @@ -263,6 +264,13 @@ def lib(host_lib: Callable[..., ctypes.CDLL]) -> ctypes.CDLL: 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 @@ -979,15 +987,37 @@ def test_a_counter_then_a_rule_on_it_in_one_frame(g: Game) -> None: assert g.var(0) == 2 and (3, 6) in g.entered() -def test_a_rule_before_its_counter_plays_in_the_next_frame(g: Game) -> None: +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) not in g.entered() + 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() - assert (3, 6) in g.entered() + 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: @@ -1164,3 +1194,28 @@ def test_the_last_rule_and_move_slots_work(g: Game) -> None: 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_monster_events.py b/framework/tests/test_monster_events.py index 9459421..21d488f 100644 --- a/framework/tests/test_monster_events.py +++ b/framework/tests/test_monster_events.py @@ -45,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 @@ -60,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() @@ -145,6 +167,59 @@ def test_enraged_and_calmed_once(g: Game) -> None: 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) From c1588f41b795ad91e5036596a33d56eaceab27e7 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:56:46 +0200 Subject: [PATCH 17/27] test(studio): the Zinogre's ranks follow the no-play-first table; the dock says it Co-Authored-By: Claude Sonnet 5.5 --- apps/studio/README.md | 3 ++- .../mhfu_studio/monster/panels/behaviour.py | 5 +++- apps/studio/tests/monster/test_behaviour.py | 1 + .../studio/tests/monster/ui/test_behaviour.py | 25 +++++++++++-------- 4 files changed, 22 insertions(+), 12 deletions(-) diff --git a/apps/studio/README.md b/apps/studio/README.md index 9bf485d..3fbb238 100644 --- a/apps/studio/README.md +++ b/apps/studio/README.md @@ -94,7 +94,8 @@ graph: every move and every block is a node, and right-click adds a block (the p `mhfu_port.behaviour.KINDS`). A wire from a block's `out` feeds the next block's `in`, or plays a move at its `play`; a move's `while playing` starts a path while it plays, and its `then` hands to another move (`after`). Any other wire is refused in the status line. `#2` on a block is its path's -rank (higher on the canvas is checked first), `⚠` a path the game refuses (the tip says why), +rank (higher on the canvas is checked first, but every path that plays nothing runs before all +the others), `⚠` a path the game refuses (the tip says why), faded a block on no complete path. Delete removes blocks and their links; a move is deleted in Moves. Every gesture is one undo step. diff --git a/apps/studio/src/mhfu_studio/monster/panels/behaviour.py b/apps/studio/src/mhfu_studio/monster/panels/behaviour.py index 5c18bf5..d2a85b5 100644 --- a/apps/studio/src/mhfu_studio/monster/panels/behaviour.py +++ b/apps/studio/src/mhfu_studio/monster/panels/behaviour.py @@ -40,7 +40,10 @@ EFFECT: dict[bool, Color] = {True: (0.82, 0.44, 0.91, 1.0), False: (0.56, 0.14, 0.67, 1.0)} 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" +) def roles() -> dict[str, Color]: diff --git a/apps/studio/tests/monster/test_behaviour.py b/apps/studio/tests/monster/test_behaviour.py index b9c19fb..07c98c2 100644 --- a/apps/studio/tests/monster/test_behaviour.py +++ b/apps/studio/tests/monster/test_behaviour.py @@ -370,6 +370,7 @@ def test_the_zinogre_reads(ports: Path) -> None: 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"} diff --git a/apps/studio/tests/monster/ui/test_behaviour.py b/apps/studio/tests/monster/ui/test_behaviour.py index 5d1dd91..9cd0a9f 100644 --- a/apps/studio/tests/monster/ui/test_behaviour.py +++ b/apps/studio/tests/monster/ui/test_behaviour.py @@ -232,6 +232,8 @@ def test_the_zinogre_opens( 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 field(p, "b23", "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) @@ -566,18 +568,21 @@ def test_renaming_a_move_keeps_the_graph(panel: BehaviourPanel, ws: MonsterWorks # ---- effects, names, sides and paths that play nothing -------------------------------------- # -def test_a_path_that_plays_nothing_has_its_place( - panel: BehaviourPanel, monkeypatch: pytest.MonkeyPatch +def test_a_path_that_plays_nothing_ranks_first_wherever_it_sits( + panel: BehaviourPanel, ws: MonsterWorkspace ) -> 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: []) - panel._drawn = 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")] == [rank(2), "", rank(1)] - assert panel.title.text() == f"3 blocks · 2 of {model.SEAM_RULES} paths" - assert panel.view._shown["b2"].view.opacity() == 1.0 + 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( From e1aacf79d69c51a81de0864b0c343d74a343f95a Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:07:15 +0200 Subject: [PATCH 18/27] test(studio): a new counter or flag name leaves every node's size The Zinogre at its fitted zoom, and a named node zoomed out: the names that enter the graph change the options of every name combo. Co-Authored-By: Claude Sonnet 5.5 --- apps/studio/tests/monster/ui/test_behaviour.py | 16 ++++++++++++++++ apps/studio/tests/monster/ui/test_node_graph.py | 10 ++++++++++ 2 files changed, 26 insertions(+) diff --git a/apps/studio/tests/monster/ui/test_behaviour.py b/apps/studio/tests/monster/ui/test_behaviour.py index e7caeb8..cdbbce8 100644 --- a/apps/studio/tests/monster/ui/test_behaviour.py +++ b/apps/studio/tests/monster/ui/test_behaviour.py @@ -316,6 +316,22 @@ def test_the_zinogre_opens( assert field(p, "b2", "hi").get_value() == 1000.0 +def test_a_new_name_keeps_every_node_size_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) + p.view.grab() # a paint: the fitted graph is small, and NodeGraphQt hides small nodes' widgets + 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" + assert {i: (s.view.width, s.view.height) for i, s in p.view._shown.items()} == sizes + + def test_spread_out_is_one_step(panel: BehaviourPanel, ws: MonsterWorkspace) -> None: before = doc(ws).manifest panel.spread.click() diff --git a/apps/studio/tests/monster/ui/test_node_graph.py b/apps/studio/tests/monster/ui/test_node_graph.py index 822199b..c866fc9 100644 --- a/apps/studio/tests/monster/ui/test_node_graph.py +++ b/apps/studio/tests/monster/ui/test_node_graph.py @@ -959,3 +959,13 @@ def test_an_unknown_type_raises(qtbot: Any) -> None: view = make(qtbot) with pytest.raises(ValueError, match="unknown type 'number'"): view.show([replace(nodes()[0], params=(P("x", "number"),))], []) + + +def test_a_new_name_keeps_the_node_size_when_zoomed_out(qtbot: Any) -> None: + view = make(qtbot, named(), []) + before = size(view, "e") + view.viewer.scale(0.15, 0.15) + view.grab() # a paint: NodeGraphQt hides a small node's widgets + opts = [("armor", "armor"), ("fresh", "fresh"), ("hits", "hits")] + view.show([replace(named()[0], options={"counter": opts, "name": opts})], []) + assert size(view, "e") == before From 7111167a0ef83fe75cec72a706afd30971de8bb3 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:16:47 +0200 Subject: [PATCH 19/27] fix(ports): the Zinogre's lower rows clear the counter_is above them b26 (counter_is, 224 high) overlapped b30 on a 200 pitch. The rows from y=2600 move down by 40. The blocks keep their order, so every path keeps its priority and the compiled rules are the same. Co-Authored-By: Claude Sonnet 5.5 --- apps/studio/tests/monster/ui/test_behaviour.py | 18 +++++++++++++++++- ports/zinogre.toml | 18 +++++++++--------- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/apps/studio/tests/monster/ui/test_behaviour.py b/apps/studio/tests/monster/ui/test_behaviour.py index cdbbce8..1427e51 100644 --- a/apps/studio/tests/monster/ui/test_behaviour.py +++ b/apps/studio/tests/monster/ui/test_behaviour.py @@ -5,6 +5,7 @@ from __future__ import annotations +import itertools from collections.abc import Callable from pathlib import Path from typing import Any @@ -22,7 +23,7 @@ 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 QComboBox, QDockWidget, QToolButton @@ -341,6 +342,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(panel: BehaviourPanel, ws: MonsterWorkspace) -> None: doc(ws).edit(lambda m: m.behaviour.blocks.__setitem__("b4", Block("on_flinch", (0.0, 0.0)))) panel.sync() diff --git a/ports/zinogre.toml b/ports/zinogre.toml index a463a50..407590b 100644 --- a/ports/zinogre.toml +++ b/ports/zinogre.toml @@ -1151,7 +1151,7 @@ play = [ kind = "idle" at = [ 400.0, - 2600.0, + 2640.0, ] next = [ "b29", @@ -1162,7 +1162,7 @@ label = "hunter far -> dash" kind = "flag_is" at = [ 680.0, - 2600.0, + 2640.0, ] flag = "in_combat" next = [ @@ -1173,7 +1173,7 @@ next = [ kind = "distance" at = [ 960.0, - 2600.0, + 2640.0, ] lo = 1500.0 next = [ @@ -1184,7 +1184,7 @@ next = [ kind = "cooldown" at = [ 1240.0, - 2600.0, + 2640.0, ] frames = 300 play = [ @@ -1195,7 +1195,7 @@ play = [ kind = "idle" at = [ 400.0, - 2800.0, + 2840.0, ] next = [ "b33", @@ -1206,7 +1206,7 @@ label = "below 30% HP -> howl" kind = "flag_is" at = [ 680.0, - 2800.0, + 2840.0, ] flag = "in_combat" next = [ @@ -1217,7 +1217,7 @@ next = [ kind = "monster_hp" at = [ 960.0, - 2800.0, + 2840.0, ] hi = 30 next = [ @@ -1228,7 +1228,7 @@ next = [ kind = "limit" at = [ 1240.0, - 2800.0, + 2840.0, ] times = 1 play = [ @@ -1239,7 +1239,7 @@ play = [ kind = "on_signal" at = [ 400.0, - 3000.0, + 3040.0, ] name = "roar" play = [ From a962cd974f3b308995bbc76583c3c138b17692e5 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:16:50 +0200 Subject: [PATCH 20/27] docs(mhfu-port): the migrate grid's comment gives the tallest block, 224 Co-Authored-By: Claude Sonnet 5.5 --- packages/mhfu-port/src/mhfu_port/behaviour.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/mhfu-port/src/mhfu_port/behaviour.py b/packages/mhfu-port/src/mhfu_port/behaviour.py index e0a8246..a5b178d 100644 --- a/packages/mhfu-port/src/mhfu_port/behaviour.py +++ b/packages/mhfu-port/src/mhfu_port/behaviour.py @@ -850,8 +850,9 @@ 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"}) From 32c6371cb493e713d6d9d7116358c567b9cd50b6 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:28:23 +0200 Subject: [PATCH 21/27] refactor(studio): the effect violet is an Ink, in the theme The Behaviour panel held it as a literal RGBA; only ui/theme.py holds a colour. Co-Authored-By: Claude Sonnet 5.5 --- .../studio/src/mhfu_studio/monster/panels/behaviour.py | 10 ++-------- apps/studio/src/mhfu_studio/shell/overlay.py | 2 ++ apps/studio/src/mhfu_studio/ui/theme.py | 1 + apps/studio/tests/monster/ui/test_behaviour.py | 7 ++++--- 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/apps/studio/src/mhfu_studio/monster/panels/behaviour.py b/apps/studio/src/mhfu_studio/monster/panels/behaviour.py index c407a21..a94d450 100644 --- a/apps/studio/src/mhfu_studio/monster/panels/behaviour.py +++ b/apps/studio/src/mhfu_studio/monster/panels/behaviour.py @@ -36,10 +36,9 @@ "state": Ink.AXIS_Y, "condition": Ink.AXIS_Z, "modifier": Ink.WARNING, + "effect": Ink.EFFECT, "move": Ink.SELECTION, } -#: the effect role's violet, for a dark theme and a light one: no `Ink` is one -EFFECT: dict[bool, Color] = {True: (0.82, 0.44, 0.91, 1.0), False: (0.56, 0.14, 0.67, 1.0)} MOVE_ROLE = "move" WARN = chr(0x26A0) HINT = ( @@ -48,11 +47,6 @@ ) -def roles() -> dict[str, Color]: - """`ROLES` and the effect's violet, for the current theme.""" - return {**ROLES, "effect": EFFECT[theme.current().dark]} - - class BehaviourPanel(kit.Panel): def __init__(self, ws: MonsterWorkspace, studio: Studio) -> None: super().__init__(scroll=False) @@ -192,7 +186,7 @@ def sync(self) -> None: return if theme.current() is not self._theme: self._theme = theme.current() - self.view.set_roles(roles()) + self.view.set_roles(ROLES) self.view.set_canvas(theme.current().view) if self._drawn is not m: self._drawn = m 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 da5f630..2a24532 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/ui/test_behaviour.py b/apps/studio/tests/monster/ui/test_behaviour.py index e505bcd..a56070f 100644 --- a/apps/studio/tests/monster/ui/test_behaviour.py +++ b/apps/studio/tests/monster/ui/test_behaviour.py @@ -16,10 +16,11 @@ 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 EFFECT, WARN, BehaviourPanel, roles +from mhfu_studio.monster.panels.behaviour import ROLES, WARN, BehaviourPanel from mhfu_studio.monster.panels.node_graph import 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 @@ -815,7 +816,7 @@ def add(m: Any) -> None: 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()} + 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"} @@ -837,7 +838,7 @@ def test_an_effect_block_wears_the_effect_colour( for dark in (True, False): theme.apply(theme.theme("Ember", dark)) panel.sync() - edge = theme.color(EFFECT[dark]) + edge = theme.color(Ink.EFFECT) have = panel.view._shown["b4"].view.border_color assert tuple(have[:3]) == (edge.red(), edge.green(), edge.blue()) finally: From 19c1aabd58009b0651933adcfc8b55ba535db219 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:28:32 +0200 Subject: [PATCH 22/27] feat(mhfu): fire a board signal from a debugger EM_BLOCK in addresses.toml places the board after the config block, so the brain's reader and writers no longer hard-code sizeof(tail_t). fire_signal sets one byte of the board's signals, as Lua's mhfu.em_signal does, and the rules on it fire in the monster's next AI frame. Co-Authored-By: Claude Sonnet 5.5 --- packages/mhfu/src/mhfu/addresses.toml | 7 +++++++ packages/mhfu/src/mhfu/live/brain.py | 23 ++++++++++++++++++++--- packages/mhfu/tests/live/test_brain.py | 19 +++++++++++++++++++ 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/packages/mhfu/src/mhfu/addresses.toml b/packages/mhfu/src/mhfu/addresses.toml index 01ccc3f..4e0e4b2 100644 --- a/packages/mhfu/src/mhfu/addresses.toml +++ b/packages/mhfu/src/mhfu/addresses.toml @@ -1008,6 +1008,13 @@ BROKEN = { offset = 0x30, type = "u8", doc = "bit k: part k broke on this monste 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"] diff --git a/packages/mhfu/src/mhfu/live/brain.py b/packages/mhfu/src/mhfu/live/brain.py index 8530e3b..56aed88 100644 --- a/packages/mhfu/src/mhfu/live/brain.py +++ b/packages/mhfu/src/mhfu/live/brain.py @@ -14,6 +14,9 @@ A slot's index is the rule's place in `mhfu_port.behaviour.compile(manifest)`. Counters restart with each quest and whenever the port's moves module is re-run. A memory request stops the emulator for a few ms, so read about once a second, never faster. + +The board is plain memory the brain takes from in its next AI frame, so a debugger can do what Lua +does: `fire_signal` is `mhfu.em_signal`. """ from __future__ import annotations @@ -98,9 +101,10 @@ def _live(mem: Memory, entity: int) -> bool: def _board() -> int | None: """Where the board's ENTITY word is 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: @@ -156,3 +160,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` (a name's place in `mhfu_port.behaviour.signals`): the rules on + it fire in the monster's next AI frame. One byte, which the brain clears when it takes it.""" + 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) From d38f024acafe3a1858e83c8f8f44bdb9df4f45a4 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:48:52 +0200 Subject: [PATCH 23/27] feat(studio): a slot's signal is the rule's board index, and a rule that plays nothing matches a slot that plays nothing Co-Authored-By: Claude Sonnet 5.5 --- .../src/mhfu_studio/monster/rule_game.py | 4 +- apps/studio/tests/monster/test_rule_game.py | 42 ++++++++++++++++++- apps/studio/tests/monster/ui/test_marks.py | 12 +++++- 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/apps/studio/src/mhfu_studio/monster/rule_game.py b/apps/studio/src/mhfu_studio/monster/rule_game.py index 9b32386..f79d9e4 100644 --- a/apps/studio/src/mhfu_studio/monster/rule_game.py +++ b/apps/studio/src/mhfu_studio/monster/rule_game.py @@ -34,7 +34,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 +75,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 diff --git a/apps/studio/tests/monster/test_rule_game.py b/apps/studio/tests/monster/test_rule_game.py index a4b92c0..1b4296e 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 diff --git a/apps/studio/tests/monster/ui/test_marks.py b/apps/studio/tests/monster/ui/test_marks.py index ba5e302..be7cf77 100644 --- a/apps/studio/tests/monster/ui/test_marks.py +++ b/apps/studio/tests/monster/ui/test_marks.py @@ -63,7 +63,17 @@ def __call__( ) -> rg.Read: self.calls.append(cfg) slots = 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(self.rules, self.fired, strict=True)) ) return rg.Read(CFG, Brain(CFG, self.ticks, slots)) From 0b25e2c8204e6bea23cfc202127b4d0b4c632164 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:52:45 +0200 Subject: [PATCH 24/27] feat(studio): Fire raises a signal trigger in the running game, at the brain Watch the game found Co-Authored-By: Claude Sonnet 5.5 --- apps/studio/README.md | 6 +- .../mhfu_studio/monster/panels/behaviour.py | 25 +- .../src/mhfu_studio/monster/rule_game.py | 36 ++- .../src/mhfu_studio/monster/workspace.py | 22 ++ apps/studio/tests/monster/test_rule_game.py | 20 ++ apps/studio/tests/monster/ui/conftest.py | 49 +++- apps/studio/tests/monster/ui/test_fire.py | 252 ++++++++++++++++++ apps/studio/tests/monster/ui/test_marks.py | 66 ++--- 8 files changed, 422 insertions(+), 54 deletions(-) create mode 100644 apps/studio/tests/monster/ui/test_fire.py diff --git a/apps/studio/README.md b/apps/studio/README.md index 9c156d2..c569909 100644 --- a/apps/studio/README.md +++ b/apps/studio/README.md @@ -98,8 +98,10 @@ block's `in` or to a move's `play`; a move's `while playing` starts a rule, and 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. +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 diff --git a/apps/studio/src/mhfu_studio/monster/panels/behaviour.py b/apps/studio/src/mhfu_studio/monster/panels/behaviour.py index 18ede10..bb116e8 100644 --- a/apps/studio/src/mhfu_studio/monster/panels/behaviour.py +++ b/apps/studio/src/mhfu_studio/monster/panels/behaviour.py @@ -63,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" @@ -112,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" @@ -182,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, @@ -516,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.setVisible(w.on) self.live.setText(w.status) diff --git a/apps/studio/src/mhfu_studio/monster/rule_game.py b/apps/studio/src/mhfu_studio/monster/rule_game.py index f79d9e4..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 @@ -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/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/tests/monster/test_rule_game.py b/apps/studio/tests/monster/test_rule_game.py index 1b4296e..b9463e0 100644 --- a/apps/studio/tests/monster/test_rule_game.py +++ b/apps/studio/tests/monster/test_rule_game.py @@ -331,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/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_fire.py b/apps/studio/tests/monster/ui/test_fire.py new file mode 100644 index 0000000..e2d3dae --- /dev/null +++ b/apps/studio/tests/monster/ui/test_fire.py @@ -0,0 +1,252 @@ +# 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) + 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 be7cf77..b38cadf 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,37 +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 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(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 @@ -89,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 @@ -103,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) @@ -147,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.isHidden() and not panel.ticker.isActive() assert all("×" not in t for t in titles(panel)) assert panel.outline.width() == triggers.WIDTH and game.calls == [] @@ -169,7 +137,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() @@ -182,7 +150,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): @@ -195,7 +163,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) @@ -208,7 +176,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) @@ -224,7 +192,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 @@ -240,7 +208,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) @@ -258,7 +226,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 == [] @@ -274,7 +242,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) @@ -285,7 +253,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) @@ -304,7 +272,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) @@ -314,7 +282,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() From 20d5343039ce7b34916e5e780b81bfb35b9507bc Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:54:38 +0200 Subject: [PATCH 25/27] fix(studio): the header's buttons keep their size while the watch status is empty Co-Authored-By: Claude Sonnet 5.5 --- apps/studio/src/mhfu_studio/monster/panels/behaviour.py | 1 - apps/studio/tests/monster/ui/test_marks.py | 7 ++++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/studio/src/mhfu_studio/monster/panels/behaviour.py b/apps/studio/src/mhfu_studio/monster/panels/behaviour.py index bb116e8..ba7b802 100644 --- a/apps/studio/src/mhfu_studio/monster/panels/behaviour.py +++ b/apps/studio/src/mhfu_studio/monster/panels/behaviour.py @@ -542,7 +542,6 @@ def _status(self) -> None: FIRE_TIP.format(triggers.title(t)) if why is None else f"Fire: {why}" ) w = self.ws.watch - self.live.setVisible(w.on) self.live.setText(w.status) self.live.setToolTip(w.status) self.live.set_level(None if w.fires is not None else "warning") diff --git a/apps/studio/tests/monster/ui/test_marks.py b/apps/studio/tests/monster/ui/test_marks.py index b38cadf..deed01b 100644 --- a/apps/studio/tests/monster/ui/test_marks.py +++ b/apps/studio/tests/monster/ui/test_marks.py @@ -116,7 +116,8 @@ def scope(qtbot: Any, p: BehaviourPanel, key: str) -> None: def test_nothing_is_marked_until_the_toggle_is_on(panel: BehaviourPanel, game: FakeGame) -> None: - assert not panel.watch.isChecked() and panel.live.isHidden() and not panel.ticker.isActive() + assert not panel.watch.isChecked() and panel.live.text() == "" + assert not panel.ticker.isActive() assert all("×" not in t for t in titles(panel)) assert panel.outline.width() == triggers.WIDTH and game.calls == [] assert panel.watch.toolTip() and kit.missing_tips(panel) == [] @@ -127,13 +128,13 @@ def test_the_toggle_starts_a_poll_once_a_second_while_the_tab_is_shown( ) -> None: QTest.mouseClick(panel.watch, LEFT, NONE) assert panel.ws.watch.on and panel.ticker.isActive() and panel.ticker.interval() == 1000 - assert not panel.live.isHidden() + assert panel.live.text() == rg.READING panel.hide() assert not panel.ticker.isActive() panel.show() assert panel.ticker.isActive() QTest.mouseClick(panel.watch, LEFT, NONE) - assert not panel.ws.watch.on and not panel.ticker.isActive() and panel.live.isHidden() + assert not panel.ws.watch.on and not panel.ticker.isActive() and panel.live.text() == "" def test_a_tab_hidden_polls_nothing( From 0d8c6c62cbe5ddf5a13ac54e4b4f5bb43a75d8b0 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:55:19 +0200 Subject: [PATCH 26/27] test(studio): the header's buttons keep their size while not watching Co-Authored-By: Claude Sonnet 5.5 --- apps/studio/tests/monster/ui/test_marks.py | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/studio/tests/monster/ui/test_marks.py b/apps/studio/tests/monster/ui/test_marks.py index deed01b..b694276 100644 --- a/apps/studio/tests/monster/ui/test_marks.py +++ b/apps/studio/tests/monster/ui/test_marks.py @@ -120,6 +120,7 @@ def test_nothing_is_marked_until_the_toggle_is_on(panel: BehaviourPanel, game: F assert not panel.ticker.isActive() assert all("×" not in t for t in titles(panel)) assert panel.outline.width() == triggers.WIDTH and game.calls == [] + assert panel.fit.width() <= panel.fit.sizeHint().width() + 4, "the buttons keep their size" assert panel.watch.toolTip() and kit.missing_tips(panel) == [] From 778bab5b5a45d738ecfb9de899557f01bca8c078 Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:23:53 +0200 Subject: [PATCH 27/27] test(studio): the Fire test counts the brain's reads after the last one has landed Co-Authored-By: Claude Sonnet 5.5 --- apps/studio/tests/monster/ui/test_fire.py | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/studio/tests/monster/ui/test_fire.py b/apps/studio/tests/monster/ui/test_fire.py index e2d3dae..36664db 100644 --- a/apps/studio/tests/monster/ui/test_fire.py +++ b/apps/studio/tests/monster/ui/test_fire.py @@ -191,6 +191,7 @@ def test_fire_writes_the_triggers_board_signal_where_watch_found_the_brain( ) -> 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())