Compare commits
167 Commits
fc504af496
...
5c4e427fab
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c4e427fab | ||
|
|
10d9f74d05 | ||
|
|
3991bcc653 | ||
|
|
898b872edd | ||
|
|
24f9b2240e | ||
|
|
8aead62116 | ||
|
|
d87f9beb81 | ||
|
|
51e9dd2094 | ||
|
|
45c4461515 | ||
|
|
bf783639c1 | ||
|
|
8723cff998 | ||
|
|
fee84cca24 | ||
|
|
a4685a9188 | ||
|
|
07eb6f1c05 | ||
|
|
bb63b0df2f | ||
|
|
7c4aa89d45 | ||
|
|
a55187e10e | ||
|
|
7eaf13893e | ||
|
|
b65cf81977 | ||
|
|
3866c93065 | ||
|
|
d7bb44973a | ||
|
|
b17c802581 | ||
|
|
0095bdf0cf | ||
|
|
8112b3f81f | ||
|
|
0ecd565774 | ||
|
|
43c0a6cf31 | ||
|
|
225c20daeb | ||
|
|
28b1d7531a | ||
|
|
0bb19320df | ||
|
|
ca5a1e926d | ||
|
|
2789dc08cb | ||
|
|
db054205b3 | ||
|
|
72dc1887d9 | ||
|
|
8a97dd0194 | ||
|
|
875a4baa29 | ||
|
|
ac78473a3e | ||
|
|
b75eb512ea | ||
|
|
560feb231a | ||
|
|
2d7cee38d3 | ||
|
|
91472df6fc | ||
|
|
bbc3a47f7a | ||
|
|
b2f3d25be0 | ||
|
|
d665f88067 | ||
|
|
acd0997cfb | ||
|
|
fcdcc5d590 | ||
|
|
553a79c795 | ||
|
|
9079715da6 | ||
|
|
ae7ff25af0 | ||
|
|
479548fa56 | ||
|
|
136149ed6b | ||
|
|
1ef101f851 | ||
|
|
007513e55c | ||
|
|
8a5b8b747d | ||
|
|
70b2872589 | ||
|
|
5021217134 | ||
|
|
ff8e4abea8 | ||
|
|
decdef29cf | ||
|
|
e30fdb7570 | ||
|
|
96ae090a3a | ||
|
|
f24fc7c643 | ||
|
|
d4926e31d6 | ||
|
|
1904ae4c0c | ||
|
|
6077844ee8 | ||
|
|
e3cc745a61 | ||
|
|
b0488e3f2e | ||
|
|
f589283572 | ||
|
|
01f9bb722a | ||
|
|
a0fdb0f3c5 | ||
|
|
89b3d23bde | ||
|
|
0e8f5427c3 | ||
|
|
ef3d7bb82b | ||
|
|
133346e3e8 | ||
|
|
2588388d9d | ||
|
|
a364f539ad | ||
|
|
677b1f1392 | ||
|
|
eaf6d7160b | ||
|
|
34c4ca0237 | ||
|
|
e4fbb155e4 | ||
|
|
21b7ddf6ae | ||
|
|
4dd61343aa | ||
|
|
453865ade2 | ||
|
|
8cce667e02 | ||
|
|
0764b8646f | ||
|
|
e4691d616b | ||
|
|
19cc7980d1 | ||
|
|
5ee270eb16 | ||
|
|
118be92dc5 | ||
|
|
c7745d8785 | ||
|
|
97b9b6fe42 | ||
|
|
78a6fe93fb | ||
|
|
d9fbb67f0c | ||
|
|
9217de3aa1 | ||
|
|
c279b811ad | ||
|
|
9e8ebd1b2b | ||
|
|
77fb93f3ea | ||
|
|
e06d97ef6f | ||
|
|
0b859f1c8e | ||
|
|
01b0c64a63 | ||
|
|
e7dac31d52 | ||
|
|
cc32223d7d | ||
|
|
ccc9b41473 | ||
|
|
1252f7bd35 | ||
|
|
5525dbee24 | ||
|
|
f765d5c7d4 | ||
|
|
9776873073 | ||
|
|
b1397e3a3e | ||
|
|
66c456c1c8 | ||
|
|
31f26655ba | ||
|
|
7914bab84e | ||
|
|
d093d872ae | ||
|
|
905fdc780a | ||
|
|
ff51c33b6c | ||
|
|
88ed8254af | ||
|
|
1dd6a70e8d | ||
|
|
f19da481c3 | ||
|
|
d3c4b3083e | ||
|
|
680630050b | ||
|
|
f6aee5b0f8 | ||
|
|
30b457c9a0 | ||
|
|
0fd4f5f9f7 | ||
|
|
a306295fe2 | ||
|
|
22a4825265 | ||
|
|
82b7d1e940 | ||
|
|
87051737da | ||
|
|
3ade8ff4f5 | ||
|
|
c0c2bb5772 | ||
|
|
4cc8b3c01c | ||
|
|
383044dd8f | ||
|
|
6ff4f70f1a | ||
|
|
8b1f613407 | ||
|
|
6c6664f011 | ||
|
|
a786599416 | ||
|
|
0a2eddd920 | ||
|
|
50790a706c | ||
|
|
dd231b081d | ||
|
|
a033bf361a | ||
|
|
2ee40c6df7 | ||
|
|
2c62a7be80 | ||
|
|
df0e132459 | ||
|
|
c37c04c1b7 | ||
|
|
b6bf9b7495 | ||
|
|
26bc4fe2ab | ||
|
|
7c4bc2966f | ||
|
|
a310697830 | ||
|
|
4ba7d8f6d0 | ||
|
|
369edd4537 | ||
|
|
a2cec7c99e | ||
|
|
ad4d4e0646 | ||
|
|
9436a0d21b | ||
|
|
45fa3d75bf | ||
|
|
4d6da23443 | ||
|
|
57dd524d9f | ||
|
|
61013fcf5c | ||
|
|
1113e52f94 | ||
|
|
91909c5755 | ||
|
|
ea340cde21 | ||
|
|
b0b9901c42 | ||
|
|
1ba3f57709 | ||
|
|
46d8239d5a | ||
|
|
301da9eeca | ||
|
|
a821b7f6b4 | ||
|
|
1f3f81d878 | ||
|
|
a1cf1d7519 | ||
|
|
3bc38b407b | ||
|
|
02e86cf16c | ||
|
|
b181257aaa | ||
|
|
220e5699cd |
@@ -9,6 +9,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SVSim.UnitTests", "SVSim.Un
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SVSim.Bootstrap", "SVSim.Bootstrap\SVSim.Bootstrap.csproj", "{666786D9-9A4D-49EA-A759-39055C57F9AA}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SVSim.BattleNode", "SVSim.BattleNode\SVSim.BattleNode.csproj", "{F4549DD3-566A-4155-8D52-3A4D2A7072F7}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -31,5 +33,9 @@ Global
|
||||
{666786D9-9A4D-49EA-A759-39055C57F9AA}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{666786D9-9A4D-49EA-A759-39055C57F9AA}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{666786D9-9A4D-49EA-A759-39055C57F9AA}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{F4549DD3-566A-4155-8D52-3A4D2A7072F7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F4549DD3-566A-4155-8D52-3A4D2A7072F7}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F4549DD3-566A-4155-8D52-3A4D2A7072F7}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F4549DD3-566A-4155-8D52-3A4D2A7072F7}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
4
SVSim.BattleNode/AssemblyMarker.cs
Normal file
4
SVSim.BattleNode/AssemblyMarker.cs
Normal file
@@ -0,0 +1,4 @@
|
||||
namespace SVSim.BattleNode;
|
||||
|
||||
/// <summary>Marker class so dotnet build emits the assembly. Remove once real content lands.</summary>
|
||||
internal static class AssemblyMarker;
|
||||
29
SVSim.BattleNode/Bridge/BattleNodeOptions.cs
Normal file
29
SVSim.BattleNode/Bridge/BattleNodeOptions.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
namespace SVSim.BattleNode.Bridge;
|
||||
|
||||
/// <summary>
|
||||
/// DI-injected options for the battle node. NodeServerUrl matches the prod
|
||||
/// do_matching wire format: <c>host:port/socket.io/</c>, no scheme prefix.
|
||||
/// BestHTTP's SocketManager parses it as the Socket.IO v2 endpoint URL.
|
||||
/// </summary>
|
||||
public sealed class BattleNodeOptions
|
||||
{
|
||||
public string NodeServerUrl { get; set; } = "localhost:5148/socket.io/";
|
||||
|
||||
/// <summary>
|
||||
/// How long the first arriver's WS waits for a partner before disconnecting.
|
||||
/// Matches the architecture spec's 60s default; override (typically lower)
|
||||
/// in tests via the factory.
|
||||
/// </summary>
|
||||
public TimeSpan WaitingRoomTimeout { get; set; } = TimeSpan.FromSeconds(60);
|
||||
|
||||
/// <summary>
|
||||
/// Dev convenience: when true, matchmaking endpoints that would otherwise park
|
||||
/// a solo poller (returning 3002 RETRY until a partner arrives) instead return
|
||||
/// a Scripted match immediately — equivalent to passing <c>?scripted=1</c> on
|
||||
/// every request. Turn off to test real PvP with two clients. Default false.
|
||||
/// <para>Trade-off: while on, two viewers polling simultaneously each get
|
||||
/// their own Scripted match instead of pairing with each other. Toggling off
|
||||
/// is the only way to get PvP behavior.</para>
|
||||
/// </summary>
|
||||
public bool SoloDefaultsToScripted { get; set; } = false;
|
||||
}
|
||||
5
SVSim.BattleNode/Bridge/BattlePlayer.cs
Normal file
5
SVSim.BattleNode/Bridge/BattlePlayer.cs
Normal file
@@ -0,0 +1,5 @@
|
||||
namespace SVSim.BattleNode.Bridge;
|
||||
|
||||
/// <summary>One player slot for a pending battle. Carries the viewer's identity and
|
||||
/// the per-battle MatchContext snapshot built at do_matching time.</summary>
|
||||
public sealed record BattlePlayer(long ViewerId, MatchContext Context);
|
||||
25
SVSim.BattleNode/Bridge/IMatchingBridge.cs
Normal file
25
SVSim.BattleNode/Bridge/IMatchingBridge.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using SVSim.BattleNode.Sessions;
|
||||
|
||||
namespace SVSim.BattleNode.Bridge;
|
||||
|
||||
public interface IMatchingBridge
|
||||
{
|
||||
/// <summary>
|
||||
/// Mint a battle id, register a pending session, return the URL the client should
|
||||
/// open a socket to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Contract rules (enforced; throws <see cref="ArgumentException"/>):
|
||||
/// <list type="bullet">
|
||||
/// <item><c>Pvp</c>: <paramref name="p2"/> required. Both viewers expected to
|
||||
/// connect WS within 60s.</item>
|
||||
/// <item><c>Bot</c>: <paramref name="p2"/> must be null. One viewer expected;
|
||||
/// opponent runs in client.</item>
|
||||
/// <item><c>Scripted</c>: <paramref name="p2"/> currently null; future
|
||||
/// server-driven bot config rides on <paramref name="p2"/>.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
PendingMatch RegisterBattle(BattlePlayer p1, BattlePlayer? p2, BattleType type);
|
||||
}
|
||||
|
||||
public sealed record PendingMatch(string BattleId, string NodeServerUrl);
|
||||
29
SVSim.BattleNode/Bridge/MatchContext.cs
Normal file
29
SVSim.BattleNode/Bridge/MatchContext.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
namespace SVSim.BattleNode.Bridge;
|
||||
|
||||
/// <summary>
|
||||
/// Per-battle player snapshot captured at do_matching time and replayed into the scripted
|
||||
/// lifecycle on WS connect. SVSim.BattleNode does not know how to build this — the HTTP-side
|
||||
/// per-mode controller is the source. Snapshot semantics: cosmetic changes between matching
|
||||
/// and WS connect have no effect on the in-battle render.
|
||||
/// </summary>
|
||||
public sealed record MatchContext(
|
||||
// Player's drafted deck — exactly 30 entries, idx 1..30 paired with the chosen cardIds
|
||||
// in the order this list provides them. Producer is responsible for the count.
|
||||
IReadOnlyList<long> SelfDeckCardIds,
|
||||
|
||||
// Player class + leader (BattleStartSelfInfo)
|
||||
string ClassId, // "1".."8"
|
||||
string CharaId, // "1".."8" — equals ClassId when no leader skin chosen
|
||||
string CardMasterName, // current card-master, e.g. "card_master_node_10015"
|
||||
|
||||
// Player cosmetics (MatchedSelfInfo)
|
||||
string CountryCode, // "KOR", "JPN", ...
|
||||
string UserName,
|
||||
string SleeveId,
|
||||
string EmblemId,
|
||||
string DegreeId,
|
||||
int FieldId,
|
||||
int IsOfficial, // 0 or 1
|
||||
|
||||
// Battle-mode hint, currently TK2 == 11. Future modes populate their own value.
|
||||
int BattleType);
|
||||
57
SVSim.BattleNode/Bridge/MatchingBridge.cs
Normal file
57
SVSim.BattleNode/Bridge/MatchingBridge.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using System.Security.Cryptography;
|
||||
using SVSim.BattleNode.Sessions;
|
||||
|
||||
namespace SVSim.BattleNode.Bridge;
|
||||
|
||||
/// <summary>
|
||||
/// In-process implementation of <see cref="IMatchingBridge"/>. The HTTP-side
|
||||
/// matching queue calls <see cref="RegisterBattle"/> once it has decided "these two
|
||||
/// play each other" or "this viewer is solo (bot/scripted)."
|
||||
/// </summary>
|
||||
public sealed class MatchingBridge : IMatchingBridge
|
||||
{
|
||||
private readonly IBattleSessionStore _store;
|
||||
private readonly BattleNodeOptions _options;
|
||||
|
||||
public MatchingBridge(IBattleSessionStore store, BattleNodeOptions options)
|
||||
{
|
||||
_store = store;
|
||||
_options = options;
|
||||
}
|
||||
|
||||
public PendingMatch RegisterBattle(BattlePlayer p1, BattlePlayer? p2, BattleType type)
|
||||
{
|
||||
ValidateContract(p1, p2, type);
|
||||
|
||||
// 12-digit decimal battle id mirrors the captures (e.g. "975695075012").
|
||||
// Two unbiased 6-digit draws concatenated — RandomNumberGenerator.GetInt32 uses
|
||||
// rejection sampling so the result is uniform on [0, 10^6).
|
||||
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, type, p1, p2));
|
||||
return new PendingMatch(battleId, _options.NodeServerUrl);
|
||||
}
|
||||
|
||||
private static void ValidateContract(BattlePlayer p1, BattlePlayer? p2, BattleType type)
|
||||
{
|
||||
if (p1 is null) throw new ArgumentNullException(nameof(p1));
|
||||
switch (type)
|
||||
{
|
||||
case BattleType.Pvp:
|
||||
if (p2 is null) throw new ArgumentException("Pvp requires both p1 and p2.", nameof(p2));
|
||||
if (p1.ViewerId == p2.ViewerId)
|
||||
throw new ArgumentException("Pvp requires distinct viewer ids.", nameof(p2));
|
||||
break;
|
||||
case BattleType.Bot:
|
||||
if (p2 is not null) throw new ArgumentException("Bot must have p2==null.", nameof(p2));
|
||||
break;
|
||||
case BattleType.Scripted:
|
||||
// p2 currently null; future server-driven bot will populate it.
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(type), type, "Unknown BattleType.");
|
||||
}
|
||||
}
|
||||
}
|
||||
70
SVSim.BattleNode/Hosting/BattleNodeExtensions.cs
Normal file
70
SVSim.BattleNode/Hosting/BattleNodeExtensions.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SVSim.BattleNode.Bridge;
|
||||
using SVSim.BattleNode.Sessions;
|
||||
|
||||
namespace SVSim.BattleNode.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Registration + pipeline extensions that turn an arbitrary ASP.NET Core host into a battle
|
||||
/// node. The library has no dependency on any specific host project — call both methods from
|
||||
/// wherever you build your <see cref="WebApplication"/>.
|
||||
/// </summary>
|
||||
public static class BattleNodeExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Register the battle node's services in DI. All four are singletons because none of them
|
||||
/// carry per-request state — per-battle state lives on the <see cref="BattleSession"/>
|
||||
/// instance the WebSocket handler constructs on connect.
|
||||
/// </summary>
|
||||
/// <param name="configure">
|
||||
/// Optional callback to override <see cref="BattleNodeOptions"/> defaults. The default
|
||||
/// <c>NodeServerUrl</c> assumes the EmulatedEntrypoint host on
|
||||
/// <c>http://localhost:5148</c> and shares the port for the Socket.IO endpoint. Override
|
||||
/// when the node runs on a different port/host or behind a reverse proxy.
|
||||
/// </param>
|
||||
public static IServiceCollection AddBattleNode(this IServiceCollection services, Action<BattleNodeOptions>? configure = null)
|
||||
{
|
||||
var options = new BattleNodeOptions();
|
||||
configure?.Invoke(options);
|
||||
services.AddSingleton(options);
|
||||
services.AddSingleton<IBattleSessionStore, InMemoryBattleSessionStore>();
|
||||
services.AddSingleton<IMatchingBridge, MatchingBridge>();
|
||||
services.AddSingleton<IWaitingRoom, WaitingRoom>();
|
||||
services.AddSingleton<BattleNodeWebSocketHandler>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wire up the WebSocket middleware and map the Socket.IO endpoint at <c>/socket.io/</c>.
|
||||
/// Call this AFTER any HTTP middleware that should still see non-WS requests (auth,
|
||||
/// routing, controllers) and BEFORE <c>MapControllers()</c>. The endpoint accepts any
|
||||
/// path under <c>/socket.io</c>; the handler doesn't read the sub-path, so default
|
||||
/// Socket.IO clients targeting <c>/socket.io/?EIO=3&transport=websocket</c> work
|
||||
/// without configuration.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Steam auth gets a free pass on WS upgrades — see
|
||||
/// <c>SteamSessionAuthenticationHandler</c>'s header-based bypass. The node has its own
|
||||
/// per-connection auth (encrypted viewerId in the upgrade headers, validated against the
|
||||
/// matched battle id in <see cref="BattleNodeWebSocketHandler.HandleAsync"/>).
|
||||
/// </remarks>
|
||||
public static IApplicationBuilder UseBattleNode(this IApplicationBuilder app)
|
||||
{
|
||||
app.UseWebSockets();
|
||||
app.Map("/socket.io", branch => branch.Run(HandleSocketIoAsync));
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Terminal handler for <c>/socket.io/*</c> — resolves the singleton
|
||||
/// <see cref="BattleNodeWebSocketHandler"/> from DI and hands the request over.
|
||||
/// Extracted from the inline lambda in <see cref="UseBattleNode"/> so stack traces
|
||||
/// show a real method name during WS connect failures.
|
||||
/// </summary>
|
||||
private static async Task HandleSocketIoAsync(Microsoft.AspNetCore.Http.HttpContext ctx)
|
||||
{
|
||||
var handler = ctx.RequestServices.GetRequiredService<BattleNodeWebSocketHandler>();
|
||||
await handler.HandleAsync(ctx);
|
||||
}
|
||||
}
|
||||
222
SVSim.BattleNode/Hosting/BattleNodeWebSocketHandler.cs
Normal file
222
SVSim.BattleNode/Hosting/BattleNodeWebSocketHandler.cs
Normal file
@@ -0,0 +1,222 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SVSim.BattleNode.Bridge;
|
||||
using SVSim.BattleNode.Sessions;
|
||||
using SVSim.BattleNode.Sessions.Participants;
|
||||
using SVSim.BattleNode.Wire;
|
||||
|
||||
namespace SVSim.BattleNode.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Validates an incoming WebSocket upgrade request, accepts it, and hands off to a fresh
|
||||
/// <see cref="BattleSession"/>. Singleton; no per-request state.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>The validation chain — cheapest checks first, crypto only after both params are
|
||||
/// present, WS accept only after the store lookup confirms the credentials match an outstanding
|
||||
/// pending battle:</para>
|
||||
/// <list type="number">
|
||||
/// <item>Reject non-WS requests with 400 (someone hit <c>/socket.io/</c> via plain HTTP).</item>
|
||||
/// <item>Read <c>BattleId</c> and encrypted <c>viewerId</c> from request headers, falling back
|
||||
/// to query string. The real client puts them on headers despite BestHTTP's
|
||||
/// <c>AdditionalQueryParams</c> API name — see project README §Wire-format gotchas.</item>
|
||||
/// <item>Decrypt the viewerId with <see cref="NodeCrypto.DecryptForNode"/>; reject on
|
||||
/// parse/decrypt failure.</item>
|
||||
/// <item>Look up the <see cref="PendingBattle"/> in the store and verify the decrypted viewer
|
||||
/// matches the one the <see cref="Bridge.IMatchingBridge"/> registered.</item>
|
||||
/// <item>AcceptWebSocketAsync, remove the pending entry (it's now an active session), construct
|
||||
/// <see cref="BattleSession"/>, await <see cref="BattleSession.RunAsync"/> until the WS
|
||||
/// closes.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public sealed class BattleNodeWebSocketHandler
|
||||
{
|
||||
private readonly IBattleSessionStore _store;
|
||||
private readonly IWaitingRoom _waitingRoom;
|
||||
private readonly BattleNodeOptions _options;
|
||||
private readonly ILogger<BattleNodeWebSocketHandler> _log;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
|
||||
public BattleNodeWebSocketHandler(
|
||||
IBattleSessionStore store,
|
||||
IWaitingRoom waitingRoom,
|
||||
BattleNodeOptions options,
|
||||
ILoggerFactory loggerFactory)
|
||||
{
|
||||
_store = store;
|
||||
_waitingRoom = waitingRoom;
|
||||
_options = options;
|
||||
_loggerFactory = loggerFactory;
|
||||
_log = loggerFactory.CreateLogger<BattleNodeWebSocketHandler>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Endpoint entry point. Sets <see cref="HttpContext.Response"/> to 400 on any validation
|
||||
/// failure; otherwise upgrades to a WebSocket and awaits
|
||||
/// <see cref="BattleSession.RunAsync"/> until the connection closes.
|
||||
/// </summary>
|
||||
public async Task HandleAsync(HttpContext ctx)
|
||||
{
|
||||
// Status code mapping: 400 protocol violations (not WS, missing creds);
|
||||
// 401 credential validation failures (decrypt, viewer mismatch); 404 unknown
|
||||
// BattleId. Log messages carry the diagnostic detail; the wire code gives the
|
||||
// client class of failure.
|
||||
if (!ctx.WebSockets.IsWebSocketRequest)
|
||||
{
|
||||
ctx.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
return;
|
||||
}
|
||||
|
||||
// BestHTTP's SocketOptions.AdditionalQueryParams puts these on HTTP request HEADERS
|
||||
// for the WebSocket-only transport (not on the URL query string). Real clients
|
||||
// therefore send BattleId/viewerId as headers; the integration test sends them as
|
||||
// query params for convenience. Check headers first, fall back to query.
|
||||
var battleId = ReadCredential(ctx, "BattleId");
|
||||
var encryptedViewerId = ReadCredential(ctx, "viewerId");
|
||||
if (string.IsNullOrEmpty(battleId) || string.IsNullOrEmpty(encryptedViewerId))
|
||||
{
|
||||
_log.LogWarning("WS upgrade missing BattleId or viewerId (header or query).");
|
||||
ctx.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
return;
|
||||
}
|
||||
|
||||
long viewerId;
|
||||
try
|
||||
{
|
||||
var plain = NodeCrypto.DecryptForNode(encryptedViewerId);
|
||||
viewerId = long.Parse(plain);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning(ex, "viewerId failed to decrypt (encryptedLen={Len})", encryptedViewerId.Length);
|
||||
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
return;
|
||||
}
|
||||
|
||||
var pending = _store.TryGetPending(battleId);
|
||||
if (pending is null)
|
||||
{
|
||||
_log.LogWarning(
|
||||
"WS upgrade for unknown BattleId={Bid} (decrypted viewerId={Vid}). " +
|
||||
"Bridge may not have minted this battle, or it was already consumed/expired.",
|
||||
battleId, viewerId);
|
||||
ctx.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
var isP1 = viewerId == pending.P1.ViewerId;
|
||||
var isP2 = pending.P2 is not null && viewerId == pending.P2.ViewerId;
|
||||
if (!isP1 && !isP2)
|
||||
{
|
||||
_log.LogWarning(
|
||||
"WS upgrade viewer-id mismatch on BattleId={Bid}: bridge expected={P1}/{P2}, decrypted={Got}.",
|
||||
battleId, pending.P1.ViewerId, pending.P2?.ViewerId, viewerId);
|
||||
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
return;
|
||||
}
|
||||
|
||||
var ws = await ctx.WebSockets.AcceptWebSocketAsync();
|
||||
|
||||
switch (pending.Type)
|
||||
{
|
||||
case BattleType.Scripted:
|
||||
{
|
||||
_store.RemovePending(battleId);
|
||||
var realParticipant = new RealParticipant(ws, viewerId, pending.P1.Context,
|
||||
_loggerFactory.CreateLogger<RealParticipant>());
|
||||
var scriptedBot = new ScriptedBotParticipant();
|
||||
var session = new BattleSession(battleId, pending.Type, realParticipant, scriptedBot,
|
||||
_loggerFactory.CreateLogger<BattleSession>());
|
||||
await session.RunAsync(ctx.RequestAborted);
|
||||
break;
|
||||
}
|
||||
|
||||
case BattleType.Pvp:
|
||||
{
|
||||
// Pick this connection's MatchContext (P1's if isP1, P2's if isP2).
|
||||
var selfCtx = isP1 ? pending.P1.Context : pending.P2!.Context;
|
||||
var self = new RealParticipant(ws, viewerId, selfCtx,
|
||||
_loggerFactory.CreateLogger<RealParticipant>());
|
||||
|
||||
var firstArriver = _waitingRoom.Pair(battleId, self);
|
||||
|
||||
if (firstArriver is not null)
|
||||
{
|
||||
// We are the SECOND arriver. Construct and drive the session.
|
||||
_store.RemovePending(battleId);
|
||||
var session = new BattleSession(
|
||||
battleId, BattleType.Pvp, firstArriver, self,
|
||||
_loggerFactory.CreateLogger<BattleSession>());
|
||||
try
|
||||
{
|
||||
await session.RunAsync(ctx.RequestAborted);
|
||||
}
|
||||
finally
|
||||
{
|
||||
firstArriver.MarkSessionFinished();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// We are the FIRST arriver. Park; ParkAsync returns the second arriver
|
||||
// on pairing, null on timeout / cancellation / TryAdd race.
|
||||
var second = await _waitingRoom.ParkAsync(
|
||||
battleId, self, _options.WaitingRoomTimeout, ctx.RequestAborted);
|
||||
|
||||
if (second is null)
|
||||
{
|
||||
// Either timeout (most common) or Park/Park race. Retry Pair once.
|
||||
second = _waitingRoom.Pair(battleId, self);
|
||||
if (second is null)
|
||||
{
|
||||
_log.LogWarning(
|
||||
"PvP waiting-room timeout or race on BattleId={Bid}; first arriver disconnected.",
|
||||
battleId);
|
||||
_store.RemovePending(battleId);
|
||||
return;
|
||||
}
|
||||
// Retry succeeded — we're the de-facto second arriver now. Own the session.
|
||||
_store.RemovePending(battleId);
|
||||
var raceSession = new BattleSession(
|
||||
battleId, BattleType.Pvp, second, self,
|
||||
_loggerFactory.CreateLogger<BattleSession>());
|
||||
try { await raceSession.RunAsync(ctx.RequestAborted); }
|
||||
finally { second.MarkSessionFinished(); }
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal first-arriver path: session is being constructed/driven by the
|
||||
// second arriver. Hold this HTTP request open until they signal completion.
|
||||
// Do NOT call self.RunAsync — the session already does.
|
||||
await self.AwaitSessionFinishedAsync(ctx.RequestAborted);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case BattleType.Bot:
|
||||
{
|
||||
// Phase 3: real (Real, NoOp) session. Bot's pending always has P2 == null
|
||||
// (per IMatchingBridge contract validation), so isP1 must be true here. The
|
||||
// earlier isP1/isP2 check has already rejected viewer mismatches.
|
||||
_store.RemovePending(battleId);
|
||||
var botReal = new RealParticipant(ws, viewerId, pending.P1.Context,
|
||||
_loggerFactory.CreateLogger<RealParticipant>());
|
||||
var noopBot = new NoOpBotParticipant();
|
||||
var botSession = new BattleSession(battleId, BattleType.Bot, botReal, noopBot,
|
||||
_loggerFactory.CreateLogger<BattleSession>());
|
||||
await botSession.RunAsync(ctx.RequestAborted);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
_log.LogError("Unknown BattleType={Type} for BattleId={Bid}; closing WS", pending.Type, battleId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadCredential(HttpContext ctx, string name)
|
||||
{
|
||||
var header = ctx.Request.Headers[name].ToString();
|
||||
if (!string.IsNullOrEmpty(header)) return header;
|
||||
return ctx.Request.Query[name].ToString();
|
||||
}
|
||||
}
|
||||
26
SVSim.BattleNode/Hosting/IWaitingRoom.cs
Normal file
26
SVSim.BattleNode/Hosting/IWaitingRoom.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using SVSim.BattleNode.Sessions.Participants;
|
||||
|
||||
namespace SVSim.BattleNode.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Per-BattleId WS rendezvous for PvP. First arriver parks; second arriver pairs.
|
||||
/// The handler reads the result and either constructs the session (second arriver)
|
||||
/// or awaits termination via the participant's session-finished signal (first arriver).
|
||||
/// </summary>
|
||||
public interface IWaitingRoom
|
||||
{
|
||||
/// <summary>Try to claim a previously-parked first arriver. Returns the first
|
||||
/// arriver (and clears the slot) if one is parked; null if this caller is the
|
||||
/// first arriver (caller should then ParkAsync).</summary>
|
||||
RealParticipant? Pair(string battleId, RealParticipant self);
|
||||
|
||||
/// <summary>Park as the first arriver; await pairing or timeout. Returns the
|
||||
/// second arriver on pairing; null on timeout / cancellation / TryAdd race.</summary>
|
||||
Task<RealParticipant?> ParkAsync(string battleId, RealParticipant self,
|
||||
TimeSpan timeout, CancellationToken ct);
|
||||
|
||||
/// <summary>Best-effort cleanup; idempotent. Called on timeout or cancellation
|
||||
/// so a stale TCS doesn't linger if the first arriver disconnects before
|
||||
/// pairing.</summary>
|
||||
void Evict(string battleId);
|
||||
}
|
||||
59
SVSim.BattleNode/Hosting/WaitingRoom.cs
Normal file
59
SVSim.BattleNode/Hosting/WaitingRoom.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using System.Collections.Concurrent;
|
||||
using SVSim.BattleNode.Sessions.Participants;
|
||||
|
||||
namespace SVSim.BattleNode.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// In-process <see cref="IWaitingRoom"/>. Backed by a ConcurrentDictionary of slots
|
||||
/// keyed by BattleId. Each slot holds the first arriver's RealParticipant and a
|
||||
/// TaskCompletionSource that gets set when the second arriver Pairs (or cancelled
|
||||
/// on timeout / abort).
|
||||
/// </summary>
|
||||
public sealed class WaitingRoom : IWaitingRoom
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, Slot> _rooms = new();
|
||||
|
||||
public RealParticipant? Pair(string battleId, RealParticipant self)
|
||||
{
|
||||
if (!_rooms.TryRemove(battleId, out var slot)) return null;
|
||||
// Hand `self` (second arriver) to the first arriver's ParkAsync...
|
||||
slot.SecondArriverTcs.TrySetResult(self);
|
||||
// ...and return the first arriver to the second arriver's handler.
|
||||
return slot.FirstArriver;
|
||||
}
|
||||
|
||||
public async Task<RealParticipant?> ParkAsync(string battleId, RealParticipant self,
|
||||
TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
var slot = new Slot(self);
|
||||
if (!_rooms.TryAdd(battleId, slot))
|
||||
{
|
||||
// Race: a concurrent Park already created a slot for the same BattleId.
|
||||
// The bridge mints a fresh BattleId per registration, so this is rare;
|
||||
// caller can re-Pair as insurance.
|
||||
return null;
|
||||
}
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCts.CancelAfter(timeout);
|
||||
using var reg = timeoutCts.Token.Register(() => slot.SecondArriverTcs.TrySetCanceled());
|
||||
try
|
||||
{
|
||||
return await slot.SecondArriverTcs.Task;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Evict(battleId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Evict(string battleId) => _rooms.TryRemove(battleId, out _);
|
||||
|
||||
private sealed class Slot
|
||||
{
|
||||
public RealParticipant FirstArriver { get; }
|
||||
public TaskCompletionSource<RealParticipant> SecondArriverTcs { get; } =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public Slot(RealParticipant first) => FirstArriver = first;
|
||||
}
|
||||
}
|
||||
183
SVSim.BattleNode/Lifecycle/ScriptedLifecycle.cs
Normal file
183
SVSim.BattleNode/Lifecycle/ScriptedLifecycle.cs
Normal file
@@ -0,0 +1,183 @@
|
||||
using System.Collections.Immutable;
|
||||
using SVSim.BattleNode.Bridge;
|
||||
using SVSim.BattleNode.Protocol;
|
||||
using SVSim.BattleNode.Protocol.Bodies;
|
||||
|
||||
namespace SVSim.BattleNode.Lifecycle;
|
||||
|
||||
/// <summary>
|
||||
/// v1 hand-rolled scripted opponent. Static frame builders for the five lifecycle uris
|
||||
/// (Matched / BattleStart / Deal / Swap response / Ready) plus a trivial opponent TurnStart
|
||||
/// the dispatch pushes after the player's TurnEnd. The values are templated from the TK2
|
||||
/// captures at <c>data_dumps/captures/battle-traffic_tk2_regular.ndjson</c> — anything
|
||||
/// hardcoded here came from a real prod frame, with names + provenance in
|
||||
/// <see cref="ScriptedProfiles"/>. The player-half of Matched/BattleStart now reads from
|
||||
/// <see cref="MatchContext"/> instead of <see cref="ScriptedProfiles"/>.
|
||||
/// </summary>
|
||||
public static class ScriptedLifecycle
|
||||
{
|
||||
/// <summary>
|
||||
/// Viewer id we present as the opponent on every scripted push. Out-of-range vs. real
|
||||
/// viewer ids so it can't collide with a real account in the auth pipeline.
|
||||
/// </summary>
|
||||
public const long FakeOpponentViewerId = 999_999_999L;
|
||||
|
||||
public static MsgEnvelope BuildMatched(
|
||||
MatchContext selfCtx, MatchContext oppoCtx,
|
||||
long selfViewerId, long oppoViewerId,
|
||||
string battleId, long seed) =>
|
||||
EnvelopeForPush(NetworkBattleUri.Matched,
|
||||
new MatchedBody(
|
||||
SelfInfo: new MatchedSelfInfo(
|
||||
CountryCode: selfCtx.CountryCode,
|
||||
UserName: selfCtx.UserName,
|
||||
SleeveId: selfCtx.SleeveId,
|
||||
EmblemId: selfCtx.EmblemId,
|
||||
DegreeId: selfCtx.DegreeId,
|
||||
FieldId: selfCtx.FieldId,
|
||||
IsOfficial: selfCtx.IsOfficial,
|
||||
OppoId: oppoViewerId,
|
||||
Seed: seed),
|
||||
OppoInfo: new MatchedOppoInfo(
|
||||
CountryCode: oppoCtx.CountryCode,
|
||||
UserName: oppoCtx.UserName,
|
||||
SleeveId: oppoCtx.SleeveId,
|
||||
EmblemId: oppoCtx.EmblemId,
|
||||
DegreeId: oppoCtx.DegreeId,
|
||||
FieldId: oppoCtx.FieldId,
|
||||
IsOfficial: oppoCtx.IsOfficial,
|
||||
OppoId: selfViewerId,
|
||||
Seed: seed,
|
||||
OppoDeckCount: oppoCtx.SelfDeckCardIds.Count),
|
||||
SelfDeck: BuildPlayerDeck(selfCtx.SelfDeckCardIds)),
|
||||
bid: battleId);
|
||||
|
||||
public static MsgEnvelope BuildBattleStart(
|
||||
MatchContext selfCtx, MatchContext oppoCtx, long selfViewerId) =>
|
||||
EnvelopeForPush(NetworkBattleUri.BattleStart,
|
||||
new BattleStartBody(
|
||||
TurnState: 0, // player goes first
|
||||
BattleType: selfCtx.BattleType,
|
||||
SelfInfo: new BattleStartSelfInfo(
|
||||
Rank: ScriptedProfiles.PlayerRank,
|
||||
BattlePoint: ScriptedProfiles.PlayerBattlePoint,
|
||||
ClassId: selfCtx.ClassId,
|
||||
CharaId: selfCtx.CharaId,
|
||||
CardMasterName: selfCtx.CardMasterName),
|
||||
OppoInfo: new BattleStartOppoInfo(
|
||||
// Rank/IsMasterRank/BattlePoint/MasterPoint stay hardcoded —
|
||||
// PvP rank tracking is deferred (per spec § Out of scope).
|
||||
Rank: "1",
|
||||
IsMasterRank: "0",
|
||||
BattlePoint: 0,
|
||||
MasterPoint: "0",
|
||||
ClassId: oppoCtx.ClassId,
|
||||
CharaId: oppoCtx.CharaId,
|
||||
CardMasterName: oppoCtx.CardMasterName)));
|
||||
|
||||
public static MsgEnvelope BuildDeal() =>
|
||||
EnvelopeForPush(NetworkBattleUri.Deal,
|
||||
new DealBody(
|
||||
Self: new[] { new PosIdx(0, 1), new PosIdx(1, 2), new PosIdx(2, 3) },
|
||||
Oppo: new[] { new PosIdx(0, 1), new PosIdx(1, 2), new PosIdx(2, 3) }));
|
||||
|
||||
/// <summary>
|
||||
/// Initial 3-card hand idxs from <see cref="BuildDeal"/>. Each position in this array
|
||||
/// is one card; the value is the card's deck idx. <see cref="ImmutableArray{T}"/> enforces
|
||||
/// the "read-only constant" contract at the type level — callers cannot mutate it, even
|
||||
/// accidentally (the prior <c>long[]</c> allowed in-place modification by anyone with the
|
||||
/// field reference).
|
||||
/// </summary>
|
||||
private static readonly ImmutableArray<long> InitialHand = ImmutableArray.Create<long>(1, 2, 3);
|
||||
|
||||
/// <summary>
|
||||
/// Compute the player's hand after a mulligan. For every idx in <paramref name="swapIndices"/>
|
||||
/// that is currently in the hand, replace it with the next unused deck idx (starting at 4,
|
||||
/// since 1..3 were dealt). Positions of kept cards are preserved.
|
||||
/// </summary>
|
||||
public static long[] ComputeHandAfterSwap(IReadOnlyList<long> swapIndices)
|
||||
{
|
||||
var hand = InitialHand.ToArray();
|
||||
var nextDeckIdx = 4L;
|
||||
for (var pos = 0; pos < hand.Length; pos++)
|
||||
{
|
||||
if (swapIndices.Contains(hand[pos]))
|
||||
{
|
||||
hand[pos] = nextDeckIdx++;
|
||||
}
|
||||
}
|
||||
return hand;
|
||||
}
|
||||
|
||||
public static MsgEnvelope BuildSwapResponse(IReadOnlyList<long> hand) =>
|
||||
EnvelopeForPush(NetworkBattleUri.Swap,
|
||||
new SwapResponseBody(Self: BuildPosIdxList(hand)));
|
||||
|
||||
public static MsgEnvelope BuildReady(IReadOnlyList<long> hand) =>
|
||||
EnvelopeForPush(NetworkBattleUri.Ready,
|
||||
new ReadyBody(
|
||||
Self: BuildPosIdxList(hand),
|
||||
Oppo: BuildPosIdxList(InitialHand),
|
||||
IdxChangeSeed: ScriptedProfiles.ReadyIdxChangeSeed,
|
||||
Spin: ScriptedProfiles.ReadySpin));
|
||||
|
||||
/// <summary>
|
||||
/// First half of the v1.1 scripted opponent turn cycle: pushed after the player's
|
||||
/// TurnEnd, transitions the client into "Opponent's turn…" state. Paired with
|
||||
/// <see cref="BuildOpponentTurnEnd"/>, which immediately follows and hands control
|
||||
/// back to the player.
|
||||
/// </summary>
|
||||
public static MsgEnvelope BuildOpponentTurnStart() =>
|
||||
EnvelopeForPush(NetworkBattleUri.TurnStart,
|
||||
new OpponentTurnStartBody(Spin: ScriptedProfiles.OpponentTurnStartSpin));
|
||||
|
||||
/// <summary>
|
||||
/// Server-pushed TurnEnd transition that closes the opponent's turn and hands control
|
||||
/// back to the player. Paired with <see cref="BuildOpponentTurnStart"/> in the v1.1 loop.
|
||||
/// Wire shape from prod capture battle-traffic_tk2_regular.ndjson L18:
|
||||
/// <c>{"uri":"TurnEnd","turnState":0,"resultCode":1,"playSeq":N}</c>.
|
||||
/// </summary>
|
||||
public static MsgEnvelope BuildOpponentTurnEnd() =>
|
||||
EnvelopeForPush(NetworkBattleUri.TurnEnd, new TurnEndBody(TurnState: 0));
|
||||
|
||||
/// <summary>
|
||||
/// Server-pushed Judge frame that follows the opponent's TurnEnd and unblocks the
|
||||
/// client's <c>JudgeOperation</c> → <c>ControlTurnStartPlayer</c>, transitioning to the
|
||||
/// player's next turn. Without this frame the client hangs on "Opponent's turn…" —
|
||||
/// see <c>data_dumps/captures/battle-traffic.ndjson</c> line 14 (client emits its own
|
||||
/// Judge then waits forever).
|
||||
/// </summary>
|
||||
public static MsgEnvelope BuildOpponentJudge() =>
|
||||
EnvelopeForPush(NetworkBattleUri.Judge, new JudgeBody(Spin: ScriptedProfiles.OpponentJudgeSpin));
|
||||
|
||||
private static IReadOnlyList<PosIdx> BuildPosIdxList(IReadOnlyList<long> hand)
|
||||
{
|
||||
var list = new List<PosIdx>(hand.Count);
|
||||
for (var pos = 0; pos < hand.Count; pos++)
|
||||
{
|
||||
list.Add(new PosIdx(Pos: pos, Idx: (int)hand[pos]));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<DeckCardRef> BuildPlayerDeck(IReadOnlyList<long> cardIds)
|
||||
{
|
||||
var deck = new List<DeckCardRef>(cardIds.Count);
|
||||
for (var i = 0; i < cardIds.Count; i++)
|
||||
{
|
||||
deck.Add(new DeckCardRef(Idx: i + 1, CardId: cardIds[i]));
|
||||
}
|
||||
return deck;
|
||||
}
|
||||
|
||||
private static MsgEnvelope EnvelopeForPush(NetworkBattleUri uri, IMsgBody body, string? bid = null) =>
|
||||
new(uri,
|
||||
ViewerId: FakeOpponentViewerId,
|
||||
Uuid: WireConstants.ServerUuid,
|
||||
Bid: bid,
|
||||
Try: 0,
|
||||
Cat: EmitCategory.Battle,
|
||||
PubSeq: null,
|
||||
PlaySeq: null,
|
||||
Body: body);
|
||||
}
|
||||
61
SVSim.BattleNode/Lifecycle/ScriptedProfiles.cs
Normal file
61
SVSim.BattleNode/Lifecycle/ScriptedProfiles.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using SVSim.BattleNode.Protocol.Bodies;
|
||||
|
||||
namespace SVSim.BattleNode.Lifecycle;
|
||||
|
||||
/// <summary>
|
||||
/// Named constants and templates for the v1 scripted lifecycle. Every value here
|
||||
/// originated in a real prod frame in
|
||||
/// <c>data_dumps/captures/battle-traffic_tk2_regular.ndjson</c>; pulling them out
|
||||
/// of <see cref="ScriptedLifecycle"/> makes the magic numerics navigable and gives
|
||||
/// the seed a single source of truth instead of two duplicated literals.
|
||||
/// </summary>
|
||||
internal static class ScriptedProfiles
|
||||
{
|
||||
// Shared per the spec — selfInfo.seed and oppoInfo.seed always agree.
|
||||
// From frame[2] (Matched).
|
||||
public const long BattleSeed = 17_548_138L;
|
||||
|
||||
public static readonly MatchedOppoInfo OpponentMatchedProfile = new(
|
||||
CountryCode: "JPN",
|
||||
UserName: "Opponent",
|
||||
SleeveId: "704141010",
|
||||
EmblemId: "400001100",
|
||||
DegreeId: "120027",
|
||||
FieldId: 5,
|
||||
IsOfficial: 0,
|
||||
OppoId: 0,
|
||||
Seed: BattleSeed,
|
||||
OppoDeckCount: 30);
|
||||
|
||||
// From frame[5] (BattleStart). Hardcoded; see spec §Deferred plumbing — sourcing these
|
||||
// from real per-viewer state needs a TK2 rank/battle-point tracker.
|
||||
public const string PlayerRank = "10";
|
||||
public const string PlayerBattlePoint = "6270";
|
||||
|
||||
public static readonly BattleStartOppoInfo OpponentBattleStartProfile = new(
|
||||
Rank: "1",
|
||||
IsMasterRank: "0",
|
||||
BattlePoint: 0,
|
||||
MasterPoint: "0",
|
||||
ClassId: "8",
|
||||
CharaId: "8",
|
||||
CardMasterName: "card_master_node_10015");
|
||||
|
||||
// From frame[8] (Ready). Provenance is "what prod sent"; the client
|
||||
// doesn't validate, but echoing matches the capture protects against
|
||||
// a regression on a future tightening.
|
||||
public const int ReadyIdxChangeSeed = 771_335_280;
|
||||
public const int ReadySpin = 243;
|
||||
|
||||
// Generic non-zero spin that lands the client in "Opponent's turn..."
|
||||
// display state. v1 doesn't simulate the opponent — once this lands,
|
||||
// the client sits there indefinitely.
|
||||
public const int OpponentTurnStartSpin = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Server-pushed Judge frame spin value. Prod varies per push (55, 175, 73, ...) — it's
|
||||
/// an animation seed, not a stateful value. Fixed at 100 here for test stability;
|
||||
/// the client's <c>JudgeOperation</c> doesn't read it.
|
||||
/// </summary>
|
||||
public const int OpponentJudgeSpin = 100;
|
||||
}
|
||||
19
SVSim.BattleNode/Protocol/BattleResult.cs
Normal file
19
SVSim.BattleNode/Protocol/BattleResult.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
namespace SVSim.BattleNode.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// Wire value of <c>result</c> on a BattleFinish frame. The client's
|
||||
/// <c>BattleFinishResponsProcessing</c> switch maps these as:
|
||||
/// 0 → LOSE, 1 → WIN, 2 → CONSISTENCY (desync / action-list mismatch).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is NOT the same as the client's in-memory <c>BATTLE_RESULT_TYPE</c> enum
|
||||
/// (NONE=0, WIN=1, LOSE=2, CONSISTENCY=3) — the wire codes shift LOSE down to 0.
|
||||
/// Always serialize as the int value, not the name; see the
|
||||
/// <c>JsonNumberEnumConverter</c> on <see cref="Bodies.BattleFinishBody.Result"/>.
|
||||
/// </remarks>
|
||||
public enum BattleResult
|
||||
{
|
||||
Lose = 0,
|
||||
Win = 1,
|
||||
Consistency = 2,
|
||||
}
|
||||
7
SVSim.BattleNode/Protocol/Bodies/AlivePushBody.cs
Normal file
7
SVSim.BattleNode/Protocol/Bodies/AlivePushBody.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.BattleNode.Protocol.Bodies;
|
||||
|
||||
public sealed record AlivePushBody(
|
||||
[property: JsonPropertyName("scs")] string Scs,
|
||||
[property: JsonPropertyName("ocs")] string Ocs) : IMsgBody;
|
||||
9
SVSim.BattleNode/Protocol/Bodies/BattleFinishBody.cs
Normal file
9
SVSim.BattleNode/Protocol/Bodies/BattleFinishBody.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.BattleNode.Protocol.Bodies;
|
||||
|
||||
public sealed record BattleFinishBody(
|
||||
[property: JsonPropertyName("result")]
|
||||
[property: JsonConverter(typeof(JsonNumberEnumConverter<BattleResult>))]
|
||||
BattleResult Result,
|
||||
[property: JsonPropertyName("resultCode")] int ResultCode = 1) : IMsgBody;
|
||||
28
SVSim.BattleNode/Protocol/Bodies/BattleStartBody.cs
Normal file
28
SVSim.BattleNode/Protocol/Bodies/BattleStartBody.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.BattleNode.Protocol.Bodies;
|
||||
|
||||
public sealed record BattleStartBody(
|
||||
[property: JsonPropertyName("turnState")] int TurnState,
|
||||
[property: JsonPropertyName("battleType")] int BattleType,
|
||||
[property: JsonPropertyName("selfInfo")] BattleStartSelfInfo SelfInfo,
|
||||
[property: JsonPropertyName("oppoInfo")] BattleStartOppoInfo OppoInfo,
|
||||
[property: JsonPropertyName("resultCode")] int ResultCode = 1) : IMsgBody;
|
||||
|
||||
public sealed record BattleStartSelfInfo(
|
||||
[property: JsonPropertyName("rank")] string Rank,
|
||||
[property: JsonPropertyName("battlePoint")] string BattlePoint,
|
||||
[property: JsonPropertyName("classId")] string ClassId,
|
||||
[property: JsonPropertyName("charaId")] string CharaId,
|
||||
[property: JsonPropertyName("cardMasterName")] string CardMasterName);
|
||||
|
||||
// Note: BattlePoint is int on the wire here (not string as on self) — matches the
|
||||
// captured prod frame at data_dumps/captures/battle-traffic_tk2_regular.ndjson.
|
||||
public sealed record BattleStartOppoInfo(
|
||||
[property: JsonPropertyName("rank")] string Rank,
|
||||
[property: JsonPropertyName("isMasterRank")] string IsMasterRank,
|
||||
[property: JsonPropertyName("battlePoint")] int BattlePoint,
|
||||
[property: JsonPropertyName("masterPoint")] string MasterPoint,
|
||||
[property: JsonPropertyName("classId")] string ClassId,
|
||||
[property: JsonPropertyName("charaId")] string CharaId,
|
||||
[property: JsonPropertyName("cardMasterName")] string CardMasterName);
|
||||
8
SVSim.BattleNode/Protocol/Bodies/DealBody.cs
Normal file
8
SVSim.BattleNode/Protocol/Bodies/DealBody.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.BattleNode.Protocol.Bodies;
|
||||
|
||||
public sealed record DealBody(
|
||||
[property: JsonPropertyName("self")] IReadOnlyList<PosIdx> Self,
|
||||
[property: JsonPropertyName("oppo")] IReadOnlyList<PosIdx> Oppo,
|
||||
[property: JsonPropertyName("resultCode")] int ResultCode = 1) : IMsgBody;
|
||||
7
SVSim.BattleNode/Protocol/Bodies/JudgeBody.cs
Normal file
7
SVSim.BattleNode/Protocol/Bodies/JudgeBody.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.BattleNode.Protocol.Bodies;
|
||||
|
||||
public sealed record JudgeBody(
|
||||
[property: JsonPropertyName("spin")] int Spin,
|
||||
[property: JsonPropertyName("resultCode")] int ResultCode = 1) : IMsgBody;
|
||||
36
SVSim.BattleNode/Protocol/Bodies/MatchedBody.cs
Normal file
36
SVSim.BattleNode/Protocol/Bodies/MatchedBody.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.BattleNode.Protocol.Bodies;
|
||||
|
||||
public sealed record MatchedBody(
|
||||
[property: JsonPropertyName("selfInfo")] MatchedSelfInfo SelfInfo,
|
||||
[property: JsonPropertyName("oppoInfo")] MatchedOppoInfo OppoInfo,
|
||||
[property: JsonPropertyName("selfDeck")] IReadOnlyList<DeckCardRef> SelfDeck,
|
||||
[property: JsonPropertyName("resultCode")] int ResultCode = 1) : IMsgBody;
|
||||
|
||||
public sealed record MatchedSelfInfo(
|
||||
[property: JsonPropertyName("country_code")] string CountryCode,
|
||||
[property: JsonPropertyName("userName")] string UserName,
|
||||
[property: JsonPropertyName("sleeveId")] string SleeveId,
|
||||
[property: JsonPropertyName("emblemId")] string EmblemId,
|
||||
[property: JsonPropertyName("degreeId")] string DegreeId,
|
||||
[property: JsonPropertyName("fieldId")] int FieldId,
|
||||
[property: JsonPropertyName("isOfficial")] int IsOfficial,
|
||||
[property: JsonPropertyName("oppoId")] long OppoId,
|
||||
[property: JsonPropertyName("seed")] long Seed);
|
||||
|
||||
public sealed record MatchedOppoInfo(
|
||||
[property: JsonPropertyName("country_code")] string CountryCode,
|
||||
[property: JsonPropertyName("userName")] string UserName,
|
||||
[property: JsonPropertyName("sleeveId")] string SleeveId,
|
||||
[property: JsonPropertyName("emblemId")] string EmblemId,
|
||||
[property: JsonPropertyName("degreeId")] string DegreeId,
|
||||
[property: JsonPropertyName("fieldId")] int FieldId,
|
||||
[property: JsonPropertyName("isOfficial")] int IsOfficial,
|
||||
[property: JsonPropertyName("oppoId")] long OppoId,
|
||||
[property: JsonPropertyName("seed")] long Seed,
|
||||
[property: JsonPropertyName("oppoDeckCount")] int OppoDeckCount);
|
||||
|
||||
public sealed record DeckCardRef(
|
||||
[property: JsonPropertyName("idx")] int Idx,
|
||||
[property: JsonPropertyName("cardId")] long CardId);
|
||||
@@ -0,0 +1,7 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.BattleNode.Protocol.Bodies;
|
||||
|
||||
public sealed record OpponentTurnStartBody(
|
||||
[property: JsonPropertyName("spin")] int Spin,
|
||||
[property: JsonPropertyName("resultCode")] int ResultCode = 1) : IMsgBody;
|
||||
7
SVSim.BattleNode/Protocol/Bodies/PosIdx.cs
Normal file
7
SVSim.BattleNode/Protocol/Bodies/PosIdx.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.BattleNode.Protocol.Bodies;
|
||||
|
||||
public sealed record PosIdx(
|
||||
[property: JsonPropertyName("pos")] int Pos,
|
||||
[property: JsonPropertyName("idx")] int Idx);
|
||||
10
SVSim.BattleNode/Protocol/Bodies/ReadyBody.cs
Normal file
10
SVSim.BattleNode/Protocol/Bodies/ReadyBody.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.BattleNode.Protocol.Bodies;
|
||||
|
||||
public sealed record ReadyBody(
|
||||
[property: JsonPropertyName("self")] IReadOnlyList<PosIdx> Self,
|
||||
[property: JsonPropertyName("oppo")] IReadOnlyList<PosIdx> Oppo,
|
||||
[property: JsonPropertyName("idxChangeSeed")] int IdxChangeSeed,
|
||||
[property: JsonPropertyName("spin")] int Spin,
|
||||
[property: JsonPropertyName("resultCode")] int ResultCode = 1) : IMsgBody;
|
||||
6
SVSim.BattleNode/Protocol/Bodies/ResultCodeOnlyBody.cs
Normal file
6
SVSim.BattleNode/Protocol/Bodies/ResultCodeOnlyBody.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.BattleNode.Protocol.Bodies;
|
||||
|
||||
public sealed record ResultCodeOnlyBody(
|
||||
[property: JsonPropertyName("resultCode")] int ResultCode = 1) : IMsgBody;
|
||||
7
SVSim.BattleNode/Protocol/Bodies/SwapResponseBody.cs
Normal file
7
SVSim.BattleNode/Protocol/Bodies/SwapResponseBody.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.BattleNode.Protocol.Bodies;
|
||||
|
||||
public sealed record SwapResponseBody(
|
||||
[property: JsonPropertyName("self")] IReadOnlyList<PosIdx> Self,
|
||||
[property: JsonPropertyName("resultCode")] int ResultCode = 1) : IMsgBody;
|
||||
7
SVSim.BattleNode/Protocol/Bodies/TurnEndBody.cs
Normal file
7
SVSim.BattleNode/Protocol/Bodies/TurnEndBody.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.BattleNode.Protocol.Bodies;
|
||||
|
||||
public sealed record TurnEndBody(
|
||||
[property: JsonPropertyName("turnState")] int TurnState,
|
||||
[property: JsonPropertyName("resultCode")] int ResultCode = 1) : IMsgBody;
|
||||
11
SVSim.BattleNode/Protocol/EmitCategory.cs
Normal file
11
SVSim.BattleNode/Protocol/EmitCategory.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace SVSim.BattleNode.Protocol;
|
||||
|
||||
/// <summary>The "cat" field on the msg envelope.</summary>
|
||||
public enum EmitCategory
|
||||
{
|
||||
Battle = 1,
|
||||
Matching = 2,
|
||||
Room = 3,
|
||||
Watch = 11,
|
||||
General = 99,
|
||||
}
|
||||
10
SVSim.BattleNode/Protocol/IMsgBody.cs
Normal file
10
SVSim.BattleNode/Protocol/IMsgBody.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace SVSim.BattleNode.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// Marker for every type that can appear as <see cref="MsgEnvelope.Body"/>.
|
||||
/// Implementers fall into two camps: typed records used on the outbound path
|
||||
/// (one per scripted frame shape) and <see cref="RawBody"/> used on the inbound
|
||||
/// path. The marker exists so the envelope can carry either without falling
|
||||
/// back to <c>object</c>.
|
||||
/// </summary>
|
||||
public interface IMsgBody { }
|
||||
175
SVSim.BattleNode/Protocol/MsgEnvelope.cs
Normal file
175
SVSim.BattleNode/Protocol/MsgEnvelope.cs
Normal file
@@ -0,0 +1,175 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.BattleNode.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// The shared envelope on every encrypted msg / synchronize frame. Body is
|
||||
/// <see cref="IMsgBody"/> — either a typed body record (outbound) or a
|
||||
/// <see cref="RawBody"/> (inbound).
|
||||
/// </summary>
|
||||
public sealed record MsgEnvelope(
|
||||
NetworkBattleUri Uri,
|
||||
long ViewerId,
|
||||
string Uuid,
|
||||
string? Bid,
|
||||
int Try,
|
||||
EmitCategory Cat,
|
||||
long? PubSeq,
|
||||
long? PlaySeq,
|
||||
IMsgBody Body)
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = CreateOptions();
|
||||
|
||||
private static readonly HashSet<string> ReservedEnvelopeKeys = new()
|
||||
{
|
||||
"uri", "viewerId", "uuid", "bid", "try", "cat", "pubSeq", "playSeq",
|
||||
};
|
||||
|
||||
private static JsonSerializerOptions CreateOptions()
|
||||
{
|
||||
var opt = new JsonSerializerOptions
|
||||
{
|
||||
// Wire-key casing is bare camelCase via per-field [JsonPropertyName] —
|
||||
// NOT EmulatedEntrypoint's snake_case policy. The naming-policy line
|
||||
// that was here previously was dead code (every wire key is explicit).
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
opt.Converters.Add(new JsonStringEnumConverter());
|
||||
return opt;
|
||||
}
|
||||
|
||||
public static string ToJson(MsgEnvelope env)
|
||||
{
|
||||
// Envelope fields MUST come before body fields on the wire. The client's
|
||||
// RealTimeNetworkAgent.SetNetworkInfo iterates the dict in insertion order and
|
||||
// clears _selfDeck on the "uri" key (via GameMgr.InitializeSelfInfo). Any body
|
||||
// field processed before "uri" is wiped before Matching.StartBattleLoad reads
|
||||
// it back. The prod wire emits envelope keys first; we must too.
|
||||
var result = new JsonObject();
|
||||
result["uri"] = env.Uri.ToString();
|
||||
result["viewerId"] = env.ViewerId;
|
||||
result["uuid"] = env.Uuid;
|
||||
result["try"] = env.Try;
|
||||
result["cat"] = (int)env.Cat;
|
||||
if (env.Bid is not null) result["bid"] = env.Bid;
|
||||
if (env.PubSeq.HasValue) result["pubSeq"] = env.PubSeq.Value;
|
||||
if (env.PlaySeq.HasValue) result["playSeq"] = env.PlaySeq.Value;
|
||||
|
||||
if (env.Body is RawBody raw)
|
||||
{
|
||||
// Inbound-echo path: flatten Entries to top-level keys.
|
||||
foreach (var (k, v) in raw.Entries)
|
||||
{
|
||||
if (ReservedEnvelopeKeys.Contains(k))
|
||||
throw new ArgumentException(
|
||||
$"RawBody key '{k}' collides with a reserved envelope field. " +
|
||||
$"Move it to a typed field on MsgEnvelope.",
|
||||
nameof(env));
|
||||
result[k] = ToJsonNode(v);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Typed body: serialize via [JsonPropertyName] attributes on the record,
|
||||
// then layer each field onto `result` after the envelope keys. DeepClone
|
||||
// because S.T.Json JsonNodes can only have one parent; reassigning a node
|
||||
// owned by `bodyNode` to `result` would throw without the clone.
|
||||
var bodyNode = (JsonObject)JsonSerializer.SerializeToNode(env.Body, env.Body.GetType(), Options)!;
|
||||
foreach (var prop in bodyNode)
|
||||
{
|
||||
result[prop.Key] = prop.Value?.DeepClone();
|
||||
}
|
||||
}
|
||||
|
||||
return result.ToJsonString(Options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert a boxed CLR value (as stored in <see cref="RawBody.Entries"/>) to a JsonNode.
|
||||
/// Explicit type switch on the runtime type — `JsonValue.Create(object?)` would create
|
||||
/// a `JsonValueCustomized<object>` that requires a TypeInfoResolver at serialize time
|
||||
/// (introduced in S.T.Json 8.0 source-gen mode).
|
||||
/// </summary>
|
||||
private static JsonNode? ToJsonNode(object? value) => value switch
|
||||
{
|
||||
null => null,
|
||||
string s => JsonValue.Create(s),
|
||||
bool b => JsonValue.Create(b),
|
||||
long l => JsonValue.Create(l),
|
||||
int i => JsonValue.Create(i),
|
||||
double d => JsonValue.Create(d),
|
||||
decimal m => JsonValue.Create(m),
|
||||
// Inbound-parsed nested objects come through as Dictionary<string, object?>; nested
|
||||
// arrays as List<object?>. FromJson is the source of these shapes — see ToObject.
|
||||
IDictionary<string, object?> dict => DictToJsonObject(dict),
|
||||
IReadOnlyList<object?> list => ListToJsonArray(list),
|
||||
_ => throw new InvalidOperationException(
|
||||
$"RawBody contains a value of unsupported type {value.GetType().FullName}. " +
|
||||
"Only primitives, nested dicts (object), and nested lists are recognized."),
|
||||
};
|
||||
|
||||
private static JsonObject DictToJsonObject(IDictionary<string, object?> dict)
|
||||
{
|
||||
var obj = new JsonObject();
|
||||
foreach (var (k, v) in dict) obj[k] = ToJsonNode(v);
|
||||
return obj;
|
||||
}
|
||||
|
||||
private static JsonArray ListToJsonArray(IReadOnlyList<object?> list)
|
||||
{
|
||||
var arr = new JsonArray();
|
||||
foreach (var v in list) arr.Add(ToJsonNode(v));
|
||||
return arr;
|
||||
}
|
||||
|
||||
public static MsgEnvelope FromJson(string json)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var uri = Enum.Parse<NetworkBattleUri>(root.GetProperty("uri").GetString()!);
|
||||
var viewerId = root.GetProperty("viewerId").GetInt64();
|
||||
var uuid = root.GetProperty("uuid").GetString()!;
|
||||
var bid = root.TryGetProperty("bid", out var bidEl) ? bidEl.GetString() : null;
|
||||
var @try = root.TryGetProperty("try", out var tryEl) ? tryEl.GetInt32() : 0;
|
||||
var cat = root.TryGetProperty("cat", out var catEl) ? (EmitCategory)catEl.GetInt32() : EmitCategory.Battle;
|
||||
var pubSeq = root.TryGetProperty("pubSeq", out var psEl) ? psEl.GetInt64() : (long?)null;
|
||||
var playSeq = root.TryGetProperty("playSeq", out var plsEl) ? plsEl.GetInt64() : (long?)null;
|
||||
|
||||
var bodyDict = new Dictionary<string, object?>();
|
||||
foreach (var prop in root.EnumerateObject())
|
||||
{
|
||||
if (ReservedEnvelopeKeys.Contains(prop.Name)) continue;
|
||||
bodyDict[prop.Name] = ToObject(prop.Value);
|
||||
}
|
||||
|
||||
return new MsgEnvelope(uri, viewerId, uuid, bid, @try, cat, pubSeq, playSeq, new RawBody(bodyDict));
|
||||
}
|
||||
|
||||
private static object? ToObject(JsonElement el) => el.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => el.GetString(),
|
||||
// Extracted to a helper because writing the conditional inline as
|
||||
// el.TryGetInt64(out var l) ? l : el.GetDouble()
|
||||
// unifies the conditional's branches to the common implicit-convertible type. long→double
|
||||
// is implicit; so the result type collapses to double and the long value silently widens.
|
||||
// Downstream OfType<long> filters then drop the (now boxed-double) entries, which broke
|
||||
// the mulligan idxList extraction. Separate method returns object explicitly so each
|
||||
// branch boxes its own runtime type.
|
||||
JsonValueKind.Number => ParseNumber(el),
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.Null => null,
|
||||
JsonValueKind.Array => el.EnumerateArray().Select(ToObject).ToList(),
|
||||
JsonValueKind.Object => el.EnumerateObject().ToDictionary(p => p.Name, p => ToObject(p.Value)),
|
||||
_ => el.GetRawText(),
|
||||
};
|
||||
|
||||
private static object ParseNumber(JsonElement el)
|
||||
{
|
||||
if (el.TryGetInt64(out var l)) return l;
|
||||
return el.GetDouble();
|
||||
}
|
||||
}
|
||||
26
SVSim.BattleNode/Protocol/MsgPayloadCodec.cs
Normal file
26
SVSim.BattleNode/Protocol/MsgPayloadCodec.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using MessagePack;
|
||||
using SVSim.BattleNode.Wire;
|
||||
|
||||
namespace SVSim.BattleNode.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// Full chain between an envelope and the bytes that ride as a SocketIO binary attachment.
|
||||
/// Inbound: bytes → msgpack-string → NodeCrypto.Decrypt → JSON → MsgEnvelope
|
||||
/// Outbound: MsgEnvelope → JSON → NodeCrypto.Encrypt → msgpack-bytes
|
||||
/// </summary>
|
||||
public static class MsgPayloadCodec
|
||||
{
|
||||
public static MsgEnvelope Decode(byte[] msgpackBytes)
|
||||
{
|
||||
var encryptedString = MessagePackSerializer.Deserialize<string>(msgpackBytes);
|
||||
var json = NodeCrypto.DecryptForNode(encryptedString);
|
||||
return MsgEnvelope.FromJson(json);
|
||||
}
|
||||
|
||||
public static byte[] Encode(MsgEnvelope envelope, string key)
|
||||
{
|
||||
var json = MsgEnvelope.ToJson(envelope);
|
||||
var encryptedString = NodeCrypto.EncryptForNode(json, key);
|
||||
return MessagePackSerializer.Serialize(encryptedString);
|
||||
}
|
||||
}
|
||||
46
SVSim.BattleNode/Protocol/NetworkBattleUri.cs
Normal file
46
SVSim.BattleNode/Protocol/NetworkBattleUri.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
namespace SVSim.BattleNode.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// Discriminator for every msg/synchronize envelope. Wire form is the bare member name
|
||||
/// (case-sensitive). See docs/api-spec/in-battle/enums.md.
|
||||
/// </summary>
|
||||
public enum NetworkBattleUri
|
||||
{
|
||||
None,
|
||||
Resume,
|
||||
Retry,
|
||||
InitNetwork,
|
||||
InitBattle,
|
||||
InitRoomBattle,
|
||||
Matched,
|
||||
Loaded,
|
||||
Deal,
|
||||
Swap,
|
||||
Ready,
|
||||
TurnStart,
|
||||
TurnEndActions,
|
||||
TurnEnd,
|
||||
TurnEndFinal,
|
||||
PlayActions,
|
||||
BattleStart,
|
||||
BattleFinish,
|
||||
ChatStamp,
|
||||
Gungnir,
|
||||
Echo,
|
||||
Retire,
|
||||
OppoDisconnect,
|
||||
End,
|
||||
Judge,
|
||||
Touch,
|
||||
SelectSkill,
|
||||
SelectObject,
|
||||
SlideObject,
|
||||
TurnEndReady,
|
||||
RecoveryStart,
|
||||
RecoveryEnd,
|
||||
JudgeResult,
|
||||
Maintenance,
|
||||
ReplayFinish,
|
||||
Kill,
|
||||
Watch,
|
||||
}
|
||||
16
SVSim.BattleNode/Protocol/RawBody.cs
Normal file
16
SVSim.BattleNode/Protocol/RawBody.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace SVSim.BattleNode.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a parsed-dictionary body for the inbound path. <see cref="MsgEnvelope.FromJson"/>
|
||||
/// returns this; <see cref="MsgEnvelope.ToJson"/> flattens <see cref="Entries"/> back to
|
||||
/// top-level keys when echoing.
|
||||
/// </summary>
|
||||
public sealed class RawBody : IMsgBody
|
||||
{
|
||||
public Dictionary<string, object?> Entries { get; }
|
||||
|
||||
public RawBody(Dictionary<string, object?> entries)
|
||||
{
|
||||
Entries = entries;
|
||||
}
|
||||
}
|
||||
41
SVSim.BattleNode/Protocol/ReceiveNodeResultCode.cs
Normal file
41
SVSim.BattleNode/Protocol/ReceiveNodeResultCode.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
namespace SVSim.BattleNode.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// The "resultCode" field on synchronize pushes. 1 = Success, else error.
|
||||
/// Mirrors the full catalog from docs/api-spec/in-battle/enums.md, including
|
||||
/// source typos in the original spec (RoomBattleReadeError, RoomTornament*).
|
||||
/// </summary>
|
||||
public enum ReceiveNodeResultCode
|
||||
{
|
||||
None = 0,
|
||||
Success = 1,
|
||||
Different_UUID = 30001,
|
||||
RedisReplyError = 30002,
|
||||
UnexistUserinfoError = 30003,
|
||||
RoomStatusInfoError = 30101,
|
||||
RoomCreateError = 30102,
|
||||
RoomEntryError = 30103,
|
||||
RoomKickError = 30104,
|
||||
RoomLeaveError = 30105,
|
||||
RoomReleaseError = 30106,
|
||||
RoomForceReleaseError = 30107,
|
||||
RoomReenterError = 30108,
|
||||
RoomBattleReadeError = 30109, // source typo per spec, preserved
|
||||
RoomTournamentDeckError = 30110,
|
||||
RoomTournamentError = 30111,
|
||||
RoomSetupLock = 30112,
|
||||
MatchingTimeOut = 30201,
|
||||
UnmatchedError = 30211,
|
||||
CurrentBattleError = 30212,
|
||||
UnexpectedPhaseError = 30213,
|
||||
WatchError = 30302,
|
||||
SwapTimeoutError = 31001,
|
||||
FoundRemovedUserErrorSelf = 32101,
|
||||
FoundRemovedUserErrorOppo = 32102,
|
||||
FoundRemovedUserErrorWatcher = 32103,
|
||||
RoomTimeEndError = 32104,
|
||||
WatcherInRemovedOwnerRoomError = 32105,
|
||||
RoomTornamentOwnTimeEndError = 32106, // source typo per spec, preserved
|
||||
RoomTornamentOppoTimeEndError = 32107, // source typo per spec, preserved
|
||||
BattleFinishTimeEnd = 32108,
|
||||
}
|
||||
27
SVSim.BattleNode/Protocol/WireConstants.cs
Normal file
27
SVSim.BattleNode/Protocol/WireConstants.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
namespace SVSim.BattleNode.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// String constants that show up on the wire as opaque tags. Lifting them out of
|
||||
/// inline string literals gives each one a single source of truth and a name that
|
||||
/// reads at the use site.
|
||||
/// </summary>
|
||||
internal static class WireConstants
|
||||
{
|
||||
/// <summary>SIO event name for ordered server-pushed frames (the lifecycle channel).</summary>
|
||||
public const string SynchronizeEvent = "synchronize";
|
||||
|
||||
/// <summary>SIO event name for client-emitted msg frames + their ack-responses.</summary>
|
||||
public const string MsgEvent = "msg";
|
||||
|
||||
/// <summary>SIO event name for Gungnir keepalive frames (both directions).</summary>
|
||||
public const string AliveEvent = "alive";
|
||||
|
||||
/// <summary>
|
||||
/// Placeholder UUID we stamp on every server-originated envelope. Prod servers stamp a
|
||||
/// real per-request UUID; the client doesn't validate it.
|
||||
/// </summary>
|
||||
public const string ServerUuid = "node-stub";
|
||||
|
||||
/// <summary>Gungnir scs/ocs value the v1 server reports unconditionally.</summary>
|
||||
public const string OnlineStatus = "ONLINE";
|
||||
}
|
||||
142
SVSim.BattleNode/README.md
Normal file
142
SVSim.BattleNode/README.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# SVSim.BattleNode
|
||||
|
||||
Socket.IO node-server scaffolding for in-battle traffic. Implements the second of the prod 4-server topology — the realtime channel that handles `Matched` / `BattleStart` / `Deal` / per-action `PlayActions` / `Echo` / `TurnEnd` between the client and a server-side opponent.
|
||||
|
||||
**v1 scope** is "scripted thin sequencer": the server accepts a connection, walks a hand-rolled lifecycle from `InitNetwork` to mulligan + first turn + opponent TurnStart, then sits at the opponent's-turn screen indefinitely. No real opponent, no `battleCode` validation, no recovery. v2 work targets each of those.
|
||||
|
||||
The library has **no dependency on `SVSim.EmulatedEntrypoint`**. It exposes one DI seam (`IMatchingBridge`) and one ASP.NET Core integration surface (`AddBattleNode` / `UseBattleNode`). Pulling the node into a separate process later is one interface and one Kestrel binding.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
SVSim.BattleNode/
|
||||
├─ Bridge/ IMatchingBridge — what /do_matching calls to mint a battle id + node URL
|
||||
├─ Hosting/ ASP.NET Core extensions + the /socket.io/ endpoint handler
|
||||
├─ Lifecycle/ ScriptedLifecycle — the v1 hand-rolled Matched/BattleStart/Deal/Swap/Ready frames
|
||||
├─ Protocol/ MsgEnvelope, NetworkBattleUri enum, msgpack ↔ envelope codec
|
||||
├─ Reliability/ InboundTracker (pubSeq dedup), OutboundSequencer (playSeq archive), Gungnir
|
||||
├─ Sessions/ BattleSession (per-connection state + WS pump), IBattleSessionStore
|
||||
└─ Wire/ EIO3 framing, SIO2 framing, NodeCrypto (AES-256-CBC)
|
||||
```
|
||||
|
||||
## Connect handshake (verified end-to-end against the real client)
|
||||
|
||||
```
|
||||
┌────────┐ ┌────────────┐
|
||||
│ Client │ │ BattleNode │
|
||||
└────┬───┘ └──────┬─────┘
|
||||
│ │
|
||||
│ HTTP POST /arena_two_pick_battle/do_matching │ (HTTP host)
|
||||
├──────────────────────────────────────────────────────────────►│
|
||||
│ ◄── { matching_state:3004, battle_id, node_server_url, │
|
||||
│ card_master_id, ... } │
|
||||
│ │
|
||||
│ WS upgrade ws://<node>/socket.io/ │
|
||||
│ headers: BattleId, viewerId=encryptForNode(uid) │
|
||||
├──────────────────────────────────────────────────────────────►│ AcceptWebSocketAsync
|
||||
│ ◄── EIO3 Open 0{sid,upgrades:[],pingInterval,pingTimeout} │
|
||||
│ │
|
||||
│ msg: InitNetwork (cat=99/general) │
|
||||
├──────────────────────────────────────────────────────────────►│
|
||||
│ ◄── synchronize: InitNetwork{resultCode:1} │
|
||||
│ │
|
||||
│ MatchingInitBattle: status=Connect; subscribe receiver │
|
||||
│ msg: InitBattle (cat=2/matching) │
|
||||
├──────────────────────────────────────────────────────────────►│
|
||||
│ ◄── synchronize: Matched{selfInfo,oppoInfo,selfDeck,bid} │
|
||||
│ │
|
||||
│ client loads decks/scene │
|
||||
│ msg: Loaded │
|
||||
├──────────────────────────────────────────────────────────────►│
|
||||
│ ◄── synchronize: BattleStart{turnState,battleType,...} │
|
||||
│ ◄── synchronize: Deal{self,oppo} │
|
||||
│ │
|
||||
│ mulligan UI; player chooses cards to swap │
|
||||
│ msg: Swap{idxList:[...]} │
|
||||
├──────────────────────────────────────────────────────────────►│
|
||||
│ ◄── synchronize: Swap{self:[post-mulligan hand]} │
|
||||
│ ◄── synchronize: Ready{self,oppo,idxChangeSeed,spin} │
|
||||
│ │
|
||||
│ turn 1: TurnStart, PlayActions, ..., TurnEnd │
|
||||
├──────────────────────────────────────────────────────────────►│
|
||||
│ ◄── synchronize: TurnStart{spin} (opponent turn signal) │
|
||||
│ │
|
||||
│ sits at "Opponent's turn…" — v1 stopping point │
|
||||
```
|
||||
|
||||
Each push from us carries a contiguous `playSeq`; client-emit `pubSeq` is echoed back via the Socket.IO ack callback. `Gungnir` runs a 5s alive heartbeat in parallel reporting `scs:ONLINE,ocs:ONLINE`.
|
||||
|
||||
## Wire-format gotchas (discovered during v1 smoke)
|
||||
|
||||
These are not in the original protocol docs and tripped us during the smoke walkthrough — leaving them here so the next reader doesn't repeat the diagnosis.
|
||||
|
||||
| Spec said | Actual wire | Where it shows up |
|
||||
|---|---|---|
|
||||
| `AdditionalQueryParams` on the WS upgrade | **HTTP request headers**, not query string. BestHTTP misnames the API. | `BattleNodeWebSocketHandler.ReadCredential` reads `BattleId` / `viewerId` from headers first, query as fallback (for tests). |
|
||||
| `node_server_url` ws://host:port | `host:port/socket.io/` — **no scheme prefix**, **path included**. | `BattleNodeOptions.NodeServerUrl` default + `do_matching` response. |
|
||||
| `card_master_id` optional | **Required** when `matching_state ∈ {3004,3007,3011}` — no `Keys.Contains` guard client-side. | Added to `DoMatchingResponseDto` with default `1`. |
|
||||
| `resultCode` optional on pushes | **Required = 1** on every scripted synchronize frame; missing means "drop in error handler". | `ScriptedLifecycle.EnvelopeForPush` injects it. |
|
||||
| Matched in response to InitNetwork | **InitBattle**. Matched in response to InitNetwork lands before the client's matching handler is subscribed and silently drops. | See dispatch in `BattleSession.ComputeResponses`. |
|
||||
| WS binary frames carry raw msgpack | EIO3 prefixes binary frames with `0x04` (Message type byte), same as the leading digit on text frames. | `BattleSession.RunAsync` strips on read; `EncodeAndSendAsync` prepends on send. |
|
||||
|
||||
There's also a JSON parsing pitfall worth knowing about (and that broke the mulligan): the inline conditional `el.TryGetInt64(out var l) ? l : el.GetDouble()` unifies its branches to the common implicit-convertible type. Since `long → double` is implicit, the long silently widens to double, and `OfType<long>` downstream drops every entry. See `MsgEnvelope.ParseNumber` for the fix — keep number parsing in a separate method so each branch boxes its own runtime type.
|
||||
|
||||
## v1 scripted opponent — what the client sees
|
||||
|
||||
The player half of `Matched` / `BattleStart` reads from a `MatchContext` assembled in
|
||||
`SVSim.EmulatedEntrypoint/Services/MatchContextBuilder` from the viewer's TK2 run + equipped
|
||||
cosmetics + config — so the mulligan renders the real drafted deck, drafted class/leader,
|
||||
and equipped emblem/degree. The opponent half stays scripted in `ScriptedProfiles`:
|
||||
|
||||
- **Opponent** is a fixed silhouette: `classId="8"`, JPN sleeve/emblem/degree, viewer id `999999999`.
|
||||
- **Battle seed** is `17548138L` in both info blocks (the seed is *shared* per battle per the spec).
|
||||
- **Mulligan** does real card replacement: any idx in your `idxList` is swapped for the next unused deck idx (`1..3` dealt, so `4..30` are pool).
|
||||
- **Opponent's turn** never actually does anything — we push a single `TurnStart{spin:100}` after your `TurnEnd` so the UI transitions to the opponent-turn display, then sit.
|
||||
|
||||
A few player-side fields are still hardcoded pending a follow-up slice — `Rank`, `BattlePoint`,
|
||||
`cardMasterName`, `fieldId`, and the per-battle RNG seed. See the spec's §Deferred plumbing
|
||||
table at `docs/superpowers/specs/2026-06-01-battle-node-real-drafted-deck-design.md` for
|
||||
what each needs.
|
||||
|
||||
## Where to extend
|
||||
|
||||
| You want to | Touch |
|
||||
|---|---|
|
||||
| Wire a new mode's `do_matching` (rank, free, open-room, …) | Add one `BuildFor<Mode>Async(viewerId, …)` method to `IMatchContextBuilder` reading that mode's deck source; the mode's controller calls `IMatchingBridge.RegisterPendingBattle(vid, ctx)`. No changes to `SVSim.BattleNode`. |
|
||||
| Add a real AI opponent | Replace the static dispatch in `BattleSession.ComputeResponses` (`TurnEnd → opponent TurnStart` case) with one that drives a decision engine. The `OutboundSequencer` already assigns `playSeq` for whatever you push. |
|
||||
| Implement recovery | `IBattleSessionStore` already keeps the pending registry. Add a per-battle archive (the `OutboundSequencer.Archive` already retains every assigned-playSeq push) and bind it to the HTTP `/battle/get_recovery_params` endpoint. |
|
||||
| Validate `battleCode` | Port `NetworkConsistency.GetConsistency` from the client decompilation. Hook into `BattleSession.HandleMsgEventAsync` on `TurnEnd` / `Judge`. |
|
||||
| Type the `orderList` register actions | Spec at `docs/api-spec/in-battle/register-actions.md` catalogs the eight shapes observed in TK2 captures. Build a discriminated union; replace `Dictionary<string, object?>` in the `Body` for the relevant URIs. |
|
||||
|
||||
## Test layout
|
||||
|
||||
```
|
||||
SVSim.UnitTests/BattleNode/
|
||||
├─ Bridge/ MatchingBridgeTests (3 tests — mint id, dedup, format)
|
||||
├─ Integration/ BattleNodeFlowTests (end-to-end via WebApplicationFactory)
|
||||
│ RawSocketIoTestClient (test helper)
|
||||
├─ Lifecycle/ ScriptedLifecycleTests (11 tests)
|
||||
├─ Protocol/ MsgEnvelopeTests (4 tests incl. number-array regression)
|
||||
│ MsgPayloadCodecTests (2 tests — roundtrip + known vector)
|
||||
├─ Reliability/ GungnirTests / InboundTrackerTests / OutboundSequencerTests
|
||||
├─ Sessions/ BattleSessionDispatchTests (8 tests — phase-state machine)
|
||||
│ InMemoryBattleSessionStoreTests
|
||||
└─ Wire/ NodeCryptoTests (with fixed-vector regression)
|
||||
EngineIoFrameTests
|
||||
SocketIoFrameTests (incl. binary attachment + JSON escaping)
|
||||
```
|
||||
|
||||
Total ~71 BattleNode-scoped tests. The integration test boots the EmulatedEntrypoint host via `SVSimTestFactory`, mints a battle through `IMatchingBridge`, opens a TestServer WebSocket, and walks the full handshake through Ready. It exercises every layer.
|
||||
|
||||
## Related docs
|
||||
|
||||
- `docs/api-spec/in-battle/transport.md` — Socket.IO + AES-for-node wire format, with smoke corrections inline.
|
||||
- `docs/api-spec/in-battle/matching.md` — `do_matching` bridge + client state machine.
|
||||
- `docs/api-spec/in-battle/server-to-client.md`, `client-to-server.md` — per-uri frame shapes.
|
||||
- `docs/api-spec/in-battle/register-actions.md` — `orderList` action catalog (for v2).
|
||||
- `docs/api-spec/in-battle/reliability.md` — pubSeq/playSeq stocking + Gungnir.
|
||||
- `docs/api-spec/in-battle/recovery.md` — the reconnect handshake (deferred to v2).
|
||||
- `docs/operations/battle-node-smoke.md` — manual end-to-end checklist.
|
||||
- `docs/operations/battle-node-smoke-walkthrough.md` — annotated walkthrough with per-step diagnostics.
|
||||
- `docs/superpowers/specs/2026-05-31-battle-node-transport-design.md` — v1 design.
|
||||
- `docs/superpowers/plans/2026-05-31-battle-node-transport.md` — v1 implementation plan.
|
||||
20
SVSim.BattleNode/Reliability/Gungnir.cs
Normal file
20
SVSim.BattleNode/Reliability/Gungnir.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace SVSim.BattleNode.Reliability;
|
||||
|
||||
/// <summary>
|
||||
/// Body builders for the alive channel. The timer/loop that drives 5s emits lives on
|
||||
/// BattleSession; this class is just the pure body-shape factory.
|
||||
/// v1 always reports scs/ocs=ONLINE — real disconnect detection is deferred. The push
|
||||
/// body itself is constructed inline in BattleSession.HandleAliveEventAsync using
|
||||
/// AlivePushBody; only the emit body (sent by us TO the client on the alive channel,
|
||||
/// currently unused in v1) remains here.
|
||||
/// </summary>
|
||||
public static class Gungnir
|
||||
{
|
||||
public static readonly TimeSpan EmitInterval = TimeSpan.FromSeconds(5);
|
||||
|
||||
public static Dictionary<string, object?> BuildAliveEmitBody(InboundTracker tracker) => new()
|
||||
{
|
||||
["currentSeq"] = tracker.HighWaterMark,
|
||||
// actionSeq omitted in v1 — no turn-transition flag yet.
|
||||
};
|
||||
}
|
||||
57
SVSim.BattleNode/Reliability/InboundTracker.cs
Normal file
57
SVSim.BattleNode/Reliability/InboundTracker.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
namespace SVSim.BattleNode.Reliability;
|
||||
|
||||
/// <summary>
|
||||
/// Per-session inbound-emit ledger. Dedupes the client's pubSeq so we never dispatch
|
||||
/// a retransmitted emit twice; ack-echo (via SIO callback) is the caller's job.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// State is bounded: the ledger keeps the most recent <see cref="WindowSize"/>
|
||||
/// pubSeqs in an LRU ring. Seqs below <c>HighWaterMark - WindowSize</c> are
|
||||
/// treated as stale-below-window and rejected without recording — this is what
|
||||
/// prevents window eviction from re-admitting an old seq as novel. The pubSeq is
|
||||
/// client-assigned monotonically; the bound is sized well above the realistic
|
||||
/// Socket.IO retransmit horizon, so legitimate retransmits always fall inside.
|
||||
/// </remarks>
|
||||
public sealed class InboundTracker
|
||||
{
|
||||
/// <summary>Sliding-window size. Anything below <c>HighWaterMark - WindowSize</c> is dropped.</summary>
|
||||
public const int WindowSize = 256;
|
||||
|
||||
private readonly HashSet<long> _seen = new(WindowSize);
|
||||
private readonly Queue<long> _order = new(WindowSize);
|
||||
|
||||
/// <summary>Highest pubSeq observed so far. Reported via Gungnir for diagnostics.</summary>
|
||||
public long HighWaterMark { get; private set; }
|
||||
|
||||
/// <summary>Record an incoming pubSeq. Returns true if the caller should dispatch the envelope, false on duplicate or stale-below-window.</summary>
|
||||
public bool Observe(long pubSeq)
|
||||
{
|
||||
// Stale-below-window guard. Required AFTER HighWaterMark is past the window,
|
||||
// otherwise an evicted ring entry would re-admit as novel.
|
||||
if (HighWaterMark > 0 && pubSeq <= HighWaterMark - WindowSize)
|
||||
return false;
|
||||
|
||||
if (pubSeq > HighWaterMark)
|
||||
{
|
||||
HighWaterMark = pubSeq;
|
||||
Record(pubSeq);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_seen.Contains(pubSeq))
|
||||
return false;
|
||||
Record(pubSeq);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void Record(long pubSeq)
|
||||
{
|
||||
if (_order.Count >= WindowSize)
|
||||
{
|
||||
var evicted = _order.Dequeue();
|
||||
_seen.Remove(evicted);
|
||||
}
|
||||
_order.Enqueue(pubSeq);
|
||||
_seen.Add(pubSeq);
|
||||
}
|
||||
}
|
||||
36
SVSim.BattleNode/Reliability/OutboundSequencer.cs
Normal file
36
SVSim.BattleNode/Reliability/OutboundSequencer.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using SVSim.BattleNode.Protocol;
|
||||
|
||||
namespace SVSim.BattleNode.Reliability;
|
||||
|
||||
/// <summary>
|
||||
/// Per-session outbound ledger. Assigns monotonic playSeq to ordered pushes and archives
|
||||
/// them for future Resume retransmit (v2). No-stock control pushes (BattleFinish/JudgeResult/Resume)
|
||||
/// are wrapped with no playSeq and skip the archive.
|
||||
/// </summary>
|
||||
public sealed class OutboundSequencer
|
||||
{
|
||||
private long _next = 1;
|
||||
private readonly Dictionary<long, MsgEnvelope> _archive = new();
|
||||
|
||||
public IReadOnlyDictionary<long, MsgEnvelope> Archive => _archive;
|
||||
|
||||
public MsgEnvelope AssignAndArchive(MsgEnvelope envelope)
|
||||
{
|
||||
var seq = _next++;
|
||||
var stamped = envelope with { PlaySeq = seq };
|
||||
_archive[seq] = stamped;
|
||||
return stamped;
|
||||
}
|
||||
|
||||
public MsgEnvelope WrapNoStock(MsgEnvelope envelope) =>
|
||||
envelope with { PlaySeq = null };
|
||||
|
||||
/// <summary>
|
||||
/// Drop all archived envelopes. Called from BattleSession's terminate cascade so
|
||||
/// the archive — the heavy state — is released the moment the battle ends, rather
|
||||
/// than waiting for the participant to be GC'd. <c>_next</c> is left untouched:
|
||||
/// a participant emitting after Clear is a bug, not a recovery case, but the seq
|
||||
/// stream stays monotonic so a stray emit doesn't silently re-use a playSeq value.
|
||||
/// </summary>
|
||||
public void Clear() => _archive.Clear();
|
||||
}
|
||||
14
SVSim.BattleNode/SVSim.BattleNode.csproj
Normal file
14
SVSim.BattleNode/SVSim.BattleNode.csproj
Normal file
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<PackageReference Include="MessagePack" Version="2.5.172" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="SVSim.UnitTests" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
13
SVSim.BattleNode/Sessions/BattleFinishReason.cs
Normal file
13
SVSim.BattleNode/Sessions/BattleFinishReason.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace SVSim.BattleNode.Sessions;
|
||||
|
||||
/// <summary>Reason a participant was terminated. Carried to
|
||||
/// <see cref="IBattleParticipant.TerminateAsync"/> so impls can log/clean differently
|
||||
/// per cause. Cleanup itself is the same regardless of reason.</summary>
|
||||
public enum BattleFinishReason
|
||||
{
|
||||
NormalFinish,
|
||||
Retire,
|
||||
OpponentDisconnect,
|
||||
Timeout,
|
||||
ServerAbort,
|
||||
}
|
||||
390
SVSim.BattleNode/Sessions/BattleSession.cs
Normal file
390
SVSim.BattleNode/Sessions/BattleSession.cs
Normal file
@@ -0,0 +1,390 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SVSim.BattleNode.Lifecycle;
|
||||
using SVSim.BattleNode.Protocol;
|
||||
using SVSim.BattleNode.Protocol.Bodies;
|
||||
using SVSim.BattleNode.Sessions.Participants;
|
||||
|
||||
namespace SVSim.BattleNode.Sessions;
|
||||
|
||||
/// <summary>
|
||||
/// v2 broker session. Holds two participants and brokers between them. Subscribes
|
||||
/// to each participant's <see cref="IBattleParticipant.FrameEmitted"/>; on each frame,
|
||||
/// runs <see cref="ComputeFrames"/> to determine the routing (target + frame + noStock
|
||||
/// flag) and dispatches via <see cref="IBattleParticipant.PushAsync"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Phase 1 wires this for <see cref="BattleType.Scripted"/> only — the dispatch logic
|
||||
/// preserves v1.2 behaviour. Phase 2 wires Pvp (broadcast Matched/BattleStart per-perspective,
|
||||
/// forward gameplay frames between participants). Phase 3 wires Bot (ack-only).
|
||||
/// </remarks>
|
||||
public sealed class BattleSession
|
||||
{
|
||||
private readonly ILogger<BattleSession> _log;
|
||||
|
||||
public string BattleId { get; }
|
||||
public BattleType Type { get; }
|
||||
public IBattleParticipant A { get; }
|
||||
public IBattleParticipant B { get; }
|
||||
public BattleSessionPhase Phase { get; private set; } = BattleSessionPhase.AwaitingInitNetwork;
|
||||
|
||||
public BattleSession(string battleId, BattleType type, IBattleParticipant a, IBattleParticipant b,
|
||||
ILogger<BattleSession> log)
|
||||
{
|
||||
BattleId = battleId;
|
||||
Type = type;
|
||||
A = a;
|
||||
B = b;
|
||||
_log = log;
|
||||
|
||||
// Subscribe to both participants' emissions.
|
||||
A.FrameEmitted += OnFrameFromA;
|
||||
B.FrameEmitted += OnFrameFromB;
|
||||
}
|
||||
|
||||
public async Task RunAsync(CancellationToken cancellation)
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellation);
|
||||
var aTask = A.RunAsync(cts.Token);
|
||||
var bTask = B.RunAsync(cts.Token);
|
||||
|
||||
if (Type == BattleType.Pvp)
|
||||
{
|
||||
// WhenAny: first WS drop / first graceful close triggers cascade.
|
||||
// ScriptedBotParticipant.RunAsync also returns immediately; that's not used
|
||||
// here (Pvp has two RealParticipants), but we'd still want a synthesized
|
||||
// BattleFinish for the survivor if either side terminates first.
|
||||
var first = await Task.WhenAny(aTask, bTask).ConfigureAwait(false);
|
||||
var survivor = first == aTask ? B : A;
|
||||
|
||||
if (Phase != BattleSessionPhase.Terminal)
|
||||
{
|
||||
// Involuntary drop (no graceful Retire): synthesize BattleFinish(Win) to survivor.
|
||||
try
|
||||
{
|
||||
await survivor.PushAsync(
|
||||
BuildBattleFinish(BattleResult.Win), noStock: true, cancellation)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning(ex,
|
||||
"BattleSession {Bid}: failed to push BattleFinish to survivor (their WS may also be closed)",
|
||||
BattleId);
|
||||
}
|
||||
Phase = BattleSessionPhase.Terminal;
|
||||
}
|
||||
|
||||
cts.Cancel(); // unblock the survivor's RunAsync read loop
|
||||
try { await Task.WhenAll(aTask, bTask).ConfigureAwait(false); }
|
||||
catch { /* swallow cancellation / WS exceptions */ }
|
||||
}
|
||||
else
|
||||
{
|
||||
// Phase 1 semantics for Scripted/Bot: wait for ALL participants. The bot's
|
||||
// RunAsync returns immediately; the session keeps running for the real one.
|
||||
try { await Task.WhenAll(aTask, bTask).ConfigureAwait(false); }
|
||||
catch { /* swallow */ }
|
||||
}
|
||||
|
||||
// Audit Md11 — release per-participant outbound archives at battle-end
|
||||
// (only RealParticipant has one; bots don't archive). Heavy state is
|
||||
// dropped synchronously here so the participant's TerminateAsync doesn't
|
||||
// need to keep the dict alive through its disposal handshake.
|
||||
if (A is RealParticipant rpA) rpA.Outbound.Clear();
|
||||
if (B is RealParticipant rpB) rpB.Outbound.Clear();
|
||||
|
||||
await Task.WhenAll(
|
||||
A.TerminateAsync(BattleFinishReason.NormalFinish),
|
||||
B.TerminateAsync(BattleFinishReason.NormalFinish))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private Task OnFrameFromA(MsgEnvelope env, CancellationToken ct) => HandleFrameAsync(A, env, ct);
|
||||
private Task OnFrameFromB(MsgEnvelope env, CancellationToken ct) => HandleFrameAsync(B, env, ct);
|
||||
|
||||
private async Task HandleFrameAsync(IBattleParticipant from, MsgEnvelope env, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var routes = ComputeFrames(from, env);
|
||||
foreach (var (target, frame, noStock) in routes)
|
||||
{
|
||||
await target.PushAsync(frame, noStock, ct);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "BattleSession {Bid}: unhandled in HandleFrameAsync", BattleId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure-logic dispatch: given an inbound frame from one participant, return the list
|
||||
/// of (target, frame, noStock) tuples the session should dispatch. Transitions
|
||||
/// <see cref="Phase"/>. Extracted so unit tests can drive the dispatch without
|
||||
/// standing up real participants.
|
||||
/// </summary>
|
||||
internal IReadOnlyList<(IBattleParticipant Target, MsgEnvelope Frame, bool NoStock)> ComputeFrames(
|
||||
IBattleParticipant from, MsgEnvelope env)
|
||||
{
|
||||
var result = new List<(IBattleParticipant, MsgEnvelope, bool)>();
|
||||
var other = ReferenceEquals(from, A) ? B : A;
|
||||
var phaseFrom = from as IHasHandshakePhase;
|
||||
|
||||
// The dispatch table only covers the Scripted-mode behaviour Phase 1 needs;
|
||||
// Phase 2 (Pvp) and Phase 3 (Bot) add the other-type branches. Handshake-phase
|
||||
// arms read the SENDER's Phase (per-participant); the session-level Phase
|
||||
// remains only for the Terminal short-circuit.
|
||||
switch (env.Uri)
|
||||
{
|
||||
case NetworkBattleUri.InitNetwork when phaseFrom?.Phase == BattleSessionPhase.AwaitingInitNetwork:
|
||||
result.Add((from, BuildAck(NetworkBattleUri.InitNetwork), true));
|
||||
phaseFrom!.Phase = BattleSessionPhase.AwaitingInitBattle;
|
||||
break;
|
||||
|
||||
// --- Phase 3 Bot arms — placed BEFORE the existing handshake arms so they
|
||||
// win pattern matching on Type == Bot. Bot mode: ack handshake, silent
|
||||
// Loaded, Judge-to-sender on TurnEnd. The rest reuse Scripted's arms
|
||||
// (Retire/Kill → BattleFinishNoContest, Swap → per-sender response,
|
||||
// default → drop). Reference: docs/api-spec/in-battle/ai-passive.md.
|
||||
//
|
||||
// Critically, do NOT push Matched or BattleStart for Bot mode. The
|
||||
// architecture spec was right about this:
|
||||
// 1. The client's MatchingInitBattle (Matching.cs:298) immediately calls
|
||||
// StartBattleLoad + GotoBattle on the IsAINetwork branch right after
|
||||
// emitting InitBattle — it does NOT wait for a wire Matched or
|
||||
// BattleStart envelope. The state-machine trigger is _initNetworkSuccess
|
||||
// (set when InitNetwork uri is received, i.e., our ack).
|
||||
// 2. Sending Matched is harmless (gated on status == Connect, which is
|
||||
// already past by the time the wire round-trip completes).
|
||||
// 3. Sending BattleStart is ACTIVELY HARMFUL: its handler at
|
||||
// Matching.cs:417 runs unconditionally and SetNetworkInfo
|
||||
// (RealTimeNetworkAgent.cs:1553-1564) overwrites OppoBattleStartInfo
|
||||
// with the wire envelope's oppoInfo. Our oppoInfo comes from
|
||||
// NoOpBotParticipant.Context placeholders (classId:0, emblemId:0,
|
||||
// etc.), corrupting the good values the client just set from the
|
||||
// HTTP /ai_<fmt>_rank_battle/start response — subsequent asset
|
||||
// loads (LoadOpponentAssets at SBattleLoad.cs:933) then look up
|
||||
// non-existent assets and silently hang on "Waiting for opponent."
|
||||
|
||||
case NetworkBattleUri.InitBattle
|
||||
when Type == BattleType.Bot && phaseFrom?.Phase == BattleSessionPhase.AwaitingInitBattle:
|
||||
// Ack only — NO Matched push.
|
||||
result.Add((from, BuildAck(NetworkBattleUri.InitBattle), true));
|
||||
phaseFrom!.Phase = BattleSessionPhase.AwaitingLoaded;
|
||||
break;
|
||||
|
||||
case NetworkBattleUri.Loaded
|
||||
when Type == BattleType.Bot && phaseFrom?.Phase == BattleSessionPhase.AwaitingLoaded:
|
||||
// Silent — no BattleStart, no Deal. The client's AINetworkBattleManager
|
||||
// populates opponent state from AIBattleStart HTTP data; pushing
|
||||
// BattleStart here overwrites that state with zeros.
|
||||
phaseFrom!.Phase = BattleSessionPhase.AwaitingSwap;
|
||||
break;
|
||||
|
||||
case NetworkBattleUri.TurnEnd
|
||||
when Type == BattleType.Bot && phaseFrom?.Phase == BattleSessionPhase.AfterReady:
|
||||
case NetworkBattleUri.TurnEndFinal
|
||||
when Type == BattleType.Bot && phaseFrom?.Phase == BattleSessionPhase.AfterReady:
|
||||
// Judge to sender ONLY (not broadcast — there's no real other side).
|
||||
// The client's JudgeOperation → ControlTurnStartPlayer flips back to
|
||||
// the local AI's turn after this Judge arrives.
|
||||
result.Add((from, BuildJudgeBroadcast(), false));
|
||||
break;
|
||||
|
||||
case NetworkBattleUri.InitBattle when phaseFrom?.Phase == BattleSessionPhase.AwaitingInitBattle:
|
||||
// Phase 1: push Matched only to the "real" participant. The session reads
|
||||
// selfInfo from from.Context and oppoInfo from other.Context (the scripted
|
||||
// bot's Context fixture preserves the prod-captured cosmetics that previously
|
||||
// lived in ScriptedProfiles).
|
||||
result.Add((from, ScriptedLifecycle.BuildMatched(
|
||||
from.Context, other.Context,
|
||||
from.ViewerId, other.ViewerId,
|
||||
BattleId, ScriptedProfiles.BattleSeed), false));
|
||||
phaseFrom!.Phase = BattleSessionPhase.AwaitingLoaded;
|
||||
break;
|
||||
|
||||
case NetworkBattleUri.Loaded when phaseFrom?.Phase == BattleSessionPhase.AwaitingLoaded:
|
||||
result.Add((from, ScriptedLifecycle.BuildBattleStart(
|
||||
from.Context, other.Context, from.ViewerId), false));
|
||||
result.Add((from, ScriptedLifecycle.BuildDeal(), false));
|
||||
phaseFrom!.Phase = BattleSessionPhase.AwaitingSwap;
|
||||
break;
|
||||
|
||||
case NetworkBattleUri.Swap when phaseFrom?.Phase == BattleSessionPhase.AwaitingSwap:
|
||||
{
|
||||
var hand = ScriptedLifecycle.ComputeHandAfterSwap(ExtractIdxList(env));
|
||||
result.Add((from, ScriptedLifecycle.BuildSwapResponse(hand), false));
|
||||
result.Add((from, ScriptedLifecycle.BuildReady(hand), false));
|
||||
phaseFrom!.Phase = BattleSessionPhase.AfterReady;
|
||||
break;
|
||||
}
|
||||
|
||||
case NetworkBattleUri.TurnEnd when phaseFrom?.Phase == BattleSessionPhase.AfterReady:
|
||||
case NetworkBattleUri.TurnEndFinal when phaseFrom?.Phase == BattleSessionPhase.AfterReady:
|
||||
if (Type == BattleType.Pvp && BothAfterReady())
|
||||
{
|
||||
// Broadcast TurnEnd + Judge to BOTH. Each client's JudgeOperation ->
|
||||
// ControlTurnStartPlayer advances the active-player state machine.
|
||||
var turnEndBroadcast = BuildTurnEndBroadcast();
|
||||
var judgeBroadcast = BuildJudgeBroadcast();
|
||||
result.Add((from, turnEndBroadcast, false));
|
||||
result.Add((other, turnEndBroadcast, false));
|
||||
result.Add((from, judgeBroadcast, false));
|
||||
result.Add((other, judgeBroadcast, false));
|
||||
}
|
||||
else if (Type == BattleType.Scripted)
|
||||
{
|
||||
// Phase 1 Scripted: forward to bot; bot fires three-frame burst back.
|
||||
result.Add((other, env, false));
|
||||
}
|
||||
// For Bot type, no-op (NoOpBot swallows; client handles its own turn end).
|
||||
break;
|
||||
|
||||
case NetworkBattleUri.Retire:
|
||||
case NetworkBattleUri.Kill:
|
||||
if (Type == BattleType.Pvp)
|
||||
{
|
||||
result.Add((from, BuildBattleFinish(BattleResult.Lose), true));
|
||||
result.Add((other, BuildBattleFinish(BattleResult.Win), true));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Scripted (and future Bot) — sender wins by default (no real opponent).
|
||||
result.Add((from, BuildBattleFinishNoContest(), true));
|
||||
}
|
||||
Phase = BattleSessionPhase.Terminal;
|
||||
break;
|
||||
|
||||
// Frames emitted by the scripted bot (TurnStart / TurnEnd / Judge) — forward
|
||||
// to the real participant. These match the v1.2 burst's three outbound pushes.
|
||||
// Pre-migration this arm only handled TurnStart/Judge because the handshake
|
||||
// TurnEnd arm above (gated on session-level Phase) also caught the bot's TurnEnd.
|
||||
// Post-migration that arm gates on the sender's per-participant Phase, which the
|
||||
// bot doesn't have, so the bot's TurnEnd now lands here.
|
||||
// The `IsRealForwardableFromScripted` guard ensures this arm matches ONLY the
|
||||
// scripted bot's emissions (sender ViewerId == FakeOpponentViewerId) — without
|
||||
// it, a TurnStart/TurnEnd/Judge from a real participant in PvP mode would match
|
||||
// here and `goto default` would skip the PvP forwarder arm below.
|
||||
case NetworkBattleUri.TurnStart when IsRealForwardableFromScripted(from, env):
|
||||
case NetworkBattleUri.TurnEnd when IsRealForwardableFromScripted(from, env):
|
||||
case NetworkBattleUri.Judge when IsRealForwardableFromScripted(from, env):
|
||||
// Generic forwarder for scripted-bot emissions. The Scripted bot's TurnStart,
|
||||
// TurnEnd, and Judge are intended for the real participant.
|
||||
result.Add((other, env, false));
|
||||
break;
|
||||
|
||||
// --- PvP gameplay forwarding (post-AfterReady).
|
||||
// Order matters: this MUST come after the FakeOpponentViewerId arms so
|
||||
// Scripted bot emissions don't fall into the PvP forwarder.
|
||||
case NetworkBattleUri.TurnStart when Type == BattleType.Pvp && BothAfterReady():
|
||||
case NetworkBattleUri.PlayActions when Type == BattleType.Pvp && BothAfterReady():
|
||||
case NetworkBattleUri.Echo when Type == BattleType.Pvp && BothAfterReady():
|
||||
case NetworkBattleUri.TurnEndActions when Type == BattleType.Pvp && BothAfterReady():
|
||||
case NetworkBattleUri.JudgeResult when Type == BattleType.Pvp && BothAfterReady():
|
||||
result.Add((other, env, false));
|
||||
break;
|
||||
|
||||
default:
|
||||
_log.LogDebug("BattleSession {Bid}: dropping uri={Uri} in phase={Phase} from vid={Vid}",
|
||||
BattleId, env.Uri, Phase, from.ViewerId);
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Phase 1: the only "scripted-bot" emissions we need to forward are the three burst
|
||||
// frames (TurnStart, TurnEnd, Judge) — and TurnEnd is already handled in the switch
|
||||
// above as a forwardable bot emission. This helper exists so the TurnStart/Judge cases
|
||||
// above only fire when the source is actually a participant (not malformed inbound).
|
||||
private static bool IsRealForwardableFromScripted(IBattleParticipant from, MsgEnvelope env)
|
||||
{
|
||||
// The bot's emitted frames carry ViewerId == FakeOpponentViewerId.
|
||||
return from.ViewerId == ScriptedLifecycle.FakeOpponentViewerId;
|
||||
}
|
||||
|
||||
// Phase 2: PvP gameplay-frame forwarding is gated on BOTH sides having completed
|
||||
// the handshake (i.e. reached AfterReady). Until then, an early TurnStart/PlayActions
|
||||
// from one side has no valid recipient.
|
||||
private bool BothAfterReady() =>
|
||||
(A as IHasHandshakePhase)?.Phase == BattleSessionPhase.AfterReady &&
|
||||
(B as IHasHandshakePhase)?.Phase == BattleSessionPhase.AfterReady;
|
||||
|
||||
private MsgEnvelope BuildAck(NetworkBattleUri uri) => new(
|
||||
uri,
|
||||
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
|
||||
Uuid: WireConstants.ServerUuid,
|
||||
Bid: null,
|
||||
Try: 0,
|
||||
Cat: EmitCategory.General,
|
||||
PubSeq: null,
|
||||
PlaySeq: null,
|
||||
Body: new ResultCodeOnlyBody());
|
||||
|
||||
private MsgEnvelope BuildBattleFinishNoContest() => new(
|
||||
NetworkBattleUri.BattleFinish,
|
||||
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
|
||||
Uuid: WireConstants.ServerUuid,
|
||||
Bid: null,
|
||||
Try: 0,
|
||||
Cat: EmitCategory.Battle,
|
||||
PubSeq: null,
|
||||
PlaySeq: null,
|
||||
Body: new BattleFinishBody(Result: BattleResult.Win));
|
||||
|
||||
private MsgEnvelope BuildTurnEndBroadcast() => new(
|
||||
NetworkBattleUri.TurnEnd,
|
||||
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
|
||||
Uuid: WireConstants.ServerUuid,
|
||||
Bid: null,
|
||||
Try: 0,
|
||||
Cat: EmitCategory.Battle,
|
||||
PubSeq: null,
|
||||
PlaySeq: null,
|
||||
Body: new TurnEndBody(TurnState: 0));
|
||||
|
||||
private MsgEnvelope BuildJudgeBroadcast() => new(
|
||||
NetworkBattleUri.Judge,
|
||||
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
|
||||
Uuid: WireConstants.ServerUuid,
|
||||
Bid: null,
|
||||
Try: 0,
|
||||
Cat: EmitCategory.Battle,
|
||||
PubSeq: null,
|
||||
PlaySeq: null,
|
||||
Body: new JudgeBody(Spin: ScriptedProfiles.OpponentJudgeSpin));
|
||||
|
||||
private MsgEnvelope BuildBattleFinish(BattleResult result) => new(
|
||||
NetworkBattleUri.BattleFinish,
|
||||
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
|
||||
Uuid: WireConstants.ServerUuid,
|
||||
Bid: null,
|
||||
Try: 0,
|
||||
Cat: EmitCategory.Battle,
|
||||
PubSeq: null,
|
||||
PlaySeq: null,
|
||||
Body: new BattleFinishBody(Result: result));
|
||||
|
||||
private static IReadOnlyList<long> ExtractIdxList(MsgEnvelope env)
|
||||
{
|
||||
if (env.Body is not RawBody rawBody) return Array.Empty<long>();
|
||||
if (rawBody.Entries.TryGetValue("idxList", out var raw) && raw is System.Collections.IEnumerable seq && raw is not string)
|
||||
{
|
||||
var result = new List<long>();
|
||||
foreach (var item in seq)
|
||||
{
|
||||
switch (item)
|
||||
{
|
||||
case long l: result.Add(l); break;
|
||||
case int i: result.Add(i); break;
|
||||
case double d: result.Add((long)d); break;
|
||||
case decimal m: result.Add((long)m); break;
|
||||
case string s when long.TryParse(s, out var p): result.Add(p); break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return Array.Empty<long>();
|
||||
}
|
||||
}
|
||||
16
SVSim.BattleNode/Sessions/BattleSessionPhase.cs
Normal file
16
SVSim.BattleNode/Sessions/BattleSessionPhase.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace SVSim.BattleNode.Sessions;
|
||||
|
||||
/// <summary>
|
||||
/// Where we are in the v1 scripted lifecycle. Drives which scripted frames the session pushes
|
||||
/// in response to inbound emits.
|
||||
/// </summary>
|
||||
public enum BattleSessionPhase
|
||||
{
|
||||
AwaitingInitNetwork,
|
||||
AwaitingInitBattle,
|
||||
AwaitingLoaded,
|
||||
AwaitingSwap,
|
||||
AfterReady,
|
||||
OpponentTurn,
|
||||
Terminal,
|
||||
}
|
||||
22
SVSim.BattleNode/Sessions/BattleType.cs
Normal file
22
SVSim.BattleNode/Sessions/BattleType.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
namespace SVSim.BattleNode.Sessions;
|
||||
|
||||
/// <summary>
|
||||
/// Discriminator for a pending battle and the session it produces. See
|
||||
/// docs/superpowers/specs/2026-06-01-battle-node-v2-architecture-design.md.
|
||||
/// </summary>
|
||||
public enum BattleType
|
||||
{
|
||||
/// <summary>Two real players. Server brokers between two WebSockets.
|
||||
/// Both <c>BattlePlayer</c> slots required.</summary>
|
||||
Pvp,
|
||||
|
||||
/// <summary>One real player; opponent runs in the client (prod's IsAINetwork
|
||||
/// path; matched only in rank rotation / rank unlimited per prod). Server is
|
||||
/// ack-only. <c>p2</c> must be null.</summary>
|
||||
Bot,
|
||||
|
||||
/// <summary>One real player; server scripts the opponent (today's v1.2
|
||||
/// behaviour, preserved as a solo testing harness). <c>p2</c> currently null;
|
||||
/// future server-driven bot config can ride on <c>p2</c>.</summary>
|
||||
Scripted,
|
||||
}
|
||||
46
SVSim.BattleNode/Sessions/IBattleParticipant.cs
Normal file
46
SVSim.BattleNode/Sessions/IBattleParticipant.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using SVSim.BattleNode.Bridge;
|
||||
using SVSim.BattleNode.Protocol;
|
||||
|
||||
namespace SVSim.BattleNode.Sessions;
|
||||
|
||||
/// <summary>
|
||||
/// One side of a battle. Two of these are held by a <c>BattleSession</c>; the session
|
||||
/// brokers between them. Concrete impls (added in subsequent Phase-1 tasks):
|
||||
/// <list type="bullet">
|
||||
/// <item><c>RealParticipant</c> — WS-backed.</item>
|
||||
/// <item><c>NoOpBotParticipant</c> — silent; for <c>BattleType.Bot</c> (AI-passive).</item>
|
||||
/// <item><c>ScriptedBotParticipant</c> — wraps the v1.2 lifecycle for
|
||||
/// <c>BattleType.Scripted</c> (solo testing harness).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public interface IBattleParticipant : IAsyncDisposable
|
||||
{
|
||||
/// <summary>Real viewer id, or a synthetic stable id for bots
|
||||
/// (<see cref="Lifecycle.ScriptedLifecycle.FakeOpponentViewerId"/>).</summary>
|
||||
long ViewerId { get; }
|
||||
|
||||
/// <summary>Per-battle MatchContext snapshot, used for building Matched/BattleStart
|
||||
/// selfInfo when this participant is "self" in the perspective.</summary>
|
||||
MatchContext Context { get; }
|
||||
|
||||
/// <summary>Session calls this to deliver a frame from the OTHER participant
|
||||
/// (or a server-synthesized broadcast). Real impl: encode + WS-send.
|
||||
/// NoOp: swallow. Scripted: may emit a response via <see cref="FrameEmitted"/>.</summary>
|
||||
/// <param name="noStock">True for control frames (BattleFinish, JudgeResult, ack);
|
||||
/// bypasses playSeq assignment + archive.</param>
|
||||
Task PushAsync(MsgEnvelope envelope, bool noStock, CancellationToken ct);
|
||||
|
||||
/// <summary>Participant fires this when it has a frame to send TO the session
|
||||
/// (its own gameplay action). Real impl: fires on WS recv. NoOp: never fires.
|
||||
/// Scripted: fires from inside PushAsync when the scripted lifecycle wants to
|
||||
/// respond to an inbound frame.</summary>
|
||||
event Func<MsgEnvelope, CancellationToken, Task>? FrameEmitted;
|
||||
|
||||
/// <summary>Drives the participant's inbound loop. For Real: the WS read loop
|
||||
/// (returns when the WS closes). For NoOp/Scripted: completes immediately (the
|
||||
/// session keeps running as long as the OTHER participant's RunAsync is alive).</summary>
|
||||
Task RunAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>Called when the battle ends. Concrete impls clean up (close WS, etc.).</summary>
|
||||
Task TerminateAsync(BattleFinishReason reason);
|
||||
}
|
||||
23
SVSim.BattleNode/Sessions/IBattleSessionStore.cs
Normal file
23
SVSim.BattleNode/Sessions/IBattleSessionStore.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
namespace SVSim.BattleNode.Sessions;
|
||||
|
||||
public interface IBattleSessionStore
|
||||
{
|
||||
/// <summary>Register a battle minted by the matching bridge, awaiting a WS connect.</summary>
|
||||
void RegisterPending(PendingBattle battle);
|
||||
|
||||
/// <summary>Look up the pending battle. Returns null if not present.</summary>
|
||||
PendingBattle? TryGetPending(string battleId);
|
||||
|
||||
/// <summary>
|
||||
/// Find a pending battle this viewer is a participant in (P1 or P2). Used by the
|
||||
/// HTTP-side <c>/ai_<fmt>/start</c> endpoint to retrieve the deck/cosmetic
|
||||
/// context the viewer registered at <c>do_matching</c> time — the <c>/start</c>
|
||||
/// request body carries no <c>deck_no</c> of its own. Returns null if the viewer
|
||||
/// has no pending battle (already consumed by WS connect, never registered, or
|
||||
/// evicted by timeout).
|
||||
/// </summary>
|
||||
PendingBattle? TryFindPendingForViewer(long viewerId);
|
||||
|
||||
/// <summary>Mark a battle as no longer pending (e.g. on successful connect or explicit close).</summary>
|
||||
bool RemovePending(string battleId);
|
||||
}
|
||||
32
SVSim.BattleNode/Sessions/InMemoryBattleSessionStore.cs
Normal file
32
SVSim.BattleNode/Sessions/InMemoryBattleSessionStore.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace SVSim.BattleNode.Sessions;
|
||||
|
||||
public sealed class InMemoryBattleSessionStore : IBattleSessionStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, PendingBattle> _pending = new();
|
||||
|
||||
public void RegisterPending(PendingBattle battle) =>
|
||||
_pending[battle.BattleId] = battle;
|
||||
|
||||
public PendingBattle? TryGetPending(string battleId) =>
|
||||
_pending.TryGetValue(battleId, out var b) ? b : null;
|
||||
|
||||
public PendingBattle? TryFindPendingForViewer(long viewerId)
|
||||
{
|
||||
// Linear scan — _pending is bounded by concurrent in-flight matches (low
|
||||
// double digits at most), so this stays cheap. Returns whichever match the
|
||||
// dictionary's enumerator yields first; in practice a viewer has at most one
|
||||
// pending battle since each /do_matching either pairs/falls-back the existing
|
||||
// slot or parks without registering.
|
||||
foreach (var b in _pending.Values)
|
||||
{
|
||||
if (b.P1.ViewerId == viewerId) return b;
|
||||
if (b.P2 is not null && b.P2.ViewerId == viewerId) return b;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public bool RemovePending(string battleId) =>
|
||||
_pending.TryRemove(battleId, out _);
|
||||
}
|
||||
35
SVSim.BattleNode/Sessions/Participants/NoOpBotParticipant.cs
Normal file
35
SVSim.BattleNode/Sessions/Participants/NoOpBotParticipant.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using SVSim.BattleNode.Bridge;
|
||||
using SVSim.BattleNode.Lifecycle;
|
||||
using SVSim.BattleNode.Protocol;
|
||||
|
||||
namespace SVSim.BattleNode.Sessions.Participants;
|
||||
|
||||
/// <summary>
|
||||
/// Silent participant — produces no frames, swallows everything pushed to it.
|
||||
/// Used as the "other" participant in <see cref="BattleType.Bot"/> sessions, where
|
||||
/// the real opponent runs in the client and the server has no opponent-side state
|
||||
/// to model. ViewerId is <see cref="ScriptedLifecycle.FakeOpponentViewerId"/>;
|
||||
/// Context is a fixed stub (irrelevant — never read because no frames are pushed
|
||||
/// to the other side).
|
||||
/// </summary>
|
||||
public sealed class NoOpBotParticipant : IBattleParticipant
|
||||
{
|
||||
public long ViewerId => ScriptedLifecycle.FakeOpponentViewerId;
|
||||
public MatchContext Context { get; } = new(
|
||||
SelfDeckCardIds: Array.Empty<long>(),
|
||||
ClassId: "0", CharaId: "0", CardMasterName: "card_master_node_10015",
|
||||
CountryCode: "", UserName: "Bot", SleeveId: "0",
|
||||
EmblemId: "0", DegreeId: "0", FieldId: 0, IsOfficial: 0,
|
||||
BattleType: 0);
|
||||
|
||||
public event Func<MsgEnvelope, CancellationToken, Task>? FrameEmitted;
|
||||
|
||||
public Task PushAsync(MsgEnvelope envelope, bool noStock, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task RunAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public Task TerminateAsync(BattleFinishReason reason) => Task.CompletedTask;
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
// Suppress unused-event warning — FrameEmitted is declared by the interface contract;
|
||||
// intentionally never invoked.
|
||||
private void Touch() => FrameEmitted?.Invoke(null!, default);
|
||||
}
|
||||
316
SVSim.BattleNode/Sessions/Participants/RealParticipant.cs
Normal file
316
SVSim.BattleNode/Sessions/Participants/RealParticipant.cs
Normal file
@@ -0,0 +1,316 @@
|
||||
using System.Net.WebSockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SVSim.BattleNode.Bridge;
|
||||
using SVSim.BattleNode.Protocol;
|
||||
using SVSim.BattleNode.Protocol.Bodies;
|
||||
using SVSim.BattleNode.Reliability;
|
||||
using SVSim.BattleNode.Wire;
|
||||
|
||||
namespace SVSim.BattleNode.Sessions.Participants;
|
||||
|
||||
/// <summary>
|
||||
/// Marker interface implemented by participants that own a handshake-phase cursor.
|
||||
/// <see cref="BattleSession.ComputeFrames"/> reads the sender's <see cref="Phase"/>
|
||||
/// when gating the handshake-phase arms (InitNetwork / InitBattle / Loaded / Swap)
|
||||
/// and the TurnEnd-AfterReady forwarder. Bots don't implement this — they never
|
||||
/// send the gating URIs.
|
||||
/// </summary>
|
||||
internal interface IHasHandshakePhase
|
||||
{
|
||||
BattleSessionPhase Phase { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WS-backed participant. Owns the WS read loop, SIO encoding/decoding, per-WS
|
||||
/// <see cref="OutboundSequencer"/> + <see cref="InboundTracker"/>. Fires
|
||||
/// <see cref="FrameEmitted"/> on each deduplicated inbound <see cref="MsgEnvelope"/>.
|
||||
/// PushAsync encodes + sends; ordered pushes get a playSeq from the sequencer,
|
||||
/// no-stock control pushes bypass it.
|
||||
/// </summary>
|
||||
public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
|
||||
{
|
||||
private readonly WebSocket _ws;
|
||||
private readonly ILogger<RealParticipant> _log;
|
||||
private CancellationToken _sessionCt;
|
||||
|
||||
public long ViewerId { get; }
|
||||
public MatchContext Context { get; }
|
||||
public InboundTracker Inbound { get; } = new();
|
||||
public OutboundSequencer Outbound { get; } = new();
|
||||
|
||||
/// <summary>Per-side handshake progression. Session reads this when gating
|
||||
/// handshake-phase synthesis (Matched / BattleStart / Deal / Swap response /
|
||||
/// Ready). Session transitions via the setter after dispatch. Defaults to
|
||||
/// AwaitingInitNetwork; only RealParticipant tracks this — bots have no phase
|
||||
/// because they never send the gating URIs. Also satisfies
|
||||
/// <see cref="IHasHandshakePhase"/> (the interface BattleSession uses to gate
|
||||
/// handshake dispatch without depending on the concrete RealParticipant type).</summary>
|
||||
internal BattleSessionPhase Phase { get; set; } = BattleSessionPhase.AwaitingInitNetwork;
|
||||
|
||||
BattleSessionPhase IHasHandshakePhase.Phase
|
||||
{
|
||||
get => Phase;
|
||||
set => Phase = value;
|
||||
}
|
||||
|
||||
public event Func<MsgEnvelope, CancellationToken, Task>? FrameEmitted;
|
||||
|
||||
private readonly TaskCompletionSource<bool> _sessionFinished
|
||||
= new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
/// <summary>Called by the second arriver's handler (in a finally block) after
|
||||
/// session.RunAsync completes. Signals the first arriver's handler that it can
|
||||
/// return and let the HTTP request complete (which closes the WS).</summary>
|
||||
internal void MarkSessionFinished() => _sessionFinished.TrySetResult(true);
|
||||
|
||||
/// <summary>Awaited by the first arriver's handler instead of calling RunAsync
|
||||
/// (the session already calls RunAsync on this instance from the second arriver's
|
||||
/// handler context — calling it twice would race the WS read loop). Returns when
|
||||
/// either MarkSessionFinished fires or the passed CT cancels.</summary>
|
||||
internal Task AwaitSessionFinishedAsync(CancellationToken ct)
|
||||
{
|
||||
if (_sessionFinished.Task.IsCompleted) return _sessionFinished.Task;
|
||||
var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var reg = ct.Register(() => tcs.TrySetCanceled(ct));
|
||||
_sessionFinished.Task.ContinueWith(t =>
|
||||
{
|
||||
reg.Dispose();
|
||||
if (t.IsCompletedSuccessfully) tcs.TrySetResult(true);
|
||||
else if (t.IsFaulted) tcs.TrySetException(t.Exception!.InnerExceptions);
|
||||
else tcs.TrySetCanceled();
|
||||
}, TaskContinuationOptions.ExecuteSynchronously);
|
||||
return tcs.Task;
|
||||
}
|
||||
|
||||
public RealParticipant(WebSocket ws, long viewerId, MatchContext context,
|
||||
ILogger<RealParticipant> log)
|
||||
{
|
||||
_ws = ws;
|
||||
_log = log;
|
||||
ViewerId = viewerId;
|
||||
Context = context;
|
||||
}
|
||||
|
||||
public async Task RunAsync(CancellationToken cancellation)
|
||||
{
|
||||
_sessionCt = cancellation;
|
||||
await SendEioOpenAsync(cancellation);
|
||||
|
||||
var buffer = new byte[8192];
|
||||
var pendingAttachments = new List<byte[]>();
|
||||
SocketIoFrame? pendingFrame = null;
|
||||
|
||||
while (_ws.State == WebSocketState.Open && !cancellation.IsCancellationRequested)
|
||||
{
|
||||
var msg = await ReadCompleteMessageAsync(buffer, cancellation);
|
||||
if (msg is null) break;
|
||||
|
||||
if (msg.Value.IsText)
|
||||
{
|
||||
var text = Encoding.UTF8.GetString(msg.Value.Bytes);
|
||||
if (text.Length == 0) continue;
|
||||
var eio = EngineIoFrame.Parse(text);
|
||||
if (eio.Type == EngineIoPacketType.Ping)
|
||||
{
|
||||
await SendTextAsync("3", cancellation);
|
||||
continue;
|
||||
}
|
||||
if (eio.Type != EngineIoPacketType.Message) continue;
|
||||
|
||||
var sio = SocketIoFrame.Parse(eio.Payload);
|
||||
if (sio.AttachmentCount > 0)
|
||||
{
|
||||
pendingFrame = sio;
|
||||
pendingAttachments.Clear();
|
||||
continue;
|
||||
}
|
||||
await DispatchSocketIo(sio);
|
||||
}
|
||||
else
|
||||
{
|
||||
var bin = msg.Value.Bytes;
|
||||
if (bin.Length > 0 && bin[0] == (byte)EngineIoPacketType.Message)
|
||||
{
|
||||
bin = bin.AsSpan(1).ToArray();
|
||||
}
|
||||
pendingAttachments.Add(bin);
|
||||
if (pendingFrame is not null && pendingAttachments.Count == pendingFrame.AttachmentCount)
|
||||
{
|
||||
var assembled = pendingFrame.WithAttachments(pendingAttachments.ToArray());
|
||||
pendingFrame = null;
|
||||
await DispatchSocketIo(assembled);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task PushAsync(MsgEnvelope envelope, bool noStock, CancellationToken ct)
|
||||
{
|
||||
var stamped = noStock ? Outbound.WrapNoStock(envelope) : Outbound.AssignAndArchive(envelope);
|
||||
await EncodeAndSendAsync(stamped, WireConstants.SynchronizeEvent, ct);
|
||||
}
|
||||
|
||||
public Task TerminateAsync(BattleFinishReason reason)
|
||||
{
|
||||
// WS will close via the read loop exiting; nothing to do here.
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (_ws.State == WebSocketState.Open || _ws.State == WebSocketState.CloseReceived)
|
||||
{
|
||||
try { _ws.Abort(); } catch { /* best effort */ }
|
||||
}
|
||||
_ws.Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task DispatchSocketIo(SocketIoFrame frame)
|
||||
{
|
||||
if (frame.Type is SocketIoPacketType.Event or SocketIoPacketType.BinaryEvent)
|
||||
{
|
||||
switch (frame.EventName)
|
||||
{
|
||||
case WireConstants.MsgEvent when frame.BinaryAttachments.Count == 1:
|
||||
await HandleMsgEventAsync(frame);
|
||||
return;
|
||||
case WireConstants.AliveEvent when frame.BinaryAttachments.Count == 1:
|
||||
await HandleAliveEventAsync(frame);
|
||||
return;
|
||||
}
|
||||
}
|
||||
_log.LogDebug("RealParticipant viewer={Vid}: dropping SIO event={Event}", ViewerId, frame.EventName);
|
||||
}
|
||||
|
||||
private async Task HandleMsgEventAsync(SocketIoFrame frame)
|
||||
{
|
||||
try
|
||||
{
|
||||
MsgEnvelope env;
|
||||
try { env = MsgPayloadCodec.Decode(frame.BinaryAttachments[0]); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning(ex, "RealParticipant viewer={Vid}: failed to decode msg envelope", ViewerId);
|
||||
return;
|
||||
}
|
||||
|
||||
bool shouldDispatch = true;
|
||||
if (env.PubSeq.HasValue)
|
||||
{
|
||||
shouldDispatch = Inbound.Observe(env.PubSeq.Value);
|
||||
if (frame.AckId.HasValue)
|
||||
{
|
||||
await SendSioAckAsync(frame.AckId.Value, env.PubSeq.Value);
|
||||
}
|
||||
}
|
||||
if (!shouldDispatch) return;
|
||||
|
||||
if (FrameEmitted is not null)
|
||||
{
|
||||
await FrameEmitted.Invoke(env, _sessionCt);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "RealParticipant viewer={Vid}: unhandled in HandleMsgEventAsync", ViewerId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleAliveEventAsync(SocketIoFrame frame)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (frame.AckId.HasValue)
|
||||
{
|
||||
await SendSioAckAsync(frame.AckId.Value, 0);
|
||||
}
|
||||
var aliveEnv = new MsgEnvelope(
|
||||
Uri: NetworkBattleUri.Gungnir,
|
||||
ViewerId: SVSim.BattleNode.Lifecycle.ScriptedLifecycle.FakeOpponentViewerId,
|
||||
Uuid: WireConstants.ServerUuid,
|
||||
Bid: null,
|
||||
Try: 0,
|
||||
Cat: EmitCategory.General,
|
||||
PubSeq: null,
|
||||
PlaySeq: null,
|
||||
Body: new AlivePushBody(Scs: WireConstants.OnlineStatus, Ocs: WireConstants.OnlineStatus));
|
||||
var stamped = Outbound.WrapNoStock(aliveEnv);
|
||||
await EncodeAndSendAsync(stamped, WireConstants.AliveEvent, _sessionCt);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "RealParticipant viewer={Vid}: unhandled in HandleAliveEventAsync", ViewerId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EncodeAndSendAsync(MsgEnvelope env, string eventName, CancellationToken ct)
|
||||
{
|
||||
var key = NodeCrypto.GenerateKey(() => RandomNumberGenerator.GetInt32(0, 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, ct);
|
||||
foreach (var bin in bins)
|
||||
{
|
||||
var prefixed = new byte[bin.Length + 1];
|
||||
prefixed[0] = (byte)EngineIoPacketType.Message;
|
||||
Buffer.BlockCopy(bin, 0, prefixed, 1, bin.Length);
|
||||
await _ws.SendAsync(prefixed, WebSocketMessageType.Binary, endOfMessage: true, ct);
|
||||
}
|
||||
}
|
||||
|
||||
internal static int ClipAckArg(long arg, ILogger log, long viewerId)
|
||||
{
|
||||
if (arg > int.MaxValue)
|
||||
{
|
||||
log.LogWarning("RealParticipant viewer={Vid}: pubSeq {Seq} exceeds int.MaxValue; clipping.", viewerId, arg);
|
||||
return int.MaxValue;
|
||||
}
|
||||
if (arg < int.MinValue)
|
||||
{
|
||||
log.LogWarning("RealParticipant viewer={Vid}: pubSeq {Seq} below int.MinValue; clipping.", viewerId, arg);
|
||||
return int.MinValue;
|
||||
}
|
||||
return (int)arg;
|
||||
}
|
||||
|
||||
private async Task SendSioAckAsync(int ackId, long arg)
|
||||
{
|
||||
var ack = SocketIoFrame.AckResponse(ackId, ClipAckArg(arg, _log, ViewerId));
|
||||
var (text, _) = ack.Encode();
|
||||
var eioText = $"{(int)EngineIoPacketType.Message}{text}";
|
||||
await SendTextAsync(eioText, _sessionCt);
|
||||
}
|
||||
|
||||
private async Task SendEioOpenAsync(CancellationToken ct)
|
||||
{
|
||||
var sid = Guid.NewGuid().ToString("N").Substring(0, 16);
|
||||
var handshake = new EngineIoHandshake(sid, Array.Empty<string>(), 25000, 60000).ToJson();
|
||||
await SendTextAsync($"0{handshake}", ct);
|
||||
}
|
||||
|
||||
private Task SendTextAsync(string text, CancellationToken ct)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(text);
|
||||
return _ws.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, ct);
|
||||
}
|
||||
|
||||
private async Task<(byte[] Bytes, bool IsText)?> ReadCompleteMessageAsync(byte[] buffer, CancellationToken ct)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
WebSocketReceiveResult result;
|
||||
do
|
||||
{
|
||||
try { result = await _ws.ReceiveAsync(buffer, ct); }
|
||||
catch (OperationCanceledException) { return null; }
|
||||
catch (WebSocketException) { return null; }
|
||||
if (result.MessageType == WebSocketMessageType.Close) return null;
|
||||
ms.Write(buffer, 0, result.Count);
|
||||
} while (!result.EndOfMessage);
|
||||
return (ms.ToArray(), result.MessageType == WebSocketMessageType.Text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Linq;
|
||||
using SVSim.BattleNode.Bridge;
|
||||
using SVSim.BattleNode.Lifecycle;
|
||||
using SVSim.BattleNode.Protocol;
|
||||
|
||||
namespace SVSim.BattleNode.Sessions.Participants;
|
||||
|
||||
/// <summary>
|
||||
/// Server-scripted opponent (today's v1.2 testing-harness behavior, repackaged).
|
||||
/// On <see cref="PushAsync"/> with <c>TurnEnd</c> or <c>TurnEndFinal</c>, fires
|
||||
/// <see cref="FrameEmitted"/> three times: <c>OpponentTurnStart</c>,
|
||||
/// <c>OpponentTurnEnd</c>, <c>OpponentJudge</c>. All other URIs are swallowed
|
||||
/// (no opponent reaction needed for v1.2 behavior).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ViewerId, Context are fixtures matching <see cref="ScriptedLifecycle.FakeOpponentViewerId"/>
|
||||
/// and a scripted opponent profile. The Context fixture is the source of truth for the
|
||||
/// scripted opponent's half of Matched and BattleStart (cosmetics, deck count, class/chara) —
|
||||
/// <see cref="BattleSession.ComputeFrames"/> reads <c>other.Context</c> for those frames.
|
||||
/// Deal still uses fixed scripted frames that ignore Context.
|
||||
/// </remarks>
|
||||
public sealed class ScriptedBotParticipant : IBattleParticipant
|
||||
{
|
||||
public long ViewerId => ScriptedLifecycle.FakeOpponentViewerId;
|
||||
public MatchContext Context { get; } = new(
|
||||
// 30 dummy card ids so oppoCtx.SelfDeckCardIds.Count == 30 (matches the
|
||||
// hardcoded OppoDeckCount that ScriptedProfiles.OpponentMatchedProfile shipped).
|
||||
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 0L).ToList(),
|
||||
// BattleStart opponent half: ClassId/CharaId from ScriptedProfiles.OpponentBattleStartProfile.
|
||||
ClassId: "8", CharaId: "8", CardMasterName: "card_master_node_10015",
|
||||
// Matched opponent half: cosmetic fields from ScriptedProfiles.OpponentMatchedProfile.
|
||||
CountryCode: "JPN", UserName: "Opponent", SleeveId: "704141010",
|
||||
EmblemId: "400001100", DegreeId: "120027", FieldId: 5, IsOfficial: 0,
|
||||
BattleType: 0);
|
||||
|
||||
public event Func<MsgEnvelope, CancellationToken, Task>? FrameEmitted;
|
||||
|
||||
public async Task PushAsync(MsgEnvelope envelope, bool noStock, CancellationToken ct)
|
||||
{
|
||||
// v1.2 behavior: react to the player's TurnEnd / TurnEndFinal with the
|
||||
// three-frame burst. Everything else is silently swallowed.
|
||||
if (envelope.Uri is NetworkBattleUri.TurnEnd or NetworkBattleUri.TurnEndFinal)
|
||||
{
|
||||
await EmitAsync(ScriptedLifecycle.BuildOpponentTurnStart(), ct).ConfigureAwait(false);
|
||||
await EmitAsync(ScriptedLifecycle.BuildOpponentTurnEnd(), ct).ConfigureAwait(false);
|
||||
await EmitAsync(ScriptedLifecycle.BuildOpponentJudge(), ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public Task RunAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public Task TerminateAsync(BattleFinishReason reason) => Task.CompletedTask;
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
private Task EmitAsync(MsgEnvelope env, CancellationToken ct) =>
|
||||
FrameEmitted?.Invoke(env, ct) ?? Task.CompletedTask;
|
||||
}
|
||||
10
SVSim.BattleNode/Sessions/PendingBattle.cs
Normal file
10
SVSim.BattleNode/Sessions/PendingBattle.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using SVSim.BattleNode.Bridge;
|
||||
|
||||
namespace SVSim.BattleNode.Sessions;
|
||||
|
||||
/// <summary>
|
||||
/// Sparse pre-connect record. Carries the battle type + one or two players. The
|
||||
/// WebSocket handler reads this to validate the incoming WS connect and to
|
||||
/// construct the right participants.
|
||||
/// </summary>
|
||||
public sealed record PendingBattle(string BattleId, BattleType Type, BattlePlayer P1, BattlePlayer? P2);
|
||||
21
SVSim.BattleNode/Wire/EngineIoFrame.cs
Normal file
21
SVSim.BattleNode/Wire/EngineIoFrame.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
namespace SVSim.BattleNode.Wire;
|
||||
|
||||
/// <summary>
|
||||
/// Engine.IO v3 packet in WebSocket transport mode. Wire form: <c><digit><payload></c>.
|
||||
/// </summary>
|
||||
public sealed record EngineIoFrame(EngineIoPacketType Type, string Payload)
|
||||
{
|
||||
public static EngineIoFrame Parse(string raw)
|
||||
{
|
||||
if (string.IsNullOrEmpty(raw))
|
||||
throw new ArgumentException("Empty EIO frame", nameof(raw));
|
||||
var typeChar = raw[0];
|
||||
if (typeChar < '0' || typeChar > '6')
|
||||
throw new ArgumentException($"Invalid EIO type char '{typeChar}'", nameof(raw));
|
||||
var type = (EngineIoPacketType)(typeChar - '0');
|
||||
var payload = raw.Length > 1 ? raw.Substring(1) : string.Empty;
|
||||
return new EngineIoFrame(type, payload);
|
||||
}
|
||||
|
||||
public string Encode() => $"{(int)Type}{Payload}";
|
||||
}
|
||||
22
SVSim.BattleNode/Wire/EngineIoHandshake.cs
Normal file
22
SVSim.BattleNode/Wire/EngineIoHandshake.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.BattleNode.Wire;
|
||||
|
||||
/// <summary>
|
||||
/// Payload of an EIO3 Open packet. Sent by the server to the client immediately after the WS upgrade.
|
||||
/// </summary>
|
||||
public sealed record EngineIoHandshake(
|
||||
[property: JsonPropertyName("sid")] string Sid,
|
||||
[property: JsonPropertyName("upgrades")] string[] Upgrades,
|
||||
[property: JsonPropertyName("pingInterval")] int PingInterval,
|
||||
[property: JsonPropertyName("pingTimeout")] int PingTimeout)
|
||||
{
|
||||
// Wire-key casing here is bare camelCase — NOT EmulatedEntrypoint's snake_case policy.
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
public string ToJson() => JsonSerializer.Serialize(this, Options);
|
||||
}
|
||||
12
SVSim.BattleNode/Wire/EngineIoPacketType.cs
Normal file
12
SVSim.BattleNode/Wire/EngineIoPacketType.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace SVSim.BattleNode.Wire;
|
||||
|
||||
public enum EngineIoPacketType
|
||||
{
|
||||
Open = 0,
|
||||
Close = 1,
|
||||
Ping = 2,
|
||||
Pong = 3,
|
||||
Message = 4,
|
||||
Upgrade = 5,
|
||||
Noop = 6,
|
||||
}
|
||||
79
SVSim.BattleNode/Wire/NodeCrypto.cs
Normal file
79
SVSim.BattleNode/Wire/NodeCrypto.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace SVSim.BattleNode.Wire;
|
||||
|
||||
/// <summary>
|
||||
/// AES-256-CBC encrypt/decrypt for the node socket channel. Port of
|
||||
/// Cryptographer.EncryptRJ256ForNode / DecryptRJ256ForNode in the decompilation.
|
||||
/// Key is prepended to ciphertext (cleartext); IV is the first 16 chars of the key.
|
||||
/// </summary>
|
||||
public static class NodeCrypto
|
||||
{
|
||||
/// <summary>
|
||||
/// Generate a fresh 32-char key for server-initiated encryption.
|
||||
/// Calls <paramref name="randHexDigit"/> 32 times; the result is masked with
|
||||
/// <c>& 0xF</c> so a misbehaving caller that returns a larger int still produces
|
||||
/// exactly one hex digit per iteration (the internal contract is "32 hex chars").
|
||||
/// The 32-char ASCII string is then base64-encoded and truncated to 32 chars.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Differs from the client's <c>Cryptographer.generateKeyString</c> in input shape:
|
||||
/// the client uses <c>Random.Next(0, 65535).ToString("x")</c> per iteration (1–4 hex
|
||||
/// chars each). The output distribution is therefore different, but both produce a
|
||||
/// valid 32-char UTF-8 AES-256 key — and the client never validates the server's key
|
||||
/// since the server is decrypt-only in practice. Server-initiated encryption (e.g.
|
||||
/// for <c>synchronize</c> pushes) uses this method.
|
||||
/// </remarks>
|
||||
public static string GenerateKey(Func<int> randHexDigit)
|
||||
{
|
||||
var sb = new StringBuilder(32);
|
||||
for (var i = 0; i < 32; i++)
|
||||
{
|
||||
sb.Append((randHexDigit() & 0xF).ToString("x"));
|
||||
}
|
||||
var ascii = Encoding.ASCII.GetBytes(sb.ToString());
|
||||
return Convert.ToBase64String(ascii).Substring(0, 32);
|
||||
}
|
||||
|
||||
/// <summary>Encrypt: returns key + base64(AES-256-CBC(plain)).</summary>
|
||||
public static string EncryptForNode(string plaintext, string key)
|
||||
{
|
||||
if (key.Length != 32)
|
||||
throw new ArgumentException($"Key must be exactly 32 chars, got {key.Length}", nameof(key));
|
||||
using var aes = BuildAes(key);
|
||||
using var encryptor = aes.CreateEncryptor();
|
||||
var plainBytes = Encoding.UTF8.GetBytes(plaintext);
|
||||
var cipherBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length);
|
||||
return key + Convert.ToBase64String(cipherBytes);
|
||||
}
|
||||
|
||||
/// <summary>Decrypt: input[0..32] is key, input[32..] is base64(ciphertext).</summary>
|
||||
public static string DecryptForNode(string encrypted)
|
||||
{
|
||||
if (encrypted.Length < 32)
|
||||
throw new ArgumentException("Encrypted blob is shorter than the 32-char key prefix", nameof(encrypted));
|
||||
var key = encrypted.Substring(0, 32);
|
||||
var cipherBytes = Convert.FromBase64String(encrypted.Substring(32));
|
||||
using var aes = BuildAes(key);
|
||||
using var decryptor = aes.CreateDecryptor();
|
||||
var plainBytes = decryptor.TransformFinalBlock(cipherBytes, 0, cipherBytes.Length);
|
||||
return Encoding.UTF8.GetString(plainBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configure an AES-256-CBC instance with the node's IV derivation (first 16 chars
|
||||
/// of the key, UTF-8). Callers own disposal. Assumes <paramref name="key"/> is the
|
||||
/// 32-char ASCII key the encrypt / decrypt path has already validated.
|
||||
/// </summary>
|
||||
private static Aes BuildAes(string key)
|
||||
{
|
||||
var aes = Aes.Create();
|
||||
aes.KeySize = 256;
|
||||
aes.Mode = CipherMode.CBC;
|
||||
aes.Padding = PaddingMode.PKCS7;
|
||||
aes.Key = Encoding.UTF8.GetBytes(key);
|
||||
aes.IV = Encoding.UTF8.GetBytes(key.Substring(0, 16));
|
||||
return aes;
|
||||
}
|
||||
}
|
||||
208
SVSim.BattleNode/Wire/SocketIoFrame.cs
Normal file
208
SVSim.BattleNode/Wire/SocketIoFrame.cs
Normal file
@@ -0,0 +1,208 @@
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace SVSim.BattleNode.Wire;
|
||||
|
||||
file static class SocketIoJsonOptions
|
||||
{
|
||||
internal static readonly JsonSerializerOptions EventNameOptions = new()
|
||||
{
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Socket.IO v2 packet. Wire form: <c><type><N>-<ackId?>[json-args]</c> where
|
||||
/// <c><N>-</c> appears only on binary types (5/6). For binary events/acks, the JSON contains
|
||||
/// placeholders <c>{"_placeholder":true,"num":N}</c> that index into <see cref="BinaryAttachments"/>.
|
||||
/// </summary>
|
||||
public sealed class SocketIoFrame
|
||||
{
|
||||
public SocketIoPacketType Type { get; }
|
||||
public int? AckId { get; }
|
||||
public int AttachmentCount { get; }
|
||||
public string? EventName { get; }
|
||||
public JsonElement[] RawArgs { get; }
|
||||
public IReadOnlyList<byte[]> BinaryAttachments { get; }
|
||||
|
||||
public SocketIoFrame(
|
||||
SocketIoPacketType type,
|
||||
int? ackId,
|
||||
int attachmentCount,
|
||||
string? eventName,
|
||||
JsonElement[] rawArgs,
|
||||
IReadOnlyList<byte[]> binaryAttachments)
|
||||
{
|
||||
Type = type;
|
||||
AckId = ackId;
|
||||
AttachmentCount = attachmentCount;
|
||||
EventName = eventName;
|
||||
RawArgs = rawArgs;
|
||||
BinaryAttachments = binaryAttachments;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse the text portion of a SIO frame. For binary events the attachments arrive as separate
|
||||
/// WS frames after the text — the caller wires them up via <see cref="WithAttachments"/>.
|
||||
/// </summary>
|
||||
public static SocketIoFrame Parse(string raw)
|
||||
{
|
||||
if (string.IsNullOrEmpty(raw))
|
||||
throw new ArgumentException("Empty SIO payload", nameof(raw));
|
||||
|
||||
var type = (SocketIoPacketType)(raw[0] - '0');
|
||||
var cursor = 1;
|
||||
|
||||
var attachmentCount = 0;
|
||||
if (type is SocketIoPacketType.BinaryEvent or SocketIoPacketType.BinaryAck)
|
||||
{
|
||||
var dashIdx = raw.IndexOf('-', cursor);
|
||||
if (dashIdx < 0)
|
||||
throw new ArgumentException("Binary frame missing '-' separator", nameof(raw));
|
||||
if (!int.TryParse(raw.AsSpan(cursor, dashIdx - cursor), out attachmentCount))
|
||||
throw new ArgumentException("Binary frame attachment count not parseable", nameof(raw));
|
||||
cursor = dashIdx + 1;
|
||||
}
|
||||
|
||||
// Namespace prefix (only present if '/' starts here, terminated by ','). v1 only
|
||||
// uses the default namespace; anything else is a protocol surprise we should
|
||||
// surface rather than silently route to default. If we ever support non-default
|
||||
// namespaces, capture into a property and let callers branch.
|
||||
if (cursor < raw.Length && raw[cursor] == '/')
|
||||
{
|
||||
var commaIdx = raw.IndexOf(',', cursor);
|
||||
var ns = commaIdx >= 0 ? raw.Substring(cursor, commaIdx - cursor) : raw.Substring(cursor);
|
||||
throw new ArgumentException(
|
||||
$"Socket.IO namespaces aren't supported — got '{ns}'. v1 expects default namespace only.",
|
||||
nameof(raw));
|
||||
}
|
||||
|
||||
int? ackId = null;
|
||||
if (cursor < raw.Length && char.IsDigit(raw[cursor]))
|
||||
{
|
||||
var start = cursor;
|
||||
while (cursor < raw.Length && char.IsDigit(raw[cursor])) cursor++;
|
||||
ackId = int.Parse(raw.AsSpan(start, cursor - start));
|
||||
}
|
||||
|
||||
var argsJson = cursor < raw.Length ? raw.Substring(cursor) : string.Empty;
|
||||
JsonElement[] allElements;
|
||||
if (string.IsNullOrEmpty(argsJson))
|
||||
{
|
||||
allElements = Array.Empty<JsonElement>();
|
||||
}
|
||||
else
|
||||
{
|
||||
using var doc = JsonDocument.Parse(argsJson);
|
||||
allElements = doc.RootElement.EnumerateArray().Select(el => el.Clone()).ToArray();
|
||||
}
|
||||
|
||||
string? eventName = null;
|
||||
JsonElement[] rawArgs;
|
||||
if (type is SocketIoPacketType.Event or SocketIoPacketType.BinaryEvent && allElements.Length > 0)
|
||||
{
|
||||
eventName = allElements[0].GetString();
|
||||
// RawArgs excludes the leading event-name element so callers index args from 0.
|
||||
rawArgs = allElements.Length > 1 ? allElements[1..] : Array.Empty<JsonElement>();
|
||||
}
|
||||
else
|
||||
{
|
||||
rawArgs = allElements;
|
||||
}
|
||||
|
||||
return new SocketIoFrame(type, ackId, attachmentCount, eventName, rawArgs, Array.Empty<byte[]>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return a new frame with the given binary attachments attached. Throws if the count doesn't
|
||||
/// match the header's declared attachment count.
|
||||
/// </summary>
|
||||
public SocketIoFrame WithAttachments(IReadOnlyList<byte[]> attachments)
|
||||
{
|
||||
if (attachments.Count != AttachmentCount)
|
||||
throw new ArgumentException(
|
||||
$"Attachment count mismatch: header says {AttachmentCount}, got {attachments.Count}");
|
||||
return new SocketIoFrame(Type, AckId, AttachmentCount, EventName, RawArgs, attachments);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a binary event frame for the given event name + binary attachments.
|
||||
/// The JSON args become <c>[eventName, {_placeholder:true,num:0}, {_placeholder:true,num:1}, ...]</c>.
|
||||
/// </summary>
|
||||
public static SocketIoFrame BinaryEventWithAttachments(string eventName, IReadOnlyList<byte[]> attachments)
|
||||
{
|
||||
// Build placeholders via the typed Nodes API; event name is stored separately.
|
||||
var placeholders = new JsonArray();
|
||||
for (var i = 0; i < attachments.Count; i++)
|
||||
{
|
||||
placeholders.Add(new JsonObject
|
||||
{
|
||||
["_placeholder"] = true,
|
||||
["num"] = i,
|
||||
});
|
||||
}
|
||||
|
||||
return new SocketIoFrame(
|
||||
SocketIoPacketType.BinaryEvent,
|
||||
ackId: null,
|
||||
attachmentCount: attachments.Count,
|
||||
eventName: eventName,
|
||||
rawArgs: NodesToElements(placeholders),
|
||||
binaryAttachments: attachments);
|
||||
}
|
||||
|
||||
/// <summary>Build an ack response with a single int argument (the spec's pubSeq echo).</summary>
|
||||
public static SocketIoFrame AckResponse(int ackId, int arg)
|
||||
{
|
||||
var args = new JsonArray { arg };
|
||||
return new SocketIoFrame(
|
||||
SocketIoPacketType.Ack, ackId, 0, null, NodesToElements(args), Array.Empty<byte[]>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert a <see cref="JsonArray"/> into the <see cref="JsonElement"/>[] that
|
||||
/// <see cref="RawArgs"/> stores. The current storage type is <see cref="JsonElement"/>
|
||||
/// because <see cref="Parse"/> produces it from <see cref="JsonDocument"/>; this helper
|
||||
/// keeps the typed-construction call sites without changing <see cref="RawArgs"/>.
|
||||
/// </summary>
|
||||
private static JsonElement[] NodesToElements(JsonArray nodes)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(nodes.ToJsonString());
|
||||
return doc.RootElement.EnumerateArray().Select(el => el.Clone()).ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encode to the wire form: (text payload, ordered list of binary attachments).
|
||||
/// The caller is responsible for sending the text frame first then each binary attachment frame.
|
||||
/// </summary>
|
||||
public (string Text, IReadOnlyList<byte[]> Binaries) Encode()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append((int)Type);
|
||||
if (Type is SocketIoPacketType.BinaryEvent or SocketIoPacketType.BinaryAck)
|
||||
{
|
||||
sb.Append(AttachmentCount).Append('-');
|
||||
}
|
||||
if (AckId.HasValue) sb.Append(AckId.Value);
|
||||
// Re-serialize args — for event/binary-event types, re-prepend the event name.
|
||||
bool hasJsonPayload = EventName is not null || RawArgs.Length > 0;
|
||||
if (hasJsonPayload)
|
||||
{
|
||||
sb.Append('[');
|
||||
if (EventName is not null)
|
||||
{
|
||||
sb.Append(JsonSerializer.Serialize(EventName, SocketIoJsonOptions.EventNameOptions));
|
||||
if (RawArgs.Length > 0) sb.Append(',');
|
||||
}
|
||||
for (var i = 0; i < RawArgs.Length; i++)
|
||||
{
|
||||
if (i > 0) sb.Append(',');
|
||||
sb.Append(RawArgs[i].GetRawText());
|
||||
}
|
||||
sb.Append(']');
|
||||
}
|
||||
return (sb.ToString(), BinaryAttachments);
|
||||
}
|
||||
}
|
||||
12
SVSim.BattleNode/Wire/SocketIoPacketType.cs
Normal file
12
SVSim.BattleNode/Wire/SocketIoPacketType.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace SVSim.BattleNode.Wire;
|
||||
|
||||
public enum SocketIoPacketType
|
||||
{
|
||||
Connect = 0,
|
||||
Disconnect = 1,
|
||||
Event = 2,
|
||||
Ack = 3,
|
||||
Error = 4,
|
||||
BinaryEvent = 5,
|
||||
BinaryAck = 6,
|
||||
}
|
||||
130
SVSim.Bootstrap/Data/seeds/bot-roster.json
Normal file
130
SVSim.Bootstrap/Data/seeds/bot-roster.json
Normal file
@@ -0,0 +1,130 @@
|
||||
[
|
||||
{
|
||||
"ai_id": 1111,
|
||||
"country_code": "JPN",
|
||||
"user_name": "Forestcraft AI",
|
||||
"sleeve_id": 704141010,
|
||||
"emblem_id": 400001100,
|
||||
"degree_id": 120027,
|
||||
"field_id": 5,
|
||||
"is_official": 0,
|
||||
"class_id": 1,
|
||||
"chara_id": 1,
|
||||
"rank": 10,
|
||||
"battle_point": 0,
|
||||
"is_master_rank": 0,
|
||||
"master_point": 0
|
||||
},
|
||||
{
|
||||
"ai_id": 1121,
|
||||
"country_code": "JPN",
|
||||
"user_name": "Swordcraft AI",
|
||||
"sleeve_id": 704141010,
|
||||
"emblem_id": 400001100,
|
||||
"degree_id": 120027,
|
||||
"field_id": 5,
|
||||
"is_official": 0,
|
||||
"class_id": 2,
|
||||
"chara_id": 2,
|
||||
"rank": 10,
|
||||
"battle_point": 0,
|
||||
"is_master_rank": 0,
|
||||
"master_point": 0
|
||||
},
|
||||
{
|
||||
"ai_id": 1131,
|
||||
"country_code": "JPN",
|
||||
"user_name": "Runecraft AI",
|
||||
"sleeve_id": 704141010,
|
||||
"emblem_id": 400001100,
|
||||
"degree_id": 120027,
|
||||
"field_id": 5,
|
||||
"is_official": 0,
|
||||
"class_id": 3,
|
||||
"chara_id": 3,
|
||||
"rank": 10,
|
||||
"battle_point": 0,
|
||||
"is_master_rank": 0,
|
||||
"master_point": 0
|
||||
},
|
||||
{
|
||||
"ai_id": 1141,
|
||||
"country_code": "JPN",
|
||||
"user_name": "Dragoncraft AI",
|
||||
"sleeve_id": 704141010,
|
||||
"emblem_id": 400001100,
|
||||
"degree_id": 120027,
|
||||
"field_id": 5,
|
||||
"is_official": 0,
|
||||
"class_id": 4,
|
||||
"chara_id": 4,
|
||||
"rank": 10,
|
||||
"battle_point": 0,
|
||||
"is_master_rank": 0,
|
||||
"master_point": 0
|
||||
},
|
||||
{
|
||||
"ai_id": 1151,
|
||||
"country_code": "JPN",
|
||||
"user_name": "Shadowcraft AI",
|
||||
"sleeve_id": 704141010,
|
||||
"emblem_id": 400001100,
|
||||
"degree_id": 120027,
|
||||
"field_id": 5,
|
||||
"is_official": 0,
|
||||
"class_id": 5,
|
||||
"chara_id": 5,
|
||||
"rank": 10,
|
||||
"battle_point": 0,
|
||||
"is_master_rank": 0,
|
||||
"master_point": 0
|
||||
},
|
||||
{
|
||||
"ai_id": 1161,
|
||||
"country_code": "JPN",
|
||||
"user_name": "Bloodcraft AI",
|
||||
"sleeve_id": 704141010,
|
||||
"emblem_id": 400001100,
|
||||
"degree_id": 120027,
|
||||
"field_id": 5,
|
||||
"is_official": 0,
|
||||
"class_id": 6,
|
||||
"chara_id": 6,
|
||||
"rank": 10,
|
||||
"battle_point": 0,
|
||||
"is_master_rank": 0,
|
||||
"master_point": 0
|
||||
},
|
||||
{
|
||||
"ai_id": 1171,
|
||||
"country_code": "JPN",
|
||||
"user_name": "Havencraft AI",
|
||||
"sleeve_id": 704141010,
|
||||
"emblem_id": 400001100,
|
||||
"degree_id": 120027,
|
||||
"field_id": 5,
|
||||
"is_official": 0,
|
||||
"class_id": 7,
|
||||
"chara_id": 7,
|
||||
"rank": 10,
|
||||
"battle_point": 0,
|
||||
"is_master_rank": 0,
|
||||
"master_point": 0
|
||||
},
|
||||
{
|
||||
"ai_id": 1181,
|
||||
"country_code": "JPN",
|
||||
"user_name": "Portalcraft AI",
|
||||
"sleeve_id": 704141010,
|
||||
"emblem_id": 400001100,
|
||||
"degree_id": 120027,
|
||||
"field_id": 5,
|
||||
"is_official": 0,
|
||||
"class_id": 8,
|
||||
"chara_id": 8,
|
||||
"rank": 10,
|
||||
"battle_point": 0,
|
||||
"is_master_rank": 0,
|
||||
"master_point": 0
|
||||
}
|
||||
]
|
||||
62
SVSim.Bootstrap/Importers/BotRosterImporter.cs
Normal file
62
SVSim.Bootstrap/Importers/BotRosterImporter.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Bootstrap.Models.Seed;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Bootstrap.Importers;
|
||||
|
||||
/// <summary>
|
||||
/// Idempotent upsert of AI bot opponents from <c>seeds/bot-roster.json</c>.
|
||||
/// Rows missing from the seed are LEFT INTACT (consistent with PracticeOpponentImporter;
|
||||
/// a partial seed shouldn't silently delete entries).
|
||||
/// </summary>
|
||||
public class BotRosterImporter
|
||||
{
|
||||
public async Task<int> ImportAsync(SVSimDbContext context, string seedDir)
|
||||
{
|
||||
string path = Path.Combine(seedDir, "bot-roster.json");
|
||||
var seed = SeedLoader.LoadList<BotRosterSeed>(path);
|
||||
if (seed.Count == 0)
|
||||
{
|
||||
Console.WriteLine("[BotRosterImporter] No seed rows; skipping.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var existing = await context.BotRoster.ToDictionaryAsync(e => e.Id);
|
||||
int created = 0, updated = 0;
|
||||
|
||||
foreach (var s in seed)
|
||||
{
|
||||
if (s.AiId == 0) continue;
|
||||
|
||||
var entry = existing.TryGetValue(s.AiId, out var ex)
|
||||
? ex : new BotRosterEntry { Id = s.AiId };
|
||||
|
||||
entry.CountryCode = s.CountryCode;
|
||||
entry.UserName = s.UserName;
|
||||
entry.SleeveId = s.SleeveId;
|
||||
entry.EmblemId = s.EmblemId;
|
||||
entry.DegreeId = s.DegreeId;
|
||||
entry.FieldId = s.FieldId;
|
||||
entry.IsOfficial = s.IsOfficial;
|
||||
entry.ClassId = s.ClassId;
|
||||
entry.CharaId = s.CharaId;
|
||||
entry.Rank = s.Rank;
|
||||
entry.BattlePoint = s.BattlePoint;
|
||||
entry.IsMasterRank = s.IsMasterRank;
|
||||
entry.MasterPoint = s.MasterPoint;
|
||||
|
||||
if (ex is null)
|
||||
{
|
||||
context.BotRoster.Add(entry);
|
||||
existing[s.AiId] = entry;
|
||||
created++;
|
||||
}
|
||||
else updated++;
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
Console.WriteLine($"[BotRosterImporter] +{created}/~{updated}");
|
||||
return created + updated;
|
||||
}
|
||||
}
|
||||
@@ -15,14 +15,30 @@ namespace SVSim.Bootstrap.Importers;
|
||||
/// </summary>
|
||||
public class ReferenceDataImporter
|
||||
{
|
||||
private readonly TextWriter _out;
|
||||
private readonly TextWriter _err;
|
||||
|
||||
public ReferenceDataImporter() : this(Console.Out, Console.Error) { }
|
||||
|
||||
/// <summary>
|
||||
/// Pass <see cref="TextWriter.Null"/> for both to silence progress banners (tests
|
||||
/// instantiate this importer ~500 times per run; the captured stdout otherwise OOMs
|
||||
/// the NUnit trx serializer).
|
||||
/// </summary>
|
||||
public ReferenceDataImporter(TextWriter output, TextWriter error)
|
||||
{
|
||||
_out = output;
|
||||
_err = error;
|
||||
}
|
||||
|
||||
public async Task ImportAllAsync(SVSimDbContext context, string dataDir)
|
||||
{
|
||||
if (!Directory.Exists(dataDir))
|
||||
{
|
||||
Console.Error.WriteLine($"[ReferenceDataImporter] Data dir missing: {dataDir}");
|
||||
_err.WriteLine($"[ReferenceDataImporter] Data dir missing: {dataDir}");
|
||||
return;
|
||||
}
|
||||
Console.WriteLine($"[ReferenceDataImporter] Reading CSVs from {dataDir}...");
|
||||
_out.WriteLine($"[ReferenceDataImporter] Reading CSVs from {dataDir}...");
|
||||
|
||||
await ImportClasses(context, dataDir);
|
||||
await ImportLeaderSkins(context, dataDir);
|
||||
@@ -34,10 +50,10 @@ public class ReferenceDataImporter
|
||||
await ImportRankInfo(context, dataDir);
|
||||
await ImportClassExp(context, dataDir);
|
||||
|
||||
Console.WriteLine("[ReferenceDataImporter] Done.");
|
||||
_out.WriteLine("[ReferenceDataImporter] Done.");
|
||||
}
|
||||
|
||||
private static async Task ImportClasses(SVSimDbContext ctx, string dir)
|
||||
private async Task ImportClasses(SVSimDbContext ctx, string dir)
|
||||
{
|
||||
var rows = ReadCsv<ClassEntry, ClassEntryMap>(dir, "classes.csv");
|
||||
var existing = await ctx.Classes.ToDictionaryAsync(c => c.Id);
|
||||
@@ -51,10 +67,10 @@ public class ReferenceDataImporter
|
||||
else { ctx.Classes.Add(r); created++; }
|
||||
}
|
||||
await ctx.SaveChangesAsync();
|
||||
Console.WriteLine($"[ReferenceDataImporter] Classes: +{created} / ~{updated}");
|
||||
_out.WriteLine($"[ReferenceDataImporter] Classes: +{created} / ~{updated}");
|
||||
}
|
||||
|
||||
private static async Task ImportLeaderSkins(SVSimDbContext ctx, string dir)
|
||||
private async Task ImportLeaderSkins(SVSimDbContext ctx, string dir)
|
||||
{
|
||||
var rows = ReadCsv<LeaderSkinEntry, LeaderSkinEntryMap>(dir, "leaderskins.csv");
|
||||
// CSV writes class_chara_id=0 for neutral/unassigned; the FK column is nullable.
|
||||
@@ -74,10 +90,10 @@ public class ReferenceDataImporter
|
||||
else { ctx.LeaderSkins.Add(r); created++; }
|
||||
}
|
||||
await ctx.SaveChangesAsync();
|
||||
Console.WriteLine($"[ReferenceDataImporter] LeaderSkins: +{created} / ~{updated}");
|
||||
_out.WriteLine($"[ReferenceDataImporter] LeaderSkins: +{created} / ~{updated}");
|
||||
}
|
||||
|
||||
private static async Task ImportSleeves(SVSimDbContext ctx, string dir)
|
||||
private async Task ImportSleeves(SVSimDbContext ctx, string dir)
|
||||
{
|
||||
var rows = ReadCsv<SleeveEntry, SleeveEntryMap>(dir, "sleeves.csv");
|
||||
var existing = (await ctx.Sleeves.ToListAsync()).ToHashSet();
|
||||
@@ -88,10 +104,10 @@ public class ReferenceDataImporter
|
||||
ctx.Sleeves.Add(r); created++;
|
||||
}
|
||||
await ctx.SaveChangesAsync();
|
||||
Console.WriteLine($"[ReferenceDataImporter] Sleeves: +{created}");
|
||||
_out.WriteLine($"[ReferenceDataImporter] Sleeves: +{created}");
|
||||
}
|
||||
|
||||
private static async Task ImportEmblems(SVSimDbContext ctx, string dir)
|
||||
private async Task ImportEmblems(SVSimDbContext ctx, string dir)
|
||||
{
|
||||
var rows = ReadCsv<EmblemEntry, EmblemEntryMap>(dir, "emblems.csv");
|
||||
var existing = (await ctx.Emblems.Select(e => e.Id).ToListAsync()).ToHashSet();
|
||||
@@ -102,10 +118,10 @@ public class ReferenceDataImporter
|
||||
ctx.Emblems.Add(r); created++;
|
||||
}
|
||||
await ctx.SaveChangesAsync();
|
||||
Console.WriteLine($"[ReferenceDataImporter] Emblems: +{created}");
|
||||
_out.WriteLine($"[ReferenceDataImporter] Emblems: +{created}");
|
||||
}
|
||||
|
||||
private static async Task ImportDegrees(SVSimDbContext ctx, string dir)
|
||||
private async Task ImportDegrees(SVSimDbContext ctx, string dir)
|
||||
{
|
||||
var rows = ReadCsv<DegreeEntry, DegreeEntryMap>(dir, "degrees.csv");
|
||||
var existing = (await ctx.Degrees.Select(e => e.Id).ToListAsync()).ToHashSet();
|
||||
@@ -116,10 +132,10 @@ public class ReferenceDataImporter
|
||||
ctx.Degrees.Add(r); created++;
|
||||
}
|
||||
await ctx.SaveChangesAsync();
|
||||
Console.WriteLine($"[ReferenceDataImporter] Degrees: +{created}");
|
||||
_out.WriteLine($"[ReferenceDataImporter] Degrees: +{created}");
|
||||
}
|
||||
|
||||
private static async Task ImportBattlefields(SVSimDbContext ctx, string dir)
|
||||
private async Task ImportBattlefields(SVSimDbContext ctx, string dir)
|
||||
{
|
||||
var rows = ReadCsv<BattlefieldEntry, BattlefieldEntryMap>(dir, "battlefields.csv");
|
||||
var existing = await ctx.Battlefields.ToDictionaryAsync(b => b.Id);
|
||||
@@ -133,10 +149,10 @@ public class ReferenceDataImporter
|
||||
else { ctx.Battlefields.Add(r); created++; }
|
||||
}
|
||||
await ctx.SaveChangesAsync();
|
||||
Console.WriteLine($"[ReferenceDataImporter] Battlefields: +{created} / ~{updated}");
|
||||
_out.WriteLine($"[ReferenceDataImporter] Battlefields: +{created} / ~{updated}");
|
||||
}
|
||||
|
||||
private static async Task ImportMyPageBackgrounds(SVSimDbContext ctx, string dir)
|
||||
private async Task ImportMyPageBackgrounds(SVSimDbContext ctx, string dir)
|
||||
{
|
||||
var rows = ReadCsv<MyPageBackgroundEntry, MyPageBackgroundEntryMap>(dir, "mypagebackgrounds.csv");
|
||||
var existing = (await ctx.MyPageBackgrounds.Select(e => e.Id).ToListAsync()).ToHashSet();
|
||||
@@ -147,10 +163,10 @@ public class ReferenceDataImporter
|
||||
ctx.MyPageBackgrounds.Add(r); created++;
|
||||
}
|
||||
await ctx.SaveChangesAsync();
|
||||
Console.WriteLine($"[ReferenceDataImporter] MyPageBackgrounds: +{created}");
|
||||
_out.WriteLine($"[ReferenceDataImporter] MyPageBackgrounds: +{created}");
|
||||
}
|
||||
|
||||
private static async Task ImportRankInfo(SVSimDbContext ctx, string dir)
|
||||
private async Task ImportRankInfo(SVSimDbContext ctx, string dir)
|
||||
{
|
||||
var rows = ReadCsv<RankInfoEntry, RankInfoEntryMap>(dir, "ranks.csv");
|
||||
var existing = await ctx.RankInfo.ToDictionaryAsync(r => r.Id);
|
||||
@@ -164,7 +180,7 @@ public class ReferenceDataImporter
|
||||
else { ctx.RankInfo.Add(r); created++; }
|
||||
}
|
||||
await ctx.SaveChangesAsync();
|
||||
Console.WriteLine($"[ReferenceDataImporter] RankInfo: +{created} / ~{updated}");
|
||||
_out.WriteLine($"[ReferenceDataImporter] RankInfo: +{created} / ~{updated}");
|
||||
}
|
||||
|
||||
private static bool ApplyRankUpdates(RankInfoEntry e, RankInfoEntry r)
|
||||
@@ -189,7 +205,7 @@ public class ReferenceDataImporter
|
||||
return changed;
|
||||
}
|
||||
|
||||
private static async Task ImportClassExp(SVSimDbContext ctx, string dir)
|
||||
private async Task ImportClassExp(SVSimDbContext ctx, string dir)
|
||||
{
|
||||
var rows = ReadCsv<ClassExpEntry, ClassExpEntryMap>(dir, "classexp.csv");
|
||||
var existing = await ctx.ClassExpCurve.ToDictionaryAsync(c => c.Id);
|
||||
@@ -203,15 +219,15 @@ public class ReferenceDataImporter
|
||||
else { ctx.ClassExpCurve.Add(r); created++; }
|
||||
}
|
||||
await ctx.SaveChangesAsync();
|
||||
Console.WriteLine($"[ReferenceDataImporter] ClassExp: +{created} / ~{updated}");
|
||||
_out.WriteLine($"[ReferenceDataImporter] ClassExp: +{created} / ~{updated}");
|
||||
}
|
||||
|
||||
private static List<T> ReadCsv<T, TMap>(string dir, string fileName) where TMap : ClassMap<T>, new()
|
||||
private List<T> ReadCsv<T, TMap>(string dir, string fileName) where TMap : ClassMap<T>, new()
|
||||
{
|
||||
string path = Path.Combine(dir, fileName);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
Console.Error.WriteLine($"[ReferenceDataImporter] Missing CSV: {path}");
|
||||
_err.WriteLine($"[ReferenceDataImporter] Missing CSV: {path}");
|
||||
return new List<T>();
|
||||
}
|
||||
using var reader = new StreamReader(path);
|
||||
|
||||
21
SVSim.Bootstrap/Models/Seed/BotRosterSeed.cs
Normal file
21
SVSim.Bootstrap/Models/Seed/BotRosterSeed.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.Bootstrap.Models.Seed;
|
||||
|
||||
public sealed class BotRosterSeed
|
||||
{
|
||||
[JsonPropertyName("ai_id")] public int AiId { get; set; }
|
||||
[JsonPropertyName("country_code")] public string CountryCode { get; set; } = "";
|
||||
[JsonPropertyName("user_name")] public string UserName { get; set; } = "";
|
||||
[JsonPropertyName("sleeve_id")] public int SleeveId { get; set; }
|
||||
[JsonPropertyName("emblem_id")] public int EmblemId { get; set; }
|
||||
[JsonPropertyName("degree_id")] public int DegreeId { get; set; }
|
||||
[JsonPropertyName("field_id")] public int FieldId { get; set; }
|
||||
[JsonPropertyName("is_official")] public int IsOfficial { get; set; }
|
||||
[JsonPropertyName("class_id")] public int ClassId { get; set; }
|
||||
[JsonPropertyName("chara_id")] public int CharaId { get; set; }
|
||||
[JsonPropertyName("rank")] public int Rank { get; set; }
|
||||
[JsonPropertyName("battle_point")] public int BattlePoint { get; set; }
|
||||
[JsonPropertyName("is_master_rank")] public int IsMasterRank { get; set; }
|
||||
[JsonPropertyName("master_point")] public int MasterPoint { get; set; }
|
||||
}
|
||||
@@ -97,6 +97,7 @@ public static class Program
|
||||
await new RotationFlagUpdater().UpdateAsync(context);
|
||||
|
||||
await new PracticeOpponentImporter().ImportAsync(context, opts.SeedDir);
|
||||
await new BotRosterImporter().ImportAsync(context, opts.SeedDir);
|
||||
await new PaymentItemImporter().ImportAsync(context, opts.SeedDir);
|
||||
await new ItemImporter().ImportAsync(context, opts.SeedDir);
|
||||
await new SleeveShopImporter().ImportAsync(context, opts.SeedDir);
|
||||
|
||||
4103
SVSim.Database/Migrations/20260602155321_AddBotRoster.Designer.cs
generated
Normal file
4103
SVSim.Database/Migrations/20260602155321_AddBotRoster.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
49
SVSim.Database/Migrations/20260602155321_AddBotRoster.cs
Normal file
49
SVSim.Database/Migrations/20260602155321_AddBotRoster.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SVSim.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddBotRoster : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "BotRoster",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false),
|
||||
AiId = table.Column<int>(type: "integer", nullable: false),
|
||||
CountryCode = table.Column<string>(type: "text", nullable: false),
|
||||
UserName = table.Column<string>(type: "text", nullable: false),
|
||||
SleeveId = table.Column<int>(type: "integer", nullable: false),
|
||||
EmblemId = table.Column<int>(type: "integer", nullable: false),
|
||||
DegreeId = table.Column<int>(type: "integer", nullable: false),
|
||||
FieldId = table.Column<int>(type: "integer", nullable: false),
|
||||
IsOfficial = table.Column<int>(type: "integer", nullable: false),
|
||||
ClassId = table.Column<int>(type: "integer", nullable: false),
|
||||
CharaId = table.Column<int>(type: "integer", nullable: false),
|
||||
Rank = table.Column<int>(type: "integer", nullable: false),
|
||||
BattlePoint = table.Column<int>(type: "integer", nullable: false),
|
||||
IsMasterRank = table.Column<int>(type: "integer", nullable: false),
|
||||
MasterPoint = table.Column<int>(type: "integer", nullable: false),
|
||||
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_BotRoster", x => x.Id);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "BotRoster");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -756,6 +756,66 @@ namespace SVSim.Database.Migrations
|
||||
b.ToTable("Battlefields");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.BotRosterEntry", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("AiId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BattlePoint")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("CharaId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ClassId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("CountryCode")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("DateCreated")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DateUpdated")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("DegreeId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("EmblemId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("FieldId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("IsMasterRank")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("IsOfficial")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("MasterPoint")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Rank")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("SleeveId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("BotRoster");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.BuildDeckProductEntry", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
||||
39
SVSim.Database/Models/BotRosterEntry.cs
Normal file
39
SVSim.Database/Models/BotRosterEntry.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using SVSim.Database.Common;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// One row per AI bot opponent the rank-battle AI-fallback path can pick. Populated
|
||||
/// from seeds/bot-roster.json by SVSim.Bootstrap.BotRosterImporter.
|
||||
///
|
||||
/// The Id (= AiId) MUST match a row in the client's baked-in master CSV
|
||||
/// <c>data_dumps/client-assets/rm_ai_setting.csv</c>; if it doesn't, the client's
|
||||
/// <c>RankMatchAISettingList.GetSettingData(aiId)</c> throws
|
||||
/// <c>InvalidOperationException</c> at battle-start.
|
||||
///
|
||||
/// Cosmetic ids (sleeve / emblem / degree / field) MUST resolve in
|
||||
/// <c>SBattleLoad.LoadOpponentAssets</c>; placeholder 1s left the client hanging on
|
||||
/// "Waiting for opponent". Prod-verified values come from the Scripted bot fixture.
|
||||
/// </summary>
|
||||
public class BotRosterEntry : BaseEntity<int>
|
||||
{
|
||||
/// <summary>Client AI catalog id (rm_ai_setting.csv enemy_ai_id). Also the PK.</summary>
|
||||
public int AiId { get => Id; set => Id = value; }
|
||||
|
||||
public string CountryCode { get; set; } = string.Empty;
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
|
||||
public int SleeveId { get; set; }
|
||||
public int EmblemId { get; set; }
|
||||
public int DegreeId { get; set; }
|
||||
public int FieldId { get; set; }
|
||||
public int IsOfficial { get; set; }
|
||||
|
||||
public int ClassId { get; set; }
|
||||
public int CharaId { get; set; }
|
||||
|
||||
public int Rank { get; set; }
|
||||
public int BattlePoint { get; set; }
|
||||
public int IsMasterRank { get; set; }
|
||||
public int MasterPoint { get; set; }
|
||||
}
|
||||
20
SVSim.Database/Models/Config/MatchingConfig.cs
Normal file
20
SVSim.Database/Models/Config/MatchingConfig.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace SVSim.Database.Models.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Tunables for the in-process pair-up matching service. Today: just the AI-fallback
|
||||
/// threshold for rank-battle modes. The full matching-queue API is a separate spec;
|
||||
/// this config section lives alongside the placeholder.
|
||||
/// </summary>
|
||||
[ConfigSection("Matching")]
|
||||
public class MatchingConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// How long (seconds) a viewer must have been parked in a PvpFirstThenAiFallback
|
||||
/// queue before their next /do_matching poll resolves to an AI battle.
|
||||
/// Defaults to 15 — matches the prod 4s pre-AIBattleStart pause plus a comfortable
|
||||
/// polling cycle.
|
||||
/// </summary>
|
||||
public int RankBattleAiFallbackThresholdSeconds { get; set; } = 15;
|
||||
|
||||
public static MatchingConfig ShippedDefaults() => new();
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Database.Repositories.BattlePass;
|
||||
@@ -6,14 +7,19 @@ namespace SVSim.Database.Repositories.BattlePass;
|
||||
public sealed class BattlePassRepository : IBattlePassRepository
|
||||
{
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly IMemoryCache _cache;
|
||||
|
||||
// Process-level cache for the immutable level curve. Bootstrap re-baseline = host restart = cache cleared.
|
||||
private static IReadOnlyList<BattlePassLevelEntry>? _curveCache;
|
||||
private static readonly SemaphoreSlim _curveCacheLock = new(1, 1);
|
||||
// Per-host cache for the immutable level curve, scoped via the DI-registered IMemoryCache.
|
||||
// In production "host == process"; in tests each WebApplicationFactory builds its own
|
||||
// service provider so the cache is naturally isolated per fixture — avoids the pre-refactor
|
||||
// race where a process-static cache populated from one test's DbContext served stale data
|
||||
// to a parallel test reading from a different DB.
|
||||
private const string LevelCurveCacheKey = "battlepass:level-curve";
|
||||
|
||||
public BattlePassRepository(SVSimDbContext db)
|
||||
public BattlePassRepository(SVSimDbContext db, IMemoryCache cache)
|
||||
{
|
||||
_db = db;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
public async Task<BattlePassSeasonEntry?> GetActiveSeasonAsync(DateTimeOffset when, CancellationToken ct)
|
||||
@@ -42,25 +48,10 @@ public sealed class BattlePassRepository : IBattlePassRepository
|
||||
|
||||
public async Task<IReadOnlyList<BattlePassLevelEntry>> GetLevelCurveAsync(CancellationToken ct)
|
||||
{
|
||||
if (_curveCache is not null) return _curveCache;
|
||||
await _curveCacheLock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (_curveCache is null)
|
||||
{
|
||||
_curveCache = await _db.BattlePassLevels.AsNoTracking()
|
||||
.OrderBy(e => e.Level)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
return _curveCache;
|
||||
}
|
||||
finally { _curveCacheLock.Release(); }
|
||||
var cached = await _cache.GetOrCreateAsync(LevelCurveCacheKey, async _ =>
|
||||
(IReadOnlyList<BattlePassLevelEntry>)await _db.BattlePassLevels.AsNoTracking()
|
||||
.OrderBy(e => e.Level)
|
||||
.ToListAsync(ct));
|
||||
return cached!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drops the process-level level-curve cache. Tests that seed BattlePassLevels after the
|
||||
/// cache has already been populated (by an earlier test's HTTP call) must call this before
|
||||
/// re-seeding so the next read fetches fresh rows.
|
||||
/// </summary>
|
||||
internal static void ResetLevelCurveCache() => _curveCache = null;
|
||||
}
|
||||
|
||||
@@ -2,18 +2,19 @@ using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.Database.Services.Inventory;
|
||||
|
||||
namespace SVSim.Database.Repositories.Card;
|
||||
|
||||
public class CardInventoryRepository : ICardInventoryRepository
|
||||
{
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly RewardGrantService _grants;
|
||||
private readonly IInventoryService _inv;
|
||||
|
||||
public CardInventoryRepository(SVSimDbContext db, RewardGrantService grants)
|
||||
public CardInventoryRepository(SVSimDbContext db, IInventoryService inv)
|
||||
{
|
||||
_db = db;
|
||||
_grants = grants;
|
||||
_inv = inv;
|
||||
}
|
||||
|
||||
public async Task<DestructOutcome> DestructCards(long viewerId, IReadOnlyDictionary<long, int> destructCounts)
|
||||
@@ -129,30 +130,27 @@ public class CardInventoryRepository : ICardInventoryRepository
|
||||
totalCost += (ulong)catalogCard.CollectionInfo.CraftCost * (ulong)num;
|
||||
}
|
||||
|
||||
// insufficient_vials checked after summing the full batch — all-or-nothing
|
||||
// insufficient_vials pre-check (validation-before-mutation atomicity, keeps same error ordering)
|
||||
if (viewer.Currency.RedEther < totalCost)
|
||||
return CreateOutcome.Fail(CreateError.InsufficientVials);
|
||||
|
||||
using var tx = await _db.Database.BeginTransactionAsync();
|
||||
// Mutation phase via InventoryService transaction — freeplay-aware RedEther debit,
|
||||
// card grants with cosmetic cascade.
|
||||
await using var tx = await _inv.BeginAsync(viewerId);
|
||||
|
||||
// Debit RedEther directly. ApplyAsync only credits — debit-pair operations live in this
|
||||
// repo, symmetric with destruct.
|
||||
viewer.Currency.RedEther -= totalCost;
|
||||
var spendResult = await tx.TrySpendAsync(SpendCurrency.RedEther, (long)totalCost);
|
||||
if (!spendResult.Success)
|
||||
return CreateOutcome.Fail(CreateError.InsufficientVials);
|
||||
|
||||
// Per-card grant via RewardGrantService — single source of truth for Card-typed grants,
|
||||
// and fires the CardCosmeticReward cascade for first-time owners. See
|
||||
// feedback_reward_grant_service memory.
|
||||
var allGrants = new List<GrantedReward>();
|
||||
foreach (var (cardId, num) in createCounts)
|
||||
{
|
||||
var granted = await _grants.ApplyAsync(viewer, UserGoodsType.Card, cardId, num);
|
||||
var granted = await tx.GrantAsync(UserGoodsType.Card, cardId, num);
|
||||
allGrants.AddRange(granted);
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
await tx.CommitAsync();
|
||||
|
||||
return CreateOutcome.Ok(new CreateResult(viewer.Currency.RedEther, allGrants));
|
||||
return CreateOutcome.Ok(new CreateResult(tx.Viewer.Currency.RedEther, allGrants));
|
||||
}
|
||||
|
||||
public async Task<ProtectOutcome> SetProtected(long viewerId, long cardId, bool isProtected)
|
||||
|
||||
@@ -104,4 +104,7 @@ public class GlobalsRepository : IGlobalsRepository
|
||||
|
||||
public Task<List<PracticeOpponentEntry>> GetPracticeOpponents() =>
|
||||
_dbContext.PracticeOpponents.AsNoTracking().OrderBy(e => e.ClassId).ThenBy(e => e.Id).ToListAsync();
|
||||
|
||||
public Task<List<BotRosterEntry>> GetBotRoster() =>
|
||||
_dbContext.BotRoster.AsNoTracking().OrderBy(e => e.ClassId).ThenBy(e => e.Id).ToListAsync();
|
||||
}
|
||||
|
||||
@@ -31,4 +31,5 @@ public interface IGlobalsRepository
|
||||
Task<PreReleaseInfo?> GetPreReleaseInfo();
|
||||
Task<List<ShadowverseCardSetEntry>> GetRotationCardSets();
|
||||
Task<List<PracticeOpponentEntry>> GetPracticeOpponents();
|
||||
Task<List<BotRosterEntry>> GetBotRoster();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Database.Repositories.Mission;
|
||||
@@ -6,13 +7,18 @@ namespace SVSim.Database.Repositories.Mission;
|
||||
public sealed class MissionCatalogRepository : IMissionCatalogRepository
|
||||
{
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly IMemoryCache _cache;
|
||||
|
||||
// Process-level cache for the derived MAX(Level) lookup. Cleared on host restart
|
||||
// (re-bootstrap is the only legitimate way to mutate the catalog at runtime).
|
||||
private static IReadOnlyDictionary<int, int>? _maxLevelCache;
|
||||
private static readonly SemaphoreSlim _maxLevelLock = new(1, 1);
|
||||
// Per-host cache for the derived MAX(Level) lookup, scoped via the DI-registered
|
||||
// IMemoryCache. See BattlePassRepository for the per-host rationale (same parallel-test
|
||||
// race avoidance — each WebApplicationFactory gets its own cache).
|
||||
private const string MaxLevelCacheKey = "mission:achievement-max-level-by-type";
|
||||
|
||||
public MissionCatalogRepository(SVSimDbContext db) { _db = db; }
|
||||
public MissionCatalogRepository(SVSimDbContext db, IMemoryCache cache)
|
||||
{
|
||||
_db = db;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
public Task<List<MissionCatalogEntry>> GetByLotTypeAsync(int lotType, CancellationToken ct) =>
|
||||
_db.MissionCatalog.AsNoTracking().Where(e => e.LotType == lotType).ToListAsync(ct);
|
||||
@@ -40,21 +46,15 @@ public sealed class MissionCatalogRepository : IMissionCatalogRepository
|
||||
|
||||
public async Task<IReadOnlyDictionary<int, int>> GetMaxLevelByAchievementTypeAsync(CancellationToken ct)
|
||||
{
|
||||
if (_maxLevelCache is not null) return _maxLevelCache;
|
||||
await _maxLevelLock.WaitAsync(ct);
|
||||
try
|
||||
var cached = await _cache.GetOrCreateAsync(MaxLevelCacheKey, async _ =>
|
||||
{
|
||||
if (_maxLevelCache is null)
|
||||
{
|
||||
var pairs = await _db.AchievementCatalog.AsNoTracking()
|
||||
.GroupBy(e => e.AchievementType)
|
||||
.Select(g => new { Type = g.Key, Max = g.Max(e => e.Level) })
|
||||
.ToListAsync(ct);
|
||||
_maxLevelCache = pairs.ToDictionary(p => p.Type, p => p.Max);
|
||||
}
|
||||
return _maxLevelCache;
|
||||
}
|
||||
finally { _maxLevelLock.Release(); }
|
||||
var pairs = await _db.AchievementCatalog.AsNoTracking()
|
||||
.GroupBy(e => e.AchievementType)
|
||||
.Select(g => new { Type = g.Key, Max = g.Max(e => e.Level) })
|
||||
.ToListAsync(ct);
|
||||
return (IReadOnlyDictionary<int, int>)pairs.ToDictionary(p => p.Type, p => p.Max);
|
||||
});
|
||||
return cached!;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<int, int>> GetMinLevelByAchievementTypeAsync(CancellationToken ct)
|
||||
|
||||
@@ -13,4 +13,18 @@ public interface IViewerRepository
|
||||
ulong socialAccountIdentifier, ulong? shortUdid = null);
|
||||
Task<Models.Viewer> RegisterAnonymousViewer(Guid udid);
|
||||
Task LinkSteamToViewer(long viewerId, ulong steamId);
|
||||
|
||||
/// <summary>
|
||||
/// Merges an anonymous viewer (just created by <c>/tool/signup</c> on a fresh UDID)
|
||||
/// into a target viewer that the Steam ticket resolved to. Transfers the anonymous
|
||||
/// viewer's UDID to the target, then deletes the anonymous viewer.
|
||||
/// </summary>
|
||||
Task MergeAnonymousViewerInto(long anonymousViewerId, long targetViewerId);
|
||||
|
||||
/// <summary>
|
||||
/// Focused load for building a battle-node <c>MatchContext</c>: viewer + Info + Info's
|
||||
/// equipped Emblem/Degree nav refs. Read-only (AsNoTracking). Returns null if the viewer
|
||||
/// doesn't exist.
|
||||
/// </summary>
|
||||
Task<Models.Viewer?> LoadForMatchContextAsync(long viewerId);
|
||||
}
|
||||
|
||||
@@ -182,6 +182,33 @@ public class ViewerRepository : IViewerRepository
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task MergeAnonymousViewerInto(long anonymousViewerId, long targetViewerId)
|
||||
{
|
||||
if (anonymousViewerId == targetViewerId) return;
|
||||
|
||||
var anon = await _dbContext.Set<Models.Viewer>()
|
||||
.FirstOrDefaultAsync(v => v.Id == anonymousViewerId);
|
||||
if (anon is null) return;
|
||||
|
||||
var target = await _dbContext.Set<Models.Viewer>()
|
||||
.FirstOrDefaultAsync(v => v.Id == targetViewerId)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Cannot merge anonymous viewer {anonymousViewerId}: target viewer {targetViewerId} not found.");
|
||||
|
||||
// Two saves: free the UDID slot on the anonymous viewer first (drops the unique-index
|
||||
// conflict), then reassign to the target and delete the anonymous row in the second
|
||||
// save. The partial-failure mode (first save succeeds, second fails) leaves a benign
|
||||
// null-UDID viewer that no client can resolve to — never two rows contending for the
|
||||
// same UDID, which is the failure we actually need to prevent.
|
||||
Guid? freedUdid = anon.Udid;
|
||||
anon.Udid = null;
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
target.Udid = freedUdid;
|
||||
_dbContext.Set<Models.Viewer>().Remove(anon);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task LinkSteamToViewer(long viewerId, ulong steamId)
|
||||
{
|
||||
var viewer = await _dbContext.Set<Models.Viewer>()
|
||||
@@ -201,6 +228,15 @@ public class ViewerRepository : IViewerRepository
|
||||
await _dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<Models.Viewer?> LoadForMatchContextAsync(long viewerId)
|
||||
{
|
||||
return await _dbContext.Set<Models.Viewer>()
|
||||
.AsNoTracking()
|
||||
.Include(v => v.Info.SelectedEmblem)
|
||||
.Include(v => v.Info.SelectedDegree)
|
||||
.FirstOrDefaultAsync(v => v.Id == viewerId);
|
||||
}
|
||||
|
||||
private async Task<Models.Viewer> BuildDefaultViewer(string displayName, int initialTutorialState = 1)
|
||||
{
|
||||
Models.Viewer viewer = new Models.Viewer
|
||||
|
||||
@@ -86,6 +86,7 @@ public class SVSimDbContext : DbContext
|
||||
public DbSet<FeatureMaintenanceEntry> FeatureMaintenances => Set<FeatureMaintenanceEntry>();
|
||||
public DbSet<PreReleaseInfo> PreReleaseInfos => Set<PreReleaseInfo>();
|
||||
public DbSet<PracticeOpponentEntry> PracticeOpponents => Set<PracticeOpponentEntry>();
|
||||
public DbSet<BotRosterEntry> BotRoster => Set<BotRosterEntry>();
|
||||
public DbSet<PuzzleGroupEntry> PuzzleGroups => Set<PuzzleGroupEntry>();
|
||||
public DbSet<PuzzleEntry> Puzzles => Set<PuzzleEntry>();
|
||||
public DbSet<PuzzleMissionEntry> PuzzleMissions => Set<PuzzleMissionEntry>();
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Database.Services;
|
||||
|
||||
public class CurrencySpendService : ICurrencySpendService
|
||||
{
|
||||
private readonly IViewerEntitlements _entitlements;
|
||||
|
||||
public CurrencySpendService(IViewerEntitlements entitlements) => _entitlements = entitlements;
|
||||
|
||||
public Task<SpendResult> TrySpendAsync(Viewer viewer, SpendCurrency currency, long cost, CancellationToken ct = default)
|
||||
{
|
||||
if (cost < 0) cost = 0;
|
||||
|
||||
// Freeplay bypass applies only to the three main currencies; SpotPoint always real.
|
||||
if (_entitlements.IsFreeplay && currency != SpendCurrency.SpotPoint)
|
||||
{
|
||||
return Task.FromResult(new SpendResult(
|
||||
SpendOutcome.Success, _entitlements.EffectiveBalance(viewer, currency)));
|
||||
}
|
||||
|
||||
ulong current = GetBalance(viewer, currency);
|
||||
if (current < (ulong)cost)
|
||||
return Task.FromResult(new SpendResult(SpendOutcome.Insufficient, (long)current));
|
||||
|
||||
ulong post = current - (ulong)cost;
|
||||
SetBalance(viewer, currency, post);
|
||||
return Task.FromResult(new SpendResult(SpendOutcome.Success, (long)post));
|
||||
}
|
||||
|
||||
private static ulong GetBalance(Viewer v, SpendCurrency c) => c switch
|
||||
{
|
||||
SpendCurrency.Crystal => v.Currency.Crystals,
|
||||
SpendCurrency.Rupee => v.Currency.Rupees,
|
||||
SpendCurrency.RedEther => v.Currency.RedEther,
|
||||
SpendCurrency.SpotPoint => v.Currency.SpotPoints,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(c)),
|
||||
};
|
||||
|
||||
private static void SetBalance(Viewer v, SpendCurrency c, ulong value)
|
||||
{
|
||||
switch (c)
|
||||
{
|
||||
case SpendCurrency.Crystal: v.Currency.Crystals = value; break;
|
||||
case SpendCurrency.Rupee: v.Currency.Rupees = value; break;
|
||||
case SpendCurrency.RedEther: v.Currency.RedEther = value; break;
|
||||
case SpendCurrency.SpotPoint: v.Currency.SpotPoints = value; break;
|
||||
default: throw new ArgumentOutOfRangeException(nameof(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Database.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Centralized debit primitive — the symmetric twin of <c>RewardGrantService.ApplyAsync</c>.
|
||||
/// Encapsulates the affordability-check + deduction + post-state-total pattern that was inlined
|
||||
/// across the shop/pack controllers. Does NOT call <c>SaveChangesAsync</c>; the caller saves.
|
||||
/// Freeplay (for Crystal/Rupee/RedEther) makes spends always succeed without deducting.
|
||||
/// </summary>
|
||||
public interface ICurrencySpendService
|
||||
{
|
||||
Task<SpendResult> TrySpendAsync(Viewer viewer, SpendCurrency currency, long cost, CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Database.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The single read/ownership authority for what a viewer is *treated as* owning. Knows the
|
||||
/// Freeplay flag; all freeplay read-side behavior lives here. See
|
||||
/// docs/superpowers/specs/2026-05-29-freeplay-mode-design.md.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Include precondition:</b> methods that inspect the viewer's collections require the
|
||||
/// viewer to have been loaded with <c>.Include(v => v.Cards).ThenInclude(c => c.Card)</c>
|
||||
/// and the cosmetic collections
|
||||
/// (<c>Sleeves</c>, <c>Emblems</c>, <c>Degrees</c>, <c>LeaderSkins</c>, <c>MyPageBackgrounds</c>)
|
||||
/// included. Without those includes the EF owned-collection nav refs are null or zero-filled
|
||||
/// (see the EF owned-collection nav-include pitfall in MEMORY.md).
|
||||
/// </remarks>
|
||||
public interface IViewerEntitlements
|
||||
{
|
||||
/// <summary>True when the global Freeplay config section is enabled.</summary>
|
||||
bool IsFreeplay { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The balance the viewer is treated as having: the configured freeplay amount for
|
||||
/// Crystal/Rupee/RedEther when freeplay is on, otherwise (and always for SpotPoint) the real
|
||||
/// <c>viewer.Currency</c> field.
|
||||
/// </summary>
|
||||
long EffectiveBalance(Viewer viewer, SpendCurrency currency);
|
||||
|
||||
bool OwnsCard(Viewer viewer, long cardId);
|
||||
|
||||
/// <summary><paramref name="type"/> uses <see cref="CosmeticType"/> (Skin == leader skin).</summary>
|
||||
bool OwnsCosmetic(Viewer viewer, CosmeticType type, int id);
|
||||
|
||||
/// <summary>The full owned-card projection for /load/index's user_card_list.</summary>
|
||||
Task<IReadOnlyList<OwnedCardEntry>> EffectiveOwnedCardsAsync(Viewer viewer, CancellationToken ct = default);
|
||||
|
||||
/// <summary>The cosmetic id-lists + leader-skin catalog/owned-set for /load/index.</summary>
|
||||
Task<EffectiveCosmetics> EffectiveCosmeticsAsync(Viewer viewer, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cosmetic projection bundle for /load/index. The four id-lists are "what the viewer owns"
|
||||
/// (all of them in freeplay). Leader skins are always the full catalog with a per-skin owned flag;
|
||||
/// <see cref="OwnedLeaderSkinIds"/> is every skin id in freeplay.
|
||||
/// </summary>
|
||||
public sealed record EffectiveCosmetics(
|
||||
IReadOnlyList<int> SleeveIds,
|
||||
IReadOnlyList<int> EmblemIds,
|
||||
IReadOnlyList<int> DegreeIds,
|
||||
IReadOnlyList<int> MyPageBackgroundIds,
|
||||
IReadOnlyList<LeaderSkinEntry> AllLeaderSkins,
|
||||
IReadOnlySet<int> OwnedLeaderSkinIds);
|
||||
28
SVSim.Database/Services/Inventory/IInventoryService.cs
Normal file
28
SVSim.Database/Services/Inventory/IInventoryService.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Services;
|
||||
|
||||
namespace SVSim.Database.Services.Inventory;
|
||||
|
||||
public interface IInventoryService
|
||||
{
|
||||
/// <summary>
|
||||
/// Loads the viewer with the canonical inventory graph (Cards.Card, Sleeves, Emblems,
|
||||
/// LeaderSkins, Degrees, MyPageBackgrounds, Items.Item under AsSplitQuery), opens a DB
|
||||
/// transaction, and returns a builder for queueing operations. Throws
|
||||
/// <see cref="InventoryViewerNotFoundException"/> if the viewer does not exist.
|
||||
/// </summary>
|
||||
Task<IInventoryTransaction> BeginAsync(
|
||||
long viewerId,
|
||||
CancellationToken ct = default,
|
||||
Action<InventoryLoadConfig>? configure = null);
|
||||
|
||||
Task<IReadOnlyList<OwnedCardEntry>> EffectiveOwnedCardsAsync(Viewer viewer, CancellationToken ct = default);
|
||||
Task<EffectiveCosmetics> EffectiveCosmeticsAsync(Viewer viewer, CancellationToken ct = default);
|
||||
long EffectiveBalance(Viewer viewer, SpendCurrency currency);
|
||||
}
|
||||
|
||||
public sealed class InventoryViewerNotFoundException : Exception
|
||||
{
|
||||
public InventoryViewerNotFoundException(long viewerId)
|
||||
: base($"Viewer {viewerId} not found") { }
|
||||
}
|
||||
49
SVSim.Database/Services/Inventory/IInventoryTransaction.cs
Normal file
49
SVSim.Database/Services/Inventory/IInventoryTransaction.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Services;
|
||||
|
||||
namespace SVSim.Database.Services.Inventory;
|
||||
|
||||
/// <summary>
|
||||
/// Scoped builder returned by <see cref="IInventoryService.BeginAsync"/>. Queue spend +
|
||||
/// grant operations; commit to save and assemble the <see cref="InventoryCommitResult"/>.
|
||||
/// <para>
|
||||
/// Dispose without committing rolls back the underlying DB transaction and detaches any
|
||||
/// in-memory mutations. <b>Always</b> wrap in <c>await using</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface IInventoryTransaction : IAsyncDisposable
|
||||
{
|
||||
Viewer Viewer { get; }
|
||||
bool IsFreeplay { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Debits one of the four scalar wallets. Freeplay-aware for Crystal/Rupee/RedEther
|
||||
/// (returns Success with the configured freeplay amount, balance unchanged); SpotPoint
|
||||
/// always real. Returns <see cref="SpendOutcome.Insufficient"/> with current balance on
|
||||
/// failure; viewer state is not mutated on failure.
|
||||
/// </summary>
|
||||
Task<SpendResult> TrySpendAsync(SpendCurrency currency, long cost, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Type-dispatched debit. Currencies (RedEther/Crystal/Rupy/SpotCardPoint) route to
|
||||
/// <see cref="TrySpendAsync"/>; Item decrements <c>OwnedItemEntry.Count</c>. Returns
|
||||
/// <see cref="SpendResult"/> whose <c>PostStateTotal</c> is the new wallet balance for
|
||||
/// currencies and the remaining item count for Item.
|
||||
/// </summary>
|
||||
Task<SpendResult> TryDebitAsync(UserGoodsType type, long detailId, int num, CancellationToken ct = default);
|
||||
|
||||
Task<IReadOnlyList<GrantedReward>> GrantAsync(UserGoodsType type, long detailId, int num, CancellationToken ct = default);
|
||||
Task<int> BackfillCardCosmeticsAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Freeplay-aware balance read against the live viewer; reflects any spends queued in
|
||||
/// this transaction. Inside a transaction, use this; outside, use
|
||||
/// <see cref="IInventoryService.EffectiveBalance"/>.
|
||||
/// </summary>
|
||||
long EffectiveBalance(SpendCurrency currency);
|
||||
bool OwnsCard(long cardId);
|
||||
bool OwnsCosmetic(CosmeticType type, int id);
|
||||
|
||||
Task<InventoryCommitResult> CommitAsync(CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace SVSim.Database.Services.Inventory;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when an inventory operation references a catalog id that doesn't exist
|
||||
/// (unknown card / item / cosmetic). Programmer error — bubbles to the global error handler.
|
||||
/// </summary>
|
||||
public sealed class InventoryCatalogException : Exception
|
||||
{
|
||||
public InventoryCatalogException(string message) : base(message) { }
|
||||
}
|
||||
20
SVSim.Database/Services/Inventory/InventoryCommitResult.cs
Normal file
20
SVSim.Database/Services/Inventory/InventoryCommitResult.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using SVSim.Database.Services;
|
||||
|
||||
namespace SVSim.Database.Services.Inventory;
|
||||
|
||||
/// <summary>
|
||||
/// Result of <see cref="IInventoryTransaction.CommitAsync"/>.
|
||||
/// <para>
|
||||
/// <see cref="RewardList"/> — wire-shape entries with currency-collision resolved (one entry per
|
||||
/// (type, id); for currencies that were both spent and granted, the last post-state in op order
|
||||
/// wins). Use this for response <c>reward_list</c> fields.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="Deltas"/> — verbatim ordered (type, id, num) sequence the caller queued. No
|
||||
/// collapse, no cosmetic-cascade entries. Use this for BP <c>achieved_info</c> and Story
|
||||
/// <c>story_reward_list</c> popups.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed record InventoryCommitResult(
|
||||
IReadOnlyList<GrantedReward> RewardList,
|
||||
IReadOnlyList<GrantedReward> Deltas);
|
||||
27
SVSim.Database/Services/Inventory/InventoryGrantTypes.cs
Normal file
27
SVSim.Database/Services/Inventory/InventoryGrantTypes.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Database.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-shape entry returned by <see cref="Inventory.IInventoryTransaction.GrantAsync"/> and
|
||||
/// collected in <see cref="Inventory.InventoryCommitResult.RewardList"/> /
|
||||
/// <see cref="Inventory.InventoryCommitResult.Deltas"/>. Field names match the
|
||||
/// <c>reward_list</c> entries used by <c>/pack/open</c>, <c>/basic_puzzle/finish</c>, and
|
||||
/// <c>/story/*/finish</c>. reward_num is a POST-STATE TOTAL for currencies and a count for
|
||||
/// collection grants — see <see cref="Models.RewardListEntry"/>.
|
||||
/// </summary>
|
||||
public sealed record GrantedReward(int RewardType, long RewardId, int RewardNum);
|
||||
|
||||
/// <summary>
|
||||
/// Cosmetic projection bundle for /load/index. The four id-lists are "what the viewer owns"
|
||||
/// (all of them in freeplay). Leader skins are always the full catalog with a per-skin owned flag;
|
||||
/// <see cref="OwnedLeaderSkinIds"/> is every skin id in freeplay.
|
||||
/// </summary>
|
||||
public sealed record EffectiveCosmetics(
|
||||
IReadOnlyList<int> SleeveIds,
|
||||
IReadOnlyList<int> EmblemIds,
|
||||
IReadOnlyList<int> DegreeIds,
|
||||
IReadOnlyList<int> MyPageBackgroundIds,
|
||||
IReadOnlyList<LeaderSkinEntry> AllLeaderSkins,
|
||||
IReadOnlySet<int> OwnedLeaderSkinIds);
|
||||
31
SVSim.Database/Services/Inventory/InventoryLoadConfig.cs
Normal file
31
SVSim.Database/Services/Inventory/InventoryLoadConfig.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using System.Linq.Expressions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Query;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Database.Services.Inventory;
|
||||
|
||||
/// <summary>
|
||||
/// Caller-supplied extra <c>.Include</c> chains on top of the canonical viewer-inventory query
|
||||
/// in <see cref="IInventoryService.BeginAsync"/>. Use to bring in extra collections needed by
|
||||
/// the calling controller (e.g. <c>MissionData</c>, <c>BuildDeckPurchases</c>).
|
||||
/// </summary>
|
||||
public sealed class InventoryLoadConfig
|
||||
{
|
||||
internal List<Func<IQueryable<Viewer>, IQueryable<Viewer>>> Includes { get; } = new();
|
||||
|
||||
public InventoryLoadConfig WithInclude<TProperty>(
|
||||
Expression<Func<Viewer, TProperty>> path)
|
||||
{
|
||||
Includes.Add(q => q.Include(path));
|
||||
return this;
|
||||
}
|
||||
|
||||
public InventoryLoadConfig WithInclude<TProperty, TThen>(
|
||||
Expression<Func<Viewer, IEnumerable<TProperty>>> collectionPath,
|
||||
Expression<Func<TProperty, TThen>> thenPath)
|
||||
{
|
||||
Includes.Add(q => q.Include(collectionPath).ThenInclude(thenPath));
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,68 @@
|
||||
using SVSim.Database.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Models.Config;
|
||||
using SVSim.Database.Repositories.Card;
|
||||
using SVSim.Database.Repositories.Collectibles;
|
||||
|
||||
namespace SVSim.Database.Services;
|
||||
namespace SVSim.Database.Services.Inventory;
|
||||
|
||||
public class ViewerEntitlements : IViewerEntitlements
|
||||
public sealed class InventoryService : IInventoryService
|
||||
{
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly IGameConfigService _config;
|
||||
private readonly ICardRepository _cards;
|
||||
private readonly ICollectionRepository _collection;
|
||||
private readonly ILogger<InventoryService> _log;
|
||||
|
||||
public ViewerEntitlements(IGameConfigService config, ICardRepository cards, ICollectionRepository collection)
|
||||
public InventoryService(
|
||||
SVSimDbContext db,
|
||||
IGameConfigService config,
|
||||
ICardRepository cards,
|
||||
ICollectionRepository collection,
|
||||
ILogger<InventoryService> log)
|
||||
{
|
||||
_db = db;
|
||||
_config = config;
|
||||
_cards = cards;
|
||||
_collection = collection;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
private FreeplayConfig Cfg => _config.Get<FreeplayConfig>();
|
||||
public async Task<IInventoryTransaction> BeginAsync(
|
||||
long viewerId,
|
||||
CancellationToken ct = default,
|
||||
Action<InventoryLoadConfig>? configure = null)
|
||||
{
|
||||
var loadCfg = new InventoryLoadConfig();
|
||||
configure?.Invoke(loadCfg);
|
||||
|
||||
public bool IsFreeplay => Cfg.Enabled;
|
||||
IQueryable<Viewer> query = _db.Viewers
|
||||
.Include(v => v.Cards).ThenInclude(c => c.Card)
|
||||
.Include(v => v.Sleeves)
|
||||
.Include(v => v.Emblems)
|
||||
.Include(v => v.LeaderSkins)
|
||||
.Include(v => v.Degrees)
|
||||
.Include(v => v.MyPageBackgrounds)
|
||||
.Include(v => v.Items).ThenInclude(i => i.Item);
|
||||
|
||||
foreach (var include in loadCfg.Includes)
|
||||
query = include(query);
|
||||
|
||||
var viewer = await query
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(v => v.Id == viewerId, ct)
|
||||
?? throw new InventoryViewerNotFoundException(viewerId);
|
||||
|
||||
var freeplay = _config.Get<FreeplayConfig>();
|
||||
var dbTx = await _db.Database.BeginTransactionAsync(ct);
|
||||
|
||||
return new InventoryTransaction(_db, dbTx, viewer, freeplay, _log);
|
||||
}
|
||||
|
||||
public long EffectiveBalance(Viewer viewer, SpendCurrency currency)
|
||||
{
|
||||
var cfg = Cfg;
|
||||
var cfg = _config.Get<FreeplayConfig>();
|
||||
if (cfg.Enabled && currency != SpendCurrency.SpotPoint)
|
||||
return checked((long)cfg.CurrencyAmount);
|
||||
|
||||
@@ -39,28 +76,12 @@ public class ViewerEntitlements : IViewerEntitlements
|
||||
};
|
||||
}
|
||||
|
||||
public bool OwnsCard(Viewer viewer, long cardId)
|
||||
=> Cfg.Enabled || viewer.Cards.Any(c => c.Card.Id == cardId && c.Count > 0);
|
||||
|
||||
public bool OwnsCosmetic(Viewer viewer, CosmeticType type, int id)
|
||||
{
|
||||
if (Cfg.Enabled) return true;
|
||||
return type switch
|
||||
{
|
||||
CosmeticType.Sleeve => viewer.Sleeves.Any(s => s.Id == id),
|
||||
CosmeticType.Emblem => viewer.Emblems.Any(e => e.Id == id),
|
||||
CosmeticType.Degree => viewer.Degrees.Any(d => d.Id == id),
|
||||
CosmeticType.Skin => viewer.LeaderSkins.Any(s => s.Id == id),
|
||||
CosmeticType.MyPageBG => viewer.MyPageBackgrounds.Any(m => m.Id == id),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<OwnedCardEntry>> EffectiveOwnedCardsAsync(Viewer viewer, CancellationToken ct = default)
|
||||
public async Task<IReadOnlyList<OwnedCardEntry>> EffectiveOwnedCardsAsync(
|
||||
Viewer viewer, CancellationToken ct = default)
|
||||
{
|
||||
var defaults = await _cards.GetDefaultCards();
|
||||
var defaultIds = defaults.Select(c => c.Id).ToHashSet();
|
||||
var cfg = Cfg;
|
||||
var cfg = _config.Get<FreeplayConfig>();
|
||||
|
||||
if (cfg.Enabled)
|
||||
{
|
||||
@@ -81,11 +102,13 @@ public class ViewerEntitlements : IViewerEntitlements
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<EffectiveCosmetics> EffectiveCosmeticsAsync(Viewer viewer, CancellationToken ct = default)
|
||||
public async Task<EffectiveCosmetics> EffectiveCosmeticsAsync(
|
||||
Viewer viewer, CancellationToken ct = default)
|
||||
{
|
||||
var allSkins = await _collection.GetLeaderSkins();
|
||||
var cfg = _config.Get<FreeplayConfig>();
|
||||
|
||||
if (Cfg.Enabled)
|
||||
if (cfg.Enabled)
|
||||
{
|
||||
return new EffectiveCosmetics(
|
||||
await _collection.GetAllSleeveIds(),
|
||||
455
SVSim.Database/Services/Inventory/InventoryTransaction.cs
Normal file
455
SVSim.Database/Services/Inventory/InventoryTransaction.cs
Normal file
@@ -0,0 +1,455 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Models.Config;
|
||||
|
||||
namespace SVSim.Database.Services.Inventory;
|
||||
|
||||
internal sealed class InventoryTransaction : IInventoryTransaction
|
||||
{
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly IDbContextTransaction _dbTx;
|
||||
private readonly ILogger _log;
|
||||
private readonly FreeplayConfig _freeplay;
|
||||
private bool _committed;
|
||||
|
||||
public Viewer Viewer { get; }
|
||||
public bool IsFreeplay => _freeplay.Enabled;
|
||||
|
||||
private readonly List<InventoryOp> _ops = new();
|
||||
|
||||
internal abstract record InventoryOp;
|
||||
internal sealed record SpendOp(SpendCurrency Currency, long Cost, long PostState) : InventoryOp;
|
||||
internal sealed record GrantOp(UserGoodsType Type, long DetailId, int Num, int PostStateOrCount, bool IsCascade) : InventoryOp;
|
||||
|
||||
public InventoryTransaction(
|
||||
SVSimDbContext db,
|
||||
IDbContextTransaction dbTx,
|
||||
Viewer viewer,
|
||||
FreeplayConfig freeplay,
|
||||
ILogger log)
|
||||
{
|
||||
_db = db;
|
||||
_dbTx = dbTx;
|
||||
Viewer = viewer;
|
||||
_freeplay = freeplay;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public Task<SpendResult> TrySpendAsync(SpendCurrency currency, long cost, CancellationToken ct = default)
|
||||
{
|
||||
ThrowIfCommitted();
|
||||
if (cost < 0) cost = 0;
|
||||
|
||||
if (_freeplay.Enabled && currency != SpendCurrency.SpotPoint)
|
||||
{
|
||||
long amount = checked((long)_freeplay.CurrencyAmount);
|
||||
_ops.Add(new SpendOp(currency, cost, amount));
|
||||
return Task.FromResult(new SpendResult(SpendOutcome.Success, amount));
|
||||
}
|
||||
|
||||
ulong current = ReadBalance(currency);
|
||||
if (current < (ulong)cost)
|
||||
return Task.FromResult(new SpendResult(SpendOutcome.Insufficient, (long)current));
|
||||
|
||||
ulong post = current - (ulong)cost;
|
||||
WriteBalance(currency, post);
|
||||
_ops.Add(new SpendOp(currency, cost, (long)post));
|
||||
return Task.FromResult(new SpendResult(SpendOutcome.Success, (long)post));
|
||||
}
|
||||
|
||||
private ulong ReadBalance(SpendCurrency c) => c switch
|
||||
{
|
||||
SpendCurrency.Crystal => Viewer.Currency.Crystals,
|
||||
SpendCurrency.Rupee => Viewer.Currency.Rupees,
|
||||
SpendCurrency.RedEther => Viewer.Currency.RedEther,
|
||||
SpendCurrency.SpotPoint => Viewer.Currency.SpotPoints,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(c)),
|
||||
};
|
||||
|
||||
private void WriteBalance(SpendCurrency c, ulong value)
|
||||
{
|
||||
switch (c)
|
||||
{
|
||||
case SpendCurrency.Crystal: Viewer.Currency.Crystals = value; break;
|
||||
case SpendCurrency.Rupee: Viewer.Currency.Rupees = value; break;
|
||||
case SpendCurrency.RedEther: Viewer.Currency.RedEther = value; break;
|
||||
case SpendCurrency.SpotPoint: Viewer.Currency.SpotPoints = value; break;
|
||||
default: throw new ArgumentOutOfRangeException(nameof(c));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<SpendResult> TryDebitAsync(UserGoodsType type, long detailId, int num, CancellationToken ct = default)
|
||||
{
|
||||
ThrowIfCommitted();
|
||||
return type switch
|
||||
{
|
||||
UserGoodsType.Crystal => TrySpendAsync(SpendCurrency.Crystal, num, ct),
|
||||
UserGoodsType.Rupy => TrySpendAsync(SpendCurrency.Rupee, num, ct),
|
||||
UserGoodsType.RedEther => TrySpendAsync(SpendCurrency.RedEther, num, ct),
|
||||
UserGoodsType.SpotCardPoint => TrySpendAsync(SpendCurrency.SpotPoint, num, ct),
|
||||
UserGoodsType.Item => Task.FromResult(DebitItem(detailId, num)),
|
||||
_ => throw new NotSupportedException($"Debit not supported for {type}"),
|
||||
};
|
||||
}
|
||||
|
||||
private SpendResult DebitItem(long detailId, int num)
|
||||
{
|
||||
var owned = Viewer.Items.FirstOrDefault(i => i.Item.Id == (int)detailId);
|
||||
if (owned is null)
|
||||
throw new InventoryCatalogException($"Item {detailId} not owned by viewer");
|
||||
if (owned.Count < num)
|
||||
return new SpendResult(SpendOutcome.Insufficient, owned.Count);
|
||||
owned.Count -= num;
|
||||
// Item debit logged as a synthetic SpendOp so CommitAsync can track it.
|
||||
// Sentinel currency (int)-1 is filtered out by CommitAsync's currency-collision loop.
|
||||
_ops.Add(new SpendOp((SpendCurrency)(-1) /* sentinel */, num, owned.Count));
|
||||
// IsCascade: true so this GrantOp is excluded from BuildDeltas output.
|
||||
_ops.Add(new GrantOp(UserGoodsType.Item, detailId, 0, owned.Count, IsCascade: true));
|
||||
return new SpendResult(SpendOutcome.Success, owned.Count);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<GrantedReward>> GrantAsync(UserGoodsType type, long detailId, int num, CancellationToken ct = default)
|
||||
{
|
||||
ThrowIfCommitted();
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case UserGoodsType.Rupy:
|
||||
Viewer.Currency.Rupees += (ulong)num;
|
||||
var rupy = checked((int)Viewer.Currency.Rupees);
|
||||
_ops.Add(new GrantOp(type, detailId, num, rupy, false));
|
||||
return Single(type, detailId, rupy);
|
||||
|
||||
case UserGoodsType.Crystal:
|
||||
Viewer.Currency.Crystals += (ulong)num;
|
||||
var crystal = checked((int)Viewer.Currency.Crystals);
|
||||
_ops.Add(new GrantOp(type, detailId, num, crystal, false));
|
||||
return Single(type, detailId, crystal);
|
||||
|
||||
case UserGoodsType.RedEther:
|
||||
Viewer.Currency.RedEther += (ulong)num;
|
||||
var red = checked((int)Viewer.Currency.RedEther);
|
||||
_ops.Add(new GrantOp(type, detailId, num, red, false));
|
||||
return Single(type, detailId, red);
|
||||
|
||||
case UserGoodsType.SpotCardPoint:
|
||||
Viewer.Currency.SpotPoints += (ulong)num;
|
||||
var spot = checked((int)Viewer.Currency.SpotPoints);
|
||||
_ops.Add(new GrantOp(type, detailId, num, spot, false));
|
||||
return Single(type, detailId, spot);
|
||||
|
||||
case UserGoodsType.Sleeve:
|
||||
AddCosmeticIfMissing(Viewer.Sleeves, detailId, _db.Sleeves);
|
||||
_ops.Add(new GrantOp(type, detailId, num, 1, false));
|
||||
return Single(type, detailId, 1);
|
||||
|
||||
case UserGoodsType.Emblem:
|
||||
AddCosmeticIfMissing(Viewer.Emblems, detailId, _db.Emblems);
|
||||
_ops.Add(new GrantOp(type, detailId, num, 1, false));
|
||||
return Single(type, detailId, 1);
|
||||
|
||||
case UserGoodsType.Skin:
|
||||
AddCosmeticIfMissing(Viewer.LeaderSkins, detailId, _db.LeaderSkins);
|
||||
_ops.Add(new GrantOp(type, detailId, num, 1, false));
|
||||
return Single(type, detailId, 1);
|
||||
|
||||
case UserGoodsType.Degree:
|
||||
AddCosmeticIfMissing(Viewer.Degrees, detailId, _db.Degrees);
|
||||
_ops.Add(new GrantOp(type, detailId, num, 1, false));
|
||||
return Single(type, detailId, 1);
|
||||
|
||||
case UserGoodsType.MyPageBG:
|
||||
AddCosmeticIfMissing(Viewer.MyPageBackgrounds, detailId, _db.MyPageBackgrounds);
|
||||
_ops.Add(new GrantOp(type, detailId, num, 1, false));
|
||||
return Single(type, detailId, 1);
|
||||
|
||||
case UserGoodsType.Item:
|
||||
{
|
||||
var owned = Viewer.Items.FirstOrDefault(i => i.Item.Id == (int)detailId);
|
||||
int post;
|
||||
if (owned is null)
|
||||
{
|
||||
var item = _db.Items.Find((int)detailId)
|
||||
?? throw new InventoryCatalogException($"Item {detailId} not in catalog");
|
||||
Viewer.Items.Add(new OwnedItemEntry { Item = item, Count = num, Viewer = Viewer });
|
||||
post = num;
|
||||
}
|
||||
else
|
||||
{
|
||||
owned.Count += num;
|
||||
post = owned.Count;
|
||||
}
|
||||
_ops.Add(new GrantOp(type, detailId, num, post, false));
|
||||
return Single(type, detailId, post);
|
||||
}
|
||||
|
||||
case UserGoodsType.Card:
|
||||
return await ApplyCardAsync(detailId, num, ct);
|
||||
|
||||
case UserGoodsType.SpotCard:
|
||||
case UserGoodsType.SpotCardOnlyLatestCardPack:
|
||||
throw new NotSupportedException(
|
||||
$"{type} rewards are not yet supported — emitters use Card=5 instead.");
|
||||
|
||||
default:
|
||||
throw new NotImplementedException(
|
||||
$"UserGoodsType {type} grant lands in a subsequent task");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<int> BackfillCardCosmeticsAsync(CancellationToken ct = default)
|
||||
{
|
||||
ThrowIfCommitted();
|
||||
|
||||
var lookupIds = Viewer.Cards
|
||||
.Select(c => c.Card.IsFoil ? c.Card.Id - 1 : c.Card.Id)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var cascade = await _db.CardCosmeticRewards
|
||||
.Where(r => lookupIds.Contains(r.CardId))
|
||||
.ToListAsync(ct);
|
||||
|
||||
int granted = 0;
|
||||
foreach (var reward in cascade)
|
||||
{
|
||||
if (AlreadyOwnsCosmetic(reward.Type, reward.CosmeticId)) continue;
|
||||
if (TryAddCascadeCosmetic(reward, reward.CardId))
|
||||
{
|
||||
granted++;
|
||||
_ops.Add(new GrantOp((UserGoodsType)(int)reward.Type, reward.CosmeticId, 1, 1, true));
|
||||
}
|
||||
}
|
||||
|
||||
return granted;
|
||||
}
|
||||
|
||||
private bool AlreadyOwnsCosmetic(CosmeticType type, long id) => type switch
|
||||
{
|
||||
CosmeticType.Sleeve => Viewer.Sleeves.Any(s => s.Id == id),
|
||||
CosmeticType.Emblem => Viewer.Emblems.Any(e => e.Id == id),
|
||||
CosmeticType.Skin => Viewer.LeaderSkins.Any(s => s.Id == id),
|
||||
CosmeticType.Degree => Viewer.Degrees.Any(d => d.Id == id),
|
||||
CosmeticType.MyPageBG => Viewer.MyPageBackgrounds.Any(b => b.Id == id),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
public long EffectiveBalance(SpendCurrency currency)
|
||||
{
|
||||
if (_freeplay.Enabled && currency != SpendCurrency.SpotPoint)
|
||||
return checked((long)_freeplay.CurrencyAmount);
|
||||
|
||||
return currency switch
|
||||
{
|
||||
SpendCurrency.Crystal => (long)Viewer.Currency.Crystals,
|
||||
SpendCurrency.Rupee => (long)Viewer.Currency.Rupees,
|
||||
SpendCurrency.RedEther => (long)Viewer.Currency.RedEther,
|
||||
SpendCurrency.SpotPoint => (long)Viewer.Currency.SpotPoints,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(currency)),
|
||||
};
|
||||
}
|
||||
|
||||
public bool OwnsCard(long cardId)
|
||||
=> _freeplay.Enabled || Viewer.Cards.Any(c => c.Card.Id == cardId && c.Count > 0);
|
||||
|
||||
public bool OwnsCosmetic(CosmeticType type, int id)
|
||||
{
|
||||
if (_freeplay.Enabled) return true;
|
||||
return type switch
|
||||
{
|
||||
CosmeticType.Sleeve => Viewer.Sleeves.Any(s => s.Id == id),
|
||||
CosmeticType.Emblem => Viewer.Emblems.Any(e => e.Id == id),
|
||||
CosmeticType.Degree => Viewer.Degrees.Any(d => d.Id == id),
|
||||
CosmeticType.Skin => Viewer.LeaderSkins.Any(s => s.Id == id),
|
||||
CosmeticType.MyPageBG => Viewer.MyPageBackgrounds.Any(m => m.Id == id),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<InventoryCommitResult> CommitAsync(CancellationToken ct = default)
|
||||
{
|
||||
ThrowIfCommitted();
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
await _dbTx.CommitAsync(ct);
|
||||
_committed = true;
|
||||
|
||||
var rewardList = BuildRewardList();
|
||||
var deltas = BuildDeltas();
|
||||
return new InventoryCommitResult(rewardList, deltas);
|
||||
}
|
||||
|
||||
private IReadOnlyList<GrantedReward> BuildRewardList()
|
||||
{
|
||||
// Pass 1 — for each currency type, find the last op (spend OR grant) that touched it
|
||||
// and emit a single entry with its post-state. Skip the sentinel item-debit currency.
|
||||
var lastCurrencyPost = new Dictionary<UserGoodsType, int>();
|
||||
var orderedTouches = new List<UserGoodsType>(); // preserve first-touch order for stable output
|
||||
|
||||
foreach (var op in _ops)
|
||||
{
|
||||
switch (op)
|
||||
{
|
||||
case SpendOp s when (int)s.Currency >= 0:
|
||||
var goodsForSpend = SpendCurrencyToGoodsType(s.Currency);
|
||||
if (!lastCurrencyPost.ContainsKey(goodsForSpend)) orderedTouches.Add(goodsForSpend);
|
||||
lastCurrencyPost[goodsForSpend] = checked((int)s.PostState);
|
||||
break;
|
||||
|
||||
case GrantOp g when IsCurrency(g.Type):
|
||||
if (!lastCurrencyPost.ContainsKey(g.Type)) orderedTouches.Add(g.Type);
|
||||
lastCurrencyPost[g.Type] = g.PostStateOrCount;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var output = new List<GrantedReward>();
|
||||
foreach (var type in orderedTouches)
|
||||
{
|
||||
output.Add(new GrantedReward((int)type, 0, lastCurrencyPost[type]));
|
||||
}
|
||||
|
||||
// Pass 2 — non-currency grants: one entry per (type, id) using LAST post-state for items
|
||||
// and cards (collapses multi-add to final count) and 1 for cosmetics.
|
||||
var nonCurrencyKey = new Dictionary<(UserGoodsType, long), int>();
|
||||
var nonCurrencyOrder = new List<(UserGoodsType, long)>();
|
||||
|
||||
foreach (var op in _ops.OfType<GrantOp>())
|
||||
{
|
||||
if (IsCurrency(op.Type)) continue;
|
||||
var key = (op.Type, op.DetailId);
|
||||
if (!nonCurrencyKey.ContainsKey(key)) nonCurrencyOrder.Add(key);
|
||||
nonCurrencyKey[key] = op.PostStateOrCount;
|
||||
}
|
||||
foreach (var (type, id) in nonCurrencyOrder)
|
||||
{
|
||||
output.Add(new GrantedReward((int)type, id, nonCurrencyKey[(type, id)]));
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
private IReadOnlyList<GrantedReward> BuildDeltas()
|
||||
=> _ops.OfType<GrantOp>()
|
||||
.Where(o => !o.IsCascade)
|
||||
.Select(o => new GrantedReward((int)o.Type, o.DetailId, o.Num))
|
||||
.ToList();
|
||||
|
||||
private static bool IsCurrency(UserGoodsType t) =>
|
||||
t is UserGoodsType.Crystal
|
||||
or UserGoodsType.Rupy
|
||||
or UserGoodsType.RedEther
|
||||
or UserGoodsType.SpotCardPoint;
|
||||
|
||||
private static UserGoodsType SpendCurrencyToGoodsType(SpendCurrency c) => c switch
|
||||
{
|
||||
SpendCurrency.Crystal => UserGoodsType.Crystal,
|
||||
SpendCurrency.Rupee => UserGoodsType.Rupy,
|
||||
SpendCurrency.RedEther => UserGoodsType.RedEther,
|
||||
SpendCurrency.SpotPoint => UserGoodsType.SpotCardPoint,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(c)),
|
||||
};
|
||||
|
||||
private static IReadOnlyList<GrantedReward> Single(UserGoodsType type, long id, int num)
|
||||
=> new[] { new GrantedReward((int)type, id, num) };
|
||||
|
||||
private void ThrowIfCommitted()
|
||||
{
|
||||
if (_committed)
|
||||
throw new InvalidOperationException("Inventory transaction already committed");
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<GrantedReward>> ApplyCardAsync(long cardId, int num, CancellationToken ct)
|
||||
{
|
||||
var owned = Viewer.Cards.FirstOrDefault(c => c.Card.Id == cardId);
|
||||
int postCount;
|
||||
if (owned is null)
|
||||
{
|
||||
var card = await _db.Cards.FirstOrDefaultAsync(c => c.Id == cardId, ct)
|
||||
?? throw new InventoryCatalogException($"Card {cardId} not in catalog");
|
||||
owned = new OwnedCardEntry { Card = card, Count = num, IsProtected = false };
|
||||
Viewer.Cards.Add(owned);
|
||||
postCount = num;
|
||||
}
|
||||
else
|
||||
{
|
||||
owned.Count += num;
|
||||
postCount = owned.Count;
|
||||
}
|
||||
|
||||
var results = new List<GrantedReward>
|
||||
{
|
||||
new((int)UserGoodsType.Card, cardId, postCount),
|
||||
};
|
||||
_ops.Add(new GrantOp(UserGoodsType.Card, cardId, num, postCount, false));
|
||||
|
||||
long lookupId = owned.Card.IsFoil ? cardId - 1 : cardId;
|
||||
var cascade = await _db.CardCosmeticRewards
|
||||
.Where(r => r.CardId == lookupId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var reward in cascade)
|
||||
{
|
||||
if (TryAddCascadeCosmetic(reward, lookupId))
|
||||
{
|
||||
results.Add(new GrantedReward((int)reward.Type, reward.CosmeticId, 1));
|
||||
_ops.Add(new GrantOp((UserGoodsType)(int)reward.Type, reward.CosmeticId, 1, 1, true));
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private bool TryAddCascadeCosmetic(CardCosmeticReward reward, long forCardId)
|
||||
{
|
||||
try
|
||||
{
|
||||
return reward.Type switch
|
||||
{
|
||||
CosmeticType.Sleeve => AddCosmeticIfMissing(Viewer.Sleeves, reward.CosmeticId, _db.Sleeves),
|
||||
CosmeticType.Emblem => AddCosmeticIfMissing(Viewer.Emblems, reward.CosmeticId, _db.Emblems),
|
||||
CosmeticType.Skin => AddCosmeticIfMissing(Viewer.LeaderSkins, reward.CosmeticId, _db.LeaderSkins),
|
||||
CosmeticType.Degree => AddCosmeticIfMissing(Viewer.Degrees, reward.CosmeticId, _db.Degrees),
|
||||
CosmeticType.MyPageBG => AddCosmeticIfMissing(Viewer.MyPageBackgrounds, reward.CosmeticId, _db.MyPageBackgrounds),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
catch (InventoryCatalogException ex)
|
||||
{
|
||||
_log.LogWarning(ex,
|
||||
"Card cascade: cosmetic {Type} {Id} for card {CardId} skipped (master row missing)",
|
||||
reward.Type, reward.CosmeticId, forCardId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool AddCosmeticIfMissing<T>(List<T> collection, long detailId, Microsoft.EntityFrameworkCore.DbSet<T> catalog) where T : class
|
||||
{
|
||||
if (collection.Any(e => GetId(e) == detailId)) return false;
|
||||
var entity = catalog.Find(checked((int)detailId))
|
||||
?? throw new InventoryCatalogException(
|
||||
$"Cosmetic id {detailId} not in catalog for type {typeof(T).Name}");
|
||||
collection.Add(entity);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static long GetId<T>(T e)
|
||||
{
|
||||
var prop = typeof(T).GetProperty("Id")
|
||||
?? throw new InvalidOperationException($"Type {typeof(T).Name} missing Id property");
|
||||
var val = prop.GetValue(e);
|
||||
return val switch { long l => l, int i => i, _ => 0 };
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (!_committed)
|
||||
{
|
||||
await _dbTx.RollbackAsync();
|
||||
_db.ChangeTracker.Clear();
|
||||
}
|
||||
await _dbTx.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Database.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-shape entry returned by <see cref="RewardGrantService.ApplyAsync"/>. Field names match
|
||||
/// the <c>reward_list</c> entries used by <c>/pack/open</c>, <c>/basic_puzzle/finish</c>, and
|
||||
/// <c>/story/*/finish</c>. reward_num is a POST-STATE TOTAL for currencies and a count for
|
||||
/// collection grants — see <see cref="Models.RewardListEntry"/>.
|
||||
/// </summary>
|
||||
public sealed record GrantedReward(int RewardType, long RewardId, int RewardNum);
|
||||
|
||||
/// <summary>
|
||||
/// Single canonical grant primitive for every <see cref="UserGoodsType"/> the server hands to a
|
||||
/// viewer. Switch on the type, mutate the appropriate viewer collection / <see cref="ViewerCurrency"/>
|
||||
/// field, return the wire-shape entries to embed in the response's <c>reward_list</c>.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>DO NOT reimplement reward dispatch in a controller or new helper.</b> This service handles
|
||||
/// RedEther, Crystal, SpotCardPoint, Item, Card (with <see cref="CardCosmeticReward"/> cascade),
|
||||
/// Sleeve, Emblem, Degree, Rupy, Skin, MyPageBG — everything except the dead-letter SpotCard /
|
||||
/// SpotCardOnlyLatestCardPack slots (use Card=5 instead). Endpoint code that takes a
|
||||
/// list of <c>(type, id, num)</c> tuples should iterate and call <see cref="ApplyAsync"/>
|
||||
/// per tuple — never switch on type yourself, never filter to "only card-typed rewards", never
|
||||
/// build a second dispatch table. Past duplicate implementations (ICardAcquisitionService in the
|
||||
/// EmulatedEntrypoint project, the first pass at /build_deck/buy) all silently dropped subsets of
|
||||
/// types and produced the same bug: wire reward visible but viewer's collection unchanged. When a
|
||||
/// new reward type comes up, add a case here. See <c>feedback_reward_grant_service</c> memory.
|
||||
/// </para>
|
||||
///
|
||||
/// Card grants additionally run the <see cref="CardCosmeticReward"/> cascade: any cosmetic
|
||||
/// associated with the granted card that the viewer doesn't yet own is granted too, and produces
|
||||
/// an additional entry in the returned list. That's why the return type is a list: most types
|
||||
/// produce one entry, Card produces 1 + N.
|
||||
///
|
||||
/// Caller is responsible for <see cref="SVSimDbContext.SaveChangesAsync(System.Threading.CancellationToken)"/> —
|
||||
/// this service only mutates the in-memory graph so a controller can stack several grants in
|
||||
/// a single transaction.
|
||||
/// </summary>
|
||||
public sealed class RewardGrantService
|
||||
{
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly ILogger<RewardGrantService> _log;
|
||||
|
||||
public RewardGrantService(SVSimDbContext db, ILogger<RewardGrantService> log)
|
||||
{
|
||||
_db = db;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<GrantedReward>> ApplyAsync(
|
||||
Viewer viewer, UserGoodsType type, long detailId, int num, CancellationToken ct = default)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case UserGoodsType.Sleeve:
|
||||
AddCosmeticIfMissing(viewer.Sleeves, detailId, _db.Sleeves);
|
||||
return Single(type, detailId, 1);
|
||||
|
||||
case UserGoodsType.Emblem:
|
||||
AddCosmeticIfMissing(viewer.Emblems, detailId, _db.Emblems);
|
||||
return Single(type, detailId, 1);
|
||||
|
||||
case UserGoodsType.Skin: // LeaderSkin in our schema
|
||||
AddCosmeticIfMissing(viewer.LeaderSkins, detailId, _db.LeaderSkins);
|
||||
return Single(type, detailId, 1);
|
||||
|
||||
case UserGoodsType.Degree:
|
||||
AddCosmeticIfMissing(viewer.Degrees, detailId, _db.Degrees);
|
||||
return Single(type, detailId, 1);
|
||||
|
||||
case UserGoodsType.MyPageBG:
|
||||
AddCosmeticIfMissing(viewer.MyPageBackgrounds, detailId, _db.MyPageBackgrounds);
|
||||
return Single(type, detailId, 1);
|
||||
|
||||
case UserGoodsType.Rupy:
|
||||
viewer.Currency.Rupees += (ulong)num;
|
||||
return Single(type, detailId, checked((int)viewer.Currency.Rupees));
|
||||
|
||||
case UserGoodsType.Crystal:
|
||||
viewer.Currency.Crystals += (ulong)num;
|
||||
return Single(type, detailId, checked((int)viewer.Currency.Crystals));
|
||||
|
||||
case UserGoodsType.RedEther:
|
||||
viewer.Currency.RedEther += (ulong)num;
|
||||
return Single(type, detailId, checked((int)viewer.Currency.RedEther));
|
||||
|
||||
case UserGoodsType.SpotCardPoint:
|
||||
viewer.Currency.SpotPoints += (ulong)num;
|
||||
return Single(type, detailId, checked((int)viewer.Currency.SpotPoints));
|
||||
|
||||
case UserGoodsType.Item:
|
||||
{
|
||||
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)detailId);
|
||||
if (owned is null)
|
||||
{
|
||||
var item = _db.Items.Find((int)detailId)
|
||||
?? throw new InvalidOperationException($"Item {detailId} not in catalog");
|
||||
viewer.Items.Add(new OwnedItemEntry { Item = item, Count = num, Viewer = viewer });
|
||||
return Single(type, detailId, num);
|
||||
}
|
||||
owned.Count += num;
|
||||
return Single(type, detailId, owned.Count);
|
||||
}
|
||||
|
||||
case UserGoodsType.Card:
|
||||
return await ApplyCardAsync(viewer, detailId, num, ct);
|
||||
|
||||
case UserGoodsType.SpotCard:
|
||||
case UserGoodsType.SpotCardOnlyLatestCardPack:
|
||||
// Spot-card-typed grants don't appear in captures — emitters always use Card=5
|
||||
// with the spot-card-specific id. These two enum slots remain unimplemented; if a
|
||||
// capture ever shows one in a reward_list we'll know to wire them up here.
|
||||
throw new NotSupportedException(
|
||||
$"{type} rewards are not yet supported — emitters use Card=5 instead.");
|
||||
|
||||
default:
|
||||
throw new NotSupportedException($"UserGoodsType {type} not yet handled by RewardGrantService");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<GrantedReward>> ApplyCardAsync(
|
||||
Viewer viewer, long cardId, int num, CancellationToken ct)
|
||||
{
|
||||
// Find-or-add OwnedCardEntry. Mirrors the primitive that used to live in
|
||||
// IPackRepository.GrantCardsToViewer — now inline so we own the in-memory-only contract.
|
||||
var owned = viewer.Cards.FirstOrDefault(c => c.Card.Id == cardId);
|
||||
int postCount;
|
||||
if (owned is null)
|
||||
{
|
||||
var card = await _db.Cards.FirstOrDefaultAsync(c => c.Id == cardId, ct)
|
||||
?? throw new InvalidOperationException($"Card {cardId} not in catalog");
|
||||
owned = new OwnedCardEntry { Card = card, Count = num, IsProtected = false };
|
||||
viewer.Cards.Add(owned);
|
||||
postCount = num;
|
||||
}
|
||||
else
|
||||
{
|
||||
owned.Count += num;
|
||||
postCount = owned.Count;
|
||||
}
|
||||
|
||||
var results = new List<GrantedReward>
|
||||
{
|
||||
new((int)UserGoodsType.Card, cardId, postCount),
|
||||
};
|
||||
|
||||
// Cascade: cosmetic mappings live on the non-foil row. If the granted card is foil
|
||||
// (card_id ends in 1, IsFoil=true), look up cascade against cardId - 1.
|
||||
long lookupId = owned.Card.IsFoil ? cardId - 1 : cardId;
|
||||
|
||||
var cascade = await _db.CardCosmeticRewards
|
||||
.Where(r => r.CardId == lookupId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var reward in cascade)
|
||||
{
|
||||
if (TryAddCascadeCosmetic(viewer, reward, lookupId))
|
||||
{
|
||||
// CosmeticType numeric values are identical to UserGoodsType — direct cast is safe.
|
||||
results.Add(new GrantedReward((int)reward.Type, reward.CosmeticId, 1));
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<GrantedReward> Single(UserGoodsType type, long id, int num)
|
||||
=> new[] { new GrantedReward((int)type, id, num) };
|
||||
|
||||
private bool TryAddCascadeCosmetic(Viewer viewer, CardCosmeticReward reward, long forCardId)
|
||||
{
|
||||
try
|
||||
{
|
||||
return reward.Type switch
|
||||
{
|
||||
CosmeticType.Sleeve => AddCosmeticIfMissing(viewer.Sleeves, reward.CosmeticId, _db.Sleeves),
|
||||
CosmeticType.Emblem => AddCosmeticIfMissing(viewer.Emblems, reward.CosmeticId, _db.Emblems),
|
||||
CosmeticType.Skin => AddCosmeticIfMissing(viewer.LeaderSkins, reward.CosmeticId, _db.LeaderSkins),
|
||||
CosmeticType.Degree => AddCosmeticIfMissing(viewer.Degrees, reward.CosmeticId, _db.Degrees),
|
||||
CosmeticType.MyPageBG => AddCosmeticIfMissing(viewer.MyPageBackgrounds, reward.CosmeticId, _db.MyPageBackgrounds),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
_log.LogWarning(ex,
|
||||
"Card cascade: cosmetic {Type} {Id} for card {CardId} skipped (master row missing)",
|
||||
reward.Type, reward.CosmeticId, forCardId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool AddCosmeticIfMissing<T>(List<T> collection, long detailId, DbSet<T> catalog) where T : class
|
||||
{
|
||||
bool alreadyOwned = collection.Any(e => GetId(e) == detailId);
|
||||
if (alreadyOwned) return false;
|
||||
|
||||
var entity = catalog.Find(checked((int)detailId))
|
||||
?? throw new InvalidOperationException(
|
||||
$"Cosmetic id {detailId} not in catalog for type {typeof(T).Name}");
|
||||
collection.Add(entity);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reflectively reads an entity's Id property — works for both <c>BaseEntity<int></c>
|
||||
/// (cosmetics) and <c>BaseEntity<long></c> (e.g. Viewer/Card) without forcing two
|
||||
/// non-generic overloads of <see cref="AddCosmeticIfMissing"/>.
|
||||
/// </summary>
|
||||
private static long GetId<T>(T e)
|
||||
{
|
||||
var prop = typeof(T).GetProperty("Id")
|
||||
?? throw new InvalidOperationException($"Type {typeof(T).Name} missing Id property");
|
||||
var val = prop.GetValue(e);
|
||||
return val switch { long l => l, int i => i, _ => 0 };
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,8 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Repositories.Mission;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.Database.Services.Inventory;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Achievement;
|
||||
using SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
@@ -22,20 +21,20 @@ public class AchievementController : SVSimController
|
||||
private readonly IMissionCatalogRepository _catalog;
|
||||
private readonly IViewerMissionStateService _state;
|
||||
private readonly IMissionAssembler _assembler;
|
||||
private readonly RewardGrantService _grantService;
|
||||
private readonly IInventoryService _inv;
|
||||
|
||||
public AchievementController(
|
||||
SVSimDbContext db,
|
||||
IMissionCatalogRepository catalog,
|
||||
IViewerMissionStateService state,
|
||||
IMissionAssembler assembler,
|
||||
RewardGrantService grantService)
|
||||
IInventoryService inv)
|
||||
{
|
||||
_db = db;
|
||||
_catalog = catalog;
|
||||
_state = state;
|
||||
_assembler = assembler;
|
||||
_grantService = grantService;
|
||||
_inv = inv;
|
||||
}
|
||||
|
||||
[HttpPost("receive_reward")]
|
||||
@@ -44,21 +43,15 @@ public class AchievementController : SVSimController
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
|
||||
// Load viewer with all the collections RewardGrantService may need to mutate.
|
||||
var viewer = await _db.Viewers
|
||||
.Include(v => v.MissionData)
|
||||
.Include(v => v.Currency)
|
||||
.Include(v => v.Cards)
|
||||
.Include(v => v.Items)
|
||||
.Include(v => v.Sleeves)
|
||||
.Include(v => v.Emblems)
|
||||
.Include(v => v.Degrees)
|
||||
.Include(v => v.LeaderSkins)
|
||||
.Include(v => v.MyPageBackgrounds)
|
||||
.AsSplitQuery()
|
||||
.FirstAsync(v => v.Id == viewerId, ct);
|
||||
// EnsureCurrentAsync needs a viewer id — use a lightweight pre-check load then
|
||||
// materialize state before opening the inventory tx.
|
||||
var viewerIdCheck = await _db.Viewers
|
||||
.Where(v => v.Id == viewerId)
|
||||
.Select(v => v.Id)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (viewerIdCheck == 0) return Unauthorized();
|
||||
|
||||
await _state.EnsureCurrentAsync(viewer.Id, ct);
|
||||
await _state.EnsureCurrentAsync(viewerId, ct);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
// Re-read viewer's achievement for this type after state-service materialization.
|
||||
@@ -75,9 +68,10 @@ public class AchievementController : SVSimController
|
||||
return Ok(new { result_code = FailureResultCode });
|
||||
}
|
||||
|
||||
// Grant via the canonical RewardGrantService primitive.
|
||||
var granted = await _grantService.ApplyAsync(
|
||||
viewer,
|
||||
// Open inventory tx and grant via InventoryService.
|
||||
await using var tx = await _inv.BeginAsync(viewerId, ct);
|
||||
|
||||
var granted = await tx.GrantAsync(
|
||||
(UserGoodsType)catalogRow.RewardType,
|
||||
catalogRow.RewardDetailId,
|
||||
catalogRow.RewardNumber,
|
||||
@@ -99,9 +93,9 @@ public class AchievementController : SVSimController
|
||||
}
|
||||
ach.NowAchievedLevel = request.Level;
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
await tx.CommitAsync(ct);
|
||||
|
||||
var dto = await _assembler.BuildAsync(viewer, ct);
|
||||
var dto = await _assembler.BuildAsync(tx.Viewer, ct);
|
||||
var resp = new AchievementReceiveRewardResponse
|
||||
{
|
||||
UserMissionList = dto.UserMissionList,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SVSim.BattleNode.Bridge;
|
||||
using SVSim.EmulatedEntrypoint.Matching;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ArenaTwoPick;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaTwoPick;
|
||||
using SVSim.EmulatedEntrypoint.Services;
|
||||
@@ -9,13 +11,91 @@ namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
public class ArenaTwoPickBattleController : SVSimController
|
||||
{
|
||||
private readonly IArenaTwoPickService _svc;
|
||||
public ArenaTwoPickBattleController(IArenaTwoPickService svc) => _svc = svc;
|
||||
private readonly IMatchingBridge _matching;
|
||||
private readonly IMatchContextBuilder _matchContextBuilder;
|
||||
private readonly IMatchingPairUpService _pairUp;
|
||||
private readonly BattleNodeOptions _battleNodeOptions;
|
||||
|
||||
public ArenaTwoPickBattleController(
|
||||
IArenaTwoPickService svc,
|
||||
IMatchingBridge matching,
|
||||
IMatchContextBuilder matchContextBuilder,
|
||||
IMatchingPairUpService pairUp,
|
||||
BattleNodeOptions battleNodeOptions)
|
||||
{
|
||||
_svc = svc;
|
||||
_matching = matching;
|
||||
_matchContextBuilder = matchContextBuilder;
|
||||
_pairUp = pairUp;
|
||||
_battleNodeOptions = battleNodeOptions;
|
||||
}
|
||||
|
||||
[HttpPost("do_matching")]
|
||||
public IActionResult DoMatching([FromBody] DoMatchingRequest req)
|
||||
public async Task<IActionResult> DoMatching(
|
||||
[FromBody] DoMatchingRequest req,
|
||||
[FromQuery(Name = "scripted")] string? scripted = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!TryGetViewerId(out _)) return Unauthorized();
|
||||
return Ok(new DoMatchingResponseDto());
|
||||
if (!TryGetViewerId(out var vid)) return Unauthorized();
|
||||
// Accept "1" or "true" (case-insensitive) as opt-in for the legacy Scripted path.
|
||||
// ASP.NET's default bool binder rejects "1", so do a permissive parse here.
|
||||
// The server-side BattleNodeOptions.SoloDefaultsToScripted flag is the other
|
||||
// route — it bypasses pair-up for every solo poll, useful when the live client
|
||||
// (which can't append query params) needs a Scripted match.
|
||||
var useScripted = (scripted is not null
|
||||
&& (scripted == "1" || string.Equals(scripted, "true", StringComparison.OrdinalIgnoreCase)))
|
||||
|| _battleNodeOptions.SoloDefaultsToScripted;
|
||||
try
|
||||
{
|
||||
var ctx = await _matchContextBuilder.BuildForTwoPickAsync(vid);
|
||||
|
||||
if (useScripted)
|
||||
{
|
||||
var scriptedMatch = _matching.RegisterBattle(
|
||||
new SVSim.BattleNode.Bridge.BattlePlayer(vid, ctx),
|
||||
p2: null,
|
||||
SVSim.BattleNode.Sessions.BattleType.Scripted);
|
||||
return Ok(new DoMatchingResponseDto
|
||||
{
|
||||
MatchingState = 3004,
|
||||
BattleId = scriptedMatch.BattleId,
|
||||
NodeServerUrl = scriptedMatch.NodeServerUrl,
|
||||
});
|
||||
}
|
||||
|
||||
var paired = await _pairUp.TryPairAsync(
|
||||
"arena_two_pick_battle",
|
||||
new SVSim.BattleNode.Bridge.BattlePlayer(vid, ctx),
|
||||
ct);
|
||||
if (paired is null)
|
||||
{
|
||||
// 3002 = RC_BATTLE_MATCHING_RETRY: client polls again. 3001 is ILLEGAL
|
||||
// and shows an error dialog on the client side. node_server_url must be
|
||||
// present (the client's DoMatchingBase.SettingDoMatchingData calls
|
||||
// .ToString() on it without a Keys.Contains guard); prod sends "" while
|
||||
// waiting and the real URL only on SUCCEEDED. battle_id stays absent
|
||||
// (its accessor IS guarded).
|
||||
return Ok(new DoMatchingResponseDto
|
||||
{
|
||||
MatchingState = 3002,
|
||||
NodeServerUrl = "",
|
||||
});
|
||||
}
|
||||
|
||||
// Owner (first arriver, cache hit) gets 3007 = RC_BATTLE_MATCHING_SUCCEEDED_OWNER;
|
||||
// joiner (second arriver who triggered the pair) gets 3004 = RC_BATTLE_MATCHING_SUCCEEDED.
|
||||
// See PairUpResult docs for why this split is observationally inert in TK2 today.
|
||||
return Ok(new DoMatchingResponseDto
|
||||
{
|
||||
MatchingState = paired.IsOwner ? 3007 : 3004,
|
||||
BattleId = paired.Match.BattleId,
|
||||
NodeServerUrl = paired.Match.NodeServerUrl,
|
||||
});
|
||||
}
|
||||
catch (ArenaTwoPickException ex)
|
||||
{
|
||||
return BadRequest(new { error_code = ex.ErrorCode });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("finish")]
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Repositories.BuildDeck;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.Database.Services.Inventory;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.BuildDeck;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.BuildDeck;
|
||||
@@ -20,39 +19,16 @@ namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
public class BuildDeckController : SVSimController
|
||||
{
|
||||
private readonly IBuildDeckRepository _repo;
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly RewardGrantService _rewards;
|
||||
private readonly ICurrencySpendService _spend;
|
||||
private readonly IInventoryService _inv;
|
||||
|
||||
public BuildDeckController(
|
||||
IBuildDeckRepository repo,
|
||||
SVSimDbContext db,
|
||||
RewardGrantService rewards,
|
||||
ICurrencySpendService spend)
|
||||
IInventoryService inv)
|
||||
{
|
||||
_repo = repo;
|
||||
_db = db;
|
||||
_rewards = rewards;
|
||||
_spend = spend;
|
||||
_inv = inv;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the viewer with the full cosmetic / inventory graph + BuildDeckPurchases. This is
|
||||
/// the single load /build_deck/buy makes; every subsequent mutation operates on the returned
|
||||
/// instance and the controller saves once at the end.
|
||||
/// </summary>
|
||||
private Task<Viewer> LoadViewerGraphAsync(long viewerId) => _db.Viewers
|
||||
.Include(v => v.Cards).ThenInclude(c => c.Card)
|
||||
.Include(v => v.LeaderSkins)
|
||||
.Include(v => v.Sleeves)
|
||||
.Include(v => v.Emblems)
|
||||
.Include(v => v.Degrees)
|
||||
.Include(v => v.MyPageBackgrounds)
|
||||
.Include(v => v.Items).ThenInclude(i => i.Item)
|
||||
.Include(v => v.BuildDeckPurchases)
|
||||
.AsSplitQuery()
|
||||
.FirstAsync(v => v.Id == viewerId);
|
||||
|
||||
// The wire shape for /build_deck/info has `data` as a bare collection of series, not a
|
||||
// DTO with a `series_list` field. The client (BuildDeckPurchaseInfoTask.Parse) iterates
|
||||
// `data` directly via numeric indexer:
|
||||
@@ -194,60 +170,45 @@ public class BuildDeckController : SVSimController
|
||||
break;
|
||||
}
|
||||
|
||||
// Single viewer load with the full graph — every subsequent mutation (currency debit,
|
||||
// purchase counter, card grants, cosmetic grants) operates on this one in-memory instance
|
||||
// so we can save once at the end.
|
||||
var viewer = await LoadViewerGraphAsync(viewerId);
|
||||
var rewardList = new List<RewardListEntry>();
|
||||
// Open the inventory transaction — loads canonical graph + BuildDeckPurchases.
|
||||
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted,
|
||||
cfg => cfg.WithInclude(v => v.BuildDeckPurchases));
|
||||
var viewer = tx.Viewer;
|
||||
|
||||
// Debit + post-state currency entry
|
||||
// Debit currency
|
||||
if (request.SalesType == 1)
|
||||
{
|
||||
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, priceCrystal!.Value);
|
||||
var r = await tx.TrySpendAsync(SpendCurrency.Crystal, priceCrystal!.Value);
|
||||
if (!r.Success) return BadRequest(new { error = "insufficient_crystals" });
|
||||
rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)r.PostStateTotal });
|
||||
}
|
||||
else if (request.SalesType == 2)
|
||||
{
|
||||
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, priceRupy!.Value);
|
||||
var r = await tx.TrySpendAsync(SpendCurrency.Rupee, priceRupy!.Value);
|
||||
if (!r.Success) return BadRequest(new { error = "insufficient_rupees" });
|
||||
rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)r.PostStateTotal });
|
||||
}
|
||||
// sales_type == 0 (free): no debit, no currency entry
|
||||
// sales_type == 0 (free): no debit
|
||||
|
||||
// Compute series purchase total BEFORE this buy
|
||||
int prevSeriesCount = product.Series!.Products
|
||||
.Sum(p => purchases.TryGetValue(p.Id, out var v) ? v.PurchaseCount : 0);
|
||||
int newSeriesCount = prevSeriesCount + 1;
|
||||
|
||||
// Increment purchase counter directly on the tracked viewer (we already loaded
|
||||
// BuildDeckPurchases via LoadViewerGraphAsync). The repo's IncrementPurchaseCount would
|
||||
// re-attach to the same instance and trigger an extra save — inlining keeps the
|
||||
// controller's single-save model intact.
|
||||
// Increment purchase counter on tx.Viewer (tx loaded BuildDeckPurchases via WithInclude).
|
||||
var purchaseRow = viewer.BuildDeckPurchases.FirstOrDefault(p => p.ProductId == product.Id);
|
||||
if (purchaseRow is null)
|
||||
viewer.BuildDeckPurchases.Add(new ViewerBuildDeckProductPurchase { ProductId = product.Id, PurchaseCount = 1 });
|
||||
else
|
||||
purchaseRow.PurchaseCount += 1;
|
||||
|
||||
// Grant the 40 deck cards. Bucket by CardId so duplicate (CardId, IsSpot) rows don't
|
||||
// emit redundant reward_list entries — ApplyAsync(Card, ...) runs the cosmetic cascade
|
||||
// and returns a post-state-total entry per call.
|
||||
var deckGrants = product.Cards
|
||||
.GroupBy(c => c.CardId)
|
||||
.Select(g => (UserGoodsType.Card, g.Key, g.Sum(c => c.Number)));
|
||||
await ApplyRewardsAsync(viewer, deckGrants, rewardList);
|
||||
// Grant deck cards (grouped by CardId)
|
||||
foreach (var grp in product.Cards.GroupBy(c => c.CardId))
|
||||
await tx.GrantAsync(UserGoodsType.Card, grp.Key, grp.Sum(c => c.Number));
|
||||
|
||||
// Per-buy rewards from the product catalog: sleeve, emblem, skin, sometimes extra cards
|
||||
// (Set 4 grants 3 copies of the featured card as a type=5 reward).
|
||||
await ApplyRewardsAsync(viewer, product.Rewards
|
||||
.OrderBy(r => r.RewardIndex)
|
||||
.Select(r => ((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber)),
|
||||
rewardList);
|
||||
// Per-buy rewards
|
||||
foreach (var r in product.Rewards.OrderBy(r => r.RewardIndex))
|
||||
await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
|
||||
|
||||
// Series-reward tier crossings: tiers where prevSeriesCount < TierIndex <= newSeriesCount.
|
||||
// Captured tiers include type 4 (Item), 5 (Card), 6 (Sleeve), 7 (Emblem) — granting them
|
||||
// all uniformly avoids the earlier card-only path that dropped non-card tier rewards.
|
||||
// Series-reward tier crossings
|
||||
var crossedTiers = product.Series.SeriesRewards
|
||||
.Where(r => r.TierIndex > prevSeriesCount && r.TierIndex <= newSeriesCount)
|
||||
.GroupBy(r => r.TierIndex)
|
||||
@@ -257,13 +218,9 @@ public class BuildDeckController : SVSimController
|
||||
var seriesRewards = new List<BuildDeckProductRewardDto>();
|
||||
foreach (var tier in crossedTiers)
|
||||
{
|
||||
await ApplyRewardsAsync(viewer, tier
|
||||
.OrderBy(r => r.ItemIndex)
|
||||
.Select(r => ((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber)),
|
||||
rewardList);
|
||||
|
||||
foreach (var item in tier.OrderBy(r => r.ItemIndex))
|
||||
{
|
||||
await tx.GrantAsync((UserGoodsType)item.RewardType, item.RewardDetailId, item.RewardNumber);
|
||||
seriesRewards.Add(new BuildDeckProductRewardDto
|
||||
{
|
||||
RewardType = item.RewardType,
|
||||
@@ -274,39 +231,17 @@ public class BuildDeckController : SVSimController
|
||||
}
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
var result = await tx.CommitAsync(HttpContext.RequestAborted);
|
||||
|
||||
return new BuildDeckBuyResponse
|
||||
{
|
||||
RewardList = rewardList,
|
||||
RewardList = result.RewardList
|
||||
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
|
||||
.ToList(),
|
||||
SeriesRewards = seriesRewards,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispatches each (type, id, num) tuple through <see cref="RewardGrantService.ApplyAsync"/>
|
||||
/// and appends the resulting wire entries to <paramref name="rewardList"/>. Caller saves.
|
||||
/// </summary>
|
||||
private async Task ApplyRewardsAsync(
|
||||
Viewer viewer,
|
||||
IEnumerable<(UserGoodsType Type, long DetailId, int Number)> rewards,
|
||||
List<RewardListEntry> rewardList)
|
||||
{
|
||||
foreach (var (type, detailId, number) in rewards)
|
||||
{
|
||||
var granted = await _rewards.ApplyAsync(viewer, type, detailId, number);
|
||||
foreach (var g in granted)
|
||||
{
|
||||
rewardList.Add(new RewardListEntry
|
||||
{
|
||||
RewardType = g.RewardType,
|
||||
RewardId = g.RewardId,
|
||||
RewardNum = g.RewardNum,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("get_purchase_count")]
|
||||
public async Task<ActionResult<BuildDeckGetPurchaseCountResponse>> GetPurchaseCount(
|
||||
BuildDeckGetPurchaseCountRequest request)
|
||||
|
||||
@@ -38,12 +38,36 @@ public class CheckController : SVSimController
|
||||
?? throw new InvalidOperationException("Auth handler must set viewer in context.");
|
||||
Viewer fullViewer = await _viewerRepository.GetViewerWithSocials(viewer.Id) ?? viewer;
|
||||
|
||||
// Wipe-and-resignup reconciliation: /tool/signup is anonymous on the wire and can't see
|
||||
// the Steam ticket, so a freshly-wiped client lands a blank V_new keyed on its new UDID
|
||||
// while the Steam handler on this very request resolves to the original V_old. The client
|
||||
// has already written V_new.Id into Certification.ViewerId from the signup response; left
|
||||
// alone, it stays wrong forever (NormalTask.Parse never reads data_headers.viewer_id —
|
||||
// only SignUpTask / GameStartCheckTask.rewrite_viewer_id / the social-chain tasks do).
|
||||
// Detect the mismatch by re-looking-up the UDID-keyed viewer and emit rewrite_viewer_id
|
||||
// when it disagrees with the auth-resolved one.
|
||||
long? rewriteViewerId = null;
|
||||
Guid? udid = HttpContext.GetUdid();
|
||||
if (udid is Guid u && u != Guid.Empty)
|
||||
{
|
||||
Viewer? udidViewer = await _viewerRepository.GetViewerByUdid(u);
|
||||
if (udidViewer is not null && udidViewer.Id != fullViewer.Id)
|
||||
{
|
||||
rewriteViewerId = fullViewer.Id;
|
||||
// Reclaim the orphan: transfer the fresh UDID onto the Steam-resolved viewer
|
||||
// and delete the just-created blank anonymous one. Future GetViewerByUdid
|
||||
// calls then short-circuit to V_old without going through the Steam handler.
|
||||
await _viewerRepository.MergeAnonymousViewerInto(udidViewer.Id, fullViewer.Id);
|
||||
}
|
||||
}
|
||||
|
||||
return new GameStartResponse
|
||||
{
|
||||
NowViewerId = fullViewer.Id,
|
||||
NowName = fullViewer.DisplayName,
|
||||
NowTutorialStep = fullViewer.MissionData.TutorialState.ToString(),
|
||||
IsSetTransitionPassword = true,
|
||||
RewriteViewerId = rewriteViewerId,
|
||||
// Stub rank map until per-format ranks are persisted (prod observed: "1"/"2"/"4"
|
||||
// keys mapping to RankName_010 / RankName_017). Empty dict here may be safe but
|
||||
// we don't yet know which client paths read this — match prod stub.
|
||||
|
||||
@@ -2,7 +2,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.Database.Services.Inventory;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Gift;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Gift;
|
||||
|
||||
@@ -27,12 +27,12 @@ public class GiftController : SVSimController
|
||||
};
|
||||
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly RewardGrantService _rewards;
|
||||
private readonly IInventoryService _inv;
|
||||
|
||||
public GiftController(SVSimDbContext db, RewardGrantService rewards)
|
||||
public GiftController(SVSimDbContext db, IInventoryService inv)
|
||||
{
|
||||
_db = db;
|
||||
_rewards = rewards;
|
||||
_inv = inv;
|
||||
}
|
||||
|
||||
[HttpPost("/tutorial/gift_top")]
|
||||
@@ -71,25 +71,7 @@ public class GiftController : SVSimController
|
||||
|
||||
var requestedIds = request.PresentIdArray.ToHashSet();
|
||||
|
||||
// Load viewer with the collections RewardGrantService mutates. Crystals/Rupees live on
|
||||
// viewer.Currency (owned, auto-loads); Items live on viewer.Items (owned collection).
|
||||
// MissionData is an owned type and auto-loads, but Include is listed explicitly to match
|
||||
// the pattern in TutorialController.Update and to make the intent clear.
|
||||
// AsSplitQuery is the default-safe pattern when including viewer collections
|
||||
// (project memory: project_ef_split_query).
|
||||
//
|
||||
// ThenInclude(i => i.Item) is load-bearing: OwnedItemEntry.Item is a separate non-owned
|
||||
// entity whose default initialiser is `new ItemEntry()` (Id=0). Without the explicit
|
||||
// ThenInclude, RewardGrantService.ApplyAsync's `FirstOrDefault(i => i.Item.Id == ...)`
|
||||
// never matches a pre-existing row → falls through to add a duplicate → (ViewerId, ItemId)
|
||||
// unique index throws on SaveChanges (project_ef_nav_include_pitfall).
|
||||
var viewer = await _db.Viewers
|
||||
.Include(v => v.Items).ThenInclude(i => i.Item)
|
||||
.Include(v => v.MissionData)
|
||||
.AsSplitQuery()
|
||||
.FirstAsync(v => v.Id == viewerId);
|
||||
|
||||
// Resolve which of the requested ids are still claimable for this viewer.
|
||||
// Resolve which of the requested ids are still claimable for this viewer before opening tx.
|
||||
var alreadyClaimedList = await _db.ViewerClaimedTutorialGifts
|
||||
.Where(g => g.ViewerId == viewerId && requestedIds.Contains(g.PresentId))
|
||||
.Select(g => g.PresentId)
|
||||
@@ -100,23 +82,43 @@ public class GiftController : SVSimController
|
||||
.Where(p => requestedIds.Contains(p.PresentId) && !alreadyClaimed.Contains(p.PresentId))
|
||||
.ToList();
|
||||
|
||||
// Apply grants via the canonical primitive. Pass the loaded viewer, NOT viewerId.
|
||||
// Open inventory tx with MissionData loaded for tutorial-step advance.
|
||||
await using var tx = await _inv.BeginAsync(viewerId, configure:
|
||||
cfg => cfg.WithInclude(v => v.MissionData));
|
||||
|
||||
// Apply grants via tx. Collect post-state per (type, detailId) for reward_list.
|
||||
// Each GrantAsync returns a list of GrantedReward with post-state totals; for currencies
|
||||
// only one entry is returned; for cards the cascade may return more entries (card + cosmetics).
|
||||
// reward_list must carry post-state totals (client does direct assignment).
|
||||
var rewardListEntries = new List<GiftRewardListEntry>();
|
||||
foreach (var p in toClaim)
|
||||
{
|
||||
var goodsType = WireRewardTypeToUserGoodsType(int.Parse(p.RewardType));
|
||||
await _rewards.ApplyAsync(viewer, goodsType, long.Parse(p.RewardDetailId), int.Parse(p.RewardCount));
|
||||
var granted = await tx.GrantAsync(goodsType, long.Parse(p.RewardDetailId), int.Parse(p.RewardCount));
|
||||
// Use the first granted entry's post-state for the top-level gift reward_list entry.
|
||||
// Gift rewards are currencies and items only (no cards in TutorialGifts), so granted
|
||||
// always has exactly one element. The post-state total is already correct from tx.
|
||||
if (granted.Count > 0)
|
||||
{
|
||||
rewardListEntries.Add(new GiftRewardListEntry
|
||||
{
|
||||
RewardType = p.RewardType,
|
||||
RewardId = p.RewardDetailId,
|
||||
RewardNum = granted[0].RewardNum.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Advance tutorial state from 31 → 41 as a side-effect of this claim (no separate
|
||||
// /tutorial/update → 41 call appears in the prod capture). Preserve max — don't downgrade
|
||||
// viewers who are already past step 41.
|
||||
const int GiftReceiveTutorialStep = 41;
|
||||
if (viewer.MissionData.TutorialState < GiftReceiveTutorialStep)
|
||||
if (tx.Viewer.MissionData.TutorialState < GiftReceiveTutorialStep)
|
||||
{
|
||||
viewer.MissionData.TutorialState = GiftReceiveTutorialStep;
|
||||
tx.Viewer.MissionData.TutorialState = GiftReceiveTutorialStep;
|
||||
}
|
||||
|
||||
// Persist claim receipts in the same transaction.
|
||||
// Persist claim receipts inside the same tx.
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var p in toClaim)
|
||||
{
|
||||
@@ -127,7 +129,7 @@ public class GiftController : SVSimController
|
||||
ClaimedAt = now,
|
||||
});
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
await tx.CommitAsync();
|
||||
|
||||
var nowString = now.ToString("yyyy-MM-dd HH:mm:ss");
|
||||
var allClaimedList = await _db.ViewerClaimedTutorialGifts
|
||||
@@ -176,54 +178,18 @@ public class GiftController : SVSimController
|
||||
// Hardcoding false hid the badge after partial claims even though present_list still
|
||||
// carried unclaimed entries.
|
||||
IsUnreceivedPresent = unclaimedPresents.Count > 0,
|
||||
// reward_list entries must carry POST-STATE TOTALS, not gift deltas.
|
||||
// The client's PlayerStaticData.UpdateHaveUserGoodsNumByJsonData does direct
|
||||
// assignment on each entry's reward_num — emitting the delta would clobber
|
||||
// the client-side cached balance down to the gift amount until the next /load/index.
|
||||
// reward_list entries carry POST-STATE TOTALS (from tx.GrantAsync).
|
||||
// See project memory: project_wire_reward_list_post_state.
|
||||
//
|
||||
// Iterate `toClaim` so idempotent re-receive doesn't re-emit post-state entries
|
||||
// the client would direct-assign again (no-op on currency, but redundant traffic
|
||||
// and risk of misinterpretation on item counts).
|
||||
RewardList = toClaim
|
||||
.Select(p => new GiftRewardListEntry
|
||||
{
|
||||
RewardType = p.RewardType,
|
||||
RewardId = p.RewardDetailId,
|
||||
RewardNum = ResolvePostStateRewardNum(p, viewer),
|
||||
})
|
||||
.ToList(),
|
||||
// Iterate toClaim so idempotent re-receive doesn't re-emit post-state entries.
|
||||
RewardList = rewardListEntries,
|
||||
// Echo the persisted state, not a hardcoded 41. The state may already be past 41
|
||||
// for replay/edge-case calls (the Math.Max-preserve block above keeps it stable);
|
||||
// emitting 41 anyway would surface a regressed step to the client and desync the
|
||||
// tutorial-state machine.
|
||||
TutorialStep = viewer.MissionData.TutorialState,
|
||||
TutorialStep = tx.Viewer.MissionData.TutorialState,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the post-grant viewer balance for the given gift entry, not the gift delta.
|
||||
/// reward_list on wire carries post-state totals (client does direct assignment).
|
||||
/// </summary>
|
||||
private static string ResolvePostStateRewardNum(PresentDto gift, SVSim.Database.Models.Viewer viewer)
|
||||
{
|
||||
switch (gift.RewardType)
|
||||
{
|
||||
case "1": // Crystal
|
||||
return ((long)viewer.Currency.Crystals).ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
case "9": // Rupy
|
||||
return ((long)viewer.Currency.Rupees).ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
case "4": // Item
|
||||
{
|
||||
int itemId = int.Parse(gift.RewardDetailId, System.Globalization.CultureInfo.InvariantCulture);
|
||||
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == itemId);
|
||||
return ((long)(owned?.Count ?? 0)).ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
default:
|
||||
return gift.RewardCount; // unknown type — fall back to gift count (better than 0)
|
||||
}
|
||||
}
|
||||
|
||||
private static UserGoodsType WireRewardTypeToUserGoodsType(int wireType) => wireType switch
|
||||
{
|
||||
1 => UserGoodsType.Crystal,
|
||||
|
||||
@@ -4,6 +4,7 @@ using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.Database.Services.Inventory;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ItemPurchase;
|
||||
@@ -21,16 +22,14 @@ namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
public class ItemPurchaseController : SVSimController
|
||||
{
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly RewardGrantService _rewards;
|
||||
private readonly IInventoryService _inv;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly ICurrencySpendService _spend;
|
||||
|
||||
public ItemPurchaseController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time, ICurrencySpendService spend)
|
||||
public ItemPurchaseController(SVSimDbContext db, IInventoryService inv, TimeProvider time)
|
||||
{
|
||||
_db = db;
|
||||
_rewards = rewards;
|
||||
_inv = inv;
|
||||
_time = time;
|
||||
_spend = spend;
|
||||
}
|
||||
|
||||
[HttpPost("info")]
|
||||
@@ -115,28 +114,17 @@ public class ItemPurchaseController : SVSimController
|
||||
if (rest <= 0)
|
||||
return BadRequest(new { error = "sold_out" });
|
||||
|
||||
var viewer = await LoadViewerGraphAsync(viewerId);
|
||||
var rewardList = new List<RewardListEntry>();
|
||||
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted);
|
||||
|
||||
// Debit the require side. RewardGrantService is grant-only, so handle this inline.
|
||||
var debit = await TryDebit(viewer, (UserGoodsType)entry.RequireItemType, entry.RequireItemId, entry.RequireItemNum);
|
||||
if (debit.Error is not null) return BadRequest(new { error = debit.Error });
|
||||
if (debit.PostState is not null) rewardList.Add(debit.PostState);
|
||||
// Debit the require side via the tx.
|
||||
var debit = await tx.TryDebitAsync(
|
||||
(UserGoodsType)entry.RequireItemType, entry.RequireItemId, entry.RequireItemNum);
|
||||
if (!debit.Success) return BadRequest(new { error = MapDebitError(entry.RequireItemType) });
|
||||
|
||||
// Grant the purchase side through the central dispatcher.
|
||||
var granted = await _rewards.ApplyAsync(viewer,
|
||||
(UserGoodsType)entry.PurchaseItemType, entry.PurchaseItemId, entry.PurchaseItemNum);
|
||||
foreach (var g in granted)
|
||||
{
|
||||
rewardList.Add(new RewardListEntry
|
||||
{
|
||||
RewardType = g.RewardType,
|
||||
RewardId = g.RewardId,
|
||||
RewardNum = g.RewardNum,
|
||||
});
|
||||
}
|
||||
// Grant the purchase side.
|
||||
await tx.GrantAsync((UserGoodsType)entry.PurchaseItemType, entry.PurchaseItemId, entry.PurchaseItemNum);
|
||||
|
||||
// Increment the per-period counter.
|
||||
// Increment the per-period counter (tracked via _db, outside the inventory tx).
|
||||
if (counter is null)
|
||||
{
|
||||
_db.ViewerEventCounters.Add(new ViewerEventCounter
|
||||
@@ -151,52 +139,27 @@ public class ItemPurchaseController : SVSimController
|
||||
{
|
||||
counter.Count++;
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return new ItemPurchasePurchaseResponse { RewardList = rewardList };
|
||||
}
|
||||
var result = await tx.CommitAsync(HttpContext.RequestAborted);
|
||||
|
||||
/// <summary>
|
||||
/// Debit <paramref name="num"/> of (<paramref name="type"/>, <paramref name="detailId"/>)
|
||||
/// from the viewer, returning a post-state-aware <see cref="RewardListEntry"/> the client
|
||||
/// uses to refresh its cached count. Returns an error string on insufficient balance.
|
||||
/// </summary>
|
||||
private async Task<(RewardListEntry? PostState, string? Error)> TryDebit(
|
||||
Viewer viewer, UserGoodsType type, long detailId, int num)
|
||||
{
|
||||
switch (type)
|
||||
return new ItemPurchasePurchaseResponse
|
||||
{
|
||||
case UserGoodsType.RedEther:
|
||||
{
|
||||
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.RedEther, num);
|
||||
if (!r.Success) return (null, "insufficient_red_ether");
|
||||
return (new RewardListEntry { RewardType = 1, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null);
|
||||
}
|
||||
case UserGoodsType.Crystal:
|
||||
{
|
||||
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, num);
|
||||
if (!r.Success) return (null, "insufficient_crystals");
|
||||
return (new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null);
|
||||
}
|
||||
case UserGoodsType.Rupy:
|
||||
{
|
||||
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, num);
|
||||
if (!r.Success) return (null, "insufficient_rupees");
|
||||
return (new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null);
|
||||
}
|
||||
case UserGoodsType.Item:
|
||||
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)detailId);
|
||||
if (owned is null || owned.Count < num)
|
||||
return (null, "insufficient_item");
|
||||
owned.Count -= num;
|
||||
return (new RewardListEntry { RewardType = 4, RewardId = detailId, RewardNum = owned.Count }, null);
|
||||
|
||||
default:
|
||||
return (null, $"debit_type_not_supported:{type}");
|
||||
}
|
||||
RewardList = result.RewardList
|
||||
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
|
||||
.ToList(),
|
||||
};
|
||||
}
|
||||
|
||||
private static string MapDebitError(int requireType) => requireType switch
|
||||
{
|
||||
(int)UserGoodsType.RedEther => "insufficient_red_ether",
|
||||
(int)UserGoodsType.Crystal => "insufficient_crystals",
|
||||
(int)UserGoodsType.Rupy => "insufficient_rupees",
|
||||
(int)UserGoodsType.Item => "insufficient_item",
|
||||
_ => "debit_type_not_supported",
|
||||
};
|
||||
|
||||
private static string CounterKey(int purchaseId) => $"item_purchase:{purchaseId}";
|
||||
|
||||
private static int CounterCount(List<ViewerEventCounter> counters, ItemPurchaseCatalogEntry entry, string monthKey)
|
||||
@@ -204,15 +167,4 @@ public class ItemPurchaseController : SVSimController
|
||||
var period = entry.IsMonthlyReset ? monthKey : JstPeriod.AllTime;
|
||||
return counters.FirstOrDefault(c => c.EventKey == CounterKey(entry.Id) && c.Period == period)?.Count ?? 0;
|
||||
}
|
||||
|
||||
private Task<Viewer> LoadViewerGraphAsync(long viewerId) => _db.Viewers
|
||||
.Include(v => v.Items).ThenInclude(i => i.Item)
|
||||
.Include(v => v.Cards).ThenInclude(c => c.Card)
|
||||
.Include(v => v.Sleeves)
|
||||
.Include(v => v.Emblems)
|
||||
.Include(v => v.LeaderSkins)
|
||||
.Include(v => v.Degrees)
|
||||
.Include(v => v.MyPageBackgrounds)
|
||||
.AsSplitQuery()
|
||||
.FirstAsync(v => v.Id == viewerId);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Repositories.Collectibles;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.Database.Services.Inventory;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.LeaderSkin;
|
||||
@@ -29,19 +30,15 @@ namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
public class LeaderSkinController : SVSimController
|
||||
{
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly RewardGrantService _rewards;
|
||||
private readonly IInventoryService _inv;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly ICurrencySpendService _spend;
|
||||
private readonly IViewerEntitlements _entitlements;
|
||||
private readonly ICollectionRepository _collection;
|
||||
|
||||
public LeaderSkinController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time, ICurrencySpendService spend, IViewerEntitlements entitlements, ICollectionRepository collection)
|
||||
public LeaderSkinController(SVSimDbContext db, IInventoryService inv, TimeProvider time, ICollectionRepository collection)
|
||||
{
|
||||
_db = db;
|
||||
_rewards = rewards;
|
||||
_inv = inv;
|
||||
_time = time;
|
||||
_spend = spend;
|
||||
_entitlements = entitlements;
|
||||
_collection = collection;
|
||||
}
|
||||
|
||||
@@ -69,7 +66,8 @@ public class LeaderSkinController : SVSimController
|
||||
var skin = await _db.LeaderSkins.FindAsync(request.LeaderSkinId);
|
||||
if (skin is null) return BadRequest(new { error = "unknown_skin" });
|
||||
if (skin.ClassId != request.ClassId) return BadRequest(new { error = "skin_class_mismatch" });
|
||||
if (!_entitlements.OwnsCosmetic(viewer, CosmeticType.Skin, skin.Id))
|
||||
var cosmeticsForSet = await _inv.EffectiveCosmeticsAsync(viewer);
|
||||
if (!cosmeticsForSet.OwnedLeaderSkinIds.Contains(skin.Id))
|
||||
return BadRequest(new { error = "skin_not_owned" });
|
||||
|
||||
classData.LeaderSkin = skin;
|
||||
@@ -88,18 +86,13 @@ public class LeaderSkinController : SVSimController
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
|
||||
if (_entitlements.IsFreeplay)
|
||||
{
|
||||
var all = (await _collection.GetLeaderSkins()).Select(s => s.Id).OrderBy(id => id).ToList();
|
||||
return new LeaderSkinIdsResponse { UserLeaderSkinIds = all };
|
||||
}
|
||||
|
||||
var ids = await _db.Viewers
|
||||
.Where(v => v.Id == viewerId)
|
||||
.SelectMany(v => v.LeaderSkins.Select(s => s.Id))
|
||||
.OrderBy(id => id)
|
||||
.ToListAsync();
|
||||
var viewer = await _db.Viewers
|
||||
.Include(v => v.LeaderSkins)
|
||||
.FirstOrDefaultAsync(v => v.Id == viewerId);
|
||||
if (viewer is null) return Unauthorized();
|
||||
|
||||
var cosmetics = await _inv.EffectiveCosmeticsAsync(viewer);
|
||||
var ids = cosmetics.OwnedLeaderSkinIds.OrderBy(id => id).ToList();
|
||||
return new LeaderSkinIdsResponse { UserLeaderSkinIds = ids };
|
||||
}
|
||||
|
||||
@@ -108,12 +101,13 @@ public class LeaderSkinController : SVSimController
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
|
||||
var ownedSkinIds = _entitlements.IsFreeplay
|
||||
? (await _collection.GetLeaderSkins()).Select(s => s.Id).ToHashSet()
|
||||
: (await _db.Viewers
|
||||
.Where(v => v.Id == viewerId)
|
||||
.SelectMany(v => v.LeaderSkins.Select(s => s.Id))
|
||||
.ToListAsync()).ToHashSet();
|
||||
var viewerForProducts = await _db.Viewers
|
||||
.Include(v => v.LeaderSkins)
|
||||
.FirstOrDefaultAsync(v => v.Id == viewerId);
|
||||
if (viewerForProducts is null) return Unauthorized();
|
||||
|
||||
var cosmeticsForProducts = await _inv.EffectiveCosmeticsAsync(viewerForProducts);
|
||||
var ownedSkinIds = cosmeticsForProducts.OwnedLeaderSkinIds;
|
||||
|
||||
var claimedSeries = (await _db.ViewerLeaderSkinSetClaims
|
||||
.Where(c => c.ViewerId == viewerId)
|
||||
@@ -183,21 +177,41 @@ public class LeaderSkinController : SVSimController
|
||||
if (!product.IsEnabled || product.Series is not { IsEnabled: true })
|
||||
return BadRequest(new { error = "product_not_available" });
|
||||
|
||||
var viewer = await LoadViewerGraphAsync(viewerId);
|
||||
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted);
|
||||
|
||||
// Already-purchased = viewer owns the leader_skin this product grants.
|
||||
if (_entitlements.OwnsCosmetic(viewer, CosmeticType.Skin, product.LeaderSkinId))
|
||||
if (tx.OwnsCosmetic(CosmeticType.Skin, product.LeaderSkinId))
|
||||
return BadRequest(new { error = "already_purchased" });
|
||||
|
||||
var rewardList = new List<RewardListEntry>();
|
||||
var debit = await DebitProductPrice(viewer, product, request.SalesType);
|
||||
if (debit.Error is not null) return BadRequest(new { error = debit.Error });
|
||||
if (debit.PostState is not null) rewardList.Add(debit.PostState);
|
||||
// Debit currency
|
||||
switch (request.SalesType)
|
||||
{
|
||||
case 0 when product.SinglePriceCrystal == 0 && product.SinglePriceRupy == 0:
|
||||
break; // free
|
||||
case 0:
|
||||
return BadRequest(new { error = "price_not_available_for_currency" });
|
||||
case 1:
|
||||
if (product.SinglePriceCrystal is null) return BadRequest(new { error = "price_not_available_for_currency" });
|
||||
{ var r = await tx.TrySpendAsync(SpendCurrency.Crystal, product.SinglePriceCrystal.Value); if (!r.Success) return BadRequest(new { error = "insufficient_crystals" }); }
|
||||
break;
|
||||
case 2:
|
||||
if (product.SinglePriceRupy is null) return BadRequest(new { error = "price_not_available_for_currency" });
|
||||
{ var r = await tx.TrySpendAsync(SpendCurrency.Rupee, product.SinglePriceRupy.Value); if (!r.Success) return BadRequest(new { error = "insufficient_rupees" }); }
|
||||
break;
|
||||
default:
|
||||
return BadRequest(new { error = "invalid_sales_type" });
|
||||
}
|
||||
|
||||
await ApplyRewardsAsync(viewer, product.Rewards, rewardList);
|
||||
foreach (var r in product.Rewards.OrderBy(r => r.OrderIndex))
|
||||
await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return new LeaderSkinBuyResponse { RewardList = rewardList };
|
||||
var result = await tx.CommitAsync(HttpContext.RequestAborted);
|
||||
return new LeaderSkinBuyResponse
|
||||
{
|
||||
RewardList = result.RewardList
|
||||
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
|
||||
.ToList(),
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("buy_set")]
|
||||
@@ -218,25 +232,44 @@ public class LeaderSkinController : SVSimController
|
||||
if (!series.IsEnabled || series.SetSalesStatus == 0)
|
||||
return BadRequest(new { error = "set_sale_not_active" });
|
||||
|
||||
var viewer = await LoadViewerGraphAsync(viewerId);
|
||||
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted);
|
||||
|
||||
if (_entitlements.IsFreeplay)
|
||||
if (tx.IsFreeplay)
|
||||
return BadRequest(new { error = "already_purchased" });
|
||||
|
||||
var rewardList = new List<RewardListEntry>();
|
||||
var debit = await DebitSetPrice(viewer, series, request.SalesType);
|
||||
if (debit.Error is not null) return BadRequest(new { error = debit.Error });
|
||||
if (debit.PostState is not null) rewardList.Add(debit.PostState);
|
||||
|
||||
// Grant every product's rewards; RewardGrantService is idempotent on already-owned
|
||||
// cosmetics, so partial-set buyers don't double-add.
|
||||
foreach (var p in series.Products.OrderBy(p => p.Id))
|
||||
// Debit set price
|
||||
switch (request.SalesType)
|
||||
{
|
||||
await ApplyRewardsAsync(viewer, p.Rewards, rewardList);
|
||||
case 0 when series.SetPriceCrystal == 0 && series.SetPriceRupy == 0:
|
||||
break; // free
|
||||
case 0:
|
||||
return BadRequest(new { error = "price_not_available_for_currency" });
|
||||
case 1:
|
||||
if (series.SetPriceCrystal is null) return BadRequest(new { error = "price_not_available_for_currency" });
|
||||
{ var r = await tx.TrySpendAsync(SpendCurrency.Crystal, series.SetPriceCrystal.Value); if (!r.Success) return BadRequest(new { error = "insufficient_crystals" }); }
|
||||
break;
|
||||
case 2:
|
||||
if (series.SetPriceRupy is null) return BadRequest(new { error = "price_not_available_for_currency" });
|
||||
{ var r = await tx.TrySpendAsync(SpendCurrency.Rupee, series.SetPriceRupy.Value); if (!r.Success) return BadRequest(new { error = "insufficient_rupees" }); }
|
||||
break;
|
||||
default:
|
||||
return BadRequest(new { error = "invalid_sales_type" });
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return new LeaderSkinBuyResponse { RewardList = rewardList };
|
||||
// Grant every product's rewards; tx.GrantAsync is idempotent on already-owned cosmetics.
|
||||
foreach (var p in series.Products.OrderBy(p => p.Id))
|
||||
{
|
||||
foreach (var r in p.Rewards.OrderBy(r => r.OrderIndex))
|
||||
await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
|
||||
}
|
||||
|
||||
var result = await tx.CommitAsync(HttpContext.RequestAborted);
|
||||
return new LeaderSkinBuyResponse
|
||||
{
|
||||
RewardList = result.RewardList
|
||||
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
|
||||
.ToList(),
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("buy_set_item")]
|
||||
@@ -257,16 +290,15 @@ public class LeaderSkinController : SVSimController
|
||||
if (existingClaim is not null)
|
||||
return new LeaderSkinBuyResponse { RewardList = new() };
|
||||
|
||||
var viewer = await LoadViewerGraphAsync(viewerId);
|
||||
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted);
|
||||
|
||||
// Must own every skin in the series to claim the bonus.
|
||||
var ownedSkinIds = viewer.LeaderSkins.Select(s => s.Id).ToHashSet();
|
||||
bool ownsAll = series.Products.Count > 0 && series.Products.All(p => ownedSkinIds.Contains(p.LeaderSkinId));
|
||||
bool ownsAll = series.Products.Count > 0 && series.Products.All(p => tx.OwnsCosmetic(CosmeticType.Skin, p.LeaderSkinId));
|
||||
if (!ownsAll)
|
||||
return BadRequest(new { error = "series_not_completed" });
|
||||
|
||||
var rewardList = new List<RewardListEntry>();
|
||||
await ApplyRewardsAsync(viewer, series.SetCompletionRewards, rewardList);
|
||||
foreach (var r in series.SetCompletionRewards.OrderBy(r => r.OrderIndex))
|
||||
await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
|
||||
|
||||
_db.ViewerLeaderSkinSetClaims.Add(new ViewerLeaderSkinSetClaim
|
||||
{
|
||||
@@ -275,8 +307,13 @@ public class LeaderSkinController : SVSimController
|
||||
ClaimedAt = _time.GetUtcNow().UtcDateTime,
|
||||
});
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return new LeaderSkinBuyResponse { RewardList = rewardList };
|
||||
var result = await tx.CommitAsync(HttpContext.RequestAborted);
|
||||
return new LeaderSkinBuyResponse
|
||||
{
|
||||
RewardList = result.RewardList
|
||||
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
|
||||
.ToList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -304,7 +341,7 @@ public class LeaderSkinController : SVSimController
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static SkinProductDto ToProductDto(LeaderSkinShopProductEntry p, HashSet<int> ownedSkinIds)
|
||||
private static SkinProductDto ToProductDto(LeaderSkinShopProductEntry p, IReadOnlySet<int> ownedSkinIds)
|
||||
{
|
||||
bool isPurchased = ownedSkinIds.Contains(p.LeaderSkinId);
|
||||
return new SkinProductDto
|
||||
@@ -339,7 +376,7 @@ public class LeaderSkinController : SVSimController
|
||||
/// emblem/sleeve typically come with the skin, so the heuristic is "skin owned → all three
|
||||
/// bundle items are de-facto owned." Refine later if a capture shows independent state.
|
||||
/// </summary>
|
||||
private static bool IsRewardOwned(LeaderSkinShopProductRewardEntry r, HashSet<int> ownedSkinIds)
|
||||
private static bool IsRewardOwned(LeaderSkinShopProductRewardEntry r, IReadOnlySet<int> ownedSkinIds)
|
||||
{
|
||||
// Skin reward: direct check.
|
||||
if (r.RewardType == (int)UserGoodsType.Skin)
|
||||
@@ -350,94 +387,4 @@ public class LeaderSkinController : SVSimController
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task<(RewardListEntry? PostState, string? Error)> DebitProductPrice(
|
||||
Viewer viewer, LeaderSkinShopProductEntry product, int salesType)
|
||||
{
|
||||
switch (salesType)
|
||||
{
|
||||
case 0 when product.SinglePriceCrystal == 0 && product.SinglePriceRupy == 0:
|
||||
return (null, null);
|
||||
case 0:
|
||||
return (null, "price_not_available_for_currency");
|
||||
case 1:
|
||||
if (product.SinglePriceCrystal is null) return (null, "price_not_available_for_currency");
|
||||
return await DebitCrystal(viewer, product.SinglePriceCrystal.Value);
|
||||
case 2:
|
||||
if (product.SinglePriceRupy is null) return (null, "price_not_available_for_currency");
|
||||
return await DebitRupy(viewer, product.SinglePriceRupy.Value);
|
||||
default:
|
||||
return (null, "invalid_sales_type");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(RewardListEntry? PostState, string? Error)> DebitSetPrice(
|
||||
Viewer viewer, LeaderSkinShopSeriesEntry series, int salesType)
|
||||
{
|
||||
switch (salesType)
|
||||
{
|
||||
case 0 when series.SetPriceCrystal == 0 && series.SetPriceRupy == 0:
|
||||
return (null, null);
|
||||
case 0:
|
||||
return (null, "price_not_available_for_currency");
|
||||
case 1:
|
||||
if (series.SetPriceCrystal is null) return (null, "price_not_available_for_currency");
|
||||
return await DebitCrystal(viewer, series.SetPriceCrystal.Value);
|
||||
case 2:
|
||||
if (series.SetPriceRupy is null) return (null, "price_not_available_for_currency");
|
||||
return await DebitRupy(viewer, series.SetPriceRupy.Value);
|
||||
default:
|
||||
return (null, "invalid_sales_type");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(RewardListEntry?, string?)> DebitCrystal(Viewer viewer, int amount)
|
||||
{
|
||||
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, amount);
|
||||
if (!r.Success) return (null, "insufficient_crystals");
|
||||
return (new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null);
|
||||
}
|
||||
|
||||
private async Task<(RewardListEntry?, string?)> DebitRupy(Viewer viewer, int amount)
|
||||
{
|
||||
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, amount);
|
||||
if (!r.Success) return (null, "insufficient_rupees");
|
||||
return (new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null);
|
||||
}
|
||||
|
||||
private async Task ApplyRewardsAsync<T>(
|
||||
Viewer viewer, IEnumerable<T> rewards, List<RewardListEntry> rewardList) where T : notnull
|
||||
{
|
||||
foreach (var r in rewards)
|
||||
{
|
||||
var (type, detailId, number) = ExtractTuple(r);
|
||||
var granted = await _rewards.ApplyAsync(viewer, (UserGoodsType)type, detailId, number);
|
||||
foreach (var g in granted)
|
||||
{
|
||||
rewardList.Add(new RewardListEntry
|
||||
{
|
||||
RewardType = g.RewardType,
|
||||
RewardId = g.RewardId,
|
||||
RewardNum = g.RewardNum,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static (int Type, long Id, int Num) ExtractTuple(object reward) => reward switch
|
||||
{
|
||||
LeaderSkinShopProductRewardEntry p => (p.RewardType, p.RewardDetailId, p.RewardNumber),
|
||||
LeaderSkinShopSeriesRewardEntry s => (s.RewardType, s.RewardDetailId, s.RewardNumber),
|
||||
_ => throw new InvalidOperationException($"unexpected reward type {reward.GetType().Name}"),
|
||||
};
|
||||
|
||||
private Task<Viewer> LoadViewerGraphAsync(long viewerId) => _db.Viewers
|
||||
.Include(v => v.LeaderSkins)
|
||||
.Include(v => v.Sleeves)
|
||||
.Include(v => v.Emblems)
|
||||
.Include(v => v.Degrees)
|
||||
.Include(v => v.MyPageBackgrounds)
|
||||
.Include(v => v.Items).ThenInclude(i => i.Item)
|
||||
.Include(v => v.Cards).ThenInclude(c => c.Card)
|
||||
.AsSplitQuery()
|
||||
.FirstAsync(v => v.Id == viewerId);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ using PreReleaseInfoDto = SVSim.EmulatedEntrypoint.Models.Dtos.PreReleaseInfo;
|
||||
using SVSim.Database.Repositories.Globals;
|
||||
using SVSim.Database.Repositories.Viewer;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.Database.Services.Inventory;
|
||||
using SVSim.EmulatedEntrypoint.Constants;
|
||||
using SVSim.EmulatedEntrypoint.Infrastructure;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
@@ -42,26 +43,24 @@ public class LoadController : SVSimController
|
||||
|
||||
private readonly IViewerRepository _viewerRepository;
|
||||
private readonly IGlobalsRepository _globalsRepository;
|
||||
private readonly ICardAcquisitionService _acquisition;
|
||||
private readonly IGameConfigService _config;
|
||||
private readonly IBattlePassService _battlePass;
|
||||
private readonly IViewerMissionStateService _missionState;
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly IViewerEntitlements _entitlements;
|
||||
private readonly IInventoryService _inv;
|
||||
|
||||
public LoadController(IViewerRepository viewerRepository, IGlobalsRepository globalsRepository,
|
||||
ICardAcquisitionService acquisition, IGameConfigService config,
|
||||
IGameConfigService config,
|
||||
IBattlePassService battlePass, IViewerMissionStateService missionState,
|
||||
SVSimDbContext db, IViewerEntitlements entitlements)
|
||||
SVSimDbContext db, IInventoryService inv)
|
||||
{
|
||||
_viewerRepository = viewerRepository;
|
||||
_globalsRepository = globalsRepository;
|
||||
_acquisition = acquisition;
|
||||
_config = config;
|
||||
_battlePass = battlePass;
|
||||
_missionState = missionState;
|
||||
_db = db;
|
||||
_entitlements = entitlements;
|
||||
_inv = inv;
|
||||
}
|
||||
|
||||
[HttpPost("index")]
|
||||
@@ -84,7 +83,9 @@ public class LoadController : SVSimController
|
||||
// .AsNoTracking() — the local `viewer` instance is detached, and the service's writes
|
||||
// (on a separate tracked instance) won't appear on this snapshot. Without the re-fetch,
|
||||
// the response payload would be one /load/index behind on newly-granted cosmetics.
|
||||
await _acquisition.BackfillCosmeticsAsync(viewer.Id);
|
||||
await using var tx = await _inv.BeginAsync(viewer.Id, ct);
|
||||
await tx.BackfillCardCosmeticsAsync(ct);
|
||||
await tx.CommitAsync(ct);
|
||||
|
||||
// Lazy-materialize mission/achievement state. Idempotent — safe to call every /load/index.
|
||||
await _missionState.EnsureCurrentAsync(viewer.Id);
|
||||
@@ -125,9 +126,9 @@ public class LoadController : SVSimController
|
||||
// re-confirm the filter if we later move to Option B and start iterating card-sets.
|
||||
// Owned-card projection (incl. the freeplay "all cards" path) lives in the entitlements
|
||||
// service so both modes share one definition.
|
||||
var allCardsAsOwned = await _entitlements.EffectiveOwnedCardsAsync(viewer, ct);
|
||||
var allCardsAsOwned = await _inv.EffectiveOwnedCardsAsync(viewer, ct);
|
||||
|
||||
var cosmetics = await _entitlements.EffectiveCosmeticsAsync(viewer, ct);
|
||||
var cosmetics = await _inv.EffectiveCosmeticsAsync(viewer, ct);
|
||||
var classExpCurve = await _globalsRepository.GetClassExpCurve();
|
||||
|
||||
List<ClassExp> classExps = new();
|
||||
@@ -168,10 +169,10 @@ public class LoadController : SVSimController
|
||||
UserInfo = new UserInfo(deviceType, viewer),
|
||||
UserCurrency = new UserCurrency(viewer)
|
||||
{
|
||||
Crystals = (ulong)_entitlements.EffectiveBalance(viewer, SpendCurrency.Crystal),
|
||||
TotalCrystals = (ulong)_entitlements.EffectiveBalance(viewer, SpendCurrency.Crystal),
|
||||
Rupees = (ulong)_entitlements.EffectiveBalance(viewer, SpendCurrency.Rupee),
|
||||
RedEther = (ulong)_entitlements.EffectiveBalance(viewer, SpendCurrency.RedEther),
|
||||
Crystals = (ulong)_inv.EffectiveBalance(viewer, SpendCurrency.Crystal),
|
||||
TotalCrystals = (ulong)_inv.EffectiveBalance(viewer, SpendCurrency.Crystal),
|
||||
Rupees = (ulong)_inv.EffectiveBalance(viewer, SpendCurrency.Rupee),
|
||||
RedEther = (ulong)_inv.EffectiveBalance(viewer, SpendCurrency.RedEther),
|
||||
},
|
||||
UserItems = viewer.Items.Select(item => new UserItem(item)).ToList(),
|
||||
SpotPoint = checked((int)viewer.Currency.SpotPoints),
|
||||
|
||||
@@ -11,6 +11,7 @@ using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Pack;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Pack;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.Database.Services.Inventory;
|
||||
using SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
@@ -30,10 +31,8 @@ public class PackController : SVSimController
|
||||
private readonly ICardFoilLookup _foils;
|
||||
private readonly IRandom _rng;
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly ICardAcquisitionService _acquisition;
|
||||
private readonly IInventoryService _inv;
|
||||
private readonly IGachaPointService _gachaPoint;
|
||||
private readonly ICurrencySpendService _spend;
|
||||
private readonly IViewerEntitlements _entitlements;
|
||||
|
||||
public PackController(
|
||||
IPackRepository packs,
|
||||
@@ -42,10 +41,8 @@ public class PackController : SVSimController
|
||||
ICardFoilLookup foils,
|
||||
IRandom rng,
|
||||
SVSimDbContext db,
|
||||
ICardAcquisitionService acquisition,
|
||||
IGachaPointService gachaPoint,
|
||||
ICurrencySpendService spend,
|
||||
IViewerEntitlements entitlements)
|
||||
IInventoryService inv,
|
||||
IGachaPointService gachaPoint)
|
||||
{
|
||||
_packs = packs;
|
||||
_opener = opener;
|
||||
@@ -53,10 +50,8 @@ public class PackController : SVSimController
|
||||
_foils = foils;
|
||||
_rng = rng;
|
||||
_db = db;
|
||||
_acquisition = acquisition;
|
||||
_inv = inv;
|
||||
_gachaPoint = gachaPoint;
|
||||
_spend = spend;
|
||||
_entitlements = entitlements;
|
||||
}
|
||||
|
||||
[HttpPost("info")]
|
||||
@@ -207,26 +202,18 @@ public class PackController : SVSimController
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
|
||||
// Load the viewer with the collections the service mutates (balances, received marker,
|
||||
// cards, cosmetics). AsSplitQuery per project_ef_split_query memory.
|
||||
var viewer = await _db.Viewers
|
||||
.Include(v => v.GachaPointBalances)
|
||||
.Include(v => v.GachaPointReceived)
|
||||
.Include(v => v.Cards)
|
||||
.Include(v => v.Sleeves)
|
||||
.Include(v => v.Emblems)
|
||||
.Include(v => v.Degrees)
|
||||
.Include(v => v.LeaderSkins)
|
||||
.Include(v => v.MyPageBackgrounds)
|
||||
.AsSplitQuery()
|
||||
.FirstAsync(v => v.Id == viewerId);
|
||||
// Open inventory tx with extra includes for GachaPointBalances + GachaPointReceived
|
||||
// (needed by TryExchangeAsync to validate balance and already-received guard).
|
||||
await using var tx = await _inv.BeginAsync(viewerId, configure: cfg => cfg
|
||||
.WithInclude(v => v.GachaPointBalances)
|
||||
.WithInclude(v => v.GachaPointReceived));
|
||||
|
||||
// Use odds_gacha_id (the seasonal pack id) — that's where the balance / received marker
|
||||
// live. Mirrors the GetGachaPointRewards fix.
|
||||
var outcome = await _gachaPoint.TryExchangeAsync(viewer, request.OddsGachaId, request.CardId);
|
||||
var outcome = await _gachaPoint.TryExchangeAsync(tx, request.OddsGachaId, request.CardId);
|
||||
if (!outcome.Success) return BadRequest(new { error = outcome.Error });
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
await tx.CommitAsync();
|
||||
|
||||
return new ExchangeGachaPointResponse
|
||||
{
|
||||
@@ -287,13 +274,12 @@ public class PackController : SVSimController
|
||||
if (!isTutorialPath && child.TypeDetail is not (1 or 2 or 3 or 4 or 5 or 6 or 7))
|
||||
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "currency_path_not_implemented" });
|
||||
|
||||
var viewer = await _db.Viewers
|
||||
.Include(v => v.PackOpenCounts)
|
||||
.Include(v => v.GachaPointBalances)
|
||||
.Include(v => v.MissionData)
|
||||
.Include(v => v.Items).ThenInclude(i => i.Item)
|
||||
.AsSplitQuery()
|
||||
.FirstAsync(v => v.Id == viewerId);
|
||||
// Load viewer via InventoryService transaction with extra includes for pack-open needs.
|
||||
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted, cfg => cfg
|
||||
.WithInclude(v => v.PackOpenCounts)
|
||||
.WithInclude(v => v.GachaPointBalances)
|
||||
.WithInclude(v => v.MissionData));
|
||||
var viewer = tx.Viewer;
|
||||
|
||||
// Tutorial alias is only valid pre-END. After state>=100 the viewer has already
|
||||
// completed the tutorial — re-running the path would re-consume the ticket they
|
||||
@@ -314,7 +300,7 @@ public class PackController : SVSimController
|
||||
case 2: // CRYSTAL_MULTI (10-pack)
|
||||
{
|
||||
long cost = (long)child.Cost * packNumber;
|
||||
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, cost);
|
||||
var r = await tx.TrySpendAsync(SpendCurrency.Crystal, cost);
|
||||
if (!r.Success) return BadRequest(new { error = "insufficient_crystals" });
|
||||
break;
|
||||
}
|
||||
@@ -322,7 +308,7 @@ public class PackController : SVSimController
|
||||
case 7: // RUPY_MULTI (10-pack)
|
||||
{
|
||||
long cost = (long)child.Cost * packNumber;
|
||||
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, cost);
|
||||
var r = await tx.TrySpendAsync(SpendCurrency.Rupee, cost);
|
||||
if (!r.Success) return BadRequest(new { error = "insufficient_rupees" });
|
||||
break;
|
||||
}
|
||||
@@ -336,7 +322,7 @@ public class PackController : SVSimController
|
||||
return BadRequest(new { error = "daily_free_already_claimed" });
|
||||
|
||||
long cost = (long)child.Cost * packNumber;
|
||||
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, cost);
|
||||
var r = await tx.TrySpendAsync(SpendCurrency.Rupee, cost);
|
||||
if (!r.Success) return BadRequest(new { error = "insufficient_rupees" });
|
||||
break;
|
||||
}
|
||||
@@ -347,15 +333,11 @@ public class PackController : SVSimController
|
||||
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "ticket_pack_missing_item_id" });
|
||||
|
||||
int ticketsNeeded = child.Cost * packNumber;
|
||||
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)ticketItemId);
|
||||
if (owned is null || owned.Count < ticketsNeeded)
|
||||
return BadRequest(new { error = "insufficient_tickets" });
|
||||
|
||||
owned.Count -= ticketsNeeded;
|
||||
var debit = await tx.TryDebitAsync(UserGoodsType.Item, ticketItemId, ticketsNeeded);
|
||||
if (!debit.Success) return BadRequest(new { error = "insufficient_tickets" });
|
||||
break;
|
||||
}
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Increment open count + mark daily-free timestamp where relevant.
|
||||
@@ -394,48 +376,17 @@ public class PackController : SVSimController
|
||||
ownedCardIds,
|
||||
_foils,
|
||||
_rng);
|
||||
var grant = await _acquisition.GrantManyAsync(viewerId, draw.Cards.Select(c => c.CardId));
|
||||
|
||||
// Grant drawn cards through the transaction — cosmetic cascade fires on first-time owners.
|
||||
foreach (var grp in draw.Cards.GroupBy(c => c.CardId))
|
||||
await tx.GrantAsync(UserGoodsType.Card, grp.Key, grp.Count());
|
||||
|
||||
// Accrue gacha points (skip tutorial path — the starter pack isn't a real open).
|
||||
if (!isTutorialPath)
|
||||
{
|
||||
_gachaPoint.Accrue(viewer, pack, child, drawCount);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Build reward_list. The service produces the type=5 (Card) entries with post-state counts
|
||||
// plus any cosmetic grants. Currency entry (type=2 Crystals or type=9 Rupy) stays in the
|
||||
// controller — it's a pack-purchase concern, not a card-grant concern. The client's
|
||||
// PlayerStaticData.UpdateHaveUserGoodsNum does direct assignment, so currency/card counts
|
||||
// must be the new TOTAL — emitting deltas would leave the on-screen balances stale.
|
||||
var rewardList = new List<RewardListEntry>();
|
||||
|
||||
// Currency reward entries only apply to purchasable packs; tutorial path omits them.
|
||||
if (!isTutorialPath)
|
||||
{
|
||||
if (child.TypeDetail is 1 or 2)
|
||||
{
|
||||
rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)_entitlements.EffectiveBalance(viewer, SpendCurrency.Crystal) });
|
||||
}
|
||||
else if (child.TypeDetail is 3 or 6 or 7)
|
||||
{
|
||||
rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)_entitlements.EffectiveBalance(viewer, SpendCurrency.Rupee) });
|
||||
}
|
||||
else if (child.TypeDetail is 4 or 5 && child.ItemId is long ticketItemId)
|
||||
{
|
||||
// Item post-state count for the ticket we just consumed — client direct-assigns
|
||||
// _userItemDict, so this must be the new total (project_wire_reward_list_post_state).
|
||||
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)ticketItemId);
|
||||
rewardList.Add(new RewardListEntry
|
||||
{
|
||||
RewardType = 4, // Item
|
||||
RewardId = ticketItemId,
|
||||
RewardNum = owned?.Count ?? 0, // post-state total
|
||||
});
|
||||
}
|
||||
}
|
||||
rewardList.AddRange(grant.RewardList);
|
||||
|
||||
// Tutorial path consumes the granted ticket (same item_id used to gate display) so the
|
||||
// pack drops out of /tutorial/pack_info on next refresh. Without this, the pack still
|
||||
// shows item_number=1 after the tutorial pack-open, the client lets the user re-click
|
||||
@@ -447,19 +398,12 @@ public class PackController : SVSimController
|
||||
int? responseTutorialStep = null;
|
||||
if (isTutorialPath)
|
||||
{
|
||||
if (child.ItemId is long ticketItemId)
|
||||
if (child.ItemId is long tutorialTicketItemId)
|
||||
{
|
||||
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)ticketItemId);
|
||||
if (owned is not null)
|
||||
{
|
||||
owned.Count = Math.Max(0, owned.Count - packNumber);
|
||||
rewardList.Add(new RewardListEntry
|
||||
{
|
||||
RewardType = 4, // Item
|
||||
RewardId = ticketItemId,
|
||||
RewardNum = owned.Count, // POST-STATE total
|
||||
});
|
||||
}
|
||||
int ticketsToConsume = packNumber;
|
||||
var debit = await tx.TryDebitAsync(UserGoodsType.Item, tutorialTicketItemId, ticketsToConsume);
|
||||
// Silently accept if the viewer doesn't have the ticket (already consumed or never granted)
|
||||
_ = debit;
|
||||
}
|
||||
|
||||
// Max-preserve: never regress the persisted state, even though Gate B already
|
||||
@@ -468,10 +412,16 @@ public class PackController : SVSimController
|
||||
// the tutorial-END signal the client expects.
|
||||
if (viewer.MissionData.TutorialState < TutorialEndStep)
|
||||
viewer.MissionData.TutorialState = TutorialEndStep;
|
||||
await _db.SaveChangesAsync();
|
||||
responseTutorialStep = TutorialEndStep;
|
||||
}
|
||||
|
||||
// CommitAsync saves all mutations and produces reward_list with currency-collision resolved.
|
||||
// Tutorial path never calls TrySpendAsync so no currency op is in the log — correct.
|
||||
var result = await tx.CommitAsync(HttpContext.RequestAborted);
|
||||
var rewardList = result.RewardList
|
||||
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
|
||||
.ToList();
|
||||
|
||||
return new PackOpenResponse
|
||||
{
|
||||
PackList = draw.Cards.Select(c => new CardPackEntryDto
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Repositories.Globals;
|
||||
using SVSim.Database.Repositories.Viewer;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.Database.Services.Inventory;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Common.BasicPuzzle;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.BasicPuzzle;
|
||||
@@ -26,20 +25,20 @@ public class PuzzleController : SVSimController
|
||||
private readonly IPuzzleCatalogRepository _catalog;
|
||||
private readonly IPuzzleClearRepository _clears;
|
||||
private readonly PuzzleMissionEvaluator _evaluator;
|
||||
private readonly RewardGrantService _rewards;
|
||||
private readonly IInventoryService _inv;
|
||||
private readonly ILogger<PuzzleController> _logger;
|
||||
|
||||
public PuzzleController(
|
||||
IPuzzleCatalogRepository catalog,
|
||||
IPuzzleClearRepository clears,
|
||||
PuzzleMissionEvaluator evaluator,
|
||||
RewardGrantService rewards,
|
||||
IInventoryService inv,
|
||||
ILogger<PuzzleController> logger)
|
||||
{
|
||||
_catalog = catalog;
|
||||
_clears = clears;
|
||||
_evaluator = evaluator;
|
||||
_rewards = rewards;
|
||||
_inv = inv;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -175,28 +174,15 @@ public class PuzzleController : SVSimController
|
||||
|
||||
if (fresh.Count > 0)
|
||||
{
|
||||
// Load viewer with all the collections RewardGrantService might mutate. Split-query
|
||||
// to avoid the cartesian-explode pitfall (CLAUDE.md "EF split query").
|
||||
var ctx = HttpContext.RequestServices.GetRequiredService<SVSimDbContext>();
|
||||
var viewer = await ctx.Viewers
|
||||
.Include(v => v.Cards).ThenInclude(c => c.Card)
|
||||
.Include(v => v.Sleeves)
|
||||
.Include(v => v.Emblems)
|
||||
.Include(v => v.LeaderSkins)
|
||||
.Include(v => v.Degrees)
|
||||
.Include(v => v.MyPageBackgrounds)
|
||||
.Include(v => v.Items).ThenInclude(i => i.Item)
|
||||
.AsSplitQuery()
|
||||
.FirstAsync(v => v.Id == viewerId);
|
||||
await using var tx = await _inv.BeginAsync(viewerId);
|
||||
|
||||
foreach (var status in fresh)
|
||||
{
|
||||
IReadOnlyList<GrantedReward> granted;
|
||||
IReadOnlyList<SVSim.Database.Services.GrantedReward> granted;
|
||||
try
|
||||
{
|
||||
granted = await _rewards.ApplyAsync(
|
||||
viewer,
|
||||
(SVSim.Database.Enums.UserGoodsType)status.Mission.RewardType,
|
||||
granted = await tx.GrantAsync(
|
||||
(UserGoodsType)status.Mission.RewardType,
|
||||
status.Mission.RewardDetailId,
|
||||
status.Mission.RewardNumber);
|
||||
}
|
||||
@@ -229,7 +215,7 @@ public class PuzzleController : SVSimController
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.SaveChangesAsync();
|
||||
await tx.CommitAsync();
|
||||
}
|
||||
|
||||
response.WinCount = "1";
|
||||
|
||||
233
SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs
Normal file
233
SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs
Normal file
@@ -0,0 +1,233 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SVSim.BattleNode.Bridge;
|
||||
using SVSim.BattleNode.Sessions;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.EmulatedEntrypoint.Constants;
|
||||
using SVSim.EmulatedEntrypoint.Matching;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.RankBattle;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
using SVSim.EmulatedEntrypoint.Security.SteamSessionAuthentication;
|
||||
using SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Rank battle family — covers rotation/unlimited human PvP + AI variants. Crossover
|
||||
/// is out of scope (no AI variant; human-only). Multi-prefix URLs (rotation_rank_battle/,
|
||||
/// unlimited_rank_battle/, ai_*_rank_battle/, rank_battle/) require explicit absolute
|
||||
/// route attributes on each action; the controller doesn't extend SVSimController's
|
||||
/// [Route("[controller]")] convention.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize(AuthenticationSchemes = SteamAuthenticationConstants.SchemeName)]
|
||||
public sealed class RankBattleController : ControllerBase
|
||||
{
|
||||
private readonly IMatchingPairUpService _pairUp;
|
||||
private readonly IMatchingBridge _bridge;
|
||||
private readonly IBattleSessionStore _sessionStore;
|
||||
private readonly IMatchContextBuilder _ctxBuilder;
|
||||
private readonly IBotRoster _botRoster;
|
||||
private readonly ILogger<RankBattleController> _log;
|
||||
|
||||
public RankBattleController(
|
||||
IMatchingPairUpService pairUp,
|
||||
IMatchingBridge bridge,
|
||||
IBattleSessionStore sessionStore,
|
||||
IMatchContextBuilder ctxBuilder,
|
||||
IBotRoster botRoster,
|
||||
ILogger<RankBattleController> log)
|
||||
{
|
||||
_pairUp = pairUp;
|
||||
_bridge = bridge;
|
||||
_sessionStore = sessionStore;
|
||||
_ctxBuilder = ctxBuilder;
|
||||
_botRoster = botRoster;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
private bool TryGetViewerId(out long viewerId)
|
||||
{
|
||||
viewerId = 0;
|
||||
var claim = User.Claims.FirstOrDefault(c => c.Type == ShadowverseClaimTypes.ViewerIdClaim)?.Value;
|
||||
return claim is not null && long.TryParse(claim, out viewerId);
|
||||
}
|
||||
|
||||
[HttpPost("/rotation_rank_battle/do_matching")]
|
||||
public Task<IActionResult> DoMatchingRotation([FromBody] DoMatchingRequestDto req, CancellationToken ct)
|
||||
=> DoMatchingInternal("rotation_rank_battle", Format.Rotation, req, ct);
|
||||
|
||||
[HttpPost("/unlimited_rank_battle/do_matching")]
|
||||
public Task<IActionResult> DoMatchingUnlimited([FromBody] DoMatchingRequestDto req, CancellationToken ct)
|
||||
=> DoMatchingInternal("unlimited_rank_battle", Format.Unlimited, req, ct);
|
||||
|
||||
// AIBattleStartTask has no SetParameter override, so the body is just the inherited
|
||||
// PostParams (viewer_id / steam_id / steam_session_ticket) — but the translation
|
||||
// middleware requires at least one parameter to bind the decrypted body. Use BaseRequest.
|
||||
[HttpPost("/ai_rotation_rank_battle/start")]
|
||||
public Task<IActionResult> AiStartRotation([FromBody] BaseRequest _, CancellationToken ct)
|
||||
=> AiStartInternal(Format.Rotation, ct);
|
||||
|
||||
[HttpPost("/ai_unlimited_rank_battle/start")]
|
||||
public Task<IActionResult> AiStartUnlimited([FromBody] BaseRequest _, CancellationToken ct)
|
||||
=> AiStartInternal(Format.Unlimited, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Shared finish handler — RankBattleFinishTask parses the same wire shape for
|
||||
/// all four URLs and routes server-side by URL (vs IsAINetwork flag in the client).
|
||||
/// Stubbed for Phase 3: echo battle_result, emit zeros elsewhere. Real rank
|
||||
/// progression math is a separate spec.
|
||||
/// </summary>
|
||||
[HttpPost("/rotation_rank_battle/finish")]
|
||||
[HttpPost("/unlimited_rank_battle/finish")]
|
||||
[HttpPost("/ai_rotation_rank_battle/finish")]
|
||||
[HttpPost("/ai_unlimited_rank_battle/finish")]
|
||||
public IActionResult Finish([FromBody] RankBattleFinishRequestDto req)
|
||||
{
|
||||
if (!TryGetViewerId(out var _)) return Unauthorized();
|
||||
return Ok(new RankBattleFinishResponseDto
|
||||
{
|
||||
BattleResult = req.BattleResult,
|
||||
// All other fields default to 0 in the DTO (ClassLevel defaults to 1).
|
||||
});
|
||||
}
|
||||
|
||||
// BaseRequest parameter on every body-less action so the translation middleware can
|
||||
// bind the decrypted msgpack body (it explicitly requires at least one parameter).
|
||||
[HttpPost("/rank_battle/force_finish")]
|
||||
public IActionResult ForceFinish([FromBody] BaseRequest _)
|
||||
{
|
||||
if (!TryGetViewerId(out var _u)) return Unauthorized();
|
||||
return Ok(new { });
|
||||
}
|
||||
|
||||
[HttpPost("/rank_battle/add_client_log")]
|
||||
[HttpPost("/rank_battle/add_all_client_log")]
|
||||
[HttpPost("/rank_battle/add_last_turn_log")]
|
||||
public IActionResult AddClientLog([FromBody] BaseRequest _)
|
||||
{
|
||||
if (!TryGetViewerId(out var _u)) return Unauthorized();
|
||||
return Ok(new { });
|
||||
}
|
||||
|
||||
[HttpPost("/rank_battle/get_latest_master_point")]
|
||||
public IActionResult GetLatestMasterPoint([FromBody] BaseRequest _)
|
||||
{
|
||||
if (!TryGetViewerId(out var _u)) return Unauthorized();
|
||||
return Ok(new { });
|
||||
}
|
||||
|
||||
private async Task<IActionResult> DoMatchingInternal(string mode, Format format, DoMatchingRequestDto req, CancellationToken ct)
|
||||
{
|
||||
if (!TryGetViewerId(out var vid)) return Unauthorized();
|
||||
|
||||
MatchContext ctx;
|
||||
try
|
||||
{
|
||||
ctx = await _ctxBuilder.BuildForRankBattleAsync(vid, format, req.DeckNo);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
// Most likely cause: viewer has no deck at that slot for this format. Surface
|
||||
// as 3001 RC_BATTLE_MATCHING_ILLEGAL — the client shows the standard
|
||||
// matchmaking-error dialog rather than retrying forever.
|
||||
_log.LogWarning(ex, "BuildForRankBattleAsync failed for viewer {Vid} format {Fmt} deckNo {DeckNo}; returning 3001.", vid, format, req.DeckNo);
|
||||
return Ok(new DoMatchingResponseDto { MatchingState = 3001, NodeServerUrl = "" });
|
||||
}
|
||||
|
||||
var paired = await _pairUp.TryPairAsync(mode, new BattlePlayer(vid, ctx), ct);
|
||||
|
||||
if (paired is null)
|
||||
{
|
||||
// Parked. 3002 RETRY. node_server_url must be present as empty string —
|
||||
// client's DoMatchingBase parser calls .ToString() without a guard.
|
||||
return Ok(new DoMatchingResponseDto
|
||||
{
|
||||
MatchingState = 3002,
|
||||
NodeServerUrl = "",
|
||||
});
|
||||
}
|
||||
|
||||
// Owner cache-pickup → 3007 (PvP) or 3011 (AI fallback).
|
||||
// Joiner (only PvP) → 3004.
|
||||
var state = paired switch
|
||||
{
|
||||
{ IsAiFallback: true } => 3011,
|
||||
{ IsOwner: true } => 3007,
|
||||
_ => 3004,
|
||||
};
|
||||
|
||||
return Ok(new DoMatchingResponseDto
|
||||
{
|
||||
MatchingState = state,
|
||||
BattleId = paired.Match.BattleId,
|
||||
NodeServerUrl = paired.Match.NodeServerUrl,
|
||||
// Placeholder per spec § Out of scope — per-battle card-master split is deferred.
|
||||
CardMasterId = 0,
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<IActionResult> AiStartInternal(Format format, CancellationToken ct)
|
||||
{
|
||||
if (!TryGetViewerId(out var vid)) return Unauthorized();
|
||||
|
||||
// The /ai_<fmt>/start request body is BaseRequest only — it carries no deck_no.
|
||||
// The deck the viewer queued with was captured in the PendingBattle's MatchContext
|
||||
// at /do_matching resolution time (when InProcessPairUp called bridge.RegisterBattle).
|
||||
// Reuse that context so SelfInfo's classId/charaId/sleeveId match what the user
|
||||
// actually picked. Rebuilding from deck #1 was the 2026-06-02 wire-bug — surfaced
|
||||
// as "queued Bloodcraft, saw Swordcraft leader."
|
||||
var pending = _sessionStore.TryFindPendingForViewer(vid);
|
||||
if (pending is null)
|
||||
{
|
||||
_log.LogWarning("AiStart for viewer {Vid} format {Fmt} has no pending battle; returning ai_id=-1.", vid, format);
|
||||
return Ok(new AiBattleStartResponseDto { AiId = -1 });
|
||||
}
|
||||
var selfCtx = pending.P1.Context;
|
||||
|
||||
var bot = await _botRoster.PickAsync(selfCtx, ct);
|
||||
|
||||
// Per spec, ai-start.md TODO: turnState semantics unclear. Default 0 (player first).
|
||||
return Ok(new AiBattleStartResponseDto
|
||||
{
|
||||
AiId = bot.AiId,
|
||||
TurnState = 0,
|
||||
SelfInfo = new AiBattlePlayerInfo
|
||||
{
|
||||
CountryCode = selfCtx.CountryCode,
|
||||
UserName = selfCtx.UserName,
|
||||
SleeveId = int.TryParse(selfCtx.SleeveId, out var sId) ? sId : -1,
|
||||
EmblemId = int.TryParse(selfCtx.EmblemId, out var eId) ? eId : -1,
|
||||
DegreeId = int.TryParse(selfCtx.DegreeId, out var dId) ? dId : -1,
|
||||
FieldId = selfCtx.FieldId,
|
||||
IsOfficial = selfCtx.IsOfficial,
|
||||
OppoId = bot.AiId,
|
||||
Seed = 0,
|
||||
Rank = 0,
|
||||
BattlePoint = 0,
|
||||
ClassId = int.TryParse(selfCtx.ClassId, out var cId) ? cId : -1,
|
||||
CharaId = int.TryParse(selfCtx.CharaId, out var chId) ? chId : -1,
|
||||
IsMasterRank = 0,
|
||||
MasterPoint = 0,
|
||||
},
|
||||
OppoInfo = new AiBattlePlayerInfo
|
||||
{
|
||||
CountryCode = bot.CountryCode,
|
||||
UserName = bot.UserName,
|
||||
SleeveId = bot.SleeveId,
|
||||
EmblemId = bot.EmblemId,
|
||||
DegreeId = bot.DegreeId,
|
||||
FieldId = bot.FieldId,
|
||||
IsOfficial = bot.IsOfficial,
|
||||
OppoId = (int)vid,
|
||||
Seed = 0,
|
||||
Rank = bot.Rank,
|
||||
BattlePoint = bot.BattlePoint,
|
||||
ClassId = bot.ClassId,
|
||||
CharaId = bot.CharaId,
|
||||
IsMasterRank = bot.IsMasterRank,
|
||||
MasterPoint = bot.MasterPoint,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Repositories.Collectibles;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.Database.Services.Inventory;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Sleeve;
|
||||
@@ -20,17 +21,13 @@ namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
public class SleeveController : SVSimController
|
||||
{
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly RewardGrantService _rewards;
|
||||
private readonly ICurrencySpendService _spend;
|
||||
private readonly IViewerEntitlements _entitlements;
|
||||
private readonly IInventoryService _inv;
|
||||
private readonly ICollectionRepository _collection;
|
||||
|
||||
public SleeveController(SVSimDbContext db, RewardGrantService rewards, ICurrencySpendService spend, IViewerEntitlements entitlements, ICollectionRepository collection)
|
||||
public SleeveController(SVSimDbContext db, IInventoryService inv, ICollectionRepository collection)
|
||||
{
|
||||
_db = db;
|
||||
_rewards = rewards;
|
||||
_spend = spend;
|
||||
_entitlements = entitlements;
|
||||
_inv = inv;
|
||||
_collection = collection;
|
||||
}
|
||||
|
||||
@@ -42,12 +39,13 @@ public class SleeveController : SVSimController
|
||||
// is_purchased_product is "viewer owns at least one sleeve granted by this product".
|
||||
// Loading the viewer's sleeve-id set once and checking each product against it avoids
|
||||
// an N+1 over products.
|
||||
var ownedSleeveIds = _entitlements.IsFreeplay
|
||||
? (await _collection.GetAllSleeveIds()).Select(id => (long)id).ToHashSet()
|
||||
: (await _db.Viewers
|
||||
.Where(v => v.Id == viewerId)
|
||||
.SelectMany(v => v.Sleeves.Select(s => (long)s.Id))
|
||||
.ToListAsync()).ToHashSet();
|
||||
var viewerForInfo = await _db.Viewers
|
||||
.Include(v => v.Sleeves)
|
||||
.FirstOrDefaultAsync(v => v.Id == viewerId);
|
||||
if (viewerForInfo is null) return Unauthorized();
|
||||
|
||||
var cosmeticsForInfo = await _inv.EffectiveCosmeticsAsync(viewerForInfo);
|
||||
var ownedSleeveIds = cosmeticsForInfo.SleeveIds.Select(id => (long)id).ToHashSet();
|
||||
|
||||
var series = await _db.SleeveShopSeries
|
||||
.Where(s => s.IsEnabled)
|
||||
@@ -113,18 +111,17 @@ public class SleeveController : SVSimController
|
||||
if (product.SeriesId != request.SeriesId)
|
||||
return BadRequest(new { error = "series_product_mismatch" });
|
||||
|
||||
var viewer = await LoadViewerGraphAsync(viewerId);
|
||||
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted);
|
||||
|
||||
if (_entitlements.IsFreeplay)
|
||||
if (tx.IsFreeplay)
|
||||
return BadRequest(new { error = "already_purchased" });
|
||||
|
||||
if (IsProductPurchased(product, viewer.Sleeves.Select(s => (long)s.Id).ToHashSet()))
|
||||
if (IsProductPurchased(product, tx.Viewer.Sleeves.Select(s => (long)s.Id).ToHashSet()))
|
||||
return BadRequest(new { error = "already_purchased" });
|
||||
|
||||
// Pricing: capture-confirmed shape is single-price-per-currency (no intro/regular tiers
|
||||
// like BuildDeck). At least one of crystal/rupy must match the chosen sales_type;
|
||||
// sales_type==0 means "free", which requires both prices == 0.
|
||||
var rewardList = new List<RewardListEntry>();
|
||||
switch (request.SalesType)
|
||||
{
|
||||
case 0: // free
|
||||
@@ -134,39 +131,27 @@ public class SleeveController : SVSimController
|
||||
case 1: // crystal
|
||||
if (product.PriceCrystal is null)
|
||||
return BadRequest(new { error = "price_not_available_for_currency" });
|
||||
var crystalRes = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, product.PriceCrystal.Value);
|
||||
if (!crystalRes.Success) return BadRequest(new { error = "insufficient_crystals" });
|
||||
rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)crystalRes.PostStateTotal });
|
||||
{ var r = await tx.TrySpendAsync(SpendCurrency.Crystal, product.PriceCrystal.Value); if (!r.Success) return BadRequest(new { error = "insufficient_crystals" }); }
|
||||
break;
|
||||
case 2: // rupy
|
||||
if (product.PriceRupy is null)
|
||||
return BadRequest(new { error = "price_not_available_for_currency" });
|
||||
var rupyRes = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, product.PriceRupy.Value);
|
||||
if (!rupyRes.Success) return BadRequest(new { error = "insufficient_rupees" });
|
||||
rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)rupyRes.PostStateTotal });
|
||||
{ var r = await tx.TrySpendAsync(SpendCurrency.Rupee, product.PriceRupy.Value); if (!r.Success) return BadRequest(new { error = "insufficient_rupees" }); }
|
||||
break;
|
||||
}
|
||||
|
||||
// Grant each catalog reward through the central dispatcher — covers sleeve (6), emblem
|
||||
// (7), and any future bundled grants. ApplyAsync returns post-state-aware reward entries
|
||||
// suitable for emission as-is.
|
||||
// Grant each catalog reward through the central dispatcher.
|
||||
foreach (var r in product.Rewards.OrderBy(r => r.OrderIndex))
|
||||
await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
|
||||
|
||||
var result = await tx.CommitAsync(HttpContext.RequestAborted);
|
||||
|
||||
return new SleeveBuyResponse
|
||||
{
|
||||
var granted = await _rewards.ApplyAsync(viewer, (UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
|
||||
foreach (var g in granted)
|
||||
{
|
||||
rewardList.Add(new RewardListEntry
|
||||
{
|
||||
RewardType = g.RewardType,
|
||||
RewardId = g.RewardId,
|
||||
RewardNum = g.RewardNum,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return new SleeveBuyResponse { RewardList = rewardList };
|
||||
RewardList = result.RewardList
|
||||
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
|
||||
.ToList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -185,14 +170,4 @@ public class SleeveController : SVSimController
|
||||
return false;
|
||||
}
|
||||
|
||||
private Task<Viewer> LoadViewerGraphAsync(long viewerId) => _db.Viewers
|
||||
.Include(v => v.Sleeves)
|
||||
.Include(v => v.Emblems)
|
||||
.Include(v => v.LeaderSkins)
|
||||
.Include(v => v.Degrees)
|
||||
.Include(v => v.MyPageBackgrounds)
|
||||
.Include(v => v.Items).ThenInclude(i => i.Item)
|
||||
.Include(v => v.Cards).ThenInclude(c => c.Card)
|
||||
.AsSplitQuery()
|
||||
.FirstAsync(v => v.Id == viewerId);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.Database.Services.Inventory;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.SpotCardExchange;
|
||||
@@ -14,8 +15,7 @@ namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
/// <summary>
|
||||
/// /spot_card_exchange/* — trade spot points for individual cards from the rotating exchange
|
||||
/// pool. Spot points are earned from battles/missions (not implemented here — earners live in
|
||||
/// battle/mission finish reward emitters via <see cref="RewardGrantService"/> +
|
||||
/// <see cref="UserGoodsType.SpotCardPoint"/>).
|
||||
/// battle/mission finish reward emitters via <see cref="UserGoodsType.SpotCardPoint"/>).
|
||||
/// </summary>
|
||||
[Route("spot_card_exchange")]
|
||||
public class SpotCardExchangeController : SVSimController
|
||||
@@ -28,16 +28,14 @@ public class SpotCardExchangeController : SVSimController
|
||||
private const int PreReleaseLimit = 2;
|
||||
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly RewardGrantService _rewards;
|
||||
private readonly IInventoryService _inv;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly ICurrencySpendService _spend;
|
||||
|
||||
public SpotCardExchangeController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time, ICurrencySpendService spend)
|
||||
public SpotCardExchangeController(SVSimDbContext db, IInventoryService inv, TimeProvider time)
|
||||
{
|
||||
_db = db;
|
||||
_rewards = rewards;
|
||||
_inv = inv;
|
||||
_time = time;
|
||||
_spend = spend;
|
||||
}
|
||||
|
||||
[HttpPost("top")]
|
||||
@@ -126,14 +124,14 @@ public class SpotCardExchangeController : SVSimController
|
||||
return BadRequest(new { error = "pre_release_limit_reached" });
|
||||
}
|
||||
|
||||
var viewer = await LoadViewerGraphAsync(viewerId);
|
||||
await using var tx = await _inv.BeginAsync(viewerId);
|
||||
|
||||
var rewardList = new List<RewardListEntry>();
|
||||
|
||||
// Debit spot points. Client-supplied exchange_point isn't authoritative — server uses
|
||||
// catalog price. Mirroring the build_deck/sleeve convention: post-state currency entry
|
||||
// first, then grants.
|
||||
var spotRes = await _spend.TrySpendAsync(viewer, SpendCurrency.SpotPoint, entry.ExchangePoint);
|
||||
var spotRes = await tx.TrySpendAsync(SpendCurrency.SpotPoint, entry.ExchangePoint);
|
||||
if (!spotRes.Success)
|
||||
return BadRequest(new { error = "insufficient_spot_points" });
|
||||
rewardList.Add(new RewardListEntry
|
||||
@@ -143,8 +141,8 @@ public class SpotCardExchangeController : SVSimController
|
||||
RewardNum = checked((int)spotRes.PostStateTotal),
|
||||
});
|
||||
|
||||
// Grant the card itself via the existing card dispatcher (handles cosmetic cascade).
|
||||
var granted = await _rewards.ApplyAsync(viewer, UserGoodsType.Card, entry.Id, 1);
|
||||
// Grant the card itself via the inventory tx (handles cosmetic cascade).
|
||||
var granted = await tx.GrantAsync(UserGoodsType.Card, entry.Id, 1);
|
||||
foreach (var g in granted)
|
||||
{
|
||||
rewardList.Add(new RewardListEntry
|
||||
@@ -163,7 +161,7 @@ public class SpotCardExchangeController : SVSimController
|
||||
ExchangedAt = _time.GetUtcNow().UtcDateTime,
|
||||
});
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
await tx.CommitAsync();
|
||||
return new SpotCardExchangeResponse { RewardList = rewardList };
|
||||
}
|
||||
|
||||
@@ -182,14 +180,4 @@ public class SpotCardExchangeController : SVSimController
|
||||
return 0;
|
||||
}
|
||||
|
||||
private Task<Viewer> LoadViewerGraphAsync(long viewerId) => _db.Viewers
|
||||
.Include(v => v.Cards).ThenInclude(c => c.Card)
|
||||
.Include(v => v.Sleeves)
|
||||
.Include(v => v.Emblems)
|
||||
.Include(v => v.LeaderSkins)
|
||||
.Include(v => v.Degrees)
|
||||
.Include(v => v.MyPageBackgrounds)
|
||||
.Include(v => v.Items).ThenInclude(i => i.Item)
|
||||
.AsSplitQuery()
|
||||
.FirstAsync(v => v.Id == viewerId);
|
||||
}
|
||||
|
||||
23
SVSim.EmulatedEntrypoint/Matching/AIBotProfile.cs
Normal file
23
SVSim.EmulatedEntrypoint/Matching/AIBotProfile.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
namespace SVSim.EmulatedEntrypoint.Matching;
|
||||
|
||||
/// <summary>
|
||||
/// Cosmetic + identity metadata for an AI opponent. Used to compose
|
||||
/// <c>oppo_info</c> in the <c>/ai_<fmt>_rank_battle/start</c> response.
|
||||
/// The wire keys are camelCase (sleeveId, emblemId, etc.) — the DTO handles
|
||||
/// the JSON serialization; this record is the internal-facing shape.
|
||||
/// </summary>
|
||||
public sealed record AIBotProfile(
|
||||
int AiId,
|
||||
string CountryCode,
|
||||
string UserName,
|
||||
int SleeveId,
|
||||
int EmblemId,
|
||||
int DegreeId,
|
||||
int FieldId,
|
||||
int IsOfficial,
|
||||
int ClassId,
|
||||
int CharaId,
|
||||
int Rank,
|
||||
int BattlePoint,
|
||||
int IsMasterRank,
|
||||
int MasterPoint);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user