diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_SpawnClientScene.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_SpawnClientScene.c index a9820d6..18920ec 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_SpawnClientScene.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_SpawnClientScene.c @@ -1,6 +1,7 @@ // A native resource preload can finish before runtime replication delivers the // prison. Keep the existing admission pending until the owner resolves every -// required replication node. No client can choose the server's spawn contents. +// required replication node and usable inventory item, then preloads its assets. +// No client can choose the server's spawn contents. modded class SCR_SpawnRequestComponent { protected int m_ERRSceneToken; @@ -8,10 +9,20 @@ modded class SCR_SpawnRequestComponent protected bool m_ERRSceneAcknowledged; protected bool m_ERRSceneTimedOut; protected float m_ERRSceneDeadline; + protected float m_ERRSceneStartedAt; + protected float m_ERRSceneInventoryPoll; + protected bool m_ERRScenePreloadRequired; + protected bool m_ERRSceneObserver; + protected ref array m_ERRSceneRoots = {}; + protected ref array m_ERRSceneStockOwners = {}; + protected ref array m_ERRSceneStockItems = {}; protected ref array m_ERRSceneEntities = {}; protected int m_ERRClientSceneToken; protected bool m_ERRClientScenePending; protected ref array m_ERRClientSceneNodes = {}; + protected ref array m_ERRClientStockOwners = {}; + protected ref array m_ERRClientStockItems = {}; + protected float m_ERRClientStockReportAt; protected void ERR_StreamScene(IEntity entity, bool normalStreaming) { @@ -22,23 +33,67 @@ modded class SCR_SpawnRequestComponent if (component) component.EnableStreamingConNode(identity, normalStreaming); } - void ERR_BeginSpawnScene(array entities, bool valid) + void ERR_BeginSpawnScene(array entities, bool valid, vector position) { if (!Replication.IsServer()) return; ERR_EndSpawnScene(); - m_ERRSceneToken++; + // Native StartSpawnPreload normally installs this observer. Keep streaming + // the requested position while we defer asset preload until inventory arrives. + ObserversSystem observers = GetObserversSystem(); + if (observers && GetPlayerController()) + { + observers.InsertObserverMP(GetPlayerController().GetRplIdentity(), position[0], position[2], null); + m_ERRSceneObserver = true; + } m_ERRScenePending = true; - m_ERRSceneDeadline = GetGame().GetWorld().GetWorldTime() + 60000; - array nodes = {}; + m_ERRSceneStartedAt = GetGame().GetWorld().GetWorldTime(); + m_ERRSceneDeadline = m_ERRSceneStartedAt + 60000; foreach (IEntity entity : entities) { - RplComponent component; - if (entity) component = RplComponent.Cast(entity.FindComponent(RplComponent)); - if (!component || component.Id() == RplId.Invalid()) { valid = false; break; } - if (nodes.Contains(component.Id())) continue; - nodes.Insert(component.Id()); - m_ERRSceneEntities.Insert(entity); - ERR_StreamScene(entity, false); + if (!entity) { valid = false; continue; } + // Inventory contents are captured through their manager below. They may + // have local/world origins unrelated to the container and may be consumed. + InventoryItemComponent item = InventoryItemComponent.Cast(entity.FindComponent(InventoryItemComponent)); + if (item && item.GetParentSlot()) continue; + if (!m_ERRSceneRoots.Contains(entity)) m_ERRSceneRoots.Insert(entity); + } + array owners, items; + if (!ERR_SpawnSceneInventory.Gather(m_ERRSceneRoots, owners, items)) valid = false; + ERR_PublishSpawnScene(owners, items, valid); + } + + protected bool ERR_AddSceneNode(IEntity entity, array nodes) + { + RplComponent component; + if (entity) component = RplComponent.Cast(entity.FindComponent(RplComponent)); + if (!component || component.Id() == RplId.Invalid()) return false; + if (nodes.Contains(component.Id())) return true; + if (nodes.Count() >= 256) return false; + nodes.Insert(component.Id()); + m_ERRSceneEntities.Insert(entity); + ERR_StreamScene(entity, false); + return true; + } + + protected void ERR_PublishSpawnScene(array owners, array items, bool valid) + { + foreach (IEntity previous : m_ERRSceneEntities) ERR_StreamScene(previous, true); + m_ERRSceneEntities.Clear(); + m_ERRSceneToken++; + m_ERRSceneAcknowledged = false; + m_ERRScenePreloadRequired = true; + m_ERRSceneInventoryPoll = GetGame().GetWorld().GetWorldTime() + 500; + m_ERRSceneStockOwners.Copy(owners); + m_ERRSceneStockItems.Copy(items); + array nodes = {}; + array stockOwners = {}, stockItems = {}; + foreach (IEntity root : m_ERRSceneRoots) + if (!ERR_AddSceneNode(root, nodes)) valid = false; + for (int i = 0; i < items.Count(); i++) + { + if (!ERR_AddSceneNode(owners[i], nodes) || !ERR_AddSceneNode(items[i], nodes)) { valid = false; break; } + stockOwners.Insert(RplComponent.Cast(owners[i].FindComponent(RplComponent)).Id()); + stockItems.Insert(RplComponent.Cast(items[i].FindComponent(RplComponent)).Id()); } if (!valid) { @@ -46,8 +101,28 @@ modded class SCR_SpawnRequestComponent Print("Reintroduced [spawn scene]: invalid server scene snapshot; admission withheld", LogLevel.ERROR); return; } - PrintFormat("Reintroduced [spawn scene]: player=%1 token=%2 waiting for %3 replicated objects", GetPlayerId(), m_ERRSceneToken, nodes.Count()); - Rpc(ERR_ReceiveSpawnScene, m_ERRSceneToken, nodes); + PrintFormat("Reintroduced [spawn scene]: player=%1 token=%2 waiting for %3 replicated objects and %4 inventory items", GetPlayerId(), m_ERRSceneToken, nodes.Count(), stockItems.Count()); + Rpc(ERR_ReceiveSpawnScene, m_ERRSceneToken, nodes, stockOwners, stockItems); + } + + protected void ERR_RefreshSceneInventory() + { + if (!Replication.IsServer() || !m_ERRScenePending || m_ERRSceneTimedOut || GetGame().GetWorld().GetWorldTime() < m_ERRSceneInventoryPoll) return; + m_ERRSceneInventoryPoll = GetGame().GetWorld().GetWorldTime() + 500; + array owners, items; + bool valid = ERR_SpawnSceneInventory.Gather(m_ERRSceneRoots, owners, items); + if (!valid || !ERR_SpawnSceneInventory.Same(owners, items, m_ERRSceneStockOwners, m_ERRSceneStockItems)) + ERR_PublishSpawnScene(owners, items, valid); + } + + // Start native resource preload only after the client has the actual scene + // and usable inventory links. A refreshed manifest requires a fresh pass. + bool ERR_TakeScenePreload() + { + if (!ERR_SpawnSceneReady() || !m_ERRScenePreloadRequired || IsPreloading()) return false; + m_ERRScenePreloadRequired = false; + PrintFormat("Reintroduced [spawn scene]: player=%1 token=%2 scene ready after %3ms; starting asset preload", GetPlayerId(), m_ERRSceneToken, GetGame().GetWorld().GetWorldTime() - m_ERRSceneStartedAt); + return true; } bool ERR_SpawnSceneReady() @@ -57,6 +132,7 @@ modded class SCR_SpawnRequestComponent bool ERR_SpawnSceneMayFinish() { if (!m_ERRScenePending) return true; + ERR_RefreshSceneInventory(); if (!m_ERRSceneAcknowledged && !m_ERRSceneTimedOut && GetGame().GetWorld().GetWorldTime() >= m_ERRSceneDeadline) { m_ERRSceneTimedOut = true; @@ -71,24 +147,66 @@ modded class SCR_SpawnRequestComponent if (!Replication.IsServer()) return; foreach (IEntity entity : m_ERRSceneEntities) ERR_StreamScene(entity, true); m_ERRSceneEntities.Clear(); + m_ERRSceneRoots.Clear(); + m_ERRSceneStockOwners.Clear(); + m_ERRSceneStockItems.Clear(); + if (m_ERRSceneObserver && GetPlayerController()) UpdateObserverMP_S(GetPlayerController().GetControlledEntity()); + m_ERRSceneObserver = false; if (m_ERRScenePending) Rpc(ERR_CancelClientSpawnScene, m_ERRSceneToken); m_ERRScenePending = false; m_ERRSceneAcknowledged = false; m_ERRSceneTimedOut = false; + m_ERRScenePreloadRequired = false; } [RplRpc(RplChannel.Reliable, RplRcver.Owner)] - protected void ERR_ReceiveSpawnScene(int token, array nodes) + protected void ERR_ReceiveSpawnScene(int token, array nodes, array stockOwners, array stockItems) { ERR_ClearClientSpawnScene(); m_ERRClientSceneToken = token; m_ERRClientScenePending = true; m_ERRClientSceneNodes.Copy(nodes); + m_ERRClientStockOwners.Copy(stockOwners); + m_ERRClientStockItems.Copy(stockItems); + m_ERRClientStockReportAt = 0; SCR_RespawnSystemComponent respawn = SCR_RespawnSystemComponent.GetInstance(); if (respawn) respawn.ERR_ShowSpawnSceneLoading(); - PrintFormat("Reintroduced [spawn scene]: client token=%1 waiting for %2 replicated objects", token, nodes.Count()); + PrintFormat("Reintroduced [spawn scene]: client token=%1 waiting for %2 replicated objects and %3 inventory items", token, nodes.Count(), stockItems.Count()); GetGame().GetCallqueue().CallLater(ERR_CheckClientSpawnScene, 100, true); } + protected bool ERR_ClientStockReady() + { + if (m_ERRClientStockOwners.Count() != m_ERRClientStockItems.Count()) return false; + RplId previousOwner = RplId.Invalid(); + array contents = {}; + for (int i = 0; i < m_ERRClientStockItems.Count(); i++) + { + RplId ownerId = m_ERRClientStockOwners[i]; + if (ownerId != previousOwner) + { + RplComponent owner = RplComponent.Cast(Replication.FindItem(ownerId)); + if (!owner || !owner.GetEntity()) return false; + InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(owner.GetEntity().FindComponent(InventoryStorageManagerComponent)); + if (!inventory) return false; + ERR_Inventory.Items(inventory, contents); + previousOwner = ownerId; + } + RplComponent item = RplComponent.Cast(Replication.FindItem(m_ERRClientStockItems[i])); + if (!item || !item.GetEntity() || !contents.Contains(item.GetEntity())) + { + ERR_ReportStockWait(ownerId, m_ERRClientStockItems[i], contents.Count()); + return false; + } + } + return true; + } + protected void ERR_ReportStockWait(RplId owner, RplId item, int visible) + { + float now = GetGame().GetWorld().GetWorldTime(); + if (now < m_ERRClientStockReportAt) return; + m_ERRClientStockReportAt = now + 5000; + PrintFormat("Reintroduced [spawn scene]: client token=%1 waiting for inventory owner=%2 item=%3 visible=%4", m_ERRClientSceneToken, owner, item, visible); + } protected void ERR_CheckClientSpawnScene() { if (!m_ERRClientScenePending || !ERR_GameMode.Get()) { ERR_ClearClientSpawnScene(); return; } @@ -97,8 +215,9 @@ modded class SCR_SpawnRequestComponent RplComponent component = RplComponent.Cast(Replication.FindItem(id)); if (!component || !component.GetEntity()) return; } + if (!ERR_ClientStockReady()) return; int token = m_ERRClientSceneToken; - PrintFormat("Reintroduced [spawn scene]: client token=%1 all %2 replicated objects present", token, m_ERRClientSceneNodes.Count()); + PrintFormat("Reintroduced [spawn scene]: client token=%1 all %2 objects and %3 inventory items ready", token, m_ERRClientSceneNodes.Count(), m_ERRClientStockItems.Count()); ERR_ClearClientSpawnScene(); Rpc(ERR_AcknowledgeSpawnScene, token); } @@ -107,12 +226,15 @@ modded class SCR_SpawnRequestComponent { if (!Replication.IsServer() || !m_ERRScenePending || m_ERRSceneTimedOut || token != m_ERRSceneToken) return; m_ERRSceneAcknowledged = true; + PrintFormat("Reintroduced [spawn scene]: player=%1 token=%2 inventory acknowledged after %3ms", GetPlayerId(), token, GetGame().GetWorld().GetWorldTime() - m_ERRSceneStartedAt); } protected void ERR_ClearClientSpawnScene() { if (GetGame()) GetGame().GetCallqueue().Remove(ERR_CheckClientSpawnScene); m_ERRClientScenePending = false; m_ERRClientSceneNodes.Clear(); + m_ERRClientStockOwners.Clear(); + m_ERRClientStockItems.Clear(); } [RplRpc(RplChannel.Reliable, RplRcver.Owner)] protected void ERR_CancelClientSpawnScene(int token) diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_SpawnFinalization.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_SpawnFinalization.c index d493b6f..215e285 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_SpawnFinalization.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_SpawnFinalization.c @@ -45,13 +45,18 @@ modded class SCR_FreeSpawnHandlerComponent array scene = {}; bool valid = mode.Sites() && mode.Sites().SpawnScene(data.GetPosition(), scene); scene.Insert(entity); - requestComponent.ERR_BeginSpawnScene(scene, valid); + requestComponent.ERR_BeginSpawnScene(scene, valid, data.GetPosition()); + return; } super.OnFinalizeBegin_S(requestComponent, data, entity); } override bool CanFinalize_S(SCR_SpawnRequestComponent requestComponent, SCR_SpawnData data, IEntity entity) { - if (ERR_GameMode.Get() && requestComponent && !requestComponent.ERR_SpawnSceneMayFinish()) return false; + if (ERR_GameMode.Get() && requestComponent) + { + if (!requestComponent.ERR_SpawnSceneMayFinish()) return false; + if (requestComponent.ERR_TakeScenePreload()) super.OnFinalizeBegin_S(requestComponent, data, entity); + } return super.CanFinalize_S(requestComponent, data, entity); } protected ERR_SpawnLogic ERR_AdmissionLogic() diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_SpawnSceneInventory.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_SpawnSceneInventory.c new file mode 100644 index 0000000..a77be0e --- /dev/null +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_SpawnSceneInventory.c @@ -0,0 +1,35 @@ +// Read current physical contents, never the authored starting quantity: another +// prisoner may already have taken loot when a late arrival joins the round. +class ERR_SpawnSceneInventory +{ + static bool Gather(array roots, out array owners, out array items) + { + owners = {}; items = {}; + foreach (IEntity root : roots) + { + if (!root) continue; + InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(root.FindComponent(InventoryStorageManagerComponent)); + if (!inventory) continue; + array contents = {}; + ERR_Inventory.Items(inventory, contents); + foreach (IEntity item : contents) + { + if (!item || items.Contains(item)) continue; + if (items.Count() >= 256) return false; + owners.Insert(root); items.Insert(item); + } + } + return true; + } + + static bool Same(array owners, array items, array previousOwners, array previousItems) + { + if (items.Count() != previousItems.Count() || owners.Count() != items.Count() || previousOwners.Count() != previousItems.Count()) return false; + for (int i = 0; i < items.Count(); i++) + { + int previous = previousItems.Find(items[i]); + if (previous < 0 || previousOwners[previous] != owners[i]) return false; + } + return true; + } +} diff --git a/docs/preparation-status.md b/docs/preparation-status.md index e41f312..28332bc 100644 --- a/docs/preparation-status.md +++ b/docs/preparation-status.md @@ -1,5 +1,57 @@ # Mission preparation presentation +## Client inventory readiness (0.0.7 candidate) + +The user reported arriving before loot was available with 0.0.6. The client's +16:57 session acknowledged all 41 scene objects in about 203ms, but that check +only resolved replication entities. It did not verify that items were linked +into usable inventories. Native resource preload also ran concurrently and +could finish before the dynamic scene arrived. + +Admission now snapshots the current physical contents of nearby scene inventory +managers and the pending prisoner. The client must resolve the objects and find +every required item in its corresponding manager's inventory before acknowledging +readiness. Only then does native asset preload start; assignment still waits for +its completion and the existing admission checks. A spawn-position replication +observer is installed before the wait, then restored on cleanup, preserving +streaming while asset preload is deferred. There is no additional fixed delay. + +While admission is pending, the server checks the current contents every 500ms. +Changed contents create a new token and require another acknowledgement and +preload pass, so a late joiner does not indefinitely wait for stock another +player has already taken. The 60-second scene deadline is not extended by these +refreshes. A timeout rejects and retries the attempt rather than admitting a +player with missing equipment. The 256-node snapshot cap remains enforced. + +`tests/test_spawn_scene_inventory.py` executes the production snapshot, inventory +containment, refresh and preload-gating methods. It covers delayed inventory +links after entity arrival, missing items/managers, wrong-owner contents, +deduplication, snapshot limits and consumed stock. `tests/test_spawn_scene.py` +also checks that ready objects alone cannot acknowledge missing inventory and +that native preload starts after acknowledgement. These controlled adapters and +local Workbench tests cannot establish dedicated-client replication timing. + +Repository checks passed. In the final-source native run at 17:24 on September +20, all 50 scene objects and 28 inventory items were acknowledged at 17:25:13.889, +asset preload began at 17:25:13.894, and the admission assertion passed at +17:25:15.320. That run subsequently failed in vanilla +`SCR_AIGetSuppressionVolumePosition` with `No suppression volume provided!`; +it is not a successful full-mission verdict. Its original cleanup report also +retains a transient exiting-process classification error. A separate follow-up +confirmed the disposable prefix idle and addon sources identical to the launch +snapshot. Raw results remain under +`/tmp/erri-spawn-inventory-final-20260920/logs/logs_2026-09-20_17-24-20`. + +The unchanged-source repeat at 17:26 completed victory and 105 assertions with +no script/runtime errors. It again confirmed inventory acknowledgement, subsequent +asset preload, then admission. The four existing RHS/WCS localization errors +remain. Its runner also recorded the transient cleanup classification failure; +the separate follow-up confirmed an idle prefix and unchanged source without +rewriting that result. Results are under +`/tmp/erri-spawn-inventory-repeat-20260920/logs/logs_2026-09-20_17-26-51`. +Publish the candidate and update both RKS #2 and the client before testing a fresh +join and a late join; the Workshop/server package was not changed by these tests. + ## Dedicated replication barrier (0.0.4) The user retested 0.0.3 on RKS #2 and reported that the black preparation screen diff --git a/publishing/changelog-0.0.7.txt b/publishing/changelog-0.0.7.txt new file mode 100644 index 0000000..3ead1b0 --- /dev/null +++ b/publishing/changelog-0.0.7.txt @@ -0,0 +1,3 @@ +Keep the preparation screen visible until nearby loot and the prisoner's equipment have arrived in their inventories, then finish loading the spawn area's assets before giving the player control. Previously, detecting the objects alone could release the player too early. + +Refresh pending inventory checks when another player takes loot, so late joiners wait for the current contents. Preserve spawn-area streaming during preparation and retain the existing timeout and retry behavior. diff --git a/tests/test_spawn_scene.py b/tests/test_spawn_scene.py index d02583b..f91cb0d 100644 --- a/tests/test_spawn_scene.py +++ b/tests/test_spawn_scene.py @@ -30,7 +30,8 @@ templatevoid PrintFormat(const char*,T...){} struct Probe{ int m_ERRSceneToken=4,m_ERRClientSceneToken=4,acks=0,lastAck=0; bool m_ERRScenePending=false,m_ERRSceneAcknowledged=false,m_ERRSceneTimedOut=false,m_ERRClientScenePending=false; - float m_ERRSceneDeadline=1000;Nodes m_ERRClientSceneNodes; + float m_ERRSceneDeadline=1000,m_ERRSceneStartedAt=0;Nodes m_ERRClientSceneNodes,m_ERRClientStockOwners,m_ERRClientStockItems; + bool stockReady=true;bool ERR_ClientStockReady(){return stockReady;}void ERR_RefreshSceneInventory(){} int GetPlayerId(){return 1;}void SendAck(int token){acks++;lastAck=token;} '''+methods+r''' }; @@ -49,7 +50,8 @@ int main(){ for(int i=0;i<100;i++){client.ERR_CheckClientSpawnScene();}assert(client.acks==0&&client.m_ERRClientScenePending); Replication::items[22]=&wall;client.ERR_CheckClientSpawnScene();assert(client.acks==0); gate.entity=false;Replication::items[33]=&gate;client.ERR_CheckClientSpawnScene();assert(client.acks==0); - gate.entity=true;client.ERR_CheckClientSpawnScene();assert(client.acks==1&&client.lastAck==4&&!client.m_ERRClientScenePending&&client.m_ERRClientSceneNodes.empty()); + gate.entity=true;client.stockReady=false;client.ERR_CheckClientSpawnScene();assert(client.acks==0&&client.m_ERRClientScenePending); + client.stockReady=true;client.ERR_CheckClientSpawnScene();assert(client.acks==1&&client.lastAck==4&&!client.m_ERRClientScenePending&&client.m_ERRClientSceneNodes.empty()); client.ERR_CheckClientSpawnScene();assert(client.acks==1); client.m_ERRClientScenePending=true;client.m_ERRClientSceneToken=5;client.m_ERRClientSceneNodes={99}; client.ERR_CancelClientSpawnScene(4);assert(client.m_ERRClientScenePending); @@ -68,11 +70,11 @@ for old, new in [('override ', ''), ('SCR_SpawnRequestComponent requestComponent run(r''' #include enum class SCR_ESpawnResult{OK,SPAWN_NOT_ALLOWED,CANNOT_ASSIGN}; -struct Request{bool ready=false,mayFinish=false;int ends=0;bool ERR_SpawnSceneReady(){return ready;}bool ERR_SpawnSceneMayFinish(){return mayFinish;}void ERR_EndSpawnScene(){ends++;}}; +struct Request{bool ready=false,mayFinish=false;int ends=0;bool ERR_SpawnSceneReady(){return ready;}bool ERR_SpawnSceneMayFinish(){return mayFinish;}bool preloadRequired=true;bool ERR_TakeScenePreload(){if(!ready||!preloadRequired)return false;preloadRequired=false;return true;}void ERR_EndSpawnScene(){ends++;}}; struct Data{};struct Actor{};struct Ticket{}; struct Mode{static bool current;static bool Get(){return current;}};bool Mode::current=true; struct Logic{Ticket ticket;bool hasTicket=true,allowed=true;Ticket* TakeSpawnTicket(Request*,Data*,Actor*){return hasTicket?&ticket:nullptr;}bool MayFinalizeSpawn(Ticket*){return allowed;}}; -struct Native{bool preloadReady=false;int assignments=0;SCR_ESpawnResult result=SCR_ESpawnResult::OK;bool CanFinalize_S(Request*,Data*,Actor*){return preloadReady;}SCR_ESpawnResult FinalizeRequest_S(Request*,Data*,Actor*){assignments++;return result;}}; +struct Native{bool preloadReady=false;int assignments=0,preloadStarts=0;void OnFinalizeBegin_S(Request*,Data*,Actor*){preloadStarts++;preloadReady=false;}SCR_ESpawnResult result=SCR_ESpawnResult::OK;bool CanFinalize_S(Request*,Data*,Actor*){return preloadReady;}SCR_ESpawnResult FinalizeRequest_S(Request*,Data*,Actor*){assignments++;return result;}}; struct Handler:Native{Logic storage;Logic* logic=&storage;int rejects=0;Logic* ERR_AdmissionLogic(){return logic;}void ERR_RejectOwnedActor(Request*,Actor*,Data*){rejects++;} '''+methods+r''' }; @@ -83,11 +85,42 @@ int main(){ h.preloadReady=true;assert(h.CanFinalize_S(&request,&data,&actor)); // Timeout can end the pending request, but cannot assign an actor. assert(h.FinalizeRequest_S(&request,&data,&actor)==SCR_ESpawnResult::SPAWN_NOT_ALLOWED&&h.assignments==0&&h.rejects==1&&request.ends==1); - request.ready=true;h.storage.allowed=false; + request.ready=true;assert(!h.CanFinalize_S(&request,&data,&actor)&&h.preloadStarts==1); + h.preloadReady=true;assert(h.CanFinalize_S(&request,&data,&actor)&&h.preloadStarts==1); + h.storage.allowed=false; assert(h.FinalizeRequest_S(&request,&data,&actor)==SCR_ESpawnResult::SPAWN_NOT_ALLOWED&&h.assignments==0&&h.rejects==2); h.storage.allowed=true;assert(h.FinalizeRequest_S(&request,&data,&actor)==SCR_ESpawnResult::OK&&h.assignments==1&&request.ends==3); h.storage.hasTicket=false;assert(h.FinalizeRequest_S(&request,&data,&actor)==SCR_ESpawnResult::SPAWN_NOT_ALLOWED&&h.assignments==1&&h.rejects==2); Mode::current=false;h.logic=nullptr;assert(h.FinalizeRequest_S(&request,&data,&actor)==SCR_ESpawnResult::OK&&h.assignments==2); } ''') -print('PASS: delayed wall/gate replication withholds assignment; native preload, identity checks, stale replies, cancellation and timeout remain enforced') +cleanup = body(source, 'void ERR_EndSpawnScene(') +for old, new in [('Replication.IsServer()', 'Replication::server'), + ('foreach (IEntity entity :', 'for (int entity :'), + ('GetPlayerController().GetControlledEntity()', 'GetPlayerController()->GetControlledEntity()'), + ('Rpc(ERR_CancelClientSpawnScene, m_ERRSceneToken)', 'Cancel(m_ERRSceneToken)')]: + cleanup = cleanup.replace(old, new) +run(r''' +#include +#include +namespace Replication{bool server=true;} +struct Items:std::vector{using std::vector::vector;void Clear(){clear();}}; +struct Controller{int GetControlledEntity(){return 42;}}; +struct Probe{ + Items m_ERRSceneEntities{1,2},m_ERRSceneRoots,m_ERRSceneStockOwners,m_ERRSceneStockItems; + bool m_ERRSceneObserver=true,m_ERRScenePending=true,m_ERRSceneAcknowledged=true,m_ERRSceneTimedOut=true,m_ERRScenePreloadRequired=true; + int m_ERRSceneToken=7,restored=0,cancelled=0,observed=0;Controller controller;bool connected=true; + Controller* GetPlayerController(){return connected?&controller:nullptr;} + void ERR_StreamScene(int,bool normal){assert(normal);restored++;} + void UpdateObserverMP_S(int entity){observed=entity;}void Cancel(int token){assert(token==7);cancelled++;} +'''+cleanup+r''' +}; +int main(){ + Probe p;Replication::server=false;p.ERR_EndSpawnScene();assert(p.restored==0&&p.m_ERRScenePending); + Replication::server=true;p.ERR_EndSpawnScene();assert(p.restored==2&&p.cancelled==1&&p.observed==42); + assert(p.m_ERRSceneEntities.empty()&&!p.m_ERRSceneObserver&&!p.m_ERRScenePending&&!p.m_ERRSceneAcknowledged&&!p.m_ERRSceneTimedOut&&!p.m_ERRScenePreloadRequired); + p.ERR_EndSpawnScene();assert(p.restored==2&&p.cancelled==1); + Probe disconnected;disconnected.connected=false;disconnected.ERR_EndSpawnScene();assert(disconnected.observed==0&&!disconnected.m_ERRSceneObserver); +} +''') +print('PASS: delayed scene/inventory arrival, deferred preload, identity checks, stale replies, timeout and observer cleanup') diff --git a/tests/test_spawn_scene_inventory.py b/tests/test_spawn_scene_inventory.py new file mode 100644 index 0000000..c65cc75 --- /dev/null +++ b/tests/test_spawn_scene_inventory.py @@ -0,0 +1,86 @@ +"""Execute production inventory manifest and client containment checks.""" +from policy_harness import ROOT, body, run +inventory=ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_SpawnSceneInventory.c' +scene=ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_SpawnClientScene.c' +def cpp(s): + s=s.replace('protected ','').replace('static ','') + s=s.replace('out array ', 'Items& ').replace('array ', 'Items ') + s=s.replace('foreach (IEntity root : roots)', 'for (IEntity root : roots)').replace('foreach (IEntity item : contents)', 'for (IEntity item : contents)') + s=s.replace('InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(root.FindComponent(InventoryStorageManagerComponent));', 'Inventory* inventory = root->inventory;') + s=s.replace('InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(owner.GetEntity().FindComponent(InventoryStorageManagerComponent));', 'Inventory* inventory = owner->GetEntity()->inventory;') + s=s.replace('RplComponent owner = RplComponent.Cast(Replication.FindItem(ownerId));', 'RplComponent* owner = Replication::FindItem(ownerId);') + s=s.replace('RplComponent item = RplComponent.Cast(Replication.FindItem(m_ERRClientStockItems[i]));', 'RplComponent* item = Replication::FindItem(m_ERRClientStockItems[i]);') + s=s.replace('owner.GetEntity()', 'owner->GetEntity()').replace('item.GetEntity()', 'item->GetEntity()') + s=s.replace('RplId.Invalid()', '-1').replace('ERR_Inventory.Items(', 'InventoryItems(') + s=s.replace('Replication.IsServer()', 'Replication::server').replace('GetGame().GetWorld().GetWorldTime()', 'now') + s=s.replace('ERR_SpawnSceneInventory.Gather(', 'Gather(').replace('ERR_SpawnSceneInventory.Same(', 'Same(') + return s +methods='\n'.join(cpp(body(inventory,key)) for key in ('static bool Gather(', 'static bool Same(')) +methods+='\n'+cpp(body(scene,'protected bool ERR_ClientStockReady(')) +methods+='\n'+cpp(body(scene,'protected void ERR_RefreshSceneInventory(')) +methods+='\n'+cpp(body(scene,'bool ERR_TakeScenePreload(')) +run(r''' +#include +#include +#include +#include +templatestruct List:std::vector{using std::vector::vector;using std::vector::operator=; + int Count()const{return this->size();}void Insert(T x){this->push_back(x);}bool Contains(T x)const{return std::find(this->begin(),this->end(),x)!=this->end();} + int Find(T x)const{auto i=std::find(this->begin(),this->end(),x);return i==this->end()?-1:i-this->begin();}}; +struct Entity;using IEntity=Entity*;using Items=List;using RplId=int; +struct Inventory{Items items;};struct Entity{Inventory* inventory=nullptr;}; +void InventoryItems(Inventory* inv,Items& items){items={};for(auto x:inv->items)if(x&&!items.Contains(x))items.Insert(x);} +struct RplComponent{IEntity entity=nullptr;IEntity GetEntity(){return entity;}}; +namespace Replication{bool server=true;std::map nodes;RplComponent* FindItem(int id){auto it=nodes.find(id);return it==nodes.end()?nullptr:it->second;}} +templatevoid PrintFormat(const char*,T...){} +struct Probe{ + List m_ERRClientStockOwners,m_ERRClientStockItems; + bool m_ERRScenePending=true,m_ERRSceneTimedOut=false,m_ERRSceneAcknowledged=false,m_ERRScenePreloadRequired=true,preloading=false; + int publishes=0,waits=0,m_ERRSceneToken=7; + float now=0,m_ERRSceneInventoryPoll=0,m_ERRSceneDeadline=60000,m_ERRSceneStartedAt=0; + Items m_ERRSceneRoots,m_ERRSceneStockOwners,m_ERRSceneStockItems; + void ERR_ReportStockWait(int,int,int){waits++;}int GetPlayerId(){return 1;} + bool IsPreloading(){return preloading;}bool ERR_SpawnSceneReady(){return m_ERRSceneAcknowledged&&!m_ERRSceneTimedOut;} + void ERR_PublishSpawnScene(Items owners,Items items,bool valid){publishes++;m_ERRSceneToken++;m_ERRSceneAcknowledged=false;m_ERRSceneStockOwners=owners;m_ERRSceneStockItems=items;m_ERRSceneTimedOut=!valid;} +'''+methods+r''' +}; +int main(){ + Inventory inv;Entity crate{&inv},gun,mag,map;inv.items={&gun,&mag,&map,&gun,nullptr}; + Probe p;Items owners,items;assert(p.Gather({&crate,&crate,nullptr},owners,items)&&items.Count()==3); + assert((items==Items{&gun,&mag,&map})&&(owners==Items{&crate,&crate,&crate})); + assert(p.Same(owners,items,{&crate,&crate,&crate},{&map,&mag,&gun})); + Entity other;assert(!p.Same(owners,items,{&other,&crate,&crate},items)); + assert(!p.Same(owners,items,{&crate,&crate},{&gun,&mag})); + std::vector many(257);Inventory full;for(auto& e:many)full.items.Insert(&e);Entity huge{&full}; + assert(!p.Gather({&huge},owners,items)&&items.Count()==256&&owners.Count()==256); + // Receiving every replication entity is insufficient: inventory links may + // arrive later, and a wrong crate with the same item count is insufficient. + RplComponent cr{&crate},gr{&gun},mr{&mag},pr{&map}; + Replication::nodes={{10,&cr},{11,&gr},{12,&mr},{13,&pr}}; + p.m_ERRClientStockOwners={10,10,10};p.m_ERRClientStockItems={11,12,13}; + inv.items={};for(int i=0;i<20;i++)assert(!p.ERR_ClientStockReady()); + inv.items={&gun,&mag};assert(!p.ERR_ClientStockReady()); + inv.items={&gun,&mag,&other};assert(!p.ERR_ClientStockReady()); + inv.items={&gun,&mag,&map};assert(p.ERR_ClientStockReady()); + cr.entity=nullptr;assert(!p.ERR_ClientStockReady());cr.entity=&crate; + crate.inventory=nullptr;assert(!p.ERR_ClientStockReady());crate.inventory=&inv; + mr.entity=nullptr;assert(!p.ERR_ClientStockReady());mr.entity=&mag; + p.m_ERRClientStockOwners={10};assert(!p.ERR_ClientStockReady()); + // Refresh a consumed/relocated stock snapshot without extending its timeout. + p.m_ERRSceneRoots={&crate};assert(p.Gather(p.m_ERRSceneRoots,p.m_ERRSceneStockOwners,p.m_ERRSceneStockItems)); + p.ERR_RefreshSceneInventory();assert(p.publishes==0); + p.m_ERRSceneAcknowledged=true;inv.items={&gun};p.now=499;p.ERR_RefreshSceneInventory();assert(p.publishes==0); + p.now=500;p.ERR_RefreshSceneInventory();assert(p.publishes==1&&p.m_ERRSceneToken==8&&!p.m_ERRSceneAcknowledged&&p.m_ERRSceneDeadline==60000); + assert((p.m_ERRSceneStockItems==Items{&gun})); + p.m_ERRSceneStockItems={&gun,&mag};p.now=1000;Replication::server=false;p.ERR_RefreshSceneInventory();assert(p.publishes==1);Replication::server=true; + p.m_ERRSceneTimedOut=true;p.ERR_RefreshSceneInventory();assert(p.publishes==1);p.m_ERRSceneTimedOut=false; + p.m_ERRScenePending=false;p.ERR_RefreshSceneInventory();assert(p.publishes==1); + // Preload cannot start before inventory acknowledgement, starts once, and + // waits for an earlier native pass before consuming a refreshed request. + assert(!p.ERR_TakeScenePreload());p.m_ERRSceneAcknowledged=true; + assert(p.ERR_TakeScenePreload()&&!p.ERR_TakeScenePreload()); + p.m_ERRScenePreloadRequired=true;p.preloading=true;assert(!p.ERR_TakeScenePreload()); + p.preloading=false;assert(p.ERR_TakeScenePreload()); +} +''') +print('PASS: actual inventory manifest, delayed/missing/wrong-owner item links, finite cap, consumed-stock refresh and preload ordering') diff --git a/tools/check.py b/tools/check.py index 4c27692..f77ae17 100644 --- a/tools/check.py +++ b/tools/check.py @@ -10,6 +10,7 @@ def check(): print(f'PASS: {check_prefabs((ROOT/"addons").rglob("*.et"))} prefabs have unique saved root entity IDs') subprocess.run([sys.executable,str(ROOT/'tests/test_prefab_identity.py')],check=True) subprocess.run([sys.executable,str(ROOT/'tests/test_spawn_scene.py')],check=True) + subprocess.run([sys.executable,str(ROOT/'tests/test_spawn_scene_inventory.py')],check=True) subprocess.run([sys.executable,str(ROOT/'tests/test_prison_ai.py')],check=True) ids={};graph={};resources={};paths={};owners={} for p in sorted((ROOT/'addons').glob('*/addon.gproj')):