feat(backend): GameSession runs reader + ticker loops per WebSocket connection
This commit is contained in:
131
Backend/GameSession.cs
Normal file
131
Backend/GameSession.cs
Normal file
@@ -0,0 +1,131 @@
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace Backend;
|
||||
|
||||
/// <summary>
|
||||
/// One <see cref="GameSession"/> per connected browser. Runs the physics tick loop
|
||||
/// and the WebSocket reader loop concurrently until either side disconnects.
|
||||
/// </summary>
|
||||
public sealed class GameSession : IAsyncDisposable
|
||||
{
|
||||
private readonly WebSocket _socket;
|
||||
private readonly GameProcess _game;
|
||||
private readonly PolicyRunner _policy;
|
||||
private readonly ILogger<GameSession> _logger;
|
||||
|
||||
// Mailbox: cursor updates from the browser. Bounded to 1 with drop-newest
|
||||
// isn't quite right — we want drop-oldest so the ticker always reads the
|
||||
// latest cursor. Channels' DropWrite behavior keeps the first, so we do
|
||||
// it manually by draining the reader on each tick.
|
||||
private readonly Channel<(float X, float Y)> _cursorInbox =
|
||||
Channel.CreateUnbounded<(float, float)>();
|
||||
|
||||
private (float X, float Y) _currentCursor = (0.5f, 0.5f);
|
||||
private float[] _lastObs = new float[PolicyRunner.ObsDim];
|
||||
|
||||
public GameSession(WebSocket socket, GameProcess game, PolicyRunner policy,
|
||||
ILogger<GameSession> logger)
|
||||
{
|
||||
_socket = socket;
|
||||
_game = game;
|
||||
_policy = policy;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task RunAsync(CancellationToken cancellation)
|
||||
{
|
||||
await _game.StartAsync();
|
||||
|
||||
// First step (noop) to get an initial observation for the policy.
|
||||
var initial = await _game.ResetAsync();
|
||||
_lastObs = initial.Observation;
|
||||
|
||||
// Send init frame to the browser.
|
||||
await SendJsonAsync(new InitFrame { World = new[] { 1f, 1f } },
|
||||
WireJsonContext.Default.InitFrame, cancellation);
|
||||
|
||||
var readerTask = ReaderLoopAsync(cancellation);
|
||||
var tickerTask = TickerLoopAsync(cancellation);
|
||||
|
||||
// First loop to complete cancels the other.
|
||||
var done = await Task.WhenAny(readerTask, tickerTask);
|
||||
try { await done; }
|
||||
catch (OperationCanceledException) { /* expected */ }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "session loop ended"); }
|
||||
}
|
||||
|
||||
private async Task ReaderLoopAsync(CancellationToken cancellation)
|
||||
{
|
||||
var buffer = new byte[4 * 1024];
|
||||
while (!cancellation.IsCancellationRequested)
|
||||
{
|
||||
var result = await _socket.ReceiveAsync(buffer, cancellation);
|
||||
if (result.MessageType == WebSocketMessageType.Close) break;
|
||||
if (result.MessageType != WebSocketMessageType.Text) continue;
|
||||
|
||||
var text = Encoding.UTF8.GetString(buffer, 0, result.Count);
|
||||
CursorMessage? msg;
|
||||
try
|
||||
{
|
||||
msg = JsonSerializer.Deserialize(text, WireJsonContext.Default.CursorMessage);
|
||||
}
|
||||
catch (JsonException) { continue; }
|
||||
if (msg is null || msg.Type != "cursor") continue;
|
||||
|
||||
await _cursorInbox.Writer.WriteAsync(
|
||||
(Clamp01(msg.X), Clamp01(msg.Y)), cancellation);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TickerLoopAsync(CancellationToken cancellation)
|
||||
{
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(20)); // 50 Hz
|
||||
while (await timer.WaitForNextTickAsync(cancellation))
|
||||
{
|
||||
// Drain the cursor mailbox — keep only the latest update.
|
||||
while (_cursorInbox.Reader.TryRead(out var next)) _currentCursor = next;
|
||||
|
||||
int action = _policy.SelectAction(_lastObs);
|
||||
var step = await _game.StepAsync(action, _currentCursor);
|
||||
_lastObs = step.Observation;
|
||||
|
||||
var frame = new StateFrame
|
||||
{
|
||||
X = step.State.X,
|
||||
Y = step.State.Y,
|
||||
Angle = step.State.Angle,
|
||||
Engine = step.State.Engine,
|
||||
Target = new[] { _currentCursor.X, _currentCursor.Y },
|
||||
Step = step.Step,
|
||||
};
|
||||
await SendJsonAsync(frame, WireJsonContext.Default.StateFrame, cancellation);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendJsonAsync<T>(
|
||||
T value,
|
||||
System.Text.Json.Serialization.Metadata.JsonTypeInfo<T> ctx,
|
||||
CancellationToken cancellation)
|
||||
{
|
||||
var bytes = JsonSerializer.SerializeToUtf8Bytes(value, ctx);
|
||||
await _socket.SendAsync(bytes, WebSocketMessageType.Text,
|
||||
endOfMessage: true, cancellation);
|
||||
}
|
||||
|
||||
private static float Clamp01(float v) => v < 0f ? 0f : (v > 1f ? 1f : v);
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_socket.State == WebSocketState.Open)
|
||||
await _socket.CloseAsync(WebSocketCloseStatus.NormalClosure,
|
||||
"session-end", CancellationToken.None);
|
||||
}
|
||||
catch { }
|
||||
await _game.DisposeAsync();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user