1824 lines
66 KiB
C#
1824 lines
66 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Net;
|
|
using System.Net.Sockets;
|
|
using FloodSWE.IO;
|
|
using FloodSWE.Streaming;
|
|
using FloodSWE.TileGraph;
|
|
using UnityEngine;
|
|
|
|
namespace FloodSWE.Networking
|
|
{
|
|
public sealed class SweServerRuntime : MonoBehaviour
|
|
{
|
|
[Header("Simulation")]
|
|
public SweTileSimulator simulator;
|
|
public SweTileLoader tileLoader;
|
|
public string activeLod = "lod1";
|
|
public int activeTileX = 1;
|
|
public int activeTileY = 1;
|
|
public bool applyTileOnStart = true;
|
|
|
|
[Header("Boundary Manifest")]
|
|
public TextAsset boundaryManifestAsset;
|
|
public string boundaryManifestPath = "GeoData/export_swe/swe_boundaries.json";
|
|
|
|
[Header("Network")]
|
|
public int commandListenPort = 29010;
|
|
public string questHost = "";
|
|
public int questFramePort = 29011;
|
|
public bool acceptFirstCommandSenderAsQuest = true;
|
|
|
|
[Header("Frame Stream")]
|
|
public float targetFrameHz = 10.0f;
|
|
[Tooltip("Maximum UDP payload bytes for a single frame datagram.")]
|
|
public int maxFramePayloadBytes = 60000;
|
|
[Tooltip("If enabled, oversized frames are downsampled until they fit max payload size.")]
|
|
public bool downsampleOversizedFrames = true;
|
|
[Tooltip("Lowest allowed resolution when downsampling oversized frames.")]
|
|
public int minFrameResolution = 64;
|
|
|
|
[Header("Source/Sink Control")]
|
|
public float sourceDepthRatePerLevelMps = 0.01f;
|
|
public float sinkDepthRatePerLevelMps = 0.01f;
|
|
[Tooltip("Applied to free_outflow sinks when no explicit sink level is set.")]
|
|
public float freeOutflowBaseLevel = 1.0f;
|
|
public bool applyFreeOutflowByDefault = true;
|
|
[Tooltip("Applies an initial source level on startup for quick validation without client commands.")]
|
|
public bool applyDefaultSourceOnStart = false;
|
|
public int defaultSourceId = 1;
|
|
public float defaultSourceLevel = 1.0f;
|
|
public bool verboseDiagnostics = true;
|
|
public float simThroughputAveragingSeconds = 1.0f;
|
|
[Range(0.0f, 1.0f)]
|
|
public float simThroughputSmoothing = 0.35f;
|
|
public float clientSignalTimeoutSeconds = 3.0f;
|
|
|
|
private readonly Dictionary<int, float> sourceLevels = new Dictionary<int, float>();
|
|
private readonly Dictionary<int, float> sinkLevels = new Dictionary<int, float>();
|
|
private readonly Dictionary<string, SweBoundaryProfile> boundaryProfiles =
|
|
new Dictionary<string, SweBoundaryProfile>(StringComparer.OrdinalIgnoreCase);
|
|
private readonly Dictionary<string, SweCheckpointState> checkpoints = new Dictionary<string, SweCheckpointState>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
private SweBoundaryManifest boundaryManifest;
|
|
private HashSet<int> activeSourceIds = new HashSet<int>();
|
|
private HashSet<int> activeSinkIds = new HashSet<int>();
|
|
private readonly Dictionary<int, int[]> activeBoundaryInflowGhostCells = new Dictionary<int, int[]>();
|
|
private readonly Dictionary<int, int[]> activeBoundarySinkGhostCells = new Dictionary<int, int[]>();
|
|
private readonly Dictionary<int, int[]> activeSourceAreaCells = new Dictionary<int, int[]>();
|
|
private readonly Dictionary<int, int[]> activeSinkCells = new Dictionary<int, int[]>();
|
|
private bool activeTileHasBoundaryCellGroups;
|
|
|
|
private UdpClient commandSocket;
|
|
private IPEndPoint commandEndpoint;
|
|
private IPEndPoint questEndpoint;
|
|
private readonly Dictionary<string, IPEndPoint> frameSubscribers = new Dictionary<string, IPEndPoint>(StringComparer.Ordinal);
|
|
private readonly Dictionary<string, IPEndPoint> stateSubscribers = new Dictionary<string, IPEndPoint>(StringComparer.Ordinal);
|
|
private int lastOversizeWarningFrameId = -1;
|
|
private string lastMissingMaskWarningTileKey = "";
|
|
private int receivedControlPackets;
|
|
private int decodedControlPackets;
|
|
private int invalidControlPackets;
|
|
private int ackPacketsSent;
|
|
private int droppedFramePackets;
|
|
private int transmittedFramePackets;
|
|
private long transmittedFrameBytes;
|
|
private int lastFramePayloadBytes;
|
|
private string lastCommandName = "none";
|
|
private string lastCommandSender = "n/a";
|
|
private float lastCommandTimeUnscaled = -1.0f;
|
|
private float lastClientChangeTimeUnscaled = -1.0f;
|
|
private float simulatedSecondsPerSecond;
|
|
private int lastPacketFrameId = -1;
|
|
private float lastPacketTimeUnscaled = -1.0f;
|
|
private float throughputWindowRealSeconds;
|
|
private float throughputWindowSimSeconds;
|
|
private int lastForcedCellCount;
|
|
private string lastForcingStatus = "idle";
|
|
|
|
public string ActiveTileLabel => $"{activeLod} ({activeTileX},{activeTileY})";
|
|
public int ConnectedClientCount => frameSubscribers.Count;
|
|
public bool HasConnectedClients => frameSubscribers.Count > 0;
|
|
public string QuestEndpointLabel => questEndpoint != null ? questEndpoint.ToString() : "none";
|
|
public float SimulatedSecondsPerSecond => simulatedSecondsPerSecond;
|
|
public bool HasSimulationThroughput => simulatedSecondsPerSecond > 0.0f;
|
|
public string LastCommandName => lastCommandName;
|
|
public string LastCommandSender => lastCommandSender;
|
|
public float LastCommandAgeSeconds => lastCommandTimeUnscaled >= 0.0f ? Time.unscaledTime - lastCommandTimeUnscaled : float.PositiveInfinity;
|
|
public bool HasRecentClientSignal => lastCommandTimeUnscaled >= 0.0f && LastCommandAgeSeconds <= Mathf.Max(0.1f, clientSignalTimeoutSeconds);
|
|
public int ReceivedControlPackets => receivedControlPackets;
|
|
public int DecodedControlPackets => decodedControlPackets;
|
|
public int InvalidControlPackets => invalidControlPackets;
|
|
public int AckPacketsSent => ackPacketsSent;
|
|
public int DroppedFramePackets => droppedFramePackets;
|
|
public int TransmittedFramePackets => transmittedFramePackets;
|
|
public long TransmittedFrameBytes => transmittedFrameBytes;
|
|
public int LastFramePayloadBytes => lastFramePayloadBytes;
|
|
public string LastForcingStatus => lastForcingStatus;
|
|
public int LastForcedCellCount => lastForcedCellCount;
|
|
public string TileLoadSummary => tileLoader != null ? tileLoader.LastLoadSummary : "SweTileLoader missing";
|
|
public float LastClientChangeAgeSeconds => lastClientChangeTimeUnscaled >= 0.0f ? Time.unscaledTime - lastClientChangeTimeUnscaled : float.PositiveInfinity;
|
|
public string DiagnosticsSummary => BuildDiagnosticsSummary();
|
|
|
|
private void OnEnable()
|
|
{
|
|
ResetDiagnostics();
|
|
if (simulator == null)
|
|
{
|
|
simulator = GetComponent<SweTileSimulator>();
|
|
}
|
|
|
|
if (simulator == null)
|
|
{
|
|
Debug.LogError("SweServerRuntime: missing SweTileSimulator reference.");
|
|
enabled = false;
|
|
return;
|
|
}
|
|
|
|
if (applyTileOnStart)
|
|
{
|
|
bool loaded = TrySetActiveTile(activeLod, activeTileX, activeTileY);
|
|
if (!loaded)
|
|
{
|
|
Debug.LogWarning($"SweServerRuntime: failed to load tile {activeLod} ({activeTileX},{activeTileY}).");
|
|
}
|
|
}
|
|
simulator.emitHeightmapPackets = true;
|
|
simulator.packetEveryNTicks = ComputePacketInterval(simulator.tickSeconds, targetFrameHz);
|
|
simulator.HeightmapPacketReady += OnHeightmapPacket;
|
|
|
|
LoadBoundaryManifest();
|
|
RefreshActiveTileBoundaryIds();
|
|
ApplyDefaultSourceIfConfigured();
|
|
RecomputeExternalDepthRate();
|
|
|
|
StartNetworking();
|
|
}
|
|
|
|
private void OnDisable()
|
|
{
|
|
if (simulator != null)
|
|
{
|
|
simulator.HeightmapPacketReady -= OnHeightmapPacket;
|
|
}
|
|
|
|
StopNetworking();
|
|
}
|
|
|
|
private void Update()
|
|
{
|
|
PollCommands();
|
|
UpdateThroughputSmoothing();
|
|
}
|
|
|
|
private void OnHeightmapPacket(HeightmapPacket packet)
|
|
{
|
|
TrackSimulationProgress(packet);
|
|
if (frameSubscribers.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!TryBuildTransmitPayload(packet, out HeightmapPacket transmitPacket, out byte[] payload))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var stale = new List<string>();
|
|
foreach (var kv in frameSubscribers)
|
|
{
|
|
try
|
|
{
|
|
commandSocket.Send(payload, payload.Length, kv.Value);
|
|
transmittedFramePackets++;
|
|
transmittedFrameBytes += payload.Length;
|
|
lastFramePayloadBytes = payload.Length;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
stale.Add(kv.Key);
|
|
Debug.LogWarning($"SweServerRuntime: failed to send frame to {kv.Value}. {ex.Message}");
|
|
}
|
|
}
|
|
|
|
for (int i = 0; i < stale.Count; i++)
|
|
{
|
|
if (frameSubscribers.Remove(stale[i]))
|
|
{
|
|
lastClientChangeTimeUnscaled = Time.unscaledTime;
|
|
}
|
|
}
|
|
}
|
|
|
|
private bool TryBuildTransmitPayload(HeightmapPacket originalPacket, out HeightmapPacket transmitPacket, out byte[] payload)
|
|
{
|
|
transmitPacket = originalPacket;
|
|
payload = SweUdpProtocol.EncodeFrame(transmitPacket);
|
|
int maxBytes = Mathf.Clamp(maxFramePayloadBytes, 1200, 65507);
|
|
if (payload.Length <= maxBytes)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (!downsampleOversizedFrames)
|
|
{
|
|
WarnOversizedFrame(originalPacket.FrameId, originalPacket.Resolution, originalPacket.Resolution, payload.Length, maxBytes);
|
|
droppedFramePackets++;
|
|
return false;
|
|
}
|
|
|
|
if (!originalPacket.TryDecodeHeights(out float[] sourceHeights))
|
|
{
|
|
if (lastOversizeWarningFrameId != originalPacket.FrameId)
|
|
{
|
|
lastOversizeWarningFrameId = originalPacket.FrameId;
|
|
Debug.LogWarning($"SweServerRuntime: oversized frame {originalPacket.FrameId} ({payload.Length} bytes) and decode failed.");
|
|
}
|
|
droppedFramePackets++;
|
|
return false;
|
|
}
|
|
|
|
int sourceRes = originalPacket.Resolution;
|
|
int targetRes = sourceRes;
|
|
int minRes = Mathf.Max(8, minFrameResolution);
|
|
|
|
while (payload.Length > maxBytes && targetRes > minRes)
|
|
{
|
|
targetRes = Mathf.Max(minRes, targetRes / 2);
|
|
float[] downsampled = DownsampleHeightsNearest(sourceHeights, sourceRes, targetRes);
|
|
transmitPacket = HeightmapPacket.FromHeights(originalPacket.FrameId, originalPacket.Tile, targetRes, downsampled);
|
|
payload = SweUdpProtocol.EncodeFrame(transmitPacket);
|
|
}
|
|
|
|
if (payload.Length <= maxBytes)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
WarnOversizedFrame(originalPacket.FrameId, sourceRes, targetRes, payload.Length, maxBytes);
|
|
droppedFramePackets++;
|
|
return false;
|
|
}
|
|
|
|
private float[] DownsampleHeightsNearest(float[] source, int sourceRes, int targetRes)
|
|
{
|
|
float[] result = new float[targetRes * targetRes];
|
|
float denom = Mathf.Max(1.0f, targetRes - 1.0f);
|
|
float sourceMax = sourceRes - 1.0f;
|
|
int idx = 0;
|
|
for (int y = 0; y < targetRes; y++)
|
|
{
|
|
int sy = Mathf.Clamp(Mathf.RoundToInt((y / denom) * sourceMax), 0, sourceRes - 1);
|
|
int sourceRow = sy * sourceRes;
|
|
for (int x = 0; x < targetRes; x++)
|
|
{
|
|
int sx = Mathf.Clamp(Mathf.RoundToInt((x / denom) * sourceMax), 0, sourceRes - 1);
|
|
result[idx++] = source[sourceRow + sx];
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private void WarnOversizedFrame(int frameId, int sourceRes, int finalRes, int payloadBytes, int maxBytes)
|
|
{
|
|
if (lastOversizeWarningFrameId == frameId)
|
|
{
|
|
return;
|
|
}
|
|
|
|
lastOversizeWarningFrameId = frameId;
|
|
Debug.LogWarning(
|
|
$"SweServerRuntime: dropped frame {frameId}; payload {payloadBytes}B exceeds max {maxBytes}B " +
|
|
$"(sourceRes={sourceRes}, finalRes={finalRes}).");
|
|
}
|
|
|
|
private void StartNetworking()
|
|
{
|
|
try
|
|
{
|
|
commandSocket = new UdpClient(commandListenPort);
|
|
commandSocket.Client.Blocking = false;
|
|
commandEndpoint = new IPEndPoint(IPAddress.Any, 0);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Debug.LogError($"SweServerRuntime: failed to start command socket on port {commandListenPort}. {ex.Message}");
|
|
enabled = false;
|
|
return;
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(questHost))
|
|
{
|
|
try
|
|
{
|
|
IPAddress[] addresses = Dns.GetHostAddresses(questHost.Trim());
|
|
if (addresses.Length > 0)
|
|
{
|
|
questEndpoint = new IPEndPoint(addresses[0], questFramePort);
|
|
AddFrameSubscriber(questEndpoint);
|
|
Debug.Log($"SweServerRuntime: fixed Quest endpoint {questEndpoint}.");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Debug.LogWarning($"SweServerRuntime: could not resolve quest host '{questHost}'. {ex.Message}");
|
|
}
|
|
}
|
|
}
|
|
|
|
private void StopNetworking()
|
|
{
|
|
if (commandSocket != null)
|
|
{
|
|
commandSocket.Close();
|
|
commandSocket = null;
|
|
}
|
|
|
|
commandEndpoint = null;
|
|
questEndpoint = null;
|
|
if (frameSubscribers.Count > 0)
|
|
{
|
|
lastClientChangeTimeUnscaled = Time.unscaledTime;
|
|
}
|
|
frameSubscribers.Clear();
|
|
stateSubscribers.Clear();
|
|
}
|
|
|
|
private void PollCommands()
|
|
{
|
|
if (commandSocket == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
while (commandSocket.Available > 0)
|
|
{
|
|
byte[] raw;
|
|
IPEndPoint sender = new IPEndPoint(IPAddress.Any, 0);
|
|
try
|
|
{
|
|
raw = commandSocket.Receive(ref sender);
|
|
}
|
|
catch (SocketException)
|
|
{
|
|
break;
|
|
}
|
|
|
|
if (raw == null || raw.Length == 0)
|
|
{
|
|
continue;
|
|
}
|
|
receivedControlPackets++;
|
|
|
|
if (acceptFirstCommandSenderAsQuest && questEndpoint == null)
|
|
{
|
|
questEndpoint = new IPEndPoint(sender.Address, questFramePort);
|
|
AddFrameSubscriber(questEndpoint);
|
|
Debug.Log($"SweServerRuntime: learned Quest endpoint {questEndpoint} from command sender {sender}.");
|
|
}
|
|
|
|
if (!SweUdpProtocol.TryDecodeControl(raw, out SweControlCommand command) || command == null)
|
|
{
|
|
invalidControlPackets++;
|
|
continue;
|
|
}
|
|
|
|
decodedControlPackets++;
|
|
lastCommandName = string.IsNullOrWhiteSpace(command.command) ? "unknown" : command.command.Trim();
|
|
lastCommandSender = sender.ToString();
|
|
lastCommandTimeUnscaled = Time.unscaledTime;
|
|
HandleCommand(command, sender);
|
|
}
|
|
}
|
|
|
|
private void HandleCommand(SweControlCommand command, IPEndPoint sender)
|
|
{
|
|
string cmd = command.command != null ? command.command.Trim().ToLowerInvariant() : string.Empty;
|
|
switch (cmd)
|
|
{
|
|
case "set_source_level":
|
|
ApplyLegacySourceLevel(command.sourceId, command.sourceLevel);
|
|
SendAck(sender, $"legacy source {command.sourceId} level={command.sourceLevel:F3}");
|
|
break;
|
|
|
|
case "set_sink_level":
|
|
ApplyLegacySinkLevel(command.sinkId, command.sinkLevel);
|
|
SendAck(sender, $"legacy sink {command.sinkId} level={command.sinkLevel:F3}");
|
|
break;
|
|
|
|
case "set_boundary_profile":
|
|
if (TryApplyBoundaryProfileCommand(command, out string profileResult))
|
|
{
|
|
SendAck(sender, profileResult);
|
|
BroadcastBoundaryState("update");
|
|
}
|
|
else
|
|
{
|
|
SendAck(sender, "set_boundary_profile rejected");
|
|
}
|
|
break;
|
|
|
|
case "set_boundaries_bulk":
|
|
if (TryApplyBoundaryBulkCommand(command, out string bulkResult))
|
|
{
|
|
SendAck(sender, bulkResult);
|
|
BroadcastBoundaryState("update");
|
|
}
|
|
else
|
|
{
|
|
SendAck(sender, "set_boundaries_bulk rejected");
|
|
}
|
|
break;
|
|
|
|
case "get_boundary_config":
|
|
SendBoundaryState(sender, "config");
|
|
SendAck(sender, "boundary_config_sent");
|
|
break;
|
|
|
|
case "subscribe_boundary_updates":
|
|
if (command.subscribe)
|
|
{
|
|
AddStateSubscriber(sender);
|
|
SendBoundaryState(sender, "config");
|
|
SendAck(sender, "boundary_updates_subscribed");
|
|
}
|
|
else
|
|
{
|
|
RemoveStateSubscriber(sender);
|
|
SendAck(sender, "boundary_updates_unsubscribed");
|
|
}
|
|
break;
|
|
|
|
case "set_active_tile":
|
|
if (TrySetActiveTile(command.lod, command.tileX, command.tileY))
|
|
{
|
|
SendAck(sender, $"active tile: {activeLod} ({activeTileX},{activeTileY})");
|
|
}
|
|
else
|
|
{
|
|
SendAck(sender, $"active tile missing: {command.lod} ({command.tileX},{command.tileY})");
|
|
}
|
|
break;
|
|
|
|
case "save_checkpoint":
|
|
SaveCheckpoint(command.checkpoint);
|
|
SendAck(sender, $"checkpoint saved: {command.checkpoint}");
|
|
break;
|
|
|
|
case "reset_checkpoint":
|
|
bool restored = RestoreCheckpoint(command.checkpoint);
|
|
SendAck(sender, restored ? $"checkpoint restored: {command.checkpoint}" : $"checkpoint missing: {command.checkpoint}");
|
|
break;
|
|
|
|
case "apply_porosity_stamp":
|
|
bool stamped = simulator.ApplyPorosityStampNormalized(new Vector2(command.u, command.v), command.radius, command.porosity);
|
|
SendAck(sender, stamped ? "porosity stamp applied" : "porosity stamp failed");
|
|
break;
|
|
|
|
case "hello":
|
|
if (acceptFirstCommandSenderAsQuest)
|
|
{
|
|
var updated = new IPEndPoint(sender.Address, questFramePort);
|
|
if (questEndpoint == null || !questEndpoint.Equals(updated))
|
|
{
|
|
questEndpoint = updated;
|
|
Debug.Log($"SweServerRuntime: refreshed Quest endpoint to {questEndpoint} from hello.");
|
|
}
|
|
|
|
AddFrameSubscriber(updated);
|
|
}
|
|
SendAck(sender, "hello");
|
|
break;
|
|
|
|
case "disconnect":
|
|
RemoveFrameSubscriber(sender.Address, questFramePort);
|
|
RemoveStateSubscriber(sender);
|
|
SendAck(sender, "disconnected");
|
|
break;
|
|
|
|
default:
|
|
SendAck(sender, $"unknown command: {command.command}");
|
|
break;
|
|
}
|
|
}
|
|
|
|
private bool TrySetActiveTile(string lod, int tileX, int tileY)
|
|
{
|
|
string targetLod = string.IsNullOrWhiteSpace(lod) ? activeLod : lod.Trim();
|
|
int targetX = tileX;
|
|
int targetY = tileY;
|
|
|
|
if (tileLoader == null)
|
|
{
|
|
activeLod = targetLod;
|
|
activeTileX = targetX;
|
|
activeTileY = targetY;
|
|
simulator.packetTileId = new TileId(SweBoundaryManifest.ParseLod(activeLod), activeTileX, activeTileY);
|
|
RefreshActiveTileBoundaryIds();
|
|
RecomputeExternalDepthRate();
|
|
BroadcastBoundaryState("tile_changed");
|
|
return true;
|
|
}
|
|
|
|
bool loaded = tileLoader.ApplyToSimulator(targetLod, targetX, targetY, simulator);
|
|
if (!loaded)
|
|
{
|
|
if (verboseDiagnostics && tileLoader != null)
|
|
{
|
|
Debug.LogWarning($"SweServerRuntime: tile load diagnostics => {tileLoader.LastLoadSummary}");
|
|
}
|
|
return false;
|
|
}
|
|
|
|
activeLod = targetLod;
|
|
activeTileX = targetX;
|
|
activeTileY = targetY;
|
|
simulator.packetTileId = new TileId(SweBoundaryManifest.ParseLod(activeLod), activeTileX, activeTileY);
|
|
RefreshActiveTileBoundaryIds();
|
|
RecomputeExternalDepthRate();
|
|
BroadcastBoundaryState("tile_changed");
|
|
if (verboseDiagnostics && tileLoader != null)
|
|
{
|
|
Debug.Log($"SweServerRuntime: active tile set => {tileLoader.LastLoadSummary}");
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private void SendAck(IPEndPoint target, string message)
|
|
{
|
|
if (commandSocket == null || target == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
byte[] payload = SweUdpProtocol.EncodeAck(message);
|
|
try
|
|
{
|
|
commandSocket.Send(payload, payload.Length, target);
|
|
ackPacketsSent++;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Debug.LogWarning($"SweServerRuntime: failed to send ack. {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private void LoadBoundaryManifest()
|
|
{
|
|
string json = null;
|
|
if (boundaryManifestAsset != null)
|
|
{
|
|
json = boundaryManifestAsset.text;
|
|
}
|
|
else if (!string.IsNullOrWhiteSpace(boundaryManifestPath))
|
|
{
|
|
string path = boundaryManifestPath;
|
|
if (!Path.IsPathRooted(path))
|
|
{
|
|
path = Path.Combine(Directory.GetCurrentDirectory(), boundaryManifestPath);
|
|
}
|
|
|
|
if (File.Exists(path))
|
|
{
|
|
json = File.ReadAllText(path);
|
|
}
|
|
}
|
|
|
|
if (!SweBoundaryManifest.TryLoad(json, out boundaryManifest))
|
|
{
|
|
Debug.LogWarning("SweServerRuntime: boundary manifest missing or invalid. Source/sink control disabled for this run.");
|
|
boundaryManifest = null;
|
|
lastForcingStatus = "disabled:manifest_missing_or_invalid";
|
|
boundaryProfiles.Clear();
|
|
return;
|
|
}
|
|
|
|
InitializeBoundaryProfilesFromManifest();
|
|
}
|
|
|
|
private void InitializeBoundaryProfilesFromManifest()
|
|
{
|
|
boundaryProfiles.Clear();
|
|
if (boundaryManifest == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (boundaryManifest.boundaries != null)
|
|
{
|
|
for (int i = 0; i < boundaryManifest.boundaries.Length; i++)
|
|
{
|
|
SweBoundaryDefinition boundary = boundaryManifest.boundaries[i];
|
|
if (boundary == null || boundary.id <= 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
SweBoundaryProfile profile = boundary.default_state != null
|
|
? boundary.default_state.ToProfile(boundary.kind, boundary.id)
|
|
: new SweBoundaryProfile
|
|
{
|
|
boundaryKind = NormalizeBoundaryKind(boundary.kind),
|
|
boundaryId = boundary.id,
|
|
enabled = false,
|
|
waterLevelM = 0.0f,
|
|
velocityUMps = 0.0f,
|
|
velocityVMps = 0.0f,
|
|
depthRateMps = 0.0f,
|
|
};
|
|
SetBoundaryProfileInternal(profile);
|
|
}
|
|
}
|
|
|
|
if (boundaryManifest.sources != null)
|
|
{
|
|
for (int i = 0; i < boundaryManifest.sources.Length; i++)
|
|
{
|
|
SweBoundarySource source = boundaryManifest.sources[i];
|
|
if (source == null || source.id <= 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
string key = MakeBoundaryProfileKey("boundary_inflow", source.id);
|
|
if (!boundaryProfiles.ContainsKey(key))
|
|
{
|
|
SetBoundaryProfileInternal(new SweBoundaryProfile
|
|
{
|
|
boundaryKind = "boundary_inflow",
|
|
boundaryId = source.id,
|
|
enabled = false,
|
|
waterLevelM = 0.0f,
|
|
velocityUMps = 0.0f,
|
|
velocityVMps = 0.0f,
|
|
depthRateMps = 0.0f,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (boundaryManifest.sinks != null)
|
|
{
|
|
for (int i = 0; i < boundaryManifest.sinks.Length; i++)
|
|
{
|
|
SweBoundarySink sink = boundaryManifest.sinks[i];
|
|
if (sink == null || sink.id <= 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
string key = MakeBoundaryProfileKey("sink", sink.id);
|
|
if (!boundaryProfiles.ContainsKey(key))
|
|
{
|
|
bool freeOutflow = sink.@params != null && sink.@params.IsMode("free_outflow");
|
|
SetBoundaryProfileInternal(new SweBoundaryProfile
|
|
{
|
|
boundaryKind = "sink",
|
|
boundaryId = sink.id,
|
|
enabled = freeOutflow,
|
|
waterLevelM = 0.0f,
|
|
velocityUMps = 0.0f,
|
|
velocityVMps = 0.0f,
|
|
depthRateMps = 0.0f,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private void RefreshActiveTileBoundaryIds()
|
|
{
|
|
activeSourceIds = new HashSet<int>();
|
|
activeSinkIds = new HashSet<int>();
|
|
activeBoundaryInflowGhostCells.Clear();
|
|
activeBoundarySinkGhostCells.Clear();
|
|
activeSourceAreaCells.Clear();
|
|
activeSinkCells.Clear();
|
|
activeTileHasBoundaryCellGroups = false;
|
|
|
|
if (boundaryManifest == null)
|
|
{
|
|
lastForcingStatus = "disabled:no_manifest";
|
|
return;
|
|
}
|
|
|
|
if (!boundaryManifest.TryGetTile(activeLod, activeTileX, activeTileY, out SweBoundaryTile tile) || tile == null)
|
|
{
|
|
Debug.LogWarning($"SweServerRuntime: no boundary entry for tile {activeLod} ({activeTileX},{activeTileY}).");
|
|
lastForcingStatus = "disabled:no_tile_boundary_entry";
|
|
return;
|
|
}
|
|
|
|
CollectIdsFromRefs(tile.source_ids, activeSourceIds);
|
|
CollectIdsFromRefs(tile.sink_ids, activeSinkIds);
|
|
CollectIdsFromRefs(tile.boundary_inflow_ids, activeSourceIds);
|
|
|
|
CollectCellGroups(tile.boundary_cells, activeBoundaryInflowGhostCells, activeSourceIds);
|
|
CollectCellGroups(tile.boundary_sink_cells, activeBoundarySinkGhostCells, activeSinkIds);
|
|
CollectCellGroups(tile.source_area_cells, activeSourceAreaCells, activeSourceIds);
|
|
CollectCellGroups(tile.sink_cells, activeSinkCells, activeSinkIds);
|
|
|
|
activeTileHasBoundaryCellGroups =
|
|
activeBoundaryInflowGhostCells.Count > 0 ||
|
|
activeBoundarySinkGhostCells.Count > 0 ||
|
|
activeSourceAreaCells.Count > 0 ||
|
|
activeSinkCells.Count > 0;
|
|
|
|
Debug.Log(
|
|
$"SweServerRuntime: active tile inflowIds={activeSourceIds.Count}, sinkIds={activeSinkIds.Count}, " +
|
|
$"boundaryInGhostGroups={activeBoundaryInflowGhostCells.Count}, boundaryOutGhostGroups={activeBoundarySinkGhostCells.Count}, " +
|
|
$"sourceAreaGroups={activeSourceAreaCells.Count}, sinkGroups={activeSinkCells.Count}");
|
|
}
|
|
|
|
private void RecomputeExternalDepthRate()
|
|
{
|
|
if (simulator == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (activeTileHasBoundaryCellGroups)
|
|
{
|
|
RecomputeBoundaryProfilesWithCellGroups();
|
|
return;
|
|
}
|
|
|
|
RecomputeLegacyBoundaryDepthRate();
|
|
}
|
|
|
|
private void RecomputeBoundaryProfilesWithCellGroups()
|
|
{
|
|
var ghostIndices = new List<int>(128);
|
|
var ghostLevels = new List<float>(128);
|
|
var ghostVelocities = new List<Vector2>(128);
|
|
var ghostInflowCells = new HashSet<int>();
|
|
var ghostOutflowCells = new HashSet<int>();
|
|
var suppressedOutflowCells = new HashSet<int>();
|
|
var boundarySinkIdsWithGhostOutflow = new HashSet<int>();
|
|
|
|
foreach (var pair in activeBoundaryInflowGhostCells)
|
|
{
|
|
int boundaryId = pair.Key;
|
|
if (!TryResolveBoundaryProfile("boundary_inflow", boundaryId, out SweBoundaryProfile profile) || profile == null)
|
|
{
|
|
continue;
|
|
}
|
|
if (!profile.enabled)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
int[] cells = pair.Value;
|
|
if (cells == null || cells.Length == 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
float level = Mathf.Max(0.0f, profile.waterLevelM);
|
|
Vector2 velocity = new Vector2(profile.velocityUMps, profile.velocityVMps);
|
|
for (int i = 0; i < cells.Length; i++)
|
|
{
|
|
int idx = cells[i];
|
|
if (idx < 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
ghostIndices.Add(idx);
|
|
ghostLevels.Add(level);
|
|
ghostVelocities.Add(velocity);
|
|
ghostInflowCells.Add(idx);
|
|
}
|
|
}
|
|
|
|
bool hasGhostForcing =
|
|
ghostIndices.Count > 0 &&
|
|
simulator.SetGhostBoundaryOverrides(ghostIndices.ToArray(), ghostLevels.ToArray(), ghostVelocities.ToArray());
|
|
if (!hasGhostForcing)
|
|
{
|
|
simulator.ClearGhostBoundaryOverrides();
|
|
}
|
|
|
|
foreach (var pair in activeBoundarySinkGhostCells)
|
|
{
|
|
int boundaryId = pair.Key;
|
|
if (boundaryId <= 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (!TryResolveBoundaryProfile("sink", boundaryId, out SweBoundaryProfile sinkProfile) ||
|
|
sinkProfile == null ||
|
|
!sinkProfile.enabled)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
boundarySinkIdsWithGhostOutflow.Add(boundaryId);
|
|
int[] cells = pair.Value;
|
|
if (cells == null || cells.Length == 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
for (int i = 0; i < cells.Length; i++)
|
|
{
|
|
int idx = cells[i];
|
|
if (idx < 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (ghostInflowCells.Contains(idx))
|
|
{
|
|
suppressedOutflowCells.Add(idx);
|
|
continue;
|
|
}
|
|
|
|
ghostOutflowCells.Add(idx);
|
|
}
|
|
}
|
|
|
|
bool hasGhostOutflow =
|
|
ghostOutflowCells.Count > 0 &&
|
|
simulator.SetGhostFreeOutflowCells(new List<int>(ghostOutflowCells).ToArray());
|
|
if (!hasGhostOutflow)
|
|
{
|
|
simulator.ClearGhostFreeOutflowCells();
|
|
}
|
|
|
|
int totalCells = simulator.gridRes * simulator.gridRes;
|
|
float[] perCell = new float[totalCells];
|
|
bool hasDepthForcing = false;
|
|
int forcedDepthCells = 0;
|
|
|
|
foreach (var pair in activeSourceAreaCells)
|
|
{
|
|
int boundaryId = pair.Key;
|
|
if (!TryResolveBoundaryProfile("source_area", boundaryId, out SweBoundaryProfile profile) || profile == null)
|
|
{
|
|
continue;
|
|
}
|
|
if (!profile.enabled)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
float rate = profile.depthRateMps;
|
|
if (Mathf.Abs(rate) <= 1e-9f && profile.waterLevelM > 0.0f)
|
|
{
|
|
rate = profile.waterLevelM * sourceDepthRatePerLevelMps;
|
|
}
|
|
if (rate <= 0.0f)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
int[] cells = pair.Value;
|
|
if (cells == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
for (int i = 0; i < cells.Length; i++)
|
|
{
|
|
int idx = cells[i];
|
|
if (idx < 0 || idx >= totalCells)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
perCell[idx] += rate;
|
|
}
|
|
}
|
|
|
|
foreach (var pair in activeSinkCells)
|
|
{
|
|
int boundaryId = pair.Key;
|
|
if (boundarySinkIdsWithGhostOutflow.Contains(boundaryId))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
float rate = 0.0f;
|
|
if (TryResolveBoundaryProfile("sink", boundaryId, out SweBoundaryProfile profile) && profile != null && profile.enabled)
|
|
{
|
|
rate = profile.depthRateMps;
|
|
if (Mathf.Abs(rate) <= 1e-9f && profile.waterLevelM > 0.0f)
|
|
{
|
|
rate = -profile.waterLevelM * sinkDepthRatePerLevelMps;
|
|
}
|
|
}
|
|
|
|
if (Mathf.Abs(rate) <= 1e-9f && applyFreeOutflowByDefault && IsFreeOutflowSink(boundaryId))
|
|
{
|
|
rate = -Mathf.Max(0.0f, freeOutflowBaseLevel) * sinkDepthRatePerLevelMps;
|
|
}
|
|
|
|
if (rate > 0.0f)
|
|
{
|
|
rate = -rate;
|
|
}
|
|
if (Mathf.Abs(rate) <= 1e-9f)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
int[] cells = pair.Value;
|
|
if (cells == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
for (int i = 0; i < cells.Length; i++)
|
|
{
|
|
int idx = cells[i];
|
|
if (idx < 0 || idx >= totalCells)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
perCell[idx] += rate;
|
|
}
|
|
}
|
|
|
|
for (int i = 0; i < perCell.Length; i++)
|
|
{
|
|
if (Mathf.Abs(perCell[i]) > 1e-9f)
|
|
{
|
|
hasDepthForcing = true;
|
|
forcedDepthCells++;
|
|
}
|
|
}
|
|
|
|
if (hasDepthForcing && simulator.SetExternalDepthRateMap(perCell))
|
|
{
|
|
simulator.SetExternalDepthRate(0.0f);
|
|
}
|
|
else
|
|
{
|
|
simulator.ClearExternalDepthRateMap();
|
|
simulator.SetExternalDepthRate(0.0f);
|
|
hasDepthForcing = false;
|
|
forcedDepthCells = 0;
|
|
}
|
|
|
|
if (!hasGhostForcing && !hasDepthForcing && !hasGhostOutflow)
|
|
{
|
|
lastForcedCellCount = 0;
|
|
lastForcingStatus = "disabled:no_active_boundary_profiles";
|
|
return;
|
|
}
|
|
|
|
lastForcedCellCount = ghostIndices.Count + forcedDepthCells + (hasGhostOutflow ? ghostOutflowCells.Count : 0);
|
|
if (hasGhostForcing && hasDepthForcing && hasGhostOutflow)
|
|
{
|
|
lastForcingStatus = "ghost+localized+free_outflow";
|
|
}
|
|
else if (hasGhostForcing && hasDepthForcing)
|
|
{
|
|
lastForcingStatus = "ghost+localized";
|
|
}
|
|
else if (hasGhostForcing && hasGhostOutflow)
|
|
{
|
|
lastForcingStatus = "ghost+free_outflow";
|
|
}
|
|
else if (hasDepthForcing && hasGhostOutflow)
|
|
{
|
|
lastForcingStatus = "localized+free_outflow";
|
|
}
|
|
else if (hasGhostForcing)
|
|
{
|
|
lastForcingStatus = "ghost_only";
|
|
}
|
|
else if (hasGhostOutflow)
|
|
{
|
|
lastForcingStatus = "free_outflow_only";
|
|
}
|
|
else
|
|
{
|
|
lastForcingStatus = "localized";
|
|
}
|
|
|
|
if (verboseDiagnostics)
|
|
{
|
|
Debug.Log(
|
|
$"SweServerRuntime: forcing applied status={lastForcingStatus} ghost={ghostIndices.Count} depth={forcedDepthCells} freeOutflow={ghostOutflowCells.Count} suppressedOutflowByInflow={suppressedOutflowCells.Count}");
|
|
}
|
|
}
|
|
|
|
private void RecomputeLegacyBoundaryDepthRate()
|
|
{
|
|
simulator.ClearGhostBoundaryOverrides();
|
|
simulator.ClearGhostFreeOutflowCells();
|
|
|
|
var sourceRates = new Dictionary<int, float>();
|
|
foreach (int sourceId in activeSourceIds)
|
|
{
|
|
if (!sourceLevels.TryGetValue(sourceId, out float value))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
float rate = Mathf.Max(0.0f, value) * sourceDepthRatePerLevelMps;
|
|
if (rate > 0.0f)
|
|
{
|
|
sourceRates[sourceId] = rate;
|
|
}
|
|
}
|
|
|
|
var sinkRates = new Dictionary<int, float>();
|
|
foreach (int sinkId in activeSinkIds)
|
|
{
|
|
if (sinkLevels.TryGetValue(sinkId, out float value))
|
|
{
|
|
float rate = Mathf.Max(0.0f, value) * sinkDepthRatePerLevelMps;
|
|
if (rate > 0.0f)
|
|
{
|
|
sinkRates[sinkId] = rate;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (applyFreeOutflowByDefault &&
|
|
boundaryManifest != null &&
|
|
boundaryManifest.TryGetSink(sinkId, out SweBoundarySink sink) &&
|
|
sink != null &&
|
|
sink.@params != null &&
|
|
sink.@params.IsMode("free_outflow"))
|
|
{
|
|
float rate = Mathf.Max(0.0f, freeOutflowBaseLevel) * sinkDepthRatePerLevelMps;
|
|
if (rate > 0.0f)
|
|
{
|
|
sinkRates[sinkId] = rate;
|
|
}
|
|
}
|
|
}
|
|
|
|
bool hasBoundaryRates = sourceRates.Count > 0 || sinkRates.Count > 0;
|
|
if (!hasBoundaryRates)
|
|
{
|
|
lastForcedCellCount = 0;
|
|
lastForcingStatus = "disabled:no_active_source_or_sink_levels_legacy";
|
|
simulator.ClearExternalDepthRateMap();
|
|
simulator.SetExternalDepthRate(0.0f);
|
|
return;
|
|
}
|
|
|
|
if (simulator.TryGetBoundaryIdMasks(out int[] sourceMask, out int[] sinkMask))
|
|
{
|
|
int n = simulator.gridRes * simulator.gridRes;
|
|
float[] perCell = new float[n];
|
|
bool anyNonZero = false;
|
|
int forcedCellCount = 0;
|
|
|
|
for (int i = 0; i < n; i++)
|
|
{
|
|
if (!IsBorderCell(i, simulator.gridRes))
|
|
{
|
|
perCell[i] = 0.0f;
|
|
continue;
|
|
}
|
|
|
|
float rate = 0.0f;
|
|
if (sourceMask != null)
|
|
{
|
|
int sourceId = sourceMask[i];
|
|
if (sourceId > 0 && sourceRates.TryGetValue(sourceId, out float sourceRate))
|
|
{
|
|
rate += sourceRate;
|
|
}
|
|
}
|
|
|
|
if (sinkMask != null)
|
|
{
|
|
int sinkId = sinkMask[i];
|
|
if (sinkId > 0 && sinkRates.TryGetValue(sinkId, out float sinkRate))
|
|
{
|
|
rate -= sinkRate;
|
|
}
|
|
}
|
|
|
|
perCell[i] = rate;
|
|
anyNonZero = anyNonZero || Mathf.Abs(rate) > 1e-9f;
|
|
if (Mathf.Abs(rate) > 1e-9f)
|
|
{
|
|
forcedCellCount++;
|
|
}
|
|
}
|
|
|
|
if (anyNonZero && simulator.SetExternalDepthRateMap(perCell))
|
|
{
|
|
simulator.SetExternalDepthRate(0.0f);
|
|
lastForcedCellCount = forcedCellCount;
|
|
lastForcingStatus = "legacy_localized";
|
|
Debug.Log($"SweServerRuntime: applied localized boundary forcing cells={forcedCellCount}");
|
|
return;
|
|
}
|
|
|
|
lastForcedCellCount = 0;
|
|
lastForcingStatus = "disabled:boundary_masks_zero_or_apply_failed_legacy";
|
|
}
|
|
else
|
|
{
|
|
string key = $"{activeLod}|{activeTileX}|{activeTileY}";
|
|
if (!string.Equals(lastMissingMaskWarningTileKey, key, StringComparison.Ordinal))
|
|
{
|
|
lastMissingMaskWarningTileKey = key;
|
|
Debug.LogWarning(
|
|
$"SweServerRuntime: no readable source/sink ID masks for tile {activeLod} ({activeTileX},{activeTileY}); " +
|
|
"disabling source/sink forcing.");
|
|
}
|
|
|
|
lastForcedCellCount = 0;
|
|
lastForcingStatus = "disabled:masks_unavailable_legacy";
|
|
}
|
|
|
|
// Strict mode: no masks means no forcing. This prevents non-physical uniform flooding.
|
|
simulator.ClearExternalDepthRateMap();
|
|
simulator.SetExternalDepthRate(0.0f);
|
|
}
|
|
|
|
private bool TryResolveBoundaryProfile(string kind, int boundaryId, out SweBoundaryProfile profile)
|
|
{
|
|
profile = null;
|
|
if (boundaryId <= 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
string normalizedKind = NormalizeBoundaryKind(kind);
|
|
string key = MakeBoundaryProfileKey(normalizedKind, boundaryId);
|
|
if (boundaryProfiles.TryGetValue(key, out profile) && profile != null)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (boundaryManifest != null &&
|
|
boundaryManifest.TryGetBoundary(normalizedKind, boundaryId, out SweBoundaryDefinition boundary) &&
|
|
boundary != null)
|
|
{
|
|
profile = boundary.default_state != null
|
|
? boundary.default_state.ToProfile(normalizedKind, boundaryId)
|
|
: new SweBoundaryProfile
|
|
{
|
|
boundaryKind = normalizedKind,
|
|
boundaryId = boundaryId,
|
|
enabled = false,
|
|
waterLevelM = 0.0f,
|
|
velocityUMps = 0.0f,
|
|
velocityVMps = 0.0f,
|
|
depthRateMps = 0.0f,
|
|
};
|
|
SetBoundaryProfileInternal(profile);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private bool IsFreeOutflowSink(int sinkId)
|
|
{
|
|
if (boundaryManifest == null || sinkId <= 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (boundaryManifest.TryGetBoundary("sink", sinkId, out SweBoundaryDefinition boundary) &&
|
|
boundary != null &&
|
|
boundary.@params != null &&
|
|
boundary.@params.IsMode("free_outflow"))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return boundaryManifest.TryGetSink(sinkId, out SweBoundarySink sink) &&
|
|
sink != null &&
|
|
sink.@params != null &&
|
|
sink.@params.IsMode("free_outflow");
|
|
}
|
|
|
|
private static bool IsBorderCell(int flatIndex, int resolution)
|
|
{
|
|
if (resolution <= 1)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
int x = flatIndex % resolution;
|
|
int y = flatIndex / resolution;
|
|
return x == 0 || y == 0 || x == resolution - 1 || y == resolution - 1;
|
|
}
|
|
|
|
private static void CollectIdsFromRefs(SweBoundaryTileIdRef[] refs, HashSet<int> outIds)
|
|
{
|
|
if (refs == null || outIds == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
for (int i = 0; i < refs.Length; i++)
|
|
{
|
|
SweBoundaryTileIdRef entry = refs[i];
|
|
if (entry != null && entry.id > 0)
|
|
{
|
|
outIds.Add(entry.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void CollectCellGroups(
|
|
SweBoundaryTileCellGroup[] groups,
|
|
Dictionary<int, int[]> outGroups,
|
|
HashSet<int> outIds)
|
|
{
|
|
if (groups == null || outGroups == null || outIds == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
for (int i = 0; i < groups.Length; i++)
|
|
{
|
|
SweBoundaryTileCellGroup group = groups[i];
|
|
if (group == null || group.id <= 0 || group.cells == null || group.cells.Length == 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
outIds.Add(group.id);
|
|
outGroups[group.id] = (int[])group.cells.Clone();
|
|
}
|
|
}
|
|
|
|
private void ApplyLegacySourceLevel(int sourceId, float sourceLevel)
|
|
{
|
|
if (sourceId <= 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
sourceLevels[sourceId] = sourceLevel;
|
|
var profile = new SweBoundaryProfile
|
|
{
|
|
boundaryKind = "boundary_inflow",
|
|
boundaryId = sourceId,
|
|
enabled = sourceLevel > 0.0f,
|
|
waterLevelM = Mathf.Max(0.0f, sourceLevel),
|
|
velocityUMps = 0.0f,
|
|
velocityVMps = 0.0f,
|
|
depthRateMps = 0.0f,
|
|
};
|
|
SetBoundaryProfileInternal(profile);
|
|
RecomputeExternalDepthRate();
|
|
}
|
|
|
|
private void ApplyLegacySinkLevel(int sinkId, float sinkLevel)
|
|
{
|
|
if (sinkId <= 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
sinkLevels[sinkId] = sinkLevel;
|
|
var profile = new SweBoundaryProfile
|
|
{
|
|
boundaryKind = "sink",
|
|
boundaryId = sinkId,
|
|
enabled = sinkLevel > 0.0f,
|
|
waterLevelM = Mathf.Max(0.0f, sinkLevel),
|
|
velocityUMps = 0.0f,
|
|
velocityVMps = 0.0f,
|
|
depthRateMps = -Mathf.Max(0.0f, sinkLevel) * sinkDepthRatePerLevelMps,
|
|
};
|
|
SetBoundaryProfileInternal(profile);
|
|
RecomputeExternalDepthRate();
|
|
}
|
|
|
|
private bool TryApplyBoundaryProfileCommand(SweControlCommand command, out string result)
|
|
{
|
|
result = "invalid_boundary_profile";
|
|
if (command == null || command.boundaryId <= 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
string kind = NormalizeBoundaryKind(command.boundaryKind);
|
|
var profile = new SweBoundaryProfile
|
|
{
|
|
boundaryKind = kind,
|
|
boundaryId = command.boundaryId,
|
|
enabled = command.enabled != 0,
|
|
waterLevelM = Mathf.Max(0.0f, command.waterLevelM),
|
|
velocityUMps = command.velocityUMps,
|
|
velocityVMps = command.velocityVMps,
|
|
depthRateMps = command.depthRateMps,
|
|
};
|
|
if (kind == "sink" && profile.depthRateMps > 0.0f)
|
|
{
|
|
profile.depthRateMps = -profile.depthRateMps;
|
|
}
|
|
|
|
SetBoundaryProfileInternal(profile);
|
|
RecomputeExternalDepthRate();
|
|
result = $"boundary profile set: {kind}:{profile.boundaryId}";
|
|
return true;
|
|
}
|
|
|
|
private bool TryApplyBoundaryBulkCommand(SweControlCommand command, out string result)
|
|
{
|
|
result = "invalid_boundary_bulk";
|
|
if (command == null || command.boundaries == null || command.boundaries.Length == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (command.replaceAll)
|
|
{
|
|
InitializeBoundaryProfilesFromManifest();
|
|
}
|
|
|
|
int applied = 0;
|
|
for (int i = 0; i < command.boundaries.Length; i++)
|
|
{
|
|
SweBoundaryProfile incoming = command.boundaries[i];
|
|
if (incoming == null || incoming.boundaryId <= 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
string kind = NormalizeBoundaryKind(incoming.boundaryKind);
|
|
var profile = incoming.Clone();
|
|
profile.boundaryKind = kind;
|
|
if (kind == "sink" && profile.depthRateMps > 0.0f)
|
|
{
|
|
profile.depthRateMps = -profile.depthRateMps;
|
|
}
|
|
|
|
SetBoundaryProfileInternal(profile);
|
|
applied++;
|
|
}
|
|
|
|
if (applied <= 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
RecomputeExternalDepthRate();
|
|
result = $"boundary bulk applied: {applied}";
|
|
return true;
|
|
}
|
|
|
|
private void AddStateSubscriber(IPEndPoint endpoint)
|
|
{
|
|
if (endpoint == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
stateSubscribers[MakeSubscriberKey(endpoint)] = endpoint;
|
|
}
|
|
|
|
private void RemoveStateSubscriber(IPEndPoint endpoint)
|
|
{
|
|
if (endpoint == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
stateSubscribers.Remove(MakeSubscriberKey(endpoint));
|
|
}
|
|
|
|
private void SendBoundaryState(IPEndPoint target, string messageType)
|
|
{
|
|
if (commandSocket == null || target == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
SweBoundaryStateMessage state = BuildBoundaryStateMessage(messageType);
|
|
byte[] payload = SweUdpProtocol.EncodeState(state);
|
|
try
|
|
{
|
|
commandSocket.Send(payload, payload.Length, target);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Debug.LogWarning($"SweServerRuntime: failed to send boundary state to {target}. {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private void BroadcastBoundaryState(string messageType)
|
|
{
|
|
if (commandSocket == null || stateSubscribers.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
SweBoundaryStateMessage state = BuildBoundaryStateMessage(messageType);
|
|
byte[] payload = SweUdpProtocol.EncodeState(state);
|
|
var stale = new List<string>();
|
|
foreach (var pair in stateSubscribers)
|
|
{
|
|
try
|
|
{
|
|
commandSocket.Send(payload, payload.Length, pair.Value);
|
|
}
|
|
catch
|
|
{
|
|
stale.Add(pair.Key);
|
|
}
|
|
}
|
|
|
|
for (int i = 0; i < stale.Count; i++)
|
|
{
|
|
stateSubscribers.Remove(stale[i]);
|
|
}
|
|
}
|
|
|
|
private SweBoundaryStateMessage BuildBoundaryStateMessage(string messageType)
|
|
{
|
|
var profiles = new List<SweBoundaryProfile>(boundaryProfiles.Values);
|
|
profiles.Sort((a, b) =>
|
|
{
|
|
int kind = string.Compare(
|
|
a != null ? a.boundaryKind : string.Empty,
|
|
b != null ? b.boundaryKind : string.Empty,
|
|
StringComparison.OrdinalIgnoreCase);
|
|
if (kind != 0)
|
|
{
|
|
return kind;
|
|
}
|
|
int aid = a != null ? a.boundaryId : 0;
|
|
int bid = b != null ? b.boundaryId : 0;
|
|
return aid.CompareTo(bid);
|
|
});
|
|
|
|
var snapshot = new SweBoundaryProfile[profiles.Count];
|
|
for (int i = 0; i < profiles.Count; i++)
|
|
{
|
|
snapshot[i] = profiles[i] != null ? profiles[i].Clone() : null;
|
|
}
|
|
|
|
return new SweBoundaryStateMessage
|
|
{
|
|
messageType = messageType,
|
|
schemaVersion = boundaryManifest != null ? boundaryManifest.schema_version : 0,
|
|
lod = activeLod,
|
|
tileX = activeTileX,
|
|
tileY = activeTileY,
|
|
boundaries = snapshot,
|
|
};
|
|
}
|
|
|
|
private static string NormalizeBoundaryKind(string kind)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(kind))
|
|
{
|
|
return "boundary_inflow";
|
|
}
|
|
|
|
string normalized = kind.Trim().ToLowerInvariant();
|
|
if (normalized == "source" || normalized == "boundary_source")
|
|
{
|
|
return "boundary_inflow";
|
|
}
|
|
if (normalized == "sourcearea" || normalized == "source-area")
|
|
{
|
|
return "source_area";
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
private static string MakeBoundaryProfileKey(string kind, int id)
|
|
{
|
|
return $"{NormalizeBoundaryKind(kind)}:{id}";
|
|
}
|
|
|
|
private void SetBoundaryProfileInternal(SweBoundaryProfile profile)
|
|
{
|
|
if (profile == null || profile.boundaryId <= 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
string kind = NormalizeBoundaryKind(profile.boundaryKind);
|
|
profile.boundaryKind = kind;
|
|
string key = MakeBoundaryProfileKey(kind, profile.boundaryId);
|
|
boundaryProfiles[key] = profile.Clone();
|
|
}
|
|
|
|
private void ApplyDefaultSourceIfConfigured()
|
|
{
|
|
if (!applyDefaultSourceOnStart)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (defaultSourceId <= 0)
|
|
{
|
|
Debug.LogWarning("SweServerRuntime: default source is enabled, but defaultSourceId is <= 0.");
|
|
return;
|
|
}
|
|
|
|
if (!activeSourceIds.Contains(defaultSourceId))
|
|
{
|
|
Debug.LogWarning(
|
|
$"SweServerRuntime: default source id {defaultSourceId} is not active on tile {activeLod} ({activeTileX},{activeTileY}).");
|
|
return;
|
|
}
|
|
|
|
float clamped = Mathf.Max(0.0f, defaultSourceLevel);
|
|
ApplyLegacySourceLevel(defaultSourceId, clamped);
|
|
Debug.Log($"SweServerRuntime: applied startup source id={defaultSourceId} level={clamped:0.###}");
|
|
}
|
|
|
|
private void SaveCheckpoint(string checkpointName)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(checkpointName))
|
|
{
|
|
checkpointName = "default";
|
|
}
|
|
|
|
if (!simulator.TryCaptureWaterHeights(out float[] water))
|
|
{
|
|
Debug.LogWarning("SweServerRuntime: checkpoint capture failed (water).");
|
|
return;
|
|
}
|
|
|
|
simulator.TryCapturePorosity(out float[] porosity);
|
|
|
|
SweCheckpointState state = new SweCheckpointState
|
|
{
|
|
waterHeights = water,
|
|
porosityValues = porosity,
|
|
sourceLevels = CloneDictionary(sourceLevels),
|
|
sinkLevels = CloneDictionary(sinkLevels),
|
|
boundaryProfiles = CloneBoundaryProfiles(boundaryProfiles),
|
|
};
|
|
|
|
checkpoints[checkpointName] = state;
|
|
}
|
|
|
|
private bool RestoreCheckpoint(string checkpointName)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(checkpointName))
|
|
{
|
|
checkpointName = "default";
|
|
}
|
|
|
|
if (!checkpoints.TryGetValue(checkpointName, out SweCheckpointState state) || state == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
bool restoredWater = simulator.TryRestoreWaterHeights(state.waterHeights, true);
|
|
if (!restoredWater)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (state.porosityValues != null)
|
|
{
|
|
simulator.TryRestorePorosity(state.porosityValues);
|
|
}
|
|
|
|
sourceLevels.Clear();
|
|
sinkLevels.Clear();
|
|
boundaryProfiles.Clear();
|
|
|
|
if (state.sourceLevels != null)
|
|
{
|
|
foreach (var pair in state.sourceLevels)
|
|
{
|
|
sourceLevels[pair.Key] = pair.Value;
|
|
}
|
|
}
|
|
|
|
if (state.sinkLevels != null)
|
|
{
|
|
foreach (var pair in state.sinkLevels)
|
|
{
|
|
sinkLevels[pair.Key] = pair.Value;
|
|
}
|
|
}
|
|
|
|
if (state.boundaryProfiles != null)
|
|
{
|
|
for (int i = 0; i < state.boundaryProfiles.Length; i++)
|
|
{
|
|
SweBoundaryProfile profile = state.boundaryProfiles[i];
|
|
if (profile == null)
|
|
{
|
|
continue;
|
|
}
|
|
SetBoundaryProfileInternal(profile);
|
|
}
|
|
}
|
|
|
|
RecomputeExternalDepthRate();
|
|
BroadcastBoundaryState("checkpoint_restored");
|
|
return true;
|
|
}
|
|
|
|
private static Dictionary<int, float> CloneDictionary(Dictionary<int, float> source)
|
|
{
|
|
var clone = new Dictionary<int, float>();
|
|
if (source == null)
|
|
{
|
|
return clone;
|
|
}
|
|
|
|
foreach (var pair in source)
|
|
{
|
|
clone[pair.Key] = pair.Value;
|
|
}
|
|
|
|
return clone;
|
|
}
|
|
|
|
private static SweBoundaryProfile[] CloneBoundaryProfiles(Dictionary<string, SweBoundaryProfile> source)
|
|
{
|
|
if (source == null || source.Count == 0)
|
|
{
|
|
return Array.Empty<SweBoundaryProfile>();
|
|
}
|
|
|
|
var outProfiles = new SweBoundaryProfile[source.Count];
|
|
int idx = 0;
|
|
foreach (var pair in source)
|
|
{
|
|
outProfiles[idx++] = pair.Value != null ? pair.Value.Clone() : null;
|
|
}
|
|
return outProfiles;
|
|
}
|
|
|
|
private static int ComputePacketInterval(float tickSeconds, float hz)
|
|
{
|
|
if (hz <= 0.0f || tickSeconds <= 0.0f)
|
|
{
|
|
return 1;
|
|
}
|
|
|
|
float targetSeconds = 1.0f / hz;
|
|
return Mathf.Max(1, Mathf.RoundToInt(targetSeconds / tickSeconds));
|
|
}
|
|
|
|
private void AddFrameSubscriber(IPEndPoint endpoint)
|
|
{
|
|
if (endpoint == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
frameSubscribers[MakeSubscriberKey(endpoint)] = endpoint;
|
|
lastClientChangeTimeUnscaled = Time.unscaledTime;
|
|
}
|
|
|
|
private void RemoveFrameSubscriber(IPAddress address, int port)
|
|
{
|
|
if (address == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
string key = MakeSubscriberKey(address, port);
|
|
if (frameSubscribers.Remove(key))
|
|
{
|
|
lastClientChangeTimeUnscaled = Time.unscaledTime;
|
|
}
|
|
if (questEndpoint != null &&
|
|
questEndpoint.Address.Equals(address) &&
|
|
questEndpoint.Port == port)
|
|
{
|
|
questEndpoint = null;
|
|
}
|
|
}
|
|
|
|
private static string MakeSubscriberKey(IPEndPoint endpoint)
|
|
{
|
|
return MakeSubscriberKey(endpoint.Address, endpoint.Port);
|
|
}
|
|
|
|
private static string MakeSubscriberKey(IPAddress address, int port)
|
|
{
|
|
return $"{address}:{port}";
|
|
}
|
|
|
|
private void TrackSimulationProgress(HeightmapPacket packet)
|
|
{
|
|
if (simulator == null || packet == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
float now = Time.unscaledTime;
|
|
if (lastPacketFrameId < 0 || lastPacketTimeUnscaled < 0.0f)
|
|
{
|
|
lastPacketFrameId = packet.FrameId;
|
|
lastPacketTimeUnscaled = now;
|
|
return;
|
|
}
|
|
|
|
int frameDelta = packet.FrameId - lastPacketFrameId;
|
|
if (frameDelta <= 0)
|
|
{
|
|
lastPacketFrameId = packet.FrameId;
|
|
lastPacketTimeUnscaled = now;
|
|
return;
|
|
}
|
|
|
|
float realDelta = Mathf.Max(0.0001f, now - lastPacketTimeUnscaled);
|
|
float simDelta = frameDelta * Mathf.Max(0.0001f, simulator.tickSeconds);
|
|
throughputWindowRealSeconds += realDelta;
|
|
throughputWindowSimSeconds += simDelta;
|
|
lastPacketFrameId = packet.FrameId;
|
|
lastPacketTimeUnscaled = now;
|
|
|
|
float minWindow = Mathf.Max(0.1f, simThroughputAveragingSeconds);
|
|
if (throughputWindowRealSeconds < minWindow)
|
|
{
|
|
return;
|
|
}
|
|
|
|
float raw = throughputWindowSimSeconds / Mathf.Max(0.0001f, throughputWindowRealSeconds);
|
|
float blend = Mathf.Clamp01(simThroughputSmoothing);
|
|
if (simulatedSecondsPerSecond <= 0.0f)
|
|
{
|
|
simulatedSecondsPerSecond = raw;
|
|
}
|
|
else
|
|
{
|
|
simulatedSecondsPerSecond = Mathf.Lerp(simulatedSecondsPerSecond, raw, blend);
|
|
}
|
|
|
|
throughputWindowRealSeconds = 0.0f;
|
|
throughputWindowSimSeconds = 0.0f;
|
|
}
|
|
|
|
private void UpdateThroughputSmoothing()
|
|
{
|
|
if (simulatedSecondsPerSecond <= 0.0f || lastPacketTimeUnscaled < 0.0f)
|
|
{
|
|
return;
|
|
}
|
|
|
|
float idleAge = Time.unscaledTime - lastPacketTimeUnscaled;
|
|
if (idleAge <= Mathf.Max(0.1f, simThroughputAveragingSeconds) * 2.0f)
|
|
{
|
|
return;
|
|
}
|
|
|
|
simulatedSecondsPerSecond = Mathf.MoveTowards(simulatedSecondsPerSecond, 0.0f, Time.unscaledDeltaTime * 2.0f);
|
|
if (simulatedSecondsPerSecond < 0.001f)
|
|
{
|
|
simulatedSecondsPerSecond = 0.0f;
|
|
}
|
|
}
|
|
|
|
private string BuildDiagnosticsSummary()
|
|
{
|
|
return
|
|
$"tile={ActiveTileLabel}; clients={ConnectedClientCount}; recentSignal={HasRecentClientSignal}; " +
|
|
$"simSecPerSec={simulatedSecondsPerSecond:0.00}; forcing={lastForcingStatus}; forcedCells={lastForcedCellCount}; " +
|
|
$"cmd={lastCommandName}@{lastCommandSender}; ctl={decodedControlPackets}/{receivedControlPackets} ack={ackPacketsSent}; " +
|
|
$"frames={transmittedFramePackets} dropped={droppedFramePackets} bytes={transmittedFrameBytes}; " +
|
|
$"loader={TileLoadSummary}";
|
|
}
|
|
|
|
private void ResetDiagnostics()
|
|
{
|
|
receivedControlPackets = 0;
|
|
decodedControlPackets = 0;
|
|
invalidControlPackets = 0;
|
|
ackPacketsSent = 0;
|
|
droppedFramePackets = 0;
|
|
transmittedFramePackets = 0;
|
|
transmittedFrameBytes = 0;
|
|
lastFramePayloadBytes = 0;
|
|
lastCommandName = "none";
|
|
lastCommandSender = "n/a";
|
|
lastCommandTimeUnscaled = -1.0f;
|
|
lastClientChangeTimeUnscaled = -1.0f;
|
|
simulatedSecondsPerSecond = 0.0f;
|
|
lastPacketFrameId = -1;
|
|
lastPacketTimeUnscaled = -1.0f;
|
|
throughputWindowRealSeconds = 0.0f;
|
|
throughputWindowSimSeconds = 0.0f;
|
|
lastForcedCellCount = 0;
|
|
lastForcingStatus = "idle";
|
|
}
|
|
|
|
[Serializable]
|
|
private sealed class SweCheckpointState
|
|
{
|
|
public float[] waterHeights;
|
|
public float[] porosityValues;
|
|
public Dictionary<int, float> sourceLevels;
|
|
public Dictionary<int, float> sinkLevels;
|
|
public SweBoundaryProfile[] boundaryProfiles;
|
|
}
|
|
}
|
|
}
|