Skip to content

Commit edbf02a

Browse files
sp00ktoberclaude
andcommitted
feat(studio): build and edit a rule inside a trigger's rows
+ Rule adds a rule for the trigger (checked last, ending in a placeholder until a move is picked). Right-click a block inserts one after it, and a reference or the placeholder inserts before the play. Delete closes the chain up round a block, takes the whole rule with its first block or its frame, and only the play with a reference. The bar picks the move a reference or the placeholder plays, and a frame shows the rule's note. Each is one undo step through the model's own edits; a rule with no move yet is an open chain the rows draw after the others. Free blocks and wires stay a gesture of All. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent 20c70d8 commit edbf02a

8 files changed

Lines changed: 1260 additions & 109 deletions

File tree

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

Lines changed: 219 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,12 @@
1212
from __future__ import annotations
1313

1414
import typing
15-
from collections.abc import Callable, Mapping, Sequence
15+
from collections import Counter
16+
from collections.abc import Callable, Collection, Mapping, Sequence
1617
from typing import Any, NamedTuple, Protocol
1718

1819
from mhfu_port import behaviour, sequence
19-
from mhfu_port.behaviour import KINDS, Block, MoveNode, Param, Path
20+
from mhfu_port.behaviour import KINDS, Block, MoveNode, Param, Path, Trigger
2021
from mhfu_port.manifest import Manifest, ManifestError
2122

