Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions S1API.Tests/Entities/CustomNpcPreparationPolicyTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using S1API.Internal.Entities;

namespace S1API.Tests.Entities;

public sealed class CustomNpcPreparationPolicyTests
{
[Fact]
public void PreparedInstanceIsReusedOnlyForItsExactCustomType()
{
var first = new FirstCustomNpc();
var second = new SecondCustomNpc();
object[] instances = { first, second };

object? result = CustomNpcPreparationPolicy.FindExactType(
instances,
typeof(SecondCustomNpc));

Assert.Same(second, result);
}

[Fact]
public void MissingPreparedTypeRequiresNewConstruction()
{
object[] instances = { new FirstCustomNpc() };

object? result = CustomNpcPreparationPolicy.FindExactType(
instances,
typeof(SecondCustomNpc));

Assert.Null(result);
}

private sealed class FirstCustomNpc
{
}

private sealed class SecondCustomNpc
{
}
}
34 changes: 34 additions & 0 deletions S1API.Tests/Entities/NPCPersistentIdsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using S1API.Internal.Entities;

namespace S1API.Tests.Entities;

public sealed class NPCPersistentIdsTests
{
[Fact]
public void IdentityIdProducesTheSameGuidAcrossConstructionPaths()
{
Assert.True(NPCPersistentIds.TryGetGuid("mod.author:custom_npc", out Guid first));
Assert.True(NPCPersistentIds.TryGetGuid(" MOD.AUTHOR:CUSTOM_NPC ", out Guid second));

Assert.Equal(first, second);
Assert.NotEqual(Guid.Empty, first);
}

[Fact]
public void DifferentIdentityIdsProduceDifferentGuids()
{
NPCPersistentIds.TryGetGuid("mod.author:customer_a", out Guid first);
NPCPersistentIds.TryGetGuid("mod.author:customer_b", out Guid second);

Assert.NotEqual(first, second);
}

[Theory]
[InlineData("")]
[InlineData(" ")]
public void MissingIdentityIdDoesNotProducePersistentGuid(string id)
{
Assert.False(NPCPersistentIds.TryGetGuid(id, out Guid guid));
Assert.Equal(Guid.Empty, guid);
}
}
67 changes: 64 additions & 3 deletions S1API/Entities/NPC.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2111,7 +2111,7 @@ protected NPC()
if (Icon == null)
NPCDataAccess.ApplyIcon(S1NPC, S1DevUtilities.PlayerSingleton<S1ContactApps.ContactsApp>.Instance.AppIcon);

S1NPC.BakedGUID = Guid.NewGuid().ToString();
AssignPersistentGuid(id);

if (IsPhysical)
ResetConversationCategoriesToDefaults();
Expand Down Expand Up @@ -2170,6 +2170,7 @@ protected NPC(string id, string? firstName, string? lastName, Sprite? icon = nul
bool hasLastName = !string.IsNullOrEmpty(lastName);

NPCDataAccess.ApplyIdentity(S1NPC, id, firstName, lastName);
AssignPersistentGuid(id);
if (icon != null)
{
NPCDataAccess.ApplyIcon(S1NPC, icon);
Expand Down Expand Up @@ -3239,10 +3240,22 @@ public bool ConversationCanBeHidden
internal bool RelationshipLoadedFromSave { get; private set; }

/// <summary>
/// INTERNAL: Marks native relationship state as hydrated from save data.
/// INTERNAL: Applies relationship data from a native save payload and retains it through activation.
/// </summary>
internal void MarkRelationshipLoadedFromSave() =>
internal void LoadRelationshipFromSave(
float relationDelta,
bool unlocked,
S1Relation.NPCRelationData.EUnlockType unlockType)
{
if (!NPCRelationshipPersistencePolicy.IsValidSavedDelta(relationDelta))
return;

_loadedRelationshipDelta = relationDelta;
_loadedRelationshipUnlocked = unlocked;
_loadedRelationshipUnlockType = unlockType;
RelationshipLoadedFromSave = true;
RestoreLoadedRelationship();
}

/// <summary>
/// INTERNAL: Constructor used for base game NPCs.
Expand Down Expand Up @@ -4044,6 +4057,48 @@ private void InitializeNetworkBehaviours()
}
}

private void AssignPersistentGuid(string? npcId)
{
Guid guid = NPCPersistentIds.TryGetGuid(npcId, out Guid persistentGuid)
? persistentGuid
: Guid.NewGuid();
S1NPC.BakedGUID = guid.ToString();
}

