diff --git a/IdleSpectator/InterestCollector.cs b/IdleSpectator/InterestCollector.cs
index b0a74eb..c140002 100644
--- a/IdleSpectator/InterestCollector.cs
+++ b/IdleSpectator/InterestCollector.cs
@@ -108,4 +108,20 @@ public static class InterestCollector
Pending.Clear();
}
}
+
+ public static bool HasPendingAtLeast(InterestTier minTier)
+ {
+ lock (Gate)
+ {
+ for (int i = 0; i < Pending.Count; i++)
+ {
+ if (Pending[i] != null && Pending[i].Tier >= minTier)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+ }
}
diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs
index 4ee111a..3e8093f 100644
--- a/IdleSpectator/InterestDirector.cs
+++ b/IdleSpectator/InterestDirector.cs
@@ -10,6 +10,7 @@ public static class InterestDirector
{
public const float MinDwellSeconds = 10f;
public const float CuriosityDwellSeconds = 6f;
+ public const float CuriosityRotateSeconds = 10f;
public const float SwitchCooldownSeconds = 5f;
public const float HighTierInterruptAfterSeconds = 3f;
public const float QuietWorldAmbientAfterSeconds = 8f;
@@ -97,6 +98,13 @@ public static class InterestDirector
}
}
+ // Queued discovery tips beat ambient, but never stall ambient under Action/Story.
+ if (InterestCollector.HasPendingAtLeast(InterestTier.Curiosity)
+ && (_current == null || _current.Tier <= InterestTier.Curiosity))
+ {
+ return;
+ }
+
bool quiet = now - _lastInterestingAt >= QuietWorldAmbientAfterSeconds;
bool dwellDone = _current == null || onCurrent >= DwellFor(_current);
bool ambientDue = now - _lastAmbientAt >= AmbientRotateSeconds;
@@ -110,6 +118,24 @@ public static class InterestDirector
}
}
+ ///
+ /// True when a drained New species tip would be allowed to take the camera.
+ ///
+ public static bool WouldAcceptCuriosity()
+ {
+ if (_current == null)
+ {
+ return true;
+ }
+
+ float onCurrent = Time.unscaledTime - _currentStartedAt;
+ float sinceSwitch = Time.unscaledTime - _lastSwitchAt;
+ return CanSwitchTo(
+ new InterestEvent { Tier = InterestTier.Curiosity },
+ onCurrent,
+ sinceSwitch);
+ }
+
private static bool PlayerTookManualControl()
{
// Rising-edge style checks only - avoid sticky flags like already_used_power
@@ -165,7 +191,8 @@ public static class InterestDirector
return true;
}
- // Curiosity is a footnote: never interrupt Action/Story/Epic, and rarely replace another curiosity.
+ // Curiosity is a footnote: never interrupt Action/Story/Epic.
+ // Curiosity→curiosity uses a shorter rotate so discovery drains can surface.
if (candidate.Tier == InterestTier.Curiosity)
{
if (_current.Tier >= InterestTier.Action)
@@ -173,7 +200,7 @@ public static class InterestDirector
return false;
}
- if (_current.Tier == InterestTier.Curiosity && onCurrent < AmbientRotateSeconds)
+ if (_current.Tier == InterestTier.Curiosity && onCurrent < CuriosityRotateSeconds)
{
return false;
}
diff --git a/IdleSpectator/SpeciesDiscovery.cs b/IdleSpectator/SpeciesDiscovery.cs
index 783c262..f7ff965 100644
--- a/IdleSpectator/SpeciesDiscovery.cs
+++ b/IdleSpectator/SpeciesDiscovery.cs
@@ -11,11 +11,14 @@ namespace IdleSpectator;
public static class SpeciesDiscovery
{
private const float DrainIntervalSeconds = 12f;
+ private const float NotReadyBackoffSeconds = 5f;
private const int MaxBuffered = 24;
private const int MaxDrainPerSession = 8;
+ private const int MaxNotReadyAttempts = 8;
- private static readonly Queue Pending = new Queue();
+ private static readonly List Pending = new List();
private static readonly HashSet SeenAssets = new HashSet();
+ private static readonly HashSet PresentedAssets = new HashSet();
private static float _lastDrainAt = -999f;
private static int _drainedThisSession;
@@ -24,6 +27,7 @@ public static class SpeciesDiscovery
public string AssetId;
public Vector3 Position;
public Subspecies Subspecies;
+ public int NotReadyAttempts;
}
public static void OnSpectatorEnabled()
@@ -50,20 +54,53 @@ public static class SpeciesDiscovery
return;
}
+ // Already shown via live path before buffer ran - nothing to queue.
+ if (PresentedAssets.Contains(asset.id))
+ {
+ return;
+ }
+
if (Pending.Count >= MaxBuffered)
{
- Pending.Dequeue();
+ Pending.RemoveAt(0);
}
Vector3 pos = tile != null ? tile.posV3 : Vector3.zero;
- Pending.Enqueue(new PendingSpecies
+ Pending.Add(new PendingSpecies
{
AssetId = asset.id,
Position = pos,
- Subspecies = subspecies
+ Subspecies = subspecies,
+ NotReadyAttempts = 0
});
}
+ ///
+ /// Live newSpecies path already tipped this asset - drop any buffered copy.
+ ///
+ public static void MarkPresented(string assetId)
+ {
+ if (string.IsNullOrEmpty(assetId))
+ {
+ return;
+ }
+
+ PresentedAssets.Add(assetId);
+ SeenAssets.Add(assetId);
+ for (int i = Pending.Count - 1; i >= 0; i--)
+ {
+ if (Pending[i].AssetId == assetId)
+ {
+ Pending.RemoveAt(i);
+ }
+ }
+ }
+
+ public static bool WasPresented(string assetId)
+ {
+ return !string.IsNullOrEmpty(assetId) && PresentedAssets.Contains(assetId);
+ }
+
public static void Update()
{
if (!SpectatorMode.Active || !Config.game_loaded || World.world == null)
@@ -87,23 +124,43 @@ public static class SpeciesDiscovery
return;
}
- PendingSpecies next = Pending.Dequeue();
+ // Wait until the director can actually show a curiosity tip.
+ if (!InterestDirector.WouldAcceptCuriosity())
+ {
+ return;
+ }
+
+ int index = FindNextReadyIndex();
+ if (index < 0)
+ {
+ // Front items not spawnable yet - retry later without dequeue flap.
+ _lastDrainAt = now - DrainIntervalSeconds + NotReadyBackoffSeconds;
+ return;
+ }
+
+ PendingSpecies next = Pending[index];
ActorAsset asset = AssetManager.actor_library?.get(next.AssetId);
if (asset == null)
{
+ Pending.RemoveAt(index);
return;
}
InterestEvent curiosity = WorldActivityScanner.FromNewSpecies(next.Subspecies, asset, null);
if (curiosity == null)
{
- // Unit may not exist yet - put back once, try later.
- if (Pending.Count < MaxBuffered)
+ next.NotReadyAttempts++;
+ if (next.NotReadyAttempts >= MaxNotReadyAttempts)
{
- Pending.Enqueue(next);
+ Pending.RemoveAt(index);
+ LogService.LogInfo($"[IdleSpectator] Discovery skip (never spawned): {next.AssetId}");
+ }
+ else
+ {
+ Pending[index] = next;
}
- _lastDrainAt = now - DrainIntervalSeconds + 3f;
+ _lastDrainAt = now - DrainIntervalSeconds + NotReadyBackoffSeconds;
return;
}
@@ -113,9 +170,40 @@ public static class SpeciesDiscovery
curiosity.Position = next.Position;
}
+ Pending.RemoveAt(index);
+ PresentedAssets.Add(next.AssetId);
_lastDrainAt = now;
_drainedThisSession++;
InterestCollector.EnqueueDirect(curiosity);
LogService.LogInfo($"[IdleSpectator] Discovery drain: {next.AssetId} ({_drainedThisSession}/{MaxDrainPerSession}, queued={Pending.Count})");
}
+
+ private static int FindNextReadyIndex()
+ {
+ for (int i = 0; i < Pending.Count; i++)
+ {
+ PendingSpecies next = Pending[i];
+ if (PresentedAssets.Contains(next.AssetId))
+ {
+ Pending.RemoveAt(i);
+ i--;
+ continue;
+ }
+
+ ActorAsset asset = AssetManager.actor_library?.get(next.AssetId);
+ if (asset == null)
+ {
+ Pending.RemoveAt(i);
+ i--;
+ continue;
+ }
+
+ if (WorldActivityScanner.FromNewSpecies(next.Subspecies, asset, null) != null)
+ {
+ return i;
+ }
+ }
+
+ return -1;
+ }
}
diff --git a/IdleSpectator/SubspeciesPatches.cs b/IdleSpectator/SubspeciesPatches.cs
index d7dd0e8..1948bab 100644
--- a/IdleSpectator/SubspeciesPatches.cs
+++ b/IdleSpectator/SubspeciesPatches.cs
@@ -32,6 +32,12 @@ public static class SubspeciesNewSpeciesPatch
return;
}
+ // Drain already tipped this species (or live tip already fired).
+ if (SpeciesDiscovery.WasPresented(assetId))
+ {
+ return;
+ }
+
float now = Time.unscaledTime;
if (LastLiveEnqueuedAt.TryGetValue(assetId, out float last) && now - last < PerSpeciesCooldownSeconds)
{
@@ -45,6 +51,7 @@ public static class SubspeciesNewSpeciesPatch
}
LastLiveEnqueuedAt[assetId] = now;
+ SpeciesDiscovery.MarkPresented(assetId);
InterestCollector.EnqueueDirect(curiosity);
}
}
diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json
index 5af44ce..5401efa 100644
--- a/IdleSpectator/mod.json
+++ b/IdleSpectator/mod.json
@@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
- "version": "0.3.9",
+ "version": "0.3.10",
"description": "AFK Idle Spectator (I): follows wars, disasters, battles, creatures, and new species from fresh worlds to empires.",
"GUID": "com.dazed.idlespectator"
}