feat: Add metro transit, gadgets, shop, social apps, and traffic models checkpoint
This commit is contained in:
1 parent
caa42b84ed
commit
74e2963a12
79 files changed
+527962
-496
No files matched your search
@@ -0,0 +1,346 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using Ananta.SDK.Network;
|
||||
using Ananta.Server.Gameplay.Traffic;
|
||||
|
||||
namespace Ananta.Server.Gameplay.World;
|
||||
|
||||
/// <summary>Send channel for the gadget grid-AOI stream (implemented in the Handlers layer).</summary>
|
||||
public interface IGadgetStreamDriver
|
||||
{
|
||||
/// <summary>Pushes one SyncGadgetGridAOIDecrease batch for the given 40m grid cells with one sector list.</summary>
|
||||
Task SendGadgetGridAoiAsync(
|
||||
TcpSession session, IReadOnlyList<(int X, int Z)> cells, IReadOnlyList<int> sectorIds, (int X, int Z) playerCell, bool firstPush);
|
||||
|
||||
/// <summary>Pushes one SyncDestructibleGridAOIIncrease batch (lampposts, props, breakables).</summary>
|
||||
Task SendSceneItemGridAoiAsync(
|
||||
TcpSession session, IReadOnlyList<(int X, int Z)> cells, (int X, int Z) playerCell, bool firstPush);
|
||||
|
||||
/// <summary>Latest reported player transform (same source and shape as the traffic driver).</summary>
|
||||
(bool HasPlayer, float X, float Y, float Z, float Yaw, uint RaidId) GetPlayerPosition(TcpSession session);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<TcpSession, SessionState> _states = new();
|
||||
private readonly object _lifecycleLock = new();
|
||||
private CancellationTokenSource? _cts;
|
||||
private volatile bool _running;
|
||||
|
||||
private IGadgetStreamDriver? _driver;
|
||||
private Func<IEnumerable<TcpSession>>? _sessionProvider;
|
||||
|
||||
/// <summary>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.</summary>
|
||||
private readonly Dictionary<(int X, int Z), List<int>> _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<IEnumerable<TcpSession>> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<int>();
|
||||
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 _);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Debug-panel helper: forces an immediate rescan of the current window.</summary>
|
||||
public void ResetSession(TcpSession session)
|
||||
{
|
||||
_states.TryRemove(session, out _);
|
||||
}
|
||||
|
||||
public int TrackedSessionCount => _states.Count;
|
||||
|
||||
private static readonly List<int> OutdoorSectors = [0];
|
||||
|
||||
/// <summary>Groups batch cells by identical baked sector list (packet carries one sector list).</summary>
|
||||
private IEnumerable<(List<(int X, int Z)> Cells, List<int> Sectors)> GroupBySectors(List<(int X, int Z)> batch)
|
||||
{
|
||||
var groups = new Dictionary<string, (List<(int X, int Z)> Cells, List<int> 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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user