Capture current mission, AI, map and administration development
Include the current addon, terrain integrations, tools, regression tests and recorded development evidence.
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Export a terrain scenario using Escape's universal TerrainConnector prefab.
|
||||
Copy the exported Missions/ and Worlds/ into the terrain author's addon and add
|
||||
Escape as a dependency. --standalone creates a separate addon instead. Neither
|
||||
mode modifies the terrain source or generates navigation.
|
||||
"""
|
||||
import argparse, hashlib, json, math, re
|
||||
from pathlib import Path
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
CORE='2528466DE220D91C'; BASE='58D0FB3206B6F859'
|
||||
CONNECTOR='{8FB1559AE3F29722}Prefabs/ERR/TerrainConnector.et'
|
||||
PROJECTS=('Soldiers','BTRlike','LowRes')
|
||||
RESOURCE=re.compile(r'\{([A-F0-9]{16})\}([A-Za-z0-9_./-]+)')
|
||||
def guid(label):return hashlib.sha256(('ERR_TERRAIN_V1:'+label).encode()).hexdigest()[:16].upper()
|
||||
def resource(value,suffix):
|
||||
m=RESOURCE.fullmatch(value) if isinstance(value,str) else None
|
||||
if not m or any(part in ('', '.', '..') for part in m[2].split('/')) or not m[2].endswith(suffix):raise ValueError('Invalid resource: '+str(value))
|
||||
return value
|
||||
|
||||
def validate(profile):
|
||||
if not isinstance(profile,dict):raise ValueError('Terrain profile must be an object')
|
||||
if set(profile)-{'id','title','addon','world','navigation','ai_world','inherit_ai_world','airfields','notes'}:raise ValueError('Unknown terrain profile fields')
|
||||
if not re.fullmatch(r'[a-z][a-z0-9_]{0,39}',profile.get('id','')):raise ValueError('Invalid terrain id')
|
||||
if not re.fullmatch(r'[A-Za-z0-9 _-]{1,48}',profile.get('title','')):raise ValueError('Invalid terrain title')
|
||||
if not re.fullmatch('[A-F0-9]{16}',profile.get('addon','')):raise ValueError('Invalid terrain addon GUID')
|
||||
resource(profile.get('world'),'.ent')
|
||||
nav=profile.get('navigation')
|
||||
if not isinstance(nav,dict) or set(nav)-set(PROJECTS):raise ValueError('Unknown navigation project')
|
||||
for files in nav.values():
|
||||
if not isinstance(files,list) or not len(files)==1:raise ValueError('Navigation needs one .nmn file or native .conf per project')
|
||||
for f in files:resource(f,('.nmn','.conf'))
|
||||
if 'ai_world' in profile:
|
||||
resource(profile['ai_world'],'.et')
|
||||
if nav:raise ValueError('Use a terrain AI world prefab OR navigation resources, not both')
|
||||
if type(profile.get('inherit_ai_world',False)) is not bool:raise ValueError('inherit_ai_world must be a boolean')
|
||||
if profile.get('inherit_ai_world') and (nav or profile.get('ai_world')):raise ValueError('Inherited terrain AI world must not be duplicated by adapter navigation')
|
||||
anchors=profile.get('airfields',[])
|
||||
if not isinstance(anchors,list) or len(anchors)>16:raise ValueError('At most 16 airfield centres are supported')
|
||||
for anchor in anchors:
|
||||
if not isinstance(anchor,list) or len(anchor)!=3 or any(type(v) not in (int,float) or not math.isfinite(v) or abs(v)>1000000 for v in anchor):raise ValueError('Airfield centres must be finite [x,y,z] coordinates')
|
||||
return profile
|
||||
|
||||
def meta(path,identity,kind='ResourceClass'):
|
||||
return f'MetaFileClass {{\n Name "{{{identity}}}{path}"\n Configurations {{\n {kind} PC {{}}\n {kind} HEADLESS : PC {{}}\n }}\n}}\n'
|
||||
|
||||
def files(profile, standalone=False):
|
||||
validate(profile);name=profile['id'];title=profile['title'];identity=guid(name+':addon')
|
||||
world=f'Worlds/ERR_{name}.ent';mission=f'Missions/ERR_{name}.conf'
|
||||
nav=profile['navigation'];ready=profile.get('inherit_ai_world',False) or bool(profile.get('ai_world')) or all(p in nav for p in PROJECTS)
|
||||
deps=list(dict.fromkeys([BASE,CORE,profile['addon']]))
|
||||
result={}
|
||||
if standalone:
|
||||
result['addon.gproj']='GameProject {\n ID "ERR_Terrain_'+name+'"\n GUID "'+identity+'"\n TITLE "Escape - '+title+'"\n Dependencies {\n'+''.join(' "'+d+'"\n' for d in deps)+' }\n}\n'
|
||||
result[world]='SubScene {\n Parent "'+profile['world']+'"\n}\n'
|
||||
result[world+'.meta']=meta(world,guid(name+':world'),'ENTResourceClass')
|
||||
result[mission]=f'SCR_MissionHeader {{\n World "{{{guid(name+":world")}}}{world}"\n SystemsConfig "{{C50579A2EC48E8EF}}Configs/Systems/MissionSystems.conf"\n m_sName "Co10 Escape Reintroduced - {title}"\n m_sAuthor "Escape Reintroduced"\n m_sDescription "Escape captivity, gather intelligence, find the radio and flee by helicopter."\n m_sGameMode "Escape"\n m_iPlayerCount 12\n m_eSaveTypes 0\n m_bShowInScenarioMenu {int(ready)}\n}}\n'
|
||||
result[mission+'.meta']=meta(mission,guid(name+':mission'))
|
||||
# All maps reference the same prefab. Only navigation and airport hints vary.
|
||||
layer='ERR_GameMode Reintroduced : "'+CONNECTOR+'" {}\n'
|
||||
ai_world=profile.get('ai_world','{E0A05C76552E7F58}Prefabs/AI/SCR_AIWorld.et')
|
||||
ai='SCR_AIWorld AIWorld : "'+ai_world+'" {\n'
|
||||
if nav:
|
||||
ai+=' components {\n'
|
||||
components=['5584F30E67F617AD','5584F30EEFEE1223','5C8C9B750D124A63']
|
||||
settings=['NavmeshWorld','ChimeraNavmeshWorld','NavmeshWorld']
|
||||
settings_ids=['50FC63BEBE3973C5','50FC63BEBE3973C5','5C8C9B750B60C6E2']
|
||||
config_ids=['60CA9BE5536BF701','60CA9BE5536BF701','5C90BD0EC793647D']
|
||||
for i,project in enumerate(PROJECTS):
|
||||
if project not in nav:continue
|
||||
navfile=nav[project][0]
|
||||
config=(f': "{navfile}" {{}}' if navfile.endswith('.conf') else f'{{\n NavmeshFile "{navfile}"\n }}')
|
||||
ai+=f' NavmeshWorldComponent "{{{components[i]}}}" {{\n NavmeshSettings {settings[i]} "{{{settings_ids[i]}}}" {{\n NavmeshFilesConfig BaseNavmeshFilesConfig "{{{config_ids[i]}}}" {config}\n }}\n }}\n'
|
||||
ai+=' }\n'
|
||||
ai+='}\n'
|
||||
if profile.get('inherit_ai_world'):ai=''
|
||||
layer+=ai
|
||||
for index,anchor in enumerate(profile.get('airfields',[])):
|
||||
coords=' '.join(format(v,'.9g') for v in anchor)
|
||||
layer+=f'GenericEntity ERR_Airfield_{index} {{\n coords {coords}\n}}\n'
|
||||
result[f'Worlds/ERR_{name}_Layers/default.layer']=layer
|
||||
result['terrain-adapter.json']=json.dumps({'schema':2,'profile':profile,'mode':'standalone' if standalone else 'embedded','addon':identity if standalone else profile['addon'],'required_dependencies':deps if standalone else [BASE,CORE],'connector':CONNECTOR,'scenarioId':'{'+guid(name+':mission')+'}'+mission,'world':world,'navigation_configured':ready,'qualification':'unverified'},indent=2)+'\n'
|
||||
return result
|
||||
|
||||
def generate(profile,destination,standalone=False):
|
||||
rendered=files(profile,standalone=standalone);destination=Path(destination)
|
||||
if destination.exists():raise ValueError('Output already exists; use a new destination to preserve authored/navigation data')
|
||||
destination.mkdir(parents=True)
|
||||
for name,content in rendered.items():
|
||||
path=destination/name;path.parent.mkdir(parents=True,exist_ok=True);path.write_text(content)
|
||||
return json.loads(rendered['terrain-adapter.json'])
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__);p.add_argument('profile',type=Path);p.add_argument('--output',type=Path,required=True)
|
||||
p.add_argument('--standalone',action='store_true',help='Create a separate addon project instead of files to include in the terrain addon')
|
||||
a=p.parse_args()
|
||||
try:report=generate(json.loads(a.profile.read_text()),a.output,standalone=a.standalone)
|
||||
except (OSError,ValueError) as e:p.error(str(e))
|
||||
print(json.dumps(report,indent=2))
|
||||
if not a.standalone:print('Copy Missions/ and Worlds/ into the terrain addon, add Escape '+CORE+' to its project dependencies, then publish that terrain. Do not also load a standalone adapter with the same scenario resources.')
|
||||
if not report['navigation_configured']:print('Navigation references missing: supply the published terrain addon’s AI-world prefab or its Soldiers/BTRlike/LowRes resources before running Escape.')
|
||||
if __name__=='__main__':main()
|
||||
Reference in New Issue
Block a user