From ae9aaafaae94c360cfb5fb0afe2fca79ef290461 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20=C5=BD=C3=A1=C4=8Dek?= Date: Thu, 15 Oct 2026 21:38:20 +0200 Subject: [PATCH] try to fix roadblocks --- .../ERR/00_Core/ERR_NativeRoadblocksProbe.c | 23 ++++++++++----- .../Scripts/Game/ERR/10_AI/ERR_Roadblocks.c | 28 ++++++++++++++++++- docs/ordinary-game-startup.md | 7 +++++ tests/test_roadblock_background.py | 20 ++++++++++--- tools/headless_workbench.py | 3 ++ 5 files changed, 69 insertions(+), 12 deletions(-) diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeRoadblocksProbe.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeRoadblocksProbe.c index 0fc2eee..2b4840d 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeRoadblocksProbe.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeRoadblocksProbe.c @@ -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"); diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_Roadblocks.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_Roadblocks.c index 5f2ff9a..8ce5eea 100644 --- a/addons/core/Scripts/Game/ERR/10_AI/ERR_Roadblocks.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_Roadblocks.c @@ -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; diff --git a/docs/ordinary-game-startup.md b/docs/ordinary-game-startup.md index 7d92d6c..7d05eb7 100644 --- a/docs/ordinary-game-startup.md +++ b/docs/ordinary-game-startup.md @@ -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. diff --git a/tests/test_roadblock_background.py b/tests/test_roadblock_background.py index 9c2cab7..d00c473 100644 --- a/tests/test_roadblock_background.py +++ b/tests/test_roadblock_background.py @@ -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 positions','Array& positions').replace('array covers','Array& 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 @@ -115,7 +118,8 @@ using string=std::string; int Now(){return 100;} templatevoid PrintFormat(T...){} templatestruct Array:std::vector{using std::vector::vector;using std::vector::operator=;int Count(){return this->size();}void Clear(){this->clear();}void Copy(Array&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 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 positions={1,2,3,4};Arraycovers(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 positions={1,2,3,4};Arraycovers(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') diff --git a/tools/headless_workbench.py b/tools/headless_workbench.py index 1eac8dd..6235ee3 100644 --- a/tools/headless_workbench.py +++ b/tools/headless_workbench.py @@ -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]