feat(battle-node): BattleSession routes lifecycle URIs through ScriptedLifecycle
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
using System.Net.WebSockets;
|
using System.Net.WebSockets;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using SVSim.BattleNode.Lifecycle;
|
||||||
using SVSim.BattleNode.Protocol;
|
using SVSim.BattleNode.Protocol;
|
||||||
using SVSim.BattleNode.Reliability;
|
using SVSim.BattleNode.Reliability;
|
||||||
using SVSim.BattleNode.Wire;
|
using SVSim.BattleNode.Wire;
|
||||||
@@ -82,11 +83,162 @@ public sealed class BattleSession
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DispatchSocketIo(SocketIoFrame frame)
|
private async void DispatchSocketIo(SocketIoFrame frame)
|
||||||
{
|
{
|
||||||
// v1 task 11 stub: log and drop. Task 13 wires the lifecycle dispatch in.
|
if (frame.Type is SocketIoPacketType.Event or SocketIoPacketType.BinaryEvent)
|
||||||
_log.LogDebug("BattleSession {Bid}: received SIO {Type} event={Event}",
|
{
|
||||||
BattleId, frame.Type, frame.EventName);
|
switch (frame.EventName)
|
||||||
|
{
|
||||||
|
case "msg" when frame.BinaryAttachments.Count == 1:
|
||||||
|
await HandleMsgEventAsync(frame);
|
||||||
|
return;
|
||||||
|
case "alive" when frame.BinaryAttachments.Count == 1:
|
||||||
|
await HandleAliveEventAsync(frame);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// hand / unknown events: log and drop.
|
||||||
|
_log.LogDebug("BattleSession {Bid}: dropping SIO event={Event}", BattleId, frame.EventName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleMsgEventAsync(SocketIoFrame frame)
|
||||||
|
{
|
||||||
|
MsgEnvelope env;
|
||||||
|
try { env = MsgPayloadCodec.Decode(frame.BinaryAttachments[0]); }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.LogWarning(ex, "BattleSession {Bid}: failed to decode msg envelope", BattleId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ack tracking + dedupe.
|
||||||
|
bool shouldDispatch = true;
|
||||||
|
if (env.PubSeq.HasValue)
|
||||||
|
{
|
||||||
|
shouldDispatch = Inbound.Observe(env.PubSeq.Value);
|
||||||
|
if (frame.AckId.HasValue)
|
||||||
|
{
|
||||||
|
await SendSioAckAsync(frame.AckId.Value, (int)env.PubSeq.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!shouldDispatch) return;
|
||||||
|
|
||||||
|
// Run the pure-logic decision and drive sends.
|
||||||
|
var responses = ComputeResponses(env);
|
||||||
|
foreach (var (responseEnv, noStock) in responses)
|
||||||
|
{
|
||||||
|
if (noStock)
|
||||||
|
await PushNoStockAsync(responseEnv);
|
||||||
|
else
|
||||||
|
await PushOrderedAsync(responseEnv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleAliveEventAsync(SocketIoFrame frame)
|
||||||
|
{
|
||||||
|
// Client emits Gungnir every 5s with an SIO ack callback expecting just liveness confirmation
|
||||||
|
// (payload ignored). We ack immediately, then push our own alive back with scs/ocs ONLINE
|
||||||
|
// placeholders — the only response the client uses to drive its scs/ocs state machine.
|
||||||
|
if (frame.AckId.HasValue)
|
||||||
|
{
|
||||||
|
await SendSioAckAsync(frame.AckId.Value, 0);
|
||||||
|
}
|
||||||
|
var aliveEnv = new MsgEnvelope(
|
||||||
|
Uri: NetworkBattleUri.Gungnir,
|
||||||
|
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
|
||||||
|
Uuid: "node-stub",
|
||||||
|
Bid: null,
|
||||||
|
Try: 0,
|
||||||
|
Cat: EmitCategory.General,
|
||||||
|
PubSeq: null,
|
||||||
|
PlaySeq: null,
|
||||||
|
Body: Gungnir.BuildAlivePushBody());
|
||||||
|
await PushNoStockAsync(aliveEnv, eventName: "alive");
|
||||||
|
}
|
||||||
|
|
||||||
|
internal IReadOnlyList<(MsgEnvelope Envelope, bool NoStock)> ComputeResponses(MsgEnvelope env)
|
||||||
|
{
|
||||||
|
var result = new List<(MsgEnvelope Envelope, bool NoStock)>();
|
||||||
|
switch (env.Uri)
|
||||||
|
{
|
||||||
|
case NetworkBattleUri.InitNetwork:
|
||||||
|
result.Add((BuildAckedEnvelope(NetworkBattleUri.InitNetwork), NoStock: true));
|
||||||
|
result.Add((ScriptedLifecycle.BuildMatched(ViewerId, ScriptedLifecycle.FakeOpponentViewerId, BattleId), NoStock: false));
|
||||||
|
result.Add((ScriptedLifecycle.BuildBattleStart(ViewerId), NoStock: false));
|
||||||
|
Phase = BattleSessionPhase.AwaitingLoaded;
|
||||||
|
break;
|
||||||
|
case NetworkBattleUri.Loaded:
|
||||||
|
result.Add((ScriptedLifecycle.BuildDeal(), NoStock: false));
|
||||||
|
Phase = BattleSessionPhase.AwaitingSwap;
|
||||||
|
break;
|
||||||
|
case NetworkBattleUri.Swap:
|
||||||
|
result.Add((ScriptedLifecycle.BuildSwapResponse(ExtractIdxList(env)), NoStock: false));
|
||||||
|
result.Add((ScriptedLifecycle.BuildReady(), NoStock: false));
|
||||||
|
Phase = BattleSessionPhase.AfterReady;
|
||||||
|
break;
|
||||||
|
case NetworkBattleUri.Retire:
|
||||||
|
case NetworkBattleUri.Kill:
|
||||||
|
result.Add((BuildBattleFinishNoContest(), NoStock: true));
|
||||||
|
Phase = BattleSessionPhase.Terminal;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MsgEnvelope BuildAckedEnvelope(NetworkBattleUri uri) => new(
|
||||||
|
uri,
|
||||||
|
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
|
||||||
|
Uuid: "node-stub",
|
||||||
|
Bid: null,
|
||||||
|
Try: 0,
|
||||||
|
Cat: EmitCategory.General,
|
||||||
|
PubSeq: null,
|
||||||
|
PlaySeq: null,
|
||||||
|
Body: new Dictionary<string, object?> { ["resultCode"] = 1 });
|
||||||
|
|
||||||
|
private MsgEnvelope BuildBattleFinishNoContest() => new(
|
||||||
|
NetworkBattleUri.BattleFinish,
|
||||||
|
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
|
||||||
|
Uuid: "node-stub",
|
||||||
|
Bid: null,
|
||||||
|
Try: 0,
|
||||||
|
Cat: EmitCategory.Battle,
|
||||||
|
PubSeq: null,
|
||||||
|
PlaySeq: null,
|
||||||
|
Body: new Dictionary<string, object?> { ["result"] = 1, ["resultCode"] = 1 });
|
||||||
|
|
||||||
|
private static IReadOnlyList<long> ExtractIdxList(MsgEnvelope env)
|
||||||
|
{
|
||||||
|
if (env.Body.TryGetValue("idxList", out var raw) && raw is List<object?> lst)
|
||||||
|
return lst.OfType<long>().ToList();
|
||||||
|
return Array.Empty<long>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task PushOrderedAsync(MsgEnvelope env, string eventName = "synchronize") =>
|
||||||
|
EncodeAndSendAsync(Outbound.AssignAndArchive(env), eventName);
|
||||||
|
|
||||||
|
private Task PushNoStockAsync(MsgEnvelope env, string eventName = "synchronize") =>
|
||||||
|
EncodeAndSendAsync(Outbound.WrapNoStock(env), eventName);
|
||||||
|
|
||||||
|
private async Task EncodeAndSendAsync(MsgEnvelope env, string eventName)
|
||||||
|
{
|
||||||
|
var seq = 0;
|
||||||
|
var key = NodeCrypto.GenerateKey(() => (seq++ * 11) % 16);
|
||||||
|
var bytes = MsgPayloadCodec.Encode(env, key);
|
||||||
|
var sio = SocketIoFrame.BinaryEventWithAttachments(eventName, new[] { bytes });
|
||||||
|
var (text, bins) = sio.Encode();
|
||||||
|
var eioText = $"{(int)EngineIoPacketType.Message}{text}";
|
||||||
|
await SendTextAsync(eioText, CancellationToken.None);
|
||||||
|
foreach (var bin in bins)
|
||||||
|
await _ws.SendAsync(bin, WebSocketMessageType.Binary, endOfMessage: true, CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SendSioAckAsync(int ackId, int arg)
|
||||||
|
{
|
||||||
|
var ack = SocketIoFrame.AckResponse(ackId, arg);
|
||||||
|
var (text, _) = ack.Encode();
|
||||||
|
var eioText = $"{(int)EngineIoPacketType.Message}{text}";
|
||||||
|
await SendTextAsync(eioText, CancellationToken.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SendEioOpenAsync(CancellationToken ct)
|
private async Task SendEioOpenAsync(CancellationToken ct)
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using NUnit.Framework;
|
||||||
|
using SVSim.BattleNode.Protocol;
|
||||||
|
using SVSim.BattleNode.Sessions;
|
||||||
|
|
||||||
|
namespace SVSim.UnitTests.BattleNode.Sessions;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
public class BattleSessionDispatchTests
|
||||||
|
{
|
||||||
|
private static BattleSession NewSession()
|
||||||
|
{
|
||||||
|
// ws is unused by ComputeResponses; pass null! and rely on the test never invoking the pump.
|
||||||
|
return new BattleSession(ws: null!, battleId: "bid-1", viewerId: 1, log: NullLogger<BattleSession>.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MsgEnvelope NewEnvelope(NetworkBattleUri uri) =>
|
||||||
|
new(uri, ViewerId: 1, Uuid: "u", Bid: null, Try: 0, Cat: EmitCategory.Battle,
|
||||||
|
PubSeq: null, PlaySeq: null, Body: new Dictionary<string, object?>());
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void InitNetwork_PushesAckThenMatchedThenBattleStart()
|
||||||
|
{
|
||||||
|
var s = NewSession();
|
||||||
|
var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||||
|
Assert.That(responses.Select(r => r.Envelope.Uri),
|
||||||
|
Is.EqualTo(new[] { NetworkBattleUri.InitNetwork, NetworkBattleUri.Matched, NetworkBattleUri.BattleStart }));
|
||||||
|
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AwaitingLoaded));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Loaded_PushesDeal_TransitionsToAwaitingSwap()
|
||||||
|
{
|
||||||
|
var s = NewSession();
|
||||||
|
s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork)); // advance phase
|
||||||
|
var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.Loaded));
|
||||||
|
Assert.That(responses.Single().Envelope.Uri, Is.EqualTo(NetworkBattleUri.Deal));
|
||||||
|
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AwaitingSwap));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Swap_PushesSwapResponseThenReady_TransitionsToAfterReady()
|
||||||
|
{
|
||||||
|
var s = NewSession();
|
||||||
|
s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||||
|
s.ComputeResponses(NewEnvelope(NetworkBattleUri.Loaded));
|
||||||
|
var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.Swap));
|
||||||
|
Assert.That(responses.Select(r => r.Envelope.Uri),
|
||||||
|
Is.EqualTo(new[] { NetworkBattleUri.Swap, NetworkBattleUri.Ready }));
|
||||||
|
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AfterReady));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Retire_PushesBattleFinishNoContest_TransitionsToTerminal()
|
||||||
|
{
|
||||||
|
var s = NewSession();
|
||||||
|
var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.Retire));
|
||||||
|
var (env, noStock) = responses.Single();
|
||||||
|
Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
||||||
|
Assert.That(noStock, Is.True);
|
||||||
|
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user