123 lines
9.3 KiB
Python
123 lines
9.3 KiB
Python
"""Validate proposed battle skill contracts without touching the running game."""
|
|
from __future__ import annotations
|
|
import argparse
|
|
import hashlib
|
|
from decimal import Decimal,ROUND_CEILING
|
|
import json
|
|
from pathlib import Path,PurePosixPath
|
|
|
|
ROOT=Path(__file__).resolve().parents[1]
|
|
EVENTS={'CastStarted','CastReleased','CastEnded','CastCancelled','ProjectileSpawned',
|
|
'ProjectileExpired','HitResolved','StatusApplied','StatusRemoved','UnitDied','BattleEnded'}
|
|
|
|
def require(condition,message):
|
|
if not condition:raise ValueError(message)
|
|
|
|
def index(items,label):
|
|
result={item['id']:item for item in items}
|
|
require(len(result)==len(items),f'Duplicate {label} id')
|
|
return result
|
|
|
|
def ticks(seconds,tick_ms):
|
|
return int((Decimal(str(seconds))*1000/Decimal(tick_ms)).to_integral_value(rounding=ROUND_CEILING))
|
|
|
|
def validate(plan,view,sound,gameplay,root=ROOT):
|
|
for item in (plan,view):
|
|
require(item['schemaVersion']==1 and item['status']=='design_only' and item['runtimeEnabled'] is False,
|
|
'Design examples must stay explicitly disabled until runtime integration')
|
|
require(plan['source']['path']=='Config/Gameplay/greenfield.json','Keep one gameplay value source')
|
|
require(plan['clock']['tickMs']==50 and plan['projectile']['collisionAuthority']=='simulation','Invalid simulation contract')
|
|
require(plan['scheduling']['priorities']['ability']>plan['scheduling']['priorities']['basic'],'Ability must win arbitration')
|
|
templates=index(plan['templates'],'template');profiles=index(view['profiles'],'profile');assets=index(view['assets'],'asset')
|
|
for template in templates.values():
|
|
actions=index(template['actions'],'logical action')
|
|
require(bool(actions),'A skill needs at least one logical action')
|
|
for action in actions.values():
|
|
require(action['trigger']=='delivery_hit' and action['type'] in ('damage','heal','apply_status'),'Unsupported logical action')
|
|
if action['type']=='damage':require(action.get('amount')=='attack_scaled' and action.get('scale') in ('one','ability_value'),'Damage parameters missing')
|
|
elif action['type']=='heal':require(action.get('amount')=='ability_value','Heal parameters missing')
|
|
else:
|
|
require(action.get('statusId') in {s['id'] for s in plan['statuses']},'Unresolved status')
|
|
require(action.get('value')=='ability_value' and action.get('duration')=='ability_duration','Status parameters missing')
|
|
skills=index(plan['skills'],'skill');anchors=set(view['anchors'])
|
|
require(view['anchorPolicy']['localForward']=='+Z' and view['anchorPolicy']['worldFacing']=='source_to_target',
|
|
'Local model forward must be +Z and world facing must aim at target')
|
|
expected={f"pet.{p['id']}.{slot}" for p in gameplay['pets'] for slot in ('basic','ability')}
|
|
expected|={f"enemy.{e['id']}.basic" for e in gameplay['enemies']}
|
|
require(set(skills)==expected,'Skill coverage must include every pet basic/ability and enemy basic')
|
|
for asset in assets.values():
|
|
path=PurePosixPath(asset['path'])
|
|
require(asset['path'].startswith('Assets/Bundles/') and '..' not in path.parts and '\\' not in asset['path'],'Unsafe asset path')
|
|
require(asset['type'] in ('animation','vfx','sfx') and asset['status'] in ('existing','planned'),'Invalid asset readiness/type')
|
|
if asset['status']=='existing':require((root/'AIClient'/str(path)).is_file(),'Asset marked existing is missing: '+asset['id'])
|
|
for profile in profiles.values():
|
|
require(set(profile['bindings'])<=EVENTS,'Unknown presentation event')
|
|
require(profile['seekReplaysOneShots'] is False,'Seek must not replay historical one-shots')
|
|
require(all(c.get('when')=='valid_hit' for c in profile['bindings'].get('HitResolved',[])),'Invalid hits must not play hit cues')
|
|
for cues in profile['bindings'].values():
|
|
for cue in cues:
|
|
if cue['kind']=='stop_token':require(bool(cue['token']),'Missing owned token');continue
|
|
require(cue['kind'] in ('animation','vfx','sfx'),'Unknown cue kind')
|
|
require(cue['asset'] in assets,'Unresolved presentation asset: '+cue['asset'])
|
|
require(assets[cue['asset']]['type']==cue['kind'] and cue['anchor'] in anchors,'Cue type/anchor mismatch')
|
|
if cue['kind']=='animation':require(cue['rootMotion'] is False,'Animation must not move logical actor')
|
|
for skill in skills.values():
|
|
kind=skill['actorKind'];actor_id=skill['actorId'];slot=skill['slot']
|
|
require(kind in ('pet','enemy') and slot in ('basic','ability'),'Unknown actor/slot')
|
|
actors=gameplay['pets' if kind=='pet' else 'enemies'];actor=next((a for a in actors if a['id']==actor_id),None)
|
|
require(actor is not None,'Unknown actor')
|
|
require(skill['sourcePointer']==f"/{'pets' if kind=='pet' else 'enemies'}/{actor_id}",'Source pointer must match actor')
|
|
require(skill['template'] in templates and skill['presentation'] in profiles,'Unresolved skill reference')
|
|
t=templates[skill['template']]
|
|
expected_template=('enemy_melee' if kind=='enemy' else ('basic_ranged' if actor['range']>2 else 'basic_melee')) if slot=='basic' else 'ability_'+actor['ability']['kind']
|
|
require(t['id']==expected_template,'Skill template changes actor semantics: '+skill['id'])
|
|
require(t['delivery'] in ('instant','projectile'),'Unsupported delivery')
|
|
timing=skill.get('timing',t['timing']);release=timing['releaseTicks'];recovery=timing['recoveryTicks']
|
|
require(type(release) is int and type(recovery) is int and release>=0 and recovery>0,'Invalid action timing')
|
|
cooldown=actor['cooldown'] if slot=='basic' else actor['ability']['cooldown']
|
|
require(release+recovery<=ticks(cooldown,50),'Action occupies longer than cooldown: '+skill['id'])
|
|
for cue in profiles[skill['presentation']]['bindings'].get('CastStarted',[]):
|
|
if cue['kind']=='animation':
|
|
mark=assets[cue['asset']]['releaseNormalized']
|
|
require(abs(mark-release/(release+recovery))<=1/(release+recovery),'Animation release marker is not aligned: '+skill['id'])
|
|
require(sound['status'] in ('planned_not_generated','partially_generated','generated'),'Invalid SFX production status')
|
|
require(1<=sound['maxInflight']<=4,'TangoFlux in-flight limit exceeds guide')
|
|
require(sound['sourceFormat']==dict(container='wav',sampleRate=44100,channels=2),'TangoFlux source format mismatch')
|
|
items=index(sound['items'],'SFX')
|
|
generated=0
|
|
for item in items.values():
|
|
require(item['prompt'].isascii() and 0<len(item['prompt'])<=2000,'SFX prompt must be specific English within service limit')
|
|
require(1<=item.get('duration',sound['defaults']['duration'])<=30,'TangoFlux duration must be 1..30 seconds')
|
|
require(type(item['seed']) is int,'Reproducible SFX seed required')
|
|
production=item.get('production')
|
|
if production:
|
|
output=root/production['output'];receipt=root/production['receipt']
|
|
require(output.is_file() and receipt.is_file(),'Generated SFX output/receipt missing')
|
|
require(hashlib.sha256(output.read_bytes()).hexdigest()==production['sha256'],'Generated SFX hash mismatch')
|
|
rows=json.loads(receipt.read_text(encoding='utf-8'))['items']
|
|
require(any(r['id']==item['id'] and r['outputSha256']==production['sha256'] and r['job']['status']=='succeeded' for r in rows),'Generated SFX receipt mismatch')
|
|
generated+=1
|
|
expected='planned_not_generated' if generated==0 else 'generated' if generated==len(items) else 'partially_generated'
|
|
require(sound['status']==expected,'SFX production status does not match receipts')
|
|
for asset in assets.values():
|
|
if asset['type']=='sfx':require(PurePosixPath(asset['path']).stem in items,'SFX production recipe missing: '+asset['id'])
|
|
return dict(status='design_contract_validated',runtimeIntegrated=False,generatedAudio=generated==len(items),generatedSfxCount=generated,
|
|
skills=len(skills),pets=len(gameplay['pets']),enemies=len(gameplay['enemies']),stages=len(gameplay['stages']),
|
|
presentationProfiles=len(profiles),existingAssets=sum(a['status']=='existing' for a in assets.values()),
|
|
plannedAssets=sum(a['status']=='planned' for a in assets.values()),sfxRecipes=len(items),
|
|
note='Design proposal validation only; formal runtime integration is validated separately by validate_skill_runtime.py')
|
|
|
|
def load(path):return json.loads(path.read_text(encoding='utf-8-sig'))
|
|
|
|
def main():
|
|
parser=argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--report',type=Path,default=Path('Tools/output/skill_system_design/validation.json'))
|
|
args=parser.parse_args()
|
|
result=validate(load(ROOT/'Config/Combat/skill_system_v1.proposal.json'),load(ROOT/'Config/Combat/skill_presentations_v1.proposal.json'),
|
|
load(ROOT/'Doc/Design/Sound/BattleSkills/manifest.json'),load(ROOT/'Config/Gameplay/greenfield.json'))
|
|
target=args.report.resolve();require(target.is_relative_to(ROOT),'Report must stay in repository')
|
|
target.parent.mkdir(parents=True,exist_ok=True);target.write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
|
|
print(json.dumps(result,ensure_ascii=False))
|
|
|
|
if __name__=='__main__':main()
|