from pathlib import Path import importlib.util,json,tempfile,os,subprocess,sys BASE=Path(os.environ.get("ERR_CONVOY_SETTINGS_ROOT", Path(__file__).resolve().parents[1])) from policy_harness import ROOT,body,run spec=importlib.util.spec_from_file_location('import_settings',BASE/'tools/import_settings.py') m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) result,deferred=m.merge({'configVersion':1,'reportIntervalSeconds':20.0,'supportSquadLimit':2,'responseRadiusMetres':1500,'convoyCount':3},{'aiIntelChancePercent':0,'intelRevealChancePercent':100}) assert result==dict(droneReconCount=0,droneCooldownSeconds=300,aiDoctrine=0,aiShadow=0,planeAltitudeMetres=250,planeReplacementSeconds=60,planeSectorSeconds=120,planeReconEnabled=1,roadblockCount=4,civilianTrafficCount=3,startMode=0,keepWeaponOnUnconscious=1,selfEpinephrine=0,restartOnEmpty=1,emptyRestartHours=5,reportIntervalSeconds=20,supportSquadLimit=2,responseRadiusMetres=1500,aiIntelChancePercent=0,intelRevealChancePercent=100,showAllSites=0,radioDefenceSeconds=180,startTimeMinutes=840,timeMultiplier=1,startDateCode=0,weatherProfile=0,viewDistanceMetres=0,automaticEquipmentChancePercent=25,antiAirChancePercent=7,antiPersonnelMineChancePercent=60,automaticConvoyEnabled=1,convoyCount=3,heliTimerMinutes=15) assert deferred==[] for count in (0,1,3,20): values,notes=m.merge(ai={'convoyCount':count}) assert values['automaticConvoyEnabled']==int(count>0) assert values['convoyCount']==count and not notes for invalid in (-1,21,True,False,None,'1',1.5,float('nan'),float('inf')): try:m.merge(ai={'convoyCount':invalid}) except ValueError:pass else:raise AssertionError(invalid) try:m.merge(ai={'convoyCount':0,'automaticConvoyEnabled':1}) except ValueError:pass else:raise AssertionError('Ambiguous old/new settings accepted') for old,percent in [(0,0),(1,100),(15,7),(37,3),(10000,0)]: assert m.merge(ai={'stingerChanceOneIn':old})[0]['antiAirChancePercent']==percent assert m.merge(ai={'stingerChanceOneIn':15,'antiAirChancePercent':100})[0]['antiAirChancePercent']==100 for invalid in (-1,10001,True,None,'15',1.5,float('nan')): try:m.merge(ai={'stingerChanceOneIn':invalid}) except ValueError:pass else:raise AssertionError(invalid) assert m.merge(ai={'stingerChanceOneIn':0})[0]['antiAirChancePercent']==0 assert m.merge()[0]=={k:v[0] for k,v in m.SCHEMA.items()} for key,(_,lo,hi) in m.SCHEMA.items(): if key not in m.AI_KEYS and key not in m.INTEL_KEYS:continue for invalid in (lo-1,hi+1,True,False,None,'10',lo+0.5,float('nan'),float('inf')): try:m.merge(**({'ai':{key:invalid}} if key in m.AI_KEYS else {'intel':{key:invalid}})) except ValueError:pass else:raise AssertionError((key,invalid)) with tempfile.TemporaryDirectory() as tmp: dest=Path(tmp)/'rereintroduced.json' m.write_new(dest,json.dumps(result));before=dest.read_bytes() try:m.write_new(dest,'overwrite') except FileExistsError:pass else:raise AssertionError('Existing destination overwritten') assert dest.read_bytes()==before assert sorted(p.name for p in Path(tmp).iterdir())==['rereintroduced.json'] legacy=Path(tmp)/'legacy-ai.json';legacy.write_text('{"convoyCount":3}');original=legacy.read_bytes() preview=subprocess.run([sys.executable,str(BASE/'tools/import_settings.py'),'--ai',str(legacy)],capture_output=True,text=True,check=True) assert json.loads(preview.stdout)['automaticConvoyEnabled']==1 assert json.loads(preview.stdout)['convoyCount']==3 and not preview.stderr and legacy.read_bytes()==original # Test the actual runtime validator against the migration schema at each boundary. validator=body(BASE/'addons/core/Scripts/Game/ERR/00_Core/ERR_Settings.c','static bool Valid(').replace('string key','std::string key') checks=[] for key,(_,lo,hi) in m.SCHEMA.items(): for value,expected in [(lo-1,False),(lo,True),(hi,True),(hi+1,False)]: checks.append(f'assert(Valid("{key}",{value})=={str(expected).lower()});') run('#include \n#include \n'+validator+'\nint main(){'+''.join(checks)+'assert(!Valid("unknown",1));}\n') print('PASS: legacy values preserved, unsupported fields reported, invalid data rejected, existing files protected, runtime bounds aligned') # Actual settings transaction: failures during final file promotion must not # poison the next round when there was no previous settings file. source = Path(os.environ.get('ERR_SETTINGS_SOURCE', ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_Settings.c')) method = body(source, 'bool Set(') method = (method.replace('string ', 'std::string ').replace('Replication.', 'Replication::') .replace('FileIO.', 'FileIO::').replace('ERR_ProfileConfig.', 'ERR_ProfileConfig::').replace('foreach (', 'for (') .replace('JsonSaveContext json = new JsonSaveContext();', 'JsonSaveContext json;') .replace('map ', 'Values ').replace('map ', 'Values ')) run(r''' #include #include #include #include using string = std::string; struct Values { std::map data; int Get(string key){return data.at(key);} void Remove(string key){data.erase(key);} void Set(string key,int value){data[key]=value;} }; struct File {bool valid=true;Values values;}; std::map disk; int fault=0; namespace Replication {bool server=true;bool IsServer(){return server;}} namespace FileIO { bool FileExists(string path){return disk.count(path);} bool DeleteFile(string path){return disk.erase(path);} bool CopyFile(string from,string to){ if(!disk.count(from))return false; disk[to]=disk[from]; if(from=="settings.tmp"){ if(fault==1){disk[to].valid=false;return false;} // partial copy reports failure if(fault==2)disk[to].valid=false; // copy succeeds, readback fails if(fault==3)disk[to].values.data["radio"]=31; // valid but incorrect contents } return true; } } struct JsonSaveContext { File pending; void EnableTypeDiscriminator(bool value){} void WriteValue(string key,int value){pending.values.data[key]=value;} bool SaveToFile(string path){disk[path]=pending;return true;} }; // This fixture exercises the custom-path transaction; unified storage has its own fixture. const string PATH="$profile:rereintroduced.json"; namespace ERR_ProfileConfig { bool Exists(string path){return FileIO::FileExists(path);} bool Save(string,JsonSaveContext&){assert(false);return false;} } struct Settings { bool m_ReadOnly=false; string m_Path="settings"; Values m_Values{{{"radio",180},{"limit",4}}},m_Resolved{{{"radio",60}}}; std::vector Keys(){return {"radio","limit"};} bool ValidSelection(string key,int value){return (key=="radio"&&value>=30&&value<=1800)||(key=="limit"&&value>=0&&value<=8);} int Get(string key){return m_Values.Get(key);} bool Read(string path,Values& values){ if(!disk.count(path)||!disk[path].valid)return false; values=disk[path].values;return true; } bool Restart(){ Values loaded; if(Read(m_Path,loaded)||Read(m_Path+".bak",loaded))m_Values=loaded; else if(FileIO::FileExists(m_Path)||FileIO::FileExists(m_Path+".bak"))m_ReadOnly=true; return !m_ReadOnly; } '''+method+r''' }; int main(){ for(int injected : {1,2,3}){ // A failed first save must not install a partial or rejected file for restart. disk.clear();fault=injected;Settings initial; assert(!initial.Set("radio",300)); assert(initial.Get("radio")==180&&initial.m_Resolved.Get("radio")==60); assert(!FileIO::FileExists("settings")); Settings restarted;assert(restarted.Restart());assert(restarted.Get("radio")==180); fault=0;assert(restarted.Set("radio",450));assert(restarted.Get("radio")==450); Settings persisted;assert(persisted.Restart()&&persisted.Get("radio")==450); // An existing valid save is restored from the newly captured backup. disk.clear();fault=0;Settings saved;assert(saved.Set("radio",600)); fault=injected;assert(!saved.Set("radio",900));assert(saved.Get("radio")==600); Settings restored;assert(restored.Restart()&&restored.Get("radio")==600); // A preexisting fallback backup remains authoritative when primary is absent. disk.clear();disk["settings.bak"]={true,Values{{{"radio",750},{"limit",4}}}}; Settings fallback;assert(fallback.Restart()&&fallback.Get("radio")==750); assert(!fallback.Set("radio",900)); Settings recovered;assert(recovered.Restart()&&recovered.Get("radio")==750); } } ''') print('PASS: failed initial settings promotion leaves defaults writable after restart; prior/fallback saves survive copy, readback and mismatch faults')