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,20 @@
|
||||
check: /tmp/err-policy-r3sh7kea/check.cpp:93: int main(): Assertion `!Store::Save("mission",change)' failed.
|
||||
PASS: shared config transaction rejects client writes, preserves first-save/prior/fallback state across open, write, backup, partial-copy, readback and mismatch failures
|
||||
Traceback (most recent call last):
|
||||
File "/tmp/erri-config-review-1509/tests/test_profile_config.py", line 105, in <module>
|
||||
run(r"""
|
||||
~~~^^^^^
|
||||
#include <cassert>
|
||||
^^^^^^^^^^^^^^^^^^
|
||||
...<64 lines>...
|
||||
}
|
||||
^
|
||||
""")
|
||||
^^^^
|
||||
File "/home/jakub/devel/escapistsrereintroduced/tests/policy_harness.py", line 14, in run
|
||||
subprocess.run([str(p/'check')],check=True)
|
||||
~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/usr/lib/python3.14/subprocess.py", line 578, in run
|
||||
raise CalledProcessError(retcode, process.args,
|
||||
output=stdout, stderr=stderr)
|
||||
subprocess.CalledProcessError: Command '['/tmp/err-policy-r3sh7kea/check']' died with <Signals.SIGABRT: 6>.
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
// One operator-owned document. Legacy names identify sections internally only;
|
||||
// custom paths used by isolated probes keep their original standalone behavior.
|
||||
class ERR_ProfileConfig
|
||||
{
|
||||
static const string PATH = "$profile:Escapists.json";
|
||||
static const string VOICE = "$profile:EscapistsWelcomeVoice.json";
|
||||
static array<string> LegacyPaths()
|
||||
{
|
||||
return {ERR_Settings.PATH, ERR_ContentSettings.PATH, ERR_FactionSelection.PATH, ERR_TerrainPreference.PATH, ERR_ItemPreferences.PATH, VOICE};
|
||||
}
|
||||
static string Section(string path)
|
||||
{
|
||||
if (path == ERR_Settings.PATH) return "mission";
|
||||
if (path == ERR_ContentSettings.PATH) return "mods";
|
||||
if (path == ERR_FactionSelection.PATH) return "factions";
|
||||
if (path == ERR_TerrainPreference.PATH) return "terrain";
|
||||
if (path == ERR_ItemPreferences.PATH) return "items";
|
||||
if (path == VOICE) return "voice";
|
||||
return "";
|
||||
}
|
||||
static bool Text(string path, out string text)
|
||||
{
|
||||
FileHandle file = FileIO.OpenFile(path, FileMode.READ);
|
||||
if (!file) return false;
|
||||
int length = file.GetLength();
|
||||
if (length < 2 || length > 8388608) { file.Close(); return false; }
|
||||
file.Read(text, length); file.Close();
|
||||
return text.Length() == length;
|
||||
}
|
||||
// The engine validates JSON syntax. This scan only separates root members,
|
||||
// retaining unknown fields and dormant addon entries without reinterpreting them.
|
||||
static bool Split(string text, map<string, string> members)
|
||||
{
|
||||
members.Clear(); text.TrimInPlace();
|
||||
JsonLoadContext valid = new JsonLoadContext();
|
||||
if (text.Length() < 2 || text.Substring(0, 1) != "{" || !valid.LoadFromString(text)) return false;
|
||||
int depth, start = 1, colon = -1;
|
||||
bool quoted, escaped;
|
||||
string block;
|
||||
for (int i = 0; i < text.Length(); i++)
|
||||
{
|
||||
if (i % 1024 == 0) block = text.Substring(i, Math.Min(1024, text.Length() - i));
|
||||
string ch = block.Substring(i % 1024, 1);
|
||||
if (quoted)
|
||||
{
|
||||
if (escaped) escaped = false;
|
||||
else if (ch == "\\") escaped = true;
|
||||
else if (ch == "\"") quoted = false;
|
||||
continue;
|
||||
}
|
||||
if (ch == "\"") { quoted = true; continue; }
|
||||
if (ch == "{" || ch == "[") depth++;
|
||||
if (ch == "}" || ch == "]") depth--;
|
||||
if (depth == 1 && ch == ":" && colon < 0) colon = i;
|
||||
if ((depth == 1 && ch == ",") || (depth == 0 && ch == "}"))
|
||||
{
|
||||
if (colon < start)
|
||||
{
|
||||
string empty = text.Substring(start, i - start); empty.TrimInPlace();
|
||||
return members.Count() == 0 && empty.IsEmpty() && i == text.Length() - 1;
|
||||
}
|
||||
JsonLoadContext keyJson = new JsonLoadContext();
|
||||
string key;
|
||||
if (!keyJson.LoadFromString("{\"key\":" + text.Substring(start, colon - start) + "}") || !keyJson.ReadValue("key", key) || members.Contains(key)) return false;
|
||||
string value = text.Substring(colon + 1, i - colon - 1); value.TrimInPlace();
|
||||
members.Set(key, value); start = i + 1; colon = -1;
|
||||
}
|
||||
}
|
||||
return depth == 0 && !quoted;
|
||||
}
|
||||
static bool Document(string path, out map<string, string> members)
|
||||
{
|
||||
string text; members = new map<string, string>();
|
||||
if (!Text(path, text) || !Split(text, members)) return false;
|
||||
// Never downgrade or rewrite a configuration created by an unknown version.
|
||||
return members.Contains("configVersion") && members.Get("configVersion") == "1";
|
||||
}
|
||||
static bool Valid(string section, string text)
|
||||
{
|
||||
JsonLoadContext json = new JsonLoadContext();
|
||||
map<string, string> fields = new map<string, string>();
|
||||
if (!Split(text, fields) || !json.LoadFromString(text)) return false;
|
||||
map<string, int> numbers; map<string, string> roles; ResourceName scenario; array<string> records;
|
||||
if (section == "mission") return ERR_Settings.Parse(json, numbers);
|
||||
if (section == "mods") return ERR_ContentSettings.Parse(json, numbers);
|
||||
if (section == "factions") return ERR_FactionSelection.Parse(json, roles);
|
||||
if (section == "terrain") return ERR_TerrainPreference.Parse(json, scenario);
|
||||
if (section == "items") return ERR_ItemPreferences.Parse(json, records);
|
||||
string url;
|
||||
return section == "voice" && json.ReadValue("welcomeUrl", url) && (url.IsEmpty() || ERR_WelcomeVoiceRules.URL(url));
|
||||
}
|
||||
static string Defaults(string section)
|
||||
{
|
||||
if (section == "mods") return "{}";
|
||||
if (section == "factions") return "{\"escapists\":\"us\",\"enemy\":\"ussr\",\"independent\":\"fia\"}";
|
||||
if (section == "terrain") return "{\"scenario\":\"\"}";
|
||||
if (section == "items") return "{\"version\":1,\"records\":[\"active\"]}";
|
||||
if (section == "voice") return "{\"welcomeUrl\":\"\"}";
|
||||
JsonSaveContext json = new JsonSaveContext(); json.EnableTypeDiscriminator(false);
|
||||
foreach (string key : ERR_Settings.Keys())
|
||||
{
|
||||
int value = ERR_Settings.AddedDefault(key);
|
||||
if (key == "reportIntervalSeconds") value = 10;
|
||||
if (key == "supportSquadLimit") value = 4;
|
||||
if (key == "responseRadiusMetres") value = 2000;
|
||||
if (key == "aiIntelChancePercent") value = 60;
|
||||
if (key == "intelRevealChancePercent") value = 15;
|
||||
if (key == "radioDefenceSeconds") value = 180;
|
||||
json.WriteValue(key, value);
|
||||
}
|
||||
return json.SaveToString();
|
||||
}
|
||||
static string Encode(map<string, string> members)
|
||||
{
|
||||
string result = "{\n";
|
||||
int index;
|
||||
foreach (string key, string raw : members)
|
||||
{
|
||||
// Quote arbitrary root keys through the engine, including escaped characters.
|
||||
JsonSaveContext name = new JsonSaveContext(); name.EnableTypeDiscriminator(false); name.WriteValue("key", key);
|
||||
string pair = name.SaveToString(); int colon = pair.IndexOf(":");
|
||||
string quotedKey = pair.Substring(colon + 1, pair.Length() - colon - 2); quotedKey.TrimInPlace();
|
||||
if (index > 0) result += ",\n";
|
||||
result += " " + quotedKey + ": " + raw; index++;
|
||||
}
|
||||
return result + "\n}\n";
|
||||
}
|
||||
static bool Same(map<string, string> a, map<string, string> b)
|
||||
{
|
||||
if (!a || !b || a.Count() != b.Count()) return false;
|
||||
foreach (string key, string value : a) if (!b.Contains(key) || b.Get(key) != value) return false;
|
||||
return true;
|
||||
}
|
||||
static bool Commit(map<string, string> members)
|
||||
{
|
||||
if (!Replication.IsServer()) return false;
|
||||
FileHandle file = FileIO.OpenFile(PATH + ".tmp", FileMode.WRITE);
|
||||
if (!file) return false;
|
||||
file.Write(Encode(members)); file.Close();
|
||||
map<string, string> checked, previous;
|
||||
if (!Document(PATH + ".tmp", checked) || !Same(members, checked)) return false;
|
||||
bool hadPrevious = Document(PATH, previous);
|
||||
if (hadPrevious)
|
||||
{
|
||||
if (!FileIO.CopyFile(PATH, PATH + ".bak") || !Document(PATH + ".bak", checked) || !Same(previous, checked)) return false;
|
||||
}
|
||||
if (!FileIO.CopyFile(PATH + ".tmp", PATH) || !Document(PATH, checked) || !Same(members, checked))
|
||||
{
|
||||
if (FileIO.FileExists(PATH + ".bak")) FileIO.CopyFile(PATH + ".bak", PATH);
|
||||
else if (!hadPrevious) FileIO.DeleteFile(PATH);
|
||||
return false;
|
||||
}
|
||||
FileIO.DeleteFile(PATH + ".tmp");
|
||||
return true;
|
||||
}
|
||||
static bool Ensure()
|
||||
{
|
||||
if (!Replication.IsServer()) return false;
|
||||
// Existing unified files are authoritative; never reimport stale legacy data.
|
||||
if (FileIO.FileExists(PATH) || FileIO.FileExists(PATH + ".bak"))
|
||||
{
|
||||
string existing; map<string, string> root = new map<string, string>();
|
||||
if (Text(PATH, existing) && Split(existing, root) && (!root.Contains("configVersion") || root.Get("configVersion") != "1"))
|
||||
{
|
||||
Print("Reintroduced [config]: unsupported Escapists.json configVersion; file preserved, writes disabled", LogLevel.ERROR);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
map<string, string> members = new map<string, string>(); members.Set("configVersion", "1");
|
||||
foreach (string path : LegacyPaths())
|
||||
{
|
||||
string section = Section(path), raw;
|
||||
if (!Text(path, raw) || !Valid(section, raw))
|
||||
{
|
||||
if (!Text(path + ".bak", raw) || !Valid(section, raw))
|
||||
{
|
||||
if (FileIO.FileExists(path) || FileIO.FileExists(path + ".bak"))
|
||||
{
|
||||
Print("Reintroduced [config]: cannot import " + path + "; repair it or its backup. Existing files preserved.", LogLevel.ERROR);
|
||||
return false;
|
||||
}
|
||||
raw = Defaults(section);
|
||||
}
|
||||
}
|
||||
raw.TrimInPlace(); members.Set(section, raw);
|
||||
}
|
||||
if (!Commit(members)) return false;
|
||||
Print("Reintroduced [config]: using $profile:Escapists.json; legacy files retained but no longer used.");
|
||||
return true;
|
||||
}
|
||||
static bool Load(string path, JsonLoadContext json)
|
||||
{
|
||||
string section = Section(path); bool backup;
|
||||
if (section.IsEmpty() && path.EndsWith(".bak")) { section = Section(path.Substring(0, path.Length() - 4)); backup = true; }
|
||||
if (section.IsEmpty()) return json.LoadFromFile(path);
|
||||
if (!Ensure()) return false;
|
||||
string source = PATH; if (backup) source += ".bak";
|
||||
map<string, string> members;
|
||||
if (!Document(source, members)) return false;
|
||||
if (!members.Contains(section)) return json.LoadFromString(Defaults(section));
|
||||
return json.LoadFromString(members.Get(section));
|
||||
}
|
||||
static bool Exists(string path)
|
||||
{
|
||||
string section = Section(path);
|
||||
if (section.IsEmpty() && path.EndsWith(".bak")) section = Section(path.Substring(0, path.Length() - 4));
|
||||
if (section.IsEmpty()) return FileIO.FileExists(path);
|
||||
// A failed migration must make all owners read-only, not just the bad section.
|
||||
if (!Ensure()) return true;
|
||||
return FileIO.FileExists(PATH) || FileIO.FileExists(PATH + ".bak");
|
||||
}
|
||||
static bool Save(string path, JsonSaveContext json)
|
||||
{
|
||||
if (!Replication.IsServer() || !Ensure()) return false;
|
||||
string section = Section(path), raw = json.SaveToString(); raw.TrimInPlace();
|
||||
if (section.IsEmpty() || !Valid(section, raw)) return false;
|
||||
map<string, string> members;
|
||||
if (!Document(PATH, members) && !Document(PATH + ".bak", members)) return false;
|
||||
// Keep unrecognized fields within the edited section too (for returning mods
|
||||
// and newer optional settings). Explicitly written values replace old values.
|
||||
map<string, string> fields = new map<string, string>();
|
||||
map<string, string> changed = new map<string, string>();
|
||||
if (members.Contains(section) && !Split(members.Get(section), fields)) return false;
|
||||
if (!Split(raw, changed)) return false;
|
||||
foreach (string key, string value : changed) fields.Set(key, value);
|
||||
raw = Encode(fields); raw.TrimInPlace();
|
||||
members.Set(section, raw);
|
||||
return Commit(members);
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
"""Execute the actual shared-file transaction with injected disk failures.
|
||||
Engine JSON/migration/menu checks are recorded separately in native evidence.
|
||||
"""
|
||||
from policy_harness import ROOT, body, run
|
||||
p = ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_ProfileConfig.c'
|
||||
method = body(p, 'static bool Commit(')
|
||||
for before, after in [
|
||||
('map<string, string>', 'Map'), ('FileHandle file', 'FileHandle* file'),
|
||||
('file.', 'file->'), ('FileIO.', 'FileIO::'), ('FileMode.', 'FileMode::'),
|
||||
('Replication.', 'Replication::')]:
|
||||
method = method.replace(before, after)
|
||||
run(r'''
|
||||
#include <cassert>
|
||||
#include <map>
|
||||
#include <string>
|
||||
using string=std::string;
|
||||
using Map=std::map<string,string>;
|
||||
const string PATH="Escapists.json";
|
||||
std::map<string,string> files;
|
||||
std::map<string,Map> documents;
|
||||
int fault=0,serial=0;
|
||||
namespace Replication {bool server=true;bool IsServer(){return server;}}
|
||||
namespace FileMode {int WRITE=1;}
|
||||
struct FileHandle {
|
||||
string path;
|
||||
void Write(string data){files[path]=fault==2?"CORRUPT":data;}
|
||||
void Close(){}
|
||||
};
|
||||
namespace FileIO {
|
||||
FileHandle* OpenFile(string path,int){static FileHandle h;h.path=path;return fault==1?nullptr:&h;}
|
||||
bool FileExists(string path){return files.count(path);}
|
||||
bool DeleteFile(string path){return files.erase(path);}
|
||||
bool CopyFile(string from,string to){
|
||||
if(!files.count(from))return false;
|
||||
if(to==PATH+".bak"&&fault==3)return false;
|
||||
files[to]=files[from];
|
||||
if(to==PATH+".bak"&&fault==4)files[to]="CORRUPT";
|
||||
if(from==PATH+".tmp"){
|
||||
if(fault==5){files[to]="PARTIAL";return false;}
|
||||
if(fault==6)files[to]="CORRUPT";
|
||||
if(fault==7){files[to]="WRONG";documents["WRONG"]={{"mission","wrong"}};}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
struct Store {
|
||||
static string Encode(Map values){string id="doc"+std::to_string(++serial);documents[id]=values;return id;}
|
||||
static bool Document(string path,Map& values){
|
||||
if(!files.count(path)||!documents.count(files[path]))return false;
|
||||
values=documents[files[path]];return true;
|
||||
}
|
||||
static bool Same(Map a,Map b){return a==b;}
|
||||
''' + method + r'''
|
||||
};
|
||||
int main(){
|
||||
Map old={{"mission","old"},{"items","presets"},{"voice","url"}}, next=old,read;
|
||||
next["mission"]="new";
|
||||
for(int injected:{1,2,5,6,7}){
|
||||
files.clear();fault=injected;
|
||||
assert(!Store::Commit(next)&&!files.count(PATH)); // failed first save stays absent
|
||||
}
|
||||
for(int injected=1;injected<=7;injected++){
|
||||
files.clear();fault=0;assert(Store::Commit(old));
|
||||
fault=injected;assert(!Store::Commit(next));
|
||||
assert(Store::Document(PATH,read)&&read==old); // current valid config remains
|
||||
fault=0;assert(Store::Commit(next));
|
||||
assert(Store::Document(PATH,read)&&read==next);
|
||||
}
|
||||
for(int injected:{1,2,5,6,7}){
|
||||
files.clear();files[PATH]="CORRUPT";files[PATH+".bak"]=Store::Encode(old);
|
||||
fault=injected;assert(!Store::Commit(next));
|
||||
assert(Store::Document(PATH+".bak",read)&&read==old); // only good backup not replaced
|
||||
fault=0;assert(Store::Commit(next));
|
||||
assert(Store::Document(PATH,read)&&read==next);
|
||||
}
|
||||
files.clear();fault=0;assert(Store::Commit(old));assert(Store::Commit(next));
|
||||
assert(Store::Document(PATH+".bak",read)&&read==old);
|
||||
assert(!files.count(PATH+".tmp"));
|
||||
Replication::server=false;assert(!Store::Commit(old));
|
||||
assert(Store::Document(PATH,read)&&read==next);
|
||||
}
|
||||
''')
|
||||
print('PASS: shared config transaction rejects client writes, preserves first-save/prior/fallback state across open, write, backup, partial-copy, readback and mismatch failures')
|
||||
@@ -0,0 +1,87 @@
|
||||
from pathlib import Path
|
||||
p=Path('/tmp/erri-config-review-1509/tests/test_profile_config.py');s=p.read_text().replace('from policy_harness import ROOT, body, run','import os\nfrom pathlib import Path\nfrom policy_harness import ROOT, body, run').replace("p = ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_ProfileConfig.c'","p = Path(os.environ.get('ERR_PROFILE_CONFIG_SOURCE', ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_ProfileConfig.c'))")
|
||||
# Existing transaction cases model well-formed sections; specialized validation is below.
|
||||
s=s.replace(' static string Encode(Map values)', ' static bool ContentsValid(Map){return true;}\n static string Encode(Map values)')
|
||||
s += r'''
|
||||
# Execute actual shared Save and Commit, with a small token document store.
|
||||
# JSON lexing and section parsers are explicit doubles: malformed terrain is a
|
||||
# valid JSON object rejected by the real TerrainPreference.Parse predicate.
|
||||
# No native filesystem atomicity or full JsonLoadContext implementation claim.
|
||||
def translate(method):
|
||||
for before, after in [
|
||||
('map<string, string>', 'Map'), ('new Map()', 'Map()'),
|
||||
('FileHandle file','FileHandle* file'), ('file.','file->'),
|
||||
('FileIO.','FileIO::'), ('FileMode.','FileMode::'), ('Replication.','Replication::'),
|
||||
('JsonSaveContext json','JsonSaveContext& json')]:
|
||||
method=method.replace(before,after)
|
||||
method=method.replace('foreach (string key, string value : changed)', 'for (auto [key,value] : changed)')
|
||||
method=method.replace('foreach (string path : LegacyPaths())', 'for (string path : LegacyPaths())')
|
||||
return method
|
||||
helper='static bool ContentsValid(Map) {return true;}'
|
||||
if 'static bool ContentsValid(' in p.read_text(): helper=translate(body(p,'static bool ContentsValid('))
|
||||
run(r"""
|
||||
#include <cassert>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <algorithm>
|
||||
struct string:std::string{using std::string::string;string(std::string s):std::string(s){}bool IsEmpty(){return empty();}void TrimInPlace(){}};
|
||||
struct Map:std::map<string,string>{using std::map<string,string>::map;bool Contains(string key){return count(key);}string Get(string key){return at(key);}void Set(string key,string value){(*this)[key]=value;}};
|
||||
const string PATH="Escapists.json";
|
||||
std::map<string,string> files;
|
||||
std::map<string,Map> documents;
|
||||
int serial=0,copies=0;
|
||||
namespace Replication{bool server=true;bool IsServer(){return server;}}
|
||||
namespace FileMode{int WRITE=1;}
|
||||
struct FileHandle{string path;void Write(string data){files[path]=data;}void Close(){}};
|
||||
namespace FileIO{
|
||||
FileHandle* OpenFile(string path,int){static FileHandle h;h.path=path;return &h;}
|
||||
bool FileExists(string path){return files.count(path);}
|
||||
bool DeleteFile(string path){return files.erase(path);}
|
||||
bool CopyFile(string from,string to){if(!files.count(from))return false;copies++;files[to]=files[from];return true;}
|
||||
}
|
||||
struct JsonSaveContext{string token;string SaveToString(){return token;}};
|
||||
struct Store{
|
||||
static std::vector<string> LegacyPaths(){return {"mission","mods","factions","terrain","items","voice"};}
|
||||
static string Section(string path){return path;}
|
||||
static bool Ensure(){return true;}
|
||||
static string Encode(Map values){string id="doc"+std::to_string(++serial);documents[id]=values;return id;}
|
||||
static bool Split(string text,Map& values){if(!documents.count(text))return false;values=documents[text];return true;}
|
||||
static bool Valid(string section,string text){Map fields;if(!Split(text,fields))return false;return section!="terrain"||(fields.Contains("scenario")&&fields.Get("scenario")!="malformed");}
|
||||
static bool Document(string path,Map& values){return files.count(path)&&Split(files[path],values)&&values.Contains("configVersion")&&values.Get("configVersion")=="1";}
|
||||
static bool Same(Map a,Map b){return a==b;}
|
||||
""" + helper + translate(body(p,'static bool Commit(')) + translate(body(p,'static bool Save(')) + r"""
|
||||
};
|
||||
int main(){
|
||||
auto validTerrain=Store::Encode({{"scenario","valid-saved-terrain"},{"futureTerrainKey","keep"}});
|
||||
auto invalidTerrain=Store::Encode({{"scenario","malformed"},{"futureTerrainKey","keep"}});
|
||||
auto mission=Store::Encode({{"timer","10"},{"unknownMissionKey","keep"}});
|
||||
auto changedMission=Store::Encode({{"timer","20"}});
|
||||
Map good={{"configVersion","1"},{"mission",mission},{"terrain",validTerrain},{"unknownRoot","keep"}},current=good,read;
|
||||
current.Set("terrain",invalidTerrain);string originalGood=Store::Encode(good),originalBad=Store::Encode(current);
|
||||
files[PATH]=originalBad;files[PATH+".bak"]=originalGood;
|
||||
// Owner would reject current terrain and accept backup. An unrelated save must
|
||||
// not destroy the only recovery document or report success with invalid data.
|
||||
assert(!Store::Valid("terrain",current.Get("terrain"))&&Store::Valid("terrain",good.Get("terrain")));
|
||||
JsonSaveContext change{changedMission};
|
||||
assert(!Store::Save("mission",change));
|
||||
assert(files[PATH]==originalBad&&files[PATH+".bak"]==originalGood&&copies==0);
|
||||
// Repair the section through its recovered menu value. Preserve the only good
|
||||
// backup; merge unknown fields and allow subsequent ordinary menu persistence.
|
||||
JsonSaveContext repair{validTerrain};assert(Store::Save("terrain",repair));
|
||||
assert(files[PATH+".bak"]==originalGood&&Store::Document(PATH,read));
|
||||
assert(read.Get("unknownRoot")=="keep");Map fields;assert(Store::Split(read.Get("terrain"),fields)&&fields.Get("futureTerrainKey")=="keep");
|
||||
string repaired=files[PATH];assert(Store::Save("mission",change));assert(files[PATH+".bak"]==repaired);
|
||||
assert(Store::Document(PATH,read)&&Store::Split(read.Get("mission"),fields));assert(fields.Get("timer")=="20"&&fields.Get("unknownMissionKey")=="keep");
|
||||
// Missing optional sections continue to use their owner defaults; unknown roots
|
||||
// are not subject to this version's validators.
|
||||
Map sparse={{"configVersion","1"},{"unrecognized","opaque"}};assert(Store::Commit(sparse));
|
||||
// Existing defaults and each known section are checked; no invalid first write.
|
||||
files.clear();copies=0;assert(!Store::Commit(current)&&files.empty()&&copies==0);
|
||||
// Updating an already healthy document retains ordinary last-valid backup.
|
||||
files[PATH]=originalGood;assert(Store::Save("mission",change));assert(files[PATH+".bak"]==originalGood);
|
||||
}
|
||||
""")
|
||||
print('PASS: actual Save/Commit preserves semantically valid backup across unrelated menu edits and repairs; unknown fields/default sections retained; parsers and disk doubled')
|
||||
'''
|
||||
p.write_text(s)
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"trigger": "Primary unified document has valid JSON/configVersion but malformed recognized terrain scenario, while .bak contains the last valid selection. Terrain owner accepts the backup; an ordinary mission menu change calls ProfileConfig.Save.",
|
||||
"before": "Save edits only mission; Commit accepts version-only Document(primary) and overwrites good backup with malformed primary. Terrain selection becomes unrecoverable on next load.",
|
||||
"after": "ContentsValid checks all present known sections before any temporary-file write. Unrelated edits return existing Save failure without touching files. Repairing the damaged section succeeds while prior invalid primary is not copied over the good backup. Unknown keys and missing/default sections are retained.",
|
||||
"scope": "Only ProfileConfig.c and existing test_profile_config.py. Document/Load fallback semantics and custom file owners unchanged.",
|
||||
"tests": "Actual Save, Commit, new ContentsValid and unchanged actual TerrainPreference.Parse executed. Existing disk-fault matrix retained. Baseline compiles/runtime fails, proposal passes, removing previous-document validation compiles/runtime fails. JSON codec, disk, catalog and non-terrain validators explicitly doubled; native not qualified.",
|
||||
"menu_failure": "ERR_Settings.Set returns false immediately when ProfileConfig.Save refuses; other owners similarly propagate false. No success response introduced.",
|
||||
"limits": [
|
||||
"An unrelated save is refused until the damaged recognized section is repaired; no automatic merge from backup or default overwrite.",
|
||||
"Unknown future root keys remain preserved without semantic validation.",
|
||||
"build-test.py is historical scratch construction, not an authoritative generator."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
--- a/addons/core/Scripts/Game/ERR/00_Core/ERR_ProfileConfig.c
|
||||
+++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_ProfileConfig.c
|
||||
@@ -131,15 +131,26 @@
|
||||
foreach (string key, string value : a) if (!b.Contains(key) || b.Get(key) != value) return false;
|
||||
return true;
|
||||
}
|
||||
+ // A section may have loaded from the backup while the primary remains valid
|
||||
+ // JSON. Never promote that damaged document or overwrite its recovery copy.
|
||||
+ static bool ContentsValid(map<string, string> members)
|
||||
+ {
|
||||
+ foreach (string path : LegacyPaths())
|
||||
+ {
|
||||
+ string section = Section(path);
|
||||
+ if (members.Contains(section) && !Valid(section, members.Get(section))) return false;
|
||||
+ }
|
||||
+ return true;
|
||||
+ }
|
||||
static bool Commit(map<string, string> members)
|
||||
{
|
||||
- if (!Replication.IsServer()) return false;
|
||||
+ if (!Replication.IsServer() || !ContentsValid(members)) return false;
|
||||
FileHandle file = FileIO.OpenFile(PATH + ".tmp", FileMode.WRITE);
|
||||
if (!file) return false;
|
||||
file.Write(Encode(members)); file.Close();
|
||||
map<string, string> checked, previous;
|
||||
if (!Document(PATH + ".tmp", checked) || !Same(members, checked)) return false;
|
||||
- bool hadPrevious = Document(PATH, previous);
|
||||
+ bool hadPrevious = Document(PATH, previous) && ContentsValid(previous);
|
||||
if (hadPrevious)
|
||||
{
|
||||
if (!FileIO.CopyFile(PATH, PATH + ".bak") || !Document(PATH + ".bak", checked) || !Same(previous, checked)) return false;
|
||||
--- a/tests/test_profile_config.py
|
||||
+++ b/tests/test_profile_config.py
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Execute the actual shared-file transaction with injected disk failures.
|
||||
Engine JSON/migration/menu checks are recorded separately in native evidence.
|
||||
"""
|
||||
+import os
|
||||
+from pathlib import Path
|
||||
from policy_harness import ROOT, body, run
|
||||
-p = ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_ProfileConfig.c'
|
||||
+p = Path(os.environ.get('ERR_PROFILE_CONFIG_SOURCE', ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_ProfileConfig.c'))
|
||||
method = body(p, 'static bool Commit(')
|
||||
for before, after in [
|
||||
('map<string, string>', 'Map'), ('FileHandle file', 'FileHandle* file'),
|
||||
@@ -44,6 +46,7 @@
|
||||
}
|
||||
}
|
||||
struct Store {
|
||||
+ static bool ContentsValid(Map){return true;}
|
||||
static string Encode(Map values){string id="doc"+std::to_string(++serial);documents[id]=values;return id;}
|
||||
static bool Document(string path,Map& values){
|
||||
if(!files.count(path)||!documents.count(files[path]))return false;
|
||||
@@ -81,3 +84,90 @@
|
||||
}
|
||||
''')
|
||||
print('PASS: shared config transaction rejects client writes, preserves first-save/prior/fallback state across open, write, backup, partial-copy, readback and mismatch failures')
|
||||
+
|
||||
+# Execute actual shared Save and Commit, with a small token document store.
|
||||
+# JSON lexing and non-terrain section parsers are explicit doubles. The terrain
|
||||
+# rejection runs the actual TerrainPreference.Parse method with a catalog double.
|
||||
+# No native filesystem atomicity or full JsonLoadContext implementation claim.
|
||||
+def translate(method):
|
||||
+ for before, after in [
|
||||
+ ('map<string, string>', 'Map'), ('new Map()', 'Map()'),
|
||||
+ ('FileHandle file','FileHandle* file'), ('file.','file->'),
|
||||
+ ('FileIO.','FileIO::'), ('FileMode.','FileMode::'), ('Replication.','Replication::'),
|
||||
+ ('JsonSaveContext json','JsonSaveContext& json')]:
|
||||
+ method=method.replace(before,after)
|
||||
+ method=method.replace('foreach (string key, string value : changed)', 'for (auto [key,value] : changed)')
|
||||
+ method=method.replace('foreach (string path : LegacyPaths())', 'for (string path : LegacyPaths())')
|
||||
+ return method
|
||||
+helper='static bool ContentsValid(Map) {return true;}'
|
||||
+if 'static bool ContentsValid(' in p.read_text(): helper=translate(body(p,'static bool ContentsValid('))
|
||||
+terrain = body(ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_TerrainPreference.c', 'static bool Parse(').replace('JsonLoadContext json', 'JsonLoadContext& json').replace('out ResourceName scenario', 'string& scenario').replace('ERR_TerrainCatalog.', 'ERR_TerrainCatalog::')
|
||||
+run(r"""
|
||||
+#include <cassert>
|
||||
+#include <map>
|
||||
+#include <string>
|
||||
+#include <vector>
|
||||
+#include <algorithm>
|
||||
+struct string:std::string{using std::string::string;string(std::string s):std::string(s){}bool IsEmpty(){return empty();}int Length(){return size();}void TrimInPlace(){}};
|
||||
+struct Map:std::map<string,string>{using std::map<string,string>::map;bool Contains(string key){return count(key);}string Get(string key){return at(key);}void Set(string key,string value){(*this)[key]=value;}};
|
||||
+const string PATH="Escapists.json";
|
||||
+std::map<string,string> files;
|
||||
+std::map<string,Map> documents;
|
||||
+int serial=0,copies=0,writes=0;
|
||||
+namespace Replication{bool server=true;bool IsServer(){return server;}}
|
||||
+namespace FileMode{int WRITE=1;}
|
||||
+struct FileHandle{string path;void Write(string data){writes++;files[path]=data;}void Close(){}};
|
||||
+namespace FileIO{
|
||||
+ FileHandle* OpenFile(string path,int){static FileHandle h;h.path=path;return &h;}
|
||||
+ bool FileExists(string path){return files.count(path);}
|
||||
+ bool DeleteFile(string path){return files.erase(path);}
|
||||
+ bool CopyFile(string from,string to){if(!files.count(from))return false;copies++;files[to]=files[from];return true;}
|
||||
+}
|
||||
+struct JsonSaveContext{string token;string SaveToString(){return token;}};
|
||||
+struct JsonLoadContext{Map fields;bool ReadValue(string key,string&value){if(!fields.Contains(key))return false;value=fields.Get(key);return true;}};
|
||||
+namespace ERR_TerrainCatalog{string Key(string scenario){return scenario=="valid-saved-terrain"?"known":"";}}
|
||||
+struct Terrain{
|
||||
+""" + terrain + r"""
|
||||
+};
|
||||
+struct Store{
|
||||
+ static std::vector<string> LegacyPaths(){return {"mission","mods","factions","terrain","items","voice"};}
|
||||
+ static string Section(string path){return path;}
|
||||
+ static bool Ensure(){return true;}
|
||||
+ static string Encode(Map values){string id="doc"+std::to_string(++serial);documents[id]=values;return id;}
|
||||
+ static bool Split(string text,Map& values){if(!documents.count(text))return false;values=documents[text];return true;}
|
||||
+ static bool Valid(string section,string text){Map fields;if(!Split(text,fields))return false;if(section!="terrain")return true;JsonLoadContext json{fields};string scenario;return Terrain::Parse(json,scenario);}
|
||||
+ static bool Document(string path,Map& values){return files.count(path)&&Split(files[path],values)&&values.Contains("configVersion")&&values.Get("configVersion")=="1";}
|
||||
+ static bool Same(Map a,Map b){return a==b;}
|
||||
+""" + helper + translate(body(p,'static bool Commit(')) + translate(body(p,'static bool Save(')) + r"""
|
||||
+};
|
||||
+int main(){
|
||||
+ auto validTerrain=Store::Encode({{"scenario","valid-saved-terrain"},{"futureTerrainKey","keep"}});
|
||||
+ auto invalidTerrain=Store::Encode({{"scenario","malformed"},{"futureTerrainKey","keep"}});
|
||||
+ auto mission=Store::Encode({{"timer","10"},{"unknownMissionKey","keep"}});
|
||||
+ auto changedMission=Store::Encode({{"timer","20"}});
|
||||
+ Map good={{"configVersion","1"},{"mission",mission},{"terrain",validTerrain},{"unknownRoot","keep"}},current=good,read;
|
||||
+ current.Set("terrain",invalidTerrain);string originalGood=Store::Encode(good),originalBad=Store::Encode(current);
|
||||
+ files[PATH]=originalBad;files[PATH+".bak"]=originalGood;
|
||||
+ // Owner would reject current terrain and accept backup. An unrelated save must
|
||||
+ // not destroy the only recovery document or report success with invalid data.
|
||||
+ assert(!Store::Valid("terrain",current.Get("terrain"))&&Store::Valid("terrain",good.Get("terrain")));
|
||||
+ JsonSaveContext change{changedMission};
|
||||
+ assert(!Store::Save("mission",change));
|
||||
+ assert(files[PATH]==originalBad&&files[PATH+".bak"]==originalGood&&copies==0&&writes==0&&files.size()==2);
|
||||
+ // Repair the section through its recovered menu value. Preserve the only good
|
||||
+ // backup; merge unknown fields and allow subsequent ordinary menu persistence.
|
||||
+ JsonSaveContext repair{validTerrain};assert(Store::Save("terrain",repair));
|
||||
+ assert(files[PATH+".bak"]==originalGood&&Store::Document(PATH,read));
|
||||
+ assert(read.Get("unknownRoot")=="keep");Map fields;assert(Store::Split(read.Get("terrain"),fields)&&fields.Get("futureTerrainKey")=="keep");
|
||||
+ string repaired=files[PATH];assert(Store::Save("mission",change));assert(files[PATH+".bak"]==repaired);
|
||||
+ assert(Store::Document(PATH,read)&&Store::Split(read.Get("mission"),fields));assert(fields.Get("timer")=="20"&&fields.Get("unknownMissionKey")=="keep");
|
||||
+ // Missing optional sections continue to use their owner defaults; unknown roots
|
||||
+ // are not subject to this version's validators.
|
||||
+ Map sparse={{"configVersion","1"},{"unrecognized","opaque"}};assert(Store::Commit(sparse));
|
||||
+ // Existing defaults and each known section are checked; no invalid first write.
|
||||
+ files.clear();copies=0;writes=0;assert(!Store::Commit(current)&&files.empty()&&copies==0&&writes==0);
|
||||
+ // Updating an already healthy document retains ordinary last-valid backup.
|
||||
+ files[PATH]=originalGood;assert(Store::Save("mission",change));assert(files[PATH+".bak"]==originalGood);
|
||||
+}
|
||||
+""")
|
||||
+print('PASS: actual Save/Commit preserves semantically valid backup across unrelated menu edits and repairs; unknown fields/default sections retained; actual terrain parser; other parsers, catalog and disk doubled')
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"complete": true,
|
||||
"approved": true,
|
||||
"reviewer": "player_admission_review",
|
||||
"files": {
|
||||
"addons/core/Scripts/Game/ERR/00_Core/ERR_ProfileConfig.c": {
|
||||
"baseline_sha256": "ac28363fd5861523db278bb18aa5499f356fef69d35435d1f4ef547da704b67d",
|
||||
"proposal_sha256": "9df8ccf45fc2ed12f4b14d08270878696e8785760a3ae51fe032b6f3818fc0e6",
|
||||
"index_sha256": null,
|
||||
"index_absent": true
|
||||
},
|
||||
"tests/test_profile_config.py": {
|
||||
"baseline_sha256": "088ea06a32a59711d8e02c426d5fdd4a14657cded725b831af312bd8eb5f133f",
|
||||
"proposal_sha256": "727dc330db23d744fca0c965474f834d0cb70432e58938f286fde28bd8415435",
|
||||
"index_sha256": null,
|
||||
"index_absent": true
|
||||
}
|
||||
},
|
||||
"patch_sha256": "9ddefab1f87ef5d0924e096d51359482e43de9b250c7986c98360c97d432104d",
|
||||
"source_manifest_sha256": "2fd845ee6bd744e8c405f7e9dc40562df2a82cb2ff215cf0df7db481b85e735f",
|
||||
"findings": [
|
||||
"Exact production delta is one present-section validator plus Commit pre-write guard and valid-previous-only backup promotion. No Document/Load/schema/default policy changes.",
|
||||
"Recognized sections invoke existing owner parsers; missing sections and unknown root/section members are retained. Successful candidate validation plus exact readback Same check prevents semantically invalid candidate promotion.",
|
||||
"Unrelated save cannot replace sole good backup with malformed recognized primary. Explicit section repair succeeds without overwriting good backup, then normal healthy saves retain last valid document.",
|
||||
"Existing first-save, client-write, backup-copy, partial-copy and readback fault assertions remain in the first harness. Added harness executes actual Save/Commit/ContentsValid and actual terrain parser; engine JSON, remaining parsers/catalog and disk are explicitly doubled.",
|
||||
"Baseline compiled runtime failure at unrelated Save acceptance and proposal green were inspected from owner logs; omission of previous-content validation fails repair-backup assertion."
|
||||
],
|
||||
"limits": [
|
||||
"Independent source/test/log review only; no compiler or native run by reviewer.",
|
||||
"No native JSON/filesystem or real profile mutation qualification inferred.",
|
||||
"Malformed untouched section intentionally blocks unrelated saves until that section is repaired; safe refusal is deliberate."
|
||||
],
|
||||
"logs": {
|
||||
"apply-check.log": {
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"tail": ""
|
||||
},
|
||||
"previous-validation-red.log": {
|
||||
"sha256": "0f8438d6a2ad9c46e8372fc1ad3f4e2c01d07179c3bc19d1279de1066ae42ae9",
|
||||
"tail": "^^^^^^^^^^^^^^^^^^^\n File \"/usr/lib/python3.14/subprocess.py\", line 578, in run\n raise CalledProcessError(retcode, process.args,\n output=stdout, stderr=stderr)\nsubprocess.CalledProcessError: Command '['/tmp/err-policy-ri3j68jc/check']' died with <Signals.SIGABRT: 6>.\n"
|
||||
},
|
||||
"proposal-green.log": {
|
||||
"sha256": "1c414380eb465210146fed3d49b2108f593abd625b6ffb92f50e7f27131de6f0",
|
||||
"tail": "rior/fallback state across open, write, backup, partial-copy, readback and mismatch failures\nPASS: actual Save/Commit preserves semantically valid backup across unrelated menu edits and repairs; unknown fields/default sections retained; actual terrain parser; other parsers, catalog and disk doubled\n"
|
||||
},
|
||||
"baseline-red.log": {
|
||||
"sha256": "a51853e6c0004c0c17b932fb4b2d201d7618648fe1ba6f802c31011228f3c404",
|
||||
"tail": "^^^^^^^^^^^^^^^^^^^\n File \"/usr/lib/python3.14/subprocess.py\", line 578, in run\n raise CalledProcessError(retcode, process.args,\n output=stdout, stderr=stderr)\nsubprocess.CalledProcessError: Command '['/tmp/err-policy-r3sh7kea/check']' died with <Signals.SIGABRT: 6>.\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
check: /tmp/err-policy-ri3j68jc/check.cpp:106: int main(): Assertion `files[PATH+".bak"]==originalGood&&Store::Document(PATH,read)' failed.
|
||||
PASS: shared config transaction rejects client writes, preserves first-save/prior/fallback state across open, write, backup, partial-copy, readback and mismatch failures
|
||||
Traceback (most recent call last):
|
||||
File "/tmp/erri-config-review-1509/tests/test_profile_config.py", line 105, in <module>
|
||||
run(r"""
|
||||
~~~^^^^^
|
||||
#include <cassert>
|
||||
^^^^^^^^^^^^^^^^^^
|
||||
...<64 lines>...
|
||||
}
|
||||
^
|
||||
""")
|
||||
^^^^
|
||||
File "/home/jakub/devel/escapistsrereintroduced/tests/policy_harness.py", line 14, in run
|
||||
subprocess.run([str(p/'check')],check=True)
|
||||
~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/usr/lib/python3.14/subprocess.py", line 578, in run
|
||||
raise CalledProcessError(retcode, process.args,
|
||||
output=stdout, stderr=stderr)
|
||||
subprocess.CalledProcessError: Command '['/tmp/err-policy-ri3j68jc/check']' died with <Signals.SIGABRT: 6>.
|
||||
@@ -0,0 +1,2 @@
|
||||
PASS: shared config transaction rejects client writes, preserves first-save/prior/fallback state across open, write, backup, partial-copy, readback and mismatch failures
|
||||
PASS: actual Save/Commit preserves semantically valid backup across unrelated menu edits and repairs; unknown fields/default sections retained; actual terrain parser; other parsers, catalog and disk doubled
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
// One operator-owned document. Legacy names identify sections internally only;
|
||||
// custom paths used by isolated probes keep their original standalone behavior.
|
||||
class ERR_ProfileConfig
|
||||
{
|
||||
static const string PATH = "$profile:Escapists.json";
|
||||
static const string VOICE = "$profile:EscapistsWelcomeVoice.json";
|
||||
static array<string> LegacyPaths()
|
||||
{
|
||||
return {ERR_Settings.PATH, ERR_ContentSettings.PATH, ERR_FactionSelection.PATH, ERR_TerrainPreference.PATH, ERR_ItemPreferences.PATH, VOICE};
|
||||
}
|
||||
static string Section(string path)
|
||||
{
|
||||
if (path == ERR_Settings.PATH) return "mission";
|
||||
if (path == ERR_ContentSettings.PATH) return "mods";
|
||||
if (path == ERR_FactionSelection.PATH) return "factions";
|
||||
if (path == ERR_TerrainPreference.PATH) return "terrain";
|
||||
if (path == ERR_ItemPreferences.PATH) return "items";
|
||||
if (path == VOICE) return "voice";
|
||||
return "";
|
||||
}
|
||||
static bool Text(string path, out string text)
|
||||
{
|
||||
FileHandle file = FileIO.OpenFile(path, FileMode.READ);
|
||||
if (!file) return false;
|
||||
int length = file.GetLength();
|
||||
if (length < 2 || length > 8388608) { file.Close(); return false; }
|
||||
file.Read(text, length); file.Close();
|
||||
return text.Length() == length;
|
||||
}
|
||||
// The engine validates JSON syntax. This scan only separates root members,
|
||||
// retaining unknown fields and dormant addon entries without reinterpreting them.
|
||||
static bool Split(string text, map<string, string> members)
|
||||
{
|
||||
members.Clear(); text.TrimInPlace();
|
||||
JsonLoadContext valid = new JsonLoadContext();
|
||||
if (text.Length() < 2 || text.Substring(0, 1) != "{" || !valid.LoadFromString(text)) return false;
|
||||
int depth, start = 1, colon = -1;
|
||||
bool quoted, escaped;
|
||||
string block;
|
||||
for (int i = 0; i < text.Length(); i++)
|
||||
{
|
||||
if (i % 1024 == 0) block = text.Substring(i, Math.Min(1024, text.Length() - i));
|
||||
string ch = block.Substring(i % 1024, 1);
|
||||
if (quoted)
|
||||
{
|
||||
if (escaped) escaped = false;
|
||||
else if (ch == "\\") escaped = true;
|
||||
else if (ch == "\"") quoted = false;
|
||||
continue;
|
||||
}
|
||||
if (ch == "\"") { quoted = true; continue; }
|
||||
if (ch == "{" || ch == "[") depth++;
|
||||
if (ch == "}" || ch == "]") depth--;
|
||||
if (depth == 1 && ch == ":" && colon < 0) colon = i;
|
||||
if ((depth == 1 && ch == ",") || (depth == 0 && ch == "}"))
|
||||
{
|
||||
if (colon < start)
|
||||
{
|
||||
string empty = text.Substring(start, i - start); empty.TrimInPlace();
|
||||
return members.Count() == 0 && empty.IsEmpty() && i == text.Length() - 1;
|
||||
}
|
||||
JsonLoadContext keyJson = new JsonLoadContext();
|
||||
string key;
|
||||
if (!keyJson.LoadFromString("{\"key\":" + text.Substring(start, colon - start) + "}") || !keyJson.ReadValue("key", key) || members.Contains(key)) return false;
|
||||
string value = text.Substring(colon + 1, i - colon - 1); value.TrimInPlace();
|
||||
members.Set(key, value); start = i + 1; colon = -1;
|
||||
}
|
||||
}
|
||||
return depth == 0 && !quoted;
|
||||
}
|
||||
static bool Document(string path, out map<string, string> members)
|
||||
{
|
||||
string text; members = new map<string, string>();
|
||||
if (!Text(path, text) || !Split(text, members)) return false;
|
||||
// Never downgrade or rewrite a configuration created by an unknown version.
|
||||
return members.Contains("configVersion") && members.Get("configVersion") == "1";
|
||||
}
|
||||
static bool Valid(string section, string text)
|
||||
{
|
||||
JsonLoadContext json = new JsonLoadContext();
|
||||
map<string, string> fields = new map<string, string>();
|
||||
if (!Split(text, fields) || !json.LoadFromString(text)) return false;
|
||||
map<string, int> numbers; map<string, string> roles; ResourceName scenario; array<string> records;
|
||||
if (section == "mission") return ERR_Settings.Parse(json, numbers);
|
||||
if (section == "mods") return ERR_ContentSettings.Parse(json, numbers);
|
||||
if (section == "factions") return ERR_FactionSelection.Parse(json, roles);
|
||||
if (section == "terrain") return ERR_TerrainPreference.Parse(json, scenario);
|
||||
if (section == "items") return ERR_ItemPreferences.Parse(json, records);
|
||||
string url;
|
||||
return section == "voice" && json.ReadValue("welcomeUrl", url) && (url.IsEmpty() || ERR_WelcomeVoiceRules.URL(url));
|
||||
}
|
||||
static string Defaults(string section)
|
||||
{
|
||||
if (section == "mods") return "{}";
|
||||
if (section == "factions") return "{\"escapists\":\"us\",\"enemy\":\"ussr\",\"independent\":\"fia\"}";
|
||||
if (section == "terrain") return "{\"scenario\":\"\"}";
|
||||
if (section == "items") return "{\"version\":1,\"records\":[\"active\"]}";
|
||||
if (section == "voice") return "{\"welcomeUrl\":\"\"}";
|
||||
JsonSaveContext json = new JsonSaveContext(); json.EnableTypeDiscriminator(false);
|
||||
foreach (string key : ERR_Settings.Keys())
|
||||
{
|
||||
int value = ERR_Settings.AddedDefault(key);
|
||||
if (key == "reportIntervalSeconds") value = 10;
|
||||
if (key == "supportSquadLimit") value = 4;
|
||||
if (key == "responseRadiusMetres") value = 2000;
|
||||
if (key == "aiIntelChancePercent") value = 60;
|
||||
if (key == "intelRevealChancePercent") value = 15;
|
||||
if (key == "radioDefenceSeconds") value = 180;
|
||||
json.WriteValue(key, value);
|
||||
}
|
||||
return json.SaveToString();
|
||||
}
|
||||
static string Encode(map<string, string> members)
|
||||
{
|
||||
string result = "{\n";
|
||||
int index;
|
||||
foreach (string key, string raw : members)
|
||||
{
|
||||
// Quote arbitrary root keys through the engine, including escaped characters.
|
||||
JsonSaveContext name = new JsonSaveContext(); name.EnableTypeDiscriminator(false); name.WriteValue("key", key);
|
||||
string pair = name.SaveToString(); int colon = pair.IndexOf(":");
|
||||
string quotedKey = pair.Substring(colon + 1, pair.Length() - colon - 2); quotedKey.TrimInPlace();
|
||||
if (index > 0) result += ",\n";
|
||||
result += " " + quotedKey + ": " + raw; index++;
|
||||
}
|
||||
return result + "\n}\n";
|
||||
}
|
||||
static bool Same(map<string, string> a, map<string, string> b)
|
||||
{
|
||||
if (!a || !b || a.Count() != b.Count()) return false;
|
||||
foreach (string key, string value : a) if (!b.Contains(key) || b.Get(key) != value) return false;
|
||||
return true;
|
||||
}
|
||||
// A section may have loaded from the backup while the primary remains valid
|
||||
// JSON. Never promote that damaged document or overwrite its recovery copy.
|
||||
static bool ContentsValid(map<string, string> members)
|
||||
{
|
||||
foreach (string path : LegacyPaths())
|
||||
{
|
||||
string section = Section(path);
|
||||
if (members.Contains(section) && !Valid(section, members.Get(section))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
static bool Commit(map<string, string> members)
|
||||
{
|
||||
if (!Replication.IsServer() || !ContentsValid(members)) return false;
|
||||
FileHandle file = FileIO.OpenFile(PATH + ".tmp", FileMode.WRITE);
|
||||
if (!file) return false;
|
||||
file.Write(Encode(members)); file.Close();
|
||||
map<string, string> checked, previous;
|
||||
if (!Document(PATH + ".tmp", checked) || !Same(members, checked)) return false;
|
||||
bool hadPrevious = Document(PATH, previous) && ContentsValid(previous);
|
||||
if (hadPrevious)
|
||||
{
|
||||
if (!FileIO.CopyFile(PATH, PATH + ".bak") || !Document(PATH + ".bak", checked) || !Same(previous, checked)) return false;
|
||||
}
|
||||
if (!FileIO.CopyFile(PATH + ".tmp", PATH) || !Document(PATH, checked) || !Same(members, checked))
|
||||
{
|
||||
if (FileIO.FileExists(PATH + ".bak")) FileIO.CopyFile(PATH + ".bak", PATH);
|
||||
else if (!hadPrevious) FileIO.DeleteFile(PATH);
|
||||
return false;
|
||||
}
|
||||
FileIO.DeleteFile(PATH + ".tmp");
|
||||
return true;
|
||||
}
|
||||
static bool Ensure()
|
||||
{
|
||||
if (!Replication.IsServer()) return false;
|
||||
// Existing unified files are authoritative; never reimport stale legacy data.
|
||||
if (FileIO.FileExists(PATH) || FileIO.FileExists(PATH + ".bak"))
|
||||
{
|
||||
string existing; map<string, string> root = new map<string, string>();
|
||||
if (Text(PATH, existing) && Split(existing, root) && (!root.Contains("configVersion") || root.Get("configVersion") != "1"))
|
||||
{
|
||||
Print("Reintroduced [config]: unsupported Escapists.json configVersion; file preserved, writes disabled", LogLevel.ERROR);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
map<string, string> members = new map<string, string>(); members.Set("configVersion", "1");
|
||||
foreach (string path : LegacyPaths())
|
||||
{
|
||||
string section = Section(path), raw;
|
||||
if (!Text(path, raw) || !Valid(section, raw))
|
||||
{
|
||||
if (!Text(path + ".bak", raw) || !Valid(section, raw))
|
||||
{
|
||||
if (FileIO.FileExists(path) || FileIO.FileExists(path + ".bak"))
|
||||
{
|
||||
Print("Reintroduced [config]: cannot import " + path + "; repair it or its backup. Existing files preserved.", LogLevel.ERROR);
|
||||
return false;
|
||||
}
|
||||
raw = Defaults(section);
|
||||
}
|
||||
}
|
||||
raw.TrimInPlace(); members.Set(section, raw);
|
||||
}
|
||||
if (!Commit(members)) return false;
|
||||
Print("Reintroduced [config]: using $profile:Escapists.json; legacy files retained but no longer used.");
|
||||
return true;
|
||||
}
|
||||
static bool Load(string path, JsonLoadContext json)
|
||||
{
|
||||
string section = Section(path); bool backup;
|
||||
if (section.IsEmpty() && path.EndsWith(".bak")) { section = Section(path.Substring(0, path.Length() - 4)); backup = true; }
|
||||
if (section.IsEmpty()) return json.LoadFromFile(path);
|
||||
if (!Ensure()) return false;
|
||||
string source = PATH; if (backup) source += ".bak";
|
||||
map<string, string> members;
|
||||
if (!Document(source, members)) return false;
|
||||
if (!members.Contains(section)) return json.LoadFromString(Defaults(section));
|
||||
return json.LoadFromString(members.Get(section));
|
||||
}
|
||||
static bool Exists(string path)
|
||||
{
|
||||
string section = Section(path);
|
||||
if (section.IsEmpty() && path.EndsWith(".bak")) section = Section(path.Substring(0, path.Length() - 4));
|
||||
if (section.IsEmpty()) return FileIO.FileExists(path);
|
||||
// A failed migration must make all owners read-only, not just the bad section.
|
||||
if (!Ensure()) return true;
|
||||
return FileIO.FileExists(PATH) || FileIO.FileExists(PATH + ".bak");
|
||||
}
|
||||
static bool Save(string path, JsonSaveContext json)
|
||||
{
|
||||
if (!Replication.IsServer() || !Ensure()) return false;
|
||||
string section = Section(path), raw = json.SaveToString(); raw.TrimInPlace();
|
||||
if (section.IsEmpty() || !Valid(section, raw)) return false;
|
||||
map<string, string> members;
|
||||
if (!Document(PATH, members) && !Document(PATH + ".bak", members)) return false;
|
||||
// Keep unrecognized fields within the edited section too (for returning mods
|
||||
// and newer optional settings). Explicitly written values replace old values.
|
||||
map<string, string> fields = new map<string, string>();
|
||||
map<string, string> changed = new map<string, string>();
|
||||
if (members.Contains(section) && !Split(members.Get(section), fields)) return false;
|
||||
if (!Split(raw, changed)) return false;
|
||||
foreach (string key, string value : changed) fields.Set(key, value);
|
||||
raw = Encode(fields); raw.TrimInPlace();
|
||||
members.Set(section, raw);
|
||||
return Commit(members);
|
||||
}
|
||||
}
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
"""Execute the actual shared-file transaction with injected disk failures.
|
||||
Engine JSON/migration/menu checks are recorded separately in native evidence.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
from policy_harness import ROOT, body, run
|
||||
p = Path(os.environ.get('ERR_PROFILE_CONFIG_SOURCE', ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_ProfileConfig.c'))
|
||||
method = body(p, 'static bool Commit(')
|
||||
for before, after in [
|
||||
('map<string, string>', 'Map'), ('FileHandle file', 'FileHandle* file'),
|
||||
('file.', 'file->'), ('FileIO.', 'FileIO::'), ('FileMode.', 'FileMode::'),
|
||||
('Replication.', 'Replication::')]:
|
||||
method = method.replace(before, after)
|
||||
run(r'''
|
||||
#include <cassert>
|
||||
#include <map>
|
||||
#include <string>
|
||||
using string=std::string;
|
||||
using Map=std::map<string,string>;
|
||||
const string PATH="Escapists.json";
|
||||
std::map<string,string> files;
|
||||
std::map<string,Map> documents;
|
||||
int fault=0,serial=0;
|
||||
namespace Replication {bool server=true;bool IsServer(){return server;}}
|
||||
namespace FileMode {int WRITE=1;}
|
||||
struct FileHandle {
|
||||
string path;
|
||||
void Write(string data){files[path]=fault==2?"CORRUPT":data;}
|
||||
void Close(){}
|
||||
};
|
||||
namespace FileIO {
|
||||
FileHandle* OpenFile(string path,int){static FileHandle h;h.path=path;return fault==1?nullptr:&h;}
|
||||
bool FileExists(string path){return files.count(path);}
|
||||
bool DeleteFile(string path){return files.erase(path);}
|
||||
bool CopyFile(string from,string to){
|
||||
if(!files.count(from))return false;
|
||||
if(to==PATH+".bak"&&fault==3)return false;
|
||||
files[to]=files[from];
|
||||
if(to==PATH+".bak"&&fault==4)files[to]="CORRUPT";
|
||||
if(from==PATH+".tmp"){
|
||||
if(fault==5){files[to]="PARTIAL";return false;}
|
||||
if(fault==6)files[to]="CORRUPT";
|
||||
if(fault==7){files[to]="WRONG";documents["WRONG"]={{"mission","wrong"}};}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
struct Store {
|
||||
static bool ContentsValid(Map){return true;}
|
||||
static string Encode(Map values){string id="doc"+std::to_string(++serial);documents[id]=values;return id;}
|
||||
static bool Document(string path,Map& values){
|
||||
if(!files.count(path)||!documents.count(files[path]))return false;
|
||||
values=documents[files[path]];return true;
|
||||
}
|
||||
static bool Same(Map a,Map b){return a==b;}
|
||||
''' + method + r'''
|
||||
};
|
||||
int main(){
|
||||
Map old={{"mission","old"},{"items","presets"},{"voice","url"}}, next=old,read;
|
||||
next["mission"]="new";
|
||||
for(int injected:{1,2,5,6,7}){
|
||||
files.clear();fault=injected;
|
||||
assert(!Store::Commit(next)&&!files.count(PATH)); // failed first save stays absent
|
||||
}
|
||||
for(int injected=1;injected<=7;injected++){
|
||||
files.clear();fault=0;assert(Store::Commit(old));
|
||||
fault=injected;assert(!Store::Commit(next));
|
||||
assert(Store::Document(PATH,read)&&read==old); // current valid config remains
|
||||
fault=0;assert(Store::Commit(next));
|
||||
assert(Store::Document(PATH,read)&&read==next);
|
||||
}
|
||||
for(int injected:{1,2,5,6,7}){
|
||||
files.clear();files[PATH]="CORRUPT";files[PATH+".bak"]=Store::Encode(old);
|
||||
fault=injected;assert(!Store::Commit(next));
|
||||
assert(Store::Document(PATH+".bak",read)&&read==old); // only good backup not replaced
|
||||
fault=0;assert(Store::Commit(next));
|
||||
assert(Store::Document(PATH,read)&&read==next);
|
||||
}
|
||||
files.clear();fault=0;assert(Store::Commit(old));assert(Store::Commit(next));
|
||||
assert(Store::Document(PATH+".bak",read)&&read==old);
|
||||
assert(!files.count(PATH+".tmp"));
|
||||
Replication::server=false;assert(!Store::Commit(old));
|
||||
assert(Store::Document(PATH,read)&&read==next);
|
||||
}
|
||||
''')
|
||||
print('PASS: shared config transaction rejects client writes, preserves first-save/prior/fallback state across open, write, backup, partial-copy, readback and mismatch failures')
|
||||
|
||||
# Execute actual shared Save and Commit, with a small token document store.
|
||||
# JSON lexing and non-terrain section parsers are explicit doubles. The terrain
|
||||
# rejection runs the actual TerrainPreference.Parse method with a catalog double.
|
||||
# No native filesystem atomicity or full JsonLoadContext implementation claim.
|
||||
def translate(method):
|
||||
for before, after in [
|
||||
('map<string, string>', 'Map'), ('new Map()', 'Map()'),
|
||||
('FileHandle file','FileHandle* file'), ('file.','file->'),
|
||||
('FileIO.','FileIO::'), ('FileMode.','FileMode::'), ('Replication.','Replication::'),
|
||||
('JsonSaveContext json','JsonSaveContext& json')]:
|
||||
method=method.replace(before,after)
|
||||
method=method.replace('foreach (string key, string value : changed)', 'for (auto [key,value] : changed)')
|
||||
method=method.replace('foreach (string path : LegacyPaths())', 'for (string path : LegacyPaths())')
|
||||
return method
|
||||
helper='static bool ContentsValid(Map) {return true;}'
|
||||
if 'static bool ContentsValid(' in p.read_text(): helper=translate(body(p,'static bool ContentsValid('))
|
||||
terrain = body(ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_TerrainPreference.c', 'static bool Parse(').replace('JsonLoadContext json', 'JsonLoadContext& json').replace('out ResourceName scenario', 'string& scenario').replace('ERR_TerrainCatalog.', 'ERR_TerrainCatalog::')
|
||||
run(r"""
|
||||
#include <cassert>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <algorithm>
|
||||
struct string:std::string{using std::string::string;string(std::string s):std::string(s){}bool IsEmpty(){return empty();}int Length(){return size();}void TrimInPlace(){}};
|
||||
struct Map:std::map<string,string>{using std::map<string,string>::map;bool Contains(string key){return count(key);}string Get(string key){return at(key);}void Set(string key,string value){(*this)[key]=value;}};
|
||||
const string PATH="Escapists.json";
|
||||
std::map<string,string> files;
|
||||
std::map<string,Map> documents;
|
||||
int serial=0,copies=0,writes=0;
|
||||
namespace Replication{bool server=true;bool IsServer(){return server;}}
|
||||
namespace FileMode{int WRITE=1;}
|
||||
struct FileHandle{string path;void Write(string data){writes++;files[path]=data;}void Close(){}};
|
||||
namespace FileIO{
|
||||
FileHandle* OpenFile(string path,int){static FileHandle h;h.path=path;return &h;}
|
||||
bool FileExists(string path){return files.count(path);}
|
||||
bool DeleteFile(string path){return files.erase(path);}
|
||||
bool CopyFile(string from,string to){if(!files.count(from))return false;copies++;files[to]=files[from];return true;}
|
||||
}
|
||||
struct JsonSaveContext{string token;string SaveToString(){return token;}};
|
||||
struct JsonLoadContext{Map fields;bool ReadValue(string key,string&value){if(!fields.Contains(key))return false;value=fields.Get(key);return true;}};
|
||||
namespace ERR_TerrainCatalog{string Key(string scenario){return scenario=="valid-saved-terrain"?"known":"";}}
|
||||
struct Terrain{
|
||||
""" + terrain + r"""
|
||||
};
|
||||
struct Store{
|
||||
static std::vector<string> LegacyPaths(){return {"mission","mods","factions","terrain","items","voice"};}
|
||||
static string Section(string path){return path;}
|
||||
static bool Ensure(){return true;}
|
||||
static string Encode(Map values){string id="doc"+std::to_string(++serial);documents[id]=values;return id;}
|
||||
static bool Split(string text,Map& values){if(!documents.count(text))return false;values=documents[text];return true;}
|
||||
static bool Valid(string section,string text){Map fields;if(!Split(text,fields))return false;if(section!="terrain")return true;JsonLoadContext json{fields};string scenario;return Terrain::Parse(json,scenario);}
|
||||
static bool Document(string path,Map& values){return files.count(path)&&Split(files[path],values)&&values.Contains("configVersion")&&values.Get("configVersion")=="1";}
|
||||
static bool Same(Map a,Map b){return a==b;}
|
||||
""" + helper + translate(body(p,'static bool Commit(')) + translate(body(p,'static bool Save(')) + r"""
|
||||
};
|
||||
int main(){
|
||||
auto validTerrain=Store::Encode({{"scenario","valid-saved-terrain"},{"futureTerrainKey","keep"}});
|
||||
auto invalidTerrain=Store::Encode({{"scenario","malformed"},{"futureTerrainKey","keep"}});
|
||||
auto mission=Store::Encode({{"timer","10"},{"unknownMissionKey","keep"}});
|
||||
auto changedMission=Store::Encode({{"timer","20"}});
|
||||
Map good={{"configVersion","1"},{"mission",mission},{"terrain",validTerrain},{"unknownRoot","keep"}},current=good,read;
|
||||
current.Set("terrain",invalidTerrain);string originalGood=Store::Encode(good),originalBad=Store::Encode(current);
|
||||
files[PATH]=originalBad;files[PATH+".bak"]=originalGood;
|
||||
// Owner would reject current terrain and accept backup. An unrelated save must
|
||||
// not destroy the only recovery document or report success with invalid data.
|
||||
assert(!Store::Valid("terrain",current.Get("terrain"))&&Store::Valid("terrain",good.Get("terrain")));
|
||||
JsonSaveContext change{changedMission};
|
||||
assert(!Store::Save("mission",change));
|
||||
assert(files[PATH]==originalBad&&files[PATH+".bak"]==originalGood&&copies==0&&writes==0&&files.size()==2);
|
||||
// Repair the section through its recovered menu value. Preserve the only good
|
||||
// backup; merge unknown fields and allow subsequent ordinary menu persistence.
|
||||
JsonSaveContext repair{validTerrain};assert(Store::Save("terrain",repair));
|
||||
assert(files[PATH+".bak"]==originalGood&&Store::Document(PATH,read));
|
||||
assert(read.Get("unknownRoot")=="keep");Map fields;assert(Store::Split(read.Get("terrain"),fields)&&fields.Get("futureTerrainKey")=="keep");
|
||||
string repaired=files[PATH];assert(Store::Save("mission",change));assert(files[PATH+".bak"]==repaired);
|
||||
assert(Store::Document(PATH,read)&&Store::Split(read.Get("mission"),fields));assert(fields.Get("timer")=="20"&&fields.Get("unknownMissionKey")=="keep");
|
||||
// Missing optional sections continue to use their owner defaults; unknown roots
|
||||
// are not subject to this version's validators.
|
||||
Map sparse={{"configVersion","1"},{"unrecognized","opaque"}};assert(Store::Commit(sparse));
|
||||
// Existing defaults and each known section are checked; no invalid first write.
|
||||
files.clear();copies=0;writes=0;assert(!Store::Commit(current)&&files.empty()&&copies==0&&writes==0);
|
||||
// Updating an already healthy document retains ordinary last-valid backup.
|
||||
files[PATH]=originalGood;assert(Store::Save("mission",change));assert(files[PATH+".bak"]==originalGood);
|
||||
}
|
||||
""")
|
||||
print('PASS: actual Save/Commit preserves semantically valid backup across unrelated menu edits and repairs; unknown fields/default sections retained; actual terrain parser; other parsers, catalog and disk doubled')
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"target": "/home/jakub/devel/escapistsrereintroduced",
|
||||
"files": {
|
||||
"addons/core/Scripts/Game/ERR/00_Core/ERR_ProfileConfig.c": {
|
||||
"baseline_sha256": "ac28363fd5861523db278bb18aa5499f356fef69d35435d1f4ef547da704b67d",
|
||||
"proposal_sha256": "9df8ccf45fc2ed12f4b14d08270878696e8785760a3ae51fe032b6f3818fc0e6",
|
||||
"index_sha256": null,
|
||||
"index_absent": true
|
||||
},
|
||||
"tests/test_profile_config.py": {
|
||||
"baseline_sha256": "088ea06a32a59711d8e02c426d5fdd4a14657cded725b831af312bd8eb5f133f",
|
||||
"proposal_sha256": "727dc330db23d744fca0c965474f834d0cb70432e58938f286fde28bd8415435",
|
||||
"index_sha256": null,
|
||||
"index_absent": true
|
||||
}
|
||||
},
|
||||
"native": "Not run; user game remains untouched.",
|
||||
"patch_sha256": "9ddefab1f87ef5d0924e096d51359482e43de9b250c7986c98360c97d432104d",
|
||||
"test_results_sha256": "20af613fa5168dc7e93734b4ce1b0f5ef771946bc310e802b58e93dee09f89cd",
|
||||
"unchanged_actual_parser": {
|
||||
"path": "addons/core/Scripts/Game/ERR/00_Core/ERR_TerrainPreference.c",
|
||||
"sha256": "93fc55f2c33e95adbcf2bead3430aa14bdb55154315760bcc9647f89dc8286ff"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
[
|
||||
{
|
||||
"name": "baseline-red",
|
||||
"exit": 1,
|
||||
"expected_observed": true,
|
||||
"log_sha256": "a51853e6c0004c0c17b932fb4b2d201d7618648fe1ba6f802c31011228f3c404",
|
||||
"source_sha256": "ac28363fd5861523db278bb18aa5499f356fef69d35435d1f4ef547da704b67d"
|
||||
},
|
||||
{
|
||||
"name": "proposal-green",
|
||||
"exit": 0,
|
||||
"expected_observed": true,
|
||||
"log_sha256": "1c414380eb465210146fed3d49b2108f593abd625b6ffb92f50e7f27131de6f0",
|
||||
"source_sha256": "9df8ccf45fc2ed12f4b14d08270878696e8785760a3ae51fe032b6f3818fc0e6"
|
||||
},
|
||||
{
|
||||
"name": "previous-validation-red",
|
||||
"exit": 1,
|
||||
"expected_observed": true,
|
||||
"log_sha256": "0f8438d6a2ad9c46e8372fc1ad3f4e2c01d07179c3bc19d1279de1066ae42ae9",
|
||||
"source_sha256": "0098c3e33fb160a04a89c3ef9635bfcc75a4db1ee42b1412257492e0f9e7728b"
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user