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:
gamer147
2026-06-01 12:44:42 -04:00
parent a0fdb0f3c5
commit 01f9bb722a
12 changed files with 144 additions and 44 deletions

View File

@@ -3,10 +3,10 @@ namespace SVSim.BattleNode.Bridge;
public interface IMatchingBridge
{
/// <summary>
/// Mint a battle id, register a pending session for the given viewer, and return the
/// URL the client should open a socket to.
/// Mint a battle id, register a pending session for the given viewer with their per-battle
/// MatchContext snapshot, and return the URL the client should open a socket to.
/// </summary>
PendingMatch RegisterPendingBattle(long viewerId);
PendingMatch RegisterPendingBattle(long viewerId, MatchContext context);
}
public sealed record PendingMatch(string BattleId, string NodeServerUrl);

View File

@@ -21,7 +21,7 @@ public sealed class MatchingBridge : IMatchingBridge
_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").
// 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 lo = RandomNumberGenerator.GetInt32(0, 1_000_000);
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);
}
}

View File

@@ -104,7 +104,7 @@ public sealed class BattleNodeWebSocketHandler
var ws = await ctx.WebSockets.AcceptWebSocketAsync();
_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);
}

View File

@@ -2,6 +2,7 @@ using System.Net.WebSockets;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using SVSim.BattleNode.Bridge;
using SVSim.BattleNode.Lifecycle;
using SVSim.BattleNode.Protocol;
using SVSim.BattleNode.Protocol.Bodies;
@@ -37,12 +38,19 @@ public sealed class BattleSession
public InboundTracker Inbound { 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;
_log = log;
BattleId = battleId;
ViewerId = viewerId;
Context = context;
}
/// <summary>

View File

@@ -1,7 +1,10 @@
using SVSim.BattleNode.Bridge;
namespace SVSim.BattleNode.Sessions;
/// <summary>
/// Sparse pre-connect record: enough to validate the incoming WS connect and resolve
/// the viewer. Full BattleSession is created on connect.
/// Sparse pre-connect record: viewer id + the per-battle MatchContext snapshot. Enough to
/// 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>
public sealed record PendingBattle(string BattleId, long ViewerId);
public sealed record PendingBattle(string BattleId, long ViewerId, MatchContext Context);

View File

@@ -11,24 +11,37 @@ public class ArenaTwoPickBattleController : SVSimController
{
private readonly IArenaTwoPickService _svc;
private readonly IMatchingBridge _matching;
private readonly IMatchContextBuilder _matchContextBuilder;
public ArenaTwoPickBattleController(IArenaTwoPickService svc, IMatchingBridge matching)
public ArenaTwoPickBattleController(
IArenaTwoPickService svc,
IMatchingBridge matching,
IMatchContextBuilder matchContextBuilder)
{
_svc = svc;
_matching = matching;
_matchContextBuilder = matchContextBuilder;
}
[HttpPost("do_matching")]
public IActionResult DoMatching([FromBody] DoMatchingRequest req)
public async Task<IActionResult> DoMatching([FromBody] DoMatchingRequest req)
{
if (!TryGetViewerId(out var vid)) return Unauthorized();
var match = _matching.RegisterPendingBattle(vid);
return Ok(new DoMatchingResponseDto
try
{
MatchingState = 3004,
BattleId = match.BattleId,
NodeServerUrl = match.NodeServerUrl,
});
var ctx = await _matchContextBuilder.BuildForTwoPickAsync(vid);
var match = _matching.RegisterPendingBattle(vid, ctx);
return Ok(new DoMatchingResponseDto
{
MatchingState = 3004,
BattleId = match.BattleId,
NodeServerUrl = match.NodeServerUrl,
});
}
catch (ArenaTwoPickException ex)
{
return BadRequest(new { error_code = ex.ErrorCode });
}
}
[HttpPost("finish")]

View File

@@ -12,14 +12,16 @@ public class MatchingBridgeTests
{
var store = new InMemoryBattleSessionStore();
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.BattleId, Is.Not.Empty);
var pending = store.TryGetPending(match.BattleId);
Assert.That(pending, Is.Not.Null);
Assert.That(pending!.ViewerId, Is.EqualTo(906243102));
Assert.That(pending.Context, Is.SameAs(ctx));
}
[Test]
@@ -27,8 +29,8 @@ public class MatchingBridgeTests
{
var bridge = new MatchingBridge(new InMemoryBattleSessionStore(), new BattleNodeOptions());
var a = bridge.RegisterPendingBattle(1);
var b = bridge.RegisterPendingBattle(2);
var a = bridge.RegisterPendingBattle(1, FixtureCtx());
var b = bridge.RegisterPendingBattle(2, FixtureCtx());
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 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, 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);
}

View File

