|
| 1 | +# SPDX-License-Identifier: GPL-3.0-or-later |
| 2 | +# SPDX-FileCopyrightText: 2026 sp00ktober |
| 3 | +import math |
| 4 | +from pathlib import Path |
| 5 | + |
| 6 | +import pytest |
| 7 | +from mhfu_port import continuity, manifest |
| 8 | +from mhfu_port.data import Data |
| 9 | +from mhp_formats.anim import Channel, Clip, Keyframe, Track, quantize |
| 10 | +from mhp_formats.skeleton import Bone, Skeleton |
| 11 | + |
| 12 | +PORTS = Path(__file__).parents[3] / "ports" |
| 13 | +ROT = (0x008, 0x010, 0x020) |
| 14 | + |
| 15 | + |
| 16 | +def skeleton() -> Skeleton: |
| 17 | + """Joint 0 and the root 1 carry the travel; the body 2 and a limb 3 turn.""" |
| 18 | + return Skeleton( |
| 19 | + [ |
| 20 | + Bone(parent=-1, child=1), |
| 21 | + Bone(parent=0, child=2), |
| 22 | + Bone(parent=1, child=3, position=(0.0, 0.0, 50.0)), |
| 23 | + Bone(parent=2, position=(0.0, 30.0, 100.0)), |
| 24 | + ] |
| 25 | + ) |
| 26 | + |
| 27 | + |
| 28 | +def turn(axis: int, degrees: float) -> Channel: |
| 29 | + return Channel(ROT[axis], [Keyframe(quantize("rot", math.radians(degrees)), 0)]) |
| 30 | + |
| 31 | + |
| 32 | +def held(channels: list[Channel], frames: int) -> Track: |
| 33 | + """Each channel at its first key's value from frame 0 to `frames`.""" |
| 34 | + return Track( |
| 35 | + [Channel(c.bit, [c.keyframes[0], c.keyframes[0]._replace(frame=frames)]) for c in channels] |
| 36 | + ) |
| 37 | + |
| 38 | + |
| 39 | +def clip(body: list[Channel], limb: list[Channel], frames: int = 10) -> Clip: |
| 40 | + """A clip whose joints 2 and 3 hold the given channels.""" |
| 41 | + return Clip([Track(), Track(), held(body, frames), held(limb, frames)]) |
| 42 | + |
| 43 | + |
| 44 | +def moveset(**poses: tuple[float, float]) -> dict[int, Clip]: |
| 45 | + """Clip i holds the body at x degrees and the limb at y degrees about x.""" |
| 46 | + return {int(k[1:]): clip([turn(0, b)], [turn(0, ll)]) for k, (b, ll) in poses.items()} |
| 47 | + |
| 48 | + |
| 49 | +def test_a_clip_fits_itself_best_and_a_rotated_one_worse(): |
| 50 | + e = continuity.ends(moveset(c1=(0, 0), c2=(0, 0), c3=(0, 90), c4=(90, 90)), skeleton()) |
| 51 | + got = dict(continuity.fits_after(e, 1)) |
| 52 | + assert got[2] == pytest.approx(0.0) and got[3] == pytest.approx(45.0) |
| 53 | + assert got[4] == pytest.approx(90.0), "both joints turn 90 degrees" |
| 54 | + assert [i for i, _ in continuity.fits_after(e, 1)] == [2, 3, 4] |
| 55 | + assert continuity.fits_after(e, 1, top=1) == [(2, pytest.approx(0.0, abs=1e-6))] |
| 56 | + |
| 57 | + |
| 58 | +def test_equal_scores_go_nearest_first(): |
| 59 | + e = continuity.ends(moveset(c1=(0, 0), c9=(0, 0), c3=(0, 0), c2=(0, 0)), skeleton()) |
| 60 | + assert [i for i, _ in continuity.fits_after(e, 1)] == [2, 3, 9] |
| 61 | + |
| 62 | + |
| 63 | +def test_the_body_heading_is_not_a_difference(): |
| 64 | + still = clip([turn(0, 30)], [turn(0, 10)]) |
| 65 | + faced = clip([turn(0, 30), turn(1, 70)], [turn(0, 10)]) # the same pose, turned about y |
| 66 | + e = continuity.ends({1: still, 2: faced}, skeleton()) |
| 67 | + assert dict(continuity.fits_after(e, 1))[2] < 1.0 |
| 68 | + |
| 69 | + |
| 70 | +def test_the_end_poses_are_the_clips_ends(): |
| 71 | + start = Track([Channel(ROT[0], [Keyframe(0, 0), Keyframe(quantize("rot", math.pi / 2), 8)])]) |
| 72 | + swing = Clip([Track(), Track(), start, Track()]) |
| 73 | + rest = clip([turn(0, 0)], []) |
| 74 | + e = continuity.ends({1: swing, 2: rest, 3: clip([turn(0, 90)], [])}, skeleton()) |
| 75 | + after_swing, before_swing = dict(continuity.fits_after(e, 1)), dict(continuity.fits_after(e, 2)) |
| 76 | + assert after_swing[3] == pytest.approx(0.0, abs=0.1), "the swing ends where 3 begins" |
| 77 | + assert before_swing[1] == pytest.approx(0.0), "and begins where the rest ends" |
| 78 | + |
| 79 | + |
| 80 | +def test_words(): |
| 81 | + assert [continuity.word(x) for x in (0.0, 10.0, 15.0, 20.0, 31.0)] == [ |
| 82 | + "good", |
| 83 | + "good", |
| 84 | + "fair", |
| 85 | + "fair", |
| 86 | + "poor", |
| 87 | + ] |
| 88 | + |
| 89 | + |
| 90 | +def test_neighbours(): |
| 91 | + ids = [101, 103, 104, 200, 201, 230, 231, 232, 5] |
| 92 | + assert continuity.neighbours(231, ids) == [230, 232, 201, 200, 104, 103, 101, 5] |
| 93 | + assert continuity.neighbours(103, [103, 101, 104, 5]) == [104, 101, 5] |
| 94 | + |
| 95 | + |
| 96 | +# ---- the Zinogre's donor (MHP3RD_DATA) ---- |
| 97 | + |
| 98 | + |
| 99 | +@pytest.fixture(scope="module") |
| 100 | +def zinogre(data: Data) -> continuity.Ends: |
| 101 | + return continuity.donor_ends(manifest.load(PORTS / "zinogre.toml"), data) |
| 102 | + |
| 103 | + |
| 104 | +def rank(e: continuity.Ends, a: int, b: int) -> int: |
| 105 | + return [i for i, _ in continuity.fits_after(e, a)].index(b) + 1 |
| 106 | + |
| 107 | + |
| 108 | +@pytest.mark.parametrize("a, b", [(218, 219), (219, 220), (221, 222), (222, 223)]) |
| 109 | +def test_the_backflip_parts_chain(zinogre, a, b): |
| 110 | + assert rank(zinogre, a, b) <= 3 |
| 111 | + |
| 112 | + |
| 113 | +def test_the_flinch_roll_parts_chain_crossed(zinogre): |
| 114 | + """The donor's part 2s are crossed with its part 1s: 103 (_left_) ends where 108 (_right_) |
| 115 | + begins, 4 degrees off, and 107 is 42 off.""" |
| 116 | + names = {103: "flinch_roll_left_part_1", 108: "flinch_roll_right_part_2"} |
| 117 | + assert rank(zinogre, 103, 108) <= 3 and rank(zinogre, 104, 107) <= 3, names |
| 118 | + assert dict(continuity.fits_after(zinogre, 103))[108] < continuity.GOOD |
| 119 | + |
| 120 | + |
| 121 | +def test_the_whole_moveset_ranks_fast(zinogre): |
| 122 | + import time |
| 123 | + |
| 124 | + t = time.perf_counter() |
| 125 | + for a in zinogre.ids: |
| 126 | + continuity.fits_after(zinogre, a) |
| 127 | + assert time.perf_counter() - t < 1.0 |
0 commit comments