From a33021f6e8b0b580645e683c0246b8aa9b5803ed Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:35:07 +0200 Subject: [PATCH 1/2] mhfu-port: a derived move follows its handler's hand-over A pair whose handler ends into one other pair hands over to its move, as MHP3rd does, and moves equal but for their label are one (own.share). The Zinogre's own moves go from 89 to 93 of 96; the hand-over to pair 8,0 is not followed. Co-Authored-By: Claude Sonnet 5.5 --- docs/formats/p3rd_ai_script.md | 16 +- packages/mhfu-port/src/mhfu_port/brain/own.py | 192 +++++++++++++- packages/mhfu-port/tests/test_own.py | 238 +++++++++++++++++- packages/mhfu-port/tests/test_spare.py | 31 +++ .../mhfu-port/tests/test_zinogre_brain.py | 80 +++++- ports/README.md | 10 +- 6 files changed, 534 insertions(+), 33 deletions(-) diff --git a/docs/formats/p3rd_ai_script.md b/docs/formats/p3rd_ai_script.md index b6b8b6a3..818627b6 100644 --- a/docs/formats/p3rd_ai_script.md +++ b/docs/formats/p3rd_ai_script.md @@ -168,6 +168,19 @@ clip that hands to the next by `after`: frames, so they hold at any blend and cursor rate (a blend holds the cursor 2 to 5 AI frames, enraged it runs 2.8 a frame). Only a step that ended on no frame of its clip (a pair, a loop, a test the cursor never changed) gets the `length`, in AI frames, the donor played it for. +- **Hand-over.** A handler that ends by entering another pair (not a rest pair) hands over to it, + as MHP3rd does: the chain goes on into that pair's moves, `p3_7_12` into `p3_7_156`. The pair + plays the `[brain.pairs]` move that names it (`"7,5" = "p3_7_4"`), else the one `own_name` + gives it, derived the same way and so following its own hand-over; an authored move of that + name is used as it is. Each pair is derived once, and a hand-over that would close a ring is cut + where it closes. A hand-over to a pair the manifest plays as a host pair or a list, or the port + cannot play (no clip, a clip the donor lacks, a walk that gave up), is not followed: + `P3_OWN_NEXT`. +- **Sharing.** After every pair is derived, moves equal in every field but `label` become one + (their `after` compared as the merge renames it, so equal tails make their predecessors equal): + `p3_7_237`, which the donor dispatches as `(7, 0)`, is `p3_7_0`. The one kept is the one + `[brain.pairs]`, the behaviour graph, an effect or an authored move's `after` names, else the + first derived; two named moves stay apart. - **Attacks.** A record's START and LEN against the cursor at the spawn give the frames; a node that outlives a clip change is split per clip. The record's volume set is matched to the host record whose volumes overlap it most on the port's rig (the same kind first, then power); below @@ -183,7 +196,8 @@ clip that hands to the next by `after`: | `P3_OWN_ATTACK` | warning | a donor record with no host record and no spare, or more attacks on a clip than the move player holds: left out | | `P3_OWN_SPARE` | info | a donor record with no host record took a spare one: its id, set, volumes, power, kind, active frames | | `P3_OWN_PARTIAL` | warning | the walk gave up, or a clip it could not resolve | -| `P3_OWN_BRANCH`, `P3_OWN_MOTION`, `P3_OWN_NEXT` | info | a branch taken one way; code movement; a pair the handler hands over to | +| `P3_OWN_BRANCH`, `P3_OWN_MOTION` | info | a branch taken one way; code movement | +| `P3_OWN_NEXT` | info | a hand-over not followed, and why | | `P3_OWN_SPLIT`, `P3_OWN_GROUP`, `P3_OWN_ACTIVE` | info | a node split per clip; records that land one hit between them in the donor; a window longer than the host record's life | ### Spare attack records diff --git a/packages/mhfu-port/src/mhfu_port/brain/own.py b/packages/mhfu-port/src/mhfu_port/brain/own.py index 64e4e483..f9d7c454 100644 --- a/packages/mhfu-port/src/mhfu_port/brain/own.py +++ b/packages/mhfu-port/src/mhfu_port/brain/own.py @@ -13,18 +13,20 @@ instead; nothing derived is stored. A pair with several clips is a chain, `p3_7_14`, `p3_7_14_2`, ..., each step handing to the next -through `after`; a step carries the donor's attacks that are active on its clip. A donor record -becomes the host record whose volumes overlap it most on the port's rig (`[brain.attacks]` names -one instead), and the node plays with the host record's power and life. A record nothing matches -gets a spare host record (`brain.spare`), added to the returned manifest as `[[attack]]` and -`[[hitbox]]`; the studio's hit tables write it into the game. +through `after`; a step carries the donor's attacks that are active on its clip. The last step +hands to the pair the handler hands over to (`P3_OWN_NEXT`: where it cannot); equal tails are one +move (`share`). A donor record becomes the host record whose volumes overlap it most on the port's +rig (`[brain.attacks]` names one instead), and the node plays with the host record's power and +life. A record nothing matches gets a spare host record (`brain.spare`), added to the returned +manifest as `[[attack]]` and `[[hitbox]]`; the studio's hit tables write it into the game. """ from __future__ import annotations import copy +import dataclasses import statistics -from collections.abc import Callable, Sequence +from collections.abc import Callable, Collection, Iterable, Sequence from typing import NamedTuple import numpy as np @@ -33,7 +35,7 @@ from mhfu.files import em_overlay from numpy.typing import NDArray -from .. import build, fk, layout, moves, p3hits, p3moveset +from .. import build, fk, layout, moves, p3hits, p3moveset, sequence from ..data import Data from ..manifest import ( MOVE_ATTACKS, @@ -46,6 +48,7 @@ Move, Steer, own_name, + pair_of, ) from ..p3hits import Plan, Step, Walk from .spare import HOST_NO_DAMAGE, NO_DAMAGE, POWER_SCALE, Spare, Spares @@ -61,6 +64,8 @@ SAMPLES = 100_000 """Points the overlap of two volume sets is sampled at.""" MAX_RATE = 0xFFFF +REST = frozenset(p for ps in p3moveset.END.values() for p in ps) +"""The pairs a move ends in, to rest: no hand-over.""" Report = Callable[[Finding], None] @@ -191,6 +196,17 @@ def overlap(a: Sequence[Prim], b: Sequence[Prim]) -> float: # --- the moves --- +class Hand(NamedTuple): + """Where a complete handler hands over to, outside the rest pairs.""" + + name: str | None = None + """The own move that pair plays; None: not followed.""" + make: Pair | None = None + """The donor pair whose derivation makes `name`; None for a move the manifest authors.""" + text: str = "" + """The finding when it is not followed.""" + + class Derivation: """One pair's plan turned into moves and findings.""" @@ -204,10 +220,15 @@ def __init__( ) -> None: self.m, self.walk, self.matcher, self.say = m, walk, matcher, say self.pairs = list(pairs) - """Every pair being derived: the spare records' power scale reads all their matches.""" + """The pairs being derived; the pairs they hand over to are derived too, and with them + feed the spare records' power scale, which reads all their matches.""" self.pair: Pair = (0, 0) self.item = "" """The move findings are about.""" + self.made: list[str] = [] + """The moves derived, in order.""" + self._authored = frozenset(m.moves) + self._done: set[Pair] = set() self._plans: dict[Pair, Plan] = {} self._spares: Spares | None = None self._near: dict[int, tuple[str, str]] = {} @@ -225,6 +246,10 @@ def _plan(self, pair: Pair) -> Plan | None: return self._plans[pair] def run(self, pair: Pair) -> None: + """Derive `pair`'s moves, and those of the pairs its handler hands over to; once each.""" + if pair in self._done: + return + self._done.add(pair) head = own_name(pair) self.pair, self.item = pair, head plan = self._plan(pair) @@ -236,12 +261,96 @@ def run(self, pair: Pair) -> None: self.find("error", "P3_OWN_NOCLIPS", "its handler plays no clip") return names = [head, *(f"{head}_{k}" for k in range(2, len(plan.steps) + 1))] + made = [] for k, step in enumerate(plan.steps): if names[k] in self.m.moves: continue self.item = names[k] after = names[k + 1] if k + 1 < len(names) else None self.m.moves[names[k]] = self._move(step, after) + made.append(names[k]) + self.made += made + if names[-1] in made: + self._follow(pair, plan, names[-1]) + + def _follow(self, pair: Pair, plan: Plan, last: str) -> None: + """Hand `last` over to the move of the pair the handler ends into. Linked before that + pair is derived, so a pair that leads back sees the ring and is the one cut.""" + hand = self._hand(plan) + if hand is None: + return + name, text = hand.name, hand.text + if name is not None: + target = self.m.moves.get(name) + if target is not None and not target.own: + text = f"{self._lead(plan)}: moves.{name} is no own move" + elif last in sequence.chain(self.m, name): + text = f"{self._lead(plan)}: following it would close a ring" + if name is None or text: + self.pair, self.item = pair, own_name(pair) + self.find("info", "P3_OWN_NEXT", text) + return + self.m.moves[last].after = name + if hand.make is not None: + self.run(hand.make) + + @staticmethod + def _lead(plan: Plan) -> str: + over = [f"{a},{b}" for a, b in plan.leaves if (a, b) not in REST] + return f"it hands over to pair{'s' if len(over) > 1 else ''} {', '.join(over)}" + + def _hand(self, plan: Plan) -> Hand | None: + """What a complete plan hands over to; None when it ends at rest or did not end.""" + over = [p for p in plan.leaves if p not in REST] + if not plan.complete or not over: + return None + if len(plan.leaves) > 1: + ends = ", ".join(f"{a},{b}" for a, b in plan.leaves) + return Hand(text=f"it hands over to one of pairs {ends}, as the run goes: not followed") + lead = self._lead(plan) + (to,) = over + brain = self.m.brain + plays = [] if brain is None else brain.targets(to) + if plays and not (len(plays) == 1 and isinstance(plays[0], str)): + what = f"host pair {plays[0][0]},{plays[0][1]}" if len(plays) == 1 else "several moves" + return Hand(text=f"{lead}, which [brain.pairs] plays as {what}: not followed") + name = plays[0] if plays else own_name(to) + assert isinstance(name, str) + if name in self._authored: + return Hand(name) + make = brain.derived().get(name) if brain is not None else None + make = make or (to if name == own_name(to) else None) + if make is None: + return Hand(text=f"{lead}, which the port does not play: no pair derives {name}") + if why := self._unplayable(make): + return Hand(text=f"{lead}, which the port does not play: {why}") + return Hand(name, make) + + def _unplayable(self, pair: Pair) -> str: + """Why `pair` yields no moves a hand-over can go on into; empty when it does.""" + plan = self._plan(pair) + if plan is None: + return "the donor has no such pair" + if not plan.steps: + return "its handler plays no clip" + if lacking := [s.clip for s in plan.steps if s.clip not in self.walk.lengths]: + return f"it plays clip {lacking[0]}, which the donor lacks" + if plan.complete: + return "" + why = next((n.text for n in plan.notes if n.code == "unresolved"), "") + return "the walk gave up before its handler ended" + (f" ({why})" if why else "") + + def _reach(self, start: Sequence[Pair]) -> list[Pair]: + """`start` and the pairs derived to follow them, in order.""" + out = list(dict.fromkeys(start)) + todo = list(out) + while todo: + plan = self._plan(todo.pop(0)) + hand = None if plan is None else self._hand(plan) + if hand is not None and hand.make is not None and hand.make not in out: + out.append(hand.make) + todo.append(hand.make) + return out def finish(self) -> None: """One `P3_OWN_SPARE` per spare record taken, once every pair has had its say: the @@ -278,10 +387,6 @@ def _notes(self, plan: Plan) -> None: ) if not plan.complete and plan.steps: self.find("warning", "P3_OWN_PARTIAL", "the walk gave up before the handler ended") - rest = {p for ps in p3moveset.END.values() for p in ps} - if plan.complete and (over := [p for p in plan.leaves if p not in rest]): - to = ", ".join(f"{a},{b}" for a, b in over) - self.find("info", "P3_OWN_NEXT", f"it hands over to pair {to}, which the script enters") def _clip(self, cid: int) -> str: held = layout.holder(self.m, cid) @@ -385,7 +490,7 @@ def _scale(self) -> float: """Host power over donor power for a spare record: the median over the records of this port's pairs that matched (the ones `[brain.attacks]` chose left out).""" mt = self.matcher() - plans = (self._plan(p) for p in dict.fromkeys([*self.pairs, self.pair])) + plans = (self._plan(p) for p in self._reach([*self.pairs, self.pair])) used = {h.record for plan in plans if plan for s in plan.steps for h in s.hits} ratios = [] for r in sorted(used): @@ -422,6 +527,63 @@ def pairs(b: Brain) -> list[Pair]: return sorted(b.derived().values()) +def anchored(m: Manifest, derived: Collection[str]) -> set[str]: + """The derived moves something outside the derived set names: `[brain.pairs]`, the behaviour + graph, an effect, or the `after` of a move that is not derived.""" + named: set[str] = set() + if m.brain is not None: + for key in m.brain.pairs: + named.update(t for t in m.brain.targets(pair_of(key)) if isinstance(t, str)) + for blk in m.behaviour.blocks.values(): + named.update(blk.play) + named.update(m.behaviour.moves) + named.update(e.move for e in m.effects) + named.update(mv.after for n, mv in m.moves.items() if mv.after and n not in derived) + return named & set(derived) + + +def share(m: Manifest, derived: Iterable[str]) -> dict[str, str]: + """Merge the derived moves equal in every field but `label` (their `after` compared as this + pass renames it) until none are: equal tails make their predecessors equal. The one kept is an + anchored move, else the first derived; two anchored moves stay apart. Every `after` that + named a dropped move names the kept one. Returns each dropped move's kept one.""" + names = list(dict.fromkeys(derived)) + keep = anchored(m, names) + gone: dict[str, str] = {} + + def now(name: str) -> str: + while name in gone: + name = gone[name] + return name + + while True: + groups: list[tuple[Move, list[str]]] = [] + for n in (n for n in names if n not in gone): + mv = m.moves[n] + key = dataclasses.replace(mv, label="", after=mv.after and now(mv.after)) + for first, same in groups: + if first == key: + same.append(n) + break + else: + groups.append((key, [n])) + merged = False + for _, same in groups: + kept = next((n for n in same if n in keep), same[0]) + for n in same: + if n != kept and n not in keep: + gone[n] = kept + merged = True + if not merged: + break + for mv in m.moves.values(): + if mv.after in gone: + mv.after = now(mv.after) + for n in gone: + del m.moves[n] + return {n: now(n) for n in gone} + + def expand(m: Manifest, data: Data) -> tuple[Manifest, list[Finding]]: """`m` with the own moves its `[brain.pairs]` derive and the clips they play; `m` itself where it derives none. A move already in `m.moves` under a derived name is kept.""" @@ -443,6 +605,10 @@ def matcher() -> Matcher: for pair in wanted: derive.run(pair) derive.finish() + dropped = share(out, derive.made) + found[:] = dict.fromkeys( + dataclasses.replace(f, item=dropped.get(f.item or "", f.item)) for f in found + ) species = m.port.host_species if ( any(mv.own and mv.carrier is None for mv in out.moves.values()) diff --git a/packages/mhfu-port/tests/test_own.py b/packages/mhfu-port/tests/test_own.py index 7da448d7..c01d192d 100644 --- a/packages/mhfu-port/tests/test_own.py +++ b/packages/mhfu-port/tests/test_own.py @@ -1,5 +1,6 @@ # SPDX-License-Identifier: GPL-3.0-or-later # SPDX-FileCopyrightText: 2026 sp00ktober +from collections.abc import Sequence from pathlib import Path from types import SimpleNamespace @@ -8,11 +9,13 @@ from mhfu import hitbox from mhfu.files import em_overlay from mhfu_port import manifest, moves, p3hits, sequence +from mhfu_port.behaviour import Block, MoveNode from mhfu_port.brain import own, spare -from mhfu_port.manifest import AttackWindow, Steer +from mhfu_port.manifest import AttackWindow, Effect, Steer from mhfu_port.p3hits import Hit, Length, Note, Plan, Step, Turn PORTS = Path(__file__).parents[3] / "ports" +Pair = tuple[int, int] TEXT = """ schema = 2 [port] @@ -30,7 +33,13 @@ [brain.pairs] "7,13" = "own" """ -LENGTHS = {61: Length(382.0), 59: Length(144.0, True), 60: Length(252.0)} +LENGTHS = { + 61: Length(382.0), + 59: Length(144.0, True), + 60: Length(252.0), + 62: Length(100.0), + 63: Length(100.0), +} HOSTS = {21: 7, 28: 8, 19: 15, 20: 7} @@ -49,17 +58,19 @@ def test_overlap(): class Fake: """A walk that plans what it is given, and a matcher with fixed answers.""" - def __init__(self, plan: Plan, hosts=HOSTS, active=10) -> None: + def __init__(self, plan: Plan | Sequence[Plan], hosts=HOSTS, active=10) -> None: + plans = [plan] if isinstance(plan, Plan) else list(plan) self.lengths = LENGTHS - self.ms = SimpleNamespace(pairs={plan.pair: object()}) - self.hosts, self.active, self._plan = hosts, active, plan + self.plans = {p.pair: p for p in plans} + self.ms = SimpleNamespace(pairs={p: p for p in self.plans}) + self.hosts, self.active = hosts, active self.hits = SimpleNamespace(records=[]) self.table = None # no spare records to hand out self.binding = SimpleNamespace(joint_of={}) self.bind = np.zeros((0, 3)) - def plan(self, pair: object) -> Plan: - return self._plan + def plan(self, pair: Pair) -> Plan: + return self.plans[pair] def match(self, record: int) -> own.Match: host = self.hosts.get(record) @@ -218,6 +229,218 @@ def test_pairs_sees_own_in_a_list(): assert m.brain is not None and own.pairs(m.brain) == [(7, 13), (7, 14)] +# --- hand-overs --- + + +def hand(pair: Pair, *steps: Step, to: Pair = (0, 0), complete: bool = True) -> Plan: + return Plan(pair, steps, (to,), complete) + + +def chained(plans: Sequence[Plan], pairs: str = "", heads=((7, 13),), tables: str = ""): + """The heads derived from a fake walk over `plans`; `pairs` and `tables` are added to TEXT.""" + m = manifest.loads(TEXT + pairs + tables) + found: list = [] + fake = Fake(plans) + d = own.Derivation(m, fake, lambda: fake, found.append, heads) # type: ignore[arg-type] + for h in heads: + d.run(h) + d.finish() + return m, found, d + + +def next_findings(found) -> list: + return [f for f in found if f.code == "P3_OWN_NEXT"] + + +def test_a_handler_hands_over_to_its_pairs_move(): + m, found, _ = chained([hand((7, 13), step(61), to=(7, 14)), hand((7, 14), step(59))]) + assert sequence.chain(m, "p3_7_13") == ["p3_7_13", "p3_7_14"] + assert m.moves["p3_7_14"].clip == "p3_59" and m.moves["p3_7_14"].after is None + assert not next_findings(found) + manifest.check(m) + + +def test_a_hand_over_goes_on_through_a_third_pair(): + plans = [ + hand((7, 13), step(61), to=(7, 14)), + hand((7, 14), step(62), step(63), to=(7, 15)), + hand((7, 15), step(59)), + ] + m, found, _ = chained(plans) + chain = sequence.chain(m, "p3_7_13") + assert chain == ["p3_7_13", "p3_7_14", "p3_7_14_2", "p3_7_15"] + assert [m.moves[n].clip for n in chain] == ["charge", "p3_62", "p3_63", "p3_59"] + assert not next_findings(found) + + +def test_a_follow_up_the_manifest_names_is_that_move(): + plans = [hand((7, 13), step(61), to=(7, 14)), hand((7, 20), step(62))] + m, found, d = chained(plans, '"7,14" = "p3_7_20"\n"7,20" = "own"\n', [(7, 13), (7, 20)]) + assert sequence.chain(m, "p3_7_13") == ["p3_7_13", "p3_7_20"] and "p3_7_14" not in m.moves + assert d.made == ["p3_7_13", "p3_7_20"] and not next_findings(found) + m, _, _ = chained(plans[:1], '"7,14" = "dash"\n', tables='[moves.dash]\nclip = "charge"\n') + assert m.moves["p3_7_13"].after == "dash" and "p3_7_14" not in m.moves + + +def test_a_follow_up_that_is_mapped_to_own_or_authored_is_derived_or_used(): + plans = [hand((7, 13), step(61), to=(7, 14)), hand((7, 14), step(59), to=(7, 15))] + plans += [hand((7, 15), step(62))] + m, _, _ = chained(plans, '"7,14" = "own"\n') + assert sequence.chain(m, "p3_7_13") == ["p3_7_13", "p3_7_14", "p3_7_15"] + m, found, _ = chained(plans, '"7,14" = "own"\n', tables="[moves.p3_7_14]\nclip = 'charge'\n") + assert sequence.chain(m, "p3_7_13") == ["p3_7_13", "p3_7_14"], "the authored move is used" + assert "p3_7_15" not in m.moves and not next_findings(found) + + +@pytest.mark.parametrize( + ("value", "what"), [("[1, 2]", "host pair 1,2"), ('[[1, 2], "own"]', "several moves")] +) +def test_a_hand_over_the_manifest_plays_as_a_host_pair_is_not_followed(value, what): + plans = [hand((7, 13), step(61), to=(7, 14)), hand((7, 14), step(59))] + m, found, _ = chained(plans, f'"7,14" = {value}\n') + assert m.moves["p3_7_13"].after is None and "p3_7_14" not in m.moves + (f,) = next_findings(found) + assert f.level == "info" and f.item == "p3_7_13" and what in f.text and "7,14" in f.text + + +@pytest.mark.parametrize( + ("follow", "why"), + [ + (hand((7, 14), step(59), complete=False), "the walk gave up before its handler ended"), + (hand((7, 14)), "its handler plays no clip"), + (hand((7, 14), step(999)), "it plays clip 999, which the donor lacks"), + (None, "the donor has no such pair"), + ], +) +def test_a_follow_up_the_port_cannot_play_is_not_followed(follow, why): + m, found, _ = chained([hand((7, 13), step(61), to=(7, 14)), *([follow] if follow else [])]) + assert m.moves["p3_7_13"].after is None and "p3_7_14" not in m.moves + (f,) = next_findings(found) + assert f"pair 7,14, which the port does not play: {why}" in f.text and f.item == "p3_7_13" + assert not [f for f in found if f.level == "error"], "the follow-up is not an error" + + +def test_a_follow_up_with_a_move_that_is_no_own_move_is_not_followed(): + plans = [hand((7, 13), step(61), to=(7, 14))] + tables = "[moves.p3_7_14]\nmain = 1\nsub = 2\nclip = 'charge'\n" + m, found, _ = chained(plans, '"7,14" = "own"\n', tables=tables) + assert m.moves["p3_7_13"].after is None + assert "moves.p3_7_14 is no own move" in next_findings(found)[0].text + + +def test_a_handler_that_ends_in_several_pairs_is_not_followed(): + p = Plan((7, 13), (step(61),), ((7, 14), (7, 15)), True) + m, found, _ = chained([p]) + assert m.moves["p3_7_13"].after is None + assert "one of pairs 7,14, 7,15" in next_findings(found)[0].text + + +def test_a_ring_is_cut_where_it_closes(): + plans = [hand((7, 13), step(61), to=(7, 14)), hand((7, 14), step(59), to=(7, 13))] + m, found, _ = chained(plans) + assert sequence.chain(m, "p3_7_13") == ["p3_7_13", "p3_7_14"] + assert m.moves["p3_7_14"].after is None + (f,) = next_findings(found) + assert f.item == "p3_7_14" and "pair 7,13: following it would close a ring" in f.text + m, found, _ = chained([hand((7, 13), step(61), to=(7, 13))]) + assert m.moves["p3_7_13"].after is None and "close a ring" in next_findings(found)[0].text + manifest.check(m) + + +def test_a_ring_through_three_pairs_is_cut_at_the_last_link(): + plans = [ + hand((7, 13), step(61), to=(7, 14)), + hand((7, 14), step(59), to=(7, 15)), + hand((7, 15), step(62), to=(7, 14)), + ] + m, found, _ = chained(plans) + assert sequence.chain(m, "p3_7_13") == ["p3_7_13", "p3_7_14", "p3_7_15"] + assert m.moves["p3_7_15"].after is None and "close a ring" in next_findings(found)[0].text + + +def test_each_pair_is_derived_once(): + plans = [hand((7, 13), step(61), to=(7, 14)), hand((7, 14), step(59))] + for heads in ([(7, 13), (7, 14)], [(7, 14), (7, 13)]): + m, _, d = chained(plans, '"7,14" = "own"\n', heads) + assert sorted(d.made) == ["p3_7_13", "p3_7_14"] + assert m.moves["p3_7_13"].after == "p3_7_14" + + +# --- sharing --- + + +def own_moves(extra: str = "", **after: str | None) -> manifest.Manifest: + """Own moves on the same clip, by name with the move each hands to.""" + body = "".join( + f'[moves.{n}]\nclip = "charge"\n' + (f'after = "{a}"\n' if a else "") + for n, a in after.items() + ) + return manifest.loads(TEXT + body + extra) + + +def test_equal_tails_make_equal_predecessors_one_move(): + m = own_moves(a1="b1", b1=None, a2="b2", b2=None) + dropped = own.share(m, ["a1", "b1", "a2", "b2"]) + assert dropped == {"a2": "a1", "b2": "b1"} and set(m.moves) == {"a1", "b1"} + assert m.moves["a1"].after == "b1" + manifest.check(m) + + +def test_moves_that_differ_in_any_field_but_the_label_stay_apart(): + m = own_moves(a="c", b="c", c=None, d="c", e="c2", c2=None) + m.moves["a"].label, m.moves["b"].label = "MHP3rd 7,1", "MHP3rd 7,2" + m.moves["d"].start = 5 + m.moves["c2"].length = 3 + dropped = own.share(m, ["a", "b", "c", "d", "e", "c2"]) + assert dropped == {"b": "a"} and set(m.moves) == {"a", "c", "d", "e", "c2"} + assert m.moves["a"].label == "MHP3rd 7,1", "the first derived is kept" + + +def test_a_chain_of_equal_steps_merges_to_a_fixed_point(): + names = {f"{h}{k}": (f"{h}{k + 1}" if k < 4 else None) for h in "xy" for k in range(1, 5)} + m = own_moves(**names) + dropped = own.share(m, list(names)) + assert dropped == {f"y{k}": f"x{k}" for k in range(1, 5)} and len(m.moves) == 4 + + +def test_an_anchored_move_is_the_one_kept(): + m = own_moves(a1="b1", b1=None, a2="b2", b2=None, x="a2") + assert own.share(m, ["a1", "b1", "a2", "b2"]) == {"a1": "a2", "b2": "b1"} + assert m.moves["x"].after == "a2" and set(m.moves) == {"a2", "b1", "x"} + assert m.moves["a2"].after == "b1", "its `after` follows the tail's merge" + + +def test_two_anchored_moves_stay_apart(): + m = own_moves(a1="b", a2="b", b=None, x="a1", y="a2", a3="b") + assert own.share(m, ["a1", "a2", "b", "a3"]) == {"a3": "a1"} + assert set(m.moves) == {"a1", "a2", "b", "x", "y"} + + +@pytest.mark.parametrize("how", ["pairs", "play", "node", "effect", "after"]) +def test_what_names_a_move_from_outside_anchors_it(how): + m = own_moves(a="c", b="c", c=None, outside="b") + assert m.brain is not None + if how == "pairs": + m.brain.pairs["7,14"] = "b" + elif how == "play": + m.behaviour.blocks["k"] = Block("any_time", (0.0, 0.0), play=["b"]) + elif how == "node": + m.behaviour.moves["b"] = MoveNode((0.0, 0.0)) + elif how == "effect": + m.effects.append(Effect("b", 1, 2, 3)) + if how != "after": + m.moves["outside"].after = None + assert own.anchored(m, ["a", "b", "c"]) == {"b"} + assert own.share(m, ["a", "b", "c"]) == {"a": "b"} + + +def test_a_ring_of_equal_moves_terminates(): + m = own_moves(a="b", b="a") + assert own.share(m, ["a", "b"]) == {} and set(m.moves) == {"a", "b"} + m = own_moves(a="b", b="c", c="a", d="e", e="f", f="d") + assert own.share(m, list("abcdef")) == {} and len(m.moves) == 6 + + # --- with the games --- @@ -256,6 +479,7 @@ def test_brute_derives_its_seven_pairs(derived): "p3_7_15", ] assert sequence.chain(m, "p3_7_14") == ["p3_7_14", "p3_7_14_2"] + assert not [f for f in found if f.code == "P3_OWN_NEXT"], "every handler ends at rest" def test_brute_windows_lie_inside_their_spans(derived, data): diff --git a/packages/mhfu-port/tests/test_spare.py b/packages/mhfu-port/tests/test_spare.py index 236d1a22..e01f87fd 100644 --- a/packages/mhfu-port/tests/test_spare.py +++ b/packages/mhfu-port/tests/test_spare.py @@ -289,6 +289,37 @@ def test_a_donor_record_without_a_match_plays_its_spare(): manifest.check(m) +class Two(Stub): + """A walk over several pairs.""" + + def __init__(self, plans: dict, hits: Hits, hosts: dict[int, int]) -> None: + super().__init__(next(iter(plans.values())), hits, hosts) + self.plans = plans + self.ms = SimpleNamespace(pairs={p: p for p in plans}) + + def plan(self, pair: object) -> Plan: + return self.plans[pair] + + +def test_the_scale_reads_the_pairs_a_handler_hands_over_to(): + hits = donor(rec(1, power=40), rec(2, power=100), rec(3, power=60, length=40)) + + def one(pair: tuple[int, int], to: tuple[int, int], *spawns: Hit) -> Plan: + return Plan(pair, (Step(61, 4, 0.0, "clip", 191, spawns, ()),), (to,), True) + + plans = { + (7, 13): one((7, 13), (7, 14), Hit(0, 3, 0, 140.0, 170.0)), + (7, 14): one((7, 14), (0, 0), Hit(0, 1, 0, 135.0, 165.0), Hit(1, 2, 0, 135.0, 165.0)), + } + m = manifest_of("[clips.charge]\nsource = 61\n") + stub = Two(plans, hits, {1: 7, 2: 8}) + d = own.Derivation(m, stub, lambda: stub, [].append, [(7, 13)]) # type: ignore[arg-type] + d.run((7, 13)) + assert m.moves["p3_7_13"].after == "p3_7_14" + (a,) = m.attacks + assert a.power == round(60 * 0.875), "the ratios 1.25 and 0.5 are the follow-up's" + + def test_the_scale_falls_back_where_nothing_matched(): m, _ = derive(donor(rec(1, power=100)), {}, [Hit(0, 1, 0, 10.0, 20.0)]) assert m.attacks[0].power == round(100 * spare.POWER_SCALE) diff --git a/packages/mhfu-port/tests/test_zinogre_brain.py b/packages/mhfu-port/tests/test_zinogre_brain.py index 04410188..a97efc98 100644 --- a/packages/mhfu-port/tests/test_zinogre_brain.py +++ b/packages/mhfu-port/tests/test_zinogre_brain.py @@ -14,7 +14,7 @@ from mhfu.em.script import brainfile, build, draw, edit, ir from mhfu.em.script import vars as script_vars from mhfu.em.script.budget import LIMIT -from mhfu_port import behaviour, manifest, moves +from mhfu_port import behaviour, manifest, moves, sequence from mhfu_port.brain import charge, fatigue, own, p3, p3ops, splice from mhfu_port.build import build as build_port from mhfu_port.cli import main @@ -119,9 +119,9 @@ def chain_of(m: manifest.Manifest, name: str) -> list[tuple[object, ...]]: return out -D2 = {"p3_7_17_3": 42, "p3_7_17_5": 24, "p3_7_19_3": 42, "p3_7_19_5": 24} +D2 = {"p3_7_17_3": 42, "p3_7_17_5": 24} """The steps that ended on a frame past their attack, live D2: `length` in AI frames stopped -them at cursor 54 of the 56 the attack needs.""" +them at cursor 54 of the 56 the attack needs. (7, 19)'s are the same moves.""" def test_the_d2_steps_start_where_the_donor_did_and_stop_past_their_attack(zinogre): @@ -135,9 +135,9 @@ def test_the_d2_steps_start_where_the_donor_did_and_stop_past_their_attack(zinog def test_every_derived_window_lies_inside_its_move_s_frames(zinogre, module_text): _, built = module_text made = {n: mv for n, mv in zinogre.m.moves.items() if n.startswith("p3_")} - assert sum(bool(mv.start) for mv in made.values()) == 10 + assert sum(bool(mv.start) for mv in made.values()) == 14 assert sum(bool(mv.stop) for mv in made.values()) == 13 - assert sum(mv.length is not None for mv in made.values()) == 14 + assert sum(mv.length is not None for mv in made.values()) == 16 for name, mv in made.items(): frames = built.layout.frames[moves.entry(zinogre.m, name, built.layout)] assert mv.start < frames and mv.stop <= frames, name @@ -146,9 +146,66 @@ def test_every_derived_window_lies_inside_its_move_s_frames(zinogre, module_text assert mv.start <= a.frame < a.end <= (mv.stop or frames), name +HANDS = { + "p3_7_4": [212, 213, 209, 210], + "p3_7_12": [203, 200, 257, 253], + "p3_7_13": [204, 253, 258, 200], + "p3_7_14": [32, 34, 221, 222, 223], + "p3_7_16": [30, 34, 221, 222, 223], + "p3_7_33": [25, 27, 225, 226], + "p3_7_65": [237], + "p3_7_67": [227, 230, 27, 225, 226], + "p3_7_68": [202, 253, 258, 200, 257, 253], + "p3_7_69": [201, 200, 257, 253, 258, 200], + "p3_7_99": [231, 235, 212, 213, 209, 210], + "p3_7_100": [213, 209, 210, 212, 213, 209, 210], +} +"""The donor clips each head whose handler hands over plays, in order, across the pairs.""" + + +def test_the_chains_play_the_donor_s_clips_across_its_hand_overs(zinogre): + m = zinogre.m + for head, clips in HANDS.items(): + steps = sequence.chain(m, head) + assert [m.clips[m.moves[n].clip].id for n in steps] == clips, head + assert not [n for n in ("p3_7_237", "p3_7_5") if n in m.moves], "twins are the kept move" + + +def test_every_hand_over_is_followed_but_to_the_pair_with_no_clip(zinogre): + (f,) = [f for f in zinogre.derived if f.code == "P3_OWN_NEXT"] + assert f.item == "p3_7_65" and "pair 8,0, which the port does not play" in f.text + assert zinogre.m.moves["p3_7_65"].after is None + + +def test_equal_tails_are_one_move(zinogre): + m = zinogre.m + assert sequence.chain(m, "p3_7_14")[-3:] == ["p3_7_0", "p3_7_0_2", "p3_7_0_3"] + assert m.moves["p3_7_16"].after == "p3_7_14_2" == m.moves["p3_7_14"].after + assert m.moves["p3_7_19"].after == "p3_7_17_2" == m.moves["p3_7_17"].after + assert "p3_7_19_3" not in m.moves + assert {"p3_1_6", "p3_1_15", "p3_1_17"} <= set(m.moves), "named moves stay apart" + assert all(f.item in m.moves for f in zinogre.derived if f.item and f.item.startswith("p3_")) + + +ELSEWHERE = {"7,70", "7,71", "7,79", "7,80", "7,81", "7,82", "7,116", "7,117", "7,118", "7,119"} +"""The twins whose handler hands over to another pair than their twin's: they play their twin's +whole chain. Deriving them would take 125 own moves of 96.""" + + +def own_steps(m: manifest.Manifest, name: str) -> list[tuple[object, ...]]: + """The leading steps of `chain_of` that come from the pair `name` heads, up to its hand-over.""" + out = [] + for n, step in zip(sequence.chain(m, name), chain_of(m, name), strict=True): + if m.moves[n].label != m.moves[name].label: + break + out.append(step) + return out + + def test_the_pairs_that_share_a_move_derive_the_same_one(zinogre, data): - """A pair that names its twin's move would derive that very move itself. (7, 71), (7, 82) and - (7, 119) are not the script's: only wrappers lead to them, and play (7, 13)'s move.""" + """A pair that names its twin's move would derive that very move itself, up to where it hands + over. (7, 71), (7, 82) and (7, 119) are not the script's: only wrappers lead to them, and play + (7, 13)'s move.""" b = zinogre.m.brain named = {k: v for k, v in b.pairs.items() if isinstance(v, str)} twins = {k: v for k, v in named.items() if v in b.derived()} @@ -157,9 +214,14 @@ def test_the_pairs_that_share_a_move_derive_the_same_one(zinogre, data): apart = manifest.load(PORT) apart.brain.pairs |= {k: manifest.OWN for k in twins} again, _ = own.expand(apart, data) + differ = set() for key, first in twins.items(): name = manifest.own_name(manifest.pair_of(key)) - assert chain_of(again, name) == chain_of(again, first), key + want = own_steps(again, first) + assert chain_of(again, name)[: len(want)] == want, key + if chain_of(again, name) != chain_of(again, first): + differ.add(key) + assert differ == ELSEWHERE def test_what_fought_the_brain_is_gone(zinogre): @@ -392,7 +454,7 @@ def test_the_own_moves_fit_the_framework(zinogre, module_text): _, built = module_text names = [n for n, mv in zinogre.m.moves.items() if mv.own] keys = sum(len(moves.curve(zinogre.m, n, built.layout) or "") // 4 for n in names) - assert len(names) == 89 and keys == 4642 + assert len(names) == 93 and keys == 4701 assert len(names) <= moves.SLOTS and keys <= moves.POOL assert not moves.problems(zinogre.m, built.layout, moves.records(Data.find().fu, 75)) diff --git a/ports/README.md b/ports/README.md index 8bdc41f4..54f419eb 100644 --- a/ports/README.md +++ b/ports/README.md @@ -215,9 +215,13 @@ A donor pair plays a host pair (a turn), the authored move that is the donor's (`charge_up`, `dash`, `dash_stop`, `break_howl`) or an own move derived from the donor (`"own"`); a pair that only turns, then enters another, turns on em75's (1, 2) and plays the move of the pair it leads to; pairs whose derived moves come out identical (twins) share the -first one's. The donor's walks are derived moves on their own walk clips: em75's (1, 0) walks to a -destination its own script sets first (docs/formats/p3rd_ai_script.md). His 89 own moves, the 14 authored and the 75 derived, take 4642 of -the registry's 5632 turn keys and 89 of its 96 slots. +first one's, and ten of them, which the donor hands over elsewhere, play it with its hand-over +(`ELSEWHERE` in `test_zinogre_brain.py`: their own would take 125 own moves of 96). A derived +move goes on into the pair the donor's handler hands over to (`p3_7_12` into `p3_7_156`), and +equal tails are one move. The donor's walks are derived moves on their own walk clips: em75's +(1, 0) walks to a destination its own script sets first (docs/formats/p3rd_ai_script.md). His 93 +own moves, the 14 authored and the 79 derived, take 4701 of the registry's 5632 turn keys and 93 +of its 96 slots. What stays of the hand-built rules, after the brain's own picks (2 plays nothing and so runs first): From 9303f565125168ff01fd9d40682f826cbeee5add Mon Sep 17 00:00:00 2001 From: sp00ktober <79335616+sp00ktober@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:35:14 +0200 Subject: [PATCH 2/2] mhfu-port own: list each chain across its hand-overs Each step shows the pair it comes from, a step shared with an earlier chain is marked once, and the header counts the own moves against the framework's. Co-Authored-By: Claude Sonnet 5.5 --- packages/mhfu-port/README.md | 2 +- packages/mhfu-port/src/mhfu_port/cli/own.py | 22 +++++++++++++++---- packages/mhfu-port/tests/test_own_cli.py | 24 +++++++++++++++++++++ 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/packages/mhfu-port/README.md b/packages/mhfu-port/README.md index 6bce4425..bcfa22c4 100644 --- a/packages/mhfu-port/README.md +++ b/packages/mhfu-port/README.md @@ -63,7 +63,7 @@ port.pac, port.summary.text() | `mhfu-port brain ID` | An MHP3rd monster's AI script as text (`mhfu_port.brain`; [format](../../docs/formats/p3rd_ai_script.md)) | | `mhfu-port brain --check` | Every monster's script lifted and encoded back to the game's bytes | | `mhfu-port p3moveset ID` | The monster's (main, sub) pairs, the clips each plays and the pairs it enters, as CSV (`p3moveset`) | -| `mhfu-port own M` | The own moves a manifest's `[brain.pairs] ... = "own"` derives from the donor: each chain, its clips, its attacks as donor record to host record (a spare one where no host record matches, `brain.spare`), its turn, and the findings (`brain.own`) | +| `mhfu-port own M` | The own moves a manifest's `[brain.pairs] ... = "own"` derives from the donor: each chain, across the pairs its handlers hand over to, its clips, its attacks as donor record to host record (a spare one where no host record matches, `brain.spare`), its turn, and the findings (`brain.own`) | | `mhfu-port splice M` | A manifest's `[brain]`: the donor's routine translated into the host's script, the findings on what did not carry over, the size and the longest run (`brain.splice`; `--source`, `-o FILE`) | `splice` writes an edit set for the host's row, which `mhfu brain push|deploy --doc FILE` sends on. diff --git a/packages/mhfu-port/src/mhfu_port/cli/own.py b/packages/mhfu-port/src/mhfu_port/cli/own.py index 7ff27645..0934c8d9 100644 --- a/packages/mhfu-port/src/mhfu_port/cli/own.py +++ b/packages/mhfu-port/src/mhfu_port/cli/own.py @@ -41,7 +41,12 @@ def run(args: argparse.Namespace) -> int: games = data.from_arguments(args) out, findings = own.expand(m, games) slots = moves.slots(out) - print(f"own {m.port.name}: MHP3rd {m.brain.donor}, {len(own.pairs(m.brain))} pairs") + total = sum(mv.own for mv in out.moves.values()) + print( + f"own {m.port.name}: MHP3rd {m.brain.donor}, {len(own.pairs(m.brain))} pairs, " + f"{total} own moves of {manifest.OWN_MOVES}" + ) + listed: dict[str, str] = {} # each move listed so far, with the head that listed it for pair in own.pairs(m.brain): head = manifest.own_name(pair) print(f"{pair[0]},{pair[1]}", end="") @@ -50,16 +55,21 @@ def run(args: argparse.Namespace) -> int: continue steps = sequence.chain(out, head) print(f" {head}" + (f" + {len(steps) - 1} more" if len(steps) > 1 else "")) + joined = False for name in steps: - print(describe(out, name, slots.get(name))) + earlier = None if joined else listed.get(name) + joined = joined or earlier is not None + print(describe(out, name, slots.get(name), earlier)) + listed.setdefault(name, head) errors = [f for f in findings if f.level == "error"] for f in findings: print(finding_line(f)) return 1 if errors else 0 -def describe(m: manifest.Manifest, name: str, slot: int | None) -> str: - """One derived move: its slot, clip, frames, length and steer, then its attacks.""" +def describe(m: manifest.Manifest, name: str, slot: int | None, shared: str | None = None) -> str: + """One derived move: its slot, clip, frames, length and steer, the pair it comes from, then its + attacks. `shared` is the head of an earlier chain that already holds it.""" mv = m.moves[name] clip = m.clips[mv.clip] if mv.clip is not None else None head = f" {name} (slot {slot}) clip {clip.id if clip else mv.anim} {mv.clip}" @@ -71,6 +81,10 @@ def describe(m: manifest.Manifest, name: str, slot: int | None) -> str: if s.turn == "hunter": window = "" if s.window is None else f" in frames {s.window[0]}-{s.window[1]}" head += f" turns to the hunter at {s.rate or 'the host charge'}{window}" + if mv.label: + head += f" ({mv.label})" + if shared is not None: + head += f" shared with {shared}" lines = [head] spare = {a.id for a in m.attacks} for a in mv.attacks: diff --git a/packages/mhfu-port/tests/test_own_cli.py b/packages/mhfu-port/tests/test_own_cli.py index bb2197d1..c6542ce4 100644 --- a/packages/mhfu-port/tests/test_own_cli.py +++ b/packages/mhfu-port/tests/test_own_cli.py @@ -51,6 +51,30 @@ def test_the_list_shows_the_frames_a_move_plays(): ) +def test_the_list_names_the_pair_and_the_chain_a_step_is_shared_with(): + m = manifest.loads(TEXT + "[moves.p3_7_13]\nclip = 'charge'\nlabel = 'MHP3rd 7,13'\n") + assert describe(m, "p3_7_13", 3).endswith("clip 61 charge (MHP3rd 7,13)") + assert describe(m, "p3_7_13", 3, "p3_7_12").endswith("(MHP3rd 7,13) shared with p3_7_12") + + +def test_the_cli_lists_the_chains_across_hand_overs(data, capsys): + path = PORTS / "zinogre.toml" + code = main(["own", str(path), "--data", str(data.fu.root), "--p3rd-data", str(data.p3rd.root)]) + out = capsys.readouterr().out + assert code == 0 and "39 pairs, 93 own moves of 96" in out + lines = out.splitlines() + head = lines.index("7,12 p3_7_12 + 3 more") + chain = [line.split()[0] for line in lines[head + 1 :] if line.startswith(" p3_")][:4] + assert chain == ["p3_7_12", "p3_7_12_2", "p3_7_12_3", "p3_7_156"] + assert any("p3_7_156 (slot" in line and "(MHP3rd 7,156)" in line for line in lines) + held = [line for line in lines if line.startswith(" p3_7_0 (slot")] + assert len(held) == 3, "its own chain, then (7, 14)'s and (7, 16)'s hand-overs" + assert not held[0].endswith("shared with p3_7_0") and held[1].endswith("shared with p3_7_0") + again = [line for line in lines if line.startswith(" p3_7_14_2 (slot")] + assert len(again) == 2 and again[1].endswith("shared with p3_7_14"), "marked once per chain" + assert not held[2].endswith("shared with p3_7_0"), "(7, 16)'s tail was marked at its first step" + + def test_the_cli_lists_the_moves(data, capsys): path = PORTS / "brute_tigrex.toml" code = main(["own", str(path), "--data", str(data.fu.root), "--p3rd-data", str(data.p3rd.root)])