Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions apps/studio/src/mhfu_studio/monster/clips.py
Original file line number Diff line number Diff line change
Expand Up @@ -479,7 +479,7 @@ def drop(m: Manifest) -> None:


def rename_move(doc: PortDocument, old: str, new: str) -> str:
"""Renames a move and every `after`, block and effect that names it."""
"""Renames a move and every `after`, block, `[brain.pairs]` item and effect that names it."""
new = check_name(new)
if old not in doc.manifest.moves:
raise ManifestError(f"no move {old!r}")
Expand All @@ -493,6 +493,8 @@ def rename(m: Manifest) -> None:
for mv in m.moves.values():
mv.after = new if mv.after == old else mv.after
behaviour.rename_move(m, old, new)
if m.brain is not None:
m.brain.rename_move(old, new)
for e in m.effects:
e.move = new if e.move == old else e.move

Expand All @@ -501,9 +503,11 @@ def rename(m: Manifest) -> None:


def move_users(m: Manifest, name: str) -> list[str]:
"""What names move `name`: other moves' `after`, the behaviour graph and effects."""
"""What names move `name`: other moves' `after`, the behaviour graph, `[brain.pairs]` and
effects."""
out = [f"moves.{k} (after)" for k, mv in m.moves.items() if mv.after == name]
out += behaviour.uses(m, name)
out += [f"brain.pairs.{k}" for k in ([] if m.brain is None else m.brain.users(name))]
out += [f"effect {e.id}" for e in m.effects if e.move == name]
return out

