Files
jakub 6a667d8deb Capture current mission, AI, map and administration development
Include the current addon, terrain integrations, tools, regression tests and recorded development evidence.
2026-10-13 22:52:53 +02:00

102 lines
7.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()