Skip to content

Commit bf0ad41

Browse files
authored
Merge pull request #277 from ifBars/agent/fix-275-client-npc-contacts
fix(npc): finalize custom NPCs on multiplayer clients
2 parents 9f8cb73 + 21eadb8 commit bf0ad41

4 files changed

Lines changed: 129 additions & 7 deletions

File tree

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
using S1API.Entities;
2+
using S1API.Internal.Entities;
3+
using S1API.Internal.Patches;
4+
5+
namespace S1API.Tests.Entities;
6+
7+
public sealed class CustomNpcReadinessPolicyTests
8+
{
9+
[Fact]
10+
public void ClientHydrationSignalsReadyOnlyAfterEveryCustomNpcTypeCompletes()
11+
{
12+
NPC.FinalizedCustomNpcTypes.Clear();
13+
NPCPatches.CustomNpcsReady = false;
14+
15+
try
16+
{
17+
var dealer = TestObjectFactory.CreateUninitialized<DealerNpc>();
18+
var customer = TestObjectFactory.CreateUninitialized<CustomerNpc>();
19+
20+
dealer.CreateFromClientNetworkSpawn();
21+
22+
Assert.False(NPC.CustomNpcsReady);
23+
24+
customer.CreateFromClientNetworkSpawn();
25+
26+
Assert.True(NPC.CustomNpcsReady);
27+
}
28+
finally
29+
{
30+
NPC.FinalizedCustomNpcTypes.Clear();
31+
NPCPatches.CustomNpcsReady = false;
32+
}
33+
}
34+
35+
[Fact]
36+
public void ClientRemainsNotReadyWhileARegisteredTypeIsMissing()
37+
{
38+
Type[] registeredTypes = { typeof(DealerNpc), typeof(CustomerNpc) };
39+
HashSet<Type> finalizedTypes = new() { typeof(DealerNpc) };
40+
41+
bool ready = CustomNpcReadinessPolicy.AreAllTypesFinalized(
42+
registeredTypes,
43+
finalizedTypes);
44+
45+
Assert.False(ready);
46+
}
47+
48+
[Fact]
49+
public void ClientBecomesReadyAfterEveryRegisteredTypeIsHydrated()
50+
{
51+
Type[] registeredTypes = { typeof(DealerNpc), typeof(CustomerNpc) };
52+
HashSet<Type> finalizedTypes = new() { typeof(DealerNpc) };
53+
54+
CustomNpcReadinessPolicy.MarkFinalized(
55+
typeof(CustomerNpc),
56+
finalizedTypes);
57+
58+
bool ready = CustomNpcReadinessPolicy.AreAllTypesFinalized(
59+
registeredTypes,
60+
finalizedTypes);
61+
62+
Assert.True(ready);
63+
}
64+
65+
[Fact]
66+
public void NoRegisteredTypesDoesNotSignalReady()
67+
{
68+
bool ready = CustomNpcReadinessPolicy.AreAllTypesFinalized(
69+
Array.Empty<Type>(),
70+
new HashSet<Type>());
71+
72+
Assert.False(ready);
73+
}
74+
75+
private sealed class DealerNpc : NPC
76+
{
77+
internal override void CreateInternal()
78+
{
79+
}
80+
}
81+
82+
private sealed class CustomerNpc : NPC
83+
{
84+
internal override void CreateInternal()
85+
{
86+
}
87+
}
88+
}

S1API/Entities/NPC.cs

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3489,6 +3489,8 @@ internal void CreateFromClientNetworkSpawn()
34893489
{
34903490
_clientNetworkSpawnHydrationDepth--;
34913491
}
3492+
3493+
MarkCustomNpcFinalized();
34923494
}
34933495

34943496
internal override void SaveInternal(string folderPath, ref List<string> extraSaveables)
@@ -4659,9 +4661,7 @@ internal void FinalizeNetworkSpawn()
46594661

46604662
// Check if all custom NPCs are now ready (finalized)
46614663
// This sets the CustomNpcsReady flag once all custom NPCs have been spawned and finalized
4662-
FinalizedCustomNpcTypes.Add(GetType());
4663-
ReconcileAllCustomNpcRelationshipConnections();
4664-
CheckAndSetCustomNpcsReady();
4664+
MarkCustomNpcFinalized();
46654665
}
46664666
catch (Exception ex)
46674667
{
@@ -4671,7 +4671,7 @@ internal void FinalizeNetworkSpawn()
46714671

46724672
/// <summary>
46734673
/// Checks if all custom NPCs have been finalized and sets the CustomNpcsReady flag.
4674-
/// This is called from FinalizeNetworkSpawn to signal when all custom NPCs are ready.
4674+
/// Called after server finalization and client network-spawn hydration.
46754675
/// </summary>
46764676
internal static void CheckAndSetCustomNpcsReady()
46774677
{
@@ -4689,8 +4689,8 @@ internal static void CheckAndSetCustomNpcsReady()
46894689
if (customNpcTypes.Count == 0)
46904690
return;
46914691

4692-
bool allTypesFinalized = customNpcTypes.All(
4693-
type => FinalizedCustomNpcTypes.Contains(type));
4692+
bool allTypesFinalized = Internal.Entities.CustomNpcReadinessPolicy
4693+
.AreAllTypesFinalized(customNpcTypes, FinalizedCustomNpcTypes);
46944694

46954695
if (allTypesFinalized)
46964696
CustomNpcsReady = true;
@@ -4701,6 +4701,15 @@ internal static void CheckAndSetCustomNpcsReady()
47014701
}
47024702
}
47034703

4704+
private void MarkCustomNpcFinalized()
4705+
{
4706+
Internal.Entities.CustomNpcReadinessPolicy.MarkFinalized(
4707+
GetType(),
4708+
FinalizedCustomNpcTypes);
4709+
ReconcileAllCustomNpcRelationshipConnections();
4710+
CheckAndSetCustomNpcsReady();
4711+
}
4712+
47044713
internal static void ReconcileAllCustomNpcRelationshipConnections()
47054714
{
47064715
var configuredNpcs = All
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
5+
namespace S1API.Internal.Entities
6+
{
7+
/// <summary>
8+
/// Determines whether every registered custom NPC type completed runtime hydration.
9+
/// </summary>
10+
internal static class CustomNpcReadinessPolicy
11+
{
12+
internal static void MarkFinalized(Type customNpcType, ISet<Type> finalizedTypes) =>
13+
finalizedTypes.Add(customNpcType);
14+
15+
internal static bool AreAllTypesFinalized(
16+
IEnumerable<Type> customNpcTypes,
17+
ISet<Type> finalizedTypes)
18+
{
19+
var expectedTypes = customNpcTypes as IReadOnlyCollection<Type>
20+
?? customNpcTypes.ToList();
21+
22+
return expectedTypes.Count > 0
23+
&& expectedTypes.All(finalizedTypes.Contains);
24+
}
25+
}
26+
}

S1API/Internal/Patches/NPCPatches.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1277,7 +1277,6 @@ private static void NPCStart(S1NPCs.NPC __instance)
12771277
else
12781278
{
12791279
apiNpc.CreateFromClientNetworkSpawn();
1280-
NPC.CheckAndSetCustomNpcsReady();
12811280
}
12821281

12831282
// Ensure visibility is set correctly on clients based on IsPhysical

0 commit comments

Comments
 (0)