using System.Collections.Concurrent; using System.Text.Json; using Ananta.SDK.Network; using Ananta.Server.Gameplay.Traffic; namespace Ananta.Server.Gameplay.World; /// Send channel for the gadget grid-AOI stream (implemented in the Handlers layer). public interface IGadgetStreamDriver { /// Pushes one SyncGadgetGridAOIDecrease batch for the given 40m grid cells with one sector list. Task SendGadgetGridAoiAsync( TcpSession session, IReadOnlyList<(int X, int Z)> cells, IReadOnlyList sectorIds, (int X, int Z) playerCell, bool firstPush); /// Pushes one SyncDestructibleGridAOIIncrease batch (lampposts, props, breakables). Task SendSceneItemGridAoiAsync( TcpSession session, IReadOnlyList<(int X, int Z)> cells, (int X, int Z) playerCell, bool firstPush); /// Latest reported player transform (same source and shape as the traffic driver). (bool HasPlayer, float X, float Y, float Z, float Yaw, uint RaidId) GetPlayerPosition(TcpSession session); } /// /// Streams the gadget grid window around every connected player. /// /// The client build bakes every interactive gadget (vending machines, claw machines, doors, /// benches, arcades, stalls...) into per-grid-cell sector data (40m cells, outdoor city data /// under sector 0). Pushing SyncGadgetGridAOIDecrease with the cells around the player makes /// the client instantiate them — the same call the community's Frida-based AnantaGadgetLoader /// performs on GadgetMgr.SyncGadgetGridAOIAddByIndex, but done server-side with no injection. /// /// Lessons baked in from that tool's field notes: cells are only accepted once the client's /// grid data is ready after login (so every visited window is re-sent periodically — re-adding /// a loaded cell never duplicates), removal is never pushed explicitly (re-adding a removed /// cell often yields nothing — "poisoned"), and the batch is budgeted so movement stays smooth. /// public sealed class GadgetStreamingEngine { public static GadgetStreamingEngine Instance { get; } = new(); internal const float CellSize = 40f; private const int TickIntervalMs = 700; // matches the proven injection tool cadence private const int RescanAllSeconds = 120; // periodic full-window re-send (self-healing) private const int MinSpawnRadiusCells = 1; public bool Enabled { get; set; } = true; public int RadiusCells { get; set; } = 3; // 7x7 window = 49 cells public int LoadBudgetCells { get; set; } = 49; private readonly ConcurrentDictionary _states = new(); private readonly object _lifecycleLock = new(); private CancellationTokenSource? _cts; private volatile bool _running; private IGadgetStreamDriver? _driver; private Func>? _sessionProvider; /// Cell → baked sector ids (CellSectors.json, exported from the client's own grid /// data). Sector 0 is the outdoor city; indoor gadgets live under per-building sector ids. private readonly Dictionary<(int X, int Z), List> _cellSectors = new(); private bool _useCellSectors; private sealed class SessionState { public HashSet<(int X, int Z)> LoadedCells = new(); public (int X, int Z) LastCell = (int.MinValue, int.MinValue); public bool FirstPushDone; public long LastRescanUtcTicks; } public void Initialize(string clientDataPath, IGadgetStreamDriver driver, Func> sessionProvider) { ArgumentNullException.ThrowIfNull(driver); ArgumentNullException.ThrowIfNull(sessionProvider); var settings = Configuration.PrivateServerConfigStore.Current.World.Content.Gadgets; Enabled = settings.Enabled; RadiusCells = Math.Max(MinSpawnRadiusCells, settings.RadiusCells); LoadBudgetCells = Math.Max(1, settings.LoadBudgetCells); _useCellSectors = settings.LoadAllSectors; LoadCellSectors(clientDataPath); _driver = driver; _sessionProvider = sessionProvider; Start(); } /// /// Loads CellSectors.json (grid cell → baked sector ids, [x, z, sectorId...] rows exported /// from the client's own runtime grid data). With LoadAllSectors=true the gadget stream /// addresses each cell's REAL sectors — that is what pulls indoor gadgets per building. /// private void LoadCellSectors(string clientDataPath) { _cellSectors.Clear(); try { var path = Path.Combine(clientDataPath, "World", "CellSectors.json"); if (!File.Exists(path)) { Console.WriteLine($"[GADGET] CellSectors.json not found at {path}; gadget stream stays on outdoor sector 0."); return; } using var doc = JsonDocument.Parse(File.ReadAllText(path)); if (!doc.RootElement.TryGetProperty("cells", out var rows)) return; foreach (var row in rows.EnumerateArray()) { if (row.GetArrayLength() < 3) continue; var x = row[0].GetInt32(); var z = row[1].GetInt32(); var sectors = new List(); for (var i = 2; i < row.GetArrayLength(); i++) sectors.Add(row[i].GetInt32()); _cellSectors[(x, z)] = sectors; } Console.WriteLine($"[GADGET] Cell sector map ready: {_cellSectors.Count} cells across {_cellSectors.Values.SelectMany(v => v).Distinct().Count()} sectors."); } catch (Exception ex) { _cellSectors.Clear(); Console.WriteLine($"[GADGET-ERROR] Failed to load CellSectors.json: {ex.Message}"); } } public void Start() { lock (_lifecycleLock) { if (_running || !Enabled) return; _running = true; _cts = new CancellationTokenSource(); var token = _cts.Token; _ = Task.Run(async () => { try { await LoopAsync(token); } finally { _running = false; } }); Console.WriteLine($"[GADGET] Grid stream started (cell=40m, radius={RadiusCells} cells, budget={LoadBudgetCells}/tick, rescan={RescanAllSeconds}s)."); } } public void Stop() { lock (_lifecycleLock) { try { _cts?.Cancel(); } catch { // Cancel is best-effort; the loop exits on its next tick regardless. } } } private async Task LoopAsync(CancellationToken token) { while (!token.IsCancellationRequested) { try { await Task.Delay(TickIntervalMs, token); } catch (OperationCanceledException) { break; } try { await TickAsync(); } catch (Exception ex) { Console.WriteLine($"[GADGET-ERROR] tick exception: {ex.Message}"); } } } private async Task TickAsync() { var driver = _driver; var provider = _sessionProvider; if (driver is null || provider is null) return; var sessions = provider().Where(s => s is not null).Distinct().ToList(); if (sessions.Count == 0) { _states.Clear(); return; } foreach (var session in sessions) { if (!_states.TryGetValue(session, out var state)) _states[session] = state = new SessionState(); var (hasPlayer, px, _, pz, _, _) = driver.GetPlayerPosition(session); if (!hasPlayer || !float.IsFinite(px) || !float.IsFinite(pz)) continue; var cell = ((int)MathF.Floor(px / CellSize), (int)MathF.Floor(pz / CellSize)); var radius = RadiusCells; // Ring candidates around the current cell, closest first. var candidates = new List<(int X, int Z, float Dist)>(); for (var dx = -radius; dx <= radius; dx++) { for (var dz = -radius; dz <= radius; dz++) { var cx = cell.Item1 + dx; var cz = cell.Item2 + dz; if (state.LoadedCells.Contains((cx, cz))) continue; candidates.Add((cx, cz, MathF.Sqrt(dx * dx + dz * dz))); } } candidates.Sort((a, b) => a.Dist.CompareTo(b.Dist)); var take = Math.Min(candidates.Count, LoadBudgetCells); if (take > 0) { var batch = new List<(int X, int Z)>(take); for (var i = 0; i < take; i++) { batch.Add((candidates[i].X, candidates[i].Z)); state.LoadedCells.Add((candidates[i].X, candidates[i].Z)); } try { // Scene items (lampposts, props, breakables) — the client resolves each // cell's baked sectors itself, one packet covers the whole batch. await driver.SendSceneItemGridAoiAsync(session, batch, cell, !state.FirstPushDone); // Gadgets: group cells by their baked sector list so indoor cells address // their building's sector instead of collapsing onto outdoor sector 0. if (_useCellSectors && _cellSectors.Count > 0) { foreach (var group in GroupBySectors(batch)) { await driver.SendGadgetGridAoiAsync(session, group.Cells, group.Sectors, cell, !state.FirstPushDone); } } else { await driver.SendGadgetGridAoiAsync(session, batch, OutdoorSectors, cell, !state.FirstPushDone); } state.FirstPushDone = true; state.LastCell = cell; } catch (ObjectDisposedException) { _states.TryRemove(session, out _); continue; } catch (Exception ex) { Console.WriteLine($"[GADGET-ERROR] push failed: {ex.Message}"); } } // Periodic full-window re-send: cells pushed before the client's grid data was // ready are silently ignored, and re-adding a loaded cell never duplicates. if ((DateTime.UtcNow.Ticks - state.LastRescanUtcTicks) / TimeSpan.TicksPerSecond >= RescanAllSeconds && state.LoadedCells.Count > 0) { state.LastRescanUtcTicks = DateTime.UtcNow.Ticks; var rescan = state.LoadedCells .Where(c => Math.Abs(c.Item1 - cell.Item1) <= radius && Math.Abs(c.Item2 - cell.Item2) <= radius) .ToList(); if (rescan.Count > 0) { try { await driver.SendSceneItemGridAoiAsync(session, rescan, cell, firstPush: false); if (_useCellSectors && _cellSectors.Count > 0) { foreach (var group in GroupBySectors(rescan)) { await driver.SendGadgetGridAoiAsync(session, group.Cells, group.Sectors, cell, firstPush: false); } } else { await driver.SendGadgetGridAoiAsync(session, rescan, OutdoorSectors, cell, firstPush: false); } } catch (ObjectDisposedException) { _states.TryRemove(session, out _); } catch (Exception ex) { Console.WriteLine($"[GADGET-ERROR] rescan failed: {ex.Message}"); } } } } // Drop states of sessions that are gone. foreach (var key in _states.Keys) { if (!sessions.Contains(key)) _states.TryRemove(key, out _); } } /// Debug-panel helper: forces an immediate rescan of the current window. public void ResetSession(TcpSession session) { _states.TryRemove(session, out _); } public int TrackedSessionCount => _states.Count; private static readonly List OutdoorSectors = [0]; /// Groups batch cells by identical baked sector list (packet carries one sector list). private IEnumerable<(List<(int X, int Z)> Cells, List Sectors)> GroupBySectors(List<(int X, int Z)> batch) { var groups = new Dictionary Cells, List Sectors)>(); foreach (var cell in batch) { var sectors = _cellSectors.TryGetValue(cell, out var s) && s.Count > 0 ? s : OutdoorSectors; var key = string.Join(',', sectors); if (!groups.TryGetValue(key, out var group)) groups[key] = group = (new List<(int X, int Z)>(), sectors); group.Cells.Add(cell); } return groups.Values; } }