- UI SFX v3: procedurally synthesized one-shots (Tools/synth_ui_sfx.py) wired to buttons, rewards, errors and red dots via UiSfx, gated by the SFX setting. - Five formal enemies (Hornlet, Dashlet, Shellhorn, HornCaptain, GreatHorn): Hunyuan 50k -> 2000-tri XPbr, DGXA TokenRig, Idle/Walk/Attack, nested per type in PrototypeArena; PrototypeBattle.Enemy gains presentation-only Moving/Strikes. - Formal partners for all ten pets in the arena and portraits, including new VineSprite, ThunderLeopard and TideWhale. - Pipeline fixes: reject local TokenRig fallback, drop loose mesh islands, configurable Unity run_script timeout, companion static stage. - Also includes concurrent work from another tool in this working tree (FlameLion, enemy combat FX, challenge mode, home explorer, server updates). - Resources registered in Doc/ResDesc_*.md; verified on isolated :18081. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
149 lines
10 KiB
Python
149 lines
10 KiB
Python
"""Focused guards: anatomy/phase validation and atomic generation promotion."""
|
|
import copy
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import sys
|
|
import tempfile
|
|
import types
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
REPO=Path(__file__).resolve().parents[2]
|
|
sys.path.insert(0,str(REPO/'Tools'))
|
|
import enemy_character_pipeline as pipeline
|
|
import generate_animal_walk as generator
|
|
|
|
class EnemyProductionTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.data=json.loads((REPO/'Config/Art/EnemyCharacters/RockBall.json').read_text(encoding='utf-8'))
|
|
|
|
def test_recipe_rejects_wrong_anatomy_and_phase_order(self):
|
|
with mock.patch.object(Path,'read_text',return_value=json.dumps(self.data)):
|
|
self.assertEqual(pipeline.recipe(Path('fixture'))['id'],'RockBall')
|
|
for change in ({'profile':'quadruped_unvalidated'},{'attackImpact':.1},{'walkStride':10}):
|
|
data={**self.data,**change}
|
|
with mock.patch.object(Path,'read_text',return_value=json.dumps(data)),self.assertRaises(ValueError):
|
|
pipeline.recipe(Path('fixture'))
|
|
|
|
def test_generation_failure_preserves_previous_artifacts_and_receipt(self):
|
|
self.run_generation(False)
|
|
|
|
def test_companion_role_and_legacy_enemy_recipe(self):
|
|
with mock.patch.object(Path,'read_text',return_value=json.dumps(self.data)):
|
|
legacy=pipeline.recipe(Path('fixture'))
|
|
self.assertEqual(legacy['visualPrefab'],self.data['enemyPrefab'])
|
|
self.assertEqual(legacy['attackEvent'],'OnEnemyAttackImpact')
|
|
companion={**self.data,'usage':'companion_visual','attackStyle':'ranged_cast',
|
|
'visualPrefab':'Assets/Bundles/Characters/Animal/FireCat/FireCat_Companion.prefab'}
|
|
companion.pop('enemyPrefab')
|
|
with mock.patch.object(Path,'read_text',return_value=json.dumps(companion)):
|
|
result=pipeline.recipe(Path('fixture'))
|
|
self.assertEqual(result['attackEvent'],'OnCompanionAttackImpact')
|
|
for change in ({'usage':'unknown'},{'boneMappingReady':False},{'attackStyle':'unknown'},
|
|
{'visualPrefab':'../outside.prefab'},{'attackReach':20}):
|
|
with mock.patch.object(Path,'read_text',return_value=json.dumps({**companion,**change})),self.assertRaises(ValueError):
|
|
pipeline.recipe(Path('fixture'))
|
|
|
|
def test_generation_success_commits_matching_receipt_last(self):
|
|
self.run_generation(True)
|
|
|
|
def test_flame_roar_requires_own_head_paws_and_mane_region(self):
|
|
lion={**self.data,'profile':'biped_short','attackStyle':'flame_roar','boneMappingReady':True,
|
|
'rootBone':'bone_0','chestBone':'bone_1','neckBone':'bone_2','headBone':'bone_3',
|
|
'rightArm':'bone_4','leftArm':'bone_6','rightPawTip':'bone_5','leftPawTip':'bone_7',
|
|
'rightFoot':'bone_8','leftFoot':'bone_10','rightFootTip':'bone_9','leftFootTip':'bone_11',
|
|
'faceRight':'bone_8','faceLeft':'bone_10','maneRegionMin':[-.5,.5,-.3],
|
|
'maneRegionMax':[.5,1,.3],'maneRigidityTolerance':.001,'attackHeadReach':.015,
|
|
'attackHeadPitchDegrees':8,'attackSpreadDegrees':25}
|
|
with mock.patch.object(Path,'read_text',return_value=json.dumps(lion)):
|
|
self.assertEqual(pipeline.recipe(Path('fixture'))['attackStyle'],'flame_roar')
|
|
for change in ({'headBone':''},{'headBone':'bone_1'},{'leftPawTip':'bone_5'},
|
|
{'maneRegionMin':[]},{'maneRigidityTolerance':.1},{'attackHeadPitchDegrees':40},
|
|
{'attackSpreadDegrees':0},{'profile':'quadruped_heavy'},{'boneMappingReady':False}):
|
|
with mock.patch.object(Path,'read_text',return_value=json.dumps({**lion,**change})),self.assertRaises(ValueError):
|
|
pipeline.recipe(Path('fixture'))
|
|
|
|
def test_turtle_requires_four_feet_shell_region_and_bind_up(self):
|
|
turtle={**self.data,'profile':'quadruped_turtle','attackStyle':'turtle_bubble','boneMappingReady':True,
|
|
'rootBone':'bone_0','shellBone':'bone_0','neckBone':'bone_1','headBone':'bone_2',
|
|
'faceRight':'bone_4','faceLeft':'bone_6','frontRightFoot':'bone_4','frontLeftFoot':'bone_6',
|
|
'hindRightFoot':'bone_8','hindLeftFoot':'bone_10','frontRightFootTip':'bone_5','frontLeftFootTip':'bone_7',
|
|
'hindRightFootTip':'bone_9','hindLeftFootTip':'bone_11','rootTiltReference':'bind_up','walkSwingFraction':.2,
|
|
'shellRegionMin':[-1,.3,-.2],'shellRegionMax':[1,1,1],'shellRigidityTolerance':.001,'attackHeadReach':.015}
|
|
with mock.patch.object(Path,'read_text',return_value=json.dumps(turtle)):
|
|
self.assertEqual(pipeline.recipe(Path('fixture'))['profile'],'quadruped_turtle')
|
|
for change in ({'hindLeftFoot':'bone_4'},{'hindLeftFootTip':'bone_5'},{'shellRegionMin':[]},
|
|
{'rootTiltReference':'bone_up'},{'walkSwingFraction':.5},{'attackStyle':'ranged_cast'},
|
|
{'headBone':''},{'boneMappingReady':False},{'shellRigidityTolerance':.1}):
|
|
with mock.patch.object(Path,'read_text',return_value=json.dumps({**turtle,**change})),self.assertRaises(ValueError):
|
|
pipeline.recipe(Path('fixture'))
|
|
|
|
def test_heavy_bash_rejects_turtle_style_missing_rigid_regions_and_shared_head(self):
|
|
heavy={**self.data,'profile':'quadruped_heavy','attackStyle':'braced_bash','boneMappingReady':True,
|
|
'rootBone':'bone_0','chestBone':'bone_1','neckBone':'bone_2','headBone':'bone_3',
|
|
'faceRight':'bone_5','faceLeft':'bone_7','frontRightFoot':'bone_5','frontLeftFoot':'bone_7',
|
|
'hindRightFoot':'bone_9','hindLeftFoot':'bone_11','frontRightFootTip':'bone_6','frontLeftFootTip':'bone_8',
|
|
'hindRightFootTip':'bone_10','hindLeftFootTip':'bone_12','rootTiltReference':'bind_up','walkSwingFraction':.2,
|
|
'armorRegionMin':[-.3,.3,-.1],'armorRegionMax':[.3,.7,.5],'armorRigidityTolerance':.001,
|
|
'hornRegionMin':[-.1,.7,-.6],'hornRegionMax':[.1,1,-.4],'hornRigidityTolerance':.001,
|
|
'attackHeadReach':.015,'attackLunge':.025,'attackHeadPitchDegrees':10}
|
|
with mock.patch.object(Path,'read_text',return_value=json.dumps(heavy)):
|
|
self.assertEqual(pipeline.recipe(Path('fixture'))['profile'],'quadruped_heavy')
|
|
for change in ({'attackStyle':'turtle_bubble'},{'hornRegionMin':[]},{'armorRigidityTolerance':.1},
|
|
{'headBone':'bone_1'},{'hindLeftFoot':'bone_5'},{'attackLunge':.5},
|
|
{'attackHeadPitchDegrees':0},{'boneMappingReady':False}):
|
|
with mock.patch.object(Path,'read_text',return_value=json.dumps({**heavy,**change})),self.assertRaises(ValueError):
|
|
pipeline.recipe(Path('fixture'))
|
|
|
|
def test_bird_requires_own_two_wing_mapping_and_matching_style(self):
|
|
bird={**self.data,'profile':'avian_grounded','attackStyle':'wing_cast',
|
|
'boneMappingReady':True,'rootBone':'bone_0','chestBone':'bone_1',
|
|
'rightArm':'bone_2','leftArm':'bone_4','rightWingTip':'bone_3','leftWingTip':'bone_5',
|
|
'rightFoot':'bone_6','leftFoot':'bone_8','rightFootTip':'bone_7','leftFootTip':'bone_9',
|
|
'faceRight':'bone_6','faceLeft':'bone_8','wingSpreadDegrees':25}
|
|
with mock.patch.object(Path,'read_text',return_value=json.dumps(bird)):
|
|
self.assertEqual(pipeline.recipe(Path('fixture'))['profile'],'avian_grounded')
|
|
for change in ({'rightWingTip':''},{'leftArm':'bone_2'},{'leftFootTip':'bone_7'},
|
|
{'attackStyle':'ranged_cast'},{'wingSpreadDegrees':90},{'boneMappingReady':False}):
|
|
with mock.patch.object(Path,'read_text',return_value=json.dumps({**bird,**change})),self.assertRaises(ValueError):
|
|
pipeline.recipe(Path('fixture'))
|
|
with mock.patch.object(Path,'read_text',return_value=json.dumps({**self.data,'attackStyle':'wing_cast'})),self.assertRaises(ValueError):
|
|
pipeline.recipe(Path('fixture'))
|
|
|
|
def run_generation(self,success):
|
|
root=REPO/'Tools/output/enemy_pipeline_tests'
|
|
root.mkdir(exist_ok=True)
|
|
with tempfile.TemporaryDirectory(dir=root) as temporary:
|
|
base=Path(temporary).resolve()
|
|
self.assertTrue(base.is_relative_to(root.resolve()))
|
|
out=base/'motion';out.mkdir()
|
|
rig=base/'rig_fixture.dat';rig.write_text('same-rig-fixture')
|
|
(out/'animated.fbx').write_text('previous-fixture')
|
|
(out/'motion_request.json').write_text('{"status":"previous"}')
|
|
runner=types.ModuleType('backend.unimate_runner')
|
|
runner._cfg=lambda: {'unimate_jobs_dir':'/fixture'}
|
|
runner._scp_to=lambda *a,**k: None
|
|
runner._ssh=lambda *a,**k: None
|
|
def submit(**kwargs):
|
|
if not success:raise RuntimeError('fixture inference failure')
|
|
(kwargs['directory']/'animated.fbx').write_text('new-fixture')
|
|
return {'remote_job':'fixture-only-no-network'}
|
|
runner.submit_unimate_job=submit
|
|
backend=types.ModuleType('backend');backend.__path__=[];backend.unimate_runner=runner
|
|
argv=['generate_animal_walk','--rig',str(rig),'--out',str(out),'--motion','Attack','--face-right','right','--face-left','left']
|
|
with mock.patch.dict(sys.modules,{'backend':backend,'backend.unimate_runner':runner}),mock.patch.object(sys,'argv',argv):
|
|
if success:generator.main()
|
|
else:
|
|
with self.assertRaises(RuntimeError):generator.main()
|
|
record=json.loads((out/'motion_request.json').read_text())
|
|
if success:
|
|
self.assertEqual((out/'animated.fbx').read_text(),'new-fixture')
|
|
self.assertEqual(record['status'],'generated')
|
|
self.assertEqual(record['source_fbx_sha256'],hashlib.sha256((out/'animated.fbx').read_bytes()).hexdigest())
|
|
else:
|
|
self.assertEqual((out/'animated.fbx').read_text(),'previous-fixture')
|
|
self.assertEqual(record,{'status':'previous'})
|
|
|
|
if __name__=='__main__':unittest.main()
|