try to fix roadblocks

This commit is contained in:
2026-10-15 21:38:20 +02:00
parent 009b2fb591
commit ae9aaafaae
5 changed files with 69 additions and 12 deletions
@@ -1,4 +1,13 @@
#ifdef WORKBENCH
class ERR_RoadblockProbeRules
{
static int Target()
{
string value;
if (System.GetCLIParam("errRoadblockProbeCount", value)) return Math.Clamp(value.ToInt(), 1, 30);
return 30;
}
}
modded class ERR_HQ
{
bool ERR_ProbeRoadblockGroup(SCR_AIGroup group)
@@ -14,7 +23,7 @@ modded class ERR_Roadblocks
{
string outcome;
if (!m_Begun && System.GetCLIParam("errSmokeOutcome", outcome) && outcome == "roadblocks")
m_Mode.Settings().Set("roadblockCount", 30);
m_Mode.Settings().Set("roadblockCount", ERR_RoadblockProbeRules.Target());
return super.Prepare(definition);
}
protected int m_ERRLayoutPosts;
@@ -27,7 +36,7 @@ modded class ERR_Roadblocks
// Inspect the actual spawned models before the transaction clears its refs.
if (m_BuildHold)
{
bool valid = m_BuildCovers.Count() == 10 && m_BuildSpawned.Count() == 15;
bool valid = ShareProps();
for (int i = 0; i < m_BuildCovers.Count() && valid; i++)
{
ERR_RoadblockProp prop = m_BuildCovers[i];
@@ -89,7 +98,7 @@ modded class ERR_Roadblocks
bool ERR_ProbeBackgroundPending() { return m_Background; }
bool ERR_ProbeCompleteRoadblocks()
{
if (m_ERRLayoutFailed || m_ERRLayoutPosts < 30 || m_Target != 30 || m_Positions.Count() != 30 || m_Background || !m_Ready) return false;
if (m_ERRLayoutFailed || m_ERRLayoutPosts < ERR_RoadblockProbeRules.Target() || m_Target != ERR_RoadblockProbeRules.Target() || m_Positions.Count() != m_Target || m_Background || !m_Ready) return false;
foreach (SCR_AIGroup group : m_Groups)
{
ERR_ForceRecord record = m_Mode.Forces().FindGroup(group);
@@ -147,13 +156,13 @@ modded class ERR_GameMode
IEntity player; if (controller) player = controller.GetControlledEntity();
if (!player || !Players().CurrentActor(player)) return;
DamageManagerComponent.Cast(player.FindComponent(DamageManagerComponent)).EnableDamageHandling(false);
if (!posts.ERR_ProbeBackgroundPending() || posts.ERR_ProbePostCount() >= 30 || !UnlockPrison(player)) { ERR_RoadblockProbeFail("did not enter background search with prison unlocked"); return; }
if (!posts.ERR_ProbeBackgroundPending() || posts.ERR_ProbePostCount() >= ERR_RoadblockProbeRules.Target() || !UnlockPrison(player)) { ERR_RoadblockProbeFail("did not enter background search with prison unlocked"); return; }
m_ERRRoadblockInitial = posts.ERR_ProbePostCount();
PrintFormat("ERR SMOKE PASS: roadblocks prison unlocked with %1/30 posts; background still pending", m_ERRRoadblockInitial);
PrintFormat("ERR SMOKE PASS: roadblocks prison unlocked with %1/%2 posts; background still pending", m_ERRRoadblockInitial, ERR_RoadblockProbeRules.Target());
}
if (posts.ERR_ProbeBackgroundPending()) return;
if (!posts.ERR_ProbeCompleteRoadblocks() || posts.ERR_ProbePostCount() <= m_ERRRoadblockInitial) { ERR_RoadblockProbeFail("30 physical four-guard posts, ledger and HQ admission"); return; }
Print("ERR SMOKE PASS: roadblocks 30 physical posts / 120 guards / 300 checkpoint props; verified model footprints and centre lane; background completed after prison unlock, no extra posts on repeat tick");
if (!posts.ERR_ProbeCompleteRoadblocks() || posts.ERR_ProbePostCount() <= m_ERRRoadblockInitial) { ERR_RoadblockProbeFail("physical four-guard posts, replicated props, ledger and HQ admission"); return; }
PrintFormat("ERR SMOKE PASS: roadblocks %1 physical posts / %2 guards / %3 replicated checkpoint props with distance streaming disabled; verified model footprints and centre lane; background completed after prison unlock, no extra posts on repeat tick", posts.ERR_ProbePostCount(), posts.ERR_ProbePostCount() * 4, posts.ERR_ProbePostCount() * 10);
ERR_StopRoadblockProbe();
Transition(ERR_Phase.DEFEAT);
Print("ERR SMOKE COMPLETE: roadblocks background completion on Everon; scripted local fixture");
@@ -224,6 +224,27 @@ class ERR_Roadblocks
m_BuildSpawned.Clear(); m_BuildGroup = null; m_BuildHold = null;
m_BuildPositions.Clear(); m_BuildCovers.Clear();
}
// A free Game Master camera does not force-stream bare, non-editable props
// with the nearby editable guards. Keep this bounded bank (<=300 objects) on
// clients, including late joiners, rather than tying it to the player's body.
// Do not report a completed post if any physical prop lacks a network node.
protected bool ShareProps()
{
if (m_BuildCovers.Count() != 10 || m_BuildSpawned.Count() != 15) return false;
for (int i = 4; i < 14; i++)
{
IEntity entity = m_BuildSpawned[i];
RplComponent component;
if (entity) component = RplComponent.Cast(entity.FindComponent(RplComponent));
if (!component || component.Id() == RplId.Invalid()) return false;
}
for (int i = 4; i < 14; i++)
{
RplComponent component = RplComponent.Cast(m_BuildSpawned[i].FindComponent(RplComponent));
component.EnableStreaming(false);
}
return true;
}
protected void BuildPost()
{
if (!PlayersClear() || m_Mode.Forces().Count() >= 250) { CancelPost(); return; }
@@ -256,6 +277,11 @@ class ERR_Roadblocks
m_BuildSpawned.Insert(m_BuildHold); m_BuildHold.SetCompletionRadius(25);
return;
}
if (!ShareProps())
{
PrintFormat("Reintroduced [roadblocks]: rejected post at %1; checkpoint props missing replication nodes", m_Centre);
CancelPost(); return;
}
string id = "roadblock-" + m_Positions.Count().ToString();
if (!m_Mode.Forces().Register(id, ERR_ForceKind.CHECKPOINT, m_Centre, 4, null, m_BuildGroup, GetGame().GetWorld().GetWorldTime()))
{ CancelPost(); return; }
@@ -264,7 +290,7 @@ class ERR_Roadblocks
m_BuildGroup.AddWaypoint(m_BuildHold);
m_Mode.RegisterGroup(m_BuildGroup); m_BuildGroup.ActivateAI();
m_Mining.Register(m_BuildGroup, m_BuildHold, m_Right);
PrintFormat("Reintroduced [roadblocks]: %1 at %2 guards=4 props=10 roadPassage=open HQ=registered", id, m_Centre);
PrintFormat("Reintroduced [roadblocks]: %1 at %2 guards=4 props=10 networkedProps=10 propStreaming=always roadPassage=open HQ=registered", id, m_Centre);
m_BuildSpawned.Clear(); m_BuildGroup = null; m_BuildHold = null;
m_BuildPositions.Clear(); m_BuildCovers.Clear();
m_NextSearch = System.GetTickCount() + 2000;
+7
View File
@@ -118,3 +118,10 @@ Before requesting `System.MakeScreenshot("$logs:err-ordinary-prison")`, the help
[121730](evidence/game-2026-09-19-121730-prison-view/report.json) passes ordinary Chat startup and the optional capture. The [unaltered player view](evidence/game-2026-09-19-121730-prison-view/ordinary-prison-view.png) shows the prisoner inside the authored walls with the correct escape objective and instructions. The exact native BMP is retained beside a documented lossless PNG conversion. The image also exposed tall wheat inside the yard; the first bounded decal candidate in [122353](evidence/game-2026-09-19-122353-yard-candidate/report.json) clears only a small central patch and is not yet full-yard treatment. Neither capture supplies player-input or full-route acceptance.
[125906 integrated startup](evidence/game-2026-09-19-125906-integrated-prison/report.json) passes after the admission-budget, interaction-feedback, optional aircraft-target and stock-scale yard changes. The native player view remains correct. The scale-ten vanilla decal clears a larger irregular patch while wheat remains near and within parts of the enclosure; complete yard clearance is not claimed. This is ordinary-game startup and initial-view evidence only.
Roadblock props remain replicated independently of player distance so an admin
viewing the island through Game Master can see the same barriers as nearby
players. This is limited to the ten props per post (at most 300); guard and
vehicle streaming is unchanged. A post is counted only after all ten props have
valid replication nodes. Local Workbench validation does not by itself prove
remote-client visibility.
+16 -4
View File
@@ -98,7 +98,7 @@ assert 'if (!PlayersClear() || m_Mode.Forces().Count() >= 250) { CancelPost(); r
print('PASS: actual roadblock scheduler yields initial deadline/attempt cap to background, budgets samples, pauses empty/full worlds, completes 30 after unlock, preserves safety and never replenishes losses; teardown cancels work')
# Run actual staged physical creation and rollback, with native entities doubled.
s='\n'.join(body(p,sig) for sig in ('protected bool SpawnPost(', 'protected void CancelPost()', 'protected void BuildPost()'))
s='\n'.join(body(p,sig) for sig in ('protected bool SpawnPost(', 'protected void CancelPost()', 'protected bool ShareProps()', 'protected void BuildPost()'))
s=s.replace('protected ', '').replace('array<vector> positions','Array<int>& positions').replace('array<ref ERR_RoadblockProp> covers','Array<Prop>& covers').replace('ERR_RoadblockProp prop', 'Prop prop')
s=s.replace('SCR_AIGroup.Cast','Node::Cast').replace('AIWaypoint.Cast','Node::Cast').replace('IEntity actor','Node* actor').replace('IEntity cover','Node* cover')
s=s.replace('foreach (IEntity ownedEntity : m_BuildSpawned)','for (Node* ownedEntity : m_BuildSpawned)').replace('vector point','int point').replace('vector coverPosition','int coverPosition')
@@ -106,6 +106,9 @@ s=s.replace('foreach (ERR_RoadblockProp plannedProp : covers)', 'for (Prop plann
s=s.replace('m_Forward.VectorToAngles()', '0').replace('m_Positions.Count().ToString()', 'std::to_string(m_Positions.Count())')
s=s.replace('GetGame().GetWorld().GetWorldTime()', 'Now()').replace('System.GetTickCount()', 'Now()').replace('null','nullptr')
s=s.replace('m_BuildGroup.', 'm_BuildGroup->').replace('m_BuildHold.', 'm_BuildHold->')
s=s.replace('IEntity entity', 'Node* entity').replace('RplComponent component;', 'Replica* component=nullptr;').replace('RplComponent component =', 'Replica* component =')
s=s.replace('RplComponent.Cast', 'Replica::Cast').replace('FindComponent(RplComponent)', 'FindComponent()').replace('RplId.Invalid()', '-1')
s=s.replace('entity.FindComponent', 'entity->FindComponent').replace('m_BuildSpawned[i].FindComponent', 'm_BuildSpawned[i]->FindComponent').replace('component.', 'component->')
for ns in ('ERR_Actors','ERR_Resources','ERR_ForceKind'):s=s.replace(ns+'.',ns+'::')
run(r'''
#include <cassert>
@@ -115,7 +118,8 @@ using string=std::string;
int Now(){return 100;}
template<class...T>void PrintFormat(T...){}
template<class T>struct Array:std::vector<T>{using std::vector<T>::vector;using std::vector<T>::operator=;int Count(){return this->size();}void Clear(){this->clear();}void Copy(Array<T>&other){*this=other;}void Insert(T value){this->push_back(value);}};
struct Node{static Node* Cast(Node*n){return n;}void SetFaction(int){}void SetSpawnImmediately(bool){}void SetDeleteWhenEmpty(bool){}void SetPermanentLOD(int){}bool AddAIEntityToGroup(Node*){return true;}void SetCompletionRadius(int r){assert(r==25);}void AddWaypoint(Node*){}void ActivateAI(){}};
struct Replica{int id=1;bool streaming=true;static Replica* Cast(Replica*r){return r;}int Id(){return id;}void EnableStreaming(bool value){streaming=value;}};
struct Node{Replica replica;bool missing=false;Replica* FindComponent(){return missing?nullptr:&replica;}static Node* Cast(Node*n){return n;}void SetFaction(int){}void SetSpawnImmediately(bool){}void SetDeleteWhenEmpty(bool){}void SetPermanentLOD(int){}bool AddAIEntityToGroup(Node*){return true;}void SetCompletionRadius(int r){assert(r==25);}void AddWaypoint(Node*){}void ActivateAI(){}};
namespace ERR_Actors{int calls=0,failAt=0;Array<Node*> all;Node* Spawn(string,int,int=0){calls++;if(calls==failAt)return nullptr;auto*n=new Node;all.Insert(n);return n;}bool SetFaction(Node*,int){return true;}}
namespace ERR_Resources{string DefendWaypoint(){return "hold";}}
namespace ERR_ForceKind{int CHECKPOINT=1;}
@@ -135,7 +139,15 @@ struct Build{
int main(){Array<int> positions={1,2,3,4};Array<Prop>covers(10);
{Build b;int start=ERR_Actors::calls;assert(b.SpawnPost(positions,covers));assert(ERR_Actors::calls==start+1);
for(int piece=0;piece<15;piece++){int calls=ERR_Actors::calls;b.BuildPost();assert(ERR_Actors::calls==calls+1&&b.m_Positions.Count()==0&&b.m_Mode.owned==0);}
b.BuildPost();assert(!b.m_BuildGroup&&b.m_Positions.Count()==1&&b.m_Groups.Count()==1&&b.m_Mode.owned==16&&b.m_Mode.groups==1&&b.m_Mining.registered==1&&b.m_NextSearch==2100);
auto shared=b.m_BuildSpawned;for(int i=4;i<14;i++)assert(shared[i]->replica.streaming);
b.BuildPost();for(int i=4;i<14;i++)assert(!shared[i]->replica.streaming);assert(!b.m_BuildGroup&&b.m_Positions.Count()==1&&b.m_Groups.Count()==1&&b.m_Mode.owned==16&&b.m_Mode.groups==1&&b.m_Mining.registered==1&&b.m_NextSearch==2100);
}
// A server-only or unregistered prop must never count as a complete roadblock.
for(int bad=4;bad<14;bad++)for(bool missing:{false,true}){
Build b;b.SpawnPost(positions,covers);for(int i=0;i<15;i++)b.BuildPost();
auto pieces=b.m_BuildSpawned;pieces[bad]->missing=missing;pieces[bad]->replica.id=-1;
b.BuildPost();assert(b.m_Positions.Count()==0&&!b.m_BuildGroup&&b.m_Mode.owned==0&&b.rolledBack==16);
for(int i=4;i<14;i++)assert(pieces[i]->replica.streaming); // reject before changing any streaming rule
}
// Player approaches at any stage: no registered post and every owned piece removed.
for(int stage=0;stage<=15;stage++){Build b;assert(b.SpawnPost(positions,covers));for(int i=0;i<stage;i++)b.BuildPost();b.playersClear=false;b.BuildPost();assert(!b.m_BuildGroup&&b.m_Positions.Count()==0&&b.m_Mode.owned==0&&b.rolledBack==stage+1);}
@@ -151,4 +163,4 @@ int main(){Array<int> positions={1,2,3,4};Array<Prop>covers(10);
for(auto*n:ERR_Actors::all)delete n;
}
''')
print('PASS: actual staged post creation spawns at most one entity per callback, commits only all four guards/ten props/waypoint, and rolls back player approach, obstructions, spawn/ledger failure and cancellation')
print('PASS: actual staged post creation spawns at most one entity per callback, commits only all four guards/ten props/waypoint, and rolls back player approach, obstructions, spawn/replication/ledger failure and cancellation; all ten committed props bypass distance streaming')
+3
View File
@@ -15,6 +15,7 @@ p.add_argument('--external-addons', type=Path, help='Installed RHS/WCS/BMP sourc
p.add_argument('--compat', type=Path, required=True, help='Disposable cloned Proton compatdata directory outside Steam')
p.add_argument('--profile', type=Path, required=True, help='Separate disposable successor Workbench profile')
p.add_argument('--smoke', choices=['coordinated-ai', 'crash-start', 'crash-geometry', 'normal-escape-route', 'escape-route', 'optional-site-skip', 'infantry-combat', 'medical-combat', 'hunter-faction', 'unconscious-camera', 'self-epinephrine', 'starter-backpack', 'speech-download', 'bundled-speech', 'objective-map', 'town-garrisons', 'site-garrisons', 'radio-interaction', 'enemy-loadout', 'aggression', 'air-logistics', 'air-assault', 'air-recon', 'airfield-markers', 'opening-tools', 'mortar-station', 'generated-sites', 'roadblocks', 'hq-local-handoff', 'infantry-transfer', 'hq-body', 'convoy', 'supply-convoys', 'supply-travel', 'mounted-ammo', 'alarm-hearing', 'hq-memory', 'hq-awareness', 'aircraft-field', 'aircraft-route', 'native-end-active', 'native-end-preparing', 'aircraft-control', 'aircraft-cancel', 'aircraft-translate', 'map-ownership', 'mortar-refresh', 'mortar-salvo', 'mortar-courier', 'station-teardown', 'inventory-hitzone', 'prison-breach', 'powered-flight', 'powered-translate', 'local-admission', 'mortar-expiry', 'mortar-transfer', 'mortar-fire', 'patrol-hq', 'victory', 'lost-helicopter', 'player-death', 'radio-network', 'hq-response', 'mortar-inventory', 'startup-failure', 'site-survey', 'cache-loss', 'extraction-capacity', 'early-helicopter-loss', 'extraction-startup-failure', 'extraction-boundary-failure', 'transport-loss', 'transport-survey', 'lifecycle', 'objective-ui', 'recovery', 'prisoner-kit', 'medical', 'casualty-load', 'casualty-direct', 'casualty-bench', 'casualty-remove', 'environment', 'environment-restart', 'patrols', 'radio-document-loss', 'radio-document-loss-partial', 'guard-cancel', 'casualty-service'], default='victory')
p.add_argument('--roadblock-probe-count', type=int, choices=range(1, 31), help='Roadblocks fixture target; default remains 30')
p.add_argument('--speech-probe-url', help='Loopback-only cached audio fixture; requires --smoke speech-download')
p.add_argument('--trace-random-radius', action='store_true', help='Workbench-only read-only zero-radius caller traces; one normal Chat round, capped at sixteen records')
p.add_argument('--maintenance-refuel', action='store_true', help='Opt-in normal-route check: original held Southern fuel can transfers finite fuel through its native UAZ action; one arranged tank depletion, no stock creation')
@@ -38,6 +39,7 @@ p.add_argument('--rounds', type=int, default=2)
p.add_argument('--timeout', type=int, default=360)
p.add_argument('--project', choices=['core', 'chat'], default='chat', help='Diagnostic fixture family (both load the combined mod)')
a = p.parse_args()
if a.roadblock_probe_count is not None and a.smoke != 'roadblocks': p.error('--roadblock-probe-count requires --smoke roadblocks')
if a.smoke == 'coordinated-ai' and (a.project != 'core' or a.rounds != 1 or a.timeout < 720): p.error('coordinated-ai requires Core, one round and timeout at least720 seconds')
if a.coordinated_blocked_only and a.smoke != 'coordinated-ai': p.error('--coordinated-blocked-only requires --smoke coordinated-ai')
if a.smoke == 'inventory-hitzone' and (a.project != 'core' or a.rounds != 1 or a.timeout < 240): p.error('inventory-hitzone requires --project core --rounds 1 and timeout at least 240 seconds')
@@ -140,6 +142,7 @@ for proc in Path('/proc').iterdir():
p.error('Another Workbench already uses this test profile')
except (FileNotFoundError, PermissionError, ProcessLookupError): pass
command = ['gamescope', '--backend', 'headless', '-W', '1280', '-H', '720', '-w', '1280', '-h', '720', '-r', '30', '--', str(proton), 'run', str(wb/'ArmaReforgerWorkbenchSteamDiag.exe'), '-gproj', 'Z:'+str(ROOT/'addons/core/addon.gproj'), '-addonsDir', 'Z:'+str(ROOT/'addons'), '-profile', profile_arg, '-logconsole', '-wbModule=WorldEditor', '-load', 'Worlds/ERR_Everon.ent', '-plugin=ERR_ValidateWorldPlugin', '-errPlay', '1', '-errSmoke', '1', '-errSmokeOutcome', a.smoke, '-noThrow', '-disableCrashReporter']
if a.roadblock_probe_count is not None: command += ['-errRoadblockProbeCount', str(a.roadblock_probe_count)]
if a.smoke == 'speech-download':
if a.project != 'core' or not a.speech_probe_url or not a.speech_probe_url.startswith('http://127.0.0.1:'): p.error('speech-download requires Core and an explicit loopback --speech-probe-url')
command += ['-errSpeechProbeURL', a.speech_probe_url]