feat(battle-node): thread MatchContext through bridge to BattleSession
IMatchingBridge.RegisterPendingBattle now takes a MatchContext; PendingBattle carries it; BattleSession stores it. ArenaTwoPickBattleController builds ctx from IMatchContextBuilder. ScriptedLifecycle still uses ScriptedProfiles for the player half — Tasks 5/6 migrate the lifecycle. Existing tests updated: MatchingBridgeTests, BattleNodeFlowTests, InMemoryBattleSessionStoreTests, BattleSessionDispatchTests, BattleSession PumpTests, ArenaTwoPickBattleControllerTests (which now seeds a TK2 run + adds a no-active-run 400 case). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -3,10 +3,10 @@ namespace SVSim.BattleNode.Bridge;
|
|||||||
public interface IMatchingBridge
|
public interface IMatchingBridge
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Mint a battle id, register a pending session for the given viewer, and return the
|
/// Mint a battle id, register a pending session for the given viewer with their per-battle
|
||||||
/// URL the client should open a socket to.
|
/// MatchContext snapshot, and return the URL the client should open a socket to.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
PendingMatch RegisterPendingBattle(long viewerId);
|
PendingMatch RegisterPendingBattle(long viewerId, MatchContext context);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record PendingMatch(string BattleId, string NodeServerUrl);
|
public sealed record PendingMatch(string BattleId, string NodeServerUrl);
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ public sealed class MatchingBridge : IMatchingBridge
|
|||||||
_options = options;
|
_options = options;
|
||||||
}
|
}
|
||||||
|
|
||||||
public PendingMatch RegisterPendingBattle(long viewerId)
|
public PendingMatch RegisterPendingBattle(long viewerId, MatchContext context)
|
||||||
{
|
{
|
||||||
// 12-digit decimal battle id mirrors the captures (e.g. "975695075012").
|
// 12-digit decimal battle id mirrors the captures (e.g. "975695075012").
|
||||||
// Two unbiased 6-digit draws concatenated — RandomNumberGenerator.GetInt32 uses
|
// Two unbiased 6-digit draws concatenated — RandomNumberGenerator.GetInt32 uses
|
||||||
@@ -31,7 +31,7 @@ public sealed class MatchingBridge : IMatchingBridge
|
|||||||
var hi = RandomNumberGenerator.GetInt32(0, 1_000_000);
|
var hi = RandomNumberGenerator.GetInt32(0, 1_000_000);
|
||||||
var lo = RandomNumberGenerator.GetInt32(0, 1_000_000);
|
var lo = RandomNumberGenerator.GetInt32(0, 1_000_000);
|
||||||
var battleId = $"{hi:D6}{lo:D6}";
|
var battleId = $"{hi:D6}{lo:D6}";
|
||||||
_store.RegisterPending(new PendingBattle(battleId, viewerId));
|
_store.RegisterPending(new PendingBattle(battleId, viewerId, context));
|
||||||
return new PendingMatch(battleId, _options.NodeServerUrl);
|
return new PendingMatch(battleId, _options.NodeServerUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ public sealed class BattleNodeWebSocketHandler
|
|||||||
|
|
||||||
var ws = await ctx.WebSockets.AcceptWebSocketAsync();
|
var ws = await ctx.WebSockets.AcceptWebSocketAsync();
|
||||||
_store.RemovePending(battleId);
|
_store.RemovePending(battleId);
|
||||||
var session = new BattleSession(ws, battleId, viewerId, _loggerFactory.CreateLogger<BattleSession>());
|
var session = new BattleSession(ws, battleId, viewerId, pending.Context, _loggerFactory.CreateLogger<BattleSession>());
|
||||||
await session.RunAsync(ctx.RequestAborted);
|
await session.RunAsync(ctx.RequestAborted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Net.WebSockets;
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using SVSim.BattleNode.Bridge;
|
||||||
using SVSim.BattleNode.Lifecycle;
|
using SVSim.BattleNode.Lifecycle;
|
||||||
using SVSim.BattleNode.Protocol;
|
using SVSim.BattleNode.Protocol;
|
||||||
using SVSim.BattleNode.Protocol.Bodies;
|
using SVSim.BattleNode.Protocol.Bodies;
|
||||||
@@ -37,12 +38,19 @@ public sealed class BattleSession
|
|||||||
public InboundTracker Inbound { get; } = new();
|
public InboundTracker Inbound { get; } = new();
|
||||||
public OutboundSequencer Outbound { get; } = new();
|
public OutboundSequencer Outbound { get; } = new();
|
||||||
|
|
||||||
public BattleSession(WebSocket ws, string battleId, long viewerId, ILogger<BattleSession> log)
|
/// <summary>
|
||||||
|
/// Player-side snapshot captured at do_matching time. ScriptedLifecycle reads the player
|
||||||
|
/// half of Matched/BattleStart frames from here; opponent half stays in ScriptedProfiles.
|
||||||
|
/// </summary>
|
||||||
|
internal MatchContext Context { get; }
|
||||||
|
|
||||||
|
public BattleSession(WebSocket ws, string battleId, long viewerId, MatchContext context, ILogger<BattleSession> log)
|
||||||
{
|
{
|
||||||
_ws = ws;
|
_ws = ws;
|
||||||
_log = log;
|
_log = log;
|
||||||
BattleId = battleId;
|
BattleId = battleId;
|
||||||
ViewerId = viewerId;
|
ViewerId = viewerId;
|
||||||
|
Context = context;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
using SVSim.BattleNode.Bridge;
|
||||||
|
|
||||||
namespace SVSim.BattleNode.Sessions;
|
namespace SVSim.BattleNode.Sessions;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sparse pre-connect record: enough to validate the incoming WS connect and resolve
|
/// Sparse pre-connect record: viewer id + the per-battle MatchContext snapshot. Enough to
|
||||||
/// the viewer. Full BattleSession is created on connect.
|
/// validate the incoming WS connect, resolve the viewer, and seed the BattleSession with the
|
||||||
|
/// player-half lifecycle data. Full BattleSession is created on connect.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record PendingBattle(string BattleId, long ViewerId);
|
public sealed record PendingBattle(string BattleId, long ViewerId, MatchContext Context);
|
||||||
|
|||||||
@@ -11,24 +11,37 @@ public class ArenaTwoPickBattleController : SVSimController
|
|||||||
{
|
{
|
||||||
private readonly IArenaTwoPickService _svc;
|
private readonly IArenaTwoPickService _svc;
|
||||||
private readonly IMatchingBridge _matching;
|
private readonly IMatchingBridge _matching;
|
||||||
|
private readonly IMatchContextBuilder _matchContextBuilder;
|
||||||
|
|
||||||
public ArenaTwoPickBattleController(IArenaTwoPickService svc, IMatchingBridge matching)
|
public ArenaTwoPickBattleController(
|
||||||
|
IArenaTwoPickService svc,
|
||||||
|
IMatchingBridge matching,
|
||||||
|
IMatchContextBuilder matchContextBuilder)
|
||||||
{
|
{
|
||||||
_svc = svc;
|
_svc = svc;
|
||||||
_matching = matching;
|
_matching = matching;
|
||||||
|
_matchContextBuilder = matchContextBuilder;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("do_matching")]
|
[HttpPost("do_matching")]
|
||||||
public IActionResult DoMatching([FromBody] DoMatchingRequest req)
|
public async Task<IActionResult> DoMatching([FromBody] DoMatchingRequest req)
|
||||||
{
|
{
|
||||||
if (!TryGetViewerId(out var vid)) return Unauthorized();
|
if (!TryGetViewerId(out var vid)) return Unauthorized();
|
||||||
var match = _matching.RegisterPendingBattle(vid);
|
try
|
||||||
return Ok(new DoMatchingResponseDto
|
|
||||||
{
|
{
|
||||||
MatchingState = 3004,
|
var ctx = await _matchContextBuilder.BuildForTwoPickAsync(vid);
|
||||||
BattleId = match.BattleId,
|
var match = _matching.RegisterPendingBattle(vid, ctx);
|
||||||
NodeServerUrl = match.NodeServerUrl,
|
return Ok(new DoMatchingResponseDto
|
||||||
});
|
{
|
||||||
|
MatchingState = 3004,
|
||||||
|
BattleId = match.BattleId,
|
||||||
|
NodeServerUrl = match.NodeServerUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (ArenaTwoPickException ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error_code = ex.ErrorCode });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("finish")]
|
[HttpPost("finish")]
|
||||||
|
|||||||
@@ -12,14 +12,16 @@ public class MatchingBridgeTests
|
|||||||
{
|
{
|
||||||
var store = new InMemoryBattleSessionStore();
|
var store = new InMemoryBattleSessionStore();
|
||||||
var bridge = new MatchingBridge(store, new BattleNodeOptions { NodeServerUrl = "localhost:5148/socket.io/" });
|
var bridge = new MatchingBridge(store, new BattleNodeOptions { NodeServerUrl = "localhost:5148/socket.io/" });
|
||||||
|
var ctx = FixtureCtx();
|
||||||
|
|
||||||
var match = bridge.RegisterPendingBattle(viewerId: 906243102);
|
var match = bridge.RegisterPendingBattle(viewerId: 906243102, context: ctx);
|
||||||
|
|
||||||
Assert.That(match.NodeServerUrl, Is.EqualTo("localhost:5148/socket.io/"));
|
Assert.That(match.NodeServerUrl, Is.EqualTo("localhost:5148/socket.io/"));
|
||||||
Assert.That(match.BattleId, Is.Not.Empty);
|
Assert.That(match.BattleId, Is.Not.Empty);
|
||||||
var pending = store.TryGetPending(match.BattleId);
|
var pending = store.TryGetPending(match.BattleId);
|
||||||
Assert.That(pending, Is.Not.Null);
|
Assert.That(pending, Is.Not.Null);
|
||||||
Assert.That(pending!.ViewerId, Is.EqualTo(906243102));
|
Assert.That(pending!.ViewerId, Is.EqualTo(906243102));
|
||||||
|
Assert.That(pending.Context, Is.SameAs(ctx));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -27,8 +29,8 @@ public class MatchingBridgeTests
|
|||||||
{
|
{
|
||||||
var bridge = new MatchingBridge(new InMemoryBattleSessionStore(), new BattleNodeOptions());
|
var bridge = new MatchingBridge(new InMemoryBattleSessionStore(), new BattleNodeOptions());
|
||||||
|
|
||||||
var a = bridge.RegisterPendingBattle(1);
|
var a = bridge.RegisterPendingBattle(1, FixtureCtx());
|
||||||
var b = bridge.RegisterPendingBattle(2);
|
var b = bridge.RegisterPendingBattle(2, FixtureCtx());
|
||||||
|
|
||||||
Assert.That(a.BattleId, Is.Not.EqualTo(b.BattleId));
|
Assert.That(a.BattleId, Is.Not.EqualTo(b.BattleId));
|
||||||
}
|
}
|
||||||
@@ -38,9 +40,16 @@ public class MatchingBridgeTests
|
|||||||
{
|
{
|
||||||
var bridge = new MatchingBridge(new InMemoryBattleSessionStore(), new BattleNodeOptions());
|
var bridge = new MatchingBridge(new InMemoryBattleSessionStore(), new BattleNodeOptions());
|
||||||
|
|
||||||
var match = bridge.RegisterPendingBattle(viewerId: 1);
|
var match = bridge.RegisterPendingBattle(viewerId: 1, context: FixtureCtx());
|
||||||
|
|
||||||
Assert.That(match.BattleId, Has.Length.EqualTo(12));
|
Assert.That(match.BattleId, Has.Length.EqualTo(12));
|
||||||
Assert.That(match.BattleId, Does.Match("^[0-9]{12}$"));
|
Assert.That(match.BattleId, Does.Match("^[0-9]{12}$"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static MatchContext FixtureCtx() => new(
|
||||||
|
SelfDeckCardIds: Enumerable.Range(1, 30).Select(i => 100_011_010L).ToList(),
|
||||||
|
ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015",
|
||||||
|
CountryCode: "KOR", UserName: "Player", SleeveId: "3000011",
|
||||||
|
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
|
||||||
|
BattleType: 11);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,9 @@ public class BattleNodeFlowTests
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// End-to-end smoke for the v1 scripted lifecycle. Boots the EmulatedEntrypoint via
|
/// End-to-end smoke for the v1 scripted lifecycle. Boots the EmulatedEntrypoint via
|
||||||
/// SVSimTestFactory (in-memory SQLite + reference-data CSV import), mints a battle
|
/// SVSimTestFactory, mints a battle through IMatchingBridge with a fixture MatchContext,
|
||||||
/// through IMatchingBridge, opens a raw Socket.IO v2 client against the in-process
|
/// opens a raw Socket.IO v2 client against the in-process TestServer, and drives
|
||||||
/// TestServer, and drives InitNetwork → Loaded → Swap, asserting the right scripted
|
/// InitNetwork → Loaded → Swap, asserting the right scripted frames come back in order.
|
||||||
/// frames come back in order.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Test]
|
[Test]
|
||||||
[Timeout(30000)]
|
[Timeout(30000)]
|
||||||
@@ -28,11 +27,10 @@ public class BattleNodeFlowTests
|
|||||||
|
|
||||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||||
var ct = cts.Token;
|
var ct = cts.Token;
|
||||||
var pending = bridge.RegisterPendingBattle(viewerId: 906243102);
|
var pending = bridge.RegisterPendingBattle(viewerId: 906243102, context: FixtureCtx());
|
||||||
|
|
||||||
var key = MakeKey();
|
var key = MakeKey();
|
||||||
var encryptedVid = NodeCrypto.EncryptForNode("906243102", key);
|
var encryptedVid = NodeCrypto.EncryptForNode("906243102", key);
|
||||||
// TestServer ignores the host portion of the URI — only the path + query route.
|
|
||||||
var wsUri = new Uri($"ws://localhost/socket.io/?BattleId={pending.BattleId}&viewerId={Uri.EscapeDataString(encryptedVid)}&EIO=3&transport=websocket");
|
var wsUri = new Uri($"ws://localhost/socket.io/?BattleId={pending.BattleId}&viewerId={Uri.EscapeDataString(encryptedVid)}&EIO=3&transport=websocket");
|
||||||
|
|
||||||
var wsClient = factory.Server.CreateWebSocketClient();
|
var wsClient = factory.Server.CreateWebSocketClient();
|
||||||
@@ -40,20 +38,16 @@ public class BattleNodeFlowTests
|
|||||||
await using var client = new RawSocketIoTestClient(ws);
|
await using var client = new RawSocketIoTestClient(ws);
|
||||||
await client.ConsumeHandshakeAsync(ct);
|
await client.ConsumeHandshakeAsync(ct);
|
||||||
|
|
||||||
// 1. InitNetwork → expect InitNetwork ack push only.
|
|
||||||
await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.InitNetwork, pubSeq: 1), key, ct);
|
await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.InitNetwork, pubSeq: 1), key, ct);
|
||||||
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.InitNetwork));
|
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.InitNetwork));
|
||||||
|
|
||||||
// 2. InitBattle → expect Matched (handler is now subscribed on the client side).
|
|
||||||
await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.InitBattle, pubSeq: 2), key, ct);
|
await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.InitBattle, pubSeq: 2), key, ct);
|
||||||
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.Matched));
|
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.Matched));
|
||||||
|
|
||||||
// 3. Loaded → expect BattleStart + Deal.
|
|
||||||
await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.Loaded, pubSeq: 3), key, ct);
|
await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.Loaded, pubSeq: 3), key, ct);
|
||||||
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.BattleStart));
|
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.BattleStart));
|
||||||
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.Deal));
|
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.Deal));
|
||||||
|
|
||||||
// 4. Swap with empty idxList → expect Swap response + Ready.
|
|
||||||
await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.Swap, pubSeq: 4,
|
await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.Swap, pubSeq: 4,
|
||||||
body: new Dictionary<string, object?> { ["idxList"] = new List<object?>() }), key, ct);
|
body: new Dictionary<string, object?> { ["idxList"] = new List<object?>() }), key, ct);
|
||||||
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.Swap));
|
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.Swap));
|
||||||
@@ -62,7 +56,6 @@ public class BattleNodeFlowTests
|
|||||||
|
|
||||||
private static MsgEnvelope MakeEnvelope(NetworkBattleUri uri, long pubSeq, Dictionary<string, object?>? body = null) =>
|
private static MsgEnvelope MakeEnvelope(NetworkBattleUri uri, long pubSeq, Dictionary<string, object?>? body = null) =>
|
||||||
new(uri, ViewerId: 906243102, Uuid: "udid-test", Bid: null, Try: 0,
|
new(uri, ViewerId: 906243102, Uuid: "udid-test", Bid: null, Try: 0,
|
||||||
// EmitMsgPack: InitNetwork → general(99); other matching URIs → matching(2); else battle(1).
|
|
||||||
Cat: uri == NetworkBattleUri.InitNetwork ? EmitCategory.General
|
Cat: uri == NetworkBattleUri.InitNetwork ? EmitCategory.General
|
||||||
: uri == NetworkBattleUri.InitBattle ? EmitCategory.Matching
|
: uri == NetworkBattleUri.InitBattle ? EmitCategory.Matching
|
||||||
: EmitCategory.Battle,
|
: EmitCategory.Battle,
|
||||||
@@ -73,4 +66,11 @@ public class BattleNodeFlowTests
|
|||||||
var seq = 0;
|
var seq = 0;
|
||||||
return NodeCrypto.GenerateKey(() => (seq++ * 13) % 16);
|
return NodeCrypto.GenerateKey(() => (seq++ * 13) % 16);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static MatchContext FixtureCtx(IReadOnlyList<long>? deck = null) => new(
|
||||||
|
SelfDeckCardIds: deck ?? Enumerable.Range(1, 30).Select(i => 100_011_010L).ToList(),
|
||||||
|
ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015",
|
||||||
|
CountryCode: "KOR", UserName: "Player", SleeveId: "3000011",
|
||||||
|
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
|
||||||
|
BattleType: 11);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs
|
// SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using NUnit.Framework;
|
using NUnit.Framework;
|
||||||
|
using SVSim.BattleNode.Bridge;
|
||||||
using SVSim.BattleNode.Protocol;
|
using SVSim.BattleNode.Protocol;
|
||||||
using SVSim.BattleNode.Protocol.Bodies;
|
using SVSim.BattleNode.Protocol.Bodies;
|
||||||
using SVSim.BattleNode.Sessions;
|
using SVSim.BattleNode.Sessions;
|
||||||
@@ -13,9 +14,16 @@ public class BattleSessionDispatchTests
|
|||||||
private static BattleSession NewSession()
|
private static BattleSession NewSession()
|
||||||
{
|
{
|
||||||
// ws is unused by ComputeResponses; pass null! and rely on the test never invoking the pump.
|
// 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);
|
return new BattleSession(ws: null!, battleId: "bid-1", viewerId: 1, context: FixtureCtx(), log: NullLogger<BattleSession>.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static MatchContext FixtureCtx() => new(
|
||||||
|
SelfDeckCardIds: Enumerable.Range(1, 30).Select(i => 100_011_010L).ToList(),
|
||||||
|
ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015",
|
||||||
|
CountryCode: "KOR", UserName: "Player", SleeveId: "3000011",
|
||||||
|
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
|
||||||
|
BattleType: 11);
|
||||||
|
|
||||||
private static MsgEnvelope NewEnvelope(NetworkBattleUri uri) =>
|
private static MsgEnvelope NewEnvelope(NetworkBattleUri uri) =>
|
||||||
new(uri, ViewerId: 1, Uuid: "u", Bid: null, Try: 0, Cat: EmitCategory.Battle,
|
new(uri, ViewerId: 1, Uuid: "u", Bid: null, Try: 0, Cat: EmitCategory.Battle,
|
||||||
PubSeq: null, PlaySeq: null, Body: new RawBody(new Dictionary<string, object?>()));
|
PubSeq: null, PlaySeq: null, Body: new RawBody(new Dictionary<string, object?>()));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using System.Text;
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using NUnit.Framework;
|
using NUnit.Framework;
|
||||||
|
using SVSim.BattleNode.Bridge;
|
||||||
using SVSim.BattleNode.Protocol;
|
using SVSim.BattleNode.Protocol;
|
||||||
using SVSim.BattleNode.Sessions;
|
using SVSim.BattleNode.Sessions;
|
||||||
using SVSim.BattleNode.Wire;
|
using SVSim.BattleNode.Wire;
|
||||||
@@ -39,7 +40,7 @@ public class BattleSessionPumpTests
|
|||||||
{
|
{
|
||||||
var ws = new TestWebSocket();
|
var ws = new TestWebSocket();
|
||||||
var session = new BattleSession(
|
var session = new BattleSession(
|
||||||
ws: ws, battleId: "bid-pump", viewerId: 906243102,
|
ws: ws, battleId: "bid-pump", viewerId: 906243102, context: FixtureCtx(),
|
||||||
log: NullLogger<BattleSession>.Instance);
|
log: NullLogger<BattleSession>.Instance);
|
||||||
|
|
||||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(8));
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(8));
|
||||||
@@ -84,7 +85,7 @@ public class BattleSessionPumpTests
|
|||||||
{
|
{
|
||||||
var ws = new TestWebSocket();
|
var ws = new TestWebSocket();
|
||||||
var session = new BattleSession(
|
var session = new BattleSession(
|
||||||
ws: ws, battleId: "bid-cancel", viewerId: 906243102,
|
ws: ws, battleId: "bid-cancel", viewerId: 906243102, context: FixtureCtx(),
|
||||||
log: NullLogger<BattleSession>.Instance);
|
log: NullLogger<BattleSession>.Instance);
|
||||||
|
|
||||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(8));
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(8));
|
||||||
@@ -127,7 +128,7 @@ public class BattleSessionPumpTests
|
|||||||
var ws = new TestWebSocket();
|
var ws = new TestWebSocket();
|
||||||
var logCapture = new CapturingLogger();
|
var logCapture = new CapturingLogger();
|
||||||
var session = new BattleSession(
|
var session = new BattleSession(
|
||||||
ws: ws, battleId: "bid-clip", viewerId: 906243102, log: logCapture);
|
ws: ws, battleId: "bid-clip", viewerId: 906243102, context: FixtureCtx(), log: logCapture);
|
||||||
|
|
||||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(8));
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(8));
|
||||||
var runTask = session.RunAsync(cts.Token);
|
var runTask = session.RunAsync(cts.Token);
|
||||||
@@ -208,6 +209,13 @@ public class BattleSessionPumpTests
|
|||||||
return NodeCrypto.GenerateKey(() => (seq++ * 7) % 16);
|
return NodeCrypto.GenerateKey(() => (seq++ * 7) % 16);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static MatchContext FixtureCtx() => new(
|
||||||
|
SelfDeckCardIds: Enumerable.Range(1, 30).Select(i => 100_011_010L).ToList(),
|
||||||
|
ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015",
|
||||||
|
CountryCode: "KOR", UserName: "Player", SleeveId: "3000011",
|
||||||
|
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
|
||||||
|
BattleType: 11);
|
||||||
|
|
||||||
private sealed class CapturingLogger : ILogger<BattleSession>
|
private sealed class CapturingLogger : ILogger<BattleSession>
|
||||||
{
|
{
|
||||||
public List<string> Warnings { get; } = new();
|
public List<string> Warnings { get; } = new();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using NUnit.Framework;
|
using NUnit.Framework;
|
||||||
|
using SVSim.BattleNode.Bridge;
|
||||||
using SVSim.BattleNode.Sessions;
|
using SVSim.BattleNode.Sessions;
|
||||||
|
|
||||||
namespace SVSim.UnitTests.BattleNode.Sessions;
|
namespace SVSim.UnitTests.BattleNode.Sessions;
|
||||||
@@ -13,7 +14,7 @@ public class InMemoryBattleSessionStoreTests
|
|||||||
[Test]
|
[Test]
|
||||||
public void RegisterThenGet_ReturnsRegisteredBattle()
|
public void RegisterThenGet_ReturnsRegisteredBattle()
|
||||||
{
|
{
|
||||||
var battle = new PendingBattle("bid-1", 906243102);
|
var battle = new PendingBattle("bid-1", 906243102, FixtureCtx());
|
||||||
_store.RegisterPending(battle);
|
_store.RegisterPending(battle);
|
||||||
|
|
||||||
Assert.That(_store.TryGetPending("bid-1"), Is.EqualTo(battle));
|
Assert.That(_store.TryGetPending("bid-1"), Is.EqualTo(battle));
|
||||||
@@ -28,7 +29,7 @@ public class InMemoryBattleSessionStoreTests
|
|||||||
[Test]
|
[Test]
|
||||||
public void Remove_ReturnsTrueWhenPresent_FalseWhenAbsent()
|
public void Remove_ReturnsTrueWhenPresent_FalseWhenAbsent()
|
||||||
{
|
{
|
||||||
_store.RegisterPending(new PendingBattle("bid", 1));
|
_store.RegisterPending(new PendingBattle("bid", 1, FixtureCtx()));
|
||||||
Assert.That(_store.RemovePending("bid"), Is.True);
|
Assert.That(_store.RemovePending("bid"), Is.True);
|
||||||
Assert.That(_store.RemovePending("bid"), Is.False);
|
Assert.That(_store.RemovePending("bid"), Is.False);
|
||||||
}
|
}
|
||||||
@@ -36,8 +37,15 @@ public class InMemoryBattleSessionStoreTests
|
|||||||
[Test]
|
[Test]
|
||||||
public void Register_DuplicateBattleId_OverwritesPrior()
|
public void Register_DuplicateBattleId_OverwritesPrior()
|
||||||
{
|
{
|
||||||
_store.RegisterPending(new PendingBattle("bid", 1));
|
_store.RegisterPending(new PendingBattle("bid", 1, FixtureCtx()));
|
||||||
_store.RegisterPending(new PendingBattle("bid", 2));
|
_store.RegisterPending(new PendingBattle("bid", 2, FixtureCtx()));
|
||||||
Assert.That(_store.TryGetPending("bid")!.ViewerId, Is.EqualTo(2));
|
Assert.That(_store.TryGetPending("bid")!.ViewerId, Is.EqualTo(2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static MatchContext FixtureCtx() => new(
|
||||||
|
SelfDeckCardIds: Enumerable.Range(1, 30).Select(i => 100_011_010L).ToList(),
|
||||||
|
ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015",
|
||||||
|
CountryCode: "KOR", UserName: "Player", SleeveId: "3000011",
|
||||||
|
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
|
||||||
|
BattleType: 11);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using SVSim.Database;
|
||||||
|
using SVSim.Database.Models;
|
||||||
using SVSim.UnitTests.Infrastructure;
|
using SVSim.UnitTests.Infrastructure;
|
||||||
|
|
||||||
namespace SVSim.UnitTests.Controllers;
|
namespace SVSim.UnitTests.Controllers;
|
||||||
@@ -12,6 +15,8 @@ public class ArenaTwoPickBattleControllerTests
|
|||||||
{
|
{
|
||||||
using var factory = new SVSimTestFactory();
|
using var factory = new SVSimTestFactory();
|
||||||
var viewerId = await factory.SeedViewerAsync();
|
var viewerId = await factory.SeedViewerAsync();
|
||||||
|
await SeedCompleteTwoPickRunAsync(factory, viewerId);
|
||||||
|
|
||||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||||
var req = new {
|
var req = new {
|
||||||
deck_no = 1L, need_init = 1, log = 1, excluded_field_id_list = new long[] { }, use_stage_select = 1, is_default_skin = 0,
|
deck_no = 1L, need_init = 1, log = 1, excluded_field_id_list = new long[] { }, use_stage_select = 1, is_default_skin = 0,
|
||||||
@@ -28,12 +33,50 @@ public class ArenaTwoPickBattleControllerTests
|
|||||||
var battleId = root.GetProperty("battle_id").GetString();
|
var battleId = root.GetProperty("battle_id").GetString();
|
||||||
Assert.That(battleId, Is.Not.Null.And.Not.Empty);
|
Assert.That(battleId, Is.Not.Null.And.Not.Empty);
|
||||||
var nodeUrl = root.GetProperty("node_server_url").GetString();
|
var nodeUrl = root.GetProperty("node_server_url").GetString();
|
||||||
// Matches prod wire format: host:port/socket.io/, no scheme prefix.
|
|
||||||
Assert.That(nodeUrl, Does.Contain("/socket.io/"));
|
Assert.That(nodeUrl, Does.Contain("/socket.io/"));
|
||||||
Assert.That(nodeUrl, Does.Not.StartWith("ws://"));
|
Assert.That(nodeUrl, Does.Not.StartWith("ws://"));
|
||||||
Assert.That(nodeUrl, Does.Not.StartWith("http://"));
|
Assert.That(nodeUrl, Does.Not.StartWith("http://"));
|
||||||
// Required when matching_state ∈ {3004,3007,3011} per
|
|
||||||
// DoMatchingBase.SettingCardMasterId; client throws KeyNotFoundException without it.
|
|
||||||
Assert.That(root.GetProperty("card_master_id").GetInt32(), Is.EqualTo(1));
|
Assert.That(root.GetProperty("card_master_id").GetInt32(), Is.EqualTo(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task DoMatching_NoActiveRun_Returns400WithErrorCode()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
var viewerId = await factory.SeedViewerAsync();
|
||||||
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||||
|
var req = new {
|
||||||
|
deck_no = 1L, need_init = 1, log = 1, excluded_field_id_list = new long[] { }, use_stage_select = 1, is_default_skin = 0,
|
||||||
|
viewer_id = "0", steam_id = 0, steam_session_ticket = "",
|
||||||
|
};
|
||||||
|
var resp = await client.PostAsync("/arena_two_pick_battle/do_matching", JsonContent.Create(req));
|
||||||
|
|
||||||
|
Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||||
|
var body = await resp.Content.ReadAsStringAsync();
|
||||||
|
using var doc = JsonDocument.Parse(body);
|
||||||
|
Assert.That(doc.RootElement.GetProperty("error_code").GetString(),
|
||||||
|
Is.EqualTo("arena_two_pick_no_active_run"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SeedCompleteTwoPickRunAsync(SVSimTestFactory factory, long viewerId)
|
||||||
|
{
|
||||||
|
using var scope = factory.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
var deck = Enumerable.Range(1, 30).Select(i => 100_011_000L + i).ToList();
|
||||||
|
db.ViewerArenaTwoPickRuns.Add(new ViewerArenaTwoPickRun
|
||||||
|
{
|
||||||
|
ViewerId = viewerId,
|
||||||
|
EntryId = 1,
|
||||||
|
ClassId = 1,
|
||||||
|
LeaderSkinId = 1,
|
||||||
|
SelectedCardIdsJson = JsonSerializer.Serialize(deck),
|
||||||
|
IsSelectCompleted = true,
|
||||||
|
MaxBattleCount = 5,
|
||||||
|
CandidateClassIdsJson = "[1,2,3]",
|
||||||
|
PendingPickSetsJson = "[]",
|
||||||
|
ResultListJson = "[]",
|
||||||
|
NextCandidateId = 1,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user