Wait for both convoy cargo phases and use actual magazine stock. Resolve empty primary weapon ammunition from muzzle metadata and recover cleanly from failed initial settings saves. Update native fixtures, scoped cleanup guards, packaging evidence and validation documentation. Make reviewed fixture tests checkout-relative. All repository checks pass against the isolated staged tree; native resupply completion and multiplayer qualification remain outstanding.
143 lines
7.6 KiB
Python
143 lines
7.6 KiB
Python
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(keepWeaponOnUnconscious=1,reportIntervalSeconds=20,supportSquadLimit=2,responseRadiusMetres=1500,aiIntelChancePercent=0,intelRevealChancePercent=100,radioDefenceSeconds=180,startTimeMinutes=840,timeMultiplier=1,startDateCode=0,weatherProfile=0,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 <cassert>\n#include <string>\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('foreach (', 'for (')
|
|
.replace('JsonSaveContext json = new JsonSaveContext();', 'JsonSaveContext json;')
|
|
.replace('map<string, int> ', 'Values ').replace('map<std::string, int> ', 'Values '))
|
|
run(r'''
|
|
#include <cassert>
|
|
#include <map>
|
|
#include <string>
|
|
#include <vector>
|
|
using string = std::string;
|
|
struct Values {
|
|
std::map<string,int> data;
|
|
int Get(string key){return data.at(key);}
|
|
void Remove(string key){data.erase(key);}
|
|
};
|
|
struct File {bool valid=true;Values values;};
|
|
std::map<string,File> 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;}
|
|
};
|
|
struct Settings {
|
|
bool m_ReadOnly=false;
|
|
string m_Path="settings";
|
|
Values m_Values{{{"radio",180},{"limit",4}}},m_Resolved{{{"radio",60}}};
|
|
std::vector<string> 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')
|