internal void RegisterPersistentGuidForContractLoad()
{
if (!Guid.TryParse(S1NPC.BakedGUID, out Guid guid))
return;

try
{
#if IL2CPPMELON
S1NPC.SetGUID(new Il2CppSystem.Guid(guid.ToString()));
#else
S1NPC.SetGUID(guid);
#endif
}
catch (Exception ex)
{
Logger.Warning(
$"[NPC] Failed to register persistent GUID for '{GetSafeNpcId()}': {ex.Message}");
}
}

private void RestoreLoadedRelationship()
{
if (!_loadedRelationshipDelta.HasValue || S1NPC.RelationData == null)
return;

S1NPC.RelationData.SetRelationship(_loadedRelationshipDelta.Value, false);
if (_loadedRelationshipUnlocked)
{
S1NPC.RelationData.Unlock(
_loadedRelationshipUnlockType,
notify: false);
}
}

private void RestoreRuntimeAvatarAppearance()
{
if (_runtimeAvatar == null)
Expand Down Expand Up @@ -4081,6 +4136,9 @@ private void RestoreRuntimeAvatarAppearance()
private NPCDrinking? _drinking;
private NPCItemHolding? _itemHolding;
private bool _relationshipDataAppliedFromPrefab;
private float? _loadedRelationshipDelta;
private bool _loadedRelationshipUnlocked;
private S1Relation.NPCRelationData.EUnlockType _loadedRelationshipUnlockType;
private readonly System.Collections.Generic.List<DealerRecommendationSubscription> _recommendationSubscriptions =
new System.Collections.Generic.List<DealerRecommendationSubscription>();

Expand Down Expand Up @@ -4117,6 +4175,7 @@ internal bool PrepareForNetworkSpawn()
}

NPCDataAccess.PrepareForRuntime(S1NPC);
RestoreLoadedRelationship();

var customer = gameObject.GetComponent<S1Economy.Customer>();
if (customer != null)
Expand Down Expand Up @@ -4199,6 +4258,8 @@ internal void FinalizeNetworkSpawn()
{
try
{
RestoreLoadedRelationship();

// Ensure NPCAwareness.Responses reference is valid after spawn
// Network spawning can sometimes break component references
if (S1NPC.Awareness != null && S1NPC.Responses is S1Responses.NPCResponses_Civilian validResponses)
Expand Down
13 changes: 12 additions & 1 deletion S1API/Entities/NPCCustomer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -581,7 +581,18 @@ private void InitializeRuntimeState(S1Economy.Customer customer)
// Ensure the deal-attendance implementation used by this game version is present.
try
{
EnsureDealAttendanceSupport(NPC?.gameObject, NPC?.GetType());
if (EnsureDealAttendanceSupport(NPC?.gameObject, NPC?.GetType()))
{
var attendance = NPC?.gameObject
.GetComponentInChildren<S1NPCs.Behaviour.CustomerAttendDealBehaviour>(true);
if (attendance != null)
{
ReflectionUtils.TrySetFieldOrProperty(
customer,
"_attendDealBehaviour",
attendance);
}
}
}
catch { /* ignore */ }
}
Expand Down
19 changes: 19 additions & 0 deletions S1API/Internal/Entities/CustomNpcPreparationPolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using System;
using System.Collections.Generic;
using System.Linq;

namespace S1API.Internal.Entities
{
/// <summary>
/// Selects prepared custom NPC instances for reuse across native loader phases.
/// </summary>
internal static class CustomNpcPreparationPolicy
{
internal static T? FindExactType<T>(
IEnumerable<T> instances,
Type requestedType)
where T : class =>
instances.FirstOrDefault(
instance => instance != null && instance.GetType() == requestedType);
}
}
35 changes: 35 additions & 0 deletions S1API/Internal/Entities/NPCPersistentIds.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using System;
using System.Security.Cryptography;
using System.Text;

namespace S1API.Internal.Entities
{
/// <summary>
/// Creates stable native identifiers for custom NPCs that participate in persisted game systems.
/// </summary>
internal static class NPCPersistentIds
{
internal static bool TryGetGuid(string? npcId, out Guid guid)
{
if (string.IsNullOrWhiteSpace(npcId))
{
guid = Guid.Empty;
return false;
}

byte[] hash;
using (SHA256 algorithm = SHA256.Create())
{
string value = $"S1API.NPC:v1:{npcId.Trim().ToLowerInvariant()}";
hash = algorithm.ComputeHash(Encoding.UTF8.GetBytes(value));
}

var guidBytes = new byte[16];
Array.Copy(hash, guidBytes, guidBytes.Length);
guidBytes[6] = (byte)((guidBytes[6] & 0x0f) | 0x50);
guidBytes[8] = (byte)((guidBytes[8] & 0x3f) | 0x80);
guid = new Guid(guidBytes);
return true;
}
}
}
Loading
Loading