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
181 changes: 181 additions & 0 deletions S1API.Tests/Internal/Utils/SceneObjectIndexTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
using S1API.Internal.Utils;

namespace S1API.Tests.Internal.Utils;

public sealed class SceneObjectIndexTests
{
private sealed class Item
{
public string? Key;
public bool Alive = true;
public bool Active = true;
}

private static (SceneObjectIndex<Item> index, List<Item> scene) Make()
{
var scene = new List<Item>();
var index = new SceneObjectIndex<Item>(() => scene.ToArray(), i => i.Key, i => i != null && i.Alive);
return (index, scene);
}

[Fact]
public void ScansOnceForRepeatedLookups()
{
var (index, scene) = Make();
scene.Add(new Item { Key = "a" });
Assert.Single(index.Get("a"));
Assert.Single(index.Get("a"));
Assert.Equal(1, index.Scans);
}

[Fact]
public void FindsAnObjectCreatedAfterALookupFoundNothing()
{
var (index, scene) = Make();
Assert.Empty(index.Get("b"));
scene.Add(new Item { Key = "b" });
Assert.Single(index.Get("b"));
}

[Fact]
public void FindsAnObjectCreatedLaterUnderANewKeyWithoutLosingIndexedOnes()
{
var (index, scene) = Make();
var first = new Item { Key = "a" };
scene.Add(first);
Assert.Same(first, Assert.Single(index.Get("a")));
var late = new Item { Key = "late" };
scene.Add(late);
Assert.Same(late, Assert.Single(index.Get("late")));
Assert.Same(first, Assert.Single(index.Get("a")));
}

[Fact]
public void RebuildsWhenAnIndexedObjectsKeyChanged()
{
var (index, scene) = Make();
var item = new Item { Key = "old" };
scene.Add(item);
index.Get("old");
item.Key = "new";
Assert.Empty(index.Get("old"));
Assert.Same(item, Assert.Single(index.Get("new")));
}

[Fact]
public void AllScansAgainWhileItFindsNothing()
{
var (index, scene) = Make();
Assert.Empty(index.All());
var late = new Item();
scene.Add(late);
Assert.Same(late, Assert.Single(index.All()));
Assert.Same(late, Assert.Single(index.All()));
Assert.Equal(2, index.Scans);
}

[Fact]
public void RebuildsWhenAnIndexedObjectWasDestroyed()
{
var (index, scene) = Make();
var old = new Item { Key = "c" };
scene.Add(old);
index.Get("c");
old.Alive = false;
scene.Remove(old);
var replacement = new Item { Key = "c" };
scene.Add(replacement);
Assert.Same(replacement, Assert.Single(index.Get("c")));
Assert.Equal(2, index.Scans);
}

[Fact]
public void AllKeepsScanOrderIncludingUnkeyedObjects()
{
var (index, scene) = Make();
var first = new Item();
var second = new Item { Key = "x" };
var third = new Item();
scene.AddRange(new[] { first, second, third });
Assert.Equal(new[] { first, second, third }, index.All());
}

[Fact]
public void FindsAnIndexedObjectRekeyedIntoAnExistingKey()
{
var (index, scene) = Make();
var first = new Item { Key = "a" };
var second = new Item { Key = "b" };
scene.AddRange(new[] { first, second });
Assert.Same(first, Assert.Single(index.Get("a")));
second.Key = "a";
Assert.Equal(new[] { first, second }, index.Get("a"));
}

[Fact]
public void FindsAnUnkeyedIndexedObjectAssignedToAnExistingKey()
{
var (index, scene) = Make();
var first = new Item { Key = "a" };
var second = new Item();
scene.AddRange(new[] { first, second });
Assert.Same(first, Assert.Single(index.Get("a")));
second.Key = "a";
Assert.Equal(new[] { first, second }, index.Get("a"));
}

[Fact]
public void NotificationFindsANewObjectUnderAnExistingKey()
{
var (index, scene) = Make();
var first = new Item { Key = "a" };
scene.Add(first);
Assert.Same(first, Assert.Single(index.Get("a")));
var late = new Item { Key = "a" };
scene.Add(late);
index.NotifyChanged(late);
Assert.Equal(new[] { first, late }, index.Get("a"));
}

[Fact]
public void NotificationOfAnExistingUnchangedObjectKeepsTheSnapshot()
{
var (index, scene) = Make();
var item = new Item { Key = "a" };
scene.Add(item);
index.Get("a");
index.NotifyChanged(item);
Assert.Same(item, Assert.Single(index.Get("a")));
Assert.Equal(1, index.Scans);
}

[Fact]
public void FiltersCurrentActivationWithoutLosingIndexedObjects()
{
var first = new Item { Key = "a" };
var second = new Item { Key = "a", Active = false };
var scene = new[] { first, second };
var index = new SceneObjectIndex<Item>(() => scene, i => i.Key, i => i != null && i.Alive,
include: i => i.Active);
Assert.Same(first, Assert.Single(index.Get("a")));
first.Active = false;
Assert.Empty(index.Get("a"));
Assert.Empty(index.All());
second.Active = true;
Assert.Same(second, Assert.Single(index.Get("a")));
Assert.Same(second, Assert.Single(index.All()));
first.Active = true;
Assert.Equal(scene, index.Get("a"));
Assert.Equal(1, index.Scans);
}

[Fact]
public void ResetAllClearsEveryIndex()
{
var (index, scene) = Make();
index.Get("d");
scene.Add(new Item { Key = "d" });
SceneObjectIndexes.ResetAll();
Assert.Single(index.Get("d"));
}
}
11 changes: 8 additions & 3 deletions S1API/Entities/NPCAppearance.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1125,6 +1125,13 @@ private static void SetLayerRecursively(GameObject obj, int layer)
#endif
}