Expand Down
55 changes: 42 additions & 13 deletions apps/studio/tests/monster/test_behaviour.py
Original file line number Diff line number Diff line change
Expand Up @@ -366,17 +366,19 @@ 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) == 39 and len(r.paths) == 17
assert sorted(n for ns in r.priority.values() for n in ns) == list(range(1, 18))
assert len(m.behaviour.blocks) == 17 and len(r.paths) == 10
assert sorted(n for ns in r.priority.values() for n in ns) == list(range(1, 11))
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", "b39"]
assert [p.play is None for p in r.paths] == [True] * 4 + [False] * 13, "bookkeeping runs first"
assert model.names(m, "var") == ["flinches", "in_combat"]
enrage = r.paths[3] # while charge_up plays: 120 frames, wait 250, enrage
assert (enrage.blocks, enrage.during) == (("b37", "b38", "b39"), "charge_up")
assert m.behaviour.blocks["b39"].kind == "enrage" and B.natural_rage(m)
charge = r.paths[13] # the 2nd flinch, from idle: charge up
assert (charge.blocks[-1], charge.play) == ("b27", "charge_up")
assert [p.blocks[-1] for p in r.paths if p.play is None] == ["b15", "b17"]
assert [p.play is None for p in r.paths] == [True] * 2 + [False] * 8, "bookkeeping runs first"
assert model.names(m, "var") == ["in_combat"]
skid = r.paths[2] # while the lunge plays: past the hunter, a skid
assert (skid.blocks, skid.during, skid.play) == (
("b1", "b2", "b3", "b4"),
"lunge",
"lunge_stop",
)
assert B.natural_rage(m) and not [b for b in m.behaviour.blocks.values() if b.kind == "enrage"]
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)
Expand All @@ -385,10 +387,10 @@ def test_the_zinogre_reads(ports: Path) -> None:
def test_renaming_a_move_keeps_its_paths(ports: Path) -> None:
z = PortDocument(manifest.load(ports / "zinogre.toml"))
before = model.compile(z.manifest)
clips.rename_move(z, "stamp", "stomp")
clips.rename_move(z, "notice_howl", "greet")
clips.rename_move(z, "lunge", "lunge_fwd")
assert {"stomp", "lunge_fwd"} <= set(z.manifest.behaviour.moves)
names = {"stomp": "stamp", "lunge_fwd": "lunge"}
assert {"greet", "lunge_fwd"} <= set(z.manifest.behaviour.moves)
names = {"greet": "notice_howl", "lunge_fwd": "lunge"}
back = [
dataclasses.replace(
r, play=names.get(r.play, r.play), from_move=names.get(r.from_move or "", r.from_move)
Expand All @@ -401,6 +403,33 @@ def test_renaming_a_move_keeps_its_paths(ports: Path) -> None:
assert model.compile(z.manifest) == before


def test_renaming_a_move_the_brain_plays_follows_its_pairs(ports: Path) -> None:
z = PortDocument(manifest.load(ports / "zinogre.toml"))
b = z.manifest.brain
assert b is not None and b.users("dash") == ["2,4", "7,29", "7,30"]
clips.rename_move(z, "dash", "rush")
assert z.manifest.brain is not None
assert z.manifest.brain.targets((2, 4)) == ["rush"]
assert z.manifest.brain.targets((7, 29)) == [(1, 2), "rush"]
assert clips.move_users(z.manifest, "rush") == [
"brain.pairs.2,4",
"brain.pairs.7,29",
"brain.pairs.7,30",
]
z.undo()
assert z.manifest.brain is not None and z.manifest.brain.users("dash") == [
"2,4",
"7,29",
"7,30",
]


def test_a_move_the_brain_plays_is_not_deleted(ports: Path) -> None:
z = PortDocument(manifest.load(ports / "zinogre.toml"))
with pytest.raises(ManifestError, match="brain.pairs.2,4"):
clips.drop_move(z, "dash")


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 b18 plays it"):
Expand Down
13 changes: 6 additions & 7 deletions apps/studio/tests/monster/ui/test_behaviour.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,16 +338,15 @@ def test_the_zinogre_opens(
ws.all_view = B.CANVAS # a loaded document starts in order
p = make(qtbot, ws)
m = zin.manifest
assert len(m.behaviour.blocks) == 39 and len(p.view._shown) == 39 + len(m.moves)
assert len(m.behaviour.blocks) == 17 and len(p.view._shown) == 17 + 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, 18))
assert plays == sorted(f"#{k}" for k in range(1, 11))
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", "b39")] == [rank(k) for k in (1, 2, 3, 4)]
assert badge(p, "b4") == rank(5) and badge(p, "b27") == rank(14)
assert badge(p, "b36") == rank(17) and p.rage.isChecked()
assert bar_on(p, "b23").controls["counter"].get_value() == "flinches"
assert options(p, "b15", "flag") == ["flinches", "in_combat"]
assert [badge(p, i) for i in ("b15", "b17")] == [rank(k) for k in (1, 2)]
assert badge(p, "b4") == rank(3) and badge(p, "b6") == rank(4)
assert badge(p, "b36") == rank(10) and p.rage.isChecked()
assert options(p, "b15", "flag") == ["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}
Expand Down
10 changes: 9 additions & 1 deletion docs/formats/p3rd_ai_script.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@ the first's note, are an error.
A random that can follow another draw in the same run gets an `rng_step2` first, as the host's
own picks do (a split one's inner randoms do). Findings are errors (`P3_PAIR_UNMAPPED`, `P3_OWN_MISSING`, `P3_UNSUPPORTED`,
`P3_HOST_OP`, `P3_UNDECIDED`, `P3_LATCH`, `P3_HISTORY`, `P3_WIDE`, `P3_ROUTINE`, `P3_FATIGUE_HOST`), which stop a
build, warnings (`P3_FOLDED`, `P3_APPROX`, `P3_PAIR_UNUSED`, `P3_WHEN`, `P3_WHEN_IGNORED`, a
build, warnings (`P3_FOLDED`, `P3_APPROX`, `P3_PAIR_UNUSED` (not for a pair the charge table names or whose
move another pair plays), `P3_WHEN`, `P3_WHEN_IGNORED`, a
`P3_HISTORY` for a call before the read) and notes (`P3_DROPPED`, `P3_WEIGHT`, `P3_SPLIT`, an `info`
`P3_FOLDED`).

Expand All @@ -119,6 +120,13 @@ A value may be a list, played in order, each item one `enter` that yields: `"7,2
is the host's turn (1, 2), then the move. An item is a host pair, an own move's name or `"own"`; the
build charges the donor pair's stamina to the last item.

A pair can also name the move another pair derives, `"7,23" = [[1, 2], "p3_7_0"]` where `"7,0" =
"own"`: a turn wrapper (a donor pair that turns toward the target, then enters another pair)
plays the move of the pair it leads to rather than a duplicate of its own handler, and twin pairs
(same clips, attacks and steer) share one move, which saves slots and turn keys. The name must be
`own_name` of a pair mapped to `"own"` (`Brain.derived`), and that pair is not reported unused
while another pair plays its move (`Brain.shared`).

The slots are `mhfu_port.moves.slots`: the own moves by sorted name, numbered from 0, at most
`EM_MOVES.MOVES.count`. The moves module `mhfu-port` writes carries each one as `slot = k` and
`mhfu_port.lua` registers by it, so the number in the script, the module and the registry is one.
Expand Down
6 changes: 4 additions & 2 deletions framework/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,8 @@ the engine counts the flinch and applies the damage, then enters the move's carr
## Own moves from a manifest

A port's own moves sit in a registry by slot (`mhfu.em_move`): `mhfu.addr.EM_MOVES.MOVES_COUNT`
(96) of them, and the turn keys they share (2048), in one 12.8 KB partition block. The seam's
(96) of them, and the turn keys they share (`EM_MOVES.KEYS_COUNT`, 5632), in one 19.9 KB
partition block. The seam's
brain, in C on the AI step, plays one through the move player when asked (`mhfu.em_play`), when the
move before it ends on its clip, its length or a wall and names it as its `after`, or when a rule
says (`play_move`, and `from_move` to fire while one plays). `mhfu_port.lua` does all of it from the
Expand Down Expand Up @@ -223,7 +224,8 @@ start sets charged, durability 100, gauge 100. Durability 0 or a `discharge` pai
traps) discharges it: gauge 0, `cooldown_out` frames of cooldown. The state stays across areas; a new
monster, the quest's end, `mhfu.em_clear()` and `mhfu.em_charge(nil)` reset or drop it, and
`mhfu.em_charge_status()` reads it. A moves module's `charge` table does all of it with the port,
its `ups` keys and `transform` by move name or slot.
its `ups` keys and `transform` by move name or slot; `mhfu-port` writes it from the donor's code
with `[brain] charge` (docs/formats/p3rd_ai_script.md).

## AI scripts from the stick

Expand Down
8 changes: 4 additions & 4 deletions framework/tests/test_em_brain.py
Original file line number Diff line number Diff line change
Expand Up @@ -561,12 +561,12 @@ def test_an_own_moves_hunter_turn_keeps_to_its_steer_window(


def test_keys_have_room(g: Game) -> None:
assert g.own(0, 46, keys=256)
for slot in range(1, 8):
full = a.EM_MOVES.KEYS.count // 256
for slot in range(full):
assert g.own(slot, 46, keys=256)
assert not g.own(8, 46, keys=1)
assert not g.own(full, 46, keys=1)
g.lib.mhfu_em_moves_clear()
assert g.own(8, 46, keys=256) and g.moves("KEY_TOP") == 256
assert g.own(full, 46, keys=256) and g.moves("KEY_TOP") == 256


def test_a_rule_on_an_event_fires_in_the_frame_it_is_seen(g: Game) -> None:
Expand Down
9 changes: 1 addition & 8 deletions packages/mhfu-port/src/mhfu_port/brain/own.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@
from ..data import Data
from ..manifest import (
MOVE_ATTACKS,
OWN,
OWN_MOVES,
AttackWindow,
Brain,
Expand All @@ -46,7 +45,6 @@
Move,
Steer,
own_name,
targets_of,
)
from ..p3hits import Plan, Step, Walk
from .spare import HOST_NO_DAMAGE, NO_DAMAGE, POWER_SCALE, Spare, Spares
Expand Down Expand Up @@ -414,12 +412,7 @@ def _steer(self, step: Step, walls: bool) -> Steer:

def pairs(b: Brain) -> list[Pair]:
"""The donor pairs whose own move the build derives, in order."""
found = []
for key, target in b.pairs.items():
main, _, sub = key.partition(",")
if OWN in targets_of(target):
found.append((int(main), int(sub)))
return sorted(found)
return sorted(b.derived().values())


def expand(m: Manifest, data: Data) -> tuple[Manifest, list[Finding]]:
Expand Down
6 changes: 3 additions & 3 deletions packages/mhfu-port/src/mhfu_port/brain/splice.py
Original file line number Diff line number Diff line change
Expand Up @@ -887,9 +887,9 @@ def _failed(es: EditSet, spec: manifest.Brain, text: str) -> Spliced:


def _unused(spec: manifest.Brain, t: Translator, named: Collection[Pair] = ()) -> list[Finding]:
"""Manifest entries that match nothing in the donor's code and that the charge table does not
name."""
met = {f"{m},{s}" for m, s in [*t.entered, *named]}
"""Manifest entries that match nothing in the donor's code, that the charge table does not name
and whose move no other entry plays."""
met = {f"{m},{s}" for m, s in [*t.entered, *named, *spec.shared()]}
out = [
Finding("warning", "P3_PAIR_UNUSED", f"{k} is mapped but the donor code never enters it")
for k in spec.pairs
Expand Down
7 changes: 5 additions & 2 deletions packages/mhfu-port/src/mhfu_port/cli/splice.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,9 @@ def summary(
grown = len(built.image.data) - (built.lift.end - built.lift.base)
added += f", {grown:+} bytes, worst run {worst.count} of {LIMIT}"
lines = [f" {added}"]
for (donor, target), uses in sorted(spliced.uses.items(), key=lambda use: use[0]):
for (donor, target), uses in sorted(
spliced.uses.items(), key=lambda use: (use[0][0], str(use[0][1]))
):
to = (
f"own {target} (slot {spliced.slots[target]})"
if isinstance(target, str)
Expand All @@ -108,8 +110,9 @@ def summary(
f"tired {stamina.tired} frames"
)
if charging is not None:
ups = ", ".join(f"{n} {g[0]}/{g[1]}/{g[2]}/{g[3]}" for n, g in sorted(charging.ups.items()))
lines.append(
f" charge: {len(charging.ups)} charge-up moves, transform {charging.transform}; "
f" charge: {ups or 'no charge-up'}; transform {charging.transform}; "
f"chips {charging.chip_gauge} gauge, {charging.chip_durability} durability; "
f"cooldowns {charging.cooldown_up} and {charging.cooldown_out}; "
f"{len(charging.discharge)} discharge pairs"
Expand Down
47 changes: 41 additions & 6 deletions packages/mhfu-port/src/mhfu_port/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -405,9 +405,9 @@ class Brain:
first free variable (`mhfu.em.script.vars`)."""
pairs: dict[str, Plays] = field(default_factory=dict)
"""What each donor pair, keyed `"main,sub"`, plays: a host pair, the name of an own move in
`[moves]`, or `OWN`; or a list of those, each entered in turn (`[[1, 2], "own"]`: the host's
turn, then the move). The splice enters an own move as `enter OWN_MOVE_MAIN k 0`, `k` its
`moves.slots` slot."""
`[moves]`, or `OWN`, or the name `OWN` gives another pair (`derived`); or a list of those, each
entered in turn (`[[1, 2], "own"]`: the host's turn, then the move). The splice enters an own
move as `enter OWN_MOVE_MAIN k 0`, `k` its `moves.slots` slot."""
when: dict[str, bool] = field(default_factory=dict)
"""The arm a donor test with no host counterpart takes, by its MHP3rd opcode name."""
attacks: dict[str, int] = field(default_factory=dict)
Expand Down Expand Up @@ -443,6 +443,41 @@ def host_pair(self, pair: Pair) -> Pair | None:
t = self.target(pair)
return t if isinstance(t, tuple) else None

def users(self, move: str) -> list[str]:
"""The `[brain.pairs]` keys that play the own move `move`."""
return [k for k in self.pairs if move in self.targets(pair_of(k))]

def rename_move(self, old: str, new: str) -> None:
"""Every `[brain.pairs]` item that plays the own move `old` plays `new`."""
for key, plays in self.pairs.items():
if old in targets_of(plays):
renamed = [new if t == old else t for t in targets_of(plays)]
self.pairs[key] = renamed if isinstance(plays, list) else renamed[0]

def derived(self) -> dict[str, Pair]:
"""The own moves `OWN` stands for, by name, each with the donor pair it comes from. Another
pair plays one by naming it: `"7,23" = [[1, 2], "p3_7_0"]` turns, then plays pair (7, 0)'s
move."""
pairs = {pair_of(key): plays for key, plays in self.pairs.items()}
return {own_name(p): p for p, plays in pairs.items() if OWN in targets_of(plays)}

def shared(self) -> set[Pair]:
"""The pairs mapped to `OWN` whose move another pair plays, so the script need not enter
them."""
names = self.derived()
return {
names[t]
for key in self.pairs
for t in self.targets(pair_of(key))
if isinstance(t, str) and t in names and names[t] != pair_of(key)
}


def pair_of(key: str) -> Pair:
"""The donor pair a `[brain.pairs]` key names."""
main, _, sub = key.partition(",")
return int(main), int(sub)


def targets_of(plays: Plays | None) -> list[Target]:
"""The targets of a `[brain.pairs]` value, as written."""
Expand Down Expand Up @@ -686,9 +721,11 @@ def _validate(m: Manifest) -> None:
_need(key not in claimed, w, f"claims main {k} as moves.{claimed.get(key)} does")
claimed[key] = name
if m.brain is not None:
_brain(m.brain)
derived = m.brain.derived()
for donor, plays in m.brain.pairs.items():
for t in targets_of(plays):
if isinstance(t, str) and t != OWN:
if isinstance(t, str) and t != OWN and t not in derived:
target = m.moves.get(t)
_need(
target is not None and target.own,
Expand Down Expand Up @@ -731,8 +768,6 @@ def _validate(m: Manifest) -> None:
_need(lever in (None, *BYTE), w, f"{lever} is not a byte")
for i, e in enumerate(m.effects):
_need(e.move in m.moves, f"effect[{i}]", f"move {e.move!r} is not in moves")
if m.brain is not None:
_brain(m.brain)
own = sum(mv.own for mv in m.moves.values())
_need(own <= OWN_MOVES, "moves", f"{own} own moves, the framework holds {OWN_MOVES}")
validate(m)
Expand Down
2 changes: 1 addition & 1 deletion packages/mhfu-port/src/mhfu_port/p3charge.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
BANDS = (1.0, 0.85, 0.6, 0.2)
"""An HP ratio inside each of a gain's bands (at least 95 %, 75 %, 40 %, below)."""
ROW = struct.Struct("<Hh")
"""A chip row: the step a hit takes and the most one reaction burst takes in steps."""
"""A chip row: the step a hit takes and the most one burst of hits takes in all."""
RANK = 0
"""The row of the chip rows taken: the lowest rank's."""
DEPTH = 2
Expand Down
Loading
Loading