Wait for client inventory readiness before player admission

This commit is contained in:
2026-09-20 17:31:33 +02:00
parent cab998fb64
commit 1201f9666f
8 changed files with 362 additions and 25 deletions
@@ -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<IEntity> m_ERRSceneRoots = {};
protected ref array<IEntity> m_ERRSceneStockOwners = {};
protected ref array<IEntity> m_ERRSceneStockItems = {};
protected ref array<IEntity> m_ERRSceneEntities = {};
protected int m_ERRClientSceneToken;
protected bool m_ERRClientScenePending;
protected ref array<RplId> m_ERRClientSceneNodes = {};
protected ref array<RplId> m_ERRClientStockOwners = {};
protected ref array<RplId> 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<IEntity> entities, bool valid)
void ERR_BeginSpawnScene(array<IEntity> 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<RplId> 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<IEntity> owners, items;
if (!ERR_SpawnSceneInventory.Gather(m_ERRSceneRoots, owners, items)) valid = false;
ERR_PublishSpawnScene(owners, items, valid);
}
protected bool ERR_AddSceneNode(IEntity entity, array<RplId> 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<IEntity> owners, array<IEntity> 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<RplId> nodes = {};
array<RplId> 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<IEntity> 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<RplId> nodes)
protected void ERR_ReceiveSpawnScene(int token, array<RplId> nodes, array<RplId> stockOwners, array<RplId> 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<IEntity> 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)
@@ -45,13 +45,18 @@ modded class SCR_FreeSpawnHandlerComponent
array<IEntity> 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()
@@ -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<IEntity> roots, out array<IEntity> owners, out array<IEntity> items)
{
owners = {}; items = {};
foreach (IEntity root : roots)
{
if (!root) continue;
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(root.FindComponent(InventoryStorageManagerComponent));
if (!inventory) continue;
array<IEntity> 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<IEntity> owners, array<IEntity> items, array<IEntity> previousOwners, array<IEntity> 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;
}
}
+52
View File
@@ -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
+3
View File
@@ -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.
+39 -6
View File
@@ -30,7 +30,8 @@ template<typename... T>void 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 <cassert>
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 <cassert>
#include <vector>
namespace Replication{bool server=true;}
struct Items:std::vector<int>{using std::vector<int>::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')
+86
View File
@@ -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<IEntity> ', 'Items& ').replace('array<IEntity> ', '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 <cassert>
#include <vector>
#include <algorithm>
#include <map>
template<class T>struct List:std::vector<T>{using std::vector<T>::vector;using std::vector<T>::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<IEntity>;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<int,RplComponent*> nodes;RplComponent* FindItem(int id){auto it=nodes.find(id);return it==nodes.end()?nullptr:it->second;}}
template<typename... T>void PrintFormat(const char*,T...){}
struct Probe{
List<int> 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<Entity> 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')
+1
View File
@@ -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')):