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
@@ -2,6 +2,7 @@ using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Ananta.SDK.Network;
|
||||
using Ananta.Server.ClientData.Client4229938;
|
||||
using Ananta.Server.Protocol.Client4229938;
|
||||
|
||||
namespace Ananta.Server.Gameplay.Crowd;
|
||||
@@ -121,8 +122,43 @@ public sealed class UrbanCrowdEngine
|
||||
40968790, // Kaneko Mei - Pedestrian
|
||||
];
|
||||
|
||||
// Common idle / chatter / phone POI action IDs
|
||||
private static readonly uint[] AmbientPoiActions = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
// Contextual ambient POI pools, built from the client's UrbanDiversityPOIActionConfig by
|
||||
// NAME keyword. The old fixed [1..10] roll included 走路 (walk-in-place), 跑步 (run) and
|
||||
// 受惊 (startled) — motion/fear states that looked broken on a standing NPC. 站姿* rows
|
||||
// are the authored standing set (phone, look-around, lean, clap…); shop rows add
|
||||
// browsing/vending actions for interior patrons.
|
||||
private uint[]? _idlePoiPool;
|
||||
private uint[]? _shopPoiPool;
|
||||
|
||||
private uint[] IdlePoiPool()
|
||||
{
|
||||
if (_idlePoiPool is null)
|
||||
{
|
||||
var pool = PoiActionCatalog4229938.All()
|
||||
.Where(p => p.MainActions.Length > 0 && p.Name.StartsWith("站姿", StringComparison.Ordinal))
|
||||
.Select(p => p.Id)
|
||||
.ToList();
|
||||
pool.Insert(0, 2u); // plain idle is always valid
|
||||
_idlePoiPool = pool.ToArray();
|
||||
}
|
||||
return _idlePoiPool;
|
||||
}
|
||||
|
||||
private uint[] ShopPoiPool()
|
||||
{
|
||||
if (_shopPoiPool is null)
|
||||
{
|
||||
var pool = PoiActionCatalog4229938.All()
|
||||
.Where(p => p.MainActions.Length > 0 &&
|
||||
(p.Name.StartsWith("站姿", StringComparison.Ordinal) ||
|
||||
p.Name.Contains("商品") || p.Name.Contains("售货机") || p.Name.Contains("购物")))
|
||||
.Select(p => p.Id)
|
||||
.ToList();
|
||||
pool.Insert(0, 2u);
|
||||
_shopPoiPool = pool.ToArray();
|
||||
}
|
||||
return _shopPoiPool;
|
||||
}
|
||||
|
||||
private readonly Random _rng = new();
|
||||
|
||||
@@ -400,7 +436,8 @@ public sealed class UrbanCrowdEngine
|
||||
|
||||
// Spawn 1 shopkeeper/patron inside
|
||||
var formworkId = CitizenFormworkPool[_rng.Next(CitizenFormworkPool.Length)];
|
||||
var poiAction = AmbientPoiActions[_rng.Next(AmbientPoiActions.Length)];
|
||||
var shopPool = ShopPoiPool();
|
||||
var poiAction = shopPool[_rng.Next(shopPool.Length)];
|
||||
var facing = _rng.Next(0, 360);
|
||||
|
||||
var (ok, ids) = await _driver.SpawnCrowdPedestriansAsync(
|
||||
@@ -475,7 +512,8 @@ public sealed class UrbanCrowdEngine
|
||||
var spawnY = MathF.Abs(pt0[1] - py) > 2.5f ? py : pt0[1];
|
||||
var formworkId = CitizenFormworkPool[_rng.Next(CitizenFormworkPool.Length)];
|
||||
var isWalker = _rng.Next(100) < 60; // 60% walkers, 40% ambient idle/talkers
|
||||
var poiAction = isWalker ? 0u : AmbientPoiActions[_rng.Next(AmbientPoiActions.Length)];
|
||||
var idlePool = IdlePoiPool();
|
||||
var poiAction = isWalker ? 0u : idlePool[_rng.Next(idlePool.Length)];
|
||||
var facing = _rng.Next(0, 360);
|
||||
|
||||
var (ok, ids) = await _driver.SpawnCrowdPedestriansAsync(
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -2,6 +2,15 @@ using Ananta.SDK.Network;
|
||||
|
||||
namespace Ananta.Server.Gameplay.Traffic;
|
||||
|
||||
/// <summary>One lane-progress sample for the batched Aether lane sync.</summary>
|
||||
public readonly record struct AetherLaneSample(ulong EntityId, int LaneHandle, float Distance, byte Status);
|
||||
|
||||
/// <summary>One Aether vehicle spawn sample for the batch form.</summary>
|
||||
public readonly record struct AetherVehicleInit(
|
||||
ulong EntityId, uint ConfigId, uint ColorPartId, int LaneHandle, float Distance,
|
||||
float Speed, double Timestamp, float X, float Y, float Z, float Yaw,
|
||||
List<(float X, float Y, float Z)>? CruisePoints = null);
|
||||
|
||||
public interface ITrafficVehicleDriver
|
||||
{
|
||||
Task<(bool Ok, ulong EntityId)> SpawnTrafficVehicleAsync(
|
||||
@@ -13,6 +22,44 @@ public interface ITrafficVehicleDriver
|
||||
Task SendVehicleMoveAsync(
|
||||
TcpSession session, ulong entityId, float x, float y, float z, float yaw, float vx, float vz);
|
||||
|
||||
/// <summary>
|
||||
/// Native client-driven spawn: registers an Aether vehicle on the client's own zone graph.
|
||||
/// The client simulates/renders it from lane handle + distance; no scene entity is created.
|
||||
/// </summary>
|
||||
Task SpawnAetherVehicleAsync(
|
||||
TcpSession session, ulong entityId, uint configId, uint colorPartId, int laneHandle,
|
||||
float distanceAlongLane, float speed, double timestamp, float x, float y, float z, float yaw);
|
||||
|
||||
/// <summary>
|
||||
/// Spawns Aether vehicles on the client's zone graph. The 4229938 client has NO batch
|
||||
/// AddDatas method — the implementer must send one SyncAetherAIVehicleAddData (68916691)
|
||||
/// notify per vehicle.
|
||||
/// </summary>
|
||||
Task SpawnAetherVehiclesAsync(
|
||||
TcpSession session, IReadOnlyList<AetherVehicleInit> vehicles);
|
||||
|
||||
/// <summary>Binds a driver/passenger NPC into an Aether vehicle seat (SyncAetherAIVehicleNpcAdd).</summary>
|
||||
Task SpawnAetherVehicleNpcAsync(
|
||||
TcpSession session, ulong npcId, uint npcFormworkId, ulong vehicleId, byte seatIndex);
|
||||
|
||||
/// <summary>Removes an Aether vehicle NPC (SyncAetherAINpcRemove 68332284 — the only Aether NPC remove the client has).</summary>
|
||||
Task RemoveAetherVehicleNpcAsync(TcpSession session, ulong npcId);
|
||||
|
||||
/// <summary>Pushes one intersection period change (SyncAetherAIIntersectionUpdateData); the id is the InstanceId previously sent in the init.</summary>
|
||||
Task SendAetherIntersectionUpdateAsync(
|
||||
TcpSession session, ulong intersectionInstanceId, int currentPeriod, int nextPeriod, byte state);
|
||||
|
||||
/// <summary>
|
||||
/// Batched lane-progress sync (one packet per session per tick); the client interpolates
|
||||
/// between samples using its 0.35s delay buffer.
|
||||
/// </summary>
|
||||
Task SendAetherLaneDatasAsync(
|
||||
TcpSession session, List<AetherLaneSample> samples, double timestamp);
|
||||
|
||||
/// <summary>Removes an Aether vehicle from the client's traffic simulation.</summary>
|
||||
Task RemoveAetherVehicleAsync(
|
||||
TcpSession session, ulong entityId);
|
||||
|
||||
bool IsVehiclePlayerControlled(ulong entityId);
|
||||
|
||||
(bool HasPlayer, float X, float Y, float Z, float Yaw, uint RaidId) GetPlayerPosition(TcpSession session);
|
||||
|
||||
@@ -8,6 +8,100 @@ public sealed class TrafficSignalFile
|
||||
{
|
||||
[JsonPropertyName("lights")]
|
||||
public List<TrafficLightEntry> Lights { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("intersections")]
|
||||
public List<TrafficIntersectionEntry> Intersections { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// City zone-graph intersection mapping (InterZones.json): for every signalized city
|
||||
/// intersection (id from the world TrafficLights dump) the indexes of its zones in the
|
||||
/// city ZoneGraphStorage zones array. The client's AetherIntersectionManager resolves each
|
||||
/// SyncAetherAIInitDatas entry by ZoneIndex against its baked StaticIntersectionData blob,
|
||||
/// so these are the values ClientTrafficIntersectionInitInfo.ZoneIndex must carry.
|
||||
/// Generated from readable_world_dump 03_ZONEGRAPHS vfc_1286_e108 + 04_WORLD_JSON vfc_1286_e104.
|
||||
/// </summary>
|
||||
public sealed class IntersectionZoneFile
|
||||
{
|
||||
[JsonPropertyName("source")]
|
||||
public string Source { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("intersections")]
|
||||
public List<IntersectionZoneEntry> Intersections { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class IntersectionZoneEntry
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public uint Id { get; set; }
|
||||
|
||||
[JsonPropertyName("zones")]
|
||||
public List<int> Zones { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The client's baked StaticIntersectionData.json (recovered from the L50 VFS, artifact 111):
|
||||
/// the authoritative intersection zones for the city raid — one entry per signalized
|
||||
/// intersection, with its real zone-graph ZoneIndex, position, traffic-light bindings and
|
||||
/// per-period control tables. This is exactly what the client's AetherIntersectionManager
|
||||
/// resolves against, so the ambient init must use these ZoneIndexes.
|
||||
/// </summary>
|
||||
public sealed class StaticIntersectionFile
|
||||
{
|
||||
[JsonPropertyName("ZoneInfos")]
|
||||
public List<StaticZoneInfoEntry> ZoneInfos { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class StaticZoneInfoEntry
|
||||
{
|
||||
[JsonPropertyName("ZoneIndex")]
|
||||
public int ZoneIndex { get; set; }
|
||||
|
||||
[JsonPropertyName("Position")]
|
||||
public List<float> Position { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("TrafficLights")]
|
||||
public List<StaticTrafficLightEntry> TrafficLights { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("TrafficLightPeriodControls")]
|
||||
public List<StaticPeriodRowEntry> TrafficLightPeriodControls { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class StaticTrafficLightEntry
|
||||
{
|
||||
[JsonPropertyName("Index")]
|
||||
public int Index { get; set; }
|
||||
|
||||
[JsonPropertyName("CrosswalkControlIndex")]
|
||||
public int CrosswalkControlIndex { get; set; }
|
||||
|
||||
[JsonPropertyName("VehicleControlIndex")]
|
||||
public int VehicleControlIndex { get; set; }
|
||||
}
|
||||
|
||||
public sealed class StaticPeriodRowEntry
|
||||
{
|
||||
[JsonPropertyName("TrafficLightControls")]
|
||||
public List<StaticLightControlEntry> TrafficLightControls { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("VehicleLanesOpen")]
|
||||
public List<int> VehicleLanesOpen { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class StaticLightControlEntry
|
||||
{
|
||||
[JsonPropertyName("TrafficLightStateFlags")]
|
||||
public int TrafficLightStateFlags { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>One signalized intersection: its id and the zebra (stop-line group) indexes it controls.</summary>
|
||||
public sealed class TrafficIntersectionEntry
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public uint Id { get; set; }
|
||||
|
||||
[JsonPropertyName("zebras")]
|
||||
public List<uint> Zebras { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class TrafficLightEntry
|
||||
|
||||
@@ -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