using System.Collections.Generic; using NeoModLoader.services; using UnityEngine; namespace IdleSpectator; /// /// Captures subspecies creation during map gen / before spectator is on, /// then drains a few as Curiosity once the world is playable. /// public static class SpeciesDiscovery { private const float DrainIntervalSecondsDefault = 12f; private const float NotReadyBackoffSecondsDefault = 5f; private const int MaxBuffered = 24; private const int MaxDrainPerSession = 8; private const int MaxNotReadyAttempts = 8; private static float _drainInterval = DrainIntervalSecondsDefault; private static float _notReadyBackoff = NotReadyBackoffSecondsDefault; 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; private struct PendingSpecies { public string AssetId; public Vector3 Position; public Subspecies Subspecies; public int NotReadyAttempts; } public static int PendingCount => Pending.Count; public static bool IsPending(string assetId) { return !string.IsNullOrEmpty(assetId) && Pending.Exists(p => p.AssetId == assetId); } public static int DrainedThisSession => _drainedThisSession; public static void OnSpectatorEnabled() { _drainedThisSession = 0; _lastDrainAt = Time.unscaledTime - _drainInterval + 2f; } public static void OnSpectatorDisabled() { // Keep buffer so re-enabling can still discover; clear session drain counter only. _drainedThisSession = 0; } /// /// Harness-only: shorten drain interval. Uses unscaled time (game 5x does not help). /// public static void SetHarnessFastTiming(bool enabled) { _drainInterval = enabled ? 0.75f : DrainIntervalSecondsDefault; _notReadyBackoff = enabled ? 0.35f : NotReadyBackoffSecondsDefault; } /// Harness: clear presented/seen so the same asset can be re-tested. public static void HarnessResetTracking() { PresentedAssets.Clear(); SeenAssets.Clear(); Pending.Clear(); _drainedThisSession = 0; _lastDrainAt = Time.unscaledTime - _drainInterval; } /// Harness: buffer a living asset as if newSpecies fired during load. public static bool HarnessNoteAsset(string assetId, Vector3 position) { if (string.IsNullOrEmpty(assetId)) { return false; } ActorAsset asset = AssetManager.actor_library?.get(assetId); if (asset == null) { return false; } SeenAssets.Remove(assetId); PresentedAssets.Remove(assetId); for (int i = Pending.Count - 1; i >= 0; i--) { if (Pending[i].AssetId == assetId) { Pending.RemoveAt(i); } } NoteCreated(asset, null, mutation: false, subspecies: null); if (Pending.Count > 0) { PendingSpecies last = Pending[Pending.Count - 1]; if (last.AssetId == assetId && position != Vector3.zero) { last.Position = position; Pending[Pending.Count - 1] = last; } } return Pending.Exists(p => p.AssetId == assetId); } /// Harness: ignore drain interval and attempt one drain immediately. public static bool HarnessForceDrainOnce() { _lastDrainAt = Time.unscaledTime - _drainInterval - 0.05f; int before = _drainedThisSession; int pendingBefore = Pending.Count; Update(); return _drainedThisSession > before || Pending.Count < pendingBefore; } public static void NoteCreated(ActorAsset asset, WorldTile tile, bool mutation, Subspecies subspecies) { if (mutation || asset == null || string.IsNullOrEmpty(asset.id)) { return; } if (!SeenAssets.Add(asset.id)) { return; } // Already shown via live path before buffer ran - nothing to queue. if (PresentedAssets.Contains(asset.id)) { return; } if (Pending.Count >= MaxBuffered) { Pending.RemoveAt(0); } Vector3 pos = tile != null ? tile.posV3 : Vector3.zero; Pending.Add(new PendingSpecies { AssetId = asset.id, Position = pos, 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) { return; } if (SmoothLoader.isLoading()) { return; } if (_drainedThisSession >= MaxDrainPerSession || Pending.Count == 0) { return; } float now = Time.unscaledTime; if (now - _lastDrainAt < _drainInterval) { return; } // 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 - _drainInterval + _notReadyBackoff; 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) { next.NotReadyAttempts++; if (next.NotReadyAttempts >= MaxNotReadyAttempts) { Pending.RemoveAt(index); LogService.LogInfo($"[IdleSpectator] Discovery skip (never spawned): {next.AssetId}"); } else { Pending[index] = next; } _lastDrainAt = now - _drainInterval + _notReadyBackoff; return; } // Prefer the recorded spawn tile when the unit search used a weak position. if (next.Position != Vector3.zero && curiosity.Position == Vector3.zero) { 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; } }