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,149 @@
|
||||
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(startMode=0,keepWeaponOnUnconscious=1,reportIntervalSeconds=20,supportSquadLimit=2,responseRadiusMetres=1500,aiIntelChancePercent=0,intelRevealChancePercent=100,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 <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('ERR_ProfileConfig.', 'ERR_ProfileConfig::').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);}
|
||||
void Set(string key,int value){data[key]=value;}
|
||||
};
|
||||
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;}
|
||||
};
|
||||
// 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<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')
|
||||
Reference in New Issue
Block a user