Files
AIGame/Tools/tests/test_enemy_character_pipeline.py
udxandClaude Opus 5.5 89e0fa246f Add formal enemies, remaining formal partners and synthesized UI SFX
- 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>
2026-10-21 12:23:55 +08:00

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()