@@ -14,10 +14,9 @@ public class BattleNodeFlowTests
{
/// <summary>
/// End-to-end smoke for the v1 scripted lifecycle. Boots the EmulatedEntrypoint via
/// SVSimTestFactory (in-memory SQLite + reference-data CSV import), mints a battle
/// through IMatchingBridge, opens a raw Socket.IO v2 client against the in-process
/// TestServer, and drives InitNetwork → Loaded → Swap, asserting the right scripted
/// frames come back in order.
/// SVSimTestFactory, mints a battle through IMatchingBridge with a fixture MatchContext,
/// opens a raw Socket.IO v2 client against the in-process TestServer, and drives
/// InitNetwork → Loaded → Swap, asserting the right scripted frames come back in order.
/// </summary>
[Test]
[Timeout(30000)]
@@ -28,11 +27,10 @@ public class BattleNodeFlowTests
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
var ct = cts.Token;
var pending = bridge.RegisterPendingBattle(viewerId: 906243102);
var pending = bridge.RegisterPendingBattle(viewerId: 906243102, context: FixtureCtx());
var key = MakeKey();
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 wsClient = factory.Server.CreateWebSocketClient();
@@ -40,20 +38,16 @@ public class BattleNodeFlowTests
await using var client = new RawSocketIoTestClient(ws);
await client.ConsumeHandshakeAsync(ct);
// 1. InitNetwork → expect InitNetwork ack push only.
await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.InitNetwork, pubSeq: 1), key, ct);
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);
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);
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.BattleStart));
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,
body: new Dictionary<string, object?> { ["idxList"] = new List<object?>() }), key, ct);
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) =>
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
: uri == NetworkBattleUri.InitBattle ? EmitCategory.Matching
: EmitCategory.Battle,
@@ -73,4 +66,11 @@ public class BattleNodeFlowTests
var seq = 0;
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);
}

View File

@@ -1,6 +1,7 @@
// SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs
using Microsoft.Extensions.Logging.Abstractions;
using NUnit.Framework;
using SVSim.BattleNode.Bridge;
using SVSim.BattleNode.Protocol;
using SVSim.BattleNode.Protocol.Bodies;
using SVSim.BattleNode.Sessions;
@@ -13,9 +14,16 @@ 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);
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) =>
new(uri, ViewerId: 1, Uuid: "u", Bid: null, Try: 0, Cat: EmitCategory.Battle,
PubSeq: null, PlaySeq: null, Body: new RawBody(new Dictionary<string, object?>()));

View File

@@ -4,6 +4,7 @@ using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using NUnit.Framework;
using SVSim.BattleNode.Bridge;
using SVSim.BattleNode.Protocol;
using SVSim.BattleNode.Sessions;
using SVSim.BattleNode.Wire;
@@ -39,7 +40,7 @@ public class BattleSessionPumpTests
{
var ws = new TestWebSocket();
var session = new BattleSession(
ws: ws, battleId: "bid-pump", viewerId: 906243102,
ws: ws, battleId: "bid-pump", viewerId: 906243102, context: FixtureCtx(),
log: NullLogger<BattleSession>.Instance);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(8));
@@ -84,7 +85,7 @@ public class BattleSessionPumpTests
{
var ws = new TestWebSocket();
var session = new BattleSession(
ws: ws, battleId: "bid-cancel", viewerId: 906243102,
ws: ws, battleId: "bid-cancel", viewerId: 906243102, context: FixtureCtx(),
log: NullLogger<BattleSession>.Instance);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(8));
@@ -127,7 +128,7 @@ public class BattleSessionPumpTests
var ws = new TestWebSocket();
var logCapture = new CapturingLogger();
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));
var runTask = session.RunAsync(cts.Token);
@@ -208,6 +209,13 @@ public class BattleSessionPumpTests
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>
{
public List<string> Warnings { get; } = new();

View File

@@ -1,4 +1,5 @@
using NUnit.Framework;
using SVSim.BattleNode.Bridge;
using SVSim.BattleNode.Sessions;
namespace SVSim.UnitTests.BattleNode.Sessions;
@@ -13,7 +14,7 @@ public class InMemoryBattleSessionStoreTests
[Test]
public void RegisterThenGet_ReturnsRegisteredBattle()
{
var battle = new PendingBattle("bid-1", 906243102);
var battle = new PendingBattle("bid-1", 906243102, FixtureCtx());
_store.RegisterPending(battle);
Assert.That(_store.TryGetPending("bid-1"), Is.EqualTo(battle));
@@ -28,7 +29,7 @@ public class InMemoryBattleSessionStoreTests
[Test]
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.False);
}
@@ -36,8 +37,15 @@ public class InMemoryBattleSessionStoreTests
[Test]
public void Register_DuplicateBattleId_OverwritesPrior()
{
_store.RegisterPending(new PendingBattle("bid", 1));
_store.RegisterPending(new PendingBattle("bid", 2));
_store.RegisterPending(new PendingBattle("bid", 1, FixtureCtx()));
_store.RegisterPending(new PendingBattle("bid", 2, FixtureCtx()));
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);
}

View File

@@ -1,6 +1,9 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Database;
using SVSim.Database.Models;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Controllers;
@@ -12,6 +15,8 @@ public class ArenaTwoPickBattleControllerTests
{
using var factory = new SVSimTestFactory();
var viewerId = await factory.SeedViewerAsync();
await SeedCompleteTwoPickRunAsync(factory, viewerId);
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,
@@ -28,12 +33,50 @@ public class ArenaTwoPickBattleControllerTests
var battleId = root.GetProperty("battle_id").GetString();
Assert.That(battleId, Is.Not.Null.And.Not.Empty);
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.Not.StartWith("ws://"));
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));
}
[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();
}
}