Enable finite automatic convoy startup and scoped gunner skill

This commit is contained in:
2026-09-20 08:23:19 +02:00
parent 782c163c68
commit 5bd66d76ca
44 changed files with 3694 additions and 48 deletions
+5 -3
View File
@@ -2,13 +2,15 @@
This does not claim native focus, rendering or multiplayer RPC coverage.
"""
import re
import re,os
from pathlib import Path
BASE=Path(os.environ.get("ERR_CONVOY_SETTINGS_ROOT", Path(__file__).resolve().parents[1]))
from policy_harness import ROOT, body, run
source = ROOT / 'addons/core/Scripts/Game/ERR/ERR_AdminMenu.c'
layout = ROOT / 'addons/core/UI/layouts/ERR/AdminMenu.layout'
keys = re.findall(r'"([A-Za-z]+)"', body(source, 'static array<string> Keys('))
settings = re.findall(r'"([A-Za-z]+)"', body(ROOT / 'addons/core/Scripts/Game/ERR/ERR_Settings.c', 'static array<string> Keys('))
assert len(keys) == 10 and set(settings) - set(keys) == {"stingerChanceOneIn"}
settings = re.findall(r'"([A-Za-z]+)"', body(BASE / 'addons/core/Scripts/Game/ERR/ERR_Settings.c', 'static array<string> Keys('))
assert len(keys) == 10 and set(settings) - set(keys) == {"stingerChanceOneIn", "automaticConvoyEnabled"}
assert set(keys) <= set(settings) # New text-command setting does not alter the fixed native menu.
# Native layouts place child widget declarations inside an anonymous Children
# block. A missing brace can preserve most widget names yet prevent menu load.
+57
View File
@@ -0,0 +1,57 @@
"""Actual settings read/default/bounds for next-round automatic convoy startup."""
import os,re
from pathlib import Path
from policy_harness import ROOT,body,run
BASE=Path(os.environ.get('ERR_CONVOY_SETTINGS_ROOT',ROOT))
P=BASE/'addons/core/Scripts/Game/ERR/ERR_Settings.c'
keys=body(P,'static array<string> Keys(').replace('array<string>','std::vector<string>')
valid=body(P,'static bool Valid(')
default=body(P,'static int AddedDefault(')
read=body(P,'protected bool Read(').replace('protected ','').replace('out map<string, int> values','Map*& values').replace('new map<string, int>()','new Map()')
read=read.replace('JsonLoadContext json','JsonLoadContext* json').replace('json.','json->').replace('values.','values->').replace('foreach (string key : Keys())','for (string key : Keys())').replace('Math.Round','std::round')
run(r'''
#include <cassert>
#include <string>
#include <map>
#include <vector>
#include <cmath>
using string=std::string;
struct Map{std::map<string,int> values;void Set(string k,int v){values[k]=v;}int Get(string k){return values[k];}};
std::map<string,std::map<string,float>> files;
struct JsonLoadContext{std::map<string,float>* data=nullptr;
bool LoadFromFile(string path){if(!files.count(path))return false;data=&files[path];return true;}
bool DoesKeyExist(string key){return data->count(key);}
bool ReadValue(string key,float& value){if(!DoesKeyExist(key))return false;value=(*data)[key];return true;}};
struct Settings{
'''+keys+valid+default+read+r'''
};
int main(){
assert(Settings::AddedDefault("automaticConvoyEnabled")==1);
assert(Settings::Valid("automaticConvoyEnabled",0)&&Settings::Valid("automaticConvoyEnabled",1));
assert(!Settings::Valid("automaticConvoyEnabled",-1)&&!Settings::Valid("automaticConvoyEnabled",2));
assert(Settings::AddedDefault("reportIntervalSeconds")==-1);
files["old"]={{"reportIntervalSeconds",10},{"supportSquadLimit",4},{"responseRadiusMetres",2000},
{"aiIntelChancePercent",60},{"intelRevealChancePercent",15},{"radioDefenceSeconds",180}};
Settings s;Map* loaded=nullptr;assert(s.Read("old",loaded)&&loaded->Get("automaticConvoyEnabled")==1);
assert(loaded->Get("reportIntervalSeconds")==10&&loaded->Get("stingerChanceOneIn")==15);
files["disabled"]=files["old"];files["disabled"]["automaticConvoyEnabled"]=0;
assert(s.Read("disabled",loaded)&&loaded->Get("automaticConvoyEnabled")==0);
files["enabled"]=files["old"];files["enabled"]["automaticConvoyEnabled"]=1;
assert(s.Read("enabled",loaded)&&loaded->Get("automaticConvoyEnabled")==1);
for(float bad:{-1.0f,2.0f,0.5f}){files["invalid"]=files["old"];files["invalid"]["automaticConvoyEnabled"]=bad;assert(!s.Read("invalid",loaded));}
files["incomplete"]=files["old"];files["incomplete"].erase("reportIntervalSeconds");assert(!s.Read("incomplete",loaded));
assert(!s.Read("missing",loaded));
}
''')
assert 'm_Values.Set("automaticConvoyEnabled", AddedDefault("automaticConvoyEnabled"));' in P.read_text()
admin=body(BASE/'addons/core/Scripts/Game/ERR/ERR_AdminService.c','void Execute(')
assert 'if (key == "automaticConvoyEnabled") line += " (saved for next round; current startup unchanged)";' in admin
provider=BASE/'addons/ai/Scripts/Game/ERR/ERR_HQAdminProvider.c'
helper=body(provider,'protected void ConvoyAutoSetting(')
for forbidden in ('StartConvoy','StartScenarioConvoy','Spawn','m_HQ.'):
assert forbidden not in helper
# Do not advertise this command-only setting as an editable field absent from the actual fixed menu.
menu=body(ROOT/'addons/core/Scripts/Game/ERR/ERR_AdminMenu.c','static array<string> Keys(')
assert 'automaticConvoyEnabled' not in menu
assert body(provider,'override bool Help(').count('replies.Insert(')<=4
print('PASS: actual read migrates old valid settings, preserves explicit disabled state, rejects invalid data; next-round feedback and fixed menu remain honest')
+41
View File
@@ -0,0 +1,41 @@
"""Execute one-shot startup timing and actual HQ dispatch boundary; no native claim."""
from policy_harness import ROOT, body, run
import re
p=ROOT/'addons/ai/Scripts/Game/ERR/ERR_ConvoyStartup.c'
s=p.read_text().replace('protected ','').replace('ERR_ForceRules.','ERR_ForceRules::')
s=s[s.index('class ERR_ConvoyStartup'):].replace('void ERR_ConvoyStartup(bool enabled)','void Initialize(bool enabled)')
s=s.replace('bool m_Enabled;', 'bool m_Enabled=false;').replace('bool m_Finished;', 'bool m_Finished=false;').replace('float m_Next;', 'float m_Next=0;')
s=s.replace('class ERR_ConvoyStartup', 'struct ERR_ConvoyStartup').rstrip()+';'
h=body(ROOT/'addons/ai/Scripts/Game/ERR/ERR_HQ.c','protected void AutomaticConvoy(')
h=h.replace('protected ','').replace('null','nullptr').replace('Replication.','Replication::').replace('ERR_GameMode.','ERR_GameMode::')
for name in ('m_Mode','m_ConvoyStartup'):h=h.replace(name+'.',name+'->')
run(r'''
#include <cassert>
#include <cmath>
#include <limits>
#include <string>
using string=std::string;
struct ERR_ForceRules {static bool Finite(float n){return std::isfinite(n);}};
'''+s+r'''
struct Replication {inline static bool server=true;static bool IsServer(){return server;}};
struct ERR_GameMode {inline static ERR_GameMode* current;bool active=true;static ERR_GameMode* Get(){return current;}bool Active(){return active;}};
struct Journal {int count=0;void Write(string,string){count++;}};
struct HQ {ERR_GameMode* m_Mode;ERR_ConvoyStartup* m_ConvoyStartup;void* m_ConvoyTask=nullptr;string m_ConvoyFailure;Journal m_Journal;bool ready=true,accepted=true;int calls=0;bool ConvoyAdmissionReady(){return ready;}bool StartScenarioConvoy(){calls++;return accepted;}
'''+h+r'''
};
int main(){
ERR_ConvoyStartup off;off.Initialize(false);assert(!off.Enabled()&&off.Finished());assert(!off.Decide(0,false,true)&&!off.Decide(90000,false,true));
ERR_ConvoyStartup once;once.Initialize(true);assert(once.Enabled());assert(!once.Decide(1000,false,true));assert(!once.Decide(30999,false,true));assert(once.Decide(31000,false,true));assert(once.Finished());assert(!once.Decide(36000,false,true));once.Complete(false);assert(once.Status()=="dispatch refused; no automatic retry");assert(!once.Decide(1000000,false,true));
ERR_ConvoyStartup later;later.Initialize(true);later.Decide(0,false,false);assert(!later.Decide(30000,false,false));assert(!later.Decide(34000,false,true));assert(later.Decide(35000,false,true));later.Complete(true);assert(later.Status()=="canonical convoy dispatched");assert(!later.Decide(60000,false,true));
ERR_ConvoyStartup expire;expire.Initialize(true);expire.Decide(0,false,false);assert(!expire.Decide(120000,false,true)&&expire.Finished());assert(expire.Status()=="startup admission deadline expired");
ERR_ConvoyStartup manual;manual.Initialize(true);assert(!manual.Decide(0,true,true)&&manual.Finished());assert(!manual.Decide(60000,false,true));
ERR_ConvoyStartup invalid;invalid.Initialize(true);assert(!invalid.Decide(std::numeric_limits<float>::quiet_NaN(),false,true));assert(!invalid.Decide(-1,false,true));invalid.Decide(10000,false,false);assert(!invalid.Decide(0,false,true));assert(invalid.Decide(40000,false,true));
ERR_ConvoyStartup stopped;stopped.Initialize(true);stopped.Decide(0,false,false);stopped.Stop();assert(!stopped.Decide(30000,false,true));
ERR_GameMode mode;ERR_GameMode::current=&mode;ERR_ConvoyStartup startup;startup.Initialize(true);HQ hq{&mode,&startup};
Replication::server=false;hq.AutomaticConvoy(0);assert(hq.calls==0&&hq.m_Journal.count==0);Replication::server=true;
ERR_GameMode::current=nullptr;hq.AutomaticConvoy(0);assert(hq.m_Journal.count==0);ERR_GameMode::current=&mode;
mode.active=false;hq.AutomaticConvoy(0);assert(hq.m_Journal.count==0);mode.active=true;
hq.AutomaticConvoy(0);hq.ready=false;hq.AutomaticConvoy(30000);assert(hq.calls==0);hq.ready=true;hq.accepted=false;hq.AutomaticConvoy(35000);assert(hq.calls==1&&startup.Finished());hq.AutomaticConvoy(100000);assert(hq.calls==1);
}
''')
print('PASS: actual automatic convoy timing/authority/admission/one-attempt/manual-task/stop policy; native deployment unproved')
+12 -7
View File
@@ -17,7 +17,7 @@ for cls in ('ERR_GameMode','SCR_AIGroup','SCR_AIGroupUtilityComponent','IEntity'
code=re.sub(r'\b'+cls+r' ([a-zA-Z_]\w*)',cls+r'* \1',code)
for obj in ('mode','group','utility','Actor','Group','Combat','Optics','Operation','item','weapons','inventory'):
code=code.replace(obj+'.',obj+'->')
for cls in ('Replication','SCR_Global','ERR_GameMode','ERR_Actors','ERR_EnemyEquipmentRules','EAISkill','ERR_HQ','Math','EStoragePurpose'):
for cls in ('Replication','SCR_Global','ERR_GameMode','ERR_Actors','ERR_EnemyEquipmentRules','ERR_EnemyGunnerSkill','EAISkill','ERR_HQ','Math','EStoragePurpose'):
code=code.replace(cls+'.',cls+'::')
code=code.replace('GetPrefabData().','GetPrefabData()->').replace('Settings().','Settings()->').replace('foreach (','for (').replace('null','nullptr').replace('super.','NativeCombat::')
code=code.replace('new ERR_EnemyEquipmentCallback()','NewOperation()')
@@ -67,6 +67,11 @@ struct NativeCombat {
IEntity* GetOwner(){return owner;}EAISkill GetAISkill(){return skill;}
virtual void SetAISkill(EAISkill value){skill=value;}virtual void ResetAISkill(){skill=EAISkill::ROOKIE;}
};
namespace ERR_EnemyGunnerSkill {
bool CapturedGroup(ERR_GameMode*,SCR_AIGroup*){return false;}
bool OriginalGunner(ERR_GameMode*,SCR_AIGroup*,IEntity*){return false;}
EAISkill Profile(EAISkill requested,bool){return requested<EAISkill::EXPERT?EAISkill::EXPERT:requested;}
}
struct ERR_EnemyOptics{int stops=0;void Stop(){stops++;}};
''' + code + r'''
std::vector<std::unique_ptr<ERR_EnemyEquipmentCallback>> operations;
@@ -151,7 +156,7 @@ namespace Replication {bool IsServer(){return true;}}
template<typename T> struct Array:std::vector<T>{int Count(){return this->size();}void Insert(T value){this->push_back(value);}void Clear(){this->clear();}};
struct SCR_AIGroup;
struct ERR_GameMode{static inline ERR_GameMode* current=nullptr;bool active=true;bool Active(){return active;}static ERR_GameMode* Get(){return current;}};
struct SCR_AICombatComponent{int binds=0;void ERR_BindEnemySkill(ERR_GameMode*,SCR_AIGroup*){binds++;}};
struct SCR_AICombatComponent{int binds=0,refreshes=0;void ERR_BindEnemySkill(ERR_GameMode*,SCR_AIGroup*){binds++;}void ERR_RefreshEnemySkill(){refreshes++;}};
struct IEntity{SCR_AICombatComponent* combat=nullptr;bool eligible=true;};
struct AIAgent{IEntity* actor=nullptr;IEntity* GetControlledEntity(){return actor;}};
struct SCR_AIGroup{Array<AIAgent*> agents;void GetAgents(Array<AIAgent*>& out){out=agents;}};
@@ -172,13 +177,13 @@ int main(){
ERR_GameMode mode;ERR_GameMode::current=&mode;ERR_EnemyEquipment service(&mode);
SCR_AIGroup group;IEntity soldier;SCR_AICombatComponent combat;soldier.combat=&combat;AIAgent agent;agent.actor=&soldier;group.agents.Insert(&agent);
service.RegisterGroup(&group);service.RegisterGroup(&group);assert(service.m_Groups.Count()==1);
service.Tick(0);assert(service.m_Entries.Count()==0);service.Tick(3000);assert(service.m_Entries.Count()==1&&combat.binds==1);
service.Tick(0);assert(service.m_Entries.Count()==0);service.Tick(3000);assert(service.m_Entries.Count()==1&&combat.binds==1&&combat.refreshes==1);
auto entry=service.m_Entries[0];assert(entry->Optics&&entry->Optics->actor==&soldier&&entry->Optics->group==&group);
assert(entry->Complete&&entry->Optics->checks==1); // Stinger is already complete; optics has its own startup completion.
service.Tick(3001);assert(entry->checks==1&&entry->Optics->checks==1);
service.Tick(6000);assert(entry->checks==2&&entry->Optics->checks==2&&combat.binds==1);
assert(entry->Complete&&entry->Optics->checks==1&&combat.refreshes==1); // Stinger is already complete; optics has its own startup completion.
service.Tick(3001);assert(entry->checks==1&&entry->Optics->checks==1&&combat.refreshes==1);
service.Tick(6000);assert(entry->checks==2&&entry->Optics->checks==2&&combat.binds==1&&combat.refreshes==2);
service.UnregisterGroup(&group);service.Tick(9000);assert(service.m_Entries[0]->Released&&service.m_Entries[0]->checks==2&&entry->Optics->checks==2&&entry->Optics->stops==1);
service.RegisterGroup(&group);service.Tick(12000);assert(service.m_Entries.Count()==1&&combat.binds==1);
service.RegisterGroup(&group);service.Tick(12000);assert(service.m_Entries.Count()==1&&combat.binds==1&&combat.refreshes==2);
service.Stop();service.RegisterGroup(&group);service.Tick(50000);assert(service.m_Groups.Count()==0&&service.m_Stopped);
ERR_EnemyEquipment delayed(&mode);SCR_AIGroup late;delayed.RegisterGroup(&late);delayed.Tick(3000);late.agents.Insert(&agent);delayed.Tick(6000);assert(delayed.m_Entries.Count()==1);
ERR_EnemyEquipment expired(&mode);SCR_AIGroup empty;expired.RegisterGroup(&empty);for(int i=1;i<=6;i++){expired.Tick(i*3000);}empty.agents.Insert(&agent);expired.Tick(21000);assert(expired.m_Entries.Count()==0);
+115
View File
@@ -0,0 +1,115 @@
"""Actual scoped binding and original turret identity predicate, with native doubles."""
import os,re
from pathlib import Path
from policy_harness import ROOT,run
helper=Path(os.environ.get('ERR_GUNNER_SOURCE', str(ROOT/'addons/ai/Scripts/Game/ERR/ERR_EnemyGunnerSkill.c'))).read_text()
equipment=Path(os.environ.get('ERR_EQUIPMENT_SOURCE', str(ROOT/'addons/ai/Scripts/Game/ERR/ERR_EnemyEquipment.c'))).read_text()
def convert(s):
s=re.sub(r'//[^\n]*','',s).replace('protected ','').replace('override ','')
s=s.replace('ERR_HQ.Cast(mode.Command())','mode->Command()').replace('TurretCompartmentSlot.Cast(slot)','dynamic_cast<TurretCompartmentSlot*>(slot)')
for cls in ('ERR_GameMode','SCR_AIGroupUtilityComponent','SCR_AIGroup','IEntity','ERR_HQ','ERR_ConvoyTask','ERR_ConvoyVehicle','ERR_ForceRecord','BaseCompartmentSlot'):
s=re.sub(r'\b'+cls+r' ([a-zA-Z_]\w*)',cls+r'* \1',s)
for obj in ('mode','group','actor','utility','hq','task','unit','record','slot'):
s=s.replace(obj+'.',obj+'->')
for field in ('Receipt','Entity'):
s=s.replace(field+'.',field+'->')
s=s.replace('Forces().','Forces()->')
for cls in ('ERR_EnemyEquipmentRules','ERR_EnemyGunnerSkill','ERR_ConvoyRules','ERR_Actors','Replication','SCR_Global','ERR_GameMode','EAISkill','ERR_ForceKind','ECompartmentType','CompartmentAccessComponent'):
s=s.replace(cls+'.',cls+'::')
s=s.replace('super.','NativeCombat::').replace('null','nullptr').replace('foreach (','for (')
s=re.sub(r'\b(bool|int|EAISkill|[A-Za-z_]\w*\*) ([A-Za-z_]\w*);',r'\1 \2 {};',s)
return s
rules=equipment[equipment.index('class ERR_EnemyEquipmentRules'):equipment.index('class ERR_EnemyEquipmentCallback')]
rules=convert(rules.replace('class ERR_EnemyEquipmentRules','struct ERR_EnemyEquipmentRules').replace('modded class SCR_AICombatComponent','struct SCR_AICombatComponent : NativeCombat')).replace('\n}\n','\n};\n')
# Declare helper before rules, then define its real method bodies after rules.
helper=helper[helper.index(' static bool CapturedGroup'):helper.rfind('}')]
helper=convert(helper).replace('static bool CapturedGroup','bool ERR_EnemyGunnerSkill::CapturedGroup').replace('static bool OriginalGunner','bool ERR_EnemyGunnerSkill::OriginalGunner').replace('static EAISkill Profile','EAISkill ERR_EnemyGunnerSkill::Profile')
run(r'''
#include <cassert>
#include <vector>
enum class EAISkill{ROOKIE=20,REGULAR=50,EXPERT=80,CYLON=100};
enum class ERR_ForceKind{CONVOY,GUARD};
enum class ECompartmentType{TURRET,CARGO};
template<class T>struct Array:std::vector<T>{using std::vector<T>::vector;using std::vector<T>::operator=;int Count(){return this->size();}};
namespace Replication{bool server=true;bool IsServer(){return server;}}
namespace SCR_Global{bool edit=false;bool IsEditMode(){return edit;}}
struct SCR_AIGroupUtilityComponent{bool military=true;bool IsMilitary(){return military;}};
struct SCR_AIGroup{SCR_AIGroupUtilityComponent utility;int players=0,faction=1;auto GetGroupUtilityComponent(){return &utility;}int GetPlayerCount(bool){return players;}int GetFaction(){return faction;}};
struct IEntity{virtual ~IEntity(){}bool alive=true,player=false;SCR_AIGroup* group=nullptr;IEntity* vehicle=nullptr;};
using Vehicle=IEntity;
struct BaseCompartmentSlot{virtual ~BaseCompartmentSlot(){}IEntity* occupant=nullptr;IEntity* vehicle=nullptr;ECompartmentType type=ECompartmentType::TURRET;auto GetType(){return type;}auto GetOccupant(){return occupant;}auto GetVehicle(){return vehicle;}};
struct TurretCompartmentSlot:BaseCompartmentSlot{};
struct ERR_ForceRecord{int Id=8;bool Physical=true,Eliminated=false;ERR_ForceKind Kind=ERR_ForceKind::CONVOY;};
struct Ledger{ERR_ForceRecord* record=nullptr;auto FindGroup(SCR_AIGroup*){return record;}};
struct ERR_ConvoyReceipt{bool Consumed=true;Array<int> Records={8};};
struct ERR_ConvoyVehicle{SCR_AIGroup* Group=nullptr;Vehicle* Entity=nullptr;bool Registered=true,Released=false;int Index=0;Array<IEntity*> Members;Array<BaseCompartmentSlot*> Seats;};
struct ERR_ConvoyTask{bool Ready=true,Stopped=false;ERR_ConvoyReceipt* Receipt=nullptr;Array<ERR_ConvoyVehicle*> Vehicles;};
struct ERR_HQ{ERR_ConvoyTask* task=nullptr;auto ConvoyTask(){return task;}};
struct ERR_GameMode{static inline ERR_GameMode* current=nullptr;bool active=true;ERR_HQ* hq=nullptr;Ledger* ledger=nullptr;static auto Get(){return current;}bool Active(){return active;}int EnemyFaction(){return 1;}auto Command(){return hq;}auto Forces(){return ledger;}};
namespace ERR_Actors{bool CanFight(IEntity* a){return a->alive;}bool Alive(IEntity* a){return a->alive;}bool Player(IEntity* a){return a->player;}auto Group(IEntity* a){return a->group;}}
namespace ERR_ConvoyRules{bool capture=false;bool Captured(Vehicle*,SCR_AIGroup* group){return capture||group->players>0;}bool PartOf(IEntity* a,IEntity* b){return a==b;}}
namespace CompartmentAccessComponent{auto GetVehicleIn(IEntity* actor){return actor->vehicle;}}
struct ERR_EnemyGunnerSkill{static bool CapturedGroup(ERR_GameMode*,SCR_AIGroup*);static bool OriginalGunner(ERR_GameMode*,SCR_AIGroup*,IEntity*);static EAISkill Profile(EAISkill,bool);};
struct NativeCombat{IEntity* owner=nullptr;EAISkill skill=EAISkill::REGULAR;int writes=0;auto GetOwner(){return owner;}auto GetAISkill(){return skill;}virtual void SetAISkill(EAISkill value){writes++;skill=value;}virtual void ResetAISkill(){skill=EAISkill::ROOKIE;}};
''' + rules + helper + r'''
int main(){
ERR_GameMode mode;ERR_GameMode::current=&mode;SCR_AIGroup group;IEntity actor,vehicle,other;actor.group=&group;actor.vehicle=&vehicle;
TurretCompartmentSlot slot;slot.occupant=&actor;slot.vehicle=&vehicle;BaseCompartmentSlot falseTurret;falseTurret.occupant=&actor;falseTurret.vehicle=&vehicle;
ERR_ConvoyVehicle unit;unit.Group=&group;unit.Entity=&vehicle;unit.Members={&actor};unit.Seats={&slot};
ERR_ConvoyReceipt receipt;ERR_ConvoyTask task;task.Receipt=&receipt;task.Vehicles={&unit};ERR_HQ hq;hq.task=&task;mode.hq=&hq;
ERR_ForceRecord record;Ledger ledger;ledger.record=&record;mode.ledger=&ledger;
assert(ERR_EnemyGunnerSkill::OriginalGunner(&mode,&group,&actor));
// Every native/canonical ownership fact independently matters.
for(int i=0;i<25;i++){
auto savedActor=actor;auto savedVehicle=vehicle;auto savedGroup=group;auto savedUnit=unit;auto savedTask=task;auto savedReceipt=receipt;auto savedRecord=record;auto savedSlot=slot;
if(i==0)Replication::server=false;
if(i==1)SCR_Global::edit=true;
if(i==2)mode.active=false;
if(i==3)actor.player=true;
if(i==4)actor.alive=false;
if(i==5)actor.group=nullptr;
if(i==6)group.players=1;
if(i==7)group.faction=2;
if(i==8)group.utility.military=false;
if(i==9)task.Stopped=true;
if(i==10)task.Ready=false;
if(i==11)receipt.Consumed=false;
if(i==12)unit.Registered=false;
if(i==13)unit.Released=true;
if(i==14)vehicle.alive=false;
if(i==15)record.Physical=false;
if(i==16)record.Eliminated=true;
if(i==17)record.Kind=ERR_ForceKind::GUARD;
if(i==18)record.Id=9;
if(i==19)unit.Members[0]=&other;
if(i==20)slot.occupant=&other;
if(i==21)unit.Seats[0]=&falseTurret;
if(i==22)slot.type=ECompartmentType::CARGO;
if(i==23)slot.vehicle=&other;
if(i==24)actor.vehicle=&other;
assert(!ERR_EnemyGunnerSkill::OriginalGunner(&mode,&group,&actor));
actor=savedActor;vehicle=savedVehicle;group=savedGroup;unit=savedUnit;task=savedTask;receipt=savedReceipt;record=savedRecord;slot=savedSlot;
Replication::server=true;SCR_Global::edit=false;mode.active=true;
}
ERR_ConvoyRules::capture=true;assert(!ERR_EnemyGunnerSkill::OriginalGunner(&mode,&group,&actor));ERR_ConvoyRules::capture=false;
// Current turret specialization must not overwrite the underlying requested skill.
SCR_AICombatComponent combat;combat.owner=&actor;combat.ERR_BindEnemySkill(&mode,&group);assert(combat.skill==EAISkill::CYLON);
combat.ResetAISkill();assert(combat.skill==EAISkill::CYLON);
combat.SetAISkill(static_cast<EAISkill>(90));assert(combat.skill==EAISkill::CYLON);
actor.vehicle=nullptr;slot.occupant=nullptr;combat.ERR_RefreshEnemySkill();assert(static_cast<int>(combat.skill)==90);
actor.vehicle=&vehicle;slot.occupant=&actor;combat.ERR_RefreshEnemySkill();assert(combat.skill==EAISkill::CYLON);
combat.SetAISkill(EAISkill::ROOKIE);slot.occupant=nullptr;combat.ERR_RefreshEnemySkill();assert(combat.skill==EAISkill::EXPERT);
slot.occupant=&actor;combat.ERR_RefreshEnemySkill();assert(combat.skill==EAISkill::CYLON);
// Captured group refresh cannot mutate any soldier.
ERR_ConvoyRules::capture=true;int writes=combat.writes;combat.ERR_RefreshEnemySkill();assert(combat.writes==writes);ERR_ConvoyRules::capture=false;
combat.ERR_ReleaseEnemySkill(&mode);assert(combat.skill==EAISkill::REGULAR);
// A legitimately high underlying setting stays high after leaving a gunner seat.
combat.SetAISkill(EAISkill::CYLON);combat.ERR_BindEnemySkill(&mode,&group);slot.occupant=nullptr;combat.ERR_RefreshEnemySkill();assert(combat.skill==EAISkill::CYLON);
combat.ERR_ReleaseEnemySkill(&mode);assert(combat.skill==EAISkill::CYLON);
// Ordinary original infantry retain the existing floor without convoy identity.
hq.task=nullptr;combat.SetAISkill(EAISkill::REGULAR);combat.ERR_BindEnemySkill(&mode,&group);assert(combat.skill==EAISkill::EXPERT);
combat.SetAISkill(static_cast<EAISkill>(90));assert(static_cast<int>(combat.skill)==90);
combat.ERR_ReleaseEnemySkill(&mode);assert(combat.skill==EAISkill::REGULAR);
}
''')
print('PASS: actual original turret identity, native/canonical guards, scoped >=80/>=100 profile, reset, dismount/reboard, requested-skill preservation and restoration')
+1 -1
View File
@@ -36,7 +36,7 @@ coordinator=coordinator.replace('GetGame().GetWorld().GetWorldTime()','worldTime
coordinator=re.sub(r'\b(bool|int|float|SCR_AIGroup|[A-Za-z_]\w*\*) ([A-Za-z_]\w*);',r'\1 \2 {};',coordinator).replace('\n}\n','\n};\n')
hook_stubs=r'''
float worldTime=0;
struct SCR_AICombatComponent{void ERR_BindEnemySkill(ERR_GameMode*,Group*){}};
struct SCR_AICombatComponent{void ERR_BindEnemySkill(ERR_GameMode*,Group*){} void ERR_RefreshEnemySkill(){}};
struct ERR_EnemyOptics{ERR_EnemyOptics(IEntity,Group*){}void Check(ERR_GameMode*){}};
struct ERR_EnemyEquipmentEntry{IEntity Actor=nullptr;struct Group*Group=nullptr;SCR_AICombatComponent*Combat=nullptr;ERR_EnemyOptics*Optics=nullptr;bool Released=false;int SkillChecks=0;
void Check(ERR_GameMode*){}void Stop(ERR_GameMode*){Released=true;}};
+57
View File
@@ -0,0 +1,57 @@
"""Actual result callbacks/UI lifecycle with engine boundaries doubled, not native replication."""
import os,re,sys
from pathlib import Path
REPO=Path(os.environ.get('ERR_REPO','/home/jakub/devel/escapistsrereintroduced'))
sys.path.insert(0,str(REPO/'tests'))
from policy_harness import body,run
SOURCE=Path(os.environ.get('ERR_RESULT_SOURCE',REPO/'addons/core/Scripts/Game/ERR/ERR_EscapeGameOverScreenInfo.c'))
MODE=Path(os.environ.get('ERR_MODE_SOURCE',REPO/'addons/core/Scripts/Game/ERR/ERR_GameMode.c'))
def adapt(s):
s=s.replace('protected ','').replace('override ','').replace('null','nullptr')
s=s.replace('ERR_GameMode.Get()','Mode::Get()').replace('ERR_GameMode mode','Mode* mode')
s=s.replace('ERR_FailureGameOverScreenInfo.Cast(', 'FailureInfo::Cast(').replace('ERR_EscapeGameOverScreenInfo.Cast(', 'EscapeInfo::Cast(')
s=s.replace('ERR_FailureGameOverScreenInfo info = new ERR_FailureGameOverScreenInfo();','FailureInfo info;')
s=s.replace('TextWidget.Cast(', 'TextWidget::Cast(').replace('TextWidget debrief','TextWidget* debrief')
s=s.replace('SCR_PlayerController.ERR_OnEscapeResultChanged()','escapeEvent')
s=s.replace('SCR_GameOverScreenUIContentData endScreenUIContent','Data* endScreenUIContent').replace('Widget w','int w')
s=s.replace('array<int> otherPlayerIds','void* otherPlayerIds').replace('string ','std::string ')
s=s.replace('super.InitContent(endScreenUIContent);','nativeInit++;').replace('super.HandlerDeattached(w);','nativeDetach++;')
for name in ('m_ERRFailureMode','m_wRoot','debrief','mode','endScreenUIContent'):s=s.replace(name+'.',name+'->')
s=s.replace('OnEndReasonChanged().','OnEndReasonChanged()->').replace('EndReason().IsEmpty()','EndReason().empty()')
s=s.replace('Insert(ERR_RefreshFailureContent)','Insert(1)').replace('Remove(ERR_RefreshFailureContent)','Remove(1)')
s=s.replace('Insert(ERR_RefreshEscapeContent)','Insert(2)').replace('Remove(ERR_RefreshEscapeContent)','Remove(2)')
return s
failure=body(SOURCE,'class ERR_FailureGameOverScreenInfo')
failure=failure[failure.index(' override string'):failure.rindex('}')]
callbacks='\n'.join(body(SOURCE,x) for x in ('override void InitContent(','protected void ERR_RefreshFailureContent(','override void HandlerDeattached('))
notify=body(MODE,'protected void ERR_OnEndReasonReplicated(')
assert '[RplProp(onRplName: "ERR_OnEndReasonReplicated")]' in MODE.read_text()
run(r'''
#include <cassert>
#include <string>
#include <functional>
struct Event{int count=0;std::function<void()> handler;void Insert(int){count=1;}void Remove(int){count=0;}void Invoke(){if(count&&handler)handler();}};
struct Mode{static inline Mode* current=nullptr;Event m_OnEndReasonChanged;std::string reason;static Mode* Get(){return current;}std::string EndReason(){return reason;}Event* OnEndReasonChanged(){return &m_OnEndReasonChanged;}
NOTIFY
};
struct FailureInfo{static bool Cast(int kind){return kind==1;}
FAILURE
};
struct EscapeInfo{static EscapeInfo* Cast(int kind){static EscapeInfo value;return kind==2?&value:nullptr;}};
struct TextWidget{std::string text;int writes=0;static TextWidget* Cast(TextWidget* w){return w;}void SetText(std::string value){text=value;writes++;}};
struct Root{TextWidget debrief;bool exists=true;TextWidget* FindAnyWidget(std::string){return exists?&debrief:nullptr;}};
struct Data{int m_GameOverInfo;};
struct Content{Event escapeEvent;bool m_ERREscapeContent=false;Mode* m_ERRFailureMode=nullptr;Root* m_wRoot=nullptr;std::string m_sDebriefingName="debrief";int nativeInit=0,nativeDetach=0,escapeRefresh=0;void ERR_RefreshEscapeContent(){escapeRefresh++;}
CALLBACKS
};
int main(){Mode mode;Mode::current=&mode;Root root;Content content;content.m_wRoot=&root;mode.m_OnEndReasonChanged.handler=[&](){content.ERR_RefreshFailureContent();};Data failure{1},escape{2},other{3};
content.InitContent(&failure);assert(mode.m_OnEndReasonChanged.count==1&&root.debrief.text.find("No surviving")!=std::string::npos);
mode.reason="An administrator abandoned this round.";mode.ERR_OnEndReasonReplicated();assert(root.debrief.text==mode.reason&&root.debrief.writes==2);
content.HandlerDeattached(0);assert(mode.m_OnEndReasonChanged.count==0&&content.m_ERRFailureMode==nullptr);mode.reason="late";mode.ERR_OnEndReasonReplicated();assert(root.debrief.writes==2);
content.InitContent(&failure);assert(root.debrief.writes==3);content.InitContent(&escape);assert(mode.m_OnEndReasonChanged.count==0&&content.escapeEvent.count==1&&content.escapeRefresh==1);mode.ERR_OnEndReasonReplicated();assert(root.debrief.writes==3);
content.InitContent(&failure);assert(content.escapeEvent.count==0);Mode replacement;Mode::current=&replacement;mode.ERR_OnEndReasonReplicated();assert(root.debrief.writes==4);
Mode::current=&mode;content.m_wRoot=nullptr;mode.ERR_OnEndReasonReplicated();assert(root.debrief.writes==4);content.m_wRoot=&root;root.exists=false;mode.ERR_OnEndReasonReplicated();assert(root.debrief.writes==4);
content.InitContent(&other);assert(content.m_ERRFailureMode==nullptr&&mode.m_OnEndReasonChanged.count==0&&content.escapeEvent.count==0);
}
'''.replace('NOTIFY',adapt(notify)).replace('FAILURE',adapt(failure)).replace('CALLBACKS',adapt(callbacks)))
print('PASS: late authoritative failure reason refreshes open result; detach, mode change and success subscriptions preserved')
+33 -7
View File
@@ -2,16 +2,17 @@
from policy_harness import ROOT,body,run
import re,os
from pathlib import Path
H=ROOT/'addons/ai/Scripts/Game/ERR/ERR_HQ.c'
H=Path(os.environ.get('ERR_HQ_SOURCE',ROOT/'addons/ai/Scripts/Game/ERR/ERR_HQ.c'))
P=Path(os.environ.get('ERR_HQ_ADMIN_SOURCE',str(ROOT/'addons/ai/Scripts/Game/ERR/ERR_HQAdminProvider.c')))
A=ROOT/'addons/core/Scripts/Game/ERR/ERR_AdminService.c'
BASE=Path(os.environ.get('ERR_CONVOY_SETTINGS_ROOT', ROOT))
A=BASE/'addons/core/Scripts/Game/ERR/ERR_AdminService.c'
def cpp(s):
for old,new in [('protected ',''),('override ',''),('null','nullptr'),('Replication.','Replication::'),
('ERR_GameMode.','ERR_GameMode::'),('ERR_ConvoyRoadPlan.','ERR_ConvoyRoadPlan::'),
('ERR_Settings.','ERR_Settings::'),('Math.','Math::'),('array<string>','Array<string>&')]:s=s.replace(old,new)
for typ in ('ERR_GameMode','ERR_ConvoyReceipt','ERR_ConvoyTask','ERR_SiteRuntime','ERR_HQUnit','ERR_AdminProvider'):
s=re.sub(r'\b'+typ+r' (?=\w)',typ+'* ',s)
for var in ('m_Mode','m_Convoys','m_ConvoyPool','m_ConvoyTask','m_HQ','site','unit','receipt','mode','provider'):
for var in ('m_ConvoyStartup','m_Mode','m_Convoys','m_ConvoyPool','m_ConvoyTask','m_HQ','site','unit','receipt','mode','provider'):
s=s.replace(var+'.',var+'->')
s=s.replace('site->Definition.', 'site->Definition->')
for name in ('GetGame()','GetWorld()','Forces()','Sites()','Settings()'):s=s.replace(name+'.',name+'->')
@@ -27,9 +28,9 @@ def cpp(s):
return s
hq='\n'.join(cpp(body(H,s)) for s in ('static bool MissionActive(', 'bool PrepareConvoy(', 'bool StartConvoy(',
'bool StartScenarioConvoy(', 'string ConvoyStatus('))
provider=cpp(body(P,'protected void StingerSetting('))+cpp(body(P,'override bool Execute('))
provider=cpp(body(P,'protected void ConvoyAutoSetting('))+cpp(body(P,'protected void StingerSetting('))+cpp(body(P,'override bool Execute('))
integer=cpp(body(A,'static bool Integer('))
validator=cpp(body(ROOT/'addons/core/Scripts/Game/ERR/ERR_Settings.c','static bool Valid('))
validator=cpp(body(BASE/'addons/core/Scripts/Game/ERR/ERR_Settings.c','static bool Valid('))
extension=cpp(body(A,'protected bool Extension('))
# Execute the exact complete auth/normalization/extension-dispatch region.
# Unrelated settings/help parsers after this boundary are covered by their own suites.
@@ -77,10 +78,11 @@ struct ERR_SiteRuntime{struct Definition* Definition=nullptr;};
struct Sites{ERR_SiteRuntime* site=nullptr;int finds=0;string last;
ERR_SiteRuntime* Find(string id){finds++;last=id;return site;}};
struct ERR_Settings{
int stinger=15,writes=0;bool saveFails=false;
static Array<string> Keys(){return {}; }int Get(string key){return key=="stingerChanceOneIn"?stinger:1;}
int automatic=1,stinger=15,writes=0;bool saveFails=false;
static Array<string> Keys(){return {}; }int Get(string key){return key=="stingerChanceOneIn"?stinger:automatic;}
'''+validator+r'''
bool Set(string key,int value){writes++;if(saveFails||!Valid(key,value))return false;
if(key=="automaticConvoyEnabled"){automatic=value;return true;}
assert(key=="stingerChanceOneIn");stinger=value;return true;}
};
struct ERR_GameMode{
@@ -118,7 +120,9 @@ struct Service{
}
string Status(){statusCalls++;return task.Stopped?"stopped; retained vehicles=3":"convoy service status";}
};
struct ERR_ConvoyStartup{string Status(){return "startup snapshot";}};
struct HQ{
ERR_ConvoyStartup* m_ConvoyStartup=nullptr;
ERR_GameMode* m_Mode;Pool* m_ConvoyPool;Service* m_Convoys;ERR_ConvoyTask* m_ConvoyTask=nullptr;
Array<vector>m_ConvoyPositions,m_ConvoyAngles;ERR_ForceRoute* m_ConvoyRoute=nullptr;string m_ConvoyFailure;
Array<ERR_HQUnit*>m_Units;
@@ -177,6 +181,28 @@ int main(){
admin.Execute(&mode,true,"hq stinger set 1junk",replies);assert(mode.settings.writes==writes&&mode.settings.stinger==0);
admin.Execute(&mode,true,"hq stinger set 10001",replies);assert(mode.settings.stinger==0&&replies[0].find("previous setting retained")!=string::npos);
replies.Clear();assert(unavailable.Execute(&mode,"hq stinger set 1",replies)&&replies[0]=="HQ unavailable.");
// New automatic switch is saved only: it does not scan, reserve or launch now.
writes=mode.settings.writes;
admin.Execute(&mode,false,"hq convoy auto set 0",replies);assert(mode.settings.writes==writes&&mode.settings.automatic==1);
Replication::server=false;admin.Execute(&mode,true,"hq convoy auto set 0",replies);
assert(replies.IsEmpty()&&mode.settings.writes==writes);Replication::server=true;
mode.settingsReady=false;admin.Execute(&mode,true,"hq convoy auto set 0",replies);
assert(mode.settings.writes==writes&&replies[0]=="Mission settings are not ready.");mode.settingsReady=true;
admin.Execute(&mode,true," HQ CONVOY AUTO SET 0 ",replies);
assert(mode.settings.automatic==0&&pool.calls==0&&service.calls==0&&ERR_ConvoyRoadPlan::calls==0);
assert(replies[0].find("next round only")!=string::npos&&replies[0].find("manual convoy start unchanged")!=string::npos);
admin.Execute(&mode,true,"hq convoy auto show",replies);assert(replies[0].find("automaticConvoyEnabled=0 saved for next round")!=string::npos);
admin.Execute(&mode,true,"hq convoy auto help",replies);assert(replies[0].find("1 enabled (default)")!=string::npos);
writes=mode.settings.writes;
for(string bad:{"hq convoy auto set", "hq convoy auto set 1 extra", "hq convoy auto show extra", "hq convoy auto set -1", "hq convoy auto set 1.0", "hq convoy auto set 1junk"}){
admin.Execute(&mode,true,bad,replies);assert(mode.settings.writes==writes&&mode.settings.automatic==0);
}
admin.Execute(&mode,true,"hq convoy auto set 2",replies);assert(mode.settings.automatic==0&&replies[0].find("previous setting retained")!=string::npos);
mode.settings.saveFails=true;admin.Execute(&mode,true,"hq convoy auto set 1",replies);
assert(mode.settings.automatic==0&&replies[0].find("previous setting retained")!=string::npos);mode.settings.saveFails=false;
replies.Clear();assert(unavailable.Execute(&mode,"hq convoy auto set 1",replies)&&replies[0]=="HQ unavailable.");
// Disabled automatic startup does not disable manual canonical dispatch below.
assert(mode.settings.automatic==0&&pool.calls==0&&service.calls==0);
// Canonical authored site and preparation failures refuse without consuming the finite receipt.
mode.sitesReady=false;assert(!hq.StartScenarioConvoy());mode.sitesReady=true;
mode.sites.site=nullptr;assert(!hq.StartScenarioConvoy());mode.sites.site=&site;
+31
View File
@@ -0,0 +1,31 @@
"""Actual native-fixture startup/deadline gate, engine boundaries doubled."""
import os,sys
from pathlib import Path
REPO=Path(os.environ.get('ERR_REPO','/home/jakub/devel/escapistsrereintroduced'))
sys.path.insert(0,str(REPO/'tests'))
from policy_harness import body,run
p=Path(os.environ.get('ERR_MEMORY_PROBE_SOURCE',REPO/'addons/chat/Scripts/Game/ERR/ERR_NativeHQMemoryProbe.c'))
s=body(p,'int Tick(ERR_HQ hq, float now)')
# Execute the unchanged deadline/startup prefix; payload/native radio assertions
# after this gate remain the full fixture's responsibility.
s=s[:s.index(' if (!m_ValueChecked)')]+ ' }\n return 1;\n }'
s=s.replace('ERR_HQ hq','ERR_HQ* hq').replace('hq.','hq->')
run(r'''
#include <cassert>
#include <string>
struct ERR_HQ{bool ready=true;bool ERR_ProbeSquadsReady(){return ready;}};
struct Probe{bool enabled=true,m_Mode=true,m_Failed=false,m_AutoChecked=false;int m_Step=0,automatic=0,checks=0;float m_Deadline=0;bool Enabled(){return enabled;}void Check(bool,std::string){checks++;m_Failed=true;}int AutomaticConvoy(ERR_HQ*){if(automatic==1)m_AutoChecked=true;return automatic;}
METHOD
};
int main(){ERR_HQ hq;
Probe p;assert(p.Tick(&hq,1000)==0&&p.m_Deadline==151000);assert(p.Tick(&hq,130000)==0&&p.m_Deadline==151000);p.automatic=1;assert(p.Tick(&hq,145000)==1&&p.m_Deadline==265000);assert(p.Tick(&hq,200000)==1&&p.m_Deadline==265000);assert(p.Tick(&hq,265000)==1);assert(p.Tick(&hq,265001)==-1&&p.checks==1);
Probe missing;assert(missing.Tick(&hq,0)==0);assert(missing.Tick(&hq,150001)==-1&&missing.checks==1);
Probe refused;refused.Tick(&hq,0);refused.automatic=-1;assert(refused.Tick(&hq,40000)==-1&&refused.m_Deadline==150000&&!refused.m_AutoChecked);
Probe unavailable;hq.ready=false;assert(unavailable.Tick(&hq,0)==0);assert(unavailable.Tick(&hq,150001)==-1);hq.ready=true;
Probe invalid;invalid.enabled=false;assert(invalid.Tick(&hq,0)==-1&&invalid.m_Deadline==0);
}
'''.replace('METHOD',s))
smoke=Path(os.environ.get('ERR_SMOKE_SOURCE',REPO/'addons/chat/Scripts/Game/ERR/ERR_NativeSmoke.c')).read_text()
assert 'if (m_ERRTestHQMemory) m_ERRSmokeDeadline = GetWorld().GetWorldTime() + 360000;' in smoke
assert 'if (m_ERRTestHQBody) m_ERRSmokeDeadline = GetWorld().GetWorldTime() + 240000;' in smoke
print('PASS: bounded150s startup followed by exactly one120s memory window; only HQ-memory outer deadline extended')
+22 -5
View File
@@ -1,11 +1,23 @@
from pathlib import Path
import importlib.util,json,tempfile
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',ROOT/'tools/import_settings.py')
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(reportIntervalSeconds=20,supportSquadLimit=2,responseRadiusMetres=1500,aiIntelChancePercent=0,intelRevealChancePercent=100,radioDefenceSeconds=180,startTimeMinutes=840,timeMultiplier=1,startDateCode=0,weatherProfile=0,stingerChanceOneIn=15)
assert deferred==['ai:convoyCount']
assert result==dict(reportIntervalSeconds=20,supportSquadLimit=2,responseRadiusMetres=1500,aiIntelChancePercent=0,intelRevealChancePercent=100,radioDefenceSeconds=180,startTimeMinutes=840,timeMultiplier=1,startDateCode=0,weatherProfile=0,stingerChanceOneIn=15,automaticConvoyEnabled=1)
assert deferred==['ai:convoyCount=3 mapped to automaticConvoyEnabled=1; successor capped at one initial convoy']
for count in (0,1,3,20):
values,notes=m.merge(ai={'convoyCount':count})
assert values['automaticConvoyEnabled']==int(count>0)
assert bool(notes)==(count>1)
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')
assert m.merge(ai={'stingerChanceOneIn':37})[0]['stingerChanceOneIn']==37
assert m.merge(ai={'stingerChanceOneIn':0})[0]['stingerChanceOneIn']==0
assert m.merge()[0]=={k:v[0] for k,v in m.SCHEMA.items()}
@@ -23,8 +35,13 @@ with tempfile.TemporaryDirectory() as tmp:
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 'successor capped at one initial convoy' in preview.stderr and legacy.read_bytes()==original
# Test the actual runtime validator against the migration schema at each boundary.
validator=body(ROOT/'addons/core/Scripts/Game/ERR/ERR_Settings.c','static bool Valid(').replace('string key','std::string key')
validator=body(BASE/'addons/core/Scripts/Game/ERR/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)]: