Compare commits

..
5 Commits
Author SHA1 Message Date
Leonid PershinandClaude Opus 4.8 46f3931f85 Lighting: lighter night floor (0.18 -> 0.24)
CI / build-test (push) Successful in 1m16s
Night was a touch too dark; raise the ambient moonlight floor a couple points.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:33:20 +03:00
Leonid Pershin 96e19c7c61 Lighting: directional sun shadows from the day/night sun angle
DayNight.SunShadow returns a cell offset opposite the sun's east-west position, longest near sunrise/sunset (low sun) and zero at noon/night, tilted slightly south so shadows fall in front of occluders. LightmapBuilder gains a directional shadow pass (MaxShadowCells length cap, SunShadowStrength darkening); LightmapSystem feeds it the current sun shadow each rebuild. Tests cover SunShadow's day-arc behaviour and the builder's directional darkening.
2026-06-14 07:04:11 +03:00
Leonid Pershin 38bdfbbb81 Enhance Calendar and Climate systems with start day offsets
CI / build-test (push) Successful in 1m13s
- Added a `startDay` parameter to the `Calendar` class to allow for fractional day offsets at clock initialization, enabling more flexible time settings.
- Updated `TotalDays` calculation to include the `startDay` offset.
- Introduced `StartDayOfYear` in `ClimateSettings` to shift the seasonal phase, allowing worlds to begin at a specific time of year.
- Adjusted `YearProgress` and `Year` calculations in the `Climate` class to account for the new `StartDayOfYear`.
- Added unit tests to verify the functionality of the new start day features in both `Calendar` and `Climate` classes.
2026-06-13 06:55:09 +03:00
Leonid Pershin 08381703f7 Add WorldCenter property to CameraState for effective camera positioning
CI / build-test (push) Successful in 1m19s
Enhanced the CameraState struct with a new WorldCenter property that calculates the effective position of the camera after bounds-clamping. This property is intended to be used for zoom-to-cursor functionality, ensuring that the repositioning aligns with what is rendered.

Added unit tests to verify that WorldCenter reflects the unclamped camera position and correctly accounts for bounds clamping, distinguishing it from the raw camera position.

Tests: WorldCenter_EqualsUnclampedCameraPosition, WorldCenter_ReflectsBoundsClamp_UnlikeRawPosition.
2026-06-13 05:21:24 +03:00
Leonid PershinandClaude Fable 5 f382fc98ea Net: heartbeat liveness, protocol version, message cap, replication reset
CI / build-test (push) Successful in 1m12s
Robustness pass over MrGameEng.Net:
- WebSocketServer heartbeats each connection (configurable interval/timeout)
  and drops peers idle past the timeout — detects half-open TCP that vanished
  without a close frame. Tracks last-activity per connection.
- Reassembled messages capped (server and client) so a peer cannot exhaust
  memory with an oversized fragmented message.
- Replication snapshots carry a protocol-version byte; a client receiving a
  mismatched version drops the message instead of decoding garbage, and a
  truncated snapshot is ignored without throwing.
- ReplicationClient.Clear() deletes all replicated entities, so clients can
  wipe stale state before reconnecting.

Tests: heartbeat healthy-survives / silent-peer-dropped, protocol-version
mismatch, truncated snapshot, replication clear.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 04:44:07 +03:00
17 changed files with 602 additions and 65 deletions
+16 -6
View File
@@ -10,16 +10,21 @@ namespace MrGameEng.Core;
public sealed class Calendar
{
private readonly GameClock _clock;
private readonly double _startDay;
private float _secondsPerDay;
/// <summary>
/// Creates a calendar reading <paramref name="clock"/>; one day spans
/// <paramref name="secondsPerDay"/> seconds of scaled time (must be positive).
/// <paramref name="startDay"/> offsets the calendar by (fractional) days at clock 0 — e.g.
/// <c>7.0 / 24</c> starts the world at 07:00 instead of midnight. It shifts the time of day and
/// the day/night phase that reads <see cref="DayProgress"/>, without touching the clock itself.
/// </summary>
public Calendar(GameClock clock, float secondsPerDay)
public Calendar(GameClock clock, float secondsPerDay, double startDay = 0.0)
{
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
SecondsPerDay = secondsPerDay;
_startDay = startDay;
}
/// <summary>Scaled seconds per in-game day. Must be positive; raising it slows the calendar.</summary>
@@ -36,8 +41,8 @@ public sealed class Calendar
);
}
/// <summary>Total elapsed days as a continuous value (e.g. 3.5 = midday of day 4).</summary>
public double TotalDays => _clock.TotalTime / _secondsPerDay;
/// <summary>Total elapsed days as a continuous value (e.g. 3.5 = midday of day 4), incl. the start offset.</summary>
public double TotalDays => _clock.TotalTime / _secondsPerDay + _startDay;
/// <summary>The current day number, counting from 1.</summary>
public int Day => (int)TotalDays + 1;
@@ -68,11 +73,16 @@ public static class CalendarEngineExtensions
/// <summary>
/// Creates a <see cref="Calendar"/> bound to the context's clock and registers it as a
/// service. Call once per world. <paramref name="secondsPerDay"/> is the scaled-time length
/// of one in-game day (must be positive).
/// of one in-game day (must be positive); <paramref name="startDay"/> offsets the starting
/// time of day in fractional days (e.g. <c>7.0 / 24</c> begins the world at 07:00).
/// </summary>
public static Calendar UseCalendar(this EngineContext context, float secondsPerDay)
public static Calendar UseCalendar(
this EngineContext context,
float secondsPerDay,
double startDay = 0.0
)
{
var calendar = new Calendar(context.Clock, secondsPerDay);
var calendar = new Calendar(context.Clock, secondsPerDay, startDay);
context.Services.Add(calendar);
return calendar;
}
+12 -2
View File
@@ -37,6 +37,13 @@ public readonly record struct ClimateSettings
/// <summary>Day of the year (0-based) with the highest temperature; defaults to mid-summer.</summary>
public int WarmestDay { get; init; }
/// <summary>
/// Day of the year (0-based) that the calendar's day 0 maps to. Shifts the whole seasonal phase
/// (season and temperature together) so a world can begin in a chosen part of the year — e.g. a
/// warm late spring instead of the cold turn of the year. Defaults to 0 (year begins at day 0).
/// </summary>
public int StartDayOfYear { get; init; }
/// <summary>Temperate defaults: a 60-day year, mean 12°, ±14° seasonal, ±5° daily, warmest mid-summer.</summary>
public static ClimateSettings Default =>
new()
@@ -78,18 +85,21 @@ public sealed class Climate
/// <summary>In-game days per year.</summary>
public int DaysPerYear => _settings.DaysPerYear;
/// <summary>Elapsed days shifted by <see cref="ClimateSettings.StartDayOfYear"/> — the seasonal clock.</summary>
private double YearDays => _calendar.TotalDays + _settings.StartDayOfYear;
/// <summary>Continuous position within the current year in <c>[0, 1)</c>.</summary>
public double YearProgress
{
get
{
var years = _calendar.TotalDays / _settings.DaysPerYear;
var years = YearDays / _settings.DaysPerYear;
return years - Math.Floor(years);
}
}
/// <summary>The current year, counting from 1.</summary>
public int Year => (int)(_calendar.TotalDays / _settings.DaysPerYear) + 1;
public int Year => (int)(YearDays / _settings.DaysPerYear) + 1;
/// <summary>Day within the current year, 0-based.</summary>
public int DayOfYear => (int)(YearProgress * _settings.DaysPerYear);
+9
View File
@@ -33,6 +33,15 @@ public readonly struct CameraState
/// <summary>Physical-screen to virtual-pixel mapping.</summary>
public required ViewportMapping Mapping { get; init; }
/// <summary>
/// World point at the centre of the virtual screen — the camera's <em>effective</em> position
/// after bounds-clamping, i.e. what the view is actually built around. Prefer this over the raw
/// <see cref="Camera.Position"/> when anchoring zoom-to-cursor, so the reposition matches what is
/// rendered even while the camera is clamped against <see cref="Camera.Bounds"/>.
/// </summary>
public Vector2 WorldCenter =>
Vector2.Transform(new Vector2(VirtualWidth / 2f, VirtualHeight / 2f), InverseView);
/// <summary>Converts a physical screen point to world coordinates.</summary>
public Vector2 ScreenToWorld(Vector2 screen)
{
@@ -52,6 +52,26 @@ public sealed class DayNight
/// <summary>Light intensity at a world point in <c>[0, 1]</c>. Global today; local (with shadows) later.</summary>
public float SampleAt(Vector2 world) => Daylight;
/// <summary>
/// Offset, in grid cells, of the shadow an occluder casts under the current sun: opposite the
/// sun's eastwest position and longest near sunrise/sunset (low sun), shrinking to zero at noon
/// and at night. Feeds the lightmap's directional shadow pass; <paramref name="maxLength"/> caps
/// the dawn/dusk shadow length. Tilted slightly "south" (down) so shadows fall in front of objects.
/// </summary>
public Vector2 SunShadow(float maxLength)
{
var day = (_calendar.DayProgress - 0.25f) / 0.5f; // daytime fraction over [06:00, 18:00]
if (day <= 0f || day >= 1f)
{
return Vector2.Zero; // night — no sun; the ambient floor handles darkness
}
var altitude = MathF.Sin(day * MathF.PI); // 0 at dawn/dusk, 1 at noon
var direction = new Vector2(2f * day - 1f, 0.4f); // sun east→west ⇒ shadow west→east, tilted south
direction.Normalize();
return direction * (maxLength * (1f - altitude));
}
/// <summary>Ambient tint for the scene: night color at night, day color at noon, eased between.</summary>
public Color Ambient
{
@@ -1,3 +1,5 @@
using Microsoft.Xna.Framework;
namespace MrGameEng.Lighting;
/// <summary>One point light projected onto the light grid: a cell position, a radius in cells and an intensity.</summary>
@@ -16,8 +18,10 @@ public static class LightmapBuilder
/// <summary>
/// Fills <paramref name="light"/> (length <paramref name="width"/>×<paramref name="height"/>) with
/// ambient light, shading occluder cells, then adds each point light with grid-traced occlusion.
/// Values end clamped to <c>[0, 1]</c>.
/// ambient light, shading occluder cells, casts each occluder's directional sun shadow along
/// <paramref name="sunShadow"/> (cells), then adds each point light with grid-traced occlusion.
/// Values end clamped to <c>[0, 1]</c>. A zero <paramref name="sunShadow"/> or
/// <paramref name="sunShadowStrength"/> skips the directional pass (e.g. at night/noon).
/// </summary>
public static void Build(
float[] light,
@@ -25,7 +29,9 @@ public static class LightmapBuilder
int height,
float ambient,
ReadOnlySpan<bool> occluders,
IReadOnlyList<LightSample> lights
IReadOnlyList<LightSample> lights,
Vector2 sunShadow = default,
float sunShadowStrength = 0f
)
{
for (var i = 0; i < light.Length; i++)
@@ -33,6 +39,8 @@ public static class LightmapBuilder
light[i] = occluders[i] ? ambient * OccluderShade : ambient;
}
CastSunShadows(light, width, height, occluders, sunShadow, sunShadowStrength);
foreach (var l in lights)
{
if (l.Radius <= 0f || l.Intensity <= 0f)
@@ -73,6 +81,62 @@ public static class LightmapBuilder
}
}
// Направленная тень от солнца: каждый окклюдер (гора/зрелая крона) отбрасывает тень вдоль
// вектора sunShadow (в клетках). Затемнение гуще у основания и тает к концу тени; сами клетки-
// окклюдеры не трогаем (они уже затенены). Окклюдеры разрежены, так что проход дёшев.
private static void CastSunShadows(
float[] light,
int width,
int height,
ReadOnlySpan<bool> occluders,
Vector2 sunShadow,
float strength
)
{
if (strength <= 0f)
{
return;
}
var steps = (int)MathF.Ceiling(sunShadow.Length());
if (steps <= 0)
{
return;
}
var stepX = sunShadow.X / steps;
var stepY = sunShadow.Y / steps;
for (var oy = 0; oy < height; oy++)
{
for (var ox = 0; ox < width; ox++)
{
if (!occluders[oy * width + ox])
{
continue;
}
for (var s = 1; s <= steps; s++)
{
var cx = ox + (int)MathF.Round(stepX * s);
var cy = oy + (int)MathF.Round(stepY * s);
if (cx < 0 || cx >= width || cy < 0 || cy >= height)
{
break;
}
var index = cy * width + cx;
if (occluders[index])
{
continue; // тень проходит над другими окклюдерами — они и так тёмные
}
var falloff = 1f - (float)(s - 1) / steps; // гуще у основания тени
light[index] *= 1f - strength * falloff;
}
}
}
}
// Есть ли прямая видимость между клетками: проводим линию (Брезенхем) и проверяем
// промежуточные клетки на окклюдер (концы исключены).
private static bool Visible(
@@ -14,7 +14,9 @@ namespace MrGameEng.Lighting;
public sealed class LightmapSystem : BaseSystem
{
private const int RebuildEvery = 6; // ~10 Гц при 60 fps
private const float NightFloor = 0.18f; // ночь тусклая, но не чёрная (лунный свет)
private const float NightFloor = 0.24f; // ночь тусклая, но не чёрная (лунный свет) — чуть светлее, чем было
private const float MaxShadowCells = 7f; // макс. длина тени от солнца (на рассвете/закате)
private const float SunShadowStrength = 0.5f; // насколько темнеет клетка у основания тени
private readonly Lightmap _lightmap;
private readonly DayNight _dayNight;
@@ -77,7 +79,9 @@ public sealed class LightmapSystem : BaseSystem
_lightmap.Height,
ambient,
_occluders(),
_lights
_lights,
_dayNight.SunShadow(MaxShadowCells),
SunShadowStrength
);
_lightmap.Upload();
}
@@ -1,4 +1,5 @@
using Friflo.Engine.ECS;
using MrGameEng.Core;
namespace MrGameEng.Net;
@@ -21,6 +22,7 @@ public sealed class ReplicationClient
private readonly ReplicationSchema _schema;
private readonly EntityStore _store;
private readonly Dictionary<int, Entity> _entities = [];
private bool _warnedVersion;
/// <summary>Creates a replication client writing into <paramref name="store"/>.</summary>
public ReplicationClient(ReplicationSchema schema, EntityStore store)
@@ -38,55 +40,98 @@ public sealed class ReplicationClient
}
}
/// <summary>
/// Deletes every replicated entity and forgets all net ids. Call before reconnecting: the
/// fresh connection receives the full world again with a clean id space, so stale entities
/// from the previous session don't linger as duplicates.
/// </summary>
public void Clear()
{
foreach (var entity in _entities.Values)
{
entity.DeleteEntity();
}
_entities.Clear();
}
/// <summary>Applies one snapshot message to the local store.</summary>
public void Apply(byte[] message)
{
using var reader = new BinaryReader(new MemoryStream(message));
if (reader.ReadByte() != ReplicationMessage.Snapshot)
// Заголовок: тип(1) + версия(1). Короче — точно не наш снапшот.
if (message.Length < 2 || reader.ReadByte() != ReplicationMessage.Snapshot)
{
return; // незнакомый тип сообщения — пропускаем, это не снапшот
}
var slots = _schema.Slots;
var count = reader.ReadInt32();
for (var record = 0; record < count; record++)
var version = reader.ReadByte();
if (version != ReplicationMessage.ProtocolVersion)
{
var netId = reader.ReadInt32();
var op = reader.ReadByte();
if (op == ReplicationMessage.OpDespawn)
if (!_warnedVersion)
{
if (_entities.Remove(netId, out var dead))
{
dead.DeleteEntity();
}
continue;
_warnedVersion = true;
Log.Warning(
$"Replication protocol mismatch: server v{version}, client "
+ $"v{ReplicationMessage.ProtocolVersion} — snapshots dropped. Schemas out of sync."
);
}
var mask = reader.ReadUInt32();
var spawned = false;
if (!_entities.TryGetValue(netId, out var entity))
{
entity = _store.CreateEntity(new NetId { Value = netId });
_entities[netId] = entity;
spawned = true;
}
return;
}
foreach (var slot in slots)
var slots = _schema.Slots;
try
{
var count = reader.ReadInt32();
for (var record = 0; record < count; record++)
{
if ((mask & (1u << slot.Bit)) == 0)
var netId = reader.ReadInt32();
var op = reader.ReadByte();
if (op == ReplicationMessage.OpDespawn)
{
if (_entities.Remove(netId, out var dead))
{
dead.DeleteEntity();
}
continue;
}
var data = reader.ReadBytes(slot.Size);
slot.Apply(entity, data, 0);
}
var mask = reader.ReadUInt32();
var spawned = false;
if (!_entities.TryGetValue(netId, out var entity))
{
entity = _store.CreateEntity(new NetId { Value = netId });
_entities[netId] = entity;
spawned = true;
}
if (spawned)
{
EntitySpawned?.Invoke(entity);
foreach (var slot in slots)
{
if ((mask & (1u << slot.Bit)) == 0)
{
continue;
}
var data = reader.ReadBytes(slot.Size);
if (data.Length < slot.Size)
{
return; // снапшот оборван на полпути — дальше читать нечего
}
slot.Apply(entity, data, 0);
}
if (spawned)
{
EntitySpawned?.Invoke(entity);
}
}
}
catch (EndOfStreamException)
{
// Структурно битый/усечённый снапшот — игнорируем остаток, соединение не роняем.
}
}
}
@@ -98,6 +98,7 @@ public sealed class ReplicationServer
using var stream = new MemoryStream();
using var writer = new BinaryWriter(stream);
writer.Write(ReplicationMessage.Snapshot);
writer.Write(ReplicationMessage.ProtocolVersion); // версия формата — клиент отвергает чужую
var countPosition = stream.Position;
writer.Write(0); // количество записей, допишем в конце
var records = 0;
@@ -169,6 +170,14 @@ public sealed class ReplicationServer
internal static class ReplicationMessage
{
internal const byte Snapshot = 1;
/// <summary>
/// Wire-format version. Bump whenever the snapshot layout or the meaning of the schema's
/// component blits changes; a client receiving a mismatched version drops the message
/// instead of decoding garbage (guards against a desync between server and client schemas).
/// </summary>
internal const byte ProtocolVersion = 1;
internal const byte OpUpsert = 0;
internal const byte OpDespawn = 1;
}
+8
View File
@@ -101,6 +101,9 @@ public sealed class WebSocketClient : INetConnection, IDisposable
/// <summary>Closes the connection.</summary>
public void Dispose() => Close();
/// <summary>Largest reassembled message accepted from the server before the connection is dropped.</summary>
public const int MaxMessageBytes = 16 * 1024 * 1024;
private async Task ReceiveLoop()
{
var buffer = new byte[64 * 1024];
@@ -117,6 +120,11 @@ public sealed class WebSocketClient : INetConnection, IDisposable
break;
}
if (message.Length + result.Count > MaxMessageBytes)
{
break; // сервер шлёт ненормально большое сообщение — рвём соединение
}
message.Write(buffer, 0, result.Count);
if (result.EndOfMessage)
{
+114 -21
View File
@@ -10,13 +10,25 @@ namespace MrGameEng.Net;
/// dedicated servers. Accepting and reading happen on background tasks; the simulation
/// drains new connections with <see cref="TryAcceptConnection"/> and reads messages by
/// polling each connection — nothing here touches the ECS world from another thread.
/// Binary messages only; pings are answered automatically.
/// Binary messages only; incoming pings are answered automatically and the server itself
/// heartbeats each connection, closing any that goes silent past <see cref="IdleTimeout"/>
/// (detects half-open TCP — a peer that vanished without a close frame). A reassembled
/// message is capped at <see cref="MaxMessageBytes"/> so a peer can't exhaust memory.
/// </summary>
public sealed class WebSocketServer : IDisposable
{
/// <summary>Largest reassembled (possibly fragmented) message accepted from a peer.</summary>
public const int MaxMessageBytes = WebSocketProtocol.MaxPayloadBytes;
/// <summary>The port the server listens on.</summary>
public int Port { get; }
/// <summary>How often the server pings each connection to keep it alive and probe liveness.</summary>
public TimeSpan HeartbeatInterval { get; }
/// <summary>A connection with no traffic for longer than this is considered dead and closed.</summary>
public TimeSpan IdleTimeout { get; }
/// <summary>Snapshot of currently open connections.</summary>
public IReadOnlyList<INetConnection> Connections
{
@@ -36,10 +48,22 @@ public sealed class WebSocketServer : IDisposable
private int _nextConnectionId;
private bool _started;
/// <summary>Creates a server for <paramref name="port"/> on all interfaces. Call <see cref="Start"/> to listen.</summary>
public WebSocketServer(int port)
/// <summary>
/// Creates a server for <paramref name="port"/> on all interfaces. Call <see cref="Start"/>
/// to listen. <paramref name="heartbeatInterval"/> (default 10 s) sets how often each
/// connection is pinged; <paramref name="idleTimeout"/> (default 30 s) how long a silent
/// connection lives before it's dropped as dead. The timeout must exceed the interval so a
/// healthy peer's pong lands before it's judged idle.
/// </summary>
public WebSocketServer(
int port,
TimeSpan? heartbeatInterval = null,
TimeSpan? idleTimeout = null
)
{
Port = port;
HeartbeatInterval = heartbeatInterval ?? TimeSpan.FromSeconds(10);
IdleTimeout = idleTimeout ?? TimeSpan.FromSeconds(30);
_listener = new TcpListener(IPAddress.Any, port);
}
@@ -54,6 +78,7 @@ public sealed class WebSocketServer : IDisposable
_started = true;
_listener.Start();
Task.Run(AcceptLoop);
Task.Run(HeartbeatLoop);
Log.Info($"WebSocketServer listening on port {Port}");
}
@@ -109,6 +134,48 @@ public sealed class WebSocketServer : IDisposable
}
}
// Пингует живые соединения и закрывает те, что молчат дольше IdleTimeout (мёртвый peer
// не отвечает pong'ом — его активность не обновляется и он отваливается по таймауту).
private async Task HeartbeatLoop()
{
while (!_shutdown.IsCancellationRequested)
{
try
{
await Task.Delay(HeartbeatInterval, _shutdown.Token);
}
catch (OperationCanceledException)
{
return;
}
ServerConnection[] snapshot;
lock (_connections)
{
snapshot = _connections.ToArray();
}
var now = DateTime.UtcNow;
foreach (var connection in snapshot)
{
if (!connection.IsOpen)
{
continue;
}
if (now - connection.LastActivityUtc > IdleTimeout)
{
Log.Info($"WebSocketServer: connection #{connection.Id} timed out (idle)");
connection.Close();
}
else
{
connection.SendPing();
}
}
}
}
private void Handshake(TcpClient client)
{
try
@@ -148,21 +215,30 @@ public sealed class WebSocketServer : IDisposable
public int Id { get; }
public bool IsOpen => !_closed;
/// <summary>UTC of the last frame received from the peer — drives idle-timeout detection.</summary>
public DateTime LastActivityUtc =>
new(Volatile.Read(ref _lastActivityTicks), DateTimeKind.Utc);
private readonly TcpClient _client;
private readonly NetworkStream _stream;
private readonly ConcurrentQueue<byte[]> _inbox = new();
private readonly object _sendLock = new();
private volatile bool _closed;
private long _lastActivityTicks;
internal ServerConnection(int id, TcpClient client)
{
Id = id;
_client = client;
_stream = client.GetStream();
_lastActivityTicks = DateTime.UtcNow.Ticks;
}
internal void StartReceiveLoop() => Task.Run(ReceiveLoop);
/// <summary>Sends a heartbeat ping; a live peer answers with a pong, refreshing activity.</summary>
internal void SendPing() => SendControl(WebSocketOpcode.Ping, []);
public void Send(ReadOnlySpan<byte> message)
{
if (_closed)
@@ -184,6 +260,27 @@ public sealed class WebSocketServer : IDisposable
}
}
private void SendControl(WebSocketOpcode opcode, ReadOnlySpan<byte> payload)
{
if (_closed)
{
return;
}
var frame = WebSocketProtocol.EncodeFrame(payload, opcode);
try
{
lock (_sendLock)
{
_stream.Write(frame, 0, frame.Length);
}
}
catch (Exception)
{
Close();
}
}
public bool TryReceive(out byte[] message) => _inbox.TryDequeue(out message!);
public void Close()
@@ -224,31 +321,18 @@ public sealed class WebSocketServer : IDisposable
break;
}
// Любой кадр (включая pong) — признак жизни: сбрасываем счётчик простоя.
Volatile.Write(ref _lastActivityTicks, DateTime.UtcNow.Ticks);
switch (opcode)
{
case WebSocketOpcode.Ping:
lock (_sendLock)
{
var pong = WebSocketProtocol.EncodeFrame(
payload,
WebSocketOpcode.Pong
);
_stream.Write(pong, 0, pong.Length);
}
SendControl(WebSocketOpcode.Pong, payload);
continue;
case WebSocketOpcode.Pong:
continue;
case WebSocketOpcode.Close:
lock (_sendLock)
{
var close = WebSocketProtocol.EncodeFrame(
[],
WebSocketOpcode.Close
);
_stream.Write(close, 0, close.Length);
}
SendControl(WebSocketOpcode.Close, []);
return;
}
@@ -258,6 +342,15 @@ public sealed class WebSocketServer : IDisposable
pending.Clear();
}
if (pending.Count + payload.Length > MaxMessageBytes)
{
Log.Warning(
$"WebSocketServer: connection #{Id} exceeded {MaxMessageBytes}-byte "
+ "message cap — closing"
);
return; // finally закроет соединение
}
pending.AddRange(payload);
if (fin && pendingOpcode == WebSocketOpcode.Binary)
{
@@ -54,6 +54,20 @@ public class CalendarTests
Assert.Equal(14 * 60 + 30, calendar.MinuteOfDay);
}
[Fact]
public void StartDay_OffsetsTheTimeOfDay()
{
var clock = new GameClock();
var calendar = new Calendar(clock, secondsPerDay: 10f, startDay: 7.0 / 24); // begin at 07:00
Assert.Equal(1, calendar.Day);
Assert.Equal(7, calendar.Hour);
Assert.Equal(0, calendar.Minute);
clock.Advance(5f); // half a day later → 19:00
Assert.Equal(19, calendar.Hour);
}
[Fact]
public void Pause_DoesNotAdvanceTheCalendar()
{
@@ -81,4 +81,15 @@ public class ClimateTests
Assert.Equal(2, climate.Year);
Assert.Equal(0, climate.DayOfYear);
}
[Fact]
public void StartDayOfYear_ShiftsTheSeasonalPhase()
{
// Begin the world already at the warmest day (15): the curve peaks at clock 0.
var (_, climate) = Make(Seasonal with { StartDayOfYear = 15 });
Assert.Equal(30f, climate.Temperature, 2); // mean 10 + amplitude 20, at the peak
Assert.Equal(15, climate.DayOfYear); // day-of-year reflects the offset
Assert.Equal(Season.Summer, climate.Season); // day 15 of a 60-day year = start of summer
}
}
@@ -83,6 +83,29 @@ public class CameraMathTests
AssertVector(new Vector2(0f, 200f), state.ScreenToWorld(Vector2.Zero));
}
[Fact]
public void WorldCenter_EqualsUnclampedCameraPosition()
{
var camera = new Camera(new Vector2(640f, 360f), zoom: 2f);
var state = CameraMath.Compute(camera, 1280, 720, ViewportMapping.Identity);
AssertVector(camera.Position, state.WorldCenter);
}
[Fact]
public void WorldCenter_ReflectsBoundsClamp_UnlikeRawPosition()
{
var bounds = new RectF(0f, 0f, 2000f, 1000f);
var camera = new Camera(new Vector2(-500f, 500f), bounds: bounds);
var state = CameraMath.Compute(camera, 800, 600, ViewportMapping.Identity);
// Raw position is (-500, 500); only X clamps (to half-width 400 from the left world edge),
// Y (500) is already inside [300, 700]. The effective centre the view is built around is (400, 500).
AssertVector(new Vector2(400f, 500f), state.WorldCenter);
}
[Fact]
public void Mapping_CentersVirtualResolutionInWiderWindow()
{
@@ -1,3 +1,4 @@
using Microsoft.Xna.Framework;
using MrGameEng.Core;
using MrGameEng.Lighting;
using Xunit;
@@ -39,6 +40,25 @@ public class DayNightTests
Assert.True(dawn < mid && mid < noon, "daylight should rise from dawn to noon");
}
[Fact]
public void SunShadow_ZeroAtNight_PointsWestInMorning_EastInAfternoon_ShortAtNoon()
{
var (clock, dayNight) = Make(); // 1s = 1 in-game hour
Assert.Equal(Vector2.Zero, dayNight.SunShadow(8f)); // 00:00 — night, no sun
clock.Advance(7f); // 07:00 — low morning sun in the east
var morning = dayNight.SunShadow(8f);
Assert.True(morning.X < 0f, "morning shadow points west");
Assert.True(morning.Length() > 2f, "low sun casts a long shadow");
clock.Advance(5f); // 12:00 — sun overhead
Assert.True(dayNight.SunShadow(8f).Length() < 1f, "noon sun casts almost no shadow");
clock.Advance(5f); // 17:00 — afternoon sun in the west
Assert.True(dayNight.SunShadow(8f).X > 0f, "afternoon shadow points east");
}
[Fact]
public void Ambient_DarkerAtNightThanAtNoon()
{
@@ -1,3 +1,4 @@
using Microsoft.Xna.Framework;
using MrGameEng.Lighting;
using Xunit;
@@ -54,6 +55,29 @@ public class LightmapBuilderTests
Assert.Equal(0f, light[5], 5); // за препятствием — тень
}
[Fact]
public void SunShadow_DarkensCellsInTheShadowDirection_FadingFromTheCaster()
{
var occ = new bool[7];
occ[3] = true; // occluder in the middle
var light = new float[7];
LightmapBuilder.Build(
light,
7,
1,
1f,
occ,
[],
sunShadow: new Vector2(3f, 0f),
sunShadowStrength: 0.6f
);
Assert.Equal(1f, light[2], 5); // toward the sun (opposite the shadow) — unshadowed
Assert.Equal(LightmapBuilder.OccluderShade, light[3], 5); // occluder cell stays self-shaded
Assert.True(light[4] < 1f); // in shadow
Assert.True(light[4] < light[6]); // darker near the caster, fading along the shadow
}
[Fact]
public void Values_StayWithinUnitRange()
{
@@ -0,0 +1,98 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using MrGameEng.Net;
using Xunit;
namespace MrGameEng.Net.Tests;
public class HeartbeatTests
{
[Fact]
public async Task HealthyClient_SurvivesPastIdleTimeout()
{
var port = FreePort();
using var server = new WebSocketServer(
port,
heartbeatInterval: TimeSpan.FromMilliseconds(100),
idleTimeout: TimeSpan.FromMilliseconds(400)
);
server.Start();
using var client = await WebSocketClient.ConnectAsync(
new Uri($"ws://localhost:{port}/"),
new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token
);
var connection = await WaitFor(
() => server.TryAcceptConnection(out var c) ? c : null,
"server accept"
);
// Дольше idleTimeout: живой клиент авто-отвечает pong на server-ping и остаётся открыт.
await Task.Delay(900, TestContext.Current.CancellationToken);
Assert.True(connection.IsOpen);
Assert.True(client.IsOpen);
}
[Fact]
public async Task SilentPeer_IsDroppedAfterIdleTimeout()
{
var port = FreePort();
using var server = new WebSocketServer(
port,
heartbeatInterval: TimeSpan.FromMilliseconds(100),
idleTimeout: TimeSpan.FromMilliseconds(400)
);
server.Start();
// Сырой peer: проходит рукопожатие, но дальше молчит и не отвечает на ping.
using var tcp = new TcpClient();
await tcp.ConnectAsync(IPAddress.Loopback, port, TestContext.Current.CancellationToken);
var request =
"GET / HTTP/1.1\r\n"
+ "Host: localhost\r\n"
+ "Upgrade: websocket\r\n"
+ "Connection: Upgrade\r\n"
+ "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
+ "Sec-WebSocket-Version: 13\r\n"
+ "\r\n";
var bytes = Encoding.ASCII.GetBytes(request);
await tcp.GetStream().WriteAsync(bytes, TestContext.Current.CancellationToken);
var connection = await WaitFor(
() => server.TryAcceptConnection(out var c) ? c : null,
"server accept"
);
Assert.True(connection.IsOpen);
// Peer не отвечает pong'ом → активность не обновляется → сервер закрывает по простою.
await WaitFor(() => connection.IsOpen ? null : "closed", "idle drop");
Assert.False(connection.IsOpen);
}
private static async Task<T> WaitFor<T>(Func<T?> poll, string what)
where T : class
{
for (var i = 0; i < 200; i++)
{
if (poll() is { } result)
{
return result;
}
await Task.Delay(25);
}
throw new TimeoutException($"Timed out waiting for {what}.");
}
private static int FreePort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
}
+77 -2
View File
@@ -103,8 +103,8 @@ public class ReplicationTests
server.Send([connection]);
Assert.True(connection.Sent.TryDequeue(out var delta));
// Запись: type(1) + count(4) + netId(4) + op(1) + mask(4) + TestPosition(8) — без TestHealth.
Assert.Equal(22, delta!.Length);
// Запись: type(1) + version(1) + count(4) + netId(4) + op(1) + mask(4) + TestPosition(8) — без TestHealth.
Assert.Equal(23, delta!.Length);
client.Apply(delta);
var replicated = FindByNetId(clientStore, 1);
@@ -185,6 +185,81 @@ public class ReplicationTests
Assert.Equal(1, spawns);
}
[Fact]
public void Clear_DeletesEveryReplicatedEntity()
{
var serverStore = new EntityStore();
var clientStore = new EntityStore();
var server = new ReplicationServer(MakeSchema(), serverStore);
var client = new ReplicationClient(MakeSchema(), clientStore);
var connection = new FakeConnection();
serverStore.CreateEntity(
new NetId { Value = server.NextNetId() },
new TestPosition { X = 1f, Y = 2f }
);
serverStore.CreateEntity(
new NetId { Value = server.NextNetId() },
new TestPosition { X = 3f, Y = 4f }
);
server.Send([connection]);
client.Pump(connection);
Assert.Equal(2, client.EntityCount);
client.Clear();
Assert.Equal(0, client.EntityCount);
foreach (var entity in clientStore.Entities)
{
Assert.False(entity.HasComponent<NetId>());
}
}
[Fact]
public void MismatchedProtocolVersion_IsDropped()
{
var serverStore = new EntityStore();
var clientStore = new EntityStore();
var server = new ReplicationServer(MakeSchema(), serverStore);
var client = new ReplicationClient(MakeSchema(), clientStore);
var connection = new FakeConnection();
serverStore.CreateEntity(
new NetId { Value = server.NextNetId() },
new TestPosition { X = 1f, Y = 2f }
);
server.Send([connection]);
Assert.True(connection.Sent.TryDequeue(out var snapshot));
// Портим байт версии (индекс 1: type=0, version=1) — клиент обязан отбросить снапшот целиком.
snapshot![1] = 0xFF;
client.Apply(snapshot);
Assert.Equal(0, client.EntityCount);
}
[Fact]
public void TruncatedSnapshot_IsIgnoredWithoutThrowing()
{
var serverStore = new EntityStore();
var clientStore = new EntityStore();
var server = new ReplicationServer(MakeSchema(), serverStore);
var client = new ReplicationClient(MakeSchema(), clientStore);
var connection = new FakeConnection();
serverStore.CreateEntity(
new NetId { Value = server.NextNetId() },
new TestPosition { X = 5f, Y = 6f },
new TestHealth { Value = 9 }
);
server.Send([connection]);
Assert.True(connection.Sent.TryDequeue(out var snapshot));
// Режем хвост: заголовок и счётчик целы, но данные компонентов оборваны.
var truncated = snapshot!.AsSpan(0, snapshot.Length - 6).ToArray();
client.Apply(truncated); // не должно бросить
// Записи могли частично примениться, но клиент остался живым и консистентным.
Assert.True(client.EntityCount <= 1);
}
private static Entity FindByNetId(EntityStore store, int netId)
{
foreach (var entity in store.Entities)