internal static readonly global::S1API.Internal.Utils.SceneObjectIndex<S1Map.NPCPoI> PoisByNpcId =
new global::S1API.Internal.Utils.SceneObjectIndex<S1Map.NPCPoI>(
() => UnityEngine.Object.FindObjectsOfType<S1Map.NPCPoI>(true),
poi => poi.NPC?.NPCData?.BasicInfo?.ID,
poi => poi != null,
include: poi => poi.gameObject.activeInHierarchy);

/// <summary>
/// Updates the icon on any map POI components that reference the given NPC.
/// </summary>
Expand All @@ -1135,9 +1142,7 @@ private static void UpdatePoiIcons(S1NPCs.NPC npc, Sprite iconSprite)

try
{
// Find all NPCPoI components in the scene that reference this NPC
var allPoIs = UnityEngine.Object.FindObjectsOfType<S1Map.NPCPoI>();
foreach (var poi in allPoIs)
foreach (var poi in PoisByNpcId.Get(npc.ID))
{
if (poi.NPC == npc && poi.IconContainer != null)
{
Expand Down
22 changes: 18 additions & 4 deletions S1API/Internal/Entities/NPCDataAccess.cs
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,12 @@ private static void PrepareData(S1NPCFramework.NPCData data)
EnsureMovementDefaults(data);
}

private static readonly SceneObjectIndex<S1NPCFramework.MovementPreset> MovementPresets =
new SceneObjectIndex<S1NPCFramework.MovementPreset>(
() => Resources.FindObjectsOfTypeAll<S1NPCFramework.MovementPreset>(),
_ => null,
preset => preset != null);

/// <summary>
/// A freshly created NPCDataObject has no MovementPreset assigned, so
/// NPCData.Movement falls back to the hardcoded class default (WalkSpeed 1.8),
Expand All @@ -412,15 +418,23 @@ private static void EnsureMovementDefaults(S1NPCFramework.NPCData data)
if (movement == null)
return;

S1NPCFramework.Movement? donor = Resources
.FindObjectsOfTypeAll<S1NPCFramework.MovementPreset>()
.Select(preset => preset?.GetValue())
.FirstOrDefault(value => value != null);
S1NPCFramework.Movement? donor = FirstMovementValue();
if (donor == null)
{
// A preset may have loaded or initialised since the presets were indexed.
MovementPresets.Invalidate();
donor = FirstMovementValue();
}

movement.WalkSpeed = donor?.WalkSpeed ?? 1.2f;
movement.MaxSpeed = donor?.MaxSpeed ?? movement.MaxSpeed;
}

private static S1NPCFramework.Movement? FirstMovementValue() =>
MovementPresets.All()
.Select(preset => preset?.GetValue())
.FirstOrDefault(value => value != null);

private static void EnsureDialogueDatabase(
S1NPCFramework.NPCData data,
S1NPCs.NPC? sourceNpc = null)
Expand Down
1 change: 1 addition & 0 deletions S1API/Internal/Lifecycle/SceneStateCleaner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ internal static void ResetForSceneChange(string sceneName, bool afterUnload)

try
{
Internal.Utils.SceneObjectIndexes.ResetAll();
if (afterUnload)
{
for (int i = 0; i < NPC.All.Count; i++)
Expand Down
20 changes: 10 additions & 10 deletions S1API/Internal/Patches/ContactsAppPatches.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#if (IL2CPPMELON)
#if (IL2CPPMELON)
using S1NPCs = Il2CppScheduleOne.NPCs;
using S1ContactsApp = Il2CppScheduleOne.UI.Phone.ContactsApp;
using S1Map = Il2CppScheduleOne.Map;
Expand Down Expand Up @@ -44,7 +44,7 @@ internal class ContactsAppPatches
if (circle == null)
return null;

return (ReflectionUtils.TryGetFieldOrProperty(circle, "AssignedNPC") as S1NPCs.NPC)?.ID
return (ReflectionUtils.TryGetFieldOrProperty(circle, "AssignedNPC") as S1NPCs.NPC)?.NPCData?.BasicInfo?.ID
?? ReflectionUtils.TryGetFieldOrProperty(circle, "AssignedNPC_ID") as string
?? ReflectionUtils.TryGetFieldOrProperty(circle, "NPCId") as string;
}
Expand All @@ -71,22 +71,22 @@ internal static void ResetState()
_hasCustomNpcTypesCache = null;
}

internal static readonly global::S1API.Internal.Utils.SceneObjectIndex<S1Relations.RelationCircle> CirclesByNpcId =
new global::S1API.Internal.Utils.SceneObjectIndex<S1Relations.RelationCircle>(
() => Object.FindObjectsOfType<S1Relations.RelationCircle>(true),
circle => GetAssignedNpcId(circle),
circle => circle != null,
global::System.StringComparer.OrdinalIgnoreCase);

internal static void RefreshContactIcon(S1NPCs.NPC npc)
{
if (npc == null || string.IsNullOrWhiteSpace(npc.ID))
return;

try
{
foreach (S1Relations.RelationCircle circle in
Object.FindObjectsOfType<S1Relations.RelationCircle>(true))
foreach (S1Relations.RelationCircle circle in CirclesByNpcId.Get(npc.ID))
{
if (!string.Equals(
GetAssignedNpcId(circle),
npc.ID,
global::System.StringComparison.OrdinalIgnoreCase))
continue;

SetAssignedNpc(circle, npc);
circle.AssignNPC(npc);
}
Expand Down
28 changes: 28 additions & 0 deletions S1API/Internal/Patches/SceneObjectIndexPatches.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using HarmonyLib;
using S1API.Entities;
using S1API.Internal.Utils;
#if IL2CPPMELON
using S1Map = Il2CppScheduleOne.Map;
using S1Relations = Il2CppScheduleOne.UI.Relations;
#elif MONOMELON
using S1Map = ScheduleOne.Map;
using S1Relations = ScheduleOne.UI.Relations;
#endif

namespace S1API.Internal.Patches
{
/// <summary>INTERNAL: Keeps indexed UI membership aligned with native assignment.</summary>
[HarmonyPatch]
internal static class SceneObjectIndexPatches
{
[HarmonyPostfix]
[HarmonyPatch(typeof(S1Map.NPCPoI), nameof(S1Map.NPCPoI.SetNPC))]
private static void NpcPoiAssigned(S1Map.NPCPoI __instance) =>
NPCAppearance.PoisByNpcId.NotifyChanged(__instance);

[HarmonyPostfix]
[HarmonyPatch(typeof(S1Relations.RelationCircle), nameof(S1Relations.RelationCircle.AssignNPC))]
private static void RelationCircleAssigned(S1Relations.RelationCircle __instance) =>
ContactsAppPatches.CirclesByNpcId.NotifyChanged(__instance);
}
}
Loading
Loading