2223
from mhfu_studio.monster.document import PortDocument
@@ -29,6 +30,8 @@
2930
"""A move node's ports: the input, then the two outputs."""
3031
ALL = "all"
3132
"""The scope that is the whole canvas, as stored; any other is a trigger's key."""
33+
INSERTABLE = ("condition", "modifier", "effect")
34+
"""The roles of the blocks a rule holds besides its first."""
3235
ROLE_TITLES = {
3336
"event": "Events",
3437
"state": "State",
@@ -204,9 +207,10 @@ def options(m: Manifest, kind: str) -> dict[str, list[tuple[str, object]]]:
204207
return out
205208

206209

207-
def palette() -> list[tuple[str, list[tuple[str, str, str]]]]:
208-
"""The kinds a block can be, a group per role: (kind, title, tip). All from `KINDS`."""
209-
roles = typing.get_args(behaviour.Role)
210+
def palette(only: Collection[str] | None = None) -> list[tuple[str, list[tuple[str, str, str]]]]:
211+
"""The kinds a block can be, a group per role (only those in `only`, if given): (kind,
212+
title, tip). All from `KINDS`."""
213+
roles = [r for r in typing.get_args(behaviour.Role) if only is None or r in only]
210214
groups = [
211215
(
212216
ROLE_TITLES.get(role, role.capitalize() + "s"),
@@ -217,6 +221,11 @@ def palette() -> list[tuple[str, list[tuple[str, str, str]]]]:
217221
return [g for g in groups if g[1]]
218222

219223

224+
def insertable() -> list[tuple[str, list[tuple[str, str, str]]]]:
225+
"""The palette of what goes inside a rule: not the event or state that starts it."""
226+
return palette(INSERTABLE)
227+
228+
220229
def start(p: Param) -> object:
221230
"""A new block's value for `p`: the default, else the least, else the first choice; none
222231
for an optional param."""
@@ -465,3 +474,208 @@ def scale(m: Manifest) -> None:
465474

466475
doc.edit(scale)
467476
return f"{len(b.blocks) + len(b.moves)} nodes spread out"
477+
478+
479+
# rules: what a trigger's rows edit
480+
481+
#: the gap that puts a new rule's first block below every other block
482+
FRESH = float(behaviour.ROW)
483+
484+
485+
def open_rules(m: Manifest) -> list[Path]:
486+
"""The chains from a source that stop at a block that plays nothing and feeds nothing: rules
487+
that have no move yet, which the brain does not check. First block's place first."""
488+
b = m.behaviour
489+
done = {p.blocks for p in behaviour.paths(m)}
490+
out: list[Path] = []
491+
492+
def walk(trail: tuple[str, ...], during: str | None) -> None:
493+
blk = b.blocks[trail[-1]]
494+
if not blk.play and not blk.next and trail not in done:
495+
out.append(Path(trail, "", during))
496+
for n in blk.next:
497+
if n not in trail:
498+
walk((*trail, n), during)
499+
500+
for i, during in behaviour.sources(m):
501+
walk((i,), during)
502+
return sorted(out, key=lambda p: (b.blocks[p.blocks[0]].at[1], b.blocks[p.blocks[0]].at[0]))
503+
504+
505+
def _values(kind: str, by: str = "") -> dict[str, object]:
506+
"""A new block's params: valid ones, the trigger's `by` value among them."""
507+
params = {p.name: v for p in KINDS[kind].params if (v := start(p)) is not None}
508+
if by and KINDS[kind].by:
509+
params[KINDS[kind].by] = by
510+
return params
511+
512+
513+
def add_rule(doc: Edits, t: Trigger) -> str:
514+
"""A rule for trigger `t`: its first block (the event or state kind with the trigger's
515+
value; for a move's while-playing port a Played for block fed by it), below every other
516+
block, so it is checked last. It plays no move yet."""
517+
m = doc.manifest
518+
b = m.behaviour
519+
if t.section == "move":
520+
if t.name not in b.moves:
521+
raise ManifestError(f"no move {t.name!r}")
522+
kind = "played_for"
523+
else:
524+
kind = t.kind
525+
if kind not in KINDS:
526+
raise ManifestError(f"{kind!r} is not a kind of block")
527+
params = _values(kind, t.name if t.section == "event" else "")
528+
i = behaviour.new_id(b)
529+
y = max((blk.at[1] for blk in b.blocks.values()), default=-FRESH) + FRESH
530+
at = _at((float(behaviour.LEFT), y))
531+
532+
def add(m: Manifest) -> None:
533+
m.behaviour.blocks[i] = Block(kind, at, params)
534+
if t.section == "move":
535+
m.behaviour.moves[t.name].during.append(i)
536+
537+
doc.edit(add)
538+
return f"{KINDS[kind].title}: a rule added, checked last"
539+
540+
541+
def insert_block(doc: Edits, after: str, kind: str, to: str | None, play: str | None) -> str:
542+
"""A block of `kind` in a rule after block `after`. `to` is the block the rule goes on to
543+
from `after`, and `play` the move it ends in when it ends there: the new block takes that
544+
place, so `after` feeds it and it feeds `to` or plays `play`."""
545+
m = doc.manifest
546+
here = _block(m, after)
547+
if kind not in KINDS or KINDS[kind].role not in INSERTABLE:
548+
raise ManifestError(f"{kind!r} is not a block a rule holds")
549+
if to is not None and to not in here.next:
550+
raise ManifestError(f"{_say(m, after)} does not feed {_say(m, to)}")
551+
if play is not None and play not in here.play:
552+
raise ManifestError(f"{_say(m, after)} does not play {play}")
553+
i = behaviour.new_id(m.behaviour)
554+
x = (here.at[0] + _block(m, to).at[0]) / 2 if to is not None else here.at[0] + behaviour.COLUMN
555+
at, params = _at((x, here.at[1])), _values(kind)
556+
557+
def add(m: Manifest) -> None:
558+
blocks = m.behaviour.blocks
559+
a, new = blocks[after], Block(kind, at, params)
560+
if to is not None:
561+
new.next = [to]
562+
a.next = [i if n == to else n for n in a.next]
563+
else:
564+
if play is not None:
565+
new.play = [play]
566+
a.play = [p for p in a.play if p != play]
567+
a.next = [*a.next, i]
568+
blocks[i] = new
569+
570+
doc.edit(add)
571+
return f"{KINDS[kind].title} inserted after {_say(m, after)}"
572+
573+
574+
def drop_block(doc: Edits, block: str) -> str:
575+
"""A block out of every rule that holds it, the chain closing up: what fed it feeds what it
576+
fed, and moves it played are played by what fed it. Not the first block of a rule."""
577+
m = doc.manifest
578+
_block(m, block)
579+
if not any(block in blk.next for blk in m.behaviour.blocks.values()):
580+
raise ManifestError("the first block of a rule goes with the rule")
581+
582+
def close(m: Manifest) -> None:
583+
blocks = m.behaviour.blocks
584+
gone = blocks.pop(block)
585+
for blk in blocks.values():
586+
if block in blk.next:
587+
blk.next = list(
588+
dict.fromkeys(n for x in blk.next for n in (gone.next if x == block else [x]))
589+
)
590+
blk.play = list(dict.fromkeys([*blk.play, *gone.play]))
591+
592+
doc.edit(close)
593+
return f"{_say(m, block)} deleted"
594+
595+
596+
def drop_rule(doc: Edits, blocks: Sequence[str], play: str | None) -> str:
597+
"""A rule out: its move, then each block from its end back that has nothing left to feed or
598+
play. Blocks other rules still run through stay."""
599+
m = doc.manifest
600+
for i in blocks:
601+
_block(m, i)
602+
603+
def drop(m: Manifest) -> None:
604+
b = m.behaviour
605+
if play is not None:
606+
b.blocks[blocks[-1]].play = [p for p in b.blocks[blocks[-1]].play if p != play]
607+
for i in reversed(blocks):
608+
if i not in b.blocks or b.blocks[i].next or b.blocks[i].play:
609+
break
610+
del b.blocks[i]
611+
for other in b.blocks.values():
612+
other.next = [n for n in other.next if n != i]
613+
for node in b.moves.values():
614+
node.during = [n for n in node.during if n != i]
615+
616+
doc.edit(drop)
617+
return "rule deleted"
618+
619+
620+
def set_play(doc: Edits, block: str, old: str | None, new: str | None) -> str:
621+
"""The move `block` plays: `old` becomes `new`; no `old` adds one, no `new` takes it away."""
622+
m = doc.manifest
623+
here = _block(m, block)
624+
if new is not None:
625+
_move(m, new)
626+
if old is not None and old not in here.play:
627+
raise ManifestError(f"{_say(m, block)} no longer plays {old}")
628+
if old == new:
629+
return f"{_say(m, block)}: unchanged"
630+
631+
def change(m: Manifest) -> None:
632+
plays = m.behaviour.blocks[block].play
633+
if old is None and new is not None:
634+
plays.append(new)
635+
elif old is not None and new is None:
636+
plays.remove(old)
637+
elif old is not None and new is not None:
638+
plays[plays.index(old)] = new
639+
640+
doc.edit(change)
641+
return (
642+
f"{_say(m, block)}: plays {new}" if new is not None else f"{_say(m, block)}: plays nothing"
643+
)
644+
645+
646+
def move_rule(doc: Edits, rules: Sequence[Path], moved: int, to: int) -> str:
647+
"""Of one trigger's `rules`, in the order they are checked, the one at `moved` takes place
648+
`to`. The places of the rules' first blocks are handed round among them, so every other rule
649+
keeps its place in the whole order; a rule's own blocks move with its first block, a move
650+
node never. Refused if the rule would keep its place."""
651+
m = doc.manifest
652+
roots = [p.blocks[0] for p in rules]
653+
if len(set(roots)) != len(roots):
654+
raise ManifestError("rules that start from the same block cannot be put in another order")
655+
if moved == to:
656+
return "unchanged"
657+
order = list(range(len(rules)))
658+
order.insert(to, order.pop(moved))
659+
heights = sorted(m.behaviour.blocks[r].at[1] for r in roots)
660+
new_y = {roots[old]: heights[k] for k, old in enumerate(order)}
661+
held = Counter(i for p in [*behaviour.paths(m), *open_rules(m)] for i in set(p.blocks))
662+
key = behaviour.trigger_key(m, rules[0])
663+
want = [roots[old] for old in order]
664+
665+
def shift(m: Manifest) -> None:
666+
blocks = m.behaviour.blocks
667+
for old, root in enumerate(roots):
668+
dy = new_y[root] - blocks[root].at[1]
669+
for i in rules[old].blocks:
670+
if dy and (i == root or held[i] == 1):
671+
x, y = blocks[i].at
672+
blocks[i].at = _at((x, y + dy))
673+
got = next(t for t in behaviour.triggers(m) if t.key == key)
674+
if [p.blocks[0] for _, p in got.rules] != want:
675+
raise ManifestError(
676+
"that would not change the rule's rank: rules at the same height are checked"
677+
" left to right, and rules that play nothing go first"
678+
)
679+
680+
doc.edit(shift)
681+
return f"rule moved to place {to + 1} of its trigger"

0 commit comments

Comments
 (0)