Compare commits
26 Commits
7bd2c0f2d7
...
2d32051cc0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d32051cc0 | ||
|
|
9ff8948903 | ||
|
|
1007cf24d2 | ||
|
|
9b8a7f1e37 | ||
|
|
578d0a75ef | ||
|
|
d119d2c277 | ||
|
|
7e167b1cef | ||
|
|
3e8901eec3 | ||
|
|
7d4da69f22 | ||
|
|
e70f32db79 | ||
|
|
a3e445cf2f | ||
|
|
564b1d678f | ||
|
|
c6fb411861 | ||
|
|
99129c786c | ||
|
|
e9af7af1b8 | ||
|
|
77c99cc230 | ||
|
|
24180d5b4b | ||
|
|
ed88683fa0 | ||
|
|
b229885259 | ||
|
|
3f5d97cb2f | ||
|
|
6f7fcfe28e | ||
|
|
11c98bf67b | ||
|
|
75f3d8ea5b | ||
|
|
617714ebea | ||
|
|
63cb3248b4 | ||
|
|
56652c7034 |
14
SVSim.BattleNode/Bridge/BattleModes.cs
Normal file
14
SVSim.BattleNode/Bridge/BattleModes.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
namespace SVSim.BattleNode.Bridge;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Known values for <see cref="MatchContext.BattleModeId"/> — the prod do_matching battle-mode id,
|
||||||
|
/// forwarded verbatim onto the wire (<c>battleType</c> field on BattleStart). Names the otherwise
|
||||||
|
/// magic <c>11</c>. Distinct from the <see cref="Sessions.BattleType"/> enum (Pvp/Bot), which is the
|
||||||
|
/// session topology, not the game mode.
|
||||||
|
/// </summary>
|
||||||
|
public static class BattleModes
|
||||||
|
{
|
||||||
|
/// <summary>Take Two (TK2) — the two-pick draft mode the v1 captures were taken from. Prod
|
||||||
|
/// rank-battle frames carry the same value (see <c>MatchContextBuilder</c>).</summary>
|
||||||
|
public const int TakeTwo = 11;
|
||||||
|
}
|
||||||
@@ -16,11 +16,18 @@ public sealed class BattleNodeOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public TimeSpan WaitingRoomTimeout { get; set; } = TimeSpan.FromSeconds(60);
|
public TimeSpan WaitingRoomTimeout { get; set; } = TimeSpan.FromSeconds(60);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cadence of the server→client alive ("Gungnir") keepalive emit. The driving timer/loop
|
||||||
|
/// (to live on <see cref="Sessions.BattleSession"/>) is deferred in v1; this is its future
|
||||||
|
/// home so the interval isn't a magic literal stranded on the <c>Gungnir</c> body factory.
|
||||||
|
/// </summary>
|
||||||
|
public TimeSpan AliveEmitInterval { get; set; } = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// When true, <see cref="Sessions.Participants.RealParticipant"/> emits per-frame
|
/// When true, <see cref="Sessions.Participants.RealParticipant"/> emits per-frame
|
||||||
/// diagnostic logs at Information level: <c>[sio-in]</c> on every inbound msg/alive/hand
|
/// diagnostic logs at Information level: <c>[sio-in]</c> on every inbound msg/alive/hand
|
||||||
/// envelope (URI, pubSeq, ackId, dispatch decision, ack-sent flag, ack arg, inbound
|
/// envelope (URI, pubSeq, ackId, dispatch decision, ack-sent flag, ack arg, inbound
|
||||||
/// watermark); <c>[sio-out]</c> on every outbound push (URI, pubSeq, playSeq, noStock);
|
/// watermark); <c>[sio-out]</c> on every outbound push (URI, pubSeq, playSeq, stock);
|
||||||
/// <c>[ws-rx-text]</c> / <c>[ws-rx-bin]</c> on every WS frame received at the transport
|
/// <c>[ws-rx-text]</c> / <c>[ws-rx-bin]</c> on every WS frame received at the transport
|
||||||
/// layer; <c>[ws-recv-exit]</c> / <c>[ws-loop-exit]</c> on read-loop termination
|
/// layer; <c>[ws-recv-exit]</c> / <c>[ws-loop-exit]</c> on read-loop termination
|
||||||
/// (with WebSocket state + exception type when applicable). Default false — keeps
|
/// (with WebSocket state + exception type when applicable). Default false — keeps
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
namespace SVSim.BattleNode.Bridge;
|
namespace SVSim.BattleNode.Bridge;
|
||||||
|
|
||||||
/// <summary>One player slot for a pending battle. Carries the viewer's identity and
|
/// <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>
|
/// the per-battle MatchContext snapshot built at do_matching time.
|
||||||
|
/// <para>FOOTGUN: this is a <c>record</c>, but <see cref="Context"/> transitively holds an
|
||||||
|
/// <c>IReadOnlyList<long></c> (the deck), so the synthesized value-equality is REFERENCE-based
|
||||||
|
/// on that list — two BattlePlayers with equal deck *contents* compare unequal. Don't use
|
||||||
|
/// BattlePlayer / <see cref="MatchContext"/> as dictionary keys or <c>Distinct()</c> / <c>HashSet</c>
|
||||||
|
/// operands without first giving them content equality. Not exercised today.</para></summary>
|
||||||
public sealed record BattlePlayer(long ViewerId, MatchContext Context);
|
public sealed record BattlePlayer(long ViewerId, MatchContext Context);
|
||||||
|
|||||||
29
SVSim.BattleNode/Bridge/CardClass.cs
Normal file
29
SVSim.BattleNode/Bridge/CardClass.cs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
namespace SVSim.BattleNode.Bridge;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A Shadowverse class (craft). The wire carries it as the stringified ordinal (<c>"1".."8"</c> on
|
||||||
|
/// the <c>classId</c> field); this enum replaces that stringly-typed value on
|
||||||
|
/// <see cref="MatchContext.ClassId"/> so the legal set lives in the type, not a trailing comment.
|
||||||
|
/// <see cref="None"/> covers an unset / placeholder context. Use <see cref="CardClassWire.ToWireValue"/>
|
||||||
|
/// to render the wire string.
|
||||||
|
/// </summary>
|
||||||
|
public enum CardClass
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
Forestcraft = 1,
|
||||||
|
Swordcraft = 2,
|
||||||
|
Runecraft = 3,
|
||||||
|
Dragoncraft = 4,
|
||||||
|
Shadowcraft = 5,
|
||||||
|
Bloodcraft = 6,
|
||||||
|
Havencraft = 7,
|
||||||
|
Portalcraft = 8,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Wire rendering for <see cref="CardClass"/>.</summary>
|
||||||
|
public static class CardClassWire
|
||||||
|
{
|
||||||
|
/// <summary>The <c>classId</c> wire value — the class ordinal as a string (<c>"1".."8"</c>,
|
||||||
|
/// <c>"0"</c> for <see cref="CardClass.None"/>), matching what the client sends/expects.</summary>
|
||||||
|
public static string ToWireValue(this CardClass cardClass) => ((int)cardClass).ToString();
|
||||||
|
}
|
||||||
13
SVSim.BattleNode/Bridge/CountryCodes.cs
Normal file
13
SVSim.BattleNode/Bridge/CountryCodes.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
namespace SVSim.BattleNode.Bridge;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Known values for <see cref="MatchContext.CountryCode"/>. NOT a closed set — the field is the
|
||||||
|
/// account's region code copied verbatim from viewer data (any value, possibly empty), and the node
|
||||||
|
/// never branches on it. These constants just name the values seen in the prod captures so test
|
||||||
|
/// fixtures and docs aren't sprinkled with bare <c>"KOR"</c>/<c>"JPN"</c> literals.
|
||||||
|
/// </summary>
|
||||||
|
public static class CountryCodes
|
||||||
|
{
|
||||||
|
public const string Korea = "KOR";
|
||||||
|
public const string Japan = "JPN";
|
||||||
|
}
|
||||||
@@ -5,6 +5,9 @@ namespace SVSim.BattleNode.Bridge;
|
|||||||
/// server-authored frame lifecycle on WS connect. SVSim.BattleNode does not know how to build this — the HTTP-side
|
/// server-authored frame 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
|
/// per-mode controller is the source. Snapshot semantics: cosmetic changes between matching
|
||||||
/// and WS connect have no effect on the in-battle render.
|
/// and WS connect have no effect on the in-battle render.
|
||||||
|
/// <para>FOOTGUN: as a record holding <see cref="SelfDeckCardIds"/> (an IReadOnlyList), the
|
||||||
|
/// synthesized value-equality is reference-based on that list — see <see cref="BattlePlayer"/>.
|
||||||
|
/// Don't use as a dict key / <c>Distinct()</c> operand without content equality.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record MatchContext(
|
public sealed record MatchContext(
|
||||||
// Player's drafted deck — exactly 30 entries, idx 1..30 paired with the chosen cardIds
|
// Player's drafted deck — exactly 30 entries, idx 1..30 paired with the chosen cardIds
|
||||||
@@ -12,12 +15,25 @@ public sealed record MatchContext(
|
|||||||
IReadOnlyList<long> SelfDeckCardIds,
|
IReadOnlyList<long> SelfDeckCardIds,
|
||||||
|
|
||||||
// Player class + leader (BattleStartSelfInfo)
|
// Player class + leader (BattleStartSelfInfo)
|
||||||
string ClassId, // "1".."8"
|
|
||||||
string CharaId, // "1".."8" — equals ClassId when no leader skin chosen
|
/// <summary>The player's class. Rendered onto the wire <c>classId</c> as <c>"1".."8"</c> via
|
||||||
|
/// <see cref="CardClassWire.ToWireValue"/>; a closed set, so it's typed, not stringly.</summary>
|
||||||
|
CardClass ClassId,
|
||||||
|
|
||||||
|
/// <summary>Leader/skin id on the wire <c>charaId</c>. FREE-FORM, not a class enum: it's the
|
||||||
|
/// equipped leader-skin id (e.g. <c>"5000123"</c>) when one is chosen, else the class ordinal
|
||||||
|
/// (<c>"1".."8"</c>). Passed through verbatim — the node never interprets it.</summary>
|
||||||
|
string CharaId,
|
||||||
|
|
||||||
string CardMasterName, // current card-master, e.g. "card_master_node_10015"
|
string CardMasterName, // current card-master, e.g. "card_master_node_10015"
|
||||||
|
|
||||||
// Player cosmetics (MatchedSelfInfo)
|
// Player cosmetics (MatchedSelfInfo)
|
||||||
string CountryCode, // "KOR", "JPN", ...
|
|
||||||
|
/// <summary>Account region code, wire <c>country_code</c>. OPEN-ENDED account data (any value,
|
||||||
|
/// possibly empty); the node never branches on it. <see cref="CountryCodes"/> names the values
|
||||||
|
/// seen in captures.</summary>
|
||||||
|
string CountryCode,
|
||||||
|
|
||||||
string UserName,
|
string UserName,
|
||||||
string SleeveId,
|
string SleeveId,
|
||||||
string EmblemId,
|
string EmblemId,
|
||||||
@@ -25,5 +41,8 @@ public sealed record MatchContext(
|
|||||||
int FieldId,
|
int FieldId,
|
||||||
int IsOfficial, // 0 or 1
|
int IsOfficial, // 0 or 1
|
||||||
|
|
||||||
// Battle-mode hint, currently TK2 == 11. Future modes populate their own value.
|
// Battle-mode hint (the prod do_matching mode id). Named BattleModeId, NOT BattleType, to
|
||||||
int BattleType);
|
// avoid colliding with the <see cref="Sessions.BattleType"/> enum (Pvp/Bot) — a different axis.
|
||||||
|
// Known values live in <see cref="BattleModes"/> (currently just TK2 == 11). Future modes add
|
||||||
|
// their own constant.
|
||||||
|
int BattleModeId);
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ namespace SVSim.BattleNode.Bridge;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class MatchingBridge : IMatchingBridge
|
public sealed class MatchingBridge : IMatchingBridge
|
||||||
{
|
{
|
||||||
|
/// <summary>Battle id is two zero-padded decimal halves concatenated (e.g. "975695" + "075012").
|
||||||
|
/// The half-width and the draw bound must stay coupled: bound == 10^digits.</summary>
|
||||||
|
private const int BattleIdHalfDigits = 6;
|
||||||
|
private const int BattleIdHalfExclusiveMax = 1_000_000; // 10^BattleIdHalfDigits
|
||||||
|
|
||||||
private readonly IBattleSessionStore _store;
|
private readonly IBattleSessionStore _store;
|
||||||
private readonly BattleNodeOptions _options;
|
private readonly BattleNodeOptions _options;
|
||||||
|
|
||||||
@@ -19,19 +24,35 @@ public sealed class MatchingBridge : IMatchingBridge
|
|||||||
_options = options;
|
_options = options;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const int MaxIdRetries = 5;
|
||||||
|
|
||||||
public PendingMatch RegisterBattle(BattlePlayer p1, BattlePlayer? p2, BattleType type)
|
public PendingMatch RegisterBattle(BattlePlayer p1, BattlePlayer? p2, BattleType type)
|
||||||
{
|
{
|
||||||
ValidateContract(p1, p2, type);
|
ValidateContract(p1, p2, type);
|
||||||
|
EvictStaleForViewer(p1.ViewerId);
|
||||||
|
if (p2 is not null) EvictStaleForViewer(p2.ViewerId);
|
||||||
|
|
||||||
// 12-digit decimal battle id mirrors the captures (e.g. "975695075012").
|
var halfFormat = "D" + BattleIdHalfDigits;
|
||||||
// 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));
|
for (var attempt = 0; attempt < MaxIdRetries; attempt++)
|
||||||
return new PendingMatch(battleId, _options.NodeServerUrl);
|
{
|
||||||
|
var hi = RandomNumberGenerator.GetInt32(0, BattleIdHalfExclusiveMax);
|
||||||
|
var lo = RandomNumberGenerator.GetInt32(0, BattleIdHalfExclusiveMax);
|
||||||
|
var battleId = hi.ToString(halfFormat) + lo.ToString(halfFormat);
|
||||||
|
|
||||||
|
if (_store.TryRegisterPending(new PendingBattle(battleId, type, p1, p2)))
|
||||||
|
return new PendingMatch(battleId, _options.NodeServerUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Failed to mint a unique battle id after {MaxIdRetries} attempts.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EvictStaleForViewer(long viewerId)
|
||||||
|
{
|
||||||
|
var stale = _store.TryFindPendingForViewer(viewerId);
|
||||||
|
if (stale is not null)
|
||||||
|
_store.RemovePending(stale.BattleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ValidateContract(BattlePlayer p1, BattlePlayer? p2, BattleType type)
|
private static void ValidateContract(BattlePlayer p1, BattlePlayer? p2, BattleType type)
|
||||||
|
|||||||
@@ -33,6 +33,15 @@ namespace SVSim.BattleNode.Hosting;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class BattleNodeWebSocketHandler
|
public sealed class BattleNodeWebSocketHandler
|
||||||
{
|
{
|
||||||
|
/// <summary>Header/query key names carrying the upgrade credentials — the auth contract
|
||||||
|
/// with the client (and the loader that sets them). Single source of truth for both ends.</summary>
|
||||||
|
private const string BattleIdCredential = "BattleId";
|
||||||
|
private const string ViewerIdCredential = "viewerId";
|
||||||
|
|
||||||
|
/// <summary>Grace period for the close handshake on a bail-out path. A fresh, short timeout —
|
||||||
|
/// <c>ctx.RequestAborted</c> may already be canceled by the path that decided to bail.</summary>
|
||||||
|
private static readonly TimeSpan PoliteCloseTimeout = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
private readonly IBattleSessionStore _store;
|
private readonly IBattleSessionStore _store;
|
||||||
private readonly IWaitingRoom _waitingRoom;
|
private readonly IWaitingRoom _waitingRoom;
|
||||||
private readonly BattleNodeOptions _options;
|
private readonly BattleNodeOptions _options;
|
||||||
@@ -73,8 +82,8 @@ public sealed class BattleNodeWebSocketHandler
|
|||||||
// for the WebSocket-only transport (not on the URL query string). Real clients
|
// 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
|
// therefore send BattleId/viewerId as headers; the integration test sends them as
|
||||||
// query params for convenience. Check headers first, fall back to query.
|
// query params for convenience. Check headers first, fall back to query.
|
||||||
var battleId = ReadCredential(ctx, "BattleId");
|
var battleId = ReadCredential(ctx, BattleIdCredential);
|
||||||
var encryptedViewerId = ReadCredential(ctx, "viewerId");
|
var encryptedViewerId = ReadCredential(ctx, ViewerIdCredential);
|
||||||
if (string.IsNullOrEmpty(battleId) || string.IsNullOrEmpty(encryptedViewerId))
|
if (string.IsNullOrEmpty(battleId) || string.IsNullOrEmpty(encryptedViewerId))
|
||||||
{
|
{
|
||||||
_log.LogWarning("WS upgrade missing BattleId or viewerId (header or query).");
|
_log.LogWarning("WS upgrade missing BattleId or viewerId (header or query).");
|
||||||
@@ -222,9 +231,7 @@ public sealed class BattleNodeWebSocketHandler
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task TryPoliteCloseAsync(WebSocket ws, string reason, string battleId)
|
private async Task TryPoliteCloseAsync(WebSocket ws, string reason, string battleId)
|
||||||
{
|
{
|
||||||
// Use a fresh, short timeout — ctx.RequestAborted may already be canceled by the
|
using var cts = new CancellationTokenSource(PoliteCloseTimeout);
|
||||||
// path that decided to bail out, which would skip the close immediately.
|
|
||||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (ws.State == WebSocketState.Open)
|
if (ws.State == WebSocketState.Open)
|
||||||
|
|||||||
@@ -4,24 +4,20 @@ namespace SVSim.BattleNode.Lifecycle;
|
|||||||
/// Default frame constants templated from TK2 prod captures, shared by the
|
/// Default frame constants templated from TK2 prod captures, shared by the
|
||||||
/// server-authored battle-frame builders. Every value here originated in a real prod
|
/// server-authored battle-frame builders. Every value here originated in a real prod
|
||||||
/// frame in <c>data_dumps/captures/battle-traffic_tk2_regular.ndjson</c>; pulling them
|
/// frame in <c>data_dumps/captures/battle-traffic_tk2_regular.ndjson</c>; pulling them
|
||||||
/// out of <see cref="ServerBattleFrames"/> makes the magic numerics navigable and gives
|
/// out of <see cref="ServerBattleFrames"/> makes the magic numerics navigable. The shared effect
|
||||||
/// the seed a single source of truth instead of two duplicated literals.
|
/// seed and the deck-shuffle/idxChangeSeed are now derived per-battle from a master seed (see
|
||||||
|
/// <see cref="BattleSeeds"/>) — only animation/UI constants remain here.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static class BattleFrameDefaults
|
internal static class BattleFrameDefaults
|
||||||
{
|
{
|
||||||
// Shared per the spec — selfInfo.seed and oppoInfo.seed always agree.
|
|
||||||
// From frame[2] (Matched).
|
|
||||||
public const long BattleSeed = 17_548_138L;
|
|
||||||
|
|
||||||
// From frame[5] (BattleStart). Hardcoded; see spec §Deferred plumbing — sourcing these
|
// From frame[5] (BattleStart). Hardcoded; see spec §Deferred plumbing — sourcing these
|
||||||
// from real per-viewer state needs a TK2 rank/battle-point tracker.
|
// from real per-viewer state needs a TK2 rank/battle-point tracker.
|
||||||
public const string PlayerRank = "10";
|
public const string PlayerRank = "10";
|
||||||
public const string PlayerBattlePoint = "6270";
|
public const string PlayerBattlePoint = "6270";
|
||||||
|
|
||||||
// From frame[8] (Ready). Provenance is "what prod sent"; the client
|
// From frame[8] (Ready). Provenance is "what prod sent"; the client doesn't validate. This is
|
||||||
// doesn't validate, but echoing matches the capture protects against
|
// an animation crank value (shared-RNG spin), NOT gameplay randomness — both clients crank it
|
||||||
// a regression on a future tightening.
|
// identically and stay synced, so it stays a constant. See the spin-rng audit.
|
||||||
public const int ReadyIdxChangeSeed = 771_335_280;
|
|
||||||
public const int ReadySpin = 243;
|
public const int ReadySpin = 243;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -30,4 +26,9 @@ internal static class BattleFrameDefaults
|
|||||||
/// the client's <c>JudgeOperation</c> doesn't read it.
|
/// the client's <c>JudgeOperation</c> doesn't read it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const int OpponentJudgeSpin = 100;
|
public const int OpponentJudgeSpin = 100;
|
||||||
|
|
||||||
|
/// <summary>Spin value the PvP relay stamps on the Judge / OpponentTurnStart handover frames
|
||||||
|
/// in the deterministic-turn slice. 0 = no animation seed; per-turn spin is deferred
|
||||||
|
/// (see the real-spin design). The client self-generates its turn-open and doesn't read it.</summary>
|
||||||
|
public const int DeterministicTurnSpin = 0;
|
||||||
}
|
}
|
||||||
|
|||||||
41
SVSim.BattleNode/Lifecycle/BattleSeeds.cs
Normal file
41
SVSim.BattleNode/Lifecycle/BattleSeeds.cs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
namespace SVSim.BattleNode.Lifecycle;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deterministic per-battle seed derivation. Given one random master seed (chosen once per battle
|
||||||
|
/// on <see cref="Sessions.Dispatch.BattleSessionState"/>), derives every RNG value the node hands
|
||||||
|
/// the clients: the shared effect seed (Matched.seed), each side's deck-shuffle RNG seed, and each
|
||||||
|
/// side's Ready.idxChangeSeed.
|
||||||
|
///
|
||||||
|
/// IMPORTANT: uses a fixed splitmix64-style bit-mix, NOT System.HashCode / string.GetHashCode
|
||||||
|
/// (those are randomized per process). Stability across process runs is what makes "same master
|
||||||
|
/// seed reproduces the same battle" — the foundation of replay — actually hold.
|
||||||
|
/// </summary>
|
||||||
|
internal static class BattleSeeds
|
||||||
|
{
|
||||||
|
/// <summary>Shared effect-RNG seed; identical for both sides (it seeds the synced stream).</summary>
|
||||||
|
public static int Stable(int master) => Derive(master, "stable");
|
||||||
|
|
||||||
|
/// <summary>Per-side Ready.idxChangeSeed (client XorShift for mid-battle card-into-deck).</summary>
|
||||||
|
public static int IdxChange(int master, long viewerId) => Derive(master, "idx", viewerId);
|
||||||
|
|
||||||
|
/// <summary>Per-side deck-shuffle RNG seed (node-side Fisher–Yates).</summary>
|
||||||
|
public static int DeckShuffle(int master, long viewerId) => Derive(master, "deck", viewerId);
|
||||||
|
|
||||||
|
/// <summary>Derive a stable non-negative int from (master, tag, discriminator). Pure arithmetic
|
||||||
|
/// — reproducible across process runs and platforms.</summary>
|
||||||
|
public static int Derive(int master, string tag, long disc = 0)
|
||||||
|
{
|
||||||
|
ulong h = Mix((uint)master);
|
||||||
|
foreach (char c in tag) h = Mix(h ^ c);
|
||||||
|
h = Mix(h ^ (ulong)disc);
|
||||||
|
return (int)(h & 0x7FFFFFFFUL);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ulong Mix(ulong x)
|
||||||
|
{
|
||||||
|
x += 0x9E3779B97F4A7C15UL;
|
||||||
|
x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9UL;
|
||||||
|
x = (x ^ (x >> 27)) * 0x94D049BB133111EBUL;
|
||||||
|
return x ^ (x >> 31);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ public static class ServerBattleFrames
|
|||||||
public static MsgEnvelope BuildMatched(
|
public static MsgEnvelope BuildMatched(
|
||||||
MatchContext selfCtx, MatchContext oppoCtx,
|
MatchContext selfCtx, MatchContext oppoCtx,
|
||||||
long selfViewerId, long oppoViewerId,
|
long selfViewerId, long oppoViewerId,
|
||||||
string battleId, long seed) =>
|
string battleId, int seed, IReadOnlyList<long> selfDeckOrder) =>
|
||||||
EnvelopeForPush(NetworkBattleUri.Matched,
|
EnvelopeForPush(NetworkBattleUri.Matched,
|
||||||
new MatchedBody(
|
new MatchedBody(
|
||||||
SelfInfo: new MatchedSelfInfo(
|
SelfInfo: new MatchedSelfInfo(
|
||||||
@@ -33,8 +33,8 @@ public static class ServerBattleFrames
|
|||||||
EmblemId: selfCtx.EmblemId,
|
EmblemId: selfCtx.EmblemId,
|
||||||
DegreeId: selfCtx.DegreeId,
|
DegreeId: selfCtx.DegreeId,
|
||||||
FieldId: selfCtx.FieldId,
|
FieldId: selfCtx.FieldId,
|
||||||
IsOfficial: selfCtx.IsOfficial,
|
IsOfficial: selfCtx.IsOfficial != 0,
|
||||||
OppoId: oppoViewerId,
|
OppoId: (int)oppoViewerId,
|
||||||
Seed: seed),
|
Seed: seed),
|
||||||
OppoInfo: new MatchedOppoInfo(
|
OppoInfo: new MatchedOppoInfo(
|
||||||
CountryCode: oppoCtx.CountryCode,
|
CountryCode: oppoCtx.CountryCode,
|
||||||
@@ -43,23 +43,23 @@ public static class ServerBattleFrames
|
|||||||
EmblemId: oppoCtx.EmblemId,
|
EmblemId: oppoCtx.EmblemId,
|
||||||
DegreeId: oppoCtx.DegreeId,
|
DegreeId: oppoCtx.DegreeId,
|
||||||
FieldId: oppoCtx.FieldId,
|
FieldId: oppoCtx.FieldId,
|
||||||
IsOfficial: oppoCtx.IsOfficial,
|
IsOfficial: oppoCtx.IsOfficial != 0,
|
||||||
OppoId: selfViewerId,
|
OppoId: (int)selfViewerId,
|
||||||
Seed: seed,
|
Seed: seed,
|
||||||
OppoDeckCount: oppoCtx.SelfDeckCardIds.Count),
|
OppoDeckCount: oppoCtx.SelfDeckCardIds.Count),
|
||||||
SelfDeck: BuildPlayerDeck(selfCtx.SelfDeckCardIds)),
|
SelfDeck: BuildPlayerDeck(selfDeckOrder)),
|
||||||
bid: battleId);
|
bid: battleId);
|
||||||
|
|
||||||
public static MsgEnvelope BuildBattleStart(
|
public static MsgEnvelope BuildBattleStart(
|
||||||
MatchContext selfCtx, MatchContext oppoCtx, long selfViewerId, int turnState) =>
|
MatchContext selfCtx, MatchContext oppoCtx, long selfViewerId, TurnState turnState) =>
|
||||||
EnvelopeForPush(NetworkBattleUri.BattleStart,
|
EnvelopeForPush(NetworkBattleUri.BattleStart,
|
||||||
new BattleStartBody(
|
new BattleStartBody(
|
||||||
TurnState: turnState, // 0 = this side goes first, 1 = second. Caller decides.
|
TurnState: turnState, // First = this side goes first, Second = second. Caller decides.
|
||||||
BattleType: selfCtx.BattleType,
|
BattleModeId: selfCtx.BattleModeId,
|
||||||
SelfInfo: new BattleStartSelfInfo(
|
SelfInfo: new BattleStartSelfInfo(
|
||||||
Rank: BattleFrameDefaults.PlayerRank,
|
Rank: BattleFrameDefaults.PlayerRank,
|
||||||
BattlePoint: BattleFrameDefaults.PlayerBattlePoint,
|
BattlePoint: BattleFrameDefaults.PlayerBattlePoint,
|
||||||
ClassId: selfCtx.ClassId,
|
ClassId: selfCtx.ClassId.ToWireValue(),
|
||||||
CharaId: selfCtx.CharaId,
|
CharaId: selfCtx.CharaId,
|
||||||
CardMasterName: selfCtx.CardMasterName),
|
CardMasterName: selfCtx.CardMasterName),
|
||||||
OppoInfo: new BattleStartOppoInfo(
|
OppoInfo: new BattleStartOppoInfo(
|
||||||
@@ -69,7 +69,7 @@ public static class ServerBattleFrames
|
|||||||
IsMasterRank: "0",
|
IsMasterRank: "0",
|
||||||
BattlePoint: 0,
|
BattlePoint: 0,
|
||||||
MasterPoint: "0",
|
MasterPoint: "0",
|
||||||
ClassId: oppoCtx.ClassId,
|
ClassId: oppoCtx.ClassId.ToWireValue(),
|
||||||
CharaId: oppoCtx.CharaId,
|
CharaId: oppoCtx.CharaId,
|
||||||
CardMasterName: oppoCtx.CardMasterName)));
|
CardMasterName: oppoCtx.CardMasterName)));
|
||||||
|
|
||||||
@@ -90,13 +90,14 @@ public static class ServerBattleFrames
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compute the player's hand after a mulligan. For every idx in <paramref name="swapIndices"/>
|
/// 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,
|
/// that is currently in the hand, replace it with the next unused deck idx (the first idx past
|
||||||
/// since 1..3 were dealt). Positions of kept cards are preserved.
|
/// the opening hand — <see cref="InitialHand"/> is 1-based and contiguous, so that's
|
||||||
|
/// <c>InitialHand.Length + 1</c>). Positions of kept cards are preserved.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static long[] ComputeHandAfterSwap(IReadOnlyList<long> swapIndices)
|
public static long[] ComputeHandAfterSwap(IReadOnlyList<long> swapIndices)
|
||||||
{
|
{
|
||||||
var hand = InitialHand.ToArray();
|
var hand = InitialHand.ToArray();
|
||||||
var nextDeckIdx = 4L;
|
var nextDeckIdx = (long)(InitialHand.Length + 1);
|
||||||
for (var pos = 0; pos < hand.Length; pos++)
|
for (var pos = 0; pos < hand.Length; pos++)
|
||||||
{
|
{
|
||||||
if (swapIndices.Contains(hand[pos]))
|
if (swapIndices.Contains(hand[pos]))
|
||||||
@@ -113,16 +114,17 @@ public static class ServerBattleFrames
|
|||||||
|
|
||||||
/// <summary>Non-interactive opponent (Bot/AI): oppo is the placeholder
|
/// <summary>Non-interactive opponent (Bot/AI): oppo is the placeholder
|
||||||
/// <see cref="InitialHand"/>.</summary>
|
/// <see cref="InitialHand"/>.</summary>
|
||||||
public static MsgEnvelope BuildReady(IReadOnlyList<long> hand) => BuildReady(hand, InitialHand);
|
public static MsgEnvelope BuildReady(IReadOnlyList<long> hand, int idxChangeSeed) =>
|
||||||
|
BuildReady(hand, InitialHand, idxChangeSeed);
|
||||||
|
|
||||||
/// <summary>Both hands known (the mulligan barrier supplies the opponent's
|
/// <summary>Both hands known (the mulligan barrier supplies the opponent's
|
||||||
/// post-mulligan hand).</summary>
|
/// post-mulligan hand).</summary>
|
||||||
public static MsgEnvelope BuildReady(IReadOnlyList<long> selfHand, IReadOnlyList<long> oppoHand) =>
|
public static MsgEnvelope BuildReady(IReadOnlyList<long> selfHand, IReadOnlyList<long> oppoHand, int idxChangeSeed) =>
|
||||||
EnvelopeForPush(NetworkBattleUri.Ready,
|
EnvelopeForPush(NetworkBattleUri.Ready,
|
||||||
new ReadyBody(
|
new ReadyBody(
|
||||||
Self: BuildPosIdxList(selfHand),
|
Self: BuildPosIdxList(selfHand),
|
||||||
Oppo: BuildPosIdxList(oppoHand),
|
Oppo: BuildPosIdxList(oppoHand),
|
||||||
IdxChangeSeed: BattleFrameDefaults.ReadyIdxChangeSeed,
|
IdxChangeSeed: idxChangeSeed,
|
||||||
Spin: BattleFrameDefaults.ReadySpin));
|
Spin: BattleFrameDefaults.ReadySpin));
|
||||||
|
|
||||||
private static IReadOnlyList<PosIdx> BuildPosIdxList(IReadOnlyList<long> hand)
|
private static IReadOnlyList<PosIdx> BuildPosIdxList(IReadOnlyList<long> hand)
|
||||||
@@ -150,7 +152,7 @@ public static class ServerBattleFrames
|
|||||||
ViewerId: FakeOpponentViewerId,
|
ViewerId: FakeOpponentViewerId,
|
||||||
Uuid: WireConstants.ServerUuid,
|
Uuid: WireConstants.ServerUuid,
|
||||||
Bid: bid,
|
Bid: bid,
|
||||||
Try: 0,
|
RetryAttempt: 0,
|
||||||
Cat: EmitCategory.Battle,
|
Cat: EmitCategory.Battle,
|
||||||
PubSeq: null,
|
PubSeq: null,
|
||||||
PlaySeq: null,
|
PlaySeq: null,
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ using System.Text.Json.Serialization;
|
|||||||
|
|
||||||
namespace SVSim.BattleNode.Protocol.Bodies;
|
namespace SVSim.BattleNode.Protocol.Bodies;
|
||||||
|
|
||||||
|
/// <summary>Gungnir keepalive push. <c>scs</c> = self connection status, <c>ocs</c> = opponent
|
||||||
|
/// connection status; both carry <see cref="WireConstants.OnlineStatus"/> ("ONLINE") in v1.
|
||||||
|
/// Intentionally has no <c>resultCode</c> — the client treats an absent resultCode on alive
|
||||||
|
/// frames as "no error" (the lone body without one).</summary>
|
||||||
public sealed record AlivePushBody(
|
public sealed record AlivePushBody(
|
||||||
[property: JsonPropertyName("scs")] string Scs,
|
[property: JsonPropertyName("scs")] string Scs,
|
||||||
[property: JsonPropertyName("ocs")] string Ocs) : IMsgBody;
|
[property: JsonPropertyName("ocs")] string Ocs) : IMsgBody;
|
||||||
|
|||||||
@@ -6,4 +6,4 @@ public sealed record BattleFinishBody(
|
|||||||
[property: JsonPropertyName("result")]
|
[property: JsonPropertyName("result")]
|
||||||
[property: JsonConverter(typeof(JsonNumberEnumConverter<BattleResult>))]
|
[property: JsonConverter(typeof(JsonNumberEnumConverter<BattleResult>))]
|
||||||
BattleResult Result,
|
BattleResult Result,
|
||||||
[property: JsonPropertyName("resultCode")] int ResultCode = 1) : IMsgBody;
|
[property: JsonPropertyName("resultCode")] int ResultCode = (int)ReceiveNodeResultCode.Success) : IMsgBody;
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ using System.Text.Json.Serialization;
|
|||||||
namespace SVSim.BattleNode.Protocol.Bodies;
|
namespace SVSim.BattleNode.Protocol.Bodies;
|
||||||
|
|
||||||
public sealed record BattleStartBody(
|
public sealed record BattleStartBody(
|
||||||
[property: JsonPropertyName("turnState")] int TurnState,
|
[property: JsonPropertyName("turnState")]
|
||||||
[property: JsonPropertyName("battleType")] int BattleType,
|
[property: JsonConverter(typeof(JsonNumberEnumConverter<TurnState>))] TurnState TurnState,
|
||||||
|
// Wire key stays "battleType" (the client's contract); the CLR name is BattleModeId so the
|
||||||
|
// project keeps one meaning of "BattleType" — the Sessions.BattleType enum (Pvp/Bot).
|
||||||
|
[property: JsonPropertyName("battleType")] int BattleModeId,
|
||||||
[property: JsonPropertyName("selfInfo")] BattleStartSelfInfo SelfInfo,
|
[property: JsonPropertyName("selfInfo")] BattleStartSelfInfo SelfInfo,
|
||||||
[property: JsonPropertyName("oppoInfo")] BattleStartOppoInfo OppoInfo,
|
[property: JsonPropertyName("oppoInfo")] BattleStartOppoInfo OppoInfo,
|
||||||
[property: JsonPropertyName("resultCode")] int ResultCode = 1) : IMsgBody;
|
[property: JsonPropertyName("resultCode")] int ResultCode = (int)ReceiveNodeResultCode.Success) : IMsgBody;
|
||||||
|
|
||||||
public sealed record BattleStartSelfInfo(
|
public sealed record BattleStartSelfInfo(
|
||||||
[property: JsonPropertyName("rank")] string Rank,
|
[property: JsonPropertyName("rank")] string Rank,
|
||||||
@@ -18,6 +21,7 @@ public sealed record BattleStartSelfInfo(
|
|||||||
|
|
||||||
// Note: BattlePoint is int on the wire here (not string as on self) — matches the
|
// 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.
|
// captured prod frame at data_dumps/captures/battle-traffic_tk2_regular.ndjson.
|
||||||
|
// The string-self / int-oppo split is INTENTIONAL; do NOT unify the two for "consistency".
|
||||||
public sealed record BattleStartOppoInfo(
|
public sealed record BattleStartOppoInfo(
|
||||||
[property: JsonPropertyName("rank")] string Rank,
|
[property: JsonPropertyName("rank")] string Rank,
|
||||||
[property: JsonPropertyName("isMasterRank")] string IsMasterRank,
|
[property: JsonPropertyName("isMasterRank")] string IsMasterRank,
|
||||||
|
|||||||
@@ -5,4 +5,4 @@ namespace SVSim.BattleNode.Protocol.Bodies;
|
|||||||
public sealed record DealBody(
|
public sealed record DealBody(
|
||||||
[property: JsonPropertyName("self")] IReadOnlyList<PosIdx> Self,
|
[property: JsonPropertyName("self")] IReadOnlyList<PosIdx> Self,
|
||||||
[property: JsonPropertyName("oppo")] IReadOnlyList<PosIdx> Oppo,
|
[property: JsonPropertyName("oppo")] IReadOnlyList<PosIdx> Oppo,
|
||||||
[property: JsonPropertyName("resultCode")] int ResultCode = 1) : IMsgBody;
|
[property: JsonPropertyName("resultCode")] int ResultCode = (int)ReceiveNodeResultCode.Success) : IMsgBody;
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ using System.Text.Json.Serialization;
|
|||||||
|
|
||||||
namespace SVSim.BattleNode.Protocol.Bodies;
|
namespace SVSim.BattleNode.Protocol.Bodies;
|
||||||
|
|
||||||
|
/// <summary>Server-pushed Judge frame (turn-handover gate; reflected to the sender in PvP).
|
||||||
|
/// Same wire shape as <see cref="OpponentTurnStartBody"/> — kept distinct because they back
|
||||||
|
/// different frames/URIs.</summary>
|
||||||
public sealed record JudgeBody(
|
public sealed record JudgeBody(
|
||||||
[property: JsonPropertyName("spin")] int Spin,
|
[property: JsonPropertyName("spin")] int Spin,
|
||||||
[property: JsonPropertyName("resultCode")] int ResultCode = 1) : IMsgBody;
|
[property: JsonPropertyName("resultCode")] int ResultCode = (int)ReceiveNodeResultCode.Success) : IMsgBody;
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ public sealed record MatchedBody(
|
|||||||
[property: JsonPropertyName("selfInfo")] MatchedSelfInfo SelfInfo,
|
[property: JsonPropertyName("selfInfo")] MatchedSelfInfo SelfInfo,
|
||||||
[property: JsonPropertyName("oppoInfo")] MatchedOppoInfo OppoInfo,
|
[property: JsonPropertyName("oppoInfo")] MatchedOppoInfo OppoInfo,
|
||||||
[property: JsonPropertyName("selfDeck")] IReadOnlyList<DeckCardRef> SelfDeck,
|
[property: JsonPropertyName("selfDeck")] IReadOnlyList<DeckCardRef> SelfDeck,
|
||||||
[property: JsonPropertyName("resultCode")] int ResultCode = 1) : IMsgBody;
|
[property: JsonPropertyName("resultCode")] int ResultCode = (int)ReceiveNodeResultCode.Success) : IMsgBody;
|
||||||
|
|
||||||
|
// Note: `country_code` is deliberately snake_case among camelCase siblings — that's what prod
|
||||||
|
// sends on this frame (verified against the TK2 capture). Do NOT "normalize" it to countryCode.
|
||||||
public sealed record MatchedSelfInfo(
|
public sealed record MatchedSelfInfo(
|
||||||
[property: JsonPropertyName("country_code")] string CountryCode,
|
[property: JsonPropertyName("country_code")] string CountryCode,
|
||||||
[property: JsonPropertyName("userName")] string UserName,
|
[property: JsonPropertyName("userName")] string UserName,
|
||||||
@@ -15,9 +17,10 @@ public sealed record MatchedSelfInfo(
|
|||||||
[property: JsonPropertyName("emblemId")] string EmblemId,
|
[property: JsonPropertyName("emblemId")] string EmblemId,
|
||||||
[property: JsonPropertyName("degreeId")] string DegreeId,
|
[property: JsonPropertyName("degreeId")] string DegreeId,
|
||||||
[property: JsonPropertyName("fieldId")] int FieldId,
|
[property: JsonPropertyName("fieldId")] int FieldId,
|
||||||
[property: JsonPropertyName("isOfficial")] int IsOfficial,
|
[property: JsonPropertyName("isOfficial")]
|
||||||
[property: JsonPropertyName("oppoId")] long OppoId,
|
[property: JsonConverter(typeof(NumericBoolJsonConverter))] bool IsOfficial,
|
||||||
[property: JsonPropertyName("seed")] long Seed);
|
[property: JsonPropertyName("oppoId")] int OppoId,
|
||||||
|
[property: JsonPropertyName("seed")] int Seed);
|
||||||
|
|
||||||
public sealed record MatchedOppoInfo(
|
public sealed record MatchedOppoInfo(
|
||||||
[property: JsonPropertyName("country_code")] string CountryCode,
|
[property: JsonPropertyName("country_code")] string CountryCode,
|
||||||
@@ -26,9 +29,10 @@ public sealed record MatchedOppoInfo(
|
|||||||
[property: JsonPropertyName("emblemId")] string EmblemId,
|
[property: JsonPropertyName("emblemId")] string EmblemId,
|
||||||
[property: JsonPropertyName("degreeId")] string DegreeId,
|
[property: JsonPropertyName("degreeId")] string DegreeId,
|
||||||
[property: JsonPropertyName("fieldId")] int FieldId,
|
[property: JsonPropertyName("fieldId")] int FieldId,
|
||||||
[property: JsonPropertyName("isOfficial")] int IsOfficial,
|
[property: JsonPropertyName("isOfficial")]
|
||||||
[property: JsonPropertyName("oppoId")] long OppoId,
|
[property: JsonConverter(typeof(NumericBoolJsonConverter))] bool IsOfficial,
|
||||||
[property: JsonPropertyName("seed")] long Seed,
|
[property: JsonPropertyName("oppoId")] int OppoId,
|
||||||
|
[property: JsonPropertyName("seed")] int Seed,
|
||||||
[property: JsonPropertyName("oppoDeckCount")] int OppoDeckCount);
|
[property: JsonPropertyName("oppoDeckCount")] int OppoDeckCount);
|
||||||
|
|
||||||
public sealed record DeckCardRef(
|
public sealed record DeckCardRef(
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ using System.Text.Json.Serialization;
|
|||||||
|
|
||||||
namespace SVSim.BattleNode.Protocol.Bodies;
|
namespace SVSim.BattleNode.Protocol.Bodies;
|
||||||
|
|
||||||
|
/// <summary>Server-pushed opponent-turn-open frame (relayed to the non-active player).
|
||||||
|
/// Same wire shape as <see cref="JudgeBody"/> — kept distinct because they back different
|
||||||
|
/// frames/URIs.</summary>
|
||||||
public sealed record OpponentTurnStartBody(
|
public sealed record OpponentTurnStartBody(
|
||||||
[property: JsonPropertyName("spin")] int Spin,
|
[property: JsonPropertyName("spin")] int Spin,
|
||||||
[property: JsonPropertyName("resultCode")] int ResultCode = 1) : IMsgBody;
|
[property: JsonPropertyName("resultCode")] int ResultCode = (int)ReceiveNodeResultCode.Success) : IMsgBody;
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ public sealed record PlayActionsBroadcastBody(
|
|||||||
/// until the chosen card is played — and passed through for a visible (open:1) board choice (§6,
|
/// until the chosen card is played — and passed through for a visible (open:1) board choice (§6,
|
||||||
/// provisional pending live confirmation).</summary>
|
/// provisional pending live confirmation).</summary>
|
||||||
public sealed record KeyActionEntry(
|
public sealed record KeyActionEntry(
|
||||||
[property: JsonPropertyName("type")] int Type,
|
[property: JsonPropertyName("type")]
|
||||||
|
[property: JsonConverter(typeof(JsonNumberEnumConverter<KeyActionType>))] KeyActionType Type,
|
||||||
[property: JsonPropertyName("cardId")] long CardId,
|
[property: JsonPropertyName("cardId")] long CardId,
|
||||||
[property: JsonPropertyName("selectCard")] SelectCardEntry? SelectCard);
|
[property: JsonPropertyName("selectCard")] SelectCardEntry? SelectCard);
|
||||||
|
|
||||||
@@ -32,7 +33,8 @@ public sealed record KeyActionEntry(
|
|||||||
/// Only emitted for the open:1 pass-through case (open:0 strips the whole <c>selectCard</c>).</summary>
|
/// Only emitted for the open:1 pass-through case (open:0 strips the whole <c>selectCard</c>).</summary>
|
||||||
public sealed record SelectCardEntry(
|
public sealed record SelectCardEntry(
|
||||||
[property: JsonPropertyName("cardId")] IReadOnlyList<long> CardId,
|
[property: JsonPropertyName("cardId")] IReadOnlyList<long> CardId,
|
||||||
[property: JsonPropertyName("open")] int Open);
|
[property: JsonPropertyName("open")]
|
||||||
|
[property: JsonConverter(typeof(JsonNumberEnumConverter<ChoiceVisibility>))] ChoiceVisibility Open);
|
||||||
|
|
||||||
/// <summary>One revealed card in a <c>knownList</c>. Vanilla slice fills cardId from the sender's
|
/// <summary>One revealed card in a <c>knownList</c>. Vanilla slice fills cardId from the sender's
|
||||||
/// deck map and leaves spellboost 0 / attachTarget "" (cost/clan/tribe deferred to the card-master
|
/// deck map and leaves spellboost 0 / attachTarget "" (cost/clan/tribe deferred to the card-master
|
||||||
@@ -48,7 +50,8 @@ public sealed record KnownCardEntry(
|
|||||||
/// verbatim — no perspective flip (bullet-3 audit F2).</summary>
|
/// verbatim — no perspective flip (bullet-3 audit F2).</summary>
|
||||||
public sealed record OppoTargetEntry(
|
public sealed record OppoTargetEntry(
|
||||||
[property: JsonPropertyName("targetIdx")] int TargetIdx,
|
[property: JsonPropertyName("targetIdx")] int TargetIdx,
|
||||||
[property: JsonPropertyName("isSelf")] int IsSelf);
|
[property: JsonPropertyName("isSelf")]
|
||||||
|
[property: JsonConverter(typeof(JsonNumberEnumConverter<CardOwner>))] CardOwner IsSelf);
|
||||||
|
|
||||||
/// <summary>One entry in a relayed <c>uList</c> (the unapproved-movement list) — a skill-driven
|
/// <summary>One entry in a relayed <c>uList</c> (the unapproved-movement list) — a skill-driven
|
||||||
/// card movement (fetch / search / summon-from-deck / discard-reveal) the node forwards VERBATIM
|
/// card movement (fetch / search / summon-from-deck / discard-reveal) the node forwards VERBATIM
|
||||||
@@ -60,12 +63,14 @@ public sealed record UnapprovedCardEntry(
|
|||||||
[property: JsonPropertyName("idxList")] IReadOnlyList<int> IdxList,
|
[property: JsonPropertyName("idxList")] IReadOnlyList<int> IdxList,
|
||||||
[property: JsonPropertyName("from")] int From,
|
[property: JsonPropertyName("from")] int From,
|
||||||
[property: JsonPropertyName("to")] int To,
|
[property: JsonPropertyName("to")] int To,
|
||||||
[property: JsonPropertyName("isSelf")] int IsSelf,
|
[property: JsonPropertyName("isSelf")]
|
||||||
|
[property: JsonConverter(typeof(JsonNumberEnumConverter<CardOwner>))] CardOwner IsSelf,
|
||||||
[property: JsonPropertyName("skill")] string Skill,
|
[property: JsonPropertyName("skill")] string Skill,
|
||||||
[property: JsonPropertyName("cardId")] long? CardId = null,
|
[property: JsonPropertyName("cardId")] long? CardId = null,
|
||||||
[property: JsonPropertyName("clan")] int? Clan = null,
|
[property: JsonPropertyName("clan")] int? Clan = null,
|
||||||
[property: JsonPropertyName("cost")] int? Cost = null,
|
[property: JsonPropertyName("cost")] int? Cost = null,
|
||||||
[property: JsonPropertyName("skillKeyCardIdx")] IReadOnlyList<int>? SkillKeyCardIdx = null,
|
[property: JsonPropertyName("skillKeyCardIdx")] IReadOnlyList<int>? SkillKeyCardIdx = null,
|
||||||
[property: JsonPropertyName("randomTargetIdx")] IReadOnlyList<int>? RandomTargetIdx = null,
|
[property: JsonPropertyName("randomTargetIdx")] IReadOnlyList<int>? RandomTargetIdx = null,
|
||||||
[property: JsonPropertyName("isInvoke")] int? IsInvoke = null,
|
[property: JsonPropertyName("isInvoke")]
|
||||||
|
[property: JsonConverter(typeof(NumericBoolJsonConverter))] bool? IsInvoke = null,
|
||||||
[property: JsonPropertyName("attachTarget")] string? AttachTarget = null);
|
[property: JsonPropertyName("attachTarget")] string? AttachTarget = null);
|
||||||
|
|||||||
@@ -7,4 +7,4 @@ public sealed record ReadyBody(
|
|||||||
[property: JsonPropertyName("oppo")] IReadOnlyList<PosIdx> Oppo,
|
[property: JsonPropertyName("oppo")] IReadOnlyList<PosIdx> Oppo,
|
||||||
[property: JsonPropertyName("idxChangeSeed")] int IdxChangeSeed,
|
[property: JsonPropertyName("idxChangeSeed")] int IdxChangeSeed,
|
||||||
[property: JsonPropertyName("spin")] int Spin,
|
[property: JsonPropertyName("spin")] int Spin,
|
||||||
[property: JsonPropertyName("resultCode")] int ResultCode = 1) : IMsgBody;
|
[property: JsonPropertyName("resultCode")] int ResultCode = (int)ReceiveNodeResultCode.Success) : IMsgBody;
|
||||||
|
|||||||
@@ -3,4 +3,4 @@ using System.Text.Json.Serialization;
|
|||||||
namespace SVSim.BattleNode.Protocol.Bodies;
|
namespace SVSim.BattleNode.Protocol.Bodies;
|
||||||
|
|
||||||
public sealed record ResultCodeOnlyBody(
|
public sealed record ResultCodeOnlyBody(
|
||||||
[property: JsonPropertyName("resultCode")] int ResultCode = 1) : IMsgBody;
|
[property: JsonPropertyName("resultCode")] int ResultCode = (int)ReceiveNodeResultCode.Success) : IMsgBody;
|
||||||
|
|||||||
@@ -4,4 +4,4 @@ namespace SVSim.BattleNode.Protocol.Bodies;
|
|||||||
|
|
||||||
public sealed record SwapResponseBody(
|
public sealed record SwapResponseBody(
|
||||||
[property: JsonPropertyName("self")] IReadOnlyList<PosIdx> Self,
|
[property: JsonPropertyName("self")] IReadOnlyList<PosIdx> Self,
|
||||||
[property: JsonPropertyName("resultCode")] int ResultCode = 1) : IMsgBody;
|
[property: JsonPropertyName("resultCode")] int ResultCode = (int)ReceiveNodeResultCode.Success) : IMsgBody;
|
||||||
|
|||||||
@@ -3,5 +3,6 @@ using System.Text.Json.Serialization;
|
|||||||
namespace SVSim.BattleNode.Protocol.Bodies;
|
namespace SVSim.BattleNode.Protocol.Bodies;
|
||||||
|
|
||||||
public sealed record TurnEndBody(
|
public sealed record TurnEndBody(
|
||||||
[property: JsonPropertyName("turnState")] int TurnState,
|
[property: JsonPropertyName("turnState")]
|
||||||
[property: JsonPropertyName("resultCode")] int ResultCode = 1) : IMsgBody;
|
[property: JsonConverter(typeof(JsonNumberEnumConverter<TurnState>))] TurnState TurnState,
|
||||||
|
[property: JsonPropertyName("resultCode")] int ResultCode = (int)ReceiveNodeResultCode.Success) : IMsgBody;
|
||||||
|
|||||||
17
SVSim.BattleNode/Protocol/CardOwner.cs
Normal file
17
SVSim.BattleNode/Protocol/CardOwner.cs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
namespace SVSim.BattleNode.Protocol;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wire value of the actor-relative <c>isSelf</c> flag on relayed lists (<c>targetList</c>,
|
||||||
|
/// <c>uList</c>): whose side a referenced card belongs to, from the SENDER's perspective. The node
|
||||||
|
/// forwards it verbatim — no perspective flip (bullet-3 audit F2). The client reads it via
|
||||||
|
/// <c>ConvertToInt(...) == 1</c> (<c>NetworkBattleReceiver.cs</c>), so it serializes as the
|
||||||
|
/// underlying int via <see cref="System.Text.Json.Serialization.JsonNumberEnumConverter{T}"/>.
|
||||||
|
/// </summary>
|
||||||
|
public enum CardOwner
|
||||||
|
{
|
||||||
|
/// <summary>Card belongs to the opponent of the sender.</summary>
|
||||||
|
Opponent = 0,
|
||||||
|
|
||||||
|
/// <summary>Card belongs to the sender.</summary>
|
||||||
|
Self = 1,
|
||||||
|
}
|
||||||
17
SVSim.BattleNode/Protocol/ChoiceVisibility.cs
Normal file
17
SVSim.BattleNode/Protocol/ChoiceVisibility.cs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
namespace SVSim.BattleNode.Protocol;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wire value of <c>open</c> on a choice/Discover <c>selectCard</c>: whether the pick is revealed.
|
||||||
|
/// The client emits it as <c>selectCardIsOpen ? 1 : 0</c> (<c>SendKeyActionDataManager.cs</c>);
|
||||||
|
/// the node uses it to decide whether to strip the pick for the opponent (<c>Hidden</c> = strip).
|
||||||
|
/// Serializes as the underlying int via
|
||||||
|
/// <see cref="System.Text.Json.Serialization.JsonNumberEnumConverter{T}"/>.
|
||||||
|
/// </summary>
|
||||||
|
public enum ChoiceVisibility
|
||||||
|
{
|
||||||
|
/// <summary>Hidden draw-to-hand pick — the chosen card stays secret until played.</summary>
|
||||||
|
Hidden = 0,
|
||||||
|
|
||||||
|
/// <summary>Visible board choice — the pick is revealed immediately.</summary>
|
||||||
|
Open = 1,
|
||||||
|
}
|
||||||
23
SVSim.BattleNode/Protocol/KeyActionType.cs
Normal file
23
SVSim.BattleNode/Protocol/KeyActionType.cs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
namespace SVSim.BattleNode.Protocol;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wire value of <c>type</c> on a keyAction entry — what kind of card-generating choice the play
|
||||||
|
/// is. Mirrors the client's <c>SendKeyActionDataManager.KeyActionType</c> exactly (same ordinals);
|
||||||
|
/// the client reads it back via <c>ConvertToInt(...)</c>, so it serializes as the underlying int
|
||||||
|
/// via <see cref="System.Text.Json.Serialization.JsonNumberEnumConverter{T}"/>. The node currently
|
||||||
|
/// relays only <see cref="Choice"/> and <see cref="HaveBeforeSkillChoice"/>
|
||||||
|
/// (<see cref="Bodies.KeyActionEntry"/> / <c>KnownListBuilder.StripKeyActionForOpponent</c>); the
|
||||||
|
/// rest are defined so the guard compares against named values instead of bare ints.
|
||||||
|
/// </summary>
|
||||||
|
public enum KeyActionType
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
Choice = 1,
|
||||||
|
Accelerated = 2,
|
||||||
|
Crystallize = 3,
|
||||||
|
Fusion = 4,
|
||||||
|
HaveBeforeSkillChoice = 5,
|
||||||
|
BurialRate = 6,
|
||||||
|
ChoiceEvolution = 7,
|
||||||
|
ChoiceBrave = 8,
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
using System.Text.Json.Serialization;
|
using SVSim.BattleNode.Wire;
|
||||||
|
|
||||||
namespace SVSim.BattleNode.Protocol;
|
namespace SVSim.BattleNode.Protocol;
|
||||||
|
|
||||||
@@ -14,32 +14,37 @@ public sealed record MsgEnvelope(
|
|||||||
long ViewerId,
|
long ViewerId,
|
||||||
string Uuid,
|
string Uuid,
|
||||||
string? Bid,
|
string? Bid,
|
||||||
int Try,
|
int RetryAttempt,
|
||||||
EmitCategory Cat,
|
EmitCategory Cat,
|
||||||
long? PubSeq,
|
long? PubSeq,
|
||||||
long? PlaySeq,
|
long? PlaySeq,
|
||||||
IMsgBody Body)
|
IMsgBody Body)
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions Options = CreateOptions();
|
// Bare-camelCase wire serialization, single-sourced in Wire.WireJsonOptions (shared with
|
||||||
|
// EngineIoHandshake). Every wire key here is explicit via the manual ToJson layering below.
|
||||||
|
private static readonly JsonSerializerOptions Options = WireJsonOptions.CamelCase;
|
||||||
|
|
||||||
|
/// <summary>The fixed envelope wire keys, single-sourced. <see cref="ReservedEnvelopeKeys"/>,
|
||||||
|
/// the <see cref="ToJson"/> writes, and the <see cref="FromJson"/> reads all draw from here, so
|
||||||
|
/// the three encodings can't drift — adding a key in one place but not another (which would let a
|
||||||
|
/// body key silently shadow an envelope field) is no longer possible.</summary>
|
||||||
|
private static class Keys
|
||||||
|
{
|
||||||
|
public const string Uri = "uri";
|
||||||
|
public const string ViewerId = "viewerId";
|
||||||
|
public const string Uuid = "uuid";
|
||||||
|
public const string Bid = "bid";
|
||||||
|
public const string Try = "try";
|
||||||
|
public const string Cat = "cat";
|
||||||
|
public const string PubSeq = "pubSeq";
|
||||||
|
public const string PlaySeq = "playSeq";
|
||||||
|
}
|
||||||
|
|
||||||
private static readonly HashSet<string> ReservedEnvelopeKeys = new()
|
private static readonly HashSet<string> ReservedEnvelopeKeys = new()
|
||||||
{
|
{
|
||||||
"uri", "viewerId", "uuid", "bid", "try", "cat", "pubSeq", "playSeq",
|
Keys.Uri, Keys.ViewerId, Keys.Uuid, Keys.Bid, Keys.Try, Keys.Cat, Keys.PubSeq, Keys.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)
|
public static string ToJson(MsgEnvelope env)
|
||||||
{
|
{
|
||||||
// Envelope fields MUST come before body fields on the wire. The client's
|
// Envelope fields MUST come before body fields on the wire. The client's
|
||||||
@@ -48,14 +53,14 @@ public sealed record MsgEnvelope(
|
|||||||
// field processed before "uri" is wiped before Matching.StartBattleLoad reads
|
// field processed before "uri" is wiped before Matching.StartBattleLoad reads
|
||||||
// it back. The prod wire emits envelope keys first; we must too.
|
// it back. The prod wire emits envelope keys first; we must too.
|
||||||
var result = new JsonObject();
|
var result = new JsonObject();
|
||||||
result["uri"] = env.Uri.ToString();
|
result[Keys.Uri] = env.Uri.ToString();
|
||||||
result["viewerId"] = env.ViewerId;
|
result[Keys.ViewerId] = env.ViewerId;
|
||||||
result["uuid"] = env.Uuid;
|
result[Keys.Uuid] = env.Uuid;
|
||||||
result["try"] = env.Try;
|
result[Keys.Try] = env.RetryAttempt;
|
||||||
result["cat"] = (int)env.Cat;
|
result[Keys.Cat] = (int)env.Cat;
|
||||||
if (env.Bid is not null) result["bid"] = env.Bid;
|
if (env.Bid is not null) result[Keys.Bid] = env.Bid;
|
||||||
if (env.PubSeq.HasValue) result["pubSeq"] = env.PubSeq.Value;
|
if (env.PubSeq.HasValue) result[Keys.PubSeq] = env.PubSeq.Value;
|
||||||
if (env.PlaySeq.HasValue) result["playSeq"] = env.PlaySeq.Value;
|
if (env.PlaySeq.HasValue) result[Keys.PlaySeq] = env.PlaySeq.Value;
|
||||||
|
|
||||||
if (env.Body is RawBody raw)
|
if (env.Body is RawBody raw)
|
||||||
{
|
{
|
||||||
@@ -129,14 +134,14 @@ public sealed record MsgEnvelope(
|
|||||||
using var doc = JsonDocument.Parse(json);
|
using var doc = JsonDocument.Parse(json);
|
||||||
var root = doc.RootElement;
|
var root = doc.RootElement;
|
||||||
|
|
||||||
var uri = Enum.Parse<NetworkBattleUri>(root.GetProperty("uri").GetString()!);
|
var uri = Enum.Parse<NetworkBattleUri>(root.GetProperty(Keys.Uri).GetString()!);
|
||||||
var viewerId = root.GetProperty("viewerId").GetInt64();
|
var viewerId = root.GetProperty(Keys.ViewerId).GetInt64();
|
||||||
var uuid = root.GetProperty("uuid").GetString()!;
|
var uuid = root.GetProperty(Keys.Uuid).GetString()!;
|
||||||
var bid = root.TryGetProperty("bid", out var bidEl) ? bidEl.GetString() : null;
|
var bid = root.TryGetProperty(Keys.Bid, out var bidEl) ? bidEl.GetString() : null;
|
||||||
var @try = root.TryGetProperty("try", out var tryEl) ? tryEl.GetInt32() : 0;
|
var retryAttempt = root.TryGetProperty(Keys.Try, out var tryEl) ? tryEl.GetInt32() : 0;
|
||||||
var cat = root.TryGetProperty("cat", out var catEl) ? (EmitCategory)catEl.GetInt32() : EmitCategory.Battle;
|
var cat = root.TryGetProperty(Keys.Cat, out var catEl) ? (EmitCategory)catEl.GetInt32() : EmitCategory.Battle;
|
||||||
var pubSeq = root.TryGetProperty("pubSeq", out var psEl) ? psEl.GetInt64() : (long?)null;
|
var pubSeq = root.TryGetProperty(Keys.PubSeq, out var psEl) ? psEl.GetInt64() : (long?)null;
|
||||||
var playSeq = root.TryGetProperty("playSeq", out var plsEl) ? plsEl.GetInt64() : (long?)null;
|
var playSeq = root.TryGetProperty(Keys.PlaySeq, out var plsEl) ? plsEl.GetInt64() : (long?)null;
|
||||||
|
|
||||||
var bodyDict = new Dictionary<string, object?>();
|
var bodyDict = new Dictionary<string, object?>();
|
||||||
foreach (var prop in root.EnumerateObject())
|
foreach (var prop in root.EnumerateObject())
|
||||||
@@ -145,7 +150,7 @@ public sealed record MsgEnvelope(
|
|||||||
bodyDict[prop.Name] = ToObject(prop.Value);
|
bodyDict[prop.Name] = ToObject(prop.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new MsgEnvelope(uri, viewerId, uuid, bid, @try, cat, pubSeq, playSeq, new RawBody(bodyDict));
|
return new MsgEnvelope(uri, viewerId, uuid, bid, retryAttempt, cat, pubSeq, playSeq, new RawBody(bodyDict));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static object? ToObject(JsonElement el) => el.ValueKind switch
|
private static object? ToObject(JsonElement el) => el.ValueKind switch
|
||||||
|
|||||||
29
SVSim.BattleNode/Protocol/NumericBoolJsonConverter.cs
Normal file
29
SVSim.BattleNode/Protocol/NumericBoolJsonConverter.cs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SVSim.BattleNode.Protocol;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serializes a <see cref="bool"/> as the wire's numeric 0/1. The client reads these flags via
|
||||||
|
/// <c>Convert.ToInt32</c> / <c>Convert.ToBoolean</c> (e.g. <c>isOfficial</c>, <c>isInvoke</c>) —
|
||||||
|
/// never as a JSON <c>true</c>/<c>false</c> token — so a real <c>bool</c> property must still emit
|
||||||
|
/// a number. Read accepts a JSON number (0 = false, non-zero = true) and, defensively, a
|
||||||
|
/// <c>true</c>/<c>false</c> token or a numeric string. Applied per-field via
|
||||||
|
/// <c>[JsonConverter(typeof(NumericBoolJsonConverter))]</c>; works on <c>bool?</c> too (System.Text.Json
|
||||||
|
/// wraps a <c>JsonConverter<bool></c> for the nullable case).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NumericBoolJsonConverter : JsonConverter<bool>
|
||||||
|
{
|
||||||
|
public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||||
|
=> reader.TokenType switch
|
||||||
|
{
|
||||||
|
JsonTokenType.Number => reader.GetInt64() != 0,
|
||||||
|
JsonTokenType.True => true,
|
||||||
|
JsonTokenType.False => false,
|
||||||
|
JsonTokenType.String => long.TryParse(reader.GetString(), out var n) && n != 0,
|
||||||
|
_ => throw new JsonException($"Cannot convert token {reader.TokenType} to a numeric bool"),
|
||||||
|
};
|
||||||
|
|
||||||
|
public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options)
|
||||||
|
=> writer.WriteNumberValue(value ? 1 : 0);
|
||||||
|
}
|
||||||
16
SVSim.BattleNode/Protocol/TurnState.cs
Normal file
16
SVSim.BattleNode/Protocol/TurnState.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
namespace SVSim.BattleNode.Protocol;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wire value of <c>turnState</c> on BattleStart / TurnEnd frames: which side acts first.
|
||||||
|
/// The client reads it via <c>Convert.ToInt32</c> (<c>RealTimeNetworkAgent.cs</c> "turnState"
|
||||||
|
/// case) into <c>NetworkUserInfoData.TurnState</c>, so it serializes as the underlying int via
|
||||||
|
/// <see cref="System.Text.Json.Serialization.JsonNumberEnumConverter{T}"/>.
|
||||||
|
/// </summary>
|
||||||
|
public enum TurnState
|
||||||
|
{
|
||||||
|
/// <summary>This side takes the first turn.</summary>
|
||||||
|
First = 0,
|
||||||
|
|
||||||
|
/// <summary>This side takes the second turn.</summary>
|
||||||
|
Second = 1,
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
namespace SVSim.BattleNode.Reliability;
|
namespace SVSim.BattleNode.Reliability;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Body builders for the alive channel. The timer/loop that drives 5s emits lives on
|
/// Body builders for the alive channel ("Gungnir" is the client's codename for the
|
||||||
/// BattleSession; this class is just the pure body-shape factory.
|
/// keepalive/connection-status channel — see <see cref="Protocol.Bodies.AlivePushBody"/>).
|
||||||
|
/// The timer/loop that would drive the emit cadence
|
||||||
|
/// (<see cref="Bridge.BattleNodeOptions.AliveEmitInterval"/>) is to live on BattleSession;
|
||||||
|
/// this class is just the pure body-shape factory.
|
||||||
/// v1 always reports scs/ocs=ONLINE — real disconnect detection is deferred. The push
|
/// v1 always reports scs/ocs=ONLINE — real disconnect detection is deferred. The push
|
||||||
/// body itself is constructed inline in BattleSession.HandleAliveEventAsync using
|
/// body itself is constructed inline in BattleSession.HandleAliveEventAsync using
|
||||||
/// AlivePushBody; only the emit body (sent by us TO the client on the alive channel,
|
/// AlivePushBody; only the emit body (sent by us TO the client on the alive channel,
|
||||||
@@ -10,8 +13,6 @@ namespace SVSim.BattleNode.Reliability;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class Gungnir
|
public static class Gungnir
|
||||||
{
|
{
|
||||||
public static readonly TimeSpan EmitInterval = TimeSpan.FromSeconds(5);
|
|
||||||
|
|
||||||
public static Dictionary<string, object?> BuildAliveEmitBody(InboundTracker tracker) => new()
|
public static Dictionary<string, object?> BuildAliveEmitBody(InboundTracker tracker) => new()
|
||||||
{
|
{
|
||||||
["currentSeq"] = tracker.HighWaterMark,
|
["currentSeq"] = tracker.HighWaterMark,
|
||||||
|
|||||||
@@ -9,7 +9,16 @@ namespace SVSim.BattleNode.Reliability;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class OutboundSequencer
|
public sealed class OutboundSequencer
|
||||||
{
|
{
|
||||||
private long _next = 1;
|
/// <summary>First playSeq assigned. Starts at 1, not 0 — 0 is reserved for no-stock /
|
||||||
|
/// unsequenced pushes (which carry a null PlaySeq via <see cref="WrapNoStock"/>).</summary>
|
||||||
|
private const long FirstPlaySeq = 1;
|
||||||
|
|
||||||
|
private long _next = FirstPlaySeq;
|
||||||
|
|
||||||
|
// Holds every ordered (stocked) push for the WHOLE match — there is no per-ack pruning, so it
|
||||||
|
// grows with battle length × concurrent battles. Bounded only by Clear() in the terminate cascade.
|
||||||
|
// Fine at current scale; if battles get long or concurrency scales, prune entries below the peer's
|
||||||
|
// ack watermark here (contrast the inbound side, which is bounded by InboundTracker.WindowSize).
|
||||||
private readonly Dictionary<long, MsgEnvelope> _archive = new();
|
private readonly Dictionary<long, MsgEnvelope> _archive = new();
|
||||||
|
|
||||||
public IReadOnlyDictionary<long, MsgEnvelope> Archive => _archive;
|
public IReadOnlyDictionary<long, MsgEnvelope> Archive => _archive;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Net.WebSockets;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using SVSim.BattleNode.Protocol;
|
using SVSim.BattleNode.Protocol;
|
||||||
using SVSim.BattleNode.Sessions.Dispatch;
|
using SVSim.BattleNode.Sessions.Dispatch;
|
||||||
@@ -9,7 +10,7 @@ namespace SVSim.BattleNode.Sessions;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// v2 broker session. Holds two participants and brokers between them. Subscribes
|
/// v2 broker session. Holds two participants and brokers between them. Subscribes
|
||||||
/// to each participant's <see cref="IBattleParticipant.FrameEmitted"/>; on each frame,
|
/// to each participant's <see cref="IBattleParticipant.FrameEmitted"/>; on each frame,
|
||||||
/// runs <see cref="ComputeFrames"/> to determine the routing (target + frame + noStock
|
/// runs <see cref="ComputeFrames"/> to determine the routing (target + frame + <see cref="Stock"/>
|
||||||
/// flag) and dispatches via <see cref="IBattleParticipant.PushAsync"/>.
|
/// flag) and dispatches via <see cref="IBattleParticipant.PushAsync"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -22,11 +23,22 @@ public sealed class BattleSession
|
|||||||
|
|
||||||
private readonly BattleSessionState _state = new();
|
private readonly BattleSessionState _state = new();
|
||||||
|
|
||||||
|
/// <summary>Serializes dispatch. Both participants' read loops raise FrameEmitted on their own
|
||||||
|
/// threads, and a dispatch (<see cref="ComputeFrames"/> + the relay <c>PushAsync</c> calls) mutates
|
||||||
|
/// shared, non-thread-safe state — the <see cref="BattleSessionState"/> dictionaries and each
|
||||||
|
/// participant's <c>OutboundSequencer</c>. This gate funnels both threads through one critical
|
||||||
|
/// section so concurrent frames can't corrupt that state.</summary>
|
||||||
|
private readonly SemaphoreSlim _dispatchGate = new(1, 1);
|
||||||
|
|
||||||
|
/// <summary>The per-battle master seed (see <see cref="BattleSessionState.MasterSeed"/>).
|
||||||
|
/// Exposed for logging + future replay persistence.</summary>
|
||||||
|
public int MasterSeed => _state.MasterSeed;
|
||||||
|
|
||||||
public string BattleId { get; }
|
public string BattleId { get; }
|
||||||
public BattleType Type { get; }
|
public BattleType Type { get; }
|
||||||
public IBattleParticipant A { get; }
|
public IBattleParticipant A { get; }
|
||||||
public IBattleParticipant B { get; }
|
public IBattleParticipant B { get; }
|
||||||
public BattleSessionPhase Phase => _state.SessionPhase;
|
public SessionLifecycle Lifecycle => _state.Lifecycle;
|
||||||
|
|
||||||
// Per-URI dispatch table. All 14 inbound URIs are registered (Tasks 5-14); unknown
|
// Per-URI dispatch table. All 14 inbound URIs are registered (Tasks 5-14); unknown
|
||||||
// URIs are dropped with a LogDebug in ComputeFrames.
|
// URIs are dropped with a LogDebug in ComputeFrames.
|
||||||
@@ -59,7 +71,7 @@ public sealed class BattleSession
|
|||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
A = A, B = B, From = from, Other = ReferenceEquals(from, A) ? B : A,
|
A = A, B = B, From = from, Other = ReferenceEquals(from, A) ? B : A,
|
||||||
Env = env, Type = Type, BattleId = BattleId, State = _state,
|
Env = env, BattleId = BattleId, State = _state,
|
||||||
};
|
};
|
||||||
|
|
||||||
public BattleSession(string battleId, BattleType type, IBattleParticipant a, IBattleParticipant b,
|
public BattleSession(string battleId, BattleType type, IBattleParticipant a, IBattleParticipant b,
|
||||||
@@ -71,6 +83,8 @@ public sealed class BattleSession
|
|||||||
B = b;
|
B = b;
|
||||||
_log = log;
|
_log = log;
|
||||||
|
|
||||||
|
_log.LogInformation("BattleSession {Bid}: master seed {Seed}", BattleId, _state.MasterSeed);
|
||||||
|
|
||||||
// Subscribe to both participants' emissions.
|
// Subscribe to both participants' emissions.
|
||||||
A.FrameEmitted += OnFrameFromA;
|
A.FrameEmitted += OnFrameFromA;
|
||||||
B.FrameEmitted += OnFrameFromB;
|
B.FrameEmitted += OnFrameFromB;
|
||||||
@@ -90,7 +104,7 @@ public sealed class BattleSession
|
|||||||
var first = await Task.WhenAny(aTask, bTask).ConfigureAwait(false);
|
var first = await Task.WhenAny(aTask, bTask).ConfigureAwait(false);
|
||||||
var survivor = first == aTask ? B : A;
|
var survivor = first == aTask ? B : A;
|
||||||
|
|
||||||
if (Phase != BattleSessionPhase.Terminal)
|
if (Lifecycle != SessionLifecycle.Terminal)
|
||||||
{
|
{
|
||||||
// Involuntary drop (no graceful Retire): synthesize BattleFinish(DisconnectWin)
|
// Involuntary drop (no graceful Retire): synthesize BattleFinish(DisconnectWin)
|
||||||
// to survivor. DisconnectWin=201 → client renders "opponent disconnected" →
|
// to survivor. DisconnectWin=201 → client renders "opponent disconnected" →
|
||||||
@@ -98,7 +112,7 @@ public sealed class BattleSession
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await survivor.PushAsync(
|
await survivor.PushAsync(
|
||||||
BattleFrames.BuildBattleFinish(BattleResult.DisconnectWin), noStock: true, cancellation)
|
BattleFrames.BuildBattleFinish(BattleResult.DisconnectWin), Stock.Bypass, cancellation)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -107,25 +121,39 @@ public sealed class BattleSession
|
|||||||
"BattleSession {Bid}: failed to push BattleFinish to survivor (their WS may also be closed)",
|
"BattleSession {Bid}: failed to push BattleFinish to survivor (their WS may also be closed)",
|
||||||
BattleId);
|
BattleId);
|
||||||
}
|
}
|
||||||
_state.SessionPhase = BattleSessionPhase.Terminal;
|
_state.Lifecycle = SessionLifecycle.Terminal;
|
||||||
}
|
}
|
||||||
|
|
||||||
cts.Cancel(); // unblock the survivor's RunAsync read loop
|
cts.Cancel(); // unblock the survivor's RunAsync read loop
|
||||||
try { await Task.WhenAll(aTask, bTask).ConfigureAwait(false); }
|
try { await Task.WhenAll(aTask, bTask).ConfigureAwait(false); }
|
||||||
catch { /* swallow cancellation / WS exceptions */ }
|
catch (Exception ex) when (ex is OperationCanceledException or WebSocketException) { }
|
||||||
|
catch (AggregateException ex) when (ex.Flatten().InnerExceptions.All(
|
||||||
|
e => e is OperationCanceledException or WebSocketException)) { }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.LogWarning(ex, "BattleSession {Bid}: unexpected exception from WhenAll (PvP drain)", BattleId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Bot mode: the NoOp opponent's RunAsync returns immediately; wait for the real
|
// Bot mode: the NoOp opponent's RunAsync returns immediately; wait for the real
|
||||||
// participant. The session keeps running for the real one.
|
// participant. The session keeps running for the real one.
|
||||||
try { await Task.WhenAll(aTask, bTask).ConfigureAwait(false); }
|
try { await Task.WhenAll(aTask, bTask).ConfigureAwait(false); }
|
||||||
catch { /* swallow */ }
|
catch (Exception ex) when (ex is OperationCanceledException or WebSocketException) { }
|
||||||
|
catch (AggregateException ex) when (ex.Flatten().InnerExceptions.All(
|
||||||
|
e => e is OperationCanceledException or WebSocketException)) { }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.LogWarning(ex, "BattleSession {Bid}: unexpected exception from WhenAll (Bot drain)", BattleId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audit Md11 — release per-participant outbound archives at battle-end
|
// Unsubscribe event handlers so the session + state aren't pinned by live delegates.
|
||||||
// (only RealParticipant has one; bots don't archive). Heavy state is
|
A.FrameEmitted -= OnFrameFromA;
|
||||||
// dropped synchronously here so the participant's TerminateAsync doesn't
|
B.FrameEmitted -= OnFrameFromB;
|
||||||
// need to keep the dict alive through its disposal handshake.
|
|
||||||
|
// Release per-participant outbound archives at battle-end
|
||||||
|
// (only RealParticipant has one; bots don't archive).
|
||||||
if (A is RealParticipant rpA) rpA.Outbound.Clear();
|
if (A is RealParticipant rpA) rpA.Outbound.Clear();
|
||||||
if (B is RealParticipant rpB) rpB.Outbound.Clear();
|
if (B is RealParticipant rpB) rpB.Outbound.Clear();
|
||||||
|
|
||||||
@@ -133,6 +161,10 @@ public sealed class BattleSession
|
|||||||
A.TerminateAsync(BattleFinishReason.NormalFinish),
|
A.TerminateAsync(BattleFinishReason.NormalFinish),
|
||||||
B.TerminateAsync(BattleFinishReason.NormalFinish))
|
B.TerminateAsync(BattleFinishReason.NormalFinish))
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
await A.DisposeAsync().ConfigureAwait(false);
|
||||||
|
await B.DisposeAsync().ConfigureAwait(false);
|
||||||
|
_dispatchGate.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task OnFrameFromA(MsgEnvelope env, CancellationToken ct) => HandleFrameAsync(A, env, ct);
|
private Task OnFrameFromA(MsgEnvelope env, CancellationToken ct) => HandleFrameAsync(A, env, ct);
|
||||||
@@ -140,23 +172,28 @@ public sealed class BattleSession
|
|||||||
|
|
||||||
private async Task HandleFrameAsync(IBattleParticipant from, MsgEnvelope env, CancellationToken ct)
|
private async Task HandleFrameAsync(IBattleParticipant from, MsgEnvelope env, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
await _dispatchGate.WaitAsync(ct).ConfigureAwait(false);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var routes = ComputeFrames(from, env);
|
var routes = ComputeFrames(from, env);
|
||||||
foreach (var (target, frame, noStock) in routes)
|
foreach (var (target, frame, stock) in routes)
|
||||||
{
|
{
|
||||||
await target.PushAsync(frame, noStock, ct);
|
await target.PushAsync(frame, stock, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_log.LogError(ex, "BattleSession {Bid}: unhandled in HandleFrameAsync", BattleId);
|
_log.LogError(ex, "BattleSession {Bid}: unhandled in HandleFrameAsync", BattleId);
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_dispatchGate.Release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pure-logic dispatch: given an inbound frame from one participant, return the list
|
/// Pure-logic dispatch: given an inbound frame from one participant, return the list
|
||||||
/// of (target, frame, noStock) tuples the session should dispatch. Transitions
|
/// of (target, frame, stock) routes the session should dispatch. Transitions
|
||||||
/// <see cref="Phase"/>. Extracted so unit tests can drive the dispatch without
|
/// <see cref="Phase"/>. Extracted so unit tests can drive the dispatch without
|
||||||
/// standing up real participants.
|
/// standing up real participants.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -165,8 +202,8 @@ public sealed class BattleSession
|
|||||||
if (Handlers.TryGetValue(env.Uri, out var handler))
|
if (Handlers.TryGetValue(env.Uri, out var handler))
|
||||||
return handler.Handle(BuildContext(from, env));
|
return handler.Handle(BuildContext(from, env));
|
||||||
|
|
||||||
_log.LogDebug("BattleSession {Bid}: dropping uri={Uri} in phase={Phase} from vid={Vid}",
|
_log.LogDebug("BattleSession {Bid}: dropping uri={Uri} in lifecycle={Lifecycle} from vid={Vid}",
|
||||||
BattleId, env.Uri, Phase, from.ViewerId);
|
BattleId, env.Uri, Lifecycle, from.ViewerId);
|
||||||
return Array.Empty<DispatchRoute>();
|
return Array.Empty<DispatchRoute>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
namespace SVSim.BattleNode.Sessions;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Where we are in the v1 server-authored frame lifecycle. Drives which server-authored frames
|
|
||||||
/// the session pushes in response to inbound emits.
|
|
||||||
/// </summary>
|
|
||||||
public enum BattleSessionPhase
|
|
||||||
{
|
|
||||||
AwaitingInitNetwork,
|
|
||||||
AwaitingInitBattle,
|
|
||||||
AwaitingLoaded,
|
|
||||||
AwaitingSwap,
|
|
||||||
AfterReady,
|
|
||||||
OpponentTurn,
|
|
||||||
Terminal,
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,7 @@ internal static class BattleFrames
|
|||||||
ViewerId: ServerBattleFrames.FakeOpponentViewerId,
|
ViewerId: ServerBattleFrames.FakeOpponentViewerId,
|
||||||
Uuid: WireConstants.ServerUuid,
|
Uuid: WireConstants.ServerUuid,
|
||||||
Bid: null,
|
Bid: null,
|
||||||
Try: 0,
|
RetryAttempt: 0,
|
||||||
Cat: EmitCategory.General,
|
Cat: EmitCategory.General,
|
||||||
PubSeq: null,
|
PubSeq: null,
|
||||||
PlaySeq: null,
|
PlaySeq: null,
|
||||||
@@ -24,18 +24,18 @@ internal static class BattleFrames
|
|||||||
ViewerId: ServerBattleFrames.FakeOpponentViewerId,
|
ViewerId: ServerBattleFrames.FakeOpponentViewerId,
|
||||||
Uuid: WireConstants.ServerUuid,
|
Uuid: WireConstants.ServerUuid,
|
||||||
Bid: null,
|
Bid: null,
|
||||||
Try: 0,
|
RetryAttempt: 0,
|
||||||
Cat: EmitCategory.Battle,
|
Cat: EmitCategory.Battle,
|
||||||
PubSeq: null,
|
PubSeq: null,
|
||||||
PlaySeq: null,
|
PlaySeq: null,
|
||||||
Body: new TurnEndBody(TurnState: 0));
|
Body: new TurnEndBody(TurnState: TurnState.First));
|
||||||
|
|
||||||
internal static MsgEnvelope BuildJudgeBroadcast() => new(
|
internal static MsgEnvelope BuildJudgeBroadcast() => new(
|
||||||
NetworkBattleUri.Judge,
|
NetworkBattleUri.Judge,
|
||||||
ViewerId: ServerBattleFrames.FakeOpponentViewerId,
|
ViewerId: ServerBattleFrames.FakeOpponentViewerId,
|
||||||
Uuid: WireConstants.ServerUuid,
|
Uuid: WireConstants.ServerUuid,
|
||||||
Bid: null,
|
Bid: null,
|
||||||
Try: 0,
|
RetryAttempt: 0,
|
||||||
Cat: EmitCategory.Battle,
|
Cat: EmitCategory.Battle,
|
||||||
PubSeq: null,
|
PubSeq: null,
|
||||||
PlaySeq: null,
|
PlaySeq: null,
|
||||||
@@ -46,7 +46,7 @@ internal static class BattleFrames
|
|||||||
ViewerId: ServerBattleFrames.FakeOpponentViewerId,
|
ViewerId: ServerBattleFrames.FakeOpponentViewerId,
|
||||||
Uuid: WireConstants.ServerUuid,
|
Uuid: WireConstants.ServerUuid,
|
||||||
Bid: null,
|
Bid: null,
|
||||||
Try: 0,
|
RetryAttempt: 0,
|
||||||
Cat: EmitCategory.Battle,
|
Cat: EmitCategory.Battle,
|
||||||
PubSeq: null,
|
PubSeq: null,
|
||||||
PlaySeq: null,
|
PlaySeq: null,
|
||||||
@@ -55,7 +55,7 @@ internal static class BattleFrames
|
|||||||
internal static IReadOnlyList<long> ExtractIdxList(MsgEnvelope env)
|
internal static IReadOnlyList<long> ExtractIdxList(MsgEnvelope env)
|
||||||
{
|
{
|
||||||
if (env.Body is not RawBody rawBody) return Array.Empty<long>();
|
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)
|
if (rawBody.Entries.TryGetValue(WireKeys.IdxList, out var raw) && raw is System.Collections.IEnumerable seq && raw is not string)
|
||||||
{
|
{
|
||||||
var result = new List<long>();
|
var result = new List<long>();
|
||||||
foreach (var item in seq)
|
foreach (var item in seq)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using SVSim.BattleNode.Lifecycle;
|
||||||
|
using SVSim.BattleNode.Protocol;
|
||||||
using SVSim.BattleNode.Sessions;
|
using SVSim.BattleNode.Sessions;
|
||||||
|
|
||||||
namespace SVSim.BattleNode.Sessions.Dispatch;
|
namespace SVSim.BattleNode.Sessions.Dispatch;
|
||||||
@@ -9,7 +11,35 @@ namespace SVSim.BattleNode.Sessions.Dispatch;
|
|||||||
/// <see cref="IdxToCardId"/> map via <see cref="RecordToken"/>; a reveal-gate set is still future.</summary>
|
/// <see cref="IdxToCardId"/> map via <see cref="RecordToken"/>; a reveal-gate set is still future.</summary>
|
||||||
internal sealed class BattleSessionState
|
internal sealed class BattleSessionState
|
||||||
{
|
{
|
||||||
public BattleSessionPhase SessionPhase { get; set; } = BattleSessionPhase.AwaitingInitNetwork;
|
/// <summary>The one random value chosen per battle. Every per-battle RNG (shared effect seed,
|
||||||
|
/// each side's deck shuffle + idxChangeSeed) derives from it via <see cref="BattleSeeds"/>.
|
||||||
|
/// Logged at session start so a battle's randomness is reproducible (future replay).</summary>
|
||||||
|
public int MasterSeed { get; }
|
||||||
|
|
||||||
|
/// <param name="masterSeed">Test hook — production uses the random default.</param>
|
||||||
|
public BattleSessionState(int? masterSeed = null) =>
|
||||||
|
MasterSeed = masterSeed ?? Random.Shared.Next();
|
||||||
|
|
||||||
|
private readonly Dictionary<IBattleParticipant, IReadOnlyList<long>> _shuffledDecks = new();
|
||||||
|
|
||||||
|
/// <summary>This side's deck, shuffled deterministically from <see cref="MasterSeed"/>
|
||||||
|
/// (Fisher–Yates). Cached per side. Both the wire selfDeck (Matched) and the reveal map
|
||||||
|
/// (<see cref="GetOrSeedDeckMap"/>) read this, so they share one shuffled order.</summary>
|
||||||
|
public IReadOnlyList<long> GetShuffledDeck(IBattleParticipant side)
|
||||||
|
{
|
||||||
|
if (_shuffledDecks.TryGetValue(side, out var cached)) return cached;
|
||||||
|
var deck = side.Context.SelfDeckCardIds.ToArray();
|
||||||
|
var rng = new Random(BattleSeeds.DeckShuffle(MasterSeed, side.ViewerId));
|
||||||
|
for (var i = deck.Length - 1; i > 0; i--)
|
||||||
|
{
|
||||||
|
var j = rng.Next(i + 1);
|
||||||
|
(deck[i], deck[j]) = (deck[j], deck[i]);
|
||||||
|
}
|
||||||
|
_shuffledDecks[side] = deck;
|
||||||
|
return deck;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionLifecycle Lifecycle { get; set; } = SessionLifecycle.Active;
|
||||||
public Dictionary<IBattleParticipant, long[]> PostSwapHands { get; } = new();
|
public Dictionary<IBattleParticipant, long[]> PostSwapHands { get; } = new();
|
||||||
|
|
||||||
/// <summary>Per-side idx->cardId, seeded lazily from <see cref="MatchContext.SelfDeckCardIds"/>.
|
/// <summary>Per-side idx->cardId, seeded lazily from <see cref="MatchContext.SelfDeckCardIds"/>.
|
||||||
@@ -17,14 +47,15 @@ internal sealed class BattleSessionState
|
|||||||
/// from add ops via <see cref="RecordToken"/>).</summary>
|
/// from add ops via <see cref="RecordToken"/>).</summary>
|
||||||
public Dictionary<IBattleParticipant, Dictionary<int, long>> IdxToCardId { get; } = new();
|
public Dictionary<IBattleParticipant, Dictionary<int, long>> IdxToCardId { get; } = new();
|
||||||
|
|
||||||
/// <summary>The sender's idx->cardId map, seeding it from its <see cref="MatchContext"/> on first
|
/// <summary>The sender's idx->cardId map, seeding it from its <see cref="GetShuffledDeck"/> order on
|
||||||
/// use. <c>BuildPlayerDeck</c> assigns deck idx = position+1, so entry (i+1) -> cardIds[i].</summary>
|
/// first use. Deck idx = position+1 in the shuffled order, so entry (i+1) -> shuffledDeck[i]. The
|
||||||
|
/// wire selfDeck (Matched) is built from the same shuffled order, so the two agree.</summary>
|
||||||
public IReadOnlyDictionary<int, long> GetOrSeedDeckMap(IBattleParticipant side)
|
public IReadOnlyDictionary<int, long> GetOrSeedDeckMap(IBattleParticipant side)
|
||||||
{
|
{
|
||||||
if (!IdxToCardId.TryGetValue(side, out var map))
|
if (!IdxToCardId.TryGetValue(side, out var map))
|
||||||
{
|
{
|
||||||
map = new Dictionary<int, long>();
|
map = new Dictionary<int, long>();
|
||||||
var deck = side.Context.SelfDeckCardIds;
|
var deck = GetShuffledDeck(side);
|
||||||
for (var i = 0; i < deck.Count; i++) map[i + 1] = deck[i];
|
for (var i = 0; i < deck.Count; i++) map[i + 1] = deck[i];
|
||||||
IdxToCardId[side] = map;
|
IdxToCardId[side] = map;
|
||||||
}
|
}
|
||||||
@@ -53,8 +84,13 @@ internal sealed class BattleSessionState
|
|||||||
/// Echo is mined but never relayed.</summary>
|
/// Echo is mined but never relayed.</summary>
|
||||||
public void RecordTokensFrom(IBattleParticipant from, IBattleParticipant other, object? orderList)
|
public void RecordTokensFrom(IBattleParticipant from, IBattleParticipant other, object? orderList)
|
||||||
{
|
{
|
||||||
|
// TRUST: isSelf is the SENDER's own perspective flag and idx is unbounded, while RecordToken
|
||||||
|
// overwrites-on-conflict. A buggy/malicious sender could pass isSelf:0 with a deck-range idx to
|
||||||
|
// rewrite the OPPONENT's card identity at a seeded slot. Acceptable for the current trusted-LAN
|
||||||
|
// relay; if peers ever become untrusted, gate on `idx > deckCount` here (generated tokens always
|
||||||
|
// allocate past the deck) so a sender can't forge over seeded deck cards.
|
||||||
foreach (var (idx, cardId, isSelf) in KnownListBuilder.MineAddOps(orderList))
|
foreach (var (idx, cardId, isSelf) in KnownListBuilder.MineAddOps(orderList))
|
||||||
RecordToken(isSelf == 1 ? from : other, idx, cardId);
|
RecordToken(isSelf == CardOwner.Self ? from : other, idx, cardId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Mine + record choice/Discover-token picks (<see cref="KnownListBuilder.MineChoicePicks"/>)
|
/// <summary>Mine + record choice/Discover-token picks (<see cref="KnownListBuilder.MineChoicePicks"/>)
|
||||||
@@ -65,7 +101,7 @@ internal sealed class BattleSessionState
|
|||||||
public void RecordChoicePicksFrom(IBattleParticipant from, IBattleParticipant other, object? orderList, object? keyAction)
|
public void RecordChoicePicksFrom(IBattleParticipant from, IBattleParticipant other, object? orderList, object? keyAction)
|
||||||
{
|
{
|
||||||
foreach (var (idx, cardId, isSelf) in KnownListBuilder.MineChoicePicks(orderList, keyAction))
|
foreach (var (idx, cardId, isSelf) in KnownListBuilder.MineChoicePicks(orderList, keyAction))
|
||||||
RecordToken(isSelf == 1 ? from : other, idx, cardId);
|
RecordToken(isSelf == CardOwner.Self ? from : other, idx, cardId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Mine + record copy/clone-token identities (<see cref="KnownListBuilder.MineCopyTokens"/>)
|
/// <summary>Mine + record copy/clone-token identities (<see cref="KnownListBuilder.MineCopyTokens"/>)
|
||||||
@@ -82,6 +118,6 @@ internal sealed class BattleSessionState
|
|||||||
var selfMap = GetOrSeedDeckMap(from);
|
var selfMap = GetOrSeedDeckMap(from);
|
||||||
var otherMap = GetOrSeedDeckMap(other);
|
var otherMap = GetOrSeedDeckMap(other);
|
||||||
foreach (var (idx, cardId, isSelf) in KnownListBuilder.MineCopyTokens(orderList, selfMap, otherMap))
|
foreach (var (idx, cardId, isSelf) in KnownListBuilder.MineCopyTokens(orderList, selfMap, otherMap))
|
||||||
RecordToken(isSelf == 1 ? from : other, idx, cardId);
|
RecordToken(isSelf == CardOwner.Self ? from : other, idx, cardId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using SVSim.BattleNode.Protocol;
|
|||||||
namespace SVSim.BattleNode.Sessions.Dispatch;
|
namespace SVSim.BattleNode.Sessions.Dispatch;
|
||||||
|
|
||||||
/// <summary>One routing decision: deliver <paramref name="Frame"/> to <paramref name="Target"/>.
|
/// <summary>One routing decision: deliver <paramref name="Frame"/> to <paramref name="Target"/>.
|
||||||
/// Named form of the tuple <c>ComputeFrames</c> historically returned. <paramref name="NoStock"/>
|
/// Named form of the tuple <c>ComputeFrames</c> historically returned. <paramref name="Stock"/>
|
||||||
/// true for control frames (BattleFinish, ack) — bypasses playSeq assignment + archive.</summary>
|
/// is <see cref="Sessions.Stock.Bypass"/> for control frames (BattleFinish, ack) — bypasses
|
||||||
internal readonly record struct DispatchRoute(IBattleParticipant Target, MsgEnvelope Frame, bool NoStock);
|
/// playSeq assignment + archive — and <see cref="Sessions.Stock.Normal"/> for gameplay frames.</summary>
|
||||||
|
internal readonly record struct DispatchRoute(IBattleParticipant Target, MsgEnvelope Frame, Stock Stock);
|
||||||
|
|||||||
@@ -14,21 +14,38 @@ internal sealed class FrameDispatchContext
|
|||||||
internal required IBattleParticipant From { get; init; }
|
internal required IBattleParticipant From { get; init; }
|
||||||
internal required IBattleParticipant Other { get; init; }
|
internal required IBattleParticipant Other { get; init; }
|
||||||
internal required MsgEnvelope Env { get; init; }
|
internal required MsgEnvelope Env { get; init; }
|
||||||
internal required BattleType Type { get; init; }
|
|
||||||
internal required string BattleId { get; init; }
|
internal required string BattleId { get; init; }
|
||||||
internal required BattleSessionState State { get; init; }
|
internal required BattleSessionState State { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The opponent is an AI-passive (ack-only) bot: it runs no handshake — no
|
||||||
|
/// <see cref="IHasHandshakePhase"/> — and receives no relayed frames (the client drives its own
|
||||||
|
/// AI; the server only acks). This is the participant property that replaces the per-handler
|
||||||
|
/// <c>BattleType.Bot</c> switch: the Bot dispatch arms gate on it. Its inverse — a live relay
|
||||||
|
/// peer — is what <see cref="BothSidesAfterReady"/> already implies (only real peers have a
|
||||||
|
/// handshake phase), so the relay arms need no separate opponent check.</summary>
|
||||||
|
internal bool OpponentIsAckOnly => Other is not IHasHandshakePhase;
|
||||||
|
|
||||||
/// <summary>The dispatching participant's handshake phase (null for a non-IHasHandshakePhase
|
/// <summary>The dispatching participant's handshake phase (null for a non-IHasHandshakePhase
|
||||||
/// participant, e.g. NoOpBot). Setting it advances the sender.</summary>
|
/// participant, e.g. NoOpBot). Setting it advances the sender.</summary>
|
||||||
internal BattleSessionPhase? SenderPhase
|
internal HandshakePhase? SenderPhase
|
||||||
{
|
{
|
||||||
get => (From as IHasHandshakePhase)?.Phase;
|
get => (From as IHasHandshakePhase)?.Phase;
|
||||||
set { if (From is IHasHandshakePhase p && value is { } v) p.Phase = v; }
|
set { if (From is IHasHandshakePhase p && value is { } v) p.Phase = v; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Both participants have completed the handshake. Reads A/B (not From/Other) so the
|
/// <summary>Just the SENDER has finished the handshake — says nothing about the opponent. The
|
||||||
/// result is identical regardless of which side sent the frame — matches legacy BothAfterReady.</summary>
|
/// Bot arms gate on this (the bot has no handshake phase of its own); contrast
|
||||||
internal bool BothAfterReady() =>
|
/// <see cref="BothSidesAfterReady"/>, which the PvP arms require. The sender-only vs both-sides
|
||||||
(A as IHasHandshakePhase)?.Phase == BattleSessionPhase.AfterReady &&
|
/// distinction is load-bearing for the Bot/PvP split (see TurnEndHandler / TurnEndFinalHandler).</summary>
|
||||||
(B as IHasHandshakePhase)?.Phase == BattleSessionPhase.AfterReady;
|
internal bool SenderIsAfterReady => SenderPhase == HandshakePhase.AfterReady;
|
||||||
|
|
||||||
|
/// <summary>BOTH participants have finished the handshake. Reads A/B (not From/Other) so the
|
||||||
|
/// result is identical regardless of which side sent the frame. Contrast
|
||||||
|
/// <see cref="SenderIsAfterReady"/> (sender only). Only a live relay peer (real player) has a
|
||||||
|
/// handshake phase, so this can only be true in a two-real-player (PvP) session — the relay
|
||||||
|
/// dispatch arms gate on this instead of a <c>BattleType</c> check (an ack-only bot opponent,
|
||||||
|
/// <see cref="OpponentIsAckOnly"/>, can never satisfy it).</summary>
|
||||||
|
internal bool BothSidesAfterReady() =>
|
||||||
|
(A as IHasHandshakePhase)?.Phase == HandshakePhase.AfterReady &&
|
||||||
|
(B as IHasHandshakePhase)?.Phase == HandshakePhase.AfterReady;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ internal sealed class EchoHandler : IFrameHandler
|
|||||||
{
|
{
|
||||||
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||||
{
|
{
|
||||||
if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady())
|
if (ctx.BothSidesAfterReady())
|
||||||
{
|
{
|
||||||
var orderList = (ctx.Env.Body as RawBody)?.Entries.GetValueOrDefault("orderList");
|
var orderList = (ctx.Env.Body as RawBody)?.Entries.GetValueOrDefault(WireKeys.OrderList);
|
||||||
ctx.State.RecordTokensFrom(ctx.From, ctx.Other, orderList);
|
ctx.State.RecordTokensFrom(ctx.From, ctx.Other, orderList);
|
||||||
// Copy tokens ride Echo too (same add-op shape); resolve baseIdx against the side's map.
|
// Copy tokens ride Echo too (same add-op shape); resolve baseIdx against the side's map.
|
||||||
ctx.State.RecordCopyTokensFrom(ctx.From, ctx.Other, orderList);
|
ctx.State.RecordCopyTokensFrom(ctx.From, ctx.Other, orderList);
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ internal sealed class ForwardWhenBothReadyHandler : IFrameHandler
|
|||||||
{
|
{
|
||||||
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||||
{
|
{
|
||||||
if (ctx.BothAfterReady())
|
if (ctx.BothSidesAfterReady())
|
||||||
return new[] { new DispatchRoute(ctx.Other, ctx.Env, false) };
|
return new[] { new DispatchRoute(ctx.Other, ctx.Env, Stock.Normal) };
|
||||||
return Array.Empty<DispatchRoute>();
|
return Array.Empty<DispatchRoute>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,26 +8,27 @@ internal sealed class InitBattleHandler : IFrameHandler
|
|||||||
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||||
{
|
{
|
||||||
// case 2: Bot — ack only, NO Matched (Matched would corrupt client opponent info).
|
// case 2: Bot — ack only, NO Matched (Matched would corrupt client opponent info).
|
||||||
if (ctx.Type == BattleType.Bot && ctx.SenderPhase == BattleSessionPhase.AwaitingInitBattle)
|
if (ctx.OpponentIsAckOnly && ctx.SenderPhase == HandshakePhase.AwaitingInitBattle)
|
||||||
{
|
{
|
||||||
var r = new List<DispatchRoute>
|
var r = new List<DispatchRoute>
|
||||||
{
|
{
|
||||||
new(ctx.From, BattleFrames.BuildAck(NetworkBattleUri.InitBattle), true),
|
new(ctx.From, BattleFrames.BuildAck(NetworkBattleUri.InitBattle), Stock.Bypass),
|
||||||
};
|
};
|
||||||
ctx.SenderPhase = BattleSessionPhase.AwaitingLoaded;
|
ctx.SenderPhase = HandshakePhase.AwaitingLoaded;
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
// case 5: general — push Matched (per-perspective) to the sender only.
|
// case 5: general — push Matched (per-perspective) to the sender only.
|
||||||
if (ctx.SenderPhase == BattleSessionPhase.AwaitingInitBattle)
|
if (ctx.SenderPhase == HandshakePhase.AwaitingInitBattle)
|
||||||
{
|
{
|
||||||
var r = new List<DispatchRoute>
|
var r = new List<DispatchRoute>
|
||||||
{
|
{
|
||||||
new(ctx.From, ServerBattleFrames.BuildMatched(
|
new(ctx.From, ServerBattleFrames.BuildMatched(
|
||||||
ctx.From.Context, ctx.Other.Context, ctx.From.ViewerId, ctx.Other.ViewerId,
|
ctx.From.Context, ctx.Other.Context, ctx.From.ViewerId, ctx.Other.ViewerId,
|
||||||
ctx.BattleId, BattleFrameDefaults.BattleSeed), false),
|
ctx.BattleId, BattleSeeds.Stable(ctx.State.MasterSeed),
|
||||||
|
ctx.State.GetShuffledDeck(ctx.From)), Stock.Normal),
|
||||||
};
|
};
|
||||||
ctx.SenderPhase = BattleSessionPhase.AwaitingLoaded;
|
ctx.SenderPhase = HandshakePhase.AwaitingLoaded;
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ internal sealed class InitNetworkHandler : IFrameHandler
|
|||||||
{
|
{
|
||||||
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||||
{
|
{
|
||||||
if (ctx.SenderPhase != BattleSessionPhase.AwaitingInitNetwork)
|
if (ctx.SenderPhase != HandshakePhase.AwaitingInitNetwork)
|
||||||
return Array.Empty<DispatchRoute>();
|
return Array.Empty<DispatchRoute>();
|
||||||
|
|
||||||
var routes = new List<DispatchRoute>
|
var routes = new List<DispatchRoute>
|
||||||
{
|
{
|
||||||
new(ctx.From, BattleFrames.BuildAck(NetworkBattleUri.InitNetwork), true),
|
new(ctx.From, BattleFrames.BuildAck(NetworkBattleUri.InitNetwork), Stock.Bypass),
|
||||||
};
|
};
|
||||||
ctx.SenderPhase = BattleSessionPhase.AwaitingInitBattle;
|
ctx.SenderPhase = HandshakePhase.AwaitingInitBattle;
|
||||||
return routes;
|
return routes;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using SVSim.BattleNode.Lifecycle;
|
||||||
using SVSim.BattleNode.Protocol;
|
using SVSim.BattleNode.Protocol;
|
||||||
using SVSim.BattleNode.Protocol.Bodies;
|
using SVSim.BattleNode.Protocol.Bodies;
|
||||||
|
|
||||||
@@ -14,10 +15,10 @@ internal sealed class JudgeHandler : IFrameHandler
|
|||||||
// start another one, stalling the loop; confirmed by the 2026-06-03 two-client capture).
|
// start another one, stalling the loop; confirmed by the 2026-06-03 two-client capture).
|
||||||
// The sender then emits TurnStart, which TurnStartHandler relays to the opponent as {spin}.
|
// The sender then emits TurnStart, which TurnStartHandler relays to the opponent as {spin}.
|
||||||
// battleCode is dropped; spin=0 for the deterministic-turn slice.
|
// battleCode is dropped; spin=0 for the deterministic-turn slice.
|
||||||
if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady())
|
if (ctx.BothSidesAfterReady())
|
||||||
{
|
{
|
||||||
var frame = ctx.Env with { Body = new JudgeBody(Spin: 0) };
|
var frame = ctx.Env with { Body = new JudgeBody(Spin: BattleFrameDefaults.DeterministicTurnSpin) };
|
||||||
return new[] { new DispatchRoute(ctx.From, frame, false) };
|
return new[] { new DispatchRoute(ctx.From, frame, Stock.Normal) };
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.Empty<DispatchRoute>();
|
return Array.Empty<DispatchRoute>();
|
||||||
|
|||||||
@@ -8,24 +8,24 @@ internal sealed class LoadedHandler : IFrameHandler
|
|||||||
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||||
{
|
{
|
||||||
// case 3: Bot — silent (client populates opponent state from AIBattleStart HTTP data).
|
// case 3: Bot — silent (client populates opponent state from AIBattleStart HTTP data).
|
||||||
if (ctx.Type == BattleType.Bot && ctx.SenderPhase == BattleSessionPhase.AwaitingLoaded)
|
if (ctx.OpponentIsAckOnly && ctx.SenderPhase == HandshakePhase.AwaitingLoaded)
|
||||||
{
|
{
|
||||||
ctx.SenderPhase = BattleSessionPhase.AwaitingSwap;
|
ctx.SenderPhase = HandshakePhase.AwaitingSwap;
|
||||||
return Array.Empty<DispatchRoute>();
|
return Array.Empty<DispatchRoute>();
|
||||||
}
|
}
|
||||||
|
|
||||||
// case 6: general — BattleStart (per-perspective) + Deal to the sender.
|
// case 6: general — BattleStart (per-perspective) + Deal to the sender.
|
||||||
if (ctx.SenderPhase == BattleSessionPhase.AwaitingLoaded)
|
if (ctx.SenderPhase == HandshakePhase.AwaitingLoaded)
|
||||||
{
|
{
|
||||||
// A goes first deterministically (turnState 0); B goes second (turnState 1).
|
// A goes first deterministically; B goes second.
|
||||||
var turnState = ReferenceEquals(ctx.From, ctx.A) ? 0 : 1;
|
var turnState = ReferenceEquals(ctx.From, ctx.A) ? TurnState.First : TurnState.Second;
|
||||||
var r = new List<DispatchRoute>
|
var r = new List<DispatchRoute>
|
||||||
{
|
{
|
||||||
new(ctx.From, ServerBattleFrames.BuildBattleStart(
|
new(ctx.From, ServerBattleFrames.BuildBattleStart(
|
||||||
ctx.From.Context, ctx.Other.Context, ctx.From.ViewerId, turnState), false),
|
ctx.From.Context, ctx.Other.Context, ctx.From.ViewerId, turnState), Stock.Normal),
|
||||||
new(ctx.From, ServerBattleFrames.BuildDeal(), false),
|
new(ctx.From, ServerBattleFrames.BuildDeal(), Stock.Normal),
|
||||||
};
|
};
|
||||||
ctx.SenderPhase = BattleSessionPhase.AwaitingSwap;
|
ctx.SenderPhase = HandshakePhase.AwaitingSwap;
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,15 +14,15 @@ internal sealed class PlayActionsHandler : IFrameHandler
|
|||||||
{
|
{
|
||||||
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||||
{
|
{
|
||||||
if (ctx.Type != BattleType.Pvp || !ctx.BothAfterReady())
|
if (!ctx.BothSidesAfterReady())
|
||||||
return Array.Empty<DispatchRoute>();
|
return Array.Empty<DispatchRoute>();
|
||||||
|
|
||||||
var entries = (ctx.Env.Body as RawBody)?.Entries ?? new Dictionary<string, object?>();
|
var entries = (ctx.Env.Body as RawBody)?.Entries ?? new Dictionary<string, object?>();
|
||||||
var playIdx = (int)KnownListBuilder.AsLong(entries.GetValueOrDefault("playIdx"));
|
var playIdx = (int)KnownListBuilder.AsLong(entries.GetValueOrDefault(WireKeys.PlayIdx));
|
||||||
var type = (int)KnownListBuilder.AsLong(entries.GetValueOrDefault("type"));
|
var type = (int)KnownListBuilder.AsLong(entries.GetValueOrDefault(WireKeys.Type));
|
||||||
|
|
||||||
var orderList = entries.GetValueOrDefault("orderList");
|
var orderList = entries.GetValueOrDefault(WireKeys.OrderList);
|
||||||
var keyAction = entries.GetValueOrDefault("keyAction");
|
var keyAction = entries.GetValueOrDefault(WireKeys.KeyAction);
|
||||||
|
|
||||||
// Mine generated-token identities from this frame's add ops into the right side's idx->cardId
|
// Mine generated-token identities from this frame's add ops into the right side's idx->cardId
|
||||||
// map (isSelf:1 → sender; isSelf:0 → opponent, a cross-side gift), so a token played in a LATER
|
// map (isSelf:1 → sender; isSelf:0 → opponent, a cross-side gift), so a token played in a LATER
|
||||||
@@ -40,13 +40,13 @@ internal sealed class PlayActionsHandler : IFrameHandler
|
|||||||
|
|
||||||
var deckMap = ctx.State.GetOrSeedDeckMap(ctx.From);
|
var deckMap = ctx.State.GetOrSeedDeckMap(ctx.From);
|
||||||
var played = KnownListBuilder.BuildPlayedCard(deckMap, playIdx, orderList);
|
var played = KnownListBuilder.BuildPlayedCard(deckMap, playIdx, orderList);
|
||||||
var oppoTargets = KnownListBuilder.RenameTargets(entries.GetValueOrDefault("targetList"));
|
var oppoTargets = KnownListBuilder.RenameTargets(entries.GetValueOrDefault(WireKeys.TargetList));
|
||||||
|
|
||||||
// Deck-sourced movements (fetch / search / summon-from-deck) ride the uList — a verbatim,
|
// Deck-sourced movements (fetch / search / summon-from-deck) ride the uList — a verbatim,
|
||||||
// separate receive slot the node forwards unchanged (bullet-3 audit F1). The node makes no
|
// separate receive slot the node forwards unchanged (bullet-3 audit F1). The node makes no
|
||||||
// reveal decision; cardId presence is the sender's call. Coexists with the synthesized
|
// reveal decision; cardId presence is the sender's call. Coexists with the synthesized
|
||||||
// knownList in the same frame (capture line 75).
|
// knownList in the same frame (capture line 75).
|
||||||
var uList = KnownListBuilder.RelayUList(entries.GetValueOrDefault("uList"));
|
var uList = KnownListBuilder.RelayUList(entries.GetValueOrDefault(WireKeys.UList));
|
||||||
|
|
||||||
var body = new PlayActionsBroadcastBody(
|
var body = new PlayActionsBroadcastBody(
|
||||||
PlayIdx: playIdx,
|
PlayIdx: playIdx,
|
||||||
@@ -59,6 +59,6 @@ internal sealed class PlayActionsHandler : IFrameHandler
|
|||||||
KeyAction: KnownListBuilder.StripKeyActionForOpponent(keyAction));
|
KeyAction: KnownListBuilder.StripKeyActionForOpponent(keyAction));
|
||||||
|
|
||||||
var frame = ctx.Env with { Body = body };
|
var frame = ctx.Env with { Body = body };
|
||||||
return new[] { new DispatchRoute(ctx.Other, frame, false) };
|
return new[] { new DispatchRoute(ctx.Other, frame, Stock.Normal) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,14 @@ internal sealed class RetireKillHandler : IFrameHandler
|
|||||||
{
|
{
|
||||||
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||||
{
|
{
|
||||||
ctx.State.SessionPhase = BattleSessionPhase.Terminal;
|
ctx.State.Lifecycle = SessionLifecycle.Terminal;
|
||||||
|
// Polarity: the SENDER retired, so From LOSES / Other WINS. This is the OPPOSITE of
|
||||||
|
// TurnEndFinalHandler (From WINS there — sender dealt the lethal). Intentional — do NOT
|
||||||
|
// "consistency-fix" the two handlers to match; a swap here silently reverses every retire.
|
||||||
return new[]
|
return new[]
|
||||||
{
|
{
|
||||||
new DispatchRoute(ctx.From, BattleFrames.BuildBattleFinish(BattleResult.RetireLose), true),
|
new DispatchRoute(ctx.From, BattleFrames.BuildBattleFinish(BattleResult.RetireLose), Stock.Bypass),
|
||||||
new DispatchRoute(ctx.Other, BattleFrames.BuildBattleFinish(BattleResult.RetireWin), true),
|
new DispatchRoute(ctx.Other, BattleFrames.BuildBattleFinish(BattleResult.RetireWin), Stock.Bypass),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,16 +8,16 @@ internal sealed class SwapHandler : IFrameHandler
|
|||||||
{
|
{
|
||||||
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||||
{
|
{
|
||||||
if (ctx.SenderPhase != BattleSessionPhase.AwaitingSwap)
|
if (ctx.SenderPhase != HandshakePhase.AwaitingSwap)
|
||||||
return Array.Empty<DispatchRoute>();
|
return Array.Empty<DispatchRoute>();
|
||||||
|
|
||||||
var routes = new List<DispatchRoute>();
|
var routes = new List<DispatchRoute>();
|
||||||
var hand = ServerBattleFrames.ComputeHandAfterSwap(BattleFrames.ExtractIdxList(ctx.Env));
|
var hand = ServerBattleFrames.ComputeHandAfterSwap(BattleFrames.ExtractIdxList(ctx.Env));
|
||||||
|
|
||||||
// SwapResponse is always immediate — completes the sender's own mulligan UI.
|
// SwapResponse is always immediate — completes the sender's own mulligan UI.
|
||||||
routes.Add(new DispatchRoute(ctx.From, ServerBattleFrames.BuildSwapResponse(hand), false));
|
routes.Add(new DispatchRoute(ctx.From, ServerBattleFrames.BuildSwapResponse(hand), Stock.Normal));
|
||||||
ctx.State.PostSwapHands[ctx.From] = hand;
|
ctx.State.PostSwapHands[ctx.From] = hand;
|
||||||
ctx.SenderPhase = BattleSessionPhase.AfterReady;
|
ctx.SenderPhase = HandshakePhase.AfterReady;
|
||||||
|
|
||||||
// Release Ready to every swapper once all handshake-driving participants have swapped.
|
// Release Ready to every swapper once all handshake-driving participants have swapped.
|
||||||
// IHasHandshakePhase membership IS the "participates in mulligan" set.
|
// IHasHandshakePhase membership IS the "participates in mulligan" set.
|
||||||
@@ -27,11 +27,12 @@ internal sealed class SwapHandler : IFrameHandler
|
|||||||
foreach (var p in swappers)
|
foreach (var p in swappers)
|
||||||
{
|
{
|
||||||
var opponent = ReferenceEquals(p, ctx.A) ? ctx.B : ctx.A;
|
var opponent = ReferenceEquals(p, ctx.A) ? ctx.B : ctx.A;
|
||||||
|
var idxSeed = BattleSeeds.IdxChange(ctx.State.MasterSeed, p.ViewerId);
|
||||||
var ready = opponent is IHasHandshakePhase
|
var ready = opponent is IHasHandshakePhase
|
||||||
&& ctx.State.PostSwapHands.TryGetValue(opponent, out var oppoHand)
|
&& ctx.State.PostSwapHands.TryGetValue(opponent, out var oppoHand)
|
||||||
? ServerBattleFrames.BuildReady(ctx.State.PostSwapHands[p], oppoHand)
|
? ServerBattleFrames.BuildReady(ctx.State.PostSwapHands[p], oppoHand, idxSeed)
|
||||||
: ServerBattleFrames.BuildReady(ctx.State.PostSwapHands[p]);
|
: ServerBattleFrames.BuildReady(ctx.State.PostSwapHands[p], idxSeed);
|
||||||
routes.Add(new DispatchRoute(p, ready, false));
|
routes.Add(new DispatchRoute(p, ready, Stock.Normal));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return routes;
|
return routes;
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ internal sealed class TurnEndActionsHandler : IFrameHandler
|
|||||||
{
|
{
|
||||||
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||||
{
|
{
|
||||||
if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady())
|
if (ctx.BothSidesAfterReady())
|
||||||
{
|
{
|
||||||
var frame = ctx.Env with { Body = new RawBody(new Dictionary<string, object?>()) };
|
var frame = ctx.Env with { Body = new RawBody(new Dictionary<string, object?>()) };
|
||||||
return new[] { new DispatchRoute(ctx.Other, frame, false) };
|
return new[] { new DispatchRoute(ctx.Other, frame, Stock.Normal) };
|
||||||
}
|
}
|
||||||
return Array.Empty<DispatchRoute>();
|
return Array.Empty<DispatchRoute>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,18 +7,22 @@ internal sealed class TurnEndFinalHandler : IFrameHandler
|
|||||||
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||||
{
|
{
|
||||||
// case 4: Bot — Judge to sender only.
|
// case 4: Bot — Judge to sender only.
|
||||||
if (ctx.Type == BattleType.Bot && ctx.SenderPhase == BattleSessionPhase.AfterReady)
|
if (ctx.OpponentIsAckOnly && ctx.SenderIsAfterReady)
|
||||||
return new[] { new DispatchRoute(ctx.From, BattleFrames.BuildJudgeBroadcast(), false) };
|
return new[] { new DispatchRoute(ctx.From, BattleFrames.BuildJudgeBroadcast(), Stock.Normal) };
|
||||||
|
|
||||||
// case 9: general — forward the envelope to other + paired BattleFinish + Terminal.
|
// case 9: general — forward the envelope to other + paired BattleFinish + Terminal.
|
||||||
if (ctx.SenderPhase == BattleSessionPhase.AfterReady)
|
if (ctx.SenderIsAfterReady)
|
||||||
{
|
{
|
||||||
ctx.State.SessionPhase = BattleSessionPhase.Terminal;
|
ctx.State.Lifecycle = SessionLifecycle.Terminal;
|
||||||
|
// Polarity: the SENDER dealt the lethal, so From WINS / Other LOSES. This is the
|
||||||
|
// OPPOSITE of RetireKillHandler (From LOSES there — retire is self-inflicted).
|
||||||
|
// Intentional — do NOT "consistency-fix" the two handlers to match; a swap here
|
||||||
|
// silently reverses every lethal-turn outcome.
|
||||||
return new[]
|
return new[]
|
||||||
{
|
{
|
||||||
new DispatchRoute(ctx.Other, ctx.Env, false),
|
new DispatchRoute(ctx.Other, ctx.Env, Stock.Normal),
|
||||||
new DispatchRoute(ctx.From, BattleFrames.BuildBattleFinish(BattleResult.LifeWin), true),
|
new DispatchRoute(ctx.From, BattleFrames.BuildBattleFinish(BattleResult.LifeWin), Stock.Bypass),
|
||||||
new DispatchRoute(ctx.Other, BattleFrames.BuildBattleFinish(BattleResult.LifeLose), true),
|
new DispatchRoute(ctx.Other, BattleFrames.BuildBattleFinish(BattleResult.LifeLose), Stock.Bypass),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,20 +8,20 @@ internal sealed class TurnEndHandler : IFrameHandler
|
|||||||
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||||
{
|
{
|
||||||
// case 4: Bot — Judge to sender only (no real opponent; client flips back to its local AI).
|
// case 4: Bot — Judge to sender only (no real opponent; client flips back to its local AI).
|
||||||
if (ctx.Type == BattleType.Bot && ctx.SenderPhase == BattleSessionPhase.AfterReady)
|
if (ctx.OpponentIsAckOnly && ctx.SenderIsAfterReady)
|
||||||
return new[] { new DispatchRoute(ctx.From, BattleFrames.BuildJudgeBroadcast(), false) };
|
return new[] { new DispatchRoute(ctx.From, BattleFrames.BuildJudgeBroadcast(), Stock.Normal) };
|
||||||
|
|
||||||
// case 8: general AfterReady arm — PvP forwards a {turnState} TurnEnd to the opponent
|
// case 8: general AfterReady arm — PvP forwards a {turnState} TurnEnd to the opponent
|
||||||
// (handover gate). Any non-Pvp non-Bot type that reaches AfterReady consumes the frame.
|
// (handover gate). Any non-Pvp non-Bot type that reaches AfterReady consumes the frame.
|
||||||
if (ctx.SenderPhase == BattleSessionPhase.AfterReady)
|
if (ctx.SenderIsAfterReady)
|
||||||
{
|
{
|
||||||
if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady())
|
if (ctx.BothSidesAfterReady())
|
||||||
{
|
{
|
||||||
// Opponent sees {turnState}; receiving TurnEnd drives ITS SendJudge (handover gate):
|
// Opponent sees {turnState}; receiving TurnEnd drives ITS SendJudge (handover gate):
|
||||||
// the opponent (the turn taker-over) then sends a Judge, which JudgeHandler reflects
|
// the opponent (the turn taker-over) then sends a Judge, which JudgeHandler reflects
|
||||||
// back to it to start its turn. battleCode/actionSeq/cemetery are dropped.
|
// back to it to start its turn. battleCode/actionSeq/cemetery are dropped.
|
||||||
var te = ctx.Env with { Body = new TurnEndBody(TurnState: 0) };
|
var te = ctx.Env with { Body = new TurnEndBody(TurnState: TurnState.First) };
|
||||||
return new[] { new DispatchRoute(ctx.Other, te, false) };
|
return new[] { new DispatchRoute(ctx.Other, te, Stock.Normal) };
|
||||||
}
|
}
|
||||||
return Array.Empty<DispatchRoute>(); // Pvp-not-both-ready → drop (Bot already returned above)
|
return Array.Empty<DispatchRoute>(); // Pvp-not-both-ready → drop (Bot already returned above)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using SVSim.BattleNode.Lifecycle;
|
||||||
using SVSim.BattleNode.Protocol;
|
using SVSim.BattleNode.Protocol;
|
||||||
using SVSim.BattleNode.Protocol.Bodies;
|
using SVSim.BattleNode.Protocol.Bodies;
|
||||||
|
|
||||||
@@ -9,10 +10,10 @@ internal sealed class TurnStartHandler : IFrameHandler
|
|||||||
{
|
{
|
||||||
// PvP: the active player's TurnStart{orderList} is dropped; the opponent receives {spin}
|
// PvP: the active player's TurnStart{orderList} is dropped; the opponent receives {spin}
|
||||||
// (spin=0 for the deterministic-turn slice) and self-generates its turn-open.
|
// (spin=0 for the deterministic-turn slice) and self-generates its turn-open.
|
||||||
if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady())
|
if (ctx.BothSidesAfterReady())
|
||||||
{
|
{
|
||||||
var frame = ctx.Env with { Body = new OpponentTurnStartBody(Spin: 0) };
|
var frame = ctx.Env with { Body = new OpponentTurnStartBody(Spin: BattleFrameDefaults.DeterministicTurnSpin) };
|
||||||
return new[] { new DispatchRoute(ctx.Other, frame, false) };
|
return new[] { new DispatchRoute(ctx.Other, frame, Stock.Normal) };
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.Empty<DispatchRoute>();
|
return Array.Empty<DispatchRoute>();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using SVSim.BattleNode.Protocol;
|
||||||
using SVSim.BattleNode.Protocol.Bodies;
|
using SVSim.BattleNode.Protocol.Bodies;
|
||||||
|
|
||||||
namespace SVSim.BattleNode.Sessions.Dispatch;
|
namespace SVSim.BattleNode.Sessions.Dispatch;
|
||||||
@@ -5,7 +6,7 @@ namespace SVSim.BattleNode.Sessions.Dispatch;
|
|||||||
/// <summary>Pure transforms from the active player's RawBody sub-structures to the opponent-facing
|
/// <summary>Pure transforms from the active player's RawBody sub-structures to the opponent-facing
|
||||||
/// shapes. No session state, no wire I/O — unit-testable in isolation. RawBody nested values arrive
|
/// shapes. No session state, no wire I/O — unit-testable in isolation. RawBody nested values arrive
|
||||||
/// as <c>Dictionary<string,object?></c> / <c>List<object?></c> with numeric leaves boxed
|
/// as <c>Dictionary<string,object?></c> / <c>List<object?></c> with numeric leaves boxed
|
||||||
/// as long/int/double (see MsgEnvelope.FromJson).</summary>
|
/// as long/int/double (see MsgEnvelope.FromJson). Inbound wire keys come from <see cref="WireKeys"/>.</summary>
|
||||||
internal static class KnownListBuilder
|
internal static class KnownListBuilder
|
||||||
{
|
{
|
||||||
/// <summary>The played card's knownList entry, or null when its identity can't be synthesized
|
/// <summary>The played card's knownList entry, or null when its identity can't be synthesized
|
||||||
@@ -31,11 +32,11 @@ internal static class KnownListBuilder
|
|||||||
foreach (var op in ops)
|
foreach (var op in ops)
|
||||||
{
|
{
|
||||||
if (op is not IDictionary<string, object?> opDict) continue;
|
if (op is not IDictionary<string, object?> opDict) continue;
|
||||||
if (!opDict.TryGetValue("move", out var moveRaw) || moveRaw is not IDictionary<string, object?> move) continue;
|
if (!opDict.TryGetValue(WireKeys.Move, out var moveRaw) || moveRaw is not IDictionary<string, object?> move) continue;
|
||||||
if (move.TryGetValue("idx", out var idxRaw) && idxRaw is IEnumerable<object?> idxList)
|
if (move.TryGetValue(WireKeys.Idx, out var idxRaw) && idxRaw is IEnumerable<object?> idxList)
|
||||||
{
|
{
|
||||||
foreach (var i in idxList)
|
foreach (var i in idxList)
|
||||||
if (AsLong(i) == playIdx && move.TryGetValue("to", out var toRaw))
|
if (AsLong(i) == playIdx && move.TryGetValue(WireKeys.To, out var toRaw))
|
||||||
return (int)AsLong(toRaw);
|
return (int)AsLong(toRaw);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,24 +55,24 @@ internal static class KnownListBuilder
|
|||||||
/// <c>idx</c>-is-list guards. This is the only place a freshly-generated card's identity exists on
|
/// <c>idx</c>-is-list guards. This is the only place a freshly-generated card's identity exists on
|
||||||
/// the wire (bullet-3 audit F1; producing code <c>RegisterToken</c>/<c>RegisterActionBase</c>) —
|
/// the wire (bullet-3 audit F1; producing code <c>RegisterToken</c>/<c>RegisterActionBase</c>) —
|
||||||
/// the played-card op itself never carries a <c>cardId</c>.</summary>
|
/// the played-card op itself never carries a <c>cardId</c>.</summary>
|
||||||
public static IEnumerable<(int Idx, long CardId, int IsSelf)> MineAddOps(object? orderList)
|
public static IEnumerable<MinedToken> MineAddOps(object? orderList)
|
||||||
{
|
{
|
||||||
if (orderList is not IEnumerable<object?> ops) yield break;
|
if (orderList is not IEnumerable<object?> ops) yield break;
|
||||||
foreach (var op in ops)
|
foreach (var op in ops)
|
||||||
{
|
{
|
||||||
if (op is not IDictionary<string, object?> opDict) continue;
|
if (op is not IDictionary<string, object?> opDict) continue;
|
||||||
if (!opDict.TryGetValue("add", out var addRaw) || addRaw is not IDictionary<string, object?> add) continue;
|
if (!opDict.TryGetValue(WireKeys.Add, out var addRaw) || addRaw is not IDictionary<string, object?> add) continue;
|
||||||
|
|
||||||
add.TryGetValue("isSelf", out var isSelfRaw);
|
add.TryGetValue(WireKeys.IsSelf, out var isSelfRaw);
|
||||||
var isSelf = (int)AsLong(isSelfRaw);
|
var isSelf = (CardOwner)(int)AsLong(isSelfRaw);
|
||||||
|
|
||||||
if (!add.TryGetValue("card", out var cardRaw) || cardRaw is not IDictionary<string, object?> card) continue;
|
if (!add.TryGetValue(WireKeys.Card, out var cardRaw) || cardRaw is not IDictionary<string, object?> card) continue;
|
||||||
if (!card.TryGetValue("cardId", out var cardIdRaw)) continue; // candidates/isChoice → no identity yet
|
if (!card.TryGetValue(WireKeys.CardId, out var cardIdRaw)) continue; // candidates/isChoice → no identity yet
|
||||||
var cardId = AsLong(cardIdRaw);
|
var cardId = AsLong(cardIdRaw);
|
||||||
|
|
||||||
if (!add.TryGetValue("idx", out var idxRaw) || idxRaw is not IEnumerable<object?> idxList) continue;
|
if (!add.TryGetValue(WireKeys.Idx, out var idxRaw) || idxRaw is not IEnumerable<object?> idxList) continue;
|
||||||
foreach (var i in idxList)
|
foreach (var i in idxList)
|
||||||
yield return ((int)AsLong(i), cardId, isSelf);
|
yield return new MinedToken((int)AsLong(i), cardId, isSelf);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +87,7 @@ internal static class KnownListBuilder
|
|||||||
/// only gates the strip (<see cref="StripKeyActionForOpponent"/>), not the recording. An add whose
|
/// only gates the strip (<see cref="StripKeyActionForOpponent"/>), not the recording. An add whose
|
||||||
/// candidates contain none of the picks is skipped (defensive — no record, no desync); Echo (no
|
/// candidates contain none of the picks is skipped (defensive — no record, no desync); Echo (no
|
||||||
/// keyAction) yields nothing, leaving it mining-only via <see cref="MineAddOps"/>.</summary>
|
/// keyAction) yields nothing, leaving it mining-only via <see cref="MineAddOps"/>.</summary>
|
||||||
public static IEnumerable<(int Idx, long CardId, int IsSelf)> MineChoicePicks(object? orderList, object? keyAction)
|
public static IEnumerable<MinedToken> MineChoicePicks(object? orderList, object? keyAction)
|
||||||
{
|
{
|
||||||
if (orderList is not IEnumerable<object?> ops) yield break;
|
if (orderList is not IEnumerable<object?> ops) yield break;
|
||||||
|
|
||||||
@@ -97,8 +98,8 @@ internal static class KnownListBuilder
|
|||||||
foreach (var ka in kaEntries)
|
foreach (var ka in kaEntries)
|
||||||
{
|
{
|
||||||
if (ka is not IDictionary<string, object?> kaDict) continue;
|
if (ka is not IDictionary<string, object?> kaDict) continue;
|
||||||
if (!kaDict.TryGetValue("selectCard", out var scRaw) || scRaw is not IDictionary<string, object?> sc) continue;
|
if (!kaDict.TryGetValue(WireKeys.SelectCard, out var scRaw) || scRaw is not IDictionary<string, object?> sc) continue;
|
||||||
if (!sc.TryGetValue("cardId", out var idsRaw) || idsRaw is not IEnumerable<object?> ids) continue;
|
if (!sc.TryGetValue(WireKeys.CardId, out var idsRaw) || idsRaw is not IEnumerable<object?> ids) continue;
|
||||||
foreach (var id in ids) picks.Add(AsLong(id));
|
foreach (var id in ids) picks.Add(AsLong(id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,10 +108,10 @@ internal static class KnownListBuilder
|
|||||||
foreach (var op in ops)
|
foreach (var op in ops)
|
||||||
{
|
{
|
||||||
if (op is not IDictionary<string, object?> opDict) continue;
|
if (op is not IDictionary<string, object?> opDict) continue;
|
||||||
if (!opDict.TryGetValue("add", out var addRaw) || addRaw is not IDictionary<string, object?> add) continue;
|
if (!opDict.TryGetValue(WireKeys.Add, out var addRaw) || addRaw is not IDictionary<string, object?> add) continue;
|
||||||
if (!add.ContainsKey("isChoice")) continue;
|
if (!add.ContainsKey(WireKeys.IsChoice)) continue;
|
||||||
if (!add.TryGetValue("card", out var cardRaw) || cardRaw is not IDictionary<string, object?> card) continue;
|
if (!add.TryGetValue(WireKeys.Card, out var cardRaw) || cardRaw is not IDictionary<string, object?> card) continue;
|
||||||
if (!card.TryGetValue("candidates", out var candRaw) || candRaw is not IEnumerable<object?> candidates) continue;
|
if (!card.TryGetValue(WireKeys.Candidates, out var candRaw) || candRaw is not IEnumerable<object?> candidates) continue;
|
||||||
|
|
||||||
// The chosen cardId is the candidate that the active player picked (∈ picks). One per op.
|
// The chosen cardId is the candidate that the active player picked (∈ picks). One per op.
|
||||||
long? chosen = null;
|
long? chosen = null;
|
||||||
@@ -121,12 +122,12 @@ internal static class KnownListBuilder
|
|||||||
}
|
}
|
||||||
if (chosen is null) continue; // no pick in this op's pool — skip (no desync, just no record)
|
if (chosen is null) continue; // no pick in this op's pool — skip (no desync, just no record)
|
||||||
|
|
||||||
add.TryGetValue("isSelf", out var isSelfRaw);
|
add.TryGetValue(WireKeys.IsSelf, out var isSelfRaw);
|
||||||
var isSelf = (int)AsLong(isSelfRaw);
|
var isSelf = (CardOwner)(int)AsLong(isSelfRaw);
|
||||||
|
|
||||||
if (!add.TryGetValue("idx", out var idxRaw) || idxRaw is not IEnumerable<object?> idxList) continue;
|
if (!add.TryGetValue(WireKeys.Idx, out var idxRaw) || idxRaw is not IEnumerable<object?> idxList) continue;
|
||||||
foreach (var i in idxList)
|
foreach (var i in idxList)
|
||||||
yield return ((int)AsLong(i), chosen.Value, isSelf);
|
yield return new MinedToken((int)AsLong(i), chosen.Value, isSelf);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +144,7 @@ internal static class KnownListBuilder
|
|||||||
/// <c>candidates</c> (→ MineChoicePicks), a <c>string</c> <c>baseIdx</c> (private-group copy,
|
/// <c>candidates</c> (→ MineChoicePicks), a <c>string</c> <c>baseIdx</c> (private-group copy,
|
||||||
/// <c>RegisterCopyToken.cs:19-22</c>), and a <c>baseIdx</c> absent from the chosen map (unknown source
|
/// <c>RegisterCopyToken.cs:19-22</c>), and a <c>baseIdx</c> absent from the chosen map (unknown source
|
||||||
/// → degrade, no desync). <c>isPremium</c> (IsFoil) is cosmetic and ignored.</summary>
|
/// → degrade, no desync). <c>isPremium</c> (IsFoil) is cosmetic and ignored.</summary>
|
||||||
public static IEnumerable<(int Idx, long CardId, int IsSelf)> MineCopyTokens(
|
public static IEnumerable<MinedToken> MineCopyTokens(
|
||||||
object? orderList,
|
object? orderList,
|
||||||
IReadOnlyDictionary<int, long> selfMap,
|
IReadOnlyDictionary<int, long> selfMap,
|
||||||
IReadOnlyDictionary<int, long> otherMap)
|
IReadOnlyDictionary<int, long> otherMap)
|
||||||
@@ -152,22 +153,22 @@ internal static class KnownListBuilder
|
|||||||
foreach (var op in ops)
|
foreach (var op in ops)
|
||||||
{
|
{
|
||||||
if (op is not IDictionary<string, object?> opDict) continue;
|
if (op is not IDictionary<string, object?> opDict) continue;
|
||||||
if (!opDict.TryGetValue("add", out var addRaw) || addRaw is not IDictionary<string, object?> add) continue;
|
if (!opDict.TryGetValue(WireKeys.Add, out var addRaw) || addRaw is not IDictionary<string, object?> add) continue;
|
||||||
|
|
||||||
if (!add.TryGetValue("card", out var cardRaw) || cardRaw is not IDictionary<string, object?> card) continue;
|
if (!add.TryGetValue(WireKeys.Card, out var cardRaw) || cardRaw is not IDictionary<string, object?> card) continue;
|
||||||
if (card.ContainsKey("cardId")) continue; // concrete token → MineAddOps
|
if (card.ContainsKey(WireKeys.CardId)) continue; // concrete token → MineAddOps
|
||||||
if (!card.TryGetValue("baseIdx", out var baseRaw)) continue; // not a copy (candidates → MineChoicePicks)
|
if (!card.TryGetValue(WireKeys.BaseIdx, out var baseRaw)) continue; // not a copy (candidates → MineChoicePicks)
|
||||||
if (baseRaw is string) continue; // private-group copy → string baseIdx, skip
|
if (baseRaw is string) continue; // private-group copy → string baseIdx, skip
|
||||||
var baseIdx = (int)AsLong(baseRaw);
|
var baseIdx = (int)AsLong(baseRaw);
|
||||||
|
|
||||||
add.TryGetValue("isSelf", out var isSelfRaw);
|
add.TryGetValue(WireKeys.IsSelf, out var isSelfRaw);
|
||||||
var isSelf = (int)AsLong(isSelfRaw);
|
var isSelf = (CardOwner)(int)AsLong(isSelfRaw);
|
||||||
var map = isSelf == 1 ? selfMap : otherMap;
|
var map = isSelf == CardOwner.Self ? selfMap : otherMap;
|
||||||
if (!map.TryGetValue(baseIdx, out var cardId)) continue; // unknown source → degrade
|
if (!map.TryGetValue(baseIdx, out var cardId)) continue; // unknown source → degrade
|
||||||
|
|
||||||
if (!add.TryGetValue("idx", out var idxRaw) || idxRaw is not IEnumerable<object?> idxList) continue;
|
if (!add.TryGetValue(WireKeys.Idx, out var idxRaw) || idxRaw is not IEnumerable<object?> idxList) continue;
|
||||||
foreach (var i in idxList)
|
foreach (var i in idxList)
|
||||||
yield return ((int)AsLong(i), cardId, isSelf);
|
yield return new MinedToken((int)AsLong(i), cardId, isSelf);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,19 +185,19 @@ internal static class KnownListBuilder
|
|||||||
foreach (var e in entries)
|
foreach (var e in entries)
|
||||||
{
|
{
|
||||||
if (e is not IDictionary<string, object?> d) continue;
|
if (e is not IDictionary<string, object?> d) continue;
|
||||||
d.TryGetValue("type", out var typeRaw);
|
d.TryGetValue(WireKeys.Type, out var typeRaw);
|
||||||
var type = (int)AsLong(typeRaw);
|
var type = (KeyActionType)(int)AsLong(typeRaw);
|
||||||
if (type is not (1 or 5)) continue; // only Choice / HaveBeforeSkillChoice handled
|
if (type is not (KeyActionType.Choice or KeyActionType.HaveBeforeSkillChoice)) continue;
|
||||||
|
|
||||||
d.TryGetValue("cardId", out var cardIdRaw);
|
d.TryGetValue(WireKeys.CardId, out var cardIdRaw);
|
||||||
var cardId = AsLong(cardIdRaw);
|
var cardId = AsLong(cardIdRaw);
|
||||||
|
|
||||||
SelectCardEntry? selectCard = null;
|
SelectCardEntry? selectCard = null;
|
||||||
if (d.TryGetValue("selectCard", out var scRaw) && scRaw is IDictionary<string, object?> sc)
|
if (d.TryGetValue(WireKeys.SelectCard, out var scRaw) && scRaw is IDictionary<string, object?> sc)
|
||||||
{
|
{
|
||||||
sc.TryGetValue("open", out var openRaw);
|
sc.TryGetValue(WireKeys.Open, out var openRaw);
|
||||||
var open = (int)AsLong(openRaw);
|
var open = (ChoiceVisibility)(int)AsLong(openRaw);
|
||||||
if (open != 0 && sc.TryGetValue("cardId", out var idsRaw) && idsRaw is IEnumerable<object?> ids)
|
if (open != ChoiceVisibility.Hidden && sc.TryGetValue(WireKeys.CardId, out var idsRaw) && idsRaw is IEnumerable<object?> ids)
|
||||||
selectCard = new SelectCardEntry(ids.Select(AsLong).ToList(), open);
|
selectCard = new SelectCardEntry(ids.Select(AsLong).ToList(), open);
|
||||||
}
|
}
|
||||||
result.Add(new KeyActionEntry(type, cardId, selectCard));
|
result.Add(new KeyActionEntry(type, cardId, selectCard));
|
||||||
@@ -213,11 +214,11 @@ internal static class KnownListBuilder
|
|||||||
foreach (var e in entries)
|
foreach (var e in entries)
|
||||||
{
|
{
|
||||||
if (e is not IDictionary<string, object?> d) continue;
|
if (e is not IDictionary<string, object?> d) continue;
|
||||||
d.TryGetValue("targetIdx", out var targetIdxRaw);
|
d.TryGetValue(WireKeys.TargetIdx, out var targetIdxRaw);
|
||||||
d.TryGetValue("isSelf", out var isSelfRaw);
|
d.TryGetValue(WireKeys.IsSelf, out var isSelfRaw);
|
||||||
result.Add(new OppoTargetEntry(
|
result.Add(new OppoTargetEntry(
|
||||||
TargetIdx: (int)AsLong(targetIdxRaw),
|
TargetIdx: (int)AsLong(targetIdxRaw),
|
||||||
IsSelf: (int)AsLong(isSelfRaw)));
|
IsSelf: (CardOwner)(int)AsLong(isSelfRaw)));
|
||||||
}
|
}
|
||||||
return result.Count == 0 ? null : result;
|
return result.Count == 0 ? null : result;
|
||||||
}
|
}
|
||||||
@@ -237,25 +238,25 @@ internal static class KnownListBuilder
|
|||||||
{
|
{
|
||||||
if (e is not IDictionary<string, object?> d) continue;
|
if (e is not IDictionary<string, object?> d) continue;
|
||||||
|
|
||||||
d.TryGetValue("idxList", out var idxRaw);
|
d.TryGetValue(WireKeys.IdxList, out var idxRaw);
|
||||||
d.TryGetValue("from", out var fromRaw);
|
d.TryGetValue(WireKeys.From, out var fromRaw);
|
||||||
d.TryGetValue("to", out var toRaw);
|
d.TryGetValue(WireKeys.To, out var toRaw);
|
||||||
d.TryGetValue("isSelf", out var isSelfRaw);
|
d.TryGetValue(WireKeys.IsSelf, out var isSelfRaw);
|
||||||
d.TryGetValue("skill", out var skillRaw);
|
d.TryGetValue(WireKeys.Skill, out var skillRaw);
|
||||||
|
|
||||||
result.Add(new UnapprovedCardEntry(
|
result.Add(new UnapprovedCardEntry(
|
||||||
IdxList: AsIntList(idxRaw) ?? new List<int>(),
|
IdxList: AsIntList(idxRaw) ?? new List<int>(),
|
||||||
From: (int)AsLong(fromRaw),
|
From: (int)AsLong(fromRaw),
|
||||||
To: (int)AsLong(toRaw),
|
To: (int)AsLong(toRaw),
|
||||||
IsSelf: (int)AsLong(isSelfRaw),
|
IsSelf: (CardOwner)(int)AsLong(isSelfRaw),
|
||||||
Skill: skillRaw as string ?? "",
|
Skill: skillRaw as string ?? "",
|
||||||
CardId: d.TryGetValue("cardId", out var c) ? AsLong(c) : null,
|
CardId: d.TryGetValue(WireKeys.CardId, out var c) ? AsLong(c) : null,
|
||||||
Clan: d.TryGetValue("clan", out var cl) ? (int)AsLong(cl) : null,
|
Clan: d.TryGetValue(WireKeys.Clan, out var cl) ? (int)AsLong(cl) : null,
|
||||||
Cost: d.TryGetValue("cost", out var co) ? (int)AsLong(co) : null,
|
Cost: d.TryGetValue(WireKeys.Cost, out var co) ? (int)AsLong(co) : null,
|
||||||
SkillKeyCardIdx: AsIntList(d.TryGetValue("skillKeyCardIdx", out var sk) ? sk : null),
|
SkillKeyCardIdx: AsIntList(d.TryGetValue(WireKeys.SkillKeyCardIdx, out var sk) ? sk : null),
|
||||||
RandomTargetIdx: AsIntList(d.TryGetValue("randomTargetIdx", out var rt) ? rt : null),
|
RandomTargetIdx: AsIntList(d.TryGetValue(WireKeys.RandomTargetIdx, out var rt) ? rt : null),
|
||||||
IsInvoke: d.TryGetValue("isInvoke", out var iv) ? (int)AsLong(iv) : null,
|
IsInvoke: d.TryGetValue(WireKeys.IsInvoke, out var iv) ? AsLong(iv) != 0 : null,
|
||||||
AttachTarget: d.TryGetValue("attachTarget", out var at) ? at as string : null));
|
AttachTarget: d.TryGetValue(WireKeys.AttachTarget, out var at) ? at as string : null));
|
||||||
}
|
}
|
||||||
return result.Count == 0 ? null : result;
|
return result.Count == 0 ? null : result;
|
||||||
}
|
}
|
||||||
|
|||||||
15
SVSim.BattleNode/Sessions/Dispatch/MinedToken.cs
Normal file
15
SVSim.BattleNode/Sessions/Dispatch/MinedToken.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
using SVSim.BattleNode.Protocol;
|
||||||
|
|
||||||
|
namespace SVSim.BattleNode.Sessions.Dispatch;
|
||||||
|
|
||||||
|
/// <summary>One generated-token identity mined from a sender's <c>orderList</c> <c>add</c> op:
|
||||||
|
/// the token's <paramref name="Idx"/> in a side's index space, its resolved
|
||||||
|
/// <paramref name="CardId"/>, and <paramref name="IsSelf"/> — whose map it belongs to (the
|
||||||
|
/// sender's own token vs a cross-side gift living in the opponent's index space; routed by
|
||||||
|
/// <see cref="BattleSessionState.RecordTokensFrom"/>). Replaces the transpose-prone
|
||||||
|
/// <c>(int Idx, long CardId, CardOwner IsSelf)</c> tuple the <c>Mine*</c> methods returned:
|
||||||
|
/// <c>Idx</c> and <c>CardId</c> are both numeric, so <c>(cardId, idx, …)</c> silently compiled
|
||||||
|
/// and corrupted the reveal map. As a positional record struct it keeps the named members and
|
||||||
|
/// positional deconstruct (call sites stay <c>foreach (var (idx, cardId, isSelf) in …)</c>)
|
||||||
|
/// while the compiler rejects a transposed construction.</summary>
|
||||||
|
internal readonly record struct MinedToken(int Idx, long CardId, CardOwner IsSelf);
|
||||||
50
SVSim.BattleNode/Sessions/Dispatch/WireKeys.cs
Normal file
50
SVSim.BattleNode/Sessions/Dispatch/WireKeys.cs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
namespace SVSim.BattleNode.Sessions.Dispatch;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single source of truth for the inbound-body (RawBody / orderList) wire-key strings the dispatch
|
||||||
|
/// path reads off the client's frames. These are the SENDER's JSON keys (mirroring the client's
|
||||||
|
/// <c>SendCardDataMaker</c> / <c>CardObj</c> serialization); a one-character typo at a read site
|
||||||
|
/// (<c>"isSelf"</c> vs <c>"IsSelf"</c>) silently degrades token resolution with no error, so every
|
||||||
|
/// read goes through a constant here instead of a repeated literal. Outbound keys stay on the
|
||||||
|
/// per-DTO <c>[JsonPropertyName]</c> attributes (already single-sourced there).
|
||||||
|
/// </summary>
|
||||||
|
internal static class WireKeys
|
||||||
|
{
|
||||||
|
// Top-level inbound body keys
|
||||||
|
public const string OrderList = "orderList";
|
||||||
|
public const string KeyAction = "keyAction";
|
||||||
|
public const string PlayIdx = "playIdx";
|
||||||
|
public const string Type = "type";
|
||||||
|
public const string TargetList = "targetList";
|
||||||
|
public const string UList = "uList";
|
||||||
|
|
||||||
|
// orderList op keys
|
||||||
|
public const string Move = "move";
|
||||||
|
public const string Add = "add";
|
||||||
|
public const string Idx = "idx";
|
||||||
|
public const string To = "to";
|
||||||
|
public const string IsSelf = "isSelf";
|
||||||
|
public const string Card = "card";
|
||||||
|
public const string CardId = "cardId";
|
||||||
|
public const string Candidates = "candidates";
|
||||||
|
public const string IsChoice = "isChoice";
|
||||||
|
public const string BaseIdx = "baseIdx";
|
||||||
|
|
||||||
|
// keyAction.selectCard keys
|
||||||
|
public const string SelectCard = "selectCard";
|
||||||
|
public const string Open = "open";
|
||||||
|
|
||||||
|
// targetList entry keys
|
||||||
|
public const string TargetIdx = "targetIdx";
|
||||||
|
|
||||||
|
// uList entry keys
|
||||||
|
public const string IdxList = "idxList";
|
||||||
|
public const string From = "from";
|
||||||
|
public const string Skill = "skill";
|
||||||
|
public const string Clan = "clan";
|
||||||
|
public const string Cost = "cost";
|
||||||
|
public const string SkillKeyCardIdx = "skillKeyCardIdx";
|
||||||
|
public const string RandomTargetIdx = "randomTargetIdx";
|
||||||
|
public const string IsInvoke = "isInvoke";
|
||||||
|
public const string AttachTarget = "attachTarget";
|
||||||
|
}
|
||||||
18
SVSim.BattleNode/Sessions/HandshakePhase.cs
Normal file
18
SVSim.BattleNode/Sessions/HandshakePhase.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
namespace SVSim.BattleNode.Sessions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-participant progression through the v1 server-authored setup handshake. Each side advances
|
||||||
|
/// InitNetwork → InitBattle → Loaded → Swap → AfterReady as the session acks its emits. Tracked
|
||||||
|
/// per participant via <see cref="Participants.IHasHandshakePhase"/>; the session reads the
|
||||||
|
/// SENDER's phase (<see cref="Dispatch.FrameDispatchContext.SenderPhase"/>) to gate which setup
|
||||||
|
/// frame to author next. Distinct from the session-global <see cref="SessionLifecycle"/> — this is
|
||||||
|
/// one axis per side, that is one axis per battle.
|
||||||
|
/// </summary>
|
||||||
|
public enum HandshakePhase
|
||||||
|
{
|
||||||
|
AwaitingInitNetwork,
|
||||||
|
AwaitingInitBattle,
|
||||||
|
AwaitingLoaded,
|
||||||
|
AwaitingSwap,
|
||||||
|
AfterReady,
|
||||||
|
}
|
||||||
@@ -24,9 +24,9 @@ public interface IBattleParticipant : IAsyncDisposable
|
|||||||
/// <summary>Session calls this to deliver a frame from the OTHER participant
|
/// <summary>Session calls this to deliver a frame from the OTHER participant
|
||||||
/// (or a server-synthesized broadcast). Real impl: encode + WS-send.
|
/// (or a server-synthesized broadcast). Real impl: encode + WS-send.
|
||||||
/// NoOp: swallow.</summary>
|
/// NoOp: swallow.</summary>
|
||||||
/// <param name="noStock">True for control frames (BattleFinish, JudgeResult, ack);
|
/// <param name="stock"><see cref="Stock.Bypass"/> for control frames (BattleFinish, JudgeResult,
|
||||||
/// bypasses playSeq assignment + archive.</param>
|
/// ack) — bypasses playSeq assignment + archive; <see cref="Stock.Normal"/> for gameplay frames.</param>
|
||||||
Task PushAsync(MsgEnvelope envelope, bool noStock, CancellationToken ct);
|
Task PushAsync(MsgEnvelope envelope, Stock stock, CancellationToken ct);
|
||||||
|
|
||||||
/// <summary>Participant fires this when it has a frame to send TO the session
|
/// <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.</summary>
|
/// (its own gameplay action). Real impl: fires on WS recv. NoOp: never fires.</summary>
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ namespace SVSim.BattleNode.Sessions;
|
|||||||
|
|
||||||
public interface IBattleSessionStore
|
public interface IBattleSessionStore
|
||||||
{
|
{
|
||||||
/// <summary>Register a battle minted by the matching bridge, awaiting a WS connect.</summary>
|
/// <summary>Register a battle minted by the matching bridge, awaiting a WS connect.
|
||||||
void RegisterPending(PendingBattle battle);
|
/// Returns false if a battle with the same id is already pending (caller should retry
|
||||||
|
/// with a fresh id).</summary>
|
||||||
|
bool TryRegisterPending(PendingBattle battle);
|
||||||
|
|
||||||
/// <summary>Look up the pending battle. Returns null if not present.</summary>
|
/// <summary>Look up the pending battle. Returns null if not present.</summary>
|
||||||
PendingBattle? TryGetPending(string battleId);
|
PendingBattle? TryGetPending(string battleId);
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ public sealed class InMemoryBattleSessionStore : IBattleSessionStore
|
|||||||
{
|
{
|
||||||
private readonly ConcurrentDictionary<string, PendingBattle> _pending = new();
|
private readonly ConcurrentDictionary<string, PendingBattle> _pending = new();
|
||||||
|
|
||||||
public void RegisterPending(PendingBattle battle) =>
|
public bool TryRegisterPending(PendingBattle battle) =>
|
||||||
_pending[battle.BattleId] = battle;
|
_pending.TryAdd(battle.BattleId, battle);
|
||||||
|
|
||||||
public PendingBattle? TryGetPending(string battleId) =>
|
public PendingBattle? TryGetPending(string battleId) =>
|
||||||
_pending.TryGetValue(battleId, out var b) ? b : null;
|
_pending.TryGetValue(battleId, out var b) ? b : null;
|
||||||
|
|||||||
@@ -14,22 +14,25 @@ namespace SVSim.BattleNode.Sessions.Participants;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class NoOpBotParticipant : IBattleParticipant
|
public sealed class NoOpBotParticipant : IBattleParticipant
|
||||||
{
|
{
|
||||||
|
/// <summary>Stub card-master id stamped on the bot's (never-read) MatchContext.</summary>
|
||||||
|
private const string BotCardMasterName = "card_master_node_10015";
|
||||||
|
|
||||||
public long ViewerId => ServerBattleFrames.FakeOpponentViewerId;
|
public long ViewerId => ServerBattleFrames.FakeOpponentViewerId;
|
||||||
public MatchContext Context { get; } = new(
|
public MatchContext Context { get; } = new(
|
||||||
SelfDeckCardIds: Array.Empty<long>(),
|
SelfDeckCardIds: Array.Empty<long>(),
|
||||||
ClassId: "0", CharaId: "0", CardMasterName: "card_master_node_10015",
|
ClassId: CardClass.None, CharaId: "0", CardMasterName: BotCardMasterName,
|
||||||
CountryCode: "", UserName: "Bot", SleeveId: "0",
|
CountryCode: "", UserName: "Bot", SleeveId: "0",
|
||||||
EmblemId: "0", DegreeId: "0", FieldId: 0, IsOfficial: 0,
|
EmblemId: "0", DegreeId: "0", FieldId: 0, IsOfficial: 0,
|
||||||
BattleType: 0);
|
BattleModeId: 0);
|
||||||
|
|
||||||
|
// Required by IBattleParticipant, but a silent bot never raises it — suppress the
|
||||||
|
// "event is never used" warning rather than keeping a dead null-emitting method.
|
||||||
|
#pragma warning disable CS0067
|
||||||
public event Func<MsgEnvelope, CancellationToken, Task>? FrameEmitted;
|
public event Func<MsgEnvelope, CancellationToken, Task>? FrameEmitted;
|
||||||
|
#pragma warning restore CS0067
|
||||||
|
|
||||||
public Task PushAsync(MsgEnvelope envelope, bool noStock, CancellationToken ct) => Task.CompletedTask;
|
public Task PushAsync(MsgEnvelope envelope, Stock stock, CancellationToken ct) => Task.CompletedTask;
|
||||||
public Task RunAsync(CancellationToken ct) => Task.CompletedTask;
|
public Task RunAsync(CancellationToken ct) => Task.CompletedTask;
|
||||||
public Task TerminateAsync(BattleFinishReason reason) => Task.CompletedTask;
|
public Task TerminateAsync(BattleFinishReason reason) => Task.CompletedTask;
|
||||||
public ValueTask DisposeAsync() => ValueTask.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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ namespace SVSim.BattleNode.Sessions.Participants;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal interface IHasHandshakePhase
|
internal interface IHasHandshakePhase
|
||||||
{
|
{
|
||||||
BattleSessionPhase Phase { get; set; }
|
HandshakePhase Phase { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -31,6 +31,24 @@ internal interface IHasHandshakePhase
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
|
public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
|
||||||
{
|
{
|
||||||
|
/// <summary>WS read-loop receive buffer, in bytes. Messages larger than this are
|
||||||
|
/// reassembled across multiple ReceiveAsync calls (see <see cref="ReadCompleteMessageAsync"/>).</summary>
|
||||||
|
private const int ReceiveBufferBytes = 8192;
|
||||||
|
|
||||||
|
/// <summary>Engine.IO heartbeat parameters advertised in the open handshake — the
|
||||||
|
/// pingInterval/pingTimeout (ms) the BestHTTP client honors. Not related to
|
||||||
|
/// <see cref="Bridge.BattleNodeOptions.WaitingRoomTimeout"/> despite the 60s coincidence.</summary>
|
||||||
|
private const int EngineIoPingIntervalMs = 25000;
|
||||||
|
private const int EngineIoPingTimeoutMs = 60000;
|
||||||
|
|
||||||
|
/// <summary>Length (hex chars) of the Engine.IO session id we mint in the open handshake.</summary>
|
||||||
|
private const int EngineIoSidLength = 16;
|
||||||
|
|
||||||
|
/// <summary>Exclusive upper bound for one random hex nibble (0x0..0xF) fed to
|
||||||
|
/// <see cref="NodeCrypto.GenerateKey"/>. Distinct concept from <see cref="EngineIoSidLength"/>
|
||||||
|
/// despite the shared value 16.</summary>
|
||||||
|
private const int KeyHexDigitExclusiveMax = 16;
|
||||||
|
|
||||||
private readonly WebSocket _ws;
|
private readonly WebSocket _ws;
|
||||||
private readonly ILogger<RealParticipant> _log;
|
private readonly ILogger<RealParticipant> _log;
|
||||||
private readonly bool _diagnosticLogging;
|
private readonly bool _diagnosticLogging;
|
||||||
@@ -48,9 +66,9 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
|
|||||||
/// because they never send the gating URIs. Also satisfies
|
/// because they never send the gating URIs. Also satisfies
|
||||||
/// <see cref="IHasHandshakePhase"/> (the interface BattleSession uses to gate
|
/// <see cref="IHasHandshakePhase"/> (the interface BattleSession uses to gate
|
||||||
/// handshake dispatch without depending on the concrete RealParticipant type).</summary>
|
/// handshake dispatch without depending on the concrete RealParticipant type).</summary>
|
||||||
internal BattleSessionPhase Phase { get; set; } = BattleSessionPhase.AwaitingInitNetwork;
|
internal HandshakePhase Phase { get; set; } = HandshakePhase.AwaitingInitNetwork;
|
||||||
|
|
||||||
BattleSessionPhase IHasHandshakePhase.Phase
|
HandshakePhase IHasHandshakePhase.Phase
|
||||||
{
|
{
|
||||||
get => Phase;
|
get => Phase;
|
||||||
set => Phase = value;
|
set => Phase = value;
|
||||||
@@ -100,7 +118,7 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
|
|||||||
_sessionCt = cancellation;
|
_sessionCt = cancellation;
|
||||||
await SendEioOpenAsync(cancellation);
|
await SendEioOpenAsync(cancellation);
|
||||||
|
|
||||||
var buffer = new byte[8192];
|
var buffer = new byte[ReceiveBufferBytes];
|
||||||
var pendingAttachments = new List<byte[]>();
|
var pendingAttachments = new List<byte[]>();
|
||||||
SocketIoFrame? pendingFrame = null;
|
SocketIoFrame? pendingFrame = null;
|
||||||
string exitReason = "loop-condition-false";
|
string exitReason = "loop-condition-false";
|
||||||
@@ -116,7 +134,15 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
|
|||||||
{
|
{
|
||||||
var text = Encoding.UTF8.GetString(msg.Value.Bytes);
|
var text = Encoding.UTF8.GetString(msg.Value.Bytes);
|
||||||
if (text.Length == 0) continue;
|
if (text.Length == 0) continue;
|
||||||
var eio = EngineIoFrame.Parse(text);
|
|
||||||
|
EngineIoFrame eio;
|
||||||
|
try { eio = EngineIoFrame.Parse(text); }
|
||||||
|
catch (ArgumentException ex)
|
||||||
|
{
|
||||||
|
_log.LogWarning(ex, "Dropping unparseable EIO frame from viewer {Vid}", ViewerId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (_diagnosticLogging)
|
if (_diagnosticLogging)
|
||||||
{
|
{
|
||||||
_log.LogInformation(
|
_log.LogInformation(
|
||||||
@@ -126,12 +152,18 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
|
|||||||
}
|
}
|
||||||
if (eio.Type == EngineIoPacketType.Ping)
|
if (eio.Type == EngineIoPacketType.Ping)
|
||||||
{
|
{
|
||||||
await SendTextAsync("3", cancellation);
|
await SendTextAsync(((int)EngineIoPacketType.Pong).ToString(), cancellation);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (eio.Type != EngineIoPacketType.Message) continue;
|
if (eio.Type != EngineIoPacketType.Message) continue;
|
||||||
|
|
||||||
var sio = SocketIoFrame.Parse(eio.Payload);
|
SocketIoFrame sio;
|
||||||
|
try { sio = SocketIoFrame.Parse(eio.Payload); }
|
||||||
|
catch (ArgumentException ex)
|
||||||
|
{
|
||||||
|
_log.LogWarning(ex, "Dropping unparseable SIO frame from viewer {Vid}", ViewerId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (sio.AttachmentCount > 0)
|
if (sio.AttachmentCount > 0)
|
||||||
{
|
{
|
||||||
pendingFrame = sio;
|
pendingFrame = sio;
|
||||||
@@ -179,14 +211,14 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task PushAsync(MsgEnvelope envelope, bool noStock, CancellationToken ct)
|
public async Task PushAsync(MsgEnvelope envelope, Stock stock, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var stamped = noStock ? Outbound.WrapNoStock(envelope) : Outbound.AssignAndArchive(envelope);
|
var stamped = stock == Stock.Bypass ? Outbound.WrapNoStock(envelope) : Outbound.AssignAndArchive(envelope);
|
||||||
if (_diagnosticLogging)
|
if (_diagnosticLogging)
|
||||||
{
|
{
|
||||||
_log.LogInformation(
|
_log.LogInformation(
|
||||||
"[sio-out] viewer={Vid} uri={Uri} pubSeq={Pseq} playSeq={Plseq} noStock={NoStock}",
|
"[sio-out] viewer={Vid} uri={Uri} pubSeq={Pseq} playSeq={Plseq} stock={Stock}",
|
||||||
ViewerId, stamped.Uri, stamped.PubSeq, stamped.PlaySeq, noStock);
|
ViewerId, stamped.Uri, stamped.PubSeq, stamped.PlaySeq, stock);
|
||||||
}
|
}
|
||||||
await EncodeAndSendAsync(stamped, WireConstants.SynchronizeEvent, ct);
|
await EncodeAndSendAsync(stamped, WireConstants.SynchronizeEvent, ct);
|
||||||
}
|
}
|
||||||
@@ -372,7 +404,7 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
|
|||||||
ViewerId: SVSim.BattleNode.Lifecycle.ServerBattleFrames.FakeOpponentViewerId,
|
ViewerId: SVSim.BattleNode.Lifecycle.ServerBattleFrames.FakeOpponentViewerId,
|
||||||
Uuid: WireConstants.ServerUuid,
|
Uuid: WireConstants.ServerUuid,
|
||||||
Bid: null,
|
Bid: null,
|
||||||
Try: 0,
|
RetryAttempt: 0,
|
||||||
Cat: EmitCategory.General,
|
Cat: EmitCategory.General,
|
||||||
PubSeq: null,
|
PubSeq: null,
|
||||||
PlaySeq: null,
|
PlaySeq: null,
|
||||||
@@ -388,7 +420,7 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
|
|||||||
|
|
||||||
private async Task EncodeAndSendAsync(MsgEnvelope env, string eventName, CancellationToken ct)
|
private async Task EncodeAndSendAsync(MsgEnvelope env, string eventName, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var key = NodeCrypto.GenerateKey(() => RandomNumberGenerator.GetInt32(0, 16));
|
var key = NodeCrypto.GenerateKey(() => RandomNumberGenerator.GetInt32(0, KeyHexDigitExclusiveMax));
|
||||||
var bytes = MsgPayloadCodec.Encode(env, key);
|
var bytes = MsgPayloadCodec.Encode(env, key);
|
||||||
var sio = SocketIoFrame.BinaryEventWithAttachments(eventName, new[] { bytes });
|
var sio = SocketIoFrame.BinaryEventWithAttachments(eventName, new[] { bytes });
|
||||||
var (text, bins) = sio.Encode();
|
var (text, bins) = sio.Encode();
|
||||||
@@ -428,8 +460,9 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
|
|||||||
|
|
||||||
private async Task SendEioOpenAsync(CancellationToken ct)
|
private async Task SendEioOpenAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
var sid = Guid.NewGuid().ToString("N").Substring(0, 16);
|
var sid = Guid.NewGuid().ToString("N").Substring(0, EngineIoSidLength);
|
||||||
var handshake = new EngineIoHandshake(sid, Array.Empty<string>(), 25000, 60000).ToJson();
|
var handshake = new EngineIoHandshake(
|
||||||
|
sid, Array.Empty<string>(), EngineIoPingIntervalMs, EngineIoPingTimeoutMs).ToJson();
|
||||||
await SendTextAsync($"0{handshake}", ct);
|
await SendTextAsync($"0{handshake}", ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
15
SVSim.BattleNode/Sessions/SessionLifecycle.cs
Normal file
15
SVSim.BattleNode/Sessions/SessionLifecycle.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
namespace SVSim.BattleNode.Sessions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Session-global lifecycle. A battle stays <see cref="Active"/> until a terminal event — a lethal
|
||||||
|
/// TurnEndFinal, a Retire/Kill, or the disconnect drop cascade — flips it to <see cref="Terminal"/>,
|
||||||
|
/// after which the drop cascade will not synthesize another BattleFinish. Distinct from the
|
||||||
|
/// per-participant <see cref="HandshakePhase"/> (which side reached which setup step); this is one
|
||||||
|
/// axis per battle. Only these two states are load-bearing — the handshake progression lives on the
|
||||||
|
/// other enum.
|
||||||
|
/// </summary>
|
||||||
|
public enum SessionLifecycle
|
||||||
|
{
|
||||||
|
Active,
|
||||||
|
Terminal,
|
||||||
|
}
|
||||||
17
SVSim.BattleNode/Sessions/Stock.cs
Normal file
17
SVSim.BattleNode/Sessions/Stock.cs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
namespace SVSim.BattleNode.Sessions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How a pushed frame interacts with the per-participant <c>OutboundSequencer</c>: whether it
|
||||||
|
/// gets a <c>playSeq</c> and is archived for ordered replay, or bypasses both. Replaces a bare
|
||||||
|
/// (and negatively-named) <c>bool noStock</c> threaded through <see cref="IBattleParticipant.PushAsync"/>
|
||||||
|
/// and <see cref="Dispatch.DispatchRoute"/> — the literal <c>true</c>/<c>false</c> at call sites gave
|
||||||
|
/// no hint which sense was which, and was trivial to invert.
|
||||||
|
/// </summary>
|
||||||
|
public enum Stock
|
||||||
|
{
|
||||||
|
/// <summary>Gameplay frame: assign the next <c>playSeq</c> and archive it for ordered replay.</summary>
|
||||||
|
Normal = 0,
|
||||||
|
|
||||||
|
/// <summary>Control frame (BattleFinish, JudgeResult, ack): bypass <c>playSeq</c> assignment + archive.</summary>
|
||||||
|
Bypass = 1,
|
||||||
|
}
|
||||||
@@ -12,11 +12,5 @@ public sealed record EngineIoHandshake(
|
|||||||
[property: JsonPropertyName("pingInterval")] int PingInterval,
|
[property: JsonPropertyName("pingInterval")] int PingInterval,
|
||||||
[property: JsonPropertyName("pingTimeout")] int PingTimeout)
|
[property: JsonPropertyName("pingTimeout")] int PingTimeout)
|
||||||
{
|
{
|
||||||
// Wire-key casing here is bare camelCase — NOT EmulatedEntrypoint's snake_case policy.
|
public string ToJson() => JsonSerializer.Serialize(this, WireJsonOptions.CamelCase);
|
||||||
private static readonly JsonSerializerOptions Options = new()
|
|
||||||
{
|
|
||||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
|
||||||
};
|
|
||||||
|
|
||||||
public string ToJson() => JsonSerializer.Serialize(this, Options);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ namespace SVSim.BattleNode.Wire;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class NodeCrypto
|
public static class NodeCrypto
|
||||||
{
|
{
|
||||||
|
/// <summary>Length of the ASCII key, in chars (AES-256 = 32 bytes = 32 ASCII chars).</summary>
|
||||||
|
private const int KeyLength = 32;
|
||||||
|
|
||||||
|
/// <summary>IV length, in chars. The node derives the IV from the first half of the key.</summary>
|
||||||
|
private const int IvLength = KeyLength / 2;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Generate a fresh 32-char key for server-initiated encryption.
|
/// Generate a fresh 32-char key for server-initiated encryption.
|
||||||
/// Calls <paramref name="randHexDigit"/> 32 times; the result is masked with
|
/// Calls <paramref name="randHexDigit"/> 32 times; the result is masked with
|
||||||
@@ -27,20 +33,20 @@ public static class NodeCrypto
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static string GenerateKey(Func<int> randHexDigit)
|
public static string GenerateKey(Func<int> randHexDigit)
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder(32);
|
var sb = new StringBuilder(KeyLength);
|
||||||
for (var i = 0; i < 32; i++)
|
for (var i = 0; i < KeyLength; i++)
|
||||||
{
|
{
|
||||||
sb.Append((randHexDigit() & 0xF).ToString("x"));
|
sb.Append((randHexDigit() & 0xF).ToString("x"));
|
||||||
}
|
}
|
||||||
var ascii = Encoding.ASCII.GetBytes(sb.ToString());
|
var ascii = Encoding.ASCII.GetBytes(sb.ToString());
|
||||||
return Convert.ToBase64String(ascii).Substring(0, 32);
|
return Convert.ToBase64String(ascii).Substring(0, KeyLength);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Encrypt: returns key + base64(AES-256-CBC(plain)).</summary>
|
/// <summary>Encrypt: returns key + base64(AES-256-CBC(plain)).</summary>
|
||||||
public static string EncryptForNode(string plaintext, string key)
|
public static string EncryptForNode(string plaintext, string key)
|
||||||
{
|
{
|
||||||
if (key.Length != 32)
|
if (key.Length != KeyLength)
|
||||||
throw new ArgumentException($"Key must be exactly 32 chars, got {key.Length}", nameof(key));
|
throw new ArgumentException($"Key must be exactly {KeyLength} chars, got {key.Length}", nameof(key));
|
||||||
using var aes = BuildAes(key);
|
using var aes = BuildAes(key);
|
||||||
using var encryptor = aes.CreateEncryptor();
|
using var encryptor = aes.CreateEncryptor();
|
||||||
var plainBytes = Encoding.UTF8.GetBytes(plaintext);
|
var plainBytes = Encoding.UTF8.GetBytes(plaintext);
|
||||||
@@ -51,10 +57,10 @@ public static class NodeCrypto
|
|||||||
/// <summary>Decrypt: input[0..32] is key, input[32..] is base64(ciphertext).</summary>
|
/// <summary>Decrypt: input[0..32] is key, input[32..] is base64(ciphertext).</summary>
|
||||||
public static string DecryptForNode(string encrypted)
|
public static string DecryptForNode(string encrypted)
|
||||||
{
|
{
|
||||||
if (encrypted.Length < 32)
|
if (encrypted.Length < KeyLength)
|
||||||
throw new ArgumentException("Encrypted blob is shorter than the 32-char key prefix", nameof(encrypted));
|
throw new ArgumentException($"Encrypted blob is shorter than the {KeyLength}-char key prefix", nameof(encrypted));
|
||||||
var key = encrypted.Substring(0, 32);
|
var key = encrypted.Substring(0, KeyLength);
|
||||||
var cipherBytes = Convert.FromBase64String(encrypted.Substring(32));
|
var cipherBytes = Convert.FromBase64String(encrypted.Substring(KeyLength));
|
||||||
using var aes = BuildAes(key);
|
using var aes = BuildAes(key);
|
||||||
using var decryptor = aes.CreateDecryptor();
|
using var decryptor = aes.CreateDecryptor();
|
||||||
var plainBytes = decryptor.TransformFinalBlock(cipherBytes, 0, cipherBytes.Length);
|
var plainBytes = decryptor.TransformFinalBlock(cipherBytes, 0, cipherBytes.Length);
|
||||||
@@ -62,10 +68,21 @@ public static class NodeCrypto
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Configure an AES-256-CBC instance with the node's IV derivation (first 16 chars
|
/// Configure an AES-256-CBC instance with the node's IV derivation (first
|
||||||
/// of the key, UTF-8). Callers own disposal. Assumes <paramref name="key"/> is the
|
/// <see cref="IvLength"/> chars of the key, UTF-8). Callers own disposal. Assumes
|
||||||
/// 32-char ASCII key the encrypt / decrypt path has already validated.
|
/// <paramref name="key"/> is the <see cref="KeyLength"/>-char ASCII key the encrypt /
|
||||||
|
/// decrypt path has already validated.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// SECURITY (latent — do NOT "tidy" this into a cached key): the IV is derived from the key, so a
|
||||||
|
/// fixed key reuses a fixed IV — the classic CBC IV-reuse weakness (equal plaintext prefixes →
|
||||||
|
/// equal ciphertext prefixes). It is masked ONLY because every server-initiated send mints a fresh
|
||||||
|
/// key via <see cref="GenerateKey"/>, so (key, IV) never repeats in practice. A future change that
|
||||||
|
/// CACHES the session key would silently reintroduce the leak — derive a per-message random IV
|
||||||
|
/// first. Related: <see cref="GenerateKey"/> base64-truncates a hex string, so the effective key
|
||||||
|
/// entropy is well below what "AES-256" implies. We mirror the client's scheme deliberately; both
|
||||||
|
/// are acceptable only because this is a localhost relay, not a hostile-network transport.
|
||||||
|
/// </remarks>
|
||||||
private static Aes BuildAes(string key)
|
private static Aes BuildAes(string key)
|
||||||
{
|
{
|
||||||
var aes = Aes.Create();
|
var aes = Aes.Create();
|
||||||
@@ -73,7 +90,7 @@ public static class NodeCrypto
|
|||||||
aes.Mode = CipherMode.CBC;
|
aes.Mode = CipherMode.CBC;
|
||||||
aes.Padding = PaddingMode.PKCS7;
|
aes.Padding = PaddingMode.PKCS7;
|
||||||
aes.Key = Encoding.UTF8.GetBytes(key);
|
aes.Key = Encoding.UTF8.GetBytes(key);
|
||||||
aes.IV = Encoding.UTF8.GetBytes(key.Substring(0, 16));
|
aes.IV = Encoding.UTF8.GetBytes(key.Substring(0, IvLength));
|
||||||
return aes;
|
return aes;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,10 @@ public sealed class SocketIoFrame
|
|||||||
if (string.IsNullOrEmpty(raw))
|
if (string.IsNullOrEmpty(raw))
|
||||||
throw new ArgumentException("Empty SIO payload", nameof(raw));
|
throw new ArgumentException("Empty SIO payload", nameof(raw));
|
||||||
|
|
||||||
var type = (SocketIoPacketType)(raw[0] - '0');
|
var typeChar = raw[0];
|
||||||
|
if (typeChar < '0' || typeChar > '6')
|
||||||
|
throw new ArgumentException($"Invalid SIO type char '{typeChar}'", nameof(raw));
|
||||||
|
var type = (SocketIoPacketType)(typeChar - '0');
|
||||||
var cursor = 1;
|
var cursor = 1;
|
||||||
|
|
||||||
var attachmentCount = 0;
|
var attachmentCount = 0;
|
||||||
@@ -84,7 +87,9 @@ public sealed class SocketIoFrame
|
|||||||
{
|
{
|
||||||
var start = cursor;
|
var start = cursor;
|
||||||
while (cursor < raw.Length && char.IsDigit(raw[cursor])) cursor++;
|
while (cursor < raw.Length && char.IsDigit(raw[cursor])) cursor++;
|
||||||
ackId = int.Parse(raw.AsSpan(start, cursor - start));
|
if (!int.TryParse(raw.AsSpan(start, cursor - start), out var parsedAckId))
|
||||||
|
throw new ArgumentException("SIO ack-id overflows int32", nameof(raw));
|
||||||
|
ackId = parsedAckId;
|
||||||
}
|
}
|
||||||
|
|
||||||
var argsJson = cursor < raw.Length ? raw.Substring(cursor) : string.Empty;
|
var argsJson = cursor < raw.Length ? raw.Substring(cursor) : string.Empty;
|
||||||
@@ -153,10 +158,11 @@ public sealed class SocketIoFrame
|
|||||||
binaryAttachments: attachments);
|
binaryAttachments: attachments);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Build an ack response with a single int argument (the spec's pubSeq echo).</summary>
|
/// <summary>Build an ack response whose single argument echoes the inbound frame's pubSeq
|
||||||
public static SocketIoFrame AckResponse(int ackId, int arg)
|
/// (the client's ordered-delivery cursor — load-bearing, not a placeholder).</summary>
|
||||||
|
public static SocketIoFrame AckResponse(int ackId, int pubSeqEcho)
|
||||||
{
|
{
|
||||||
var args = new JsonArray { arg };
|
var args = new JsonArray { pubSeqEcho };
|
||||||
return new SocketIoFrame(
|
return new SocketIoFrame(
|
||||||
SocketIoPacketType.Ack, ackId, 0, null, NodesToElements(args), Array.Empty<byte[]>());
|
SocketIoPacketType.Ack, ackId, 0, null, NodesToElements(args), Array.Empty<byte[]>());
|
||||||
}
|
}
|
||||||
|
|||||||
24
SVSim.BattleNode/Wire/WireJsonOptions.cs
Normal file
24
SVSim.BattleNode/Wire/WireJsonOptions.cs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SVSim.BattleNode.Wire;
|
||||||
|
|
||||||
|
/// <summary>Shared System.Text.Json options for the bare-camelCase Socket.IO / Engine.IO wire:
|
||||||
|
/// per-field <c>[JsonPropertyName]</c> casing (NOT EmulatedEntrypoint's snake_case policy), null
|
||||||
|
/// fields omitted, and unattributed enums written as their name. Single-sourced here because
|
||||||
|
/// <see cref="EngineIoHandshake"/> and <see cref="Protocol.MsgEnvelope"/> previously each built a
|
||||||
|
/// byte-identical block in their own namespace — a drift hazard.</summary>
|
||||||
|
internal static class WireJsonOptions
|
||||||
|
{
|
||||||
|
public static readonly JsonSerializerOptions CamelCase = Create();
|
||||||
|
|
||||||
|
private static JsonSerializerOptions Create()
|
||||||
|
{
|
||||||
|
var opt = new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
};
|
||||||
|
opt.Converters.Add(new JsonStringEnumConverter());
|
||||||
|
return opt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -162,7 +162,8 @@ public sealed class RankBattleController : ControllerBase
|
|||||||
}
|
}
|
||||||
var selfCtx = pending.P1.Context;
|
var selfCtx = pending.P1.Context;
|
||||||
|
|
||||||
var bot = await _botRoster.PickAsync(selfCtx, ct);
|
var bot = await _botRoster.PickAsync(selfCtx, pending.BattleId, ct);
|
||||||
|
var seed = Random.Shared.Next();
|
||||||
|
|
||||||
// Per spec, ai-start.md TODO: turnState semantics unclear. Default 0 (player first).
|
// Per spec, ai-start.md TODO: turnState semantics unclear. Default 0 (player first).
|
||||||
return Ok(new AiBattleStartResponseDto
|
return Ok(new AiBattleStartResponseDto
|
||||||
@@ -179,10 +180,10 @@ public sealed class RankBattleController : ControllerBase
|
|||||||
FieldId = selfCtx.FieldId,
|
FieldId = selfCtx.FieldId,
|
||||||
IsOfficial = selfCtx.IsOfficial,
|
IsOfficial = selfCtx.IsOfficial,
|
||||||
OppoId = bot.AiId,
|
OppoId = bot.AiId,
|
||||||
Seed = 0,
|
Seed = seed,
|
||||||
Rank = 0,
|
Rank = 0,
|
||||||
BattlePoint = 0,
|
BattlePoint = 0,
|
||||||
ClassId = int.TryParse(selfCtx.ClassId, out var cId) ? cId : -1,
|
ClassId = (int)selfCtx.ClassId,
|
||||||
CharaId = int.TryParse(selfCtx.CharaId, out var chId) ? chId : -1,
|
CharaId = int.TryParse(selfCtx.CharaId, out var chId) ? chId : -1,
|
||||||
IsMasterRank = 0,
|
IsMasterRank = 0,
|
||||||
MasterPoint = 0,
|
MasterPoint = 0,
|
||||||
@@ -197,7 +198,7 @@ public sealed class RankBattleController : ControllerBase
|
|||||||
FieldId = bot.FieldId,
|
FieldId = bot.FieldId,
|
||||||
IsOfficial = bot.IsOfficial,
|
IsOfficial = bot.IsOfficial,
|
||||||
OppoId = (int)vid,
|
OppoId = (int)vid,
|
||||||
Seed = 0,
|
Seed = seed,
|
||||||
Rank = bot.Rank,
|
Rank = bot.Rank,
|
||||||
BattlePoint = bot.BattlePoint,
|
BattlePoint = bot.BattlePoint,
|
||||||
ClassId = bot.ClassId,
|
ClassId = bot.ClassId,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ public sealed class BotRoster : IBotRoster
|
|||||||
_globals = globals;
|
_globals = globals;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<AIBotProfile> PickAsync(MatchContext selfCtx, CancellationToken ct = default)
|
public async Task<AIBotProfile> PickAsync(MatchContext selfCtx, string battleId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var roster = await _globals.GetBotRoster();
|
var roster = await _globals.GetBotRoster();
|
||||||
if (roster.Count == 0)
|
if (roster.Count == 0)
|
||||||
@@ -27,11 +27,9 @@ public sealed class BotRoster : IBotRoster
|
|||||||
"BotRoster is empty. Run SVSim.Bootstrap to import seeds/bot-roster.json.");
|
"BotRoster is empty. Run SVSim.Bootstrap to import seeds/bot-roster.json.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deterministic: hash the ctx and pick from the roster. Same ctx →
|
// Deterministic per battle ID: same pending battle → same bot on retry,
|
||||||
// same bot so a mid-flight retry of /ai_<fmt>/start returns the same
|
// but different battles get different opponents.
|
||||||
// opponent (no fresh roster pick on each call).
|
var hash = StringComparer.Ordinal.GetHashCode(battleId);
|
||||||
var hash = StringComparer.Ordinal.GetHashCode(selfCtx.UserName)
|
|
||||||
^ StringComparer.Ordinal.GetHashCode(selfCtx.ClassId);
|
|
||||||
var index = (int)((uint)hash % roster.Count);
|
var index = (int)((uint)hash % roster.Count);
|
||||||
var row = roster[index];
|
var row = roster[index];
|
||||||
return ToProfile(row);
|
return ToProfile(row);
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ namespace SVSim.EmulatedEntrypoint.Matching;
|
|||||||
public interface IBotRoster
|
public interface IBotRoster
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns a bot profile for the calling viewer. Deterministic per
|
/// Returns a bot profile. Deterministic per <paramref name="battleId"/> so a
|
||||||
/// <see cref="MatchContext"/> — the same context value returns the same bot, so a
|
/// mid-flight retry of <c>/ai_<fmt>/start</c> picks the same opponent,
|
||||||
/// mid-flight retry of <c>/ai_<fmt>/start</c> picks the same opponent.
|
/// but different battles get different bots.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<AIBotProfile> PickAsync(MatchContext selfCtx, CancellationToken ct = default);
|
Task<AIBotProfile> PickAsync(MatchContext selfCtx, string battleId, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ public class MatchContextBuilder : IMatchContextBuilder
|
|||||||
|
|
||||||
return new MatchContext(
|
return new MatchContext(
|
||||||
SelfDeckCardIds: deck,
|
SelfDeckCardIds: deck,
|
||||||
ClassId: run.ClassId.ToString(),
|
ClassId: (CardClass)run.ClassId,
|
||||||
CharaId: charaId,
|
CharaId: charaId,
|
||||||
// Hardcoded v1; see spec §Deferred plumbing.
|
// Hardcoded v1; see spec §Deferred plumbing.
|
||||||
CardMasterName: "card_master_node_10015",
|
CardMasterName: "card_master_node_10015",
|
||||||
@@ -67,7 +67,7 @@ public class MatchContextBuilder : IMatchContextBuilder
|
|||||||
// Hardcoded v1; needs equipped-MyPageBackground lookup (see spec §Deferred).
|
// Hardcoded v1; needs equipped-MyPageBackground lookup (see spec §Deferred).
|
||||||
FieldId: 43,
|
FieldId: 43,
|
||||||
IsOfficial: viewer.Info.IsOfficial ? 1 : 0,
|
IsOfficial: viewer.Info.IsOfficial ? 1 : 0,
|
||||||
BattleType: 11);
|
BattleModeId: BattleModes.TakeTwo);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<MatchContext> BuildForRankBattleAsync(long viewerId, Format format, int deckNo)
|
public async Task<MatchContext> BuildForRankBattleAsync(long viewerId, Format format, int deckNo)
|
||||||
@@ -95,11 +95,16 @@ public class MatchContextBuilder : IMatchContextBuilder
|
|||||||
var sleeveId = deck.Sleeve.Id != 0
|
var sleeveId = deck.Sleeve.Id != 0
|
||||||
? deck.Sleeve.Id.ToString()
|
? deck.Sleeve.Id.ToString()
|
||||||
: defaults.SleeveId.ToString();
|
: defaults.SleeveId.ToString();
|
||||||
var deckCardIds = deck.Cards.Select(c => c.Card.Id).ToList();
|
// DeckCard is count-based (one row per unique card + a Count). The node's deck
|
||||||
|
// is one entry PER PHYSICAL CARD (idx 1..N), so expand each row by its Count —
|
||||||
|
// otherwise a 3-copy card ships as a single in-battle card.
|
||||||
|
var deckCardIds = deck.Cards
|
||||||
|
.SelectMany(c => Enumerable.Repeat(c.Card.Id, c.Count))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
return new MatchContext(
|
return new MatchContext(
|
||||||
SelfDeckCardIds: deckCardIds,
|
SelfDeckCardIds: deckCardIds,
|
||||||
ClassId: deck.Class.Id.ToString(),
|
ClassId: (CardClass)deck.Class.Id,
|
||||||
CharaId: charaId,
|
CharaId: charaId,
|
||||||
CardMasterName: "card_master_node_10015",
|
CardMasterName: "card_master_node_10015",
|
||||||
CountryCode: viewer.Info.CountryCode ?? string.Empty,
|
CountryCode: viewer.Info.CountryCode ?? string.Empty,
|
||||||
@@ -109,6 +114,6 @@ public class MatchContextBuilder : IMatchContextBuilder
|
|||||||
DegreeId: degreeId,
|
DegreeId: degreeId,
|
||||||
FieldId: 43,
|
FieldId: 43,
|
||||||
IsOfficial: viewer.Info.IsOfficial ? 1 : 0,
|
IsOfficial: viewer.Info.IsOfficial ? 1 : 0,
|
||||||
BattleType: 11);
|
BattleModeId: BattleModes.TakeTwo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,10 +76,25 @@ public class MatchingBridgeTests
|
|||||||
new BattlePlayer(1, FixtureCtx()), new BattlePlayer(2, FixtureCtx()), BattleType.Bot));
|
new BattlePlayer(1, FixtureCtx()), new BattlePlayer(2, FixtureCtx()), BattleType.Bot));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void RegisterBattle_evicts_stale_pending_for_same_viewer()
|
||||||
|
{
|
||||||
|
var store = new InMemoryBattleSessionStore();
|
||||||
|
var bridge = new MatchingBridge(store, new BattleNodeOptions());
|
||||||
|
var p1 = new BattlePlayer(42, FixtureCtx());
|
||||||
|
|
||||||
|
var first = bridge.RegisterBattle(p1, p2: null, BattleType.Bot);
|
||||||
|
Assert.That(store.TryGetPending(first.BattleId), Is.Not.Null);
|
||||||
|
|
||||||
|
var second = bridge.RegisterBattle(p1, p2: null, BattleType.Bot);
|
||||||
|
Assert.That(store.TryGetPending(first.BattleId), Is.Null, "stale entry must be evicted");
|
||||||
|
Assert.That(store.TryGetPending(second.BattleId), Is.Not.Null);
|
||||||
|
}
|
||||||
|
|
||||||
private static MatchContext FixtureCtx() => new(
|
private static MatchContext FixtureCtx() => new(
|
||||||
SelfDeckCardIds: Enumerable.Range(1, 30).Select(i => 100_011_010L).ToList(),
|
SelfDeckCardIds: Enumerable.Range(1, 30).Select(i => 100_011_010L).ToList(),
|
||||||
ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015",
|
ClassId: CardClass.Forestcraft, CharaId: "1", CardMasterName: "card_master_node_10015",
|
||||||
CountryCode: "KOR", UserName: "Player", SleeveId: "3000011",
|
CountryCode: CountryCodes.Korea, UserName: "Player", SleeveId: "3000011",
|
||||||
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
|
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
|
||||||
BattleType: 11);
|
BattleModeId: BattleModes.TakeTwo);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,10 +69,10 @@ public class WaitingRoomTests
|
|||||||
var ws = new TestWebSocket();
|
var ws = new TestWebSocket();
|
||||||
var ctx = new MatchContext(
|
var ctx = new MatchContext(
|
||||||
SelfDeckCardIds: Array.Empty<long>(),
|
SelfDeckCardIds: Array.Empty<long>(),
|
||||||
ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015",
|
ClassId: CardClass.Forestcraft, CharaId: "1", CardMasterName: "card_master_node_10015",
|
||||||
CountryCode: "KOR", UserName: "Player", SleeveId: "0",
|
CountryCode: CountryCodes.Korea, UserName: "Player", SleeveId: "0",
|
||||||
EmblemId: "0", DegreeId: "0", FieldId: 0, IsOfficial: 0,
|
EmblemId: "0", DegreeId: "0", FieldId: 0, IsOfficial: 0,
|
||||||
BattleType: 11);
|
BattleModeId: BattleModes.TakeTwo);
|
||||||
return new RealParticipant(ws, viewerId, ctx, NullLogger<RealParticipant>.Instance);
|
return new RealParticipant(ws, viewerId, ctx, NullLogger<RealParticipant>.Instance);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ public class BattleNodeFlowTests
|
|||||||
|
|
||||||
internal static MatchContext FixtureCtx(IReadOnlyList<long>? deck = null) => new(
|
internal static MatchContext FixtureCtx(IReadOnlyList<long>? deck = null) => new(
|
||||||
SelfDeckCardIds: deck ?? Enumerable.Range(1, 30).Select(i => 100_011_010L).ToList(),
|
SelfDeckCardIds: deck ?? Enumerable.Range(1, 30).Select(i => 100_011_010L).ToList(),
|
||||||
ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015",
|
ClassId: CardClass.Forestcraft, CharaId: "1", CardMasterName: "card_master_node_10015",
|
||||||
CountryCode: "KOR", UserName: "Player", SleeveId: "3000011",
|
CountryCode: CountryCodes.Korea, UserName: "Player", SleeveId: "3000011",
|
||||||
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
|
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
|
||||||
BattleType: 11);
|
BattleModeId: BattleModes.TakeTwo);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// End-to-end: a viewer with a real TK2 run sees their drafted card-ids in the Matched
|
/// End-to-end: a viewer with a real TK2 run sees their drafted card-ids in the Matched
|
||||||
@@ -98,19 +98,28 @@ public class BattleNodeFlowTests
|
|||||||
var body = ((RawBody)matched.Body).Entries;
|
var body = ((RawBody)matched.Body).Entries;
|
||||||
var selfDeck = (List<object?>)body["selfDeck"]!;
|
var selfDeck = (List<object?>)body["selfDeck"]!;
|
||||||
Assert.That(selfDeck.Count, Is.EqualTo(30));
|
Assert.That(selfDeck.Count, Is.EqualTo(30));
|
||||||
for (int i = 0; i < 30; i++)
|
|
||||||
|
// The node shuffles each deck per-battle from the master seed (see BattleSeeds /
|
||||||
|
// BattleSessionState.GetShuffledDeck), so cardIds are no longer in drafted order. What must
|
||||||
|
// hold: idxs are the contiguous 1..30 positions, and the set of cardIds is exactly the
|
||||||
|
// drafted deck (a permutation — same multiset, reordered).
|
||||||
|
var idxs = new List<long>(30);
|
||||||
|
var cardIds = new List<long>(30);
|
||||||
|
foreach (var e in selfDeck)
|
||||||
{
|
{
|
||||||
var entry = (Dictionary<string, object?>)selfDeck[i]!;
|
var entry = (Dictionary<string, object?>)e!;
|
||||||
Assert.That((long)entry["idx"]!, Is.EqualTo(i + 1L),
|
idxs.Add((long)entry["idx"]!);
|
||||||
$"slot {i}: idx should be 1-based position");
|
cardIds.Add((long)entry["cardId"]!);
|
||||||
Assert.That((long)entry["cardId"]!, Is.EqualTo(draftedDeck[i]),
|
|
||||||
$"slot {i}: cardId should match the drafted card");
|
|
||||||
}
|
}
|
||||||
|
Assert.That(idxs, Is.EqualTo(Enumerable.Range(1, 30).Select(i => (long)i)),
|
||||||
|
"idxs are the contiguous 1-based positions 1..30");
|
||||||
|
Assert.That(cardIds, Is.EquivalentTo(draftedDeck),
|
||||||
|
"selfDeck is a permutation of the drafted deck (shuffled, same multiset)");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static MsgEnvelope MakeEnvelopeWith(long vid, NetworkBattleUri uri, long pubSeq,
|
private static MsgEnvelope MakeEnvelopeWith(long vid, NetworkBattleUri uri, long pubSeq,
|
||||||
Dictionary<string, object?>? body = null) =>
|
Dictionary<string, object?>? body = null) =>
|
||||||
new(uri, ViewerId: vid, Uuid: "udid-test", Bid: null, Try: 0,
|
new(uri, ViewerId: vid, Uuid: "udid-test", Bid: null, RetryAttempt: 0,
|
||||||
Cat: uri == NetworkBattleUri.InitNetwork ? EmitCategory.General
|
Cat: uri == NetworkBattleUri.InitNetwork ? EmitCategory.General
|
||||||
: uri == NetworkBattleUri.InitBattle ? EmitCategory.Matching
|
: uri == NetworkBattleUri.InitBattle ? EmitCategory.Matching
|
||||||
: EmitCategory.Battle,
|
: EmitCategory.Battle,
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ public class CaptureConformanceTests
|
|||||||
|
|
||||||
private static MsgEnvelope MakeEnvelope(long vid, NetworkBattleUri uri, long pubSeq,
|
private static MsgEnvelope MakeEnvelope(long vid, NetworkBattleUri uri, long pubSeq,
|
||||||
Dictionary<string, object?>? body = null) =>
|
Dictionary<string, object?>? body = null) =>
|
||||||
new(uri, ViewerId: vid, Uuid: "udid-test", Bid: null, Try: 0,
|
new(uri, ViewerId: vid, Uuid: "udid-test", Bid: null, RetryAttempt: 0,
|
||||||
Cat: uri == NetworkBattleUri.InitNetwork ? EmitCategory.General
|
Cat: uri == NetworkBattleUri.InitNetwork ? EmitCategory.General
|
||||||
: uri == NetworkBattleUri.InitBattle ? EmitCategory.Matching
|
: uri == NetworkBattleUri.InitBattle ? EmitCategory.Matching
|
||||||
: EmitCategory.Battle,
|
: EmitCategory.Battle,
|
||||||
@@ -287,7 +287,7 @@ public class CaptureConformanceTests
|
|||||||
|
|
||||||
var body = new SVSim.BattleNode.Protocol.Bodies.PlayActionsBroadcastBody(
|
var body = new SVSim.BattleNode.Protocol.Bodies.PlayActionsBroadcastBody(
|
||||||
PlayIdx: 17, Type: 30, KnownList: new[] { entry! }, OppoTargetList: null);
|
PlayIdx: 17, Type: 30, KnownList: new[] { entry! }, OppoTargetList: null);
|
||||||
var env = new MsgEnvelope(NetworkBattleUri.PlayActions, ViewerId: 1, Uuid: "u", Bid: null, Try: 0,
|
var env = new MsgEnvelope(NetworkBattleUri.PlayActions, ViewerId: 1, Uuid: "u", Bid: null, RetryAttempt: 0,
|
||||||
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Body: body);
|
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Body: body);
|
||||||
|
|
||||||
using var ourDoc = JsonDocument.Parse(MsgEnvelope.ToJson(env));
|
using var ourDoc = JsonDocument.Parse(MsgEnvelope.ToJson(env));
|
||||||
@@ -335,7 +335,7 @@ public class CaptureConformanceTests
|
|||||||
|
|
||||||
var body = new SVSim.BattleNode.Protocol.Bodies.PlayActionsBroadcastBody(
|
var body = new SVSim.BattleNode.Protocol.Bodies.PlayActionsBroadcastBody(
|
||||||
PlayIdx: 38, Type: 30, KnownList: new[] { entry! }, OppoTargetList: null);
|
PlayIdx: 38, Type: 30, KnownList: new[] { entry! }, OppoTargetList: null);
|
||||||
var env = new MsgEnvelope(NetworkBattleUri.PlayActions, ViewerId: 1, Uuid: "u", Bid: null, Try: 0,
|
var env = new MsgEnvelope(NetworkBattleUri.PlayActions, ViewerId: 1, Uuid: "u", Bid: null, RetryAttempt: 0,
|
||||||
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Body: body);
|
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Body: body);
|
||||||
|
|
||||||
using var ourDoc = JsonDocument.Parse(MsgEnvelope.ToJson(env));
|
using var ourDoc = JsonDocument.Parse(MsgEnvelope.ToJson(env));
|
||||||
@@ -376,7 +376,7 @@ public class CaptureConformanceTests
|
|||||||
|
|
||||||
var body = new SVSim.BattleNode.Protocol.Bodies.PlayActionsBroadcastBody(
|
var body = new SVSim.BattleNode.Protocol.Bodies.PlayActionsBroadcastBody(
|
||||||
PlayIdx: 37, Type: 30, KnownList: null, OppoTargetList: null, UList: relayed);
|
PlayIdx: 37, Type: 30, KnownList: null, OppoTargetList: null, UList: relayed);
|
||||||
var env = new MsgEnvelope(NetworkBattleUri.PlayActions, ViewerId: 1, Uuid: "u", Bid: null, Try: 0,
|
var env = new MsgEnvelope(NetworkBattleUri.PlayActions, ViewerId: 1, Uuid: "u", Bid: null, RetryAttempt: 0,
|
||||||
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Body: body);
|
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Body: body);
|
||||||
|
|
||||||
using var ourDoc = JsonDocument.Parse(MsgEnvelope.ToJson(env));
|
using var ourDoc = JsonDocument.Parse(MsgEnvelope.ToJson(env));
|
||||||
@@ -414,7 +414,7 @@ public class CaptureConformanceTests
|
|||||||
.MineCopyTokens(orderList, new Dictionary<int, long>(), otherMap)
|
.MineCopyTokens(orderList, new Dictionary<int, long>(), otherMap)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
Assert.That(mined, Is.EquivalentTo(new[] { (49, 123_456_789L, 0) }));
|
Assert.That(mined, Is.EquivalentTo(new[] { new SVSim.BattleNode.Sessions.Dispatch.MinedToken(49, 123_456_789L, CardOwner.Opponent) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -455,7 +455,7 @@ public class CaptureConformanceTests
|
|||||||
var keyActionOut = SVSim.BattleNode.Sessions.Dispatch.KnownListBuilder.StripKeyActionForOpponent(keyActionIn);
|
var keyActionOut = SVSim.BattleNode.Sessions.Dispatch.KnownListBuilder.StripKeyActionForOpponent(keyActionIn);
|
||||||
var body = new SVSim.BattleNode.Protocol.Bodies.PlayActionsBroadcastBody(
|
var body = new SVSim.BattleNode.Protocol.Bodies.PlayActionsBroadcastBody(
|
||||||
PlayIdx: 18, Type: 30, KnownList: new[] { played! }, OppoTargetList: null, KeyAction: keyActionOut);
|
PlayIdx: 18, Type: 30, KnownList: new[] { played! }, OppoTargetList: null, KeyAction: keyActionOut);
|
||||||
var env = new MsgEnvelope(NetworkBattleUri.PlayActions, ViewerId: 1, Uuid: "u", Bid: null, Try: 0,
|
var env = new MsgEnvelope(NetworkBattleUri.PlayActions, ViewerId: 1, Uuid: "u", Bid: null, RetryAttempt: 0,
|
||||||
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Body: body);
|
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Body: body);
|
||||||
|
|
||||||
using var ourDoc = JsonDocument.Parse(MsgEnvelope.ToJson(env));
|
using var ourDoc = JsonDocument.Parse(MsgEnvelope.ToJson(env));
|
||||||
@@ -520,7 +520,7 @@ public class CaptureConformanceTests
|
|||||||
|
|
||||||
var body = new SVSim.BattleNode.Protocol.Bodies.PlayActionsBroadcastBody(
|
var body = new SVSim.BattleNode.Protocol.Bodies.PlayActionsBroadcastBody(
|
||||||
PlayIdx: 46, Type: 30, KnownList: new[] { entry! }, OppoTargetList: null);
|
PlayIdx: 46, Type: 30, KnownList: new[] { entry! }, OppoTargetList: null);
|
||||||
var env = new MsgEnvelope(NetworkBattleUri.PlayActions, ViewerId: 1, Uuid: "u", Bid: null, Try: 0,
|
var env = new MsgEnvelope(NetworkBattleUri.PlayActions, ViewerId: 1, Uuid: "u", Bid: null, RetryAttempt: 0,
|
||||||
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Body: body);
|
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Body: body);
|
||||||
|
|
||||||
using var ourDoc = JsonDocument.Parse(MsgEnvelope.ToJson(env));
|
using var ourDoc = JsonDocument.Parse(MsgEnvelope.ToJson(env));
|
||||||
|
|||||||
46
SVSim.UnitTests/BattleNode/Lifecycle/BattleSeedsTests.cs
Normal file
46
SVSim.UnitTests/BattleNode/Lifecycle/BattleSeedsTests.cs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
using NUnit.Framework;
|
||||||
|
using SVSim.BattleNode.Lifecycle;
|
||||||
|
|
||||||
|
namespace SVSim.UnitTests.BattleNode.Lifecycle;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
public class BattleSeedsTests
|
||||||
|
{
|
||||||
|
// Golden values pin cross-run/cross-platform stability. They were computed from the exact
|
||||||
|
// splitmix64 mix specified in BattleSeeds. If these ever change, replay reproducibility broke —
|
||||||
|
// do NOT "update them to match"; find what changed the algorithm (e.g. someone slipped in
|
||||||
|
// GetHashCode, which is per-process randomized).
|
||||||
|
[Test]
|
||||||
|
public void Derive_golden_values_are_stable()
|
||||||
|
{
|
||||||
|
Assert.That(BattleSeeds.Stable(12345), Is.EqualTo(1577307848));
|
||||||
|
Assert.That(BattleSeeds.IdxChange(12345, 906243102), Is.EqualTo(1638231407));
|
||||||
|
Assert.That(BattleSeeds.DeckShuffle(12345, 906243102), Is.EqualTo(355953180));
|
||||||
|
Assert.That(BattleSeeds.IdxChange(12345, 847666884), Is.EqualTo(518125159));
|
||||||
|
Assert.That(BattleSeeds.Stable(99999), Is.EqualTo(323349150));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Derive_is_deterministic_for_same_inputs()
|
||||||
|
{
|
||||||
|
Assert.That(BattleSeeds.Derive(7, "x", 42), Is.EqualTo(BattleSeeds.Derive(7, "x", 42)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Derive_differs_across_tag_master_and_discriminator()
|
||||||
|
{
|
||||||
|
var baseline = BattleSeeds.Derive(7, "x", 42);
|
||||||
|
Assert.That(BattleSeeds.Derive(8, "x", 42), Is.Not.EqualTo(baseline), "different master");
|
||||||
|
Assert.That(BattleSeeds.Derive(7, "y", 42), Is.Not.EqualTo(baseline), "different tag");
|
||||||
|
Assert.That(BattleSeeds.Derive(7, "x", 43), Is.Not.EqualTo(baseline), "different disc");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Derive_is_always_non_negative()
|
||||||
|
{
|
||||||
|
// System.Random tolerates any int, but a non-negative seed keeps parity with prod's
|
||||||
|
// positive seed values and avoids surprises.
|
||||||
|
Assert.That(BattleSeeds.Stable(int.MinValue), Is.GreaterThanOrEqualTo(0));
|
||||||
|
Assert.That(BattleSeeds.Stable(-1), Is.GreaterThanOrEqualTo(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,19 +14,19 @@ public class ServerBattleFramesTests
|
|||||||
{
|
{
|
||||||
var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(),
|
var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(),
|
||||||
selfViewerId: 906243102, oppoViewerId: 847666884,
|
selfViewerId: 906243102, oppoViewerId: 847666884,
|
||||||
battleId: "b", seed: BattleFrameDefaults.BattleSeed);
|
battleId: "b", seed: 17_548_138, selfDeckOrder: FixtureCtx().SelfDeckCardIds);
|
||||||
|
|
||||||
Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.Matched));
|
Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.Matched));
|
||||||
var body = (MatchedBody)env.Body;
|
var body = (MatchedBody)env.Body;
|
||||||
Assert.That(body.SelfInfo.OppoId, Is.EqualTo(847666884L));
|
Assert.That(body.SelfInfo.OppoId, Is.EqualTo(847666884));
|
||||||
Assert.That(body.OppoInfo.OppoId, Is.EqualTo(906243102L));
|
Assert.That(body.OppoInfo.OppoId, Is.EqualTo(906243102));
|
||||||
Assert.That(env.Bid, Is.EqualTo("b"));
|
Assert.That(env.Bid, Is.EqualTo("b"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void BuildMatched_ContainsThirtyCardSelfDeck()
|
public void BuildMatched_ContainsThirtyCardSelfDeck()
|
||||||
{
|
{
|
||||||
var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(), 1, 2, "b", BattleFrameDefaults.BattleSeed);
|
var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(), 1, 2, "b", 17_548_138, FixtureCtx().SelfDeckCardIds);
|
||||||
var body = (MatchedBody)env.Body;
|
var body = (MatchedBody)env.Body;
|
||||||
Assert.That(body.SelfDeck.Count, Is.EqualTo(30));
|
Assert.That(body.SelfDeck.Count, Is.EqualTo(30));
|
||||||
}
|
}
|
||||||
@@ -35,7 +35,7 @@ public class ServerBattleFramesTests
|
|||||||
public void BuildMatched_deck_idxs_pair_1to30_with_context_card_ids()
|
public void BuildMatched_deck_idxs_pair_1to30_with_context_card_ids()
|
||||||
{
|
{
|
||||||
var draftedDeck = Enumerable.Range(1, 30).Select(i => 200_000_000L + i).ToList();
|
var draftedDeck = Enumerable.Range(1, 30).Select(i => 200_000_000L + i).ToList();
|
||||||
var env = ServerBattleFrames.BuildMatched(FixtureCtx(draftedDeck), FakeOpponentCtx(), 1, 2, "b", BattleFrameDefaults.BattleSeed);
|
var env = ServerBattleFrames.BuildMatched(FixtureCtx(draftedDeck), FakeOpponentCtx(), 1, 2, "b", 17_548_138, draftedDeck);
|
||||||
var body = (MatchedBody)env.Body;
|
var body = (MatchedBody)env.Body;
|
||||||
|
|
||||||
for (int i = 0; i < 30; i++)
|
for (int i = 0; i < 30; i++)
|
||||||
@@ -56,7 +56,7 @@ public class ServerBattleFramesTests
|
|||||||
EmblemId = "888", DegreeId = "777", FieldId = 42, IsOfficial = 1,
|
EmblemId = "888", DegreeId = "777", FieldId = 42, IsOfficial = 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
var env = ServerBattleFrames.BuildMatched(ctx, FakeOpponentCtx(), 1, 2, "b", BattleFrameDefaults.BattleSeed);
|
var env = ServerBattleFrames.BuildMatched(ctx, FakeOpponentCtx(), 1, 2, "b", 17_548_138, ctx.SelfDeckCardIds);
|
||||||
var body = (MatchedBody)env.Body;
|
var body = (MatchedBody)env.Body;
|
||||||
|
|
||||||
Assert.That(body.SelfInfo.CountryCode, Is.EqualTo("JPN"));
|
Assert.That(body.SelfInfo.CountryCode, Is.EqualTo("JPN"));
|
||||||
@@ -65,35 +65,35 @@ public class ServerBattleFramesTests
|
|||||||
Assert.That(body.SelfInfo.EmblemId, Is.EqualTo("888"));
|
Assert.That(body.SelfInfo.EmblemId, Is.EqualTo("888"));
|
||||||
Assert.That(body.SelfInfo.DegreeId, Is.EqualTo("777"));
|
Assert.That(body.SelfInfo.DegreeId, Is.EqualTo("777"));
|
||||||
Assert.That(body.SelfInfo.FieldId, Is.EqualTo(42));
|
Assert.That(body.SelfInfo.FieldId, Is.EqualTo(42));
|
||||||
Assert.That(body.SelfInfo.IsOfficial, Is.EqualTo(1));
|
Assert.That(body.SelfInfo.IsOfficial, Is.True);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void BuildBattleStart_HasTurnStateZero_AndUsesContextBattleType()
|
public void BuildBattleStart_HasTurnStateZero_AndUsesContextBattleModeId()
|
||||||
{
|
{
|
||||||
var env = ServerBattleFrames.BuildBattleStart(FixtureCtx(), FakeOpponentCtx(), selfViewerId: 1, turnState: 0);
|
var env = ServerBattleFrames.BuildBattleStart(FixtureCtx(), FakeOpponentCtx(), selfViewerId: 1, turnState: TurnState.First);
|
||||||
var body = (BattleStartBody)env.Body;
|
var body = (BattleStartBody)env.Body;
|
||||||
Assert.That(body.TurnState, Is.EqualTo(0));
|
Assert.That(body.TurnState, Is.EqualTo(TurnState.First));
|
||||||
Assert.That(body.BattleType, Is.EqualTo(11));
|
Assert.That(body.BattleModeId, Is.EqualTo(BattleModes.TakeTwo));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void BuildBattleStart_class_chara_cardMaster_battleType_flow_from_context()
|
public void BuildBattleStart_class_chara_cardMaster_battleModeId_flow_from_context()
|
||||||
{
|
{
|
||||||
var ctx = FixtureCtx() with
|
var ctx = FixtureCtx() with
|
||||||
{
|
{
|
||||||
ClassId = "7", CharaId = "5000123",
|
ClassId = CardClass.Havencraft, CharaId = "5000123",
|
||||||
CardMasterName = "card_master_test_v2",
|
CardMasterName = "card_master_test_v2",
|
||||||
BattleType = 42,
|
BattleModeId = 42,
|
||||||
};
|
};
|
||||||
|
|
||||||
var env = ServerBattleFrames.BuildBattleStart(ctx, FakeOpponentCtx(), selfViewerId: 1, turnState: 0);
|
var env = ServerBattleFrames.BuildBattleStart(ctx, FakeOpponentCtx(), selfViewerId: 1, turnState: TurnState.First);
|
||||||
var body = (BattleStartBody)env.Body;
|
var body = (BattleStartBody)env.Body;
|
||||||
|
|
||||||
Assert.That(body.SelfInfo.ClassId, Is.EqualTo("7"));
|
Assert.That(body.SelfInfo.ClassId, Is.EqualTo("7"));
|
||||||
Assert.That(body.SelfInfo.CharaId, Is.EqualTo("5000123"));
|
Assert.That(body.SelfInfo.CharaId, Is.EqualTo("5000123"));
|
||||||
Assert.That(body.SelfInfo.CardMasterName, Is.EqualTo("card_master_test_v2"));
|
Assert.That(body.SelfInfo.CardMasterName, Is.EqualTo("card_master_test_v2"));
|
||||||
Assert.That(body.BattleType, Is.EqualTo(42));
|
Assert.That(body.BattleModeId, Is.EqualTo(42));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -136,11 +136,11 @@ public class ServerBattleFramesTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void BuildReady_IncludesIdxChangeSeedAndSpin_AndUsesGivenHand()
|
public void BuildReady_IncludesGivenIdxChangeSeedAndSpin_AndUsesGivenHand()
|
||||||
{
|
{
|
||||||
var env = ServerBattleFrames.BuildReady(new long[] { 1, 4, 3 });
|
var env = ServerBattleFrames.BuildReady(new long[] { 1, 4, 3 }, idxChangeSeed: 555_000);
|
||||||
var body = (ReadyBody)env.Body;
|
var body = (ReadyBody)env.Body;
|
||||||
Assert.That(body.IdxChangeSeed, Is.EqualTo(771_335_280));
|
Assert.That(body.IdxChangeSeed, Is.EqualTo(555_000));
|
||||||
Assert.That(body.Spin, Is.EqualTo(243));
|
Assert.That(body.Spin, Is.EqualTo(243));
|
||||||
Assert.That(body.Self[1].Idx, Is.EqualTo(4));
|
Assert.That(body.Self[1].Idx, Is.EqualTo(4));
|
||||||
}
|
}
|
||||||
@@ -148,7 +148,7 @@ public class ServerBattleFramesTests
|
|||||||
[Test]
|
[Test]
|
||||||
public void BuildReady_two_arg_sets_oppo_to_supplied_hand()
|
public void BuildReady_two_arg_sets_oppo_to_supplied_hand()
|
||||||
{
|
{
|
||||||
var env = ServerBattleFrames.BuildReady(new long[] { 1, 4, 3 }, new long[] { 1, 2, 6 });
|
var env = ServerBattleFrames.BuildReady(new long[] { 1, 4, 3 }, new long[] { 1, 2, 6 }, idxChangeSeed: 555_000);
|
||||||
var body = (ReadyBody)env.Body;
|
var body = (ReadyBody)env.Body;
|
||||||
|
|
||||||
Assert.That(body.Self.Select(p => p.Idx), Is.EqualTo(new[] { 1, 4, 3 }));
|
Assert.That(body.Self.Select(p => p.Idx), Is.EqualTo(new[] { 1, 4, 3 }));
|
||||||
@@ -159,7 +159,7 @@ public class ServerBattleFramesTests
|
|||||||
[Test]
|
[Test]
|
||||||
public void BuildReady_one_arg_defaults_oppo_to_InitialHand()
|
public void BuildReady_one_arg_defaults_oppo_to_InitialHand()
|
||||||
{
|
{
|
||||||
var env = ServerBattleFrames.BuildReady(new long[] { 1, 4, 3 });
|
var env = ServerBattleFrames.BuildReady(new long[] { 1, 4, 3 }, idxChangeSeed: 555_000);
|
||||||
var body = (ReadyBody)env.Body;
|
var body = (ReadyBody)env.Body;
|
||||||
|
|
||||||
Assert.That(body.Oppo.Select(p => p.Idx), Is.EqualTo(new[] { 1, 2, 3 }),
|
Assert.That(body.Oppo.Select(p => p.Idx), Is.EqualTo(new[] { 1, 2, 3 }),
|
||||||
@@ -168,17 +168,17 @@ public class ServerBattleFramesTests
|
|||||||
|
|
||||||
private static MatchContext FixtureCtx(IReadOnlyList<long>? deck = null) => new(
|
private static MatchContext FixtureCtx(IReadOnlyList<long>? deck = null) => new(
|
||||||
SelfDeckCardIds: deck ?? Enumerable.Range(1, 30).Select(i => 100_011_010L).ToList(),
|
SelfDeckCardIds: deck ?? Enumerable.Range(1, 30).Select(i => 100_011_010L).ToList(),
|
||||||
ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015",
|
ClassId: CardClass.Forestcraft, CharaId: "1", CardMasterName: "card_master_node_10015",
|
||||||
CountryCode: "KOR", UserName: "Player", SleeveId: "3000011",
|
CountryCode: CountryCodes.Korea, UserName: "Player", SleeveId: "3000011",
|
||||||
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
|
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
|
||||||
BattleType: 11);
|
BattleModeId: BattleModes.TakeTwo);
|
||||||
|
|
||||||
// A prod-captured opponent MatchContext fixture that the BuildMatched/BuildBattleStart
|
// A prod-captured opponent MatchContext fixture that the BuildMatched/BuildBattleStart
|
||||||
// helpers read from for the oppo half.
|
// helpers read from for the oppo half.
|
||||||
private static MatchContext FakeOpponentCtx() => new(
|
private static MatchContext FakeOpponentCtx() => new(
|
||||||
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 0L).ToList(),
|
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 0L).ToList(),
|
||||||
ClassId: "8", CharaId: "8", CardMasterName: "card_master_node_10015",
|
ClassId: CardClass.Portalcraft, CharaId: "8", CardMasterName: "card_master_node_10015",
|
||||||
CountryCode: "JPN", UserName: "Opponent", SleeveId: "704141010",
|
CountryCode: CountryCodes.Japan, UserName: "Opponent", SleeveId: "704141010",
|
||||||
EmblemId: "400001100", DegreeId: "120027", FieldId: 5, IsOfficial: 0,
|
EmblemId: "400001100", DegreeId: "120027", FieldId: 5, IsOfficial: 0,
|
||||||
BattleType: 0);
|
BattleModeId: 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ public class TypedBodyWireShapeTests
|
|||||||
// with "Value cannot be null. Parameter name: source". The prod wire format
|
// with "Value cannot be null. Parameter name: source". The prod wire format
|
||||||
// emits envelope keys (uri first) before body keys; we must too.
|
// emits envelope keys (uri first) before body keys; we must too.
|
||||||
var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(),
|
var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(),
|
||||||
selfViewerId: 1, oppoViewerId: 2, battleId: "b", seed: BattleFrameDefaults.BattleSeed);
|
selfViewerId: 1, oppoViewerId: 2, battleId: "b", seed: 17_548_138,
|
||||||
|
selfDeckOrder: FixtureCtx().SelfDeckCardIds);
|
||||||
var json = MsgEnvelope.ToJson(env);
|
var json = MsgEnvelope.ToJson(env);
|
||||||
|
|
||||||
var uriIdx = json.IndexOf("\"uri\":", StringComparison.Ordinal);
|
var uriIdx = json.IndexOf("\"uri\":", StringComparison.Ordinal);
|
||||||
@@ -47,7 +48,7 @@ public class TypedBodyWireShapeTests
|
|||||||
{
|
{
|
||||||
var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(),
|
var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(),
|
||||||
selfViewerId: 906243102, oppoViewerId: 847666884, battleId: "597830888107",
|
selfViewerId: 906243102, oppoViewerId: 847666884, battleId: "597830888107",
|
||||||
seed: BattleFrameDefaults.BattleSeed);
|
seed: 17_548_138, selfDeckOrder: FixtureCtx().SelfDeckCardIds);
|
||||||
|
|
||||||
var json = MsgEnvelope.ToJson(env);
|
var json = MsgEnvelope.ToJson(env);
|
||||||
var node = JsonNode.Parse(json)!.AsObject();
|
var node = JsonNode.Parse(json)!.AsObject();
|
||||||
@@ -86,7 +87,7 @@ public class TypedBodyWireShapeTests
|
|||||||
[Test]
|
[Test]
|
||||||
public void BuildBattleStart_SerializesAllWireKeysAndPreservesBattlePointAsymmetry()
|
public void BuildBattleStart_SerializesAllWireKeysAndPreservesBattlePointAsymmetry()
|
||||||
{
|
{
|
||||||
var env = ServerBattleFrames.BuildBattleStart(FixtureCtx(), FakeOpponentCtx(), selfViewerId: 906243102, turnState: 0);
|
var env = ServerBattleFrames.BuildBattleStart(FixtureCtx(), FakeOpponentCtx(), selfViewerId: 906243102, turnState: TurnState.First);
|
||||||
|
|
||||||
var json = MsgEnvelope.ToJson(env);
|
var json = MsgEnvelope.ToJson(env);
|
||||||
var node = JsonNode.Parse(json)!.AsObject();
|
var node = JsonNode.Parse(json)!.AsObject();
|
||||||
@@ -137,7 +138,7 @@ public class TypedBodyWireShapeTests
|
|||||||
[Test]
|
[Test]
|
||||||
public void BuildReady_SerializesAllFieldsIncludingSeedAndSpin()
|
public void BuildReady_SerializesAllFieldsIncludingSeedAndSpin()
|
||||||
{
|
{
|
||||||
var env = ServerBattleFrames.BuildReady(new long[] { 1, 4, 3 });
|
var env = ServerBattleFrames.BuildReady(new long[] { 1, 4, 3 }, idxChangeSeed: 771_335_280);
|
||||||
var json = MsgEnvelope.ToJson(env);
|
var json = MsgEnvelope.ToJson(env);
|
||||||
var node = JsonNode.Parse(json)!.AsObject();
|
var node = JsonNode.Parse(json)!.AsObject();
|
||||||
|
|
||||||
@@ -154,10 +155,10 @@ public class TypedBodyWireShapeTests
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private static MatchContext FixtureCtx() => new(
|
private static MatchContext FixtureCtx() => new(
|
||||||
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(),
|
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(),
|
||||||
ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015",
|
ClassId: CardClass.Forestcraft, CharaId: "1", CardMasterName: "card_master_node_10015",
|
||||||
CountryCode: "KOR", UserName: "Player", SleeveId: "3000011",
|
CountryCode: CountryCodes.Korea, UserName: "Player", SleeveId: "3000011",
|
||||||
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
|
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
|
||||||
BattleType: 11);
|
BattleModeId: BattleModes.TakeTwo);
|
||||||
|
|
||||||
// Prod-captured opponent fixture — 30-card deck and the prod-captured opponent
|
// Prod-captured opponent fixture — 30-card deck and the prod-captured opponent
|
||||||
// cosmetics (ClassId/CharaId "8") so the wire bytes asserted below (oppoInfo classId/charaId,
|
// cosmetics (ClassId/CharaId "8") so the wire bytes asserted below (oppoInfo classId/charaId,
|
||||||
@@ -165,8 +166,8 @@ public class TypedBodyWireShapeTests
|
|||||||
// signature change.
|
// signature change.
|
||||||
private static MatchContext FakeOpponentCtx() => new(
|
private static MatchContext FakeOpponentCtx() => new(
|
||||||
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 0L).ToList(),
|
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 0L).ToList(),
|
||||||
ClassId: "8", CharaId: "8", CardMasterName: "card_master_node_10015",
|
ClassId: CardClass.Portalcraft, CharaId: "8", CardMasterName: "card_master_node_10015",
|
||||||
CountryCode: "JPN", UserName: "Opponent", SleeveId: "704141010",
|
CountryCode: CountryCodes.Japan, UserName: "Opponent", SleeveId: "704141010",
|
||||||
EmblemId: "400001100", DegreeId: "120027", FieldId: 5, IsOfficial: 0,
|
EmblemId: "400001100", DegreeId: "120027", FieldId: 5, IsOfficial: 0,
|
||||||
BattleType: 0);
|
BattleModeId: 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
using NUnit.Framework;
|
using NUnit.Framework;
|
||||||
|
using SVSim.BattleNode.Bridge;
|
||||||
|
using SVSim.BattleNode.Protocol;
|
||||||
using SVSim.BattleNode.Protocol.Bodies;
|
using SVSim.BattleNode.Protocol.Bodies;
|
||||||
|
|
||||||
namespace SVSim.UnitTests.BattleNode.Protocol.Bodies;
|
namespace SVSim.UnitTests.BattleNode.Protocol.Bodies;
|
||||||
@@ -12,7 +14,7 @@ public class BattleStartBodyTests
|
|||||||
public void Serializes_TopLevelFields_WithCorrectWireKeys()
|
public void Serializes_TopLevelFields_WithCorrectWireKeys()
|
||||||
{
|
{
|
||||||
var body = new BattleStartBody(
|
var body = new BattleStartBody(
|
||||||
TurnState: 0, BattleType: 11,
|
TurnState: TurnState.First, BattleModeId: BattleModes.TakeTwo,
|
||||||
SelfInfo: new BattleStartSelfInfo("10", "6270", "1", "1", "card_master_node_10015"),
|
SelfInfo: new BattleStartSelfInfo("10", "6270", "1", "1", "card_master_node_10015"),
|
||||||
OppoInfo: new BattleStartOppoInfo("1", "0", 0, "0", "8", "8", "card_master_node_10015"));
|
OppoInfo: new BattleStartOppoInfo("1", "0", 0, "0", "8", "8", "card_master_node_10015"));
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ public class MatchedBodyTests
|
|||||||
SelfInfo: new MatchedSelfInfo(
|
SelfInfo: new MatchedSelfInfo(
|
||||||
CountryCode: "KOR", UserName: "Player", SleeveId: "3000011",
|
CountryCode: "KOR", UserName: "Player", SleeveId: "3000011",
|
||||||
EmblemId: "701441011", DegreeId: "300003", FieldId: 43,
|
EmblemId: "701441011", DegreeId: "300003", FieldId: 43,
|
||||||
IsOfficial: 0, OppoId: 847666884L, Seed: 17_548_138L),
|
IsOfficial: false, OppoId: 847666884, Seed: 17_548_138),
|
||||||
OppoInfo: new MatchedOppoInfo(
|
OppoInfo: new MatchedOppoInfo(
|
||||||
CountryCode: "JPN", UserName: "Opponent", SleeveId: "704141010",
|
CountryCode: "JPN", UserName: "Opponent", SleeveId: "704141010",
|
||||||
EmblemId: "400001100", DegreeId: "120027", FieldId: 5,
|
EmblemId: "400001100", DegreeId: "120027", FieldId: 5,
|
||||||
IsOfficial: 0, OppoId: 906243102L, Seed: 17_548_138L, OppoDeckCount: 30),
|
IsOfficial: false, OppoId: 906243102, Seed: 17_548_138, OppoDeckCount: 30),
|
||||||
SelfDeck: new[] { new DeckCardRef(Idx: 1, CardId: 100011010L) });
|
SelfDeck: new[] { new DeckCardRef(Idx: 1, CardId: 100011010L) });
|
||||||
|
|
||||||
var node = (JsonObject)JsonSerializer.SerializeToNode(body)!;
|
var node = (JsonObject)JsonSerializer.SerializeToNode(body)!;
|
||||||
@@ -32,16 +32,16 @@ public class MatchedBodyTests
|
|||||||
Assert.That(selfInfo["degreeId"]!.GetValue<string>(), Is.EqualTo("300003"));
|
Assert.That(selfInfo["degreeId"]!.GetValue<string>(), Is.EqualTo("300003"));
|
||||||
Assert.That(selfInfo["fieldId"]!.GetValue<int>(), Is.EqualTo(43));
|
Assert.That(selfInfo["fieldId"]!.GetValue<int>(), Is.EqualTo(43));
|
||||||
Assert.That(selfInfo["isOfficial"]!.GetValue<int>(), Is.EqualTo(0));
|
Assert.That(selfInfo["isOfficial"]!.GetValue<int>(), Is.EqualTo(0));
|
||||||
Assert.That(selfInfo["oppoId"]!.GetValue<long>(), Is.EqualTo(847666884L));
|
Assert.That(selfInfo["oppoId"]!.GetValue<int>(), Is.EqualTo(847666884));
|
||||||
Assert.That(selfInfo["seed"]!.GetValue<long>(), Is.EqualTo(17_548_138L));
|
Assert.That(selfInfo["seed"]!.GetValue<int>(), Is.EqualTo(17_548_138));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void OppoInfo_HasOppoDeckCount_OnTheWire()
|
public void OppoInfo_HasOppoDeckCount_OnTheWire()
|
||||||
{
|
{
|
||||||
var body = new MatchedBody(
|
var body = new MatchedBody(
|
||||||
SelfInfo: new MatchedSelfInfo("KOR","P","s","e","d",0,0,1L,1L),
|
SelfInfo: new MatchedSelfInfo("KOR","P","s","e","d",0,false,1,1),
|
||||||
OppoInfo: new MatchedOppoInfo("JPN","O","s","e","d",0,0,1L,1L, OppoDeckCount: 30),
|
OppoInfo: new MatchedOppoInfo("JPN","O","s","e","d",0,false,1,1, OppoDeckCount: 30),
|
||||||
SelfDeck: System.Array.Empty<DeckCardRef>());
|
SelfDeck: System.Array.Empty<DeckCardRef>());
|
||||||
|
|
||||||
var node = (JsonObject)JsonSerializer.SerializeToNode(body)!;
|
var node = (JsonObject)JsonSerializer.SerializeToNode(body)!;
|
||||||
@@ -54,8 +54,8 @@ public class MatchedBodyTests
|
|||||||
public void SelfInfo_DoesNotHaveOppoDeckCount_OnTheWire()
|
public void SelfInfo_DoesNotHaveOppoDeckCount_OnTheWire()
|
||||||
{
|
{
|
||||||
var body = new MatchedBody(
|
var body = new MatchedBody(
|
||||||
SelfInfo: new MatchedSelfInfo("KOR","P","s","e","d",0,0,1L,1L),
|
SelfInfo: new MatchedSelfInfo("KOR","P","s","e","d",0,false,1,1),
|
||||||
OppoInfo: new MatchedOppoInfo("JPN","O","s","e","d",0,0,1L,1L,30),
|
OppoInfo: new MatchedOppoInfo("JPN","O","s","e","d",0,false,1,1,30),
|
||||||
SelfDeck: System.Array.Empty<DeckCardRef>());
|
SelfDeck: System.Array.Empty<DeckCardRef>());
|
||||||
|
|
||||||
var node = (JsonObject)JsonSerializer.SerializeToNode(body)!;
|
var node = (JsonObject)JsonSerializer.SerializeToNode(body)!;
|
||||||
@@ -68,8 +68,8 @@ public class MatchedBodyTests
|
|||||||
public void ResultCode_DefaultsToOne_OnConstruction()
|
public void ResultCode_DefaultsToOne_OnConstruction()
|
||||||
{
|
{
|
||||||
var body = new MatchedBody(
|
var body = new MatchedBody(
|
||||||
SelfInfo: new MatchedSelfInfo("KOR","P","s","e","d",0,0,1L,1L),
|
SelfInfo: new MatchedSelfInfo("KOR","P","s","e","d",0,false,1,1),
|
||||||
OppoInfo: new MatchedOppoInfo("JPN","O","s","e","d",0,0,1L,1L,30),
|
OppoInfo: new MatchedOppoInfo("JPN","O","s","e","d",0,false,1,1,30),
|
||||||
SelfDeck: System.Array.Empty<DeckCardRef>());
|
SelfDeck: System.Array.Empty<DeckCardRef>());
|
||||||
|
|
||||||
Assert.That(body.ResultCode, Is.EqualTo(1));
|
Assert.That(body.ResultCode, Is.EqualTo(1));
|
||||||
@@ -81,8 +81,8 @@ public class MatchedBodyTests
|
|||||||
public void SelfDeck_SerializesAsArray_WithIdxAndCardIdKeys()
|
public void SelfDeck_SerializesAsArray_WithIdxAndCardIdKeys()
|
||||||
{
|
{
|
||||||
var body = new MatchedBody(
|
var body = new MatchedBody(
|
||||||
SelfInfo: new MatchedSelfInfo("KOR","P","s","e","d",0,0,1L,1L),
|
SelfInfo: new MatchedSelfInfo("KOR","P","s","e","d",0,false,1,1),
|
||||||
OppoInfo: new MatchedOppoInfo("JPN","O","s","e","d",0,0,1L,1L,30),
|
OppoInfo: new MatchedOppoInfo("JPN","O","s","e","d",0,false,1,1,30),
|
||||||
SelfDeck: new[]
|
SelfDeck: new[]
|
||||||
{
|
{
|
||||||
new DeckCardRef(Idx: 1, CardId: 100011010L),
|
new DeckCardRef(Idx: 1, CardId: 100011010L),
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ public class MsgEnvelopeTests
|
|||||||
ViewerId: 906243102,
|
ViewerId: 906243102,
|
||||||
Uuid: "udid-1234",
|
Uuid: "udid-1234",
|
||||||
Bid: "597830888107",
|
Bid: "597830888107",
|
||||||
Try: 0,
|
RetryAttempt: 0,
|
||||||
Cat: EmitCategory.General,
|
Cat: EmitCategory.General,
|
||||||
PubSeq: null,
|
PubSeq: null,
|
||||||
PlaySeq: null,
|
PlaySeq: null,
|
||||||
@@ -60,7 +60,7 @@ public class MsgEnvelopeTests
|
|||||||
ViewerId: 1,
|
ViewerId: 1,
|
||||||
Uuid: "u",
|
Uuid: "u",
|
||||||
Bid: null,
|
Bid: null,
|
||||||
Try: 0,
|
RetryAttempt: 0,
|
||||||
Cat: EmitCategory.Battle,
|
Cat: EmitCategory.Battle,
|
||||||
PubSeq: null,
|
PubSeq: null,
|
||||||
PlaySeq: 5,
|
PlaySeq: 5,
|
||||||
@@ -92,7 +92,7 @@ public class MsgEnvelopeTests
|
|||||||
ViewerId: 1,
|
ViewerId: 1,
|
||||||
Uuid: "u",
|
Uuid: "u",
|
||||||
Bid: null,
|
Bid: null,
|
||||||
Try: 0,
|
RetryAttempt: 0,
|
||||||
Cat: EmitCategory.Battle,
|
Cat: EmitCategory.Battle,
|
||||||
PubSeq: null,
|
PubSeq: null,
|
||||||
PlaySeq: null,
|
PlaySeq: null,
|
||||||
@@ -110,7 +110,7 @@ public class MsgEnvelopeTests
|
|||||||
ViewerId: 1,
|
ViewerId: 1,
|
||||||
Uuid: "u",
|
Uuid: "u",
|
||||||
Bid: null,
|
Bid: null,
|
||||||
Try: 0,
|
RetryAttempt: 0,
|
||||||
Cat: EmitCategory.Battle,
|
Cat: EmitCategory.Battle,
|
||||||
PubSeq: null,
|
PubSeq: null,
|
||||||
PlaySeq: null,
|
PlaySeq: null,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ public class MsgPayloadCodecTests
|
|||||||
ViewerId: 906243102,
|
ViewerId: 906243102,
|
||||||
Uuid: "udid",
|
Uuid: "udid",
|
||||||
Bid: "1234",
|
Bid: "1234",
|
||||||
Try: 0,
|
RetryAttempt: 0,
|
||||||
Cat: EmitCategory.Battle,
|
Cat: EmitCategory.Battle,
|
||||||
PubSeq: 3,
|
PubSeq: 3,
|
||||||
PlaySeq: null,
|
PlaySeq: null,
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using NUnit.Framework;
|
||||||
|
using SVSim.BattleNode.Protocol;
|
||||||
|
|
||||||
|
namespace SVSim.UnitTests.BattleNode.Protocol;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
public class NumericBoolJsonConverterTests
|
||||||
|
{
|
||||||
|
private sealed record Probe(
|
||||||
|
[property: JsonPropertyName("flag")]
|
||||||
|
[property: JsonConverter(typeof(NumericBoolJsonConverter))]
|
||||||
|
bool Flag,
|
||||||
|
[property: JsonPropertyName("opt")]
|
||||||
|
[property: JsonConverter(typeof(NumericBoolJsonConverter))]
|
||||||
|
bool? Opt = null);
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions Options = new()
|
||||||
|
{
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
};
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Writes_true_as_numeric_1_and_false_as_numeric_0()
|
||||||
|
{
|
||||||
|
var node = JsonSerializer.SerializeToElement(new Probe(Flag: true), Options);
|
||||||
|
Assert.That(node.GetProperty("flag").ValueKind, Is.EqualTo(JsonValueKind.Number));
|
||||||
|
Assert.That(node.GetProperty("flag").GetInt32(), Is.EqualTo(1));
|
||||||
|
|
||||||
|
var falseNode = JsonSerializer.SerializeToElement(new Probe(Flag: false), Options);
|
||||||
|
Assert.That(falseNode.GetProperty("flag").GetInt32(), Is.EqualTo(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Reads_numeric_0_and_1_back_to_bool()
|
||||||
|
{
|
||||||
|
Assert.That(JsonSerializer.Deserialize<Probe>("{\"flag\":1}", Options)!.Flag, Is.True);
|
||||||
|
Assert.That(JsonSerializer.Deserialize<Probe>("{\"flag\":0}", Options)!.Flag, Is.False);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Nullable_true_emits_1_and_null_is_omitted()
|
||||||
|
{
|
||||||
|
var present = JsonSerializer.SerializeToElement(new Probe(Flag: false, Opt: true), Options);
|
||||||
|
Assert.That(present.GetProperty("opt").GetInt32(), Is.EqualTo(1));
|
||||||
|
|
||||||
|
var absent = JsonSerializer.SerializeToElement(new Probe(Flag: false, Opt: null), Options);
|
||||||
|
Assert.That(absent.TryGetProperty("opt", out _), Is.False, "null bool? must be omitted, not emitted");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ namespace SVSim.UnitTests.BattleNode.Reliability;
|
|||||||
public class OutboundSequencerTests
|
public class OutboundSequencerTests
|
||||||
{
|
{
|
||||||
private static MsgEnvelope MakeEnvelope(NetworkBattleUri uri) =>
|
private static MsgEnvelope MakeEnvelope(NetworkBattleUri uri) =>
|
||||||
new(uri, ViewerId: 1, Uuid: "u", Bid: null, Try: 0, Cat: EmitCategory.Battle,
|
new(uri, ViewerId: 1, Uuid: "u", Bid: null, RetryAttempt: 0, Cat: EmitCategory.Battle,
|
||||||
PubSeq: null, PlaySeq: null, Body: new RawBody(new Dictionary<string, object?>()));
|
PubSeq: null, PlaySeq: null, Body: new RawBody(new Dictionary<string, object?>()));
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using NUnit.Framework;
|
||||||
|
using SVSim.BattleNode.Bridge;
|
||||||
|
using SVSim.BattleNode.Protocol;
|
||||||
|
using SVSim.BattleNode.Sessions;
|
||||||
|
using SVSim.BattleNode.Sessions.Participants;
|
||||||
|
|
||||||
|
namespace SVSim.UnitTests.BattleNode.Sessions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In PvP a <see cref="BattleSession"/> subscribes to BOTH participants' FrameEmitted, and each
|
||||||
|
/// RealParticipant raises it from its own WebSocket read loop — i.e. two threads. The dispatch path
|
||||||
|
/// (ComputeFrames + the relay PushAsync calls) mutates shared, non-thread-safe session state, so it
|
||||||
|
/// must be serialized per session. This drives the two participants' dispatch concurrently and asserts
|
||||||
|
/// no two dispatches ever overlap.
|
||||||
|
/// </summary>
|
||||||
|
[TestFixture]
|
||||||
|
public class BattleSessionDispatchConcurrencyTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public async Task Concurrent_dispatch_from_both_participants_is_serialized()
|
||||||
|
{
|
||||||
|
var detector = new ConcurrencyDetector();
|
||||||
|
var a = new ProbeParticipant(1001, CtxA(), detector);
|
||||||
|
var b = new ProbeParticipant(2002, CtxB(), detector);
|
||||||
|
var s = new BattleSession("bid-conc", BattleType.Pvp, a, b, NullLogger<BattleSession>.Instance);
|
||||||
|
|
||||||
|
// Reach AfterReady single-threaded (ComputeFrames returns routes but never calls PushAsync,
|
||||||
|
// so the detector is untouched during setup).
|
||||||
|
DriveToAfterReady(s, a);
|
||||||
|
DriveToAfterReady(s, b);
|
||||||
|
|
||||||
|
detector.Arm();
|
||||||
|
|
||||||
|
// Fire a gameplay frame from each side at the same instant. A's TurnStart routes to B.PushAsync
|
||||||
|
// and B's to A.PushAsync, so both dispatches run their PushAsync concurrently unless the session
|
||||||
|
// serializes them.
|
||||||
|
using var gate = new ManualResetEventSlim(false);
|
||||||
|
var ta = Task.Run(async () => { gate.Wait(); await a.RaiseAsync(Env(NetworkBattleUri.TurnStart)); });
|
||||||
|
var tb = Task.Run(async () => { gate.Wait(); await b.RaiseAsync(Env(NetworkBattleUri.TurnStart)); });
|
||||||
|
gate.Set();
|
||||||
|
await Task.WhenAll(ta, tb);
|
||||||
|
|
||||||
|
Assert.That(detector.MaxConcurrent, Is.EqualTo(1),
|
||||||
|
"Two read-loop threads dispatched into shared session state concurrently; " +
|
||||||
|
"HandleFrameAsync must serialize dispatch per session.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DriveToAfterReady(BattleSession s, ProbeParticipant p)
|
||||||
|
{
|
||||||
|
s.ComputeFrames(p, Env(NetworkBattleUri.InitNetwork));
|
||||||
|
s.ComputeFrames(p, Env(NetworkBattleUri.InitBattle));
|
||||||
|
s.ComputeFrames(p, Env(NetworkBattleUri.Loaded));
|
||||||
|
s.ComputeFrames(p, Env(NetworkBattleUri.Swap));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MsgEnvelope Env(NetworkBattleUri uri) =>
|
||||||
|
new(uri, ViewerId: 1, Uuid: "u", Bid: null, RetryAttempt: 0,
|
||||||
|
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null,
|
||||||
|
Body: new RawBody(new Dictionary<string, object?>()));
|
||||||
|
|
||||||
|
private static MatchContext CtxA() => new(
|
||||||
|
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(),
|
||||||
|
ClassId: CardClass.Runecraft, CharaId: "3", CardMasterName: "card_master_node_10015",
|
||||||
|
CountryCode: CountryCodes.Korea, UserName: "PlayerA", SleeveId: "3000011",
|
||||||
|
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0, BattleModeId: BattleModes.TakeTwo);
|
||||||
|
|
||||||
|
private static MatchContext CtxB() => new(
|
||||||
|
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 200_011_010L).ToList(),
|
||||||
|
ClassId: CardClass.Shadowcraft, CharaId: "5", CardMasterName: "card_master_node_10015",
|
||||||
|
CountryCode: CountryCodes.Japan, UserName: "PlayerB", SleeveId: "3000022",
|
||||||
|
EmblemId: "701441022", DegreeId: "300004", FieldId: 44, IsOfficial: 0, BattleModeId: BattleModes.TakeTwo);
|
||||||
|
|
||||||
|
/// <summary>Tracks the peak number of dispatches in flight at once. Records the count under a
|
||||||
|
/// short lock, then holds (outside the lock) to widen the overlap window so a serialization bug
|
||||||
|
/// is observed deterministically rather than relied on to interleave by chance.</summary>
|
||||||
|
private sealed class ConcurrencyDetector
|
||||||
|
{
|
||||||
|
private const int WidenMs = 50;
|
||||||
|
private readonly object _lock = new();
|
||||||
|
private int _current;
|
||||||
|
private volatile bool _armed;
|
||||||
|
public int MaxConcurrent { get; private set; }
|
||||||
|
|
||||||
|
public void Arm() => _armed = true;
|
||||||
|
|
||||||
|
public async Task EnterAsync()
|
||||||
|
{
|
||||||
|
if (!_armed) return;
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_current++;
|
||||||
|
if (_current > MaxConcurrent) MaxConcurrent = _current;
|
||||||
|
}
|
||||||
|
await Task.Delay(WidenMs);
|
||||||
|
lock (_lock) { _current--; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ProbeParticipant : IBattleParticipant, IHasHandshakePhase
|
||||||
|
{
|
||||||
|
private readonly ConcurrencyDetector _detector;
|
||||||
|
public long ViewerId { get; }
|
||||||
|
public MatchContext Context { get; }
|
||||||
|
public HandshakePhase Phase { get; set; } = HandshakePhase.AwaitingInitNetwork;
|
||||||
|
public event Func<MsgEnvelope, CancellationToken, Task>? FrameEmitted;
|
||||||
|
|
||||||
|
public ProbeParticipant(long viewerId, MatchContext context, ConcurrencyDetector detector)
|
||||||
|
{
|
||||||
|
ViewerId = viewerId;
|
||||||
|
Context = context;
|
||||||
|
_detector = detector;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task RaiseAsync(MsgEnvelope env) =>
|
||||||
|
FrameEmitted?.Invoke(env, CancellationToken.None) ?? Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task PushAsync(MsgEnvelope env, Stock stock, CancellationToken ct) => _detector.EnterAsync();
|
||||||
|
public Task RunAsync(CancellationToken ct) => Task.CompletedTask;
|
||||||
|
public Task TerminateAsync(BattleFinishReason reason) => Task.CompletedTask;
|
||||||
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ public class BattleSessionDispatchTests
|
|||||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded));
|
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded));
|
||||||
|
|
||||||
var bs = (BattleStartBody)routes[0].Frame.Body;
|
var bs = (BattleStartBody)routes[0].Frame.Body;
|
||||||
Assert.That(bs.TurnState, Is.EqualTo(0), "A (first arriver) goes first.");
|
Assert.That(bs.TurnState, Is.EqualTo(TurnState.First), "A (first arriver) goes first.");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -33,7 +33,7 @@ public class BattleSessionDispatchTests
|
|||||||
var routes = s.ComputeFrames(b, NewEnvelope(NetworkBattleUri.Loaded));
|
var routes = s.ComputeFrames(b, NewEnvelope(NetworkBattleUri.Loaded));
|
||||||
|
|
||||||
var bs = (BattleStartBody)routes[0].Frame.Body;
|
var bs = (BattleStartBody)routes[0].Frame.Body;
|
||||||
Assert.That(bs.TurnState, Is.EqualTo(1), "B (second arriver) goes second.");
|
Assert.That(bs.TurnState, Is.EqualTo(TurnState.Second), "B (second arriver) goes second.");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -44,19 +44,19 @@ public class BattleSessionDispatchTests
|
|||||||
var s = new BattleSession("bid-1", BattleType.Pvp, a, b, NullLogger<BattleSession>.Instance);
|
var s = new BattleSession("bid-1", BattleType.Pvp, a, b, NullLogger<BattleSession>.Instance);
|
||||||
|
|
||||||
// A is AwaitingInitNetwork; B is AwaitingInitBattle (manually set).
|
// A is AwaitingInitNetwork; B is AwaitingInitBattle (manually set).
|
||||||
b.Phase = BattleSessionPhase.AwaitingInitBattle;
|
b.Phase = HandshakePhase.AwaitingInitBattle;
|
||||||
|
|
||||||
// A's InitNetwork should ack (matches A's phase).
|
// A's InitNetwork should ack (matches A's phase).
|
||||||
var routesA = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
var routesA = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||||
Assert.That(routesA.Count, Is.EqualTo(1));
|
Assert.That(routesA.Count, Is.EqualTo(1));
|
||||||
Assert.That(routesA[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.InitNetwork));
|
Assert.That(routesA[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.InitNetwork));
|
||||||
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitBattle));
|
Assert.That(a.Phase, Is.EqualTo(HandshakePhase.AwaitingInitBattle));
|
||||||
|
|
||||||
// B's InitBattle should produce Matched (matches B's phase, set above).
|
// B's InitBattle should produce Matched (matches B's phase, set above).
|
||||||
var routesB = s.ComputeFrames(b, NewEnvelope(NetworkBattleUri.InitBattle));
|
var routesB = s.ComputeFrames(b, NewEnvelope(NetworkBattleUri.InitBattle));
|
||||||
Assert.That(routesB.Count, Is.EqualTo(1));
|
Assert.That(routesB.Count, Is.EqualTo(1));
|
||||||
Assert.That(routesB[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.Matched));
|
Assert.That(routesB[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.Matched));
|
||||||
Assert.That(b.Phase, Is.EqualTo(BattleSessionPhase.AwaitingLoaded));
|
Assert.That(b.Phase, Is.EqualTo(HandshakePhase.AwaitingLoaded));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -66,7 +66,7 @@ public class BattleSessionDispatchTests
|
|||||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap));
|
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap));
|
||||||
|
|
||||||
Assert.That(routes, Is.Empty);
|
Assert.That(routes, Is.Empty);
|
||||||
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitNetwork));
|
Assert.That(a.Phase, Is.EqualTo(HandshakePhase.AwaitingInitNetwork));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -91,6 +91,41 @@ public class BattleSessionDispatchTests
|
|||||||
"Both sides must see the same seed.");
|
"Both sides must see the same seed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Pvp_Matched_seed_derives_from_master_via_BattleSeeds_Stable()
|
||||||
|
{
|
||||||
|
var (s, a, _) = NewPvpSession();
|
||||||
|
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||||
|
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
|
||||||
|
|
||||||
|
var body = (MatchedBody)routes[0].Frame.Body;
|
||||||
|
Assert.That(body.SelfInfo.Seed, Is.EqualTo(BattleSeeds.Stable(s.MasterSeed)));
|
||||||
|
Assert.That(body.OppoInfo.Seed, Is.EqualTo(BattleSeeds.Stable(s.MasterSeed)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Pvp_Ready_idxChangeSeed_derives_from_master_and_recipient_viewer()
|
||||||
|
{
|
||||||
|
var (s, a, b) = NewPvpSession();
|
||||||
|
// Both sides must complete the handshake before either can swap; then a swaps, then b's
|
||||||
|
// swap releases Ready to BOTH (mirrors Pvp_Swap_from_both_releases_Ready).
|
||||||
|
foreach (var p in new[] { a, b })
|
||||||
|
{
|
||||||
|
s.ComputeFrames(p, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||||
|
s.ComputeFrames(p, NewEnvelope(NetworkBattleUri.InitBattle));
|
||||||
|
s.ComputeFrames(p, NewEnvelope(NetworkBattleUri.Loaded));
|
||||||
|
}
|
||||||
|
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap)); // a swaps first
|
||||||
|
var bRoutes = s.ComputeFrames(b, NewEnvelope(NetworkBattleUri.Swap)); // b releases both Readys
|
||||||
|
|
||||||
|
var readyToA = bRoutes.Single(r => ReferenceEquals(r.Target, a) && r.Frame.Uri == NetworkBattleUri.Ready);
|
||||||
|
var readyToB = bRoutes.Single(r => ReferenceEquals(r.Target, b) && r.Frame.Uri == NetworkBattleUri.Ready);
|
||||||
|
Assert.That(((ReadyBody)readyToA.Frame.Body).IdxChangeSeed,
|
||||||
|
Is.EqualTo(BattleSeeds.IdxChange(s.MasterSeed, a.ViewerId)));
|
||||||
|
Assert.That(((ReadyBody)readyToB.Frame.Body).IdxChangeSeed,
|
||||||
|
Is.EqualTo(BattleSeeds.IdxChange(s.MasterSeed, b.ViewerId)));
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void Pvp_InitBattle_from_B_pushes_Matched_with_A_oppoInfo_to_B_only()
|
public void Pvp_InitBattle_from_B_pushes_Matched_with_A_oppoInfo_to_B_only()
|
||||||
{
|
{
|
||||||
@@ -134,7 +169,7 @@ public class BattleSessionDispatchTests
|
|||||||
|
|
||||||
Assert.That(routes.Select(r => r.Frame.Uri), Is.EqualTo(new[] { NetworkBattleUri.Swap }),
|
Assert.That(routes.Select(r => r.Frame.Uri), Is.EqualTo(new[] { NetworkBattleUri.Swap }),
|
||||||
"Ready is withheld until BOTH sides have mulliganed.");
|
"Ready is withheld until BOTH sides have mulliganed.");
|
||||||
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AfterReady),
|
Assert.That(a.Phase, Is.EqualTo(HandshakePhase.AfterReady),
|
||||||
"Phase advances on Swap even though Ready is withheld.");
|
"Phase advances on Swap even though Ready is withheld.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,7 +281,7 @@ public class BattleSessionDispatchTests
|
|||||||
|
|
||||||
Assert.That(pb.OppoTargetList!.Count, Is.EqualTo(1));
|
Assert.That(pb.OppoTargetList!.Count, Is.EqualTo(1));
|
||||||
Assert.That(pb.OppoTargetList[0].TargetIdx, Is.EqualTo(8));
|
Assert.That(pb.OppoTargetList[0].TargetIdx, Is.EqualTo(8));
|
||||||
Assert.That(pb.OppoTargetList[0].IsSelf, Is.EqualTo(0));
|
Assert.That(pb.OppoTargetList[0].IsSelf, Is.EqualTo(CardOwner.Opponent));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -281,7 +316,7 @@ public class BattleSessionDispatchTests
|
|||||||
Assert.That(pb.UList[0].IdxList, Is.EqualTo(new[] { 16, 22 }));
|
Assert.That(pb.UList[0].IdxList, Is.EqualTo(new[] { 16, 22 }));
|
||||||
Assert.That(pb.UList[0].From, Is.EqualTo(0));
|
Assert.That(pb.UList[0].From, Is.EqualTo(0));
|
||||||
Assert.That(pb.UList[0].To, Is.EqualTo(10));
|
Assert.That(pb.UList[0].To, Is.EqualTo(10));
|
||||||
Assert.That(pb.UList[0].IsSelf, Is.EqualTo(1));
|
Assert.That(pb.UList[0].IsSelf, Is.EqualTo(CardOwner.Self));
|
||||||
Assert.That(pb.UList[0].Skill, Is.EqualTo("37|36|0"));
|
Assert.That(pb.UList[0].Skill, Is.EqualTo("37|36|0"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -618,7 +653,7 @@ public class BattleSessionDispatchTests
|
|||||||
Assert.That(pb.KnownList!.Single().CardId, Is.EqualTo(100_011_010L), "generating deck card revealed");
|
Assert.That(pb.KnownList!.Single().CardId, Is.EqualTo(100_011_010L), "generating deck card revealed");
|
||||||
// keyAction forwarded as {type,cardId}; selectCard stripped for the hidden choice.
|
// keyAction forwarded as {type,cardId}; selectCard stripped for the hidden choice.
|
||||||
Assert.That(pb.KeyAction, Is.Not.Null);
|
Assert.That(pb.KeyAction, Is.Not.Null);
|
||||||
Assert.That(pb.KeyAction!.Single().Type, Is.EqualTo(1));
|
Assert.That(pb.KeyAction!.Single().Type, Is.EqualTo(KeyActionType.Choice));
|
||||||
Assert.That(pb.KeyAction.Single().CardId, Is.EqualTo(100_011_010L));
|
Assert.That(pb.KeyAction.Single().CardId, Is.EqualTo(100_011_010L));
|
||||||
Assert.That(pb.KeyAction.Single().SelectCard, Is.Null, "the pick stays hidden for open:0");
|
Assert.That(pb.KeyAction.Single().SelectCard, Is.Null, "the pick stays hidden for open:0");
|
||||||
}
|
}
|
||||||
@@ -676,7 +711,7 @@ public class BattleSessionDispatchTests
|
|||||||
{
|
{
|
||||||
var (s, a, b) = NewPvpSession();
|
var (s, a, b) = NewPvpSession();
|
||||||
DriveToAfterReady(s, a);
|
DriveToAfterReady(s, a);
|
||||||
// B not AfterReady → not BothAfterReady.
|
// B not AfterReady → not BothSidesAfterReady.
|
||||||
var body = MoveOrderList(3, 10, 20);
|
var body = MoveOrderList(3, 10, 20);
|
||||||
body["playIdx"] = 3L; body["type"] = 30L;
|
body["playIdx"] = 3L; body["type"] = 30L;
|
||||||
var routes = s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, body));
|
var routes = s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, body));
|
||||||
@@ -684,7 +719,7 @@ public class BattleSessionDispatchTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void Pvp_Echo_from_A_in_BothAfterReady_is_consumed_not_relayed()
|
public void Pvp_Echo_from_A_in_BothSidesAfterReady_is_consumed_not_relayed()
|
||||||
{
|
{
|
||||||
var (s, a, b) = NewPvpSession();
|
var (s, a, b) = NewPvpSession();
|
||||||
DriveToAfterReady(s, a);
|
DriveToAfterReady(s, a);
|
||||||
@@ -747,7 +782,7 @@ public class BattleSessionDispatchTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void Pvp_JudgeResult_from_A_in_BothAfterReady_forwards_to_B()
|
public void Pvp_JudgeResult_from_A_in_BothSidesAfterReady_forwards_to_B()
|
||||||
{
|
{
|
||||||
var (s, a, b) = NewPvpSession();
|
var (s, a, b) = NewPvpSession();
|
||||||
DriveToAfterReady(s, a);
|
DriveToAfterReady(s, a);
|
||||||
@@ -772,7 +807,7 @@ public class BattleSessionDispatchTests
|
|||||||
Assert.That(routes[0].Target, Is.SameAs(b));
|
Assert.That(routes[0].Target, Is.SameAs(b));
|
||||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnEnd));
|
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnEnd));
|
||||||
var body = (SVSim.BattleNode.Protocol.Bodies.TurnEndBody)routes[0].Frame.Body;
|
var body = (SVSim.BattleNode.Protocol.Bodies.TurnEndBody)routes[0].Frame.Body;
|
||||||
Assert.That(body.TurnState, Is.EqualTo(0));
|
Assert.That(body.TurnState, Is.EqualTo(TurnState.First));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -798,7 +833,7 @@ public class BattleSessionDispatchTests
|
|||||||
Assert.That(routes[2].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
Assert.That(routes[2].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
||||||
Assert.That(((BattleFinishBody)routes[2].Frame.Body).Result, Is.EqualTo(BattleResult.LifeLose));
|
Assert.That(((BattleFinishBody)routes[2].Frame.Body).Result, Is.EqualTo(BattleResult.LifeLose));
|
||||||
|
|
||||||
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal));
|
Assert.That(s.Lifecycle, Is.EqualTo(SessionLifecycle.Terminal));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -829,9 +864,9 @@ public class BattleSessionDispatchTests
|
|||||||
Assert.That(((BattleFinishBody)aRoute.Frame.Body).Result, Is.EqualTo(BattleResult.RetireLose));
|
Assert.That(((BattleFinishBody)aRoute.Frame.Body).Result, Is.EqualTo(BattleResult.RetireLose));
|
||||||
Assert.That(bRoute.Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
Assert.That(bRoute.Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
||||||
Assert.That(((BattleFinishBody)bRoute.Frame.Body).Result, Is.EqualTo(BattleResult.RetireWin));
|
Assert.That(((BattleFinishBody)bRoute.Frame.Body).Result, Is.EqualTo(BattleResult.RetireWin));
|
||||||
Assert.That(aRoute.NoStock, Is.True);
|
Assert.That(aRoute.Stock, Is.EqualTo(Stock.Bypass));
|
||||||
Assert.That(bRoute.NoStock, Is.True);
|
Assert.That(bRoute.Stock, Is.EqualTo(Stock.Bypass));
|
||||||
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal));
|
Assert.That(s.Lifecycle, Is.EqualTo(SessionLifecycle.Terminal));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -844,7 +879,7 @@ public class BattleSessionDispatchTests
|
|||||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Kill));
|
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Kill));
|
||||||
|
|
||||||
Assert.That(routes.Count, Is.EqualTo(2));
|
Assert.That(routes.Count, Is.EqualTo(2));
|
||||||
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal));
|
Assert.That(s.Lifecycle, Is.EqualTo(SessionLifecycle.Terminal));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static (BattleSession, FakeRealParticipant, FakeParticipant) NewBotSession()
|
private static (BattleSession, FakeRealParticipant, FakeParticipant) NewBotSession()
|
||||||
@@ -857,9 +892,9 @@ public class BattleSessionDispatchTests
|
|||||||
|
|
||||||
private static MatchContext NoOpBotContext() => new(
|
private static MatchContext NoOpBotContext() => new(
|
||||||
SelfDeckCardIds: Array.Empty<long>(),
|
SelfDeckCardIds: Array.Empty<long>(),
|
||||||
ClassId: "0", CharaId: "0", CardMasterName: "card_master_node_10015",
|
ClassId: CardClass.None, CharaId: "0", CardMasterName: "card_master_node_10015",
|
||||||
CountryCode: "", UserName: "Bot", SleeveId: "0",
|
CountryCode: "", UserName: "Bot", SleeveId: "0",
|
||||||
EmblemId: "0", DegreeId: "0", FieldId: 0, IsOfficial: 0, BattleType: 0);
|
EmblemId: "0", DegreeId: "0", FieldId: 0, IsOfficial: 0, BattleModeId: 0);
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void Bot_InitNetwork_acks_to_sender()
|
public void Bot_InitNetwork_acks_to_sender()
|
||||||
@@ -870,7 +905,7 @@ public class BattleSessionDispatchTests
|
|||||||
Assert.That(routes.Count, Is.EqualTo(1));
|
Assert.That(routes.Count, Is.EqualTo(1));
|
||||||
Assert.That(routes[0].Target, Is.SameAs(a));
|
Assert.That(routes[0].Target, Is.SameAs(a));
|
||||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.InitNetwork));
|
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.InitNetwork));
|
||||||
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitBattle));
|
Assert.That(a.Phase, Is.EqualTo(HandshakePhase.AwaitingInitBattle));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -888,7 +923,7 @@ public class BattleSessionDispatchTests
|
|||||||
Assert.That(routes[0].Target, Is.SameAs(a));
|
Assert.That(routes[0].Target, Is.SameAs(a));
|
||||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.InitBattle),
|
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.InitBattle),
|
||||||
"Expected an ack envelope for InitBattle, NOT a Matched envelope.");
|
"Expected an ack envelope for InitBattle, NOT a Matched envelope.");
|
||||||
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingLoaded));
|
Assert.That(a.Phase, Is.EqualTo(HandshakePhase.AwaitingLoaded));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -904,7 +939,7 @@ public class BattleSessionDispatchTests
|
|||||||
// handler at Matching.cs:417 → SetNetworkInfo overwrites it with our
|
// handler at Matching.cs:417 → SetNetworkInfo overwrites it with our
|
||||||
// placeholder NoOpBotParticipant.Context zeros).
|
// placeholder NoOpBotParticipant.Context zeros).
|
||||||
Assert.That(routes, Is.Empty, "Bot Loaded is silent.");
|
Assert.That(routes, Is.Empty, "Bot Loaded is silent.");
|
||||||
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingSwap),
|
Assert.That(a.Phase, Is.EqualTo(HandshakePhase.AwaitingSwap),
|
||||||
"Phase still advances even though there are no outbound routes.");
|
"Phase still advances even though there are no outbound routes.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -921,7 +956,7 @@ public class BattleSessionDispatchTests
|
|||||||
Assert.That(routes.Select(r => r.Frame.Uri),
|
Assert.That(routes.Select(r => r.Frame.Uri),
|
||||||
Is.EqualTo(new[] { NetworkBattleUri.Swap, NetworkBattleUri.Ready }));
|
Is.EqualTo(new[] { NetworkBattleUri.Swap, NetworkBattleUri.Ready }));
|
||||||
Assert.That(routes.All(r => ReferenceEquals(r.Target, a)), Is.True);
|
Assert.That(routes.All(r => ReferenceEquals(r.Target, a)), Is.True);
|
||||||
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AfterReady));
|
Assert.That(a.Phase, Is.EqualTo(HandshakePhase.AfterReady));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -1013,32 +1048,32 @@ public class BattleSessionDispatchTests
|
|||||||
|
|
||||||
private static MatchContext PlayerACtx() => new(
|
private static MatchContext PlayerACtx() => new(
|
||||||
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(),
|
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(),
|
||||||
ClassId: "3", CharaId: "3", CardMasterName: "card_master_node_10015",
|
ClassId: CardClass.Runecraft, CharaId: "3", CardMasterName: "card_master_node_10015",
|
||||||
CountryCode: "KOR", UserName: "PlayerA", SleeveId: "3000011",
|
CountryCode: CountryCodes.Korea, UserName: "PlayerA", SleeveId: "3000011",
|
||||||
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
|
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
|
||||||
BattleType: 11);
|
BattleModeId: BattleModes.TakeTwo);
|
||||||
|
|
||||||
private static MatchContext PlayerBCtx() => new(
|
private static MatchContext PlayerBCtx() => new(
|
||||||
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 200_011_010L).ToList(),
|
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 200_011_010L).ToList(),
|
||||||
ClassId: "5", CharaId: "5", CardMasterName: "card_master_node_10015",
|
ClassId: CardClass.Shadowcraft, CharaId: "5", CardMasterName: "card_master_node_10015",
|
||||||
CountryCode: "JPN", UserName: "PlayerB", SleeveId: "3000022",
|
CountryCode: CountryCodes.Japan, UserName: "PlayerB", SleeveId: "3000022",
|
||||||
EmblemId: "701441022", DegreeId: "300004", FieldId: 44, IsOfficial: 0,
|
EmblemId: "701441022", DegreeId: "300004", FieldId: 44, IsOfficial: 0,
|
||||||
BattleType: 11);
|
BattleModeId: BattleModes.TakeTwo);
|
||||||
|
|
||||||
private static MatchContext FixtureCtx() => new(
|
private static MatchContext FixtureCtx() => new(
|
||||||
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(),
|
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(),
|
||||||
ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015",
|
ClassId: CardClass.Forestcraft, CharaId: "1", CardMasterName: "card_master_node_10015",
|
||||||
CountryCode: "KOR", UserName: "Player", SleeveId: "3000011",
|
CountryCode: CountryCodes.Korea, UserName: "Player", SleeveId: "3000011",
|
||||||
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
|
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
|
||||||
BattleType: 11);
|
BattleModeId: BattleModes.TakeTwo);
|
||||||
|
|
||||||
private static MsgEnvelope NewEnvelope(NetworkBattleUri uri) =>
|
private static MsgEnvelope NewEnvelope(NetworkBattleUri uri) =>
|
||||||
new(uri, ViewerId: 1, Uuid: "u", Bid: null, Try: 0,
|
new(uri, ViewerId: 1, Uuid: "u", Bid: null, RetryAttempt: 0,
|
||||||
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null,
|
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null,
|
||||||
Body: new RawBody(new Dictionary<string, object?>()));
|
Body: new RawBody(new Dictionary<string, object?>()));
|
||||||
|
|
||||||
private static MsgEnvelope EnvWith(NetworkBattleUri uri, Dictionary<string, object?> body) =>
|
private static MsgEnvelope EnvWith(NetworkBattleUri uri, Dictionary<string, object?> body) =>
|
||||||
new(uri, ViewerId: 1, Uuid: "u", Bid: null, Try: 0,
|
new(uri, ViewerId: 1, Uuid: "u", Bid: null, RetryAttempt: 0,
|
||||||
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Body: new RawBody(body));
|
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Body: new RawBody(body));
|
||||||
|
|
||||||
private static Dictionary<string, object?> MoveOrderList(int idx, int from, int to) => new()
|
private static Dictionary<string, object?> MoveOrderList(int idx, int from, int to) => new()
|
||||||
@@ -1064,7 +1099,7 @@ public class BattleSessionDispatchTests
|
|||||||
public MatchContext Context { get; }
|
public MatchContext Context { get; }
|
||||||
public event Func<MsgEnvelope, CancellationToken, Task>? FrameEmitted;
|
public event Func<MsgEnvelope, CancellationToken, Task>? FrameEmitted;
|
||||||
public FakeParticipant(long viewerId, MatchContext context) { ViewerId = viewerId; Context = context; }
|
public FakeParticipant(long viewerId, MatchContext context) { ViewerId = viewerId; Context = context; }
|
||||||
public Task PushAsync(MsgEnvelope env, bool noStock, CancellationToken ct) => Task.CompletedTask;
|
public Task PushAsync(MsgEnvelope env, Stock stock, CancellationToken ct) => Task.CompletedTask;
|
||||||
public Task RunAsync(CancellationToken ct) => Task.CompletedTask;
|
public Task RunAsync(CancellationToken ct) => Task.CompletedTask;
|
||||||
public Task TerminateAsync(BattleFinishReason reason) => Task.CompletedTask;
|
public Task TerminateAsync(BattleFinishReason reason) => Task.CompletedTask;
|
||||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||||
@@ -1079,10 +1114,10 @@ public class BattleSessionDispatchTests
|
|||||||
{
|
{
|
||||||
public long ViewerId { get; }
|
public long ViewerId { get; }
|
||||||
public MatchContext Context { get; }
|
public MatchContext Context { get; }
|
||||||
public BattleSessionPhase Phase { get; set; } = BattleSessionPhase.AwaitingInitNetwork;
|
public HandshakePhase Phase { get; set; } = HandshakePhase.AwaitingInitNetwork;
|
||||||
public event Func<MsgEnvelope, CancellationToken, Task>? FrameEmitted;
|
public event Func<MsgEnvelope, CancellationToken, Task>? FrameEmitted;
|
||||||
public FakeRealParticipant(long viewerId, MatchContext context) { ViewerId = viewerId; Context = context; }
|
public FakeRealParticipant(long viewerId, MatchContext context) { ViewerId = viewerId; Context = context; }
|
||||||
public Task PushAsync(MsgEnvelope env, bool noStock, CancellationToken ct) => Task.CompletedTask;
|
public Task PushAsync(MsgEnvelope env, Stock stock, CancellationToken ct) => Task.CompletedTask;
|
||||||
public Task RunAsync(CancellationToken ct) => Task.CompletedTask;
|
public Task RunAsync(CancellationToken ct) => Task.CompletedTask;
|
||||||
public Task TerminateAsync(BattleFinishReason reason) => Task.CompletedTask;
|
public Task TerminateAsync(BattleFinishReason reason) => Task.CompletedTask;
|
||||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ public class BattleSessionStateTests
|
|||||||
public MatchContext Context { get; }
|
public MatchContext Context { get; }
|
||||||
public event Func<SVSim.BattleNode.Protocol.MsgEnvelope, CancellationToken, Task>? FrameEmitted;
|
public event Func<SVSim.BattleNode.Protocol.MsgEnvelope, CancellationToken, Task>? FrameEmitted;
|
||||||
public StubParticipant(long id, MatchContext ctx) { ViewerId = id; Context = ctx; }
|
public StubParticipant(long id, MatchContext ctx) { ViewerId = id; Context = ctx; }
|
||||||
public Task PushAsync(SVSim.BattleNode.Protocol.MsgEnvelope e, bool n, CancellationToken c) => Task.CompletedTask;
|
public Task PushAsync(SVSim.BattleNode.Protocol.MsgEnvelope e, Stock n, CancellationToken c) => Task.CompletedTask;
|
||||||
public Task RunAsync(CancellationToken c) => Task.CompletedTask;
|
public Task RunAsync(CancellationToken c) => Task.CompletedTask;
|
||||||
public Task TerminateAsync(BattleFinishReason r) => Task.CompletedTask;
|
public Task TerminateAsync(BattleFinishReason r) => Task.CompletedTask;
|
||||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||||
@@ -22,22 +22,26 @@ public class BattleSessionStateTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static MatchContext Ctx(params long[] deck) => new(
|
private static MatchContext Ctx(params long[] deck) => new(
|
||||||
SelfDeckCardIds: deck, ClassId: "1", CharaId: "1", CardMasterName: "cm",
|
SelfDeckCardIds: deck, ClassId: CardClass.Forestcraft, CharaId: "1", CardMasterName: "cm",
|
||||||
CountryCode: "KOR", UserName: "P", SleeveId: "0", EmblemId: "0", DegreeId: "0",
|
CountryCode: CountryCodes.Korea, UserName: "P", SleeveId: "0", EmblemId: "0", DegreeId: "0",
|
||||||
FieldId: 0, IsOfficial: 0, BattleType: 11);
|
FieldId: 0, IsOfficial: 0, BattleModeId: BattleModes.TakeTwo);
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void GetOrSeedDeckMap_maps_idx_1based_to_deck_cardIds()
|
public void GetOrSeedDeckMap_maps_idx_1based_to_the_shuffled_order()
|
||||||
{
|
{
|
||||||
var state = new BattleSessionState();
|
// The map seeds from GetShuffledDeck, not raw build order. idx (i+1) -> shuffledDeck[i],
|
||||||
|
// and the set of cardIds is unchanged (1..3 present, 4 absent).
|
||||||
|
var state = new BattleSessionState(masterSeed: 12345);
|
||||||
var p = new StubParticipant(1, Ctx(900L, 901L, 902L));
|
var p = new StubParticipant(1, Ctx(900L, 901L, 902L));
|
||||||
|
var shuffled = state.GetShuffledDeck(p);
|
||||||
|
|
||||||
var map = state.GetOrSeedDeckMap(p);
|
var map = state.GetOrSeedDeckMap(p);
|
||||||
|
|
||||||
Assert.That(map[1], Is.EqualTo(900L));
|
Assert.That(map[1], Is.EqualTo(shuffled[0]));
|
||||||
Assert.That(map[2], Is.EqualTo(901L));
|
Assert.That(map[2], Is.EqualTo(shuffled[1]));
|
||||||
Assert.That(map[3], Is.EqualTo(902L));
|
Assert.That(map[3], Is.EqualTo(shuffled[2]));
|
||||||
Assert.That(map.ContainsKey(4), Is.False);
|
Assert.That(map.ContainsKey(4), Is.False);
|
||||||
|
Assert.That(new[] { map[1], map[2], map[3] }, Is.EquivalentTo(new[] { 900L, 901L, 902L }));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -47,4 +51,43 @@ public class BattleSessionStateTests
|
|||||||
var p = new StubParticipant(1, Ctx(900L));
|
var p = new StubParticipant(1, Ctx(900L));
|
||||||
Assert.That(state.GetOrSeedDeckMap(p), Is.SameAs(state.GetOrSeedDeckMap(p)));
|
Assert.That(state.GetOrSeedDeckMap(p), Is.SameAs(state.GetOrSeedDeckMap(p)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void GetShuffledDeck_is_a_permutation_of_the_input()
|
||||||
|
{
|
||||||
|
var state = new BattleSessionState(masterSeed: 12345);
|
||||||
|
var p = new StubParticipant(1001, Ctx(DistinctDeck()));
|
||||||
|
|
||||||
|
Assert.That(state.GetShuffledDeck(p), Is.EquivalentTo(DistinctDeck()),
|
||||||
|
"same multiset of cards, just reordered");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void GetShuffledDeck_actually_reorders_a_distinct_deck()
|
||||||
|
{
|
||||||
|
var state = new BattleSessionState(masterSeed: 12345);
|
||||||
|
var p = new StubParticipant(1001, Ctx(DistinctDeck()));
|
||||||
|
|
||||||
|
Assert.That(state.GetShuffledDeck(p), Is.Not.EqualTo(DistinctDeck()),
|
||||||
|
"a 30-card distinct deck should not survive the shuffle in original order");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void GetShuffledDeck_is_deterministic_for_same_master_seed_and_viewer()
|
||||||
|
{
|
||||||
|
var a = new BattleSessionState(masterSeed: 777).GetShuffledDeck(new StubParticipant(1001, Ctx(DistinctDeck())));
|
||||||
|
var b = new BattleSessionState(masterSeed: 777).GetShuffledDeck(new StubParticipant(1001, Ctx(DistinctDeck())));
|
||||||
|
Assert.That(a, Is.EqualTo(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void GetShuffledDeck_differs_across_master_seeds()
|
||||||
|
{
|
||||||
|
var a = new BattleSessionState(masterSeed: 1).GetShuffledDeck(new StubParticipant(1001, Ctx(DistinctDeck())));
|
||||||
|
var b = new BattleSessionState(masterSeed: 2).GetShuffledDeck(new StubParticipant(1001, Ctx(DistinctDeck())));
|
||||||
|
Assert.That(a, Is.Not.EqualTo(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long[] DistinctDeck() =>
|
||||||
|
Enumerable.Range(1, 30).Select(i => 200_000_000L + i).ToArray();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,12 +47,12 @@ public class BattleSessionTerminateCascadeTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static MsgEnvelope MakeEnvelope(NetworkBattleUri uri) =>
|
private static MsgEnvelope MakeEnvelope(NetworkBattleUri uri) =>
|
||||||
new(uri, ViewerId: 1, Uuid: "u", Bid: null, Try: 0, Cat: EmitCategory.Battle,
|
new(uri, ViewerId: 1, Uuid: "u", Bid: null, RetryAttempt: 0, Cat: EmitCategory.Battle,
|
||||||
PubSeq: null, PlaySeq: null, Body: new RawBody(new Dictionary<string, object?>()));
|
PubSeq: null, PlaySeq: null, Body: new RawBody(new Dictionary<string, object?>()));
|
||||||
|
|
||||||
private static MatchContext MakeFakeContext() => new(
|
private static MatchContext MakeFakeContext() => new(
|
||||||
SelfDeckCardIds: Array.Empty<long>(),
|
SelfDeckCardIds: Array.Empty<long>(),
|
||||||
ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015",
|
ClassId: CardClass.Forestcraft, CharaId: "1", CardMasterName: "card_master_node_10015",
|
||||||
CountryCode: "JP", UserName: "Test", SleeveId: "0",
|
CountryCode: "JP", UserName: "Test", SleeveId: "0",
|
||||||
EmblemId: "0", DegreeId: "0", FieldId: 0, IsOfficial: 0, BattleType: 11);
|
EmblemId: "0", DegreeId: "0", FieldId: 0, IsOfficial: 0, BattleModeId: BattleModes.TakeTwo);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,45 +7,46 @@ namespace SVSim.UnitTests.BattleNode.Sessions;
|
|||||||
[TestFixture]
|
[TestFixture]
|
||||||
public class InMemoryBattleSessionStoreTests
|
public class InMemoryBattleSessionStoreTests
|
||||||
{
|
{
|
||||||
private InMemoryBattleSessionStore _store = null!;
|
|
||||||
|
|
||||||
[SetUp] public void Setup() => _store = new InMemoryBattleSessionStore();
|
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void RegisterThenGet_ReturnsRegisteredBattle()
|
public void TryRegisterThenGet_ReturnsRegisteredBattle()
|
||||||
{
|
{
|
||||||
|
var store = new InMemoryBattleSessionStore();
|
||||||
var battle = new PendingBattle("bid-1", BattleType.Bot, new BattlePlayer(906243102, FixtureCtx()), null);
|
var battle = new PendingBattle("bid-1", BattleType.Bot, new BattlePlayer(906243102, FixtureCtx()), null);
|
||||||
_store.RegisterPending(battle);
|
Assert.That(store.TryRegisterPending(battle), Is.True);
|
||||||
|
|
||||||
Assert.That(_store.TryGetPending("bid-1"), Is.EqualTo(battle));
|
Assert.That(store.TryGetPending("bid-1"), Is.EqualTo(battle));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void Get_UnknownBattleId_ReturnsNull()
|
public void Get_UnknownBattleId_ReturnsNull()
|
||||||
{
|
{
|
||||||
Assert.That(_store.TryGetPending("nope"), Is.Null);
|
var store = new InMemoryBattleSessionStore();
|
||||||
|
Assert.That(store.TryGetPending("nope"), Is.Null);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void Remove_ReturnsTrueWhenPresent_FalseWhenAbsent()
|
public void Remove_ReturnsTrueWhenPresent_FalseWhenAbsent()
|
||||||
{
|
{
|
||||||
_store.RegisterPending(new PendingBattle("bid", BattleType.Bot, new BattlePlayer(1, FixtureCtx()), null));
|
var store = new InMemoryBattleSessionStore();
|
||||||
Assert.That(_store.RemovePending("bid"), Is.True);
|
store.TryRegisterPending(new PendingBattle("bid", BattleType.Bot, new BattlePlayer(1, FixtureCtx()), null));
|
||||||
Assert.That(_store.RemovePending("bid"), Is.False);
|
Assert.That(store.RemovePending("bid"), Is.True);
|
||||||
|
Assert.That(store.RemovePending("bid"), Is.False);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void Register_DuplicateBattleId_OverwritesPrior()
|
public void TryRegister_DuplicateBattleId_ReturnsFalseAndPreservesOriginal()
|
||||||
{
|
{
|
||||||
_store.RegisterPending(new PendingBattle("bid", BattleType.Bot, new BattlePlayer(1, FixtureCtx()), null));
|
var store = new InMemoryBattleSessionStore();
|
||||||
_store.RegisterPending(new PendingBattle("bid", BattleType.Bot, new BattlePlayer(2, FixtureCtx()), null));
|
store.TryRegisterPending(new PendingBattle("bid", BattleType.Bot, new BattlePlayer(1, FixtureCtx()), null));
|
||||||
Assert.That(_store.TryGetPending("bid")!.P1.ViewerId, Is.EqualTo(2));
|
var second = store.TryRegisterPending(new PendingBattle("bid", BattleType.Bot, new BattlePlayer(2, FixtureCtx()), null));
|
||||||
|
Assert.That(second, Is.False);
|
||||||
|
Assert.That(store.TryGetPending("bid")!.P1.ViewerId, Is.EqualTo(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static MatchContext FixtureCtx() => new(
|
private static MatchContext FixtureCtx() => new(
|
||||||
SelfDeckCardIds: Enumerable.Range(1, 30).Select(i => 100_011_010L).ToList(),
|
SelfDeckCardIds: Enumerable.Range(1, 30).Select(i => 100_011_010L).ToList(),
|
||||||
ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015",
|
ClassId: CardClass.Forestcraft, CharaId: "1", CardMasterName: "card_master_node_10015",
|
||||||
CountryCode: "KOR", UserName: "Player", SleeveId: "3000011",
|
CountryCode: CountryCodes.Korea, UserName: "Player", SleeveId: "3000011",
|
||||||
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
|
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
|
||||||
BattleType: 11);
|
BattleModeId: BattleModes.TakeTwo);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using NUnit.Framework;
|
using NUnit.Framework;
|
||||||
|
using SVSim.BattleNode.Protocol;
|
||||||
using SVSim.BattleNode.Protocol.Bodies;
|
using SVSim.BattleNode.Protocol.Bodies;
|
||||||
using SVSim.BattleNode.Sessions.Dispatch;
|
using SVSim.BattleNode.Sessions.Dispatch;
|
||||||
|
|
||||||
@@ -103,7 +104,7 @@ public class KnownListBuilderTests
|
|||||||
Assert.That(renamed, Is.Not.Null);
|
Assert.That(renamed, Is.Not.Null);
|
||||||
Assert.That(renamed!.Count, Is.EqualTo(1));
|
Assert.That(renamed!.Count, Is.EqualTo(1));
|
||||||
Assert.That(renamed[0].TargetIdx, Is.EqualTo(8));
|
Assert.That(renamed[0].TargetIdx, Is.EqualTo(8));
|
||||||
Assert.That(renamed[0].IsSelf, Is.EqualTo(0));
|
Assert.That(renamed[0].IsSelf, Is.EqualTo(CardOwner.Opponent));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -130,7 +131,7 @@ public class KnownListBuilderTests
|
|||||||
var orderList = new List<object?> { AddOp(new[] { 31L, 32L }, 900111010L) };
|
var orderList = new List<object?> { AddOp(new[] { 31L, 32L }, 900111010L) };
|
||||||
var mined = KnownListBuilder.MineAddOps(orderList).ToList();
|
var mined = KnownListBuilder.MineAddOps(orderList).ToList();
|
||||||
|
|
||||||
Assert.That(mined, Is.EquivalentTo(new[] { (31, 900111010L, 1), (32, 900111010L, 1) }));
|
Assert.That(mined, Is.EquivalentTo(new[] { new MinedToken(31, 900111010L, CardOwner.Self), new MinedToken(32, 900111010L, CardOwner.Self) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -141,7 +142,7 @@ public class KnownListBuilderTests
|
|||||||
// it; the caller routes it into the OTHER side's map.
|
// it; the caller routes it into the OTHER side's map.
|
||||||
var orderList = new List<object?> { AddOp(new[] { 31L }, 900111010L, isSelf: 0) };
|
var orderList = new List<object?> { AddOp(new[] { 31L }, 900111010L, isSelf: 0) };
|
||||||
Assert.That(KnownListBuilder.MineAddOps(orderList),
|
Assert.That(KnownListBuilder.MineAddOps(orderList),
|
||||||
Is.EquivalentTo(new[] { (31, 900111010L, 0) }));
|
Is.EquivalentTo(new[] { new MinedToken(31, 900111010L, CardOwner.Opponent) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -204,7 +205,7 @@ public class KnownListBuilderTests
|
|||||||
AddOp(new[] { 32L }, 900811090L),
|
AddOp(new[] { 32L }, 900811090L),
|
||||||
};
|
};
|
||||||
var mined = KnownListBuilder.MineAddOps(orderList).ToList();
|
var mined = KnownListBuilder.MineAddOps(orderList).ToList();
|
||||||
Assert.That(mined, Is.EquivalentTo(new[] { (31, 900111010L, 1), (32, 900811090L, 1) }));
|
Assert.That(mined, Is.EquivalentTo(new[] { new MinedToken(31, 900111010L, CardOwner.Self), new MinedToken(32, 900811090L, CardOwner.Self) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// A choice/Discover add op as it arrives in a RawBody: candidates-only (no concrete cardId —
|
// A choice/Discover add op as it arrives in a RawBody: candidates-only (no concrete cardId —
|
||||||
@@ -247,7 +248,7 @@ public class KnownListBuilderTests
|
|||||||
var keyAction = KeyActionChoice(generatingCardId: 810014030L, chosen: new[] { 810041260L }, open: 0);
|
var keyAction = KeyActionChoice(generatingCardId: 810014030L, chosen: new[] { 810041260L }, open: 0);
|
||||||
|
|
||||||
Assert.That(KnownListBuilder.MineChoicePicks(orderList, keyAction),
|
Assert.That(KnownListBuilder.MineChoicePicks(orderList, keyAction),
|
||||||
Is.EquivalentTo(new[] { (46, 810041260L, 1) }));
|
Is.EquivalentTo(new[] { new MinedToken(46, 810041260L, CardOwner.Self) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -259,7 +260,7 @@ public class KnownListBuilderTests
|
|||||||
var keyAction = KeyActionChoice(810014030L, new[] { 101041020L }, open: 0);
|
var keyAction = KeyActionChoice(810014030L, new[] { 101041020L }, open: 0);
|
||||||
|
|
||||||
Assert.That(KnownListBuilder.MineChoicePicks(orderList, keyAction),
|
Assert.That(KnownListBuilder.MineChoicePicks(orderList, keyAction),
|
||||||
Is.EquivalentTo(new[] { (46, 101041020L, 0) }));
|
Is.EquivalentTo(new[] { new MinedToken(46, 101041020L, CardOwner.Opponent) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -302,7 +303,7 @@ public class KnownListBuilderTests
|
|||||||
|
|
||||||
Assert.That(stripped, Is.Not.Null);
|
Assert.That(stripped, Is.Not.Null);
|
||||||
Assert.That(stripped!.Count, Is.EqualTo(1));
|
Assert.That(stripped!.Count, Is.EqualTo(1));
|
||||||
Assert.That(stripped[0].Type, Is.EqualTo(1));
|
Assert.That(stripped[0].Type, Is.EqualTo(KeyActionType.Choice));
|
||||||
Assert.That(stripped[0].CardId, Is.EqualTo(810014030L));
|
Assert.That(stripped[0].CardId, Is.EqualTo(810014030L));
|
||||||
Assert.That(stripped[0].SelectCard, Is.Null);
|
Assert.That(stripped[0].SelectCard, Is.Null);
|
||||||
}
|
}
|
||||||
@@ -316,7 +317,7 @@ public class KnownListBuilderTests
|
|||||||
|
|
||||||
Assert.That(stripped![0].SelectCard, Is.Not.Null);
|
Assert.That(stripped![0].SelectCard, Is.Not.Null);
|
||||||
Assert.That(stripped[0].SelectCard!.CardId, Is.EqualTo(new[] { 810041260L }));
|
Assert.That(stripped[0].SelectCard!.CardId, Is.EqualTo(new[] { 810041260L }));
|
||||||
Assert.That(stripped[0].SelectCard.Open, Is.EqualTo(1));
|
Assert.That(stripped[0].SelectCard.Open, Is.EqualTo(ChoiceVisibility.Open));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -356,7 +357,7 @@ public class KnownListBuilderTests
|
|||||||
var selfMap = new Dictionary<int, long> { [5] = 100_011_010L };
|
var selfMap = new Dictionary<int, long> { [5] = 100_011_010L };
|
||||||
var otherMap = new Dictionary<int, long>();
|
var otherMap = new Dictionary<int, long>();
|
||||||
var mined = KnownListBuilder.MineCopyTokens(orderList, selfMap, otherMap).ToList();
|
var mined = KnownListBuilder.MineCopyTokens(orderList, selfMap, otherMap).ToList();
|
||||||
Assert.That(mined, Is.EquivalentTo(new[] { (31, 100_011_010L, 1) }));
|
Assert.That(mined, Is.EquivalentTo(new[] { new MinedToken(31, 100_011_010L, CardOwner.Self) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -368,7 +369,7 @@ public class KnownListBuilderTests
|
|||||||
var selfMap = new Dictionary<int, long>();
|
var selfMap = new Dictionary<int, long>();
|
||||||
var otherMap = new Dictionary<int, long> { [21] = 900_841_330L };
|
var otherMap = new Dictionary<int, long> { [21] = 900_841_330L };
|
||||||
var mined = KnownListBuilder.MineCopyTokens(orderList, selfMap, otherMap).ToList();
|
var mined = KnownListBuilder.MineCopyTokens(orderList, selfMap, otherMap).ToList();
|
||||||
Assert.That(mined, Is.EquivalentTo(new[] { (49, 900_841_330L, 0) }));
|
Assert.That(mined, Is.EquivalentTo(new[] { new MinedToken(49, 900_841_330L, CardOwner.Opponent) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -421,7 +422,7 @@ public class KnownListBuilderTests
|
|||||||
var orderList = new List<object?> { CopyOp(new[] { 31L, 32L }, baseIdx: 5L, isSelf: 1) };
|
var orderList = new List<object?> { CopyOp(new[] { 31L, 32L }, baseIdx: 5L, isSelf: 1) };
|
||||||
var selfMap = new Dictionary<int, long> { [5] = 700L };
|
var selfMap = new Dictionary<int, long> { [5] = 700L };
|
||||||
var mined = KnownListBuilder.MineCopyTokens(orderList, selfMap, new Dictionary<int, long>()).ToList();
|
var mined = KnownListBuilder.MineCopyTokens(orderList, selfMap, new Dictionary<int, long>()).ToList();
|
||||||
Assert.That(mined, Is.EquivalentTo(new[] { (31, 700L, 1), (32, 700L, 1) }));
|
Assert.That(mined, Is.EquivalentTo(new[] { new MinedToken(31, 700L, CardOwner.Self), new MinedToken(32, 700L, CardOwner.Self) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// A uList entry as it arrives in a RawBody. Minimal = the 5 always-present fields
|
// A uList entry as it arrives in a RawBody. Minimal = the 5 always-present fields
|
||||||
@@ -447,7 +448,7 @@ public class KnownListBuilderTests
|
|||||||
Assert.That(e.IdxList, Is.EqualTo(new[] { 16, 22 }));
|
Assert.That(e.IdxList, Is.EqualTo(new[] { 16, 22 }));
|
||||||
Assert.That(e.From, Is.EqualTo(0));
|
Assert.That(e.From, Is.EqualTo(0));
|
||||||
Assert.That(e.To, Is.EqualTo(10));
|
Assert.That(e.To, Is.EqualTo(10));
|
||||||
Assert.That(e.IsSelf, Is.EqualTo(1));
|
Assert.That(e.IsSelf, Is.EqualTo(CardOwner.Self));
|
||||||
Assert.That(e.Skill, Is.EqualTo("37|36|0"));
|
Assert.That(e.Skill, Is.EqualTo("37|36|0"));
|
||||||
Assert.That(e.CardId, Is.Null);
|
Assert.That(e.CardId, Is.Null);
|
||||||
Assert.That(e.Clan, Is.Null);
|
Assert.That(e.Clan, Is.Null);
|
||||||
@@ -479,7 +480,7 @@ public class KnownListBuilderTests
|
|||||||
Assert.That(e.Cost, Is.EqualTo(2));
|
Assert.That(e.Cost, Is.EqualTo(2));
|
||||||
Assert.That(e.SkillKeyCardIdx, Is.EqualTo(new[] { 7 }));
|
Assert.That(e.SkillKeyCardIdx, Is.EqualTo(new[] { 7 }));
|
||||||
Assert.That(e.RandomTargetIdx, Is.EqualTo(new[] { 2, 3 }));
|
Assert.That(e.RandomTargetIdx, Is.EqualTo(new[] { 2, 3 }));
|
||||||
Assert.That(e.IsInvoke, Is.EqualTo(1));
|
Assert.That(e.IsInvoke, Is.True);
|
||||||
Assert.That(e.AttachTarget, Is.EqualTo("12,13"));
|
Assert.That(e.AttachTarget, Is.EqualTo("12,13"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -496,7 +497,7 @@ public class KnownListBuilderTests
|
|||||||
Assert.That(relayed!.Count, Is.EqualTo(2));
|
Assert.That(relayed!.Count, Is.EqualTo(2));
|
||||||
Assert.That(relayed[0].Skill, Is.EqualTo("a"));
|
Assert.That(relayed[0].Skill, Is.EqualTo("a"));
|
||||||
Assert.That(relayed[1].Skill, Is.EqualTo("b"));
|
Assert.That(relayed[1].Skill, Is.EqualTo("b"));
|
||||||
Assert.That(relayed[1].IsSelf, Is.EqualTo(0));
|
Assert.That(relayed[1].IsSelf, Is.EqualTo(CardOwner.Opponent));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
|
|||||||
@@ -18,11 +18,11 @@ public class NoOpBotParticipantTests
|
|||||||
p.FrameEmitted += (_, _) => { fired++; return Task.CompletedTask; };
|
p.FrameEmitted += (_, _) => { fired++; return Task.CompletedTask; };
|
||||||
|
|
||||||
var env = new MsgEnvelope(
|
var env = new MsgEnvelope(
|
||||||
NetworkBattleUri.TurnEnd, ViewerId: 1, Uuid: "u", Bid: null, Try: 0,
|
NetworkBattleUri.TurnEnd, ViewerId: 1, Uuid: "u", Bid: null, RetryAttempt: 0,
|
||||||
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null,
|
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null,
|
||||||
Body: new ResultCodeOnlyBody());
|
Body: new ResultCodeOnlyBody());
|
||||||
|
|
||||||
Assert.DoesNotThrowAsync(() => p.PushAsync(env, noStock: false, CancellationToken.None));
|
Assert.DoesNotThrowAsync(() => p.PushAsync(env, Stock.Normal, CancellationToken.None));
|
||||||
Assert.That(fired, Is.EqualTo(0));
|
Assert.That(fired, Is.EqualTo(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -147,8 +147,8 @@ public class RealParticipantHandEventTests
|
|||||||
|
|
||||||
private static MatchContext FixtureCtx() => new(
|
private static MatchContext FixtureCtx() => new(
|
||||||
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(),
|
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(),
|
||||||
ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015",
|
ClassId: CardClass.Forestcraft, CharaId: "1", CardMasterName: "card_master_node_10015",
|
||||||
CountryCode: "KOR", UserName: "Player", SleeveId: "3000011",
|
CountryCode: CountryCodes.Korea, UserName: "Player", SleeveId: "3000011",
|
||||||
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
|
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
|
||||||
BattleType: 11);
|
BattleModeId: BattleModes.TakeTwo);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ public class RealParticipantTests
|
|||||||
// First ordered push gets playSeq = 1; second = 2; etc.
|
// First ordered push gets playSeq = 1; second = 2; etc.
|
||||||
// Inspect the participant's outbound sequencer state via its public Archive.
|
// Inspect the participant's outbound sequencer state via its public Archive.
|
||||||
var env = NewEnvelope(NetworkBattleUri.Matched);
|
var env = NewEnvelope(NetworkBattleUri.Matched);
|
||||||
p.PushAsync(env, noStock: false, CancellationToken.None).Wait();
|
p.PushAsync(env, Stock.Normal, CancellationToken.None).Wait();
|
||||||
p.PushAsync(env, noStock: false, CancellationToken.None).Wait();
|
p.PushAsync(env, Stock.Normal, CancellationToken.None).Wait();
|
||||||
|
|
||||||
Assert.That(p.Outbound.Archive.Count, Is.EqualTo(2));
|
Assert.That(p.Outbound.Archive.Count, Is.EqualTo(2));
|
||||||
Assert.That(p.Outbound.Archive[1].PlaySeq, Is.EqualTo(1));
|
Assert.That(p.Outbound.Archive[1].PlaySeq, Is.EqualTo(1));
|
||||||
@@ -37,7 +37,7 @@ public class RealParticipantTests
|
|||||||
var p = new RealParticipant(ws, viewerId: 1, FixtureCtx(),
|
var p = new RealParticipant(ws, viewerId: 1, FixtureCtx(),
|
||||||
NullLogger<RealParticipant>.Instance);
|
NullLogger<RealParticipant>.Instance);
|
||||||
|
|
||||||
p.PushAsync(NewEnvelope(NetworkBattleUri.BattleFinish), noStock: true, CancellationToken.None).Wait();
|
p.PushAsync(NewEnvelope(NetworkBattleUri.BattleFinish), Stock.Bypass, CancellationToken.None).Wait();
|
||||||
|
|
||||||
// No playSeq archive entry for no-stock pushes.
|
// No playSeq archive entry for no-stock pushes.
|
||||||
Assert.That(p.Outbound.Archive.Count, Is.EqualTo(0));
|
Assert.That(p.Outbound.Archive.Count, Is.EqualTo(0));
|
||||||
@@ -97,7 +97,7 @@ public class RealParticipantTests
|
|||||||
var p = new RealParticipant(ws, viewerId: 1, FixtureCtx(),
|
var p = new RealParticipant(ws, viewerId: 1, FixtureCtx(),
|
||||||
NullLogger<RealParticipant>.Instance);
|
NullLogger<RealParticipant>.Instance);
|
||||||
|
|
||||||
Assert.That(p.Phase, Is.EqualTo(SVSim.BattleNode.Sessions.BattleSessionPhase.AwaitingInitNetwork));
|
Assert.That(p.Phase, Is.EqualTo(SVSim.BattleNode.Sessions.HandshakePhase.AwaitingInitNetwork));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -108,9 +108,9 @@ public class RealParticipantTests
|
|||||||
NullLogger<RealParticipant>.Instance);
|
NullLogger<RealParticipant>.Instance);
|
||||||
|
|
||||||
// Setter is `internal`; SVSim.UnitTests has InternalsVisibleTo on SVSim.BattleNode.
|
// Setter is `internal`; SVSim.UnitTests has InternalsVisibleTo on SVSim.BattleNode.
|
||||||
p.Phase = SVSim.BattleNode.Sessions.BattleSessionPhase.AfterReady;
|
p.Phase = SVSim.BattleNode.Sessions.HandshakePhase.AfterReady;
|
||||||
|
|
||||||
Assert.That(p.Phase, Is.EqualTo(SVSim.BattleNode.Sessions.BattleSessionPhase.AfterReady));
|
Assert.That(p.Phase, Is.EqualTo(SVSim.BattleNode.Sessions.HandshakePhase.AfterReady));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -121,9 +121,9 @@ public class RealParticipantTests
|
|||||||
var a = new RealParticipant(wsA, viewerId: 1, FixtureCtx(), NullLogger<RealParticipant>.Instance);
|
var a = new RealParticipant(wsA, viewerId: 1, FixtureCtx(), NullLogger<RealParticipant>.Instance);
|
||||||
var b = new RealParticipant(wsB, viewerId: 2, FixtureCtx(), NullLogger<RealParticipant>.Instance);
|
var b = new RealParticipant(wsB, viewerId: 2, FixtureCtx(), NullLogger<RealParticipant>.Instance);
|
||||||
|
|
||||||
a.Phase = SVSim.BattleNode.Sessions.BattleSessionPhase.AfterReady;
|
a.Phase = SVSim.BattleNode.Sessions.HandshakePhase.AfterReady;
|
||||||
|
|
||||||
Assert.That(b.Phase, Is.EqualTo(SVSim.BattleNode.Sessions.BattleSessionPhase.AwaitingInitNetwork),
|
Assert.That(b.Phase, Is.EqualTo(SVSim.BattleNode.Sessions.HandshakePhase.AwaitingInitNetwork),
|
||||||
"B's Phase must not change when A's Phase is set.");
|
"B's Phase must not change when A's Phase is set.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,13 +171,13 @@ public class RealParticipantTests
|
|||||||
|
|
||||||
private static MatchContext FixtureCtx() => new(
|
private static MatchContext FixtureCtx() => new(
|
||||||
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(),
|
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(),
|
||||||
ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015",
|
ClassId: CardClass.Forestcraft, CharaId: "1", CardMasterName: "card_master_node_10015",
|
||||||
CountryCode: "KOR", UserName: "Player", SleeveId: "3000011",
|
CountryCode: CountryCodes.Korea, UserName: "Player", SleeveId: "3000011",
|
||||||
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
|
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
|
||||||
BattleType: 11);
|
BattleModeId: BattleModes.TakeTwo);
|
||||||
|
|
||||||
private static MsgEnvelope NewEnvelope(NetworkBattleUri uri) =>
|
private static MsgEnvelope NewEnvelope(NetworkBattleUri uri) =>
|
||||||
new(uri, ViewerId: 1, Uuid: "u", Bid: null, Try: 0,
|
new(uri, ViewerId: 1, Uuid: "u", Bid: null, RetryAttempt: 0,
|
||||||
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null,
|
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null,
|
||||||
Body: new ResultCodeOnlyBody());
|
Body: new ResultCodeOnlyBody());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ public class SocketIoFrameTests
|
|||||||
[Test]
|
[Test]
|
||||||
public void Encode_AckResponse_IsTypeIdAndArrayOfArgs()
|
public void Encode_AckResponse_IsTypeIdAndArrayOfArgs()
|
||||||
{
|
{
|
||||||
var frame = SocketIoFrame.AckResponse(ackId: 7, arg: 123);
|
var frame = SocketIoFrame.AckResponse(ackId: 7, pubSeqEcho: 123);
|
||||||
var (text, bins) = frame.Encode();
|
var (text, bins) = frame.Encode();
|
||||||
|
|
||||||
Assert.That(text, Is.EqualTo("37[123]"));
|
Assert.That(text, Is.EqualTo("37[123]"));
|
||||||
@@ -125,4 +125,17 @@ public class SocketIoFrameTests
|
|||||||
// The event name must be JSON-escaped: each " becomes \", and the literal \ becomes \\.
|
// The event name must be JSON-escaped: each " becomes \", and the literal \ becomes \\.
|
||||||
Assert.That(text, Does.Contain("\"weird \\\"name\\\" with \\\\ backslash\""));
|
Assert.That(text, Does.Contain("\"weird \\\"name\\\" with \\\\ backslash\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Parse_InvalidTypeChar_Throws()
|
||||||
|
{
|
||||||
|
var ex = Assert.Throws<ArgumentException>(() => SocketIoFrame.Parse("9[\"msg\"]"));
|
||||||
|
Assert.That(ex!.Message, Does.Contain("Invalid SIO type char"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Parse_OverflowingAckId_Throws()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentException>(() => SocketIoFrame.Parse("2999999999999[\"msg\"]"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ namespace SVSim.UnitTests.Matching;
|
|||||||
[TestFixture]
|
[TestFixture]
|
||||||
public class BotRosterTests
|
public class BotRosterTests
|
||||||
{
|
{
|
||||||
private static MatchContext Ctx(string userName, string classId) => new(
|
private static MatchContext Ctx(string userName, CardClass classId) => new(
|
||||||
SelfDeckCardIds: Array.Empty<long>(),
|
SelfDeckCardIds: Array.Empty<long>(),
|
||||||
ClassId: classId, CharaId: classId, CardMasterName: "card_master_node_10015",
|
ClassId: classId, CharaId: classId.ToWireValue(), CardMasterName: "card_master_node_10015",
|
||||||
CountryCode: "JP", UserName: userName, SleeveId: "0",
|
CountryCode: "JP", UserName: userName, SleeveId: "0",
|
||||||
EmblemId: "0", DegreeId: "0", FieldId: 0, IsOfficial: 0, BattleType: 11);
|
EmblemId: "0", DegreeId: "0", FieldId: 0, IsOfficial: 0, BattleModeId: BattleModes.TakeTwo);
|
||||||
|
|
||||||
private static async Task<BotRoster> NewRosterAsync(SVSimTestFactory factory)
|
private static async Task<BotRoster> NewRosterAsync(SVSimTestFactory factory)
|
||||||
{
|
{
|
||||||
@@ -30,7 +30,7 @@ public class BotRosterTests
|
|||||||
using var factory = new SVSimTestFactory();
|
using var factory = new SVSimTestFactory();
|
||||||
var roster = await NewRosterAsync(factory);
|
var roster = await NewRosterAsync(factory);
|
||||||
|
|
||||||
var bot = await roster.PickAsync(Ctx("PlayerA", "1"));
|
var bot = await roster.PickAsync(Ctx("PlayerA", CardClass.Forestcraft), "123456789012");
|
||||||
|
|
||||||
// Series-1 enemy_ai_id values from data_dumps/client-assets/rm_ai_setting.csv —
|
// Series-1 enemy_ai_id values from data_dumps/client-assets/rm_ai_setting.csv —
|
||||||
// one per class (1=Forest, 2=Sword, 3=Rune, 4=Dragon, 5=Shadow, 6=Blood, 7=Haven, 8=Portal).
|
// one per class (1=Forest, 2=Sword, 3=Rune, 4=Dragon, 5=Shadow, 6=Blood, 7=Haven, 8=Portal).
|
||||||
@@ -44,7 +44,7 @@ public class BotRosterTests
|
|||||||
using var factory = new SVSimTestFactory();
|
using var factory = new SVSimTestFactory();
|
||||||
var roster = await NewRosterAsync(factory);
|
var roster = await NewRosterAsync(factory);
|
||||||
|
|
||||||
var bot = await roster.PickAsync(Ctx("PlayerA", "1"));
|
var bot = await roster.PickAsync(Ctx("PlayerA", CardClass.Forestcraft), "123456789012");
|
||||||
|
|
||||||
Assert.That(bot.ClassId, Is.InRange(1, 8));
|
Assert.That(bot.ClassId, Is.InRange(1, 8));
|
||||||
Assert.That(bot.CharaId, Is.InRange(1, 8));
|
Assert.That(bot.CharaId, Is.InRange(1, 8));
|
||||||
@@ -53,16 +53,33 @@ public class BotRosterTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task PickAsync_is_deterministic_per_match_context()
|
public async Task PickAsync_is_deterministic_per_battle_id()
|
||||||
{
|
{
|
||||||
using var factory = new SVSimTestFactory();
|
using var factory = new SVSimTestFactory();
|
||||||
var roster = await NewRosterAsync(factory);
|
var roster = await NewRosterAsync(factory);
|
||||||
var ctx = Ctx("PlayerA", "3");
|
var ctx = Ctx("PlayerA", CardClass.Runecraft);
|
||||||
|
|
||||||
var a = await roster.PickAsync(ctx);
|
var a = await roster.PickAsync(ctx, "999888777666");
|
||||||
var b = await roster.PickAsync(ctx);
|
var b = await roster.PickAsync(ctx, "999888777666");
|
||||||
|
|
||||||
Assert.That(a, Is.EqualTo(b), "Same ctx → same bot, so mid-flight retries get the same opponent.");
|
Assert.That(a, Is.EqualTo(b), "Same battleId → same bot, so mid-flight retries get the same opponent.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task PickAsync_varies_across_different_battle_ids()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
var roster = await NewRosterAsync(factory);
|
||||||
|
var ctx = Ctx("PlayerA", CardClass.Runecraft);
|
||||||
|
|
||||||
|
var seen = new HashSet<int>();
|
||||||
|
for (var i = 0; i < 20; i++)
|
||||||
|
{
|
||||||
|
var bot = await roster.PickAsync(ctx, $"{100000000000 + i}");
|
||||||
|
seen.Add(bot.AiId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.That(seen.Count, Is.GreaterThan(1), "Different battle IDs should pick different bots.");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -74,7 +91,7 @@ public class BotRosterTests
|
|||||||
var globals = scope.ServiceProvider.GetRequiredService<IGlobalsRepository>();
|
var globals = scope.ServiceProvider.GetRequiredService<IGlobalsRepository>();
|
||||||
var roster = new BotRoster(globals);
|
var roster = new BotRoster(globals);
|
||||||
|
|
||||||
Assert.That(async () => await roster.PickAsync(Ctx("PlayerA", "1")),
|
Assert.That(async () => await roster.PickAsync(Ctx("PlayerA", CardClass.Forestcraft), "000000000001"),
|
||||||
Throws.InvalidOperationException);
|
Throws.InvalidOperationException);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,10 +45,10 @@ public class InProcessPairUpRankFallbackTests
|
|||||||
|
|
||||||
private static BattlePlayer Player(long id) =>
|
private static BattlePlayer Player(long id) =>
|
||||||
new(id, new MatchContext(
|
new(id, new MatchContext(
|
||||||
SelfDeckCardIds: Array.Empty<long>(), ClassId: "0", CharaId: "0",
|
SelfDeckCardIds: Array.Empty<long>(), ClassId: CardClass.None, CharaId: "0",
|
||||||
CardMasterName: "card_master_node_10015",
|
CardMasterName: "card_master_node_10015",
|
||||||
CountryCode: "JP", UserName: $"P{id}", SleeveId: "0",
|
CountryCode: "JP", UserName: $"P{id}", SleeveId: "0",
|
||||||
EmblemId: "0", DegreeId: "0", FieldId: 0, IsOfficial: 0, BattleType: 11));
|
EmblemId: "0", DegreeId: "0", FieldId: 0, IsOfficial: 0, BattleModeId: BattleModes.TakeTwo));
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task TK2_policy_is_PvpOnly_no_fallback_regression()
|
public async Task TK2_policy_is_PvpOnly_no_fallback_regression()
|
||||||
|
|||||||
@@ -91,8 +91,8 @@ public class InProcessPairUpTests
|
|||||||
|
|
||||||
private static MatchContext Ctx() => new(
|
private static MatchContext Ctx() => new(
|
||||||
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(),
|
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(),
|
||||||
ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015",
|
ClassId: CardClass.Forestcraft, CharaId: "1", CardMasterName: "card_master_node_10015",
|
||||||
CountryCode: "KOR", UserName: "Player", SleeveId: "3000011",
|
CountryCode: CountryCodes.Korea, UserName: "Player", SleeveId: "3000011",
|
||||||
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
|
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
|
||||||
BattleType: 11);
|
BattleModeId: BattleModes.TakeTwo);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,10 +28,10 @@ public class MatchingResolverTests
|
|||||||
|
|
||||||
private static BattlePlayer Player(long vid = 1) =>
|
private static BattlePlayer Player(long vid = 1) =>
|
||||||
new(vid, new MatchContext(
|
new(vid, new MatchContext(
|
||||||
SelfDeckCardIds: Array.Empty<long>(), ClassId: "0", CharaId: "0",
|
SelfDeckCardIds: Array.Empty<long>(), ClassId: CardClass.None, CharaId: "0",
|
||||||
CardMasterName: "card_master_node_10015",
|
CardMasterName: "card_master_node_10015",
|
||||||
CountryCode: "JP", UserName: $"P{vid}", SleeveId: "0",
|
CountryCode: "JP", UserName: $"P{vid}", SleeveId: "0",
|
||||||
EmblemId: "0", DegreeId: "0", FieldId: 0, IsOfficial: 0, BattleType: 11));
|
EmblemId: "0", DegreeId: "0", FieldId: 0, IsOfficial: 0, BattleModeId: BattleModes.TakeTwo));
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task When_neither_flag_set_calls_pairUp_and_parks_returns_3002_with_empty_url()
|
public async Task When_neither_flag_set_calls_pairUp_and_parks_returns_3002_with_empty_url()
|
||||||
|
|||||||
@@ -57,14 +57,14 @@ public class MatchContextBuilderTests
|
|||||||
var ctx = await builder.BuildForTwoPickAsync(vid);
|
var ctx = await builder.BuildForTwoPickAsync(vid);
|
||||||
|
|
||||||
Assert.That(ctx.SelfDeckCardIds, Is.EqualTo(deck));
|
Assert.That(ctx.SelfDeckCardIds, Is.EqualTo(deck));
|
||||||
Assert.That(ctx.ClassId, Is.EqualTo("5"));
|
Assert.That(ctx.ClassId, Is.EqualTo(CardClass.Shadowcraft));
|
||||||
Assert.That(ctx.CharaId, Is.EqualTo("5000001")); // LeaderSkinId set
|
Assert.That(ctx.CharaId, Is.EqualTo("5000001")); // LeaderSkinId set
|
||||||
Assert.That(ctx.CountryCode, Is.EqualTo("KOR"));
|
Assert.That(ctx.CountryCode, Is.EqualTo("KOR"));
|
||||||
Assert.That(ctx.UserName, Is.EqualTo("Drafter"));
|
Assert.That(ctx.UserName, Is.EqualTo("Drafter"));
|
||||||
Assert.That(ctx.EmblemId, Is.EqualTo(emblemId.ToString()));
|
Assert.That(ctx.EmblemId, Is.EqualTo(emblemId.ToString()));
|
||||||
Assert.That(ctx.DegreeId, Is.EqualTo(degreeId.ToString()));
|
Assert.That(ctx.DegreeId, Is.EqualTo(degreeId.ToString()));
|
||||||
Assert.That(ctx.IsOfficial, Is.EqualTo(0));
|
Assert.That(ctx.IsOfficial, Is.EqualTo(0));
|
||||||
Assert.That(ctx.BattleType, Is.EqualTo(11));
|
Assert.That(ctx.BattleModeId, Is.EqualTo(BattleModes.TakeTwo));
|
||||||
// Hardcoded v1 fixtures (see spec §Deferred plumbing)
|
// Hardcoded v1 fixtures (see spec §Deferred plumbing)
|
||||||
Assert.That(ctx.CardMasterName, Is.EqualTo("card_master_node_10015"));
|
Assert.That(ctx.CardMasterName, Is.EqualTo("card_master_node_10015"));
|
||||||
Assert.That(ctx.FieldId, Is.EqualTo(43));
|
Assert.That(ctx.FieldId, Is.EqualTo(43));
|
||||||
@@ -131,12 +131,39 @@ public class MatchContextBuilderTests
|
|||||||
var ctx = await builder.BuildForRankBattleAsync(viewerId, Format.Rotation, deckNo: 1);
|
var ctx = await builder.BuildForRankBattleAsync(viewerId, Format.Rotation, deckNo: 1);
|
||||||
|
|
||||||
Assert.That(ctx.UserName, Is.EqualTo("Ranker"));
|
Assert.That(ctx.UserName, Is.EqualTo("Ranker"));
|
||||||
Assert.That(ctx.BattleType, Is.EqualTo(11), "BattleType=11 matches the prod rank-battle wire value (same as TK2).");
|
Assert.That(ctx.BattleModeId, Is.EqualTo(BattleModes.TakeTwo), "rank-battle carries the same mode id as TK2 on the wire.");
|
||||||
Assert.That(ctx.ClassId, Is.Not.Null.And.Not.Empty, "ClassId from the selected deck's class.");
|
Assert.That(ctx.ClassId, Is.Not.EqualTo(CardClass.None), "ClassId from the selected deck's class.");
|
||||||
Assert.That(ctx.CardMasterName, Is.EqualTo("card_master_node_10015"));
|
Assert.That(ctx.CardMasterName, Is.EqualTo("card_master_node_10015"));
|
||||||
Assert.That(ctx.FieldId, Is.EqualTo(43));
|
Assert.That(ctx.FieldId, Is.EqualTo(43));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task BuildForRankBattle_expands_each_deck_card_by_its_count()
|
||||||
|
{
|
||||||
|
// Regression for the "matched deck only has 1 of each card" battle-node bug:
|
||||||
|
// DeckCard is count-based (one row per unique card + a Count), so
|
||||||
|
// deck.Cards.Select(c => c.Card.Id) collapsed 3 copies into a single entry.
|
||||||
|
// The MatchContext deck must carry one entry PER PHYSICAL CARD.
|
||||||
|
await using var factory = new SVSimTestFactory();
|
||||||
|
var viewerId = await factory.SeedViewerAsync(displayName: "Ranker");
|
||||||
|
await factory.SeedGlobalsAsync();
|
||||||
|
await factory.SeedDeckAsync(viewerId, Format.Unlimited, number: 1, name: "Triples");
|
||||||
|
await factory.AddCardToDeckAsync(viewerId, Format.Unlimited, 1, 10001001L, count: 3);
|
||||||
|
await factory.AddCardToDeckAsync(viewerId, Format.Unlimited, 1, 10001002L, count: 2);
|
||||||
|
await factory.AddCardToDeckAsync(viewerId, Format.Unlimited, 1, 10001003L, count: 1);
|
||||||
|
|
||||||
|
using var scope = factory.Services.CreateScope();
|
||||||
|
var builder = scope.ServiceProvider.GetRequiredService<IMatchContextBuilder>();
|
||||||
|
|
||||||
|
var ctx = await builder.BuildForRankBattleAsync(viewerId, Format.Unlimited, deckNo: 1);
|
||||||
|
|
||||||
|
Assert.That(ctx.SelfDeckCardIds.Count, Is.EqualTo(6),
|
||||||
|
"3 + 2 + 1 copies must produce 6 physical card entries, not 3 unique ids.");
|
||||||
|
Assert.That(ctx.SelfDeckCardIds.Count(id => id == 10001001L), Is.EqualTo(3));
|
||||||
|
Assert.That(ctx.SelfDeckCardIds.Count(id => id == 10001002L), Is.EqualTo(2));
|
||||||
|
Assert.That(ctx.SelfDeckCardIds.Count(id => id == 10001003L), Is.EqualTo(1));
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task BuildForRankBattle_throws_when_no_deck_for_format()
|
public async Task BuildForRankBattle_throws_when_no_deck_for_format()
|
||||||
{
|
{
|
||||||
@@ -171,8 +198,8 @@ public class MatchContextBuilderTests
|
|||||||
var deck1Ctx = await builder.BuildForRankBattleAsync(viewerId, Format.Unlimited, deckNo: 1);
|
var deck1Ctx = await builder.BuildForRankBattleAsync(viewerId, Format.Unlimited, deckNo: 1);
|
||||||
var deck5Ctx = await builder.BuildForRankBattleAsync(viewerId, Format.Unlimited, deckNo: 5);
|
var deck5Ctx = await builder.BuildForRankBattleAsync(viewerId, Format.Unlimited, deckNo: 5);
|
||||||
|
|
||||||
Assert.That(deck1Ctx.ClassId, Is.EqualTo("1"), "deckNo=1 → class 1.");
|
Assert.That(deck1Ctx.ClassId, Is.EqualTo(CardClass.Forestcraft), "deckNo=1 → class 1.");
|
||||||
Assert.That(deck5Ctx.ClassId, Is.EqualTo("6"), "deckNo=5 → class 6 (the wire-bug case).");
|
Assert.That(deck5Ctx.ClassId, Is.EqualTo(CardClass.Bloodcraft), "deckNo=5 → class 6 (the wire-bug case).");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
|
|||||||
Reference in New Issue
Block a user