Compare commits
55 Commits
fb1e91cdf1
...
4b38a9d3e0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b38a9d3e0 | ||
|
|
ac78e809cd | ||
|
|
ba18790156 | ||
|
|
e9493e24c4 | ||
|
|
b0e3783757 | ||
|
|
f21ab7a38c | ||
|
|
8085119439 | ||
|
|
ca9ad5db8f | ||
|
|
963adbbd1b | ||
|
|
3fe378d801 | ||
|
|
3ccd986e65 | ||
|
|
3feb535072 | ||
|
|
a916afe924 | ||
|
|
3b6b8d3c94 | ||
|
|
e98bd10dbe | ||
|
|
c360d639f2 | ||
|
|
bca94648f7 | ||
|
|
f0026972cb | ||
|
|
f9c671c089 | ||
|
|
58994a53c9 | ||
|
|
3c8a00c928 | ||
|
|
6e85a6b2db | ||
|
|
6b580c622d | ||
|
|
506d286529 | ||
|
|
030d3b8057 | ||
|
|
b295fd8f09 | ||
|
|
486f72f4a0 | ||
|
|
268b864e28 | ||
|
|
503c382646 | ||
|
|
db2f711894 | ||
|
|
aacd7b56ad | ||
|
|
c03fb3c139 | ||
|
|
d35818360f | ||
|
|
538099ff4b | ||
|
|
477faf3df3 | ||
|
|
3e2931b085 | ||
|
|
e5ec8a0de1 | ||
|
|
7c36933c06 | ||
|
|
73d2c4e1b8 | ||
|
|
57d91236a0 | ||
|
|
4f89463f9c | ||
|
|
85c43a9a72 | ||
|
|
95554cee04 | ||
|
|
afe2984075 | ||
|
|
feb387d3d5 | ||
|
|
2d31037648 | ||
|
|
8052ed60ec | ||
|
|
a533e9d89d | ||
|
|
633c29b44f | ||
|
|
ae11fe0957 | ||
|
|
84ed07d3af | ||
|
|
feaa149f04 | ||
|
|
c27bf444a5 | ||
|
|
ae94d62357 | ||
|
|
05d8169012 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -408,4 +408,6 @@ FodyWeavers.xsd
|
||||
*.msp
|
||||
|
||||
# JetBrains Rider
|
||||
*.sln.iml
|
||||
*.sln.iml
|
||||
# Stale editor backups
|
||||
*.bak
|
||||
|
||||
@@ -16,17 +16,6 @@ public sealed class BattleNodeOptions
|
||||
/// </summary>
|
||||
public TimeSpan WaitingRoomTimeout { get; set; } = TimeSpan.FromSeconds(60);
|
||||
|
||||
/// <summary>
|
||||
/// Dev convenience: when true, matchmaking endpoints that would otherwise park
|
||||
/// a solo poller (returning 3002 RETRY until a partner arrives) instead return
|
||||
/// a Scripted match immediately — equivalent to passing <c>?scripted=1</c> on
|
||||
/// every request. Turn off to test real PvP with two clients. Default false.
|
||||
/// <para>Trade-off: while on, two viewers polling simultaneously each get
|
||||
/// their own Scripted match instead of pairing with each other. Toggling off
|
||||
/// is the only way to get PvP behavior.</para>
|
||||
/// </summary>
|
||||
public bool SoloDefaultsToScripted { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
|
||||
@@ -15,8 +15,6 @@ public interface IMatchingBridge
|
||||
/// connect WS within 60s.</item>
|
||||
/// <item><c>Bot</c>: <paramref name="p2"/> must be null. One viewer expected;
|
||||
/// opponent runs in client.</item>
|
||||
/// <item><c>Scripted</c>: <paramref name="p2"/> currently null; future
|
||||
/// server-driven bot config rides on <paramref name="p2"/>.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
PendingMatch RegisterBattle(BattlePlayer p1, BattlePlayer? p2, BattleType type);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
namespace SVSim.BattleNode.Bridge;
|
||||
|
||||
/// <summary>
|
||||
/// Per-battle player snapshot captured at do_matching time and replayed into the scripted
|
||||
/// lifecycle on WS connect. SVSim.BattleNode does not know how to build this — the HTTP-side
|
||||
/// Per-battle player snapshot captured at do_matching time and replayed into the
|
||||
/// 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
|
||||
/// and WS connect have no effect on the in-battle render.
|
||||
/// </summary>
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace SVSim.BattleNode.Bridge;
|
||||
/// <summary>
|
||||
/// In-process implementation of <see cref="IMatchingBridge"/>. The HTTP-side
|
||||
/// matching queue calls <see cref="RegisterBattle"/> once it has decided "these two
|
||||
/// play each other" or "this viewer is solo (bot/scripted)."
|
||||
/// play each other" or "this viewer is solo (bot)."
|
||||
/// </summary>
|
||||
public sealed class MatchingBridge : IMatchingBridge
|
||||
{
|
||||
@@ -47,9 +47,6 @@ public sealed class MatchingBridge : IMatchingBridge
|
||||
case BattleType.Bot:
|
||||
if (p2 is not null) throw new ArgumentException("Bot must have p2==null.", nameof(p2));
|
||||
break;
|
||||
case BattleType.Scripted:
|
||||
// p2 currently null; future server-driven bot will populate it.
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(type), type, "Unknown BattleType.");
|
||||
}
|
||||
|
||||
@@ -120,18 +120,6 @@ public sealed class BattleNodeWebSocketHandler
|
||||
|
||||
switch (pending.Type)
|
||||
{
|
||||
case BattleType.Scripted:
|
||||
{
|
||||
_store.RemovePending(battleId);
|
||||
var realParticipant = new RealParticipant(ws, viewerId, pending.P1.Context,
|
||||
_loggerFactory.CreateLogger<RealParticipant>(), _options.DiagnosticLogging);
|
||||
var scriptedBot = new ScriptedBotParticipant();
|
||||
var session = new BattleSession(battleId, pending.Type, realParticipant, scriptedBot,
|
||||
_loggerFactory.CreateLogger<BattleSession>());
|
||||
await session.RunAsync(ctx.RequestAborted);
|
||||
break;
|
||||
}
|
||||
|
||||
case BattleType.Pvp:
|
||||
{
|
||||
// Pick this connection's MatchContext (P1's if isP1, P2's if isP2).
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
namespace SVSim.BattleNode.Lifecycle;
|
||||
|
||||
/// <summary>
|
||||
/// Named constants and templates for the v1 scripted lifecycle. Every value here
|
||||
/// originated in a real prod frame in
|
||||
/// <c>data_dumps/captures/battle-traffic_tk2_regular.ndjson</c>; pulling them out
|
||||
/// of <see cref="ScriptedLifecycle"/> makes the magic numerics navigable and gives
|
||||
/// Default frame constants templated from TK2 prod captures, shared by the
|
||||
/// 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
|
||||
/// out of <see cref="ServerBattleFrames"/> makes the magic numerics navigable and gives
|
||||
/// the seed a single source of truth instead of two duplicated literals.
|
||||
/// </summary>
|
||||
internal static class ScriptedProfiles
|
||||
internal static class BattleFrameDefaults
|
||||
{
|
||||
// Shared per the spec — selfInfo.seed and oppoInfo.seed always agree.
|
||||
// From frame[2] (Matched).
|
||||
@@ -24,11 +24,6 @@ internal static class ScriptedProfiles
|
||||
public const int ReadyIdxChangeSeed = 771_335_280;
|
||||
public const int ReadySpin = 243;
|
||||
|
||||
// Generic non-zero spin that lands the client in "Opponent's turn..."
|
||||
// display state. v1 doesn't simulate the opponent — once this lands,
|
||||
// the client sits there indefinitely.
|
||||
public const int OpponentTurnStartSpin = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Server-pushed Judge frame spin value. Prod varies per push (55, 175, 73, ...) — it's
|
||||
/// an animation seed, not a stateful value. Fixed at 100 here for test stability;
|
||||
@@ -6,19 +6,17 @@ using SVSim.BattleNode.Protocol.Bodies;
|
||||
namespace SVSim.BattleNode.Lifecycle;
|
||||
|
||||
/// <summary>
|
||||
/// v1 hand-rolled scripted opponent. Static frame builders for the five lifecycle uris
|
||||
/// (Matched / BattleStart / Deal / Swap response / Ready) plus a trivial opponent TurnStart
|
||||
/// the dispatch pushes after the player's TurnEnd. The values are templated from the TK2
|
||||
/// captures at <c>data_dumps/captures/battle-traffic_tk2_regular.ndjson</c> — anything
|
||||
/// hardcoded here came from a real prod frame, with names + provenance in
|
||||
/// <see cref="ScriptedProfiles"/>. The player-half of Matched/BattleStart now reads from
|
||||
/// <see cref="MatchContext"/> instead of <see cref="ScriptedProfiles"/>.
|
||||
/// Server-authored battle frames pushed to the client during match setup and teardown
|
||||
/// (Matched / BattleStart / Deal / Swap response / Ready) plus the post-mulligan hand
|
||||
/// computation. Used by every battle mode's handshake/mulligan dispatch arms. Hardcoded
|
||||
/// values are templated from the TK2 prod captures (battle-traffic_tk2_*.ndjson); see
|
||||
/// <see cref="BattleFrameDefaults"/> for provenance.
|
||||
/// </summary>
|
||||
public static class ScriptedLifecycle
|
||||
public static class ServerBattleFrames
|
||||
{
|
||||
/// <summary>
|
||||
/// Viewer id we present as the opponent on every scripted push. Out-of-range vs. real
|
||||
/// viewer ids so it can't collide with a real account in the auth pipeline.
|
||||
/// Viewer id we present as the opponent on every server-authored opponent push. Out-of-range
|
||||
/// vs. real viewer ids so it can't collide with a real account in the auth pipeline.
|
||||
/// </summary>
|
||||
public const long FakeOpponentViewerId = 999_999_999L;
|
||||
|
||||
@@ -53,14 +51,14 @@ public static class ScriptedLifecycle
|
||||
bid: battleId);
|
||||
|
||||
public static MsgEnvelope BuildBattleStart(
|
||||
MatchContext selfCtx, MatchContext oppoCtx, long selfViewerId) =>
|
||||
MatchContext selfCtx, MatchContext oppoCtx, long selfViewerId, int turnState) =>
|
||||
EnvelopeForPush(NetworkBattleUri.BattleStart,
|
||||
new BattleStartBody(
|
||||
TurnState: 0, // player goes first
|
||||
TurnState: turnState, // 0 = this side goes first, 1 = second. Caller decides.
|
||||
BattleType: selfCtx.BattleType,
|
||||
SelfInfo: new BattleStartSelfInfo(
|
||||
Rank: ScriptedProfiles.PlayerRank,
|
||||
BattlePoint: ScriptedProfiles.PlayerBattlePoint,
|
||||
Rank: BattleFrameDefaults.PlayerRank,
|
||||
BattlePoint: BattleFrameDefaults.PlayerBattlePoint,
|
||||
ClassId: selfCtx.ClassId,
|
||||
CharaId: selfCtx.CharaId,
|
||||
CardMasterName: selfCtx.CardMasterName),
|
||||
@@ -113,42 +111,19 @@ public static class ScriptedLifecycle
|
||||
EnvelopeForPush(NetworkBattleUri.Swap,
|
||||
new SwapResponseBody(Self: BuildPosIdxList(hand)));
|
||||
|
||||
public static MsgEnvelope BuildReady(IReadOnlyList<long> hand) =>
|
||||
/// <summary>Non-interactive opponent (Bot/AI): oppo is the placeholder
|
||||
/// <see cref="InitialHand"/>.</summary>
|
||||
public static MsgEnvelope BuildReady(IReadOnlyList<long> hand) => BuildReady(hand, InitialHand);
|
||||
|
||||
/// <summary>Both hands known (the mulligan barrier supplies the opponent's
|
||||
/// post-mulligan hand).</summary>
|
||||
public static MsgEnvelope BuildReady(IReadOnlyList<long> selfHand, IReadOnlyList<long> oppoHand) =>
|
||||
EnvelopeForPush(NetworkBattleUri.Ready,
|
||||
new ReadyBody(
|
||||
Self: BuildPosIdxList(hand),
|
||||
Oppo: BuildPosIdxList(InitialHand),
|
||||
IdxChangeSeed: ScriptedProfiles.ReadyIdxChangeSeed,
|
||||
Spin: ScriptedProfiles.ReadySpin));
|
||||
|
||||
/// <summary>
|
||||
/// First half of the v1.1 scripted opponent turn cycle: pushed after the player's
|
||||
/// TurnEnd, transitions the client into "Opponent's turn…" state. Paired with
|
||||
/// <see cref="BuildOpponentTurnEnd"/>, which immediately follows and hands control
|
||||
/// back to the player.
|
||||
/// </summary>
|
||||
public static MsgEnvelope BuildOpponentTurnStart() =>
|
||||
EnvelopeForPush(NetworkBattleUri.TurnStart,
|
||||
new OpponentTurnStartBody(Spin: ScriptedProfiles.OpponentTurnStartSpin));
|
||||
|
||||
/// <summary>
|
||||
/// Server-pushed TurnEnd transition that closes the opponent's turn and hands control
|
||||
/// back to the player. Paired with <see cref="BuildOpponentTurnStart"/> in the v1.1 loop.
|
||||
/// Wire shape from prod capture battle-traffic_tk2_regular.ndjson L18:
|
||||
/// <c>{"uri":"TurnEnd","turnState":0,"resultCode":1,"playSeq":N}</c>.
|
||||
/// </summary>
|
||||
public static MsgEnvelope BuildOpponentTurnEnd() =>
|
||||
EnvelopeForPush(NetworkBattleUri.TurnEnd, new TurnEndBody(TurnState: 0));
|
||||
|
||||
/// <summary>
|
||||
/// Server-pushed Judge frame that follows the opponent's TurnEnd and unblocks the
|
||||
/// client's <c>JudgeOperation</c> → <c>ControlTurnStartPlayer</c>, transitioning to the
|
||||
/// player's next turn. Without this frame the client hangs on "Opponent's turn…" —
|
||||
/// see <c>data_dumps/captures/battle-traffic.ndjson</c> line 14 (client emits its own
|
||||
/// Judge then waits forever).
|
||||
/// </summary>
|
||||
public static MsgEnvelope BuildOpponentJudge() =>
|
||||
EnvelopeForPush(NetworkBattleUri.Judge, new JudgeBody(Spin: ScriptedProfiles.OpponentJudgeSpin));
|
||||
Self: BuildPosIdxList(selfHand),
|
||||
Oppo: BuildPosIdxList(oppoHand),
|
||||
IdxChangeSeed: BattleFrameDefaults.ReadyIdxChangeSeed,
|
||||
Spin: BattleFrameDefaults.ReadySpin));
|
||||
|
||||
private static IReadOnlyList<PosIdx> BuildPosIdxList(IReadOnlyList<long> hand)
|
||||
{
|
||||
30
SVSim.BattleNode/Protocol/Bodies/PlayActionsBroadcastBody.cs
Normal file
30
SVSim.BattleNode/Protocol/Bodies/PlayActionsBroadcastBody.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.BattleNode.Protocol.Bodies;
|
||||
|
||||
/// <summary>Opponent-facing PlayActions frame the node synthesizes from the active player's
|
||||
/// send. <c>KnownList</c> reveals the played card's identity (null = token reveal deferred, see
|
||||
/// the deterministic-turn slice). <c>OppoTargetList</c> is the renamed <c>targetList</c>
|
||||
/// (independent of KnownList — a targeted hand play carries both). Both omitted when null via the
|
||||
/// envelope's WhenWritingNull policy.</summary>
|
||||
public sealed record PlayActionsBroadcastBody(
|
||||
[property: JsonPropertyName("playIdx")] int PlayIdx,
|
||||
[property: JsonPropertyName("type")] int Type,
|
||||
[property: JsonPropertyName("knownList")] IReadOnlyList<KnownCardEntry>? KnownList,
|
||||
[property: JsonPropertyName("oppoTargetList")] IReadOnlyList<OppoTargetEntry>? OppoTargetList) : IMsgBody;
|
||||
|
||||
/// <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
|
||||
/// port — the receiver re-derives them from cardId).</summary>
|
||||
public sealed record KnownCardEntry(
|
||||
[property: JsonPropertyName("idx")] int Idx,
|
||||
[property: JsonPropertyName("cardId")] long CardId,
|
||||
[property: JsonPropertyName("to")] int To,
|
||||
[property: JsonPropertyName("spellboost")] int Spellboost,
|
||||
[property: JsonPropertyName("attachTarget")] string AttachTarget);
|
||||
|
||||
/// <summary>Renamed <c>targetList</c> entry. <c>isSelf</c> is actor-relative and passes through
|
||||
/// verbatim — no perspective flip (bullet-3 audit F2).</summary>
|
||||
public sealed record OppoTargetEntry(
|
||||
[property: JsonPropertyName("targetIdx")] int TargetIdx,
|
||||
[property: JsonPropertyName("isSelf")] int IsSelf);
|
||||
@@ -3,7 +3,7 @@ namespace SVSim.BattleNode.Protocol;
|
||||
/// <summary>
|
||||
/// Marker for every type that can appear as <see cref="MsgEnvelope.Body"/>.
|
||||
/// Implementers fall into two camps: typed records used on the outbound path
|
||||
/// (one per scripted frame shape) and <see cref="RawBody"/> used on the inbound
|
||||
/// (one per server-authored frame shape) and <see cref="RawBody"/> used on the inbound
|
||||
/// path. The marker exists so the envelope can carry either without falling
|
||||
/// back to <c>object</c>.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SVSim.BattleNode.Lifecycle;
|
||||
using SVSim.BattleNode.Protocol;
|
||||
using SVSim.BattleNode.Protocol.Bodies;
|
||||
using SVSim.BattleNode.Sessions.Dispatch;
|
||||
using SVSim.BattleNode.Sessions.Dispatch.Handlers;
|
||||
using SVSim.BattleNode.Sessions.Participants;
|
||||
|
||||
namespace SVSim.BattleNode.Sessions;
|
||||
@@ -13,19 +13,54 @@ namespace SVSim.BattleNode.Sessions;
|
||||
/// flag) and dispatches via <see cref="IBattleParticipant.PushAsync"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Phase 1 wires this for <see cref="BattleType.Scripted"/> only — the dispatch logic
|
||||
/// preserves v1.2 behaviour. Phase 2 wires Pvp (broadcast Matched/BattleStart per-perspective,
|
||||
/// forward gameplay frames between participants). Phase 3 wires Bot (ack-only).
|
||||
/// Wires both battle modes: Pvp (broadcast Matched/BattleStart per-perspective, forward
|
||||
/// gameplay frames between the two real participants) and Bot (ack-only, NoOp opponent).
|
||||
/// </remarks>
|
||||
public sealed class BattleSession
|
||||
{
|
||||
private readonly ILogger<BattleSession> _log;
|
||||
|
||||
private readonly BattleSessionState _state = new();
|
||||
|
||||
public string BattleId { get; }
|
||||
public BattleType Type { get; }
|
||||
public IBattleParticipant A { get; }
|
||||
public IBattleParticipant B { get; }
|
||||
public BattleSessionPhase Phase { get; private set; } = BattleSessionPhase.AwaitingInitNetwork;
|
||||
public BattleSessionPhase Phase => _state.SessionPhase;
|
||||
|
||||
// Per-URI dispatch table. All 14 inbound URIs are registered (Tasks 5-14); unknown
|
||||
// URIs are dropped with a LogDebug in ComputeFrames.
|
||||
private static readonly IReadOnlyDictionary<NetworkBattleUri, IFrameHandler> Handlers = BuildHandlers();
|
||||
|
||||
private static IReadOnlyDictionary<NetworkBattleUri, IFrameHandler> BuildHandlers()
|
||||
{
|
||||
var retireKill = new RetireKillHandler();
|
||||
var forwardWhenReady = new ForwardWhenBothReadyHandler();
|
||||
return new Dictionary<NetworkBattleUri, IFrameHandler>
|
||||
{
|
||||
[NetworkBattleUri.InitNetwork] = new InitNetworkHandler(),
|
||||
[NetworkBattleUri.InitBattle] = new InitBattleHandler(),
|
||||
[NetworkBattleUri.Loaded] = new LoadedHandler(),
|
||||
[NetworkBattleUri.Swap] = new SwapHandler(),
|
||||
[NetworkBattleUri.TurnEnd] = new TurnEndHandler(),
|
||||
[NetworkBattleUri.TurnEndFinal] = new TurnEndFinalHandler(),
|
||||
[NetworkBattleUri.Retire] = retireKill,
|
||||
[NetworkBattleUri.Kill] = retireKill,
|
||||
[NetworkBattleUri.TurnStart] = new TurnStartHandler(),
|
||||
[NetworkBattleUri.Judge] = new JudgeHandler(),
|
||||
[NetworkBattleUri.PlayActions] = new PlayActionsHandler(),
|
||||
[NetworkBattleUri.Echo] = new EchoHandler(),
|
||||
[NetworkBattleUri.TurnEndActions] = new TurnEndActionsHandler(),
|
||||
[NetworkBattleUri.JudgeResult] = forwardWhenReady,
|
||||
};
|
||||
}
|
||||
|
||||
private FrameDispatchContext BuildContext(IBattleParticipant from, MsgEnvelope env) =>
|
||||
new()
|
||||
{
|
||||
A = A, B = B, From = from, Other = ReferenceEquals(from, A) ? B : A,
|
||||
Env = env, Type = Type, BattleId = BattleId, State = _state,
|
||||
};
|
||||
|
||||
public BattleSession(string battleId, BattleType type, IBattleParticipant a, IBattleParticipant b,
|
||||
ILogger<BattleSession> log)
|
||||
@@ -49,10 +84,9 @@ public sealed class BattleSession
|
||||
|
||||
if (Type == BattleType.Pvp)
|
||||
{
|
||||
// WhenAny: first WS drop / first graceful close triggers cascade.
|
||||
// ScriptedBotParticipant.RunAsync also returns immediately; that's not used
|
||||
// here (Pvp has two RealParticipants), but we'd still want a synthesized
|
||||
// BattleFinish for the survivor if either side terminates first.
|
||||
// WhenAny: first WS drop / first graceful close triggers cascade. Pvp has two
|
||||
// RealParticipants; we synthesize a BattleFinish for the survivor if either side
|
||||
// terminates first.
|
||||
var first = await Task.WhenAny(aTask, bTask).ConfigureAwait(false);
|
||||
var survivor = first == aTask ? B : A;
|
||||
|
||||
@@ -64,7 +98,7 @@ public sealed class BattleSession
|
||||
try
|
||||
{
|
||||
await survivor.PushAsync(
|
||||
BuildBattleFinish(BattleResult.DisconnectWin), noStock: true, cancellation)
|
||||
BattleFrames.BuildBattleFinish(BattleResult.DisconnectWin), noStock: true, cancellation)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -73,7 +107,7 @@ public sealed class BattleSession
|
||||
"BattleSession {Bid}: failed to push BattleFinish to survivor (their WS may also be closed)",
|
||||
BattleId);
|
||||
}
|
||||
Phase = BattleSessionPhase.Terminal;
|
||||
_state.SessionPhase = BattleSessionPhase.Terminal;
|
||||
}
|
||||
|
||||
cts.Cancel(); // unblock the survivor's RunAsync read loop
|
||||
@@ -82,8 +116,8 @@ public sealed class BattleSession
|
||||
}
|
||||
else
|
||||
{
|
||||
// Phase 1 semantics for Scripted/Bot: wait for ALL participants. The bot's
|
||||
// RunAsync returns immediately; the session keeps running for the real one.
|
||||
// Bot mode: the NoOp opponent's RunAsync returns immediately; wait for the real
|
||||
// participant. The session keeps running for the real one.
|
||||
try { await Task.WhenAll(aTask, bTask).ConfigureAwait(false); }
|
||||
catch { /* swallow */ }
|
||||
}
|
||||
@@ -126,268 +160,14 @@ public sealed class BattleSession
|
||||
/// <see cref="Phase"/>. Extracted so unit tests can drive the dispatch without
|
||||
/// standing up real participants.
|
||||
/// </summary>
|
||||
internal IReadOnlyList<(IBattleParticipant Target, MsgEnvelope Frame, bool NoStock)> ComputeFrames(
|
||||
IBattleParticipant from, MsgEnvelope env)
|
||||
internal IReadOnlyList<DispatchRoute> ComputeFrames(IBattleParticipant from, MsgEnvelope env)
|
||||
{
|
||||
var result = new List<(IBattleParticipant, MsgEnvelope, bool)>();
|
||||
var other = ReferenceEquals(from, A) ? B : A;
|
||||
var phaseFrom = from as IHasHandshakePhase;
|
||||
if (Handlers.TryGetValue(env.Uri, out var handler))
|
||||
return handler.Handle(BuildContext(from, env));
|
||||
|
||||
// The dispatch table only covers the Scripted-mode behaviour Phase 1 needs;
|
||||
// Phase 2 (Pvp) and Phase 3 (Bot) add the other-type branches. Handshake-phase
|
||||
// arms read the SENDER's Phase (per-participant); the session-level Phase
|
||||
// remains only for the Terminal short-circuit.
|
||||
switch (env.Uri)
|
||||
{
|
||||
case NetworkBattleUri.InitNetwork when phaseFrom?.Phase == BattleSessionPhase.AwaitingInitNetwork:
|
||||
result.Add((from, BuildAck(NetworkBattleUri.InitNetwork), true));
|
||||
phaseFrom!.Phase = BattleSessionPhase.AwaitingInitBattle;
|
||||
break;
|
||||
|
||||
// --- Phase 3 Bot arms — placed BEFORE the existing handshake arms so they
|
||||
// win pattern matching on Type == Bot. Bot mode: ack handshake, silent
|
||||
// Loaded, Judge-to-sender on TurnEnd. The rest reuse Scripted's arms
|
||||
// (Retire/Kill → BattleFinishNoContest, Swap → per-sender response,
|
||||
// default → drop). Reference: docs/api-spec/in-battle/ai-passive.md.
|
||||
//
|
||||
// Critically, do NOT push Matched or BattleStart for Bot mode. The
|
||||
// architecture spec was right about this:
|
||||
// 1. The client's MatchingInitBattle (Matching.cs:298) immediately calls
|
||||
// StartBattleLoad + GotoBattle on the IsAINetwork branch right after
|
||||
// emitting InitBattle — it does NOT wait for a wire Matched or
|
||||
// BattleStart envelope. The state-machine trigger is _initNetworkSuccess
|
||||
// (set when InitNetwork uri is received, i.e., our ack).
|
||||
// 2. Sending Matched is harmless (gated on status == Connect, which is
|
||||
// already past by the time the wire round-trip completes).
|
||||
// 3. Sending BattleStart is ACTIVELY HARMFUL: its handler at
|
||||
// Matching.cs:417 runs unconditionally and SetNetworkInfo
|
||||
// (RealTimeNetworkAgent.cs:1553-1564) overwrites OppoBattleStartInfo
|
||||
// with the wire envelope's oppoInfo. Our oppoInfo comes from
|
||||
// NoOpBotParticipant.Context placeholders (classId:0, emblemId:0,
|
||||
// etc.), corrupting the good values the client just set from the
|
||||
// HTTP /ai_<fmt>_rank_battle/start response — subsequent asset
|
||||
// loads (LoadOpponentAssets at SBattleLoad.cs:933) then look up
|
||||
// non-existent assets and silently hang on "Waiting for opponent."
|
||||
|
||||
case NetworkBattleUri.InitBattle
|
||||
when Type == BattleType.Bot && phaseFrom?.Phase == BattleSessionPhase.AwaitingInitBattle:
|
||||
// Ack only — NO Matched push.
|
||||
result.Add((from, BuildAck(NetworkBattleUri.InitBattle), true));
|
||||
phaseFrom!.Phase = BattleSessionPhase.AwaitingLoaded;
|
||||
break;
|
||||
|
||||
case NetworkBattleUri.Loaded
|
||||
when Type == BattleType.Bot && phaseFrom?.Phase == BattleSessionPhase.AwaitingLoaded:
|
||||
// Silent — no BattleStart, no Deal. The client's AINetworkBattleManager
|
||||
// populates opponent state from AIBattleStart HTTP data; pushing
|
||||
// BattleStart here overwrites that state with zeros.
|
||||
phaseFrom!.Phase = BattleSessionPhase.AwaitingSwap;
|
||||
break;
|
||||
|
||||
case NetworkBattleUri.TurnEnd
|
||||
when Type == BattleType.Bot && phaseFrom?.Phase == BattleSessionPhase.AfterReady:
|
||||
case NetworkBattleUri.TurnEndFinal
|
||||
when Type == BattleType.Bot && phaseFrom?.Phase == BattleSessionPhase.AfterReady:
|
||||
// Judge to sender ONLY (not broadcast — there's no real other side).
|
||||
// The client's JudgeOperation → ControlTurnStartPlayer flips back to
|
||||
// the local AI's turn after this Judge arrives.
|
||||
result.Add((from, BuildJudgeBroadcast(), false));
|
||||
break;
|
||||
|
||||
case NetworkBattleUri.InitBattle when phaseFrom?.Phase == BattleSessionPhase.AwaitingInitBattle:
|
||||
// Phase 1: push Matched only to the "real" participant. The session reads
|
||||
// selfInfo from from.Context and oppoInfo from other.Context (the scripted
|
||||
// bot's Context fixture preserves the prod-captured cosmetics that previously
|
||||
// lived in ScriptedProfiles).
|
||||
result.Add((from, ScriptedLifecycle.BuildMatched(
|
||||
from.Context, other.Context,
|
||||
from.ViewerId, other.ViewerId,
|
||||
BattleId, ScriptedProfiles.BattleSeed), false));
|
||||
phaseFrom!.Phase = BattleSessionPhase.AwaitingLoaded;
|
||||
break;
|
||||
|
||||
case NetworkBattleUri.Loaded when phaseFrom?.Phase == BattleSessionPhase.AwaitingLoaded:
|
||||
result.Add((from, ScriptedLifecycle.BuildBattleStart(
|
||||
from.Context, other.Context, from.ViewerId), false));
|
||||
result.Add((from, ScriptedLifecycle.BuildDeal(), false));
|
||||
phaseFrom!.Phase = BattleSessionPhase.AwaitingSwap;
|
||||
break;
|
||||
|
||||
case NetworkBattleUri.Swap when phaseFrom?.Phase == BattleSessionPhase.AwaitingSwap:
|
||||
{
|
||||
var hand = ScriptedLifecycle.ComputeHandAfterSwap(ExtractIdxList(env));
|
||||
result.Add((from, ScriptedLifecycle.BuildSwapResponse(hand), false));
|
||||
result.Add((from, ScriptedLifecycle.BuildReady(hand), false));
|
||||
phaseFrom!.Phase = BattleSessionPhase.AfterReady;
|
||||
break;
|
||||
}
|
||||
|
||||
// Regular TurnEnd: continues the game. Scripted forwards to bot for the 3-frame
|
||||
// burst; PvP broadcasts; Bot stays silent.
|
||||
case NetworkBattleUri.TurnEnd when phaseFrom?.Phase == BattleSessionPhase.AfterReady:
|
||||
if (Type == BattleType.Pvp && BothAfterReady())
|
||||
{
|
||||
var turnEndBroadcast = BuildTurnEndBroadcast();
|
||||
var judgeBroadcast = BuildJudgeBroadcast();
|
||||
result.Add((from, turnEndBroadcast, false));
|
||||
result.Add((other, turnEndBroadcast, false));
|
||||
result.Add((from, judgeBroadcast, false));
|
||||
result.Add((other, judgeBroadcast, false));
|
||||
}
|
||||
else if (Type == BattleType.Scripted)
|
||||
{
|
||||
result.Add((other, env, false));
|
||||
}
|
||||
// Bot type: no-op (NoOpBot swallows; client handles its own turn end).
|
||||
break;
|
||||
|
||||
// TurnEndFinal: client signals the player's FINAL turn is over (game-end
|
||||
// condition met, usually killed opponent's leader). Unified across types:
|
||||
// forward the envelope to other (matches prod TK2 capture
|
||||
// battle-traffic_tk2_regular.ndjson:273 — loser-side receives TurnEndFinal
|
||||
// from server before BattleFinish), then push BattleFinish per-side with
|
||||
// player-perspective codes (LifeWin to winner, LifeLose to loser).
|
||||
// ScriptedBotParticipant no longer reacts to TurnEndFinal (only TurnEnd) —
|
||||
// this dispatch arm owns it. NoOpBotParticipant swallows. Phase → Terminal
|
||||
// so the RunAsync cascade doesn't synthesize a follow-up BattleFinish.
|
||||
case NetworkBattleUri.TurnEndFinal when phaseFrom?.Phase == BattleSessionPhase.AfterReady:
|
||||
result.Add((other, env, false));
|
||||
result.Add((from, BuildBattleFinish(BattleResult.LifeWin), true));
|
||||
result.Add((other, BuildBattleFinish(BattleResult.LifeLose), true));
|
||||
Phase = BattleSessionPhase.Terminal;
|
||||
break;
|
||||
|
||||
// Retire / Kill: sender concedes (Retire) or the client requested an immediate
|
||||
// terminate (Kill). Unified across types: push BattleFinish per-side with the
|
||||
// proper retire codes. Bots swallow their push (no real-opponent state).
|
||||
case NetworkBattleUri.Retire:
|
||||
case NetworkBattleUri.Kill:
|
||||
result.Add((from, BuildBattleFinish(BattleResult.RetireLose), true));
|
||||
result.Add((other, BuildBattleFinish(BattleResult.RetireWin), true));
|
||||
Phase = BattleSessionPhase.Terminal;
|
||||
break;
|
||||
|
||||
// Frames emitted by the scripted bot (TurnStart / TurnEnd / Judge) — forward
|
||||
// to the real participant. These match the v1.2 burst's three outbound pushes.
|
||||
// Pre-migration this arm only handled TurnStart/Judge because the handshake
|
||||
// TurnEnd arm above (gated on session-level Phase) also caught the bot's TurnEnd.
|
||||
// Post-migration that arm gates on the sender's per-participant Phase, which the
|
||||
// bot doesn't have, so the bot's TurnEnd now lands here.
|
||||
// The `IsRealForwardableFromScripted` guard ensures this arm matches ONLY the
|
||||
// scripted bot's emissions (sender ViewerId == FakeOpponentViewerId) — without
|
||||
// it, a TurnStart/TurnEnd/Judge from a real participant in PvP mode would match
|
||||
// here and `goto default` would skip the PvP forwarder arm below.
|
||||
case NetworkBattleUri.TurnStart when IsRealForwardableFromScripted(from, env):
|
||||
case NetworkBattleUri.TurnEnd when IsRealForwardableFromScripted(from, env):
|
||||
case NetworkBattleUri.Judge when IsRealForwardableFromScripted(from, env):
|
||||
// Generic forwarder for scripted-bot emissions. The Scripted bot's TurnStart,
|
||||
// TurnEnd, and Judge are intended for the real participant.
|
||||
result.Add((other, env, false));
|
||||
break;
|
||||
|
||||
// Gameplay-frame forwarding (post-AfterReady). Unified across types:
|
||||
// BothAfterReady() is only true when both participants are RealParticipants
|
||||
// (ScriptedBot/NoOpBot don't implement IHasHandshakePhase so their Phase is
|
||||
// always null), so this arm naturally fires for PvP only. Order matters:
|
||||
// this MUST come after the FakeOpponentViewerId arms so Scripted bot
|
||||
// emissions don't fall into this forwarder.
|
||||
case NetworkBattleUri.TurnStart when BothAfterReady():
|
||||
case NetworkBattleUri.PlayActions when BothAfterReady():
|
||||
case NetworkBattleUri.Echo when BothAfterReady():
|
||||
case NetworkBattleUri.TurnEndActions when BothAfterReady():
|
||||
case NetworkBattleUri.JudgeResult when BothAfterReady():
|
||||
result.Add((other, env, false));
|
||||
break;
|
||||
|
||||
default:
|
||||
_log.LogDebug("BattleSession {Bid}: dropping uri={Uri} in phase={Phase} from vid={Vid}",
|
||||
BattleId, env.Uri, Phase, from.ViewerId);
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
_log.LogDebug("BattleSession {Bid}: dropping uri={Uri} in phase={Phase} from vid={Vid}",
|
||||
BattleId, env.Uri, Phase, from.ViewerId);
|
||||
return Array.Empty<DispatchRoute>();
|
||||
}
|
||||
|
||||
// Phase 1: the only "scripted-bot" emissions we need to forward are the three burst
|
||||
// frames (TurnStart, TurnEnd, Judge) — and TurnEnd is already handled in the switch
|
||||
// above as a forwardable bot emission. This helper exists so the TurnStart/Judge cases
|
||||
// above only fire when the source is actually a participant (not malformed inbound).
|
||||
private static bool IsRealForwardableFromScripted(IBattleParticipant from, MsgEnvelope env)
|
||||
{
|
||||
// The bot's emitted frames carry ViewerId == FakeOpponentViewerId.
|
||||
return from.ViewerId == ScriptedLifecycle.FakeOpponentViewerId;
|
||||
}
|
||||
|
||||
// Phase 2: PvP gameplay-frame forwarding is gated on BOTH sides having completed
|
||||
// the handshake (i.e. reached AfterReady). Until then, an early TurnStart/PlayActions
|
||||
// from one side has no valid recipient.
|
||||
private bool BothAfterReady() =>
|
||||
(A as IHasHandshakePhase)?.Phase == BattleSessionPhase.AfterReady &&
|
||||
(B as IHasHandshakePhase)?.Phase == BattleSessionPhase.AfterReady;
|
||||
|
||||
private MsgEnvelope BuildAck(NetworkBattleUri uri) => new(
|
||||
uri,
|
||||
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
|
||||
Uuid: WireConstants.ServerUuid,
|
||||
Bid: null,
|
||||
Try: 0,
|
||||
Cat: EmitCategory.General,
|
||||
PubSeq: null,
|
||||
PlaySeq: null,
|
||||
Body: new ResultCodeOnlyBody());
|
||||
|
||||
private MsgEnvelope BuildTurnEndBroadcast() => new(
|
||||
NetworkBattleUri.TurnEnd,
|
||||
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
|
||||
Uuid: WireConstants.ServerUuid,
|
||||
Bid: null,
|
||||
Try: 0,
|
||||
Cat: EmitCategory.Battle,
|
||||
PubSeq: null,
|
||||
PlaySeq: null,
|
||||
Body: new TurnEndBody(TurnState: 0));
|
||||
|
||||
private MsgEnvelope BuildJudgeBroadcast() => new(
|
||||
NetworkBattleUri.Judge,
|
||||
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
|
||||
Uuid: WireConstants.ServerUuid,
|
||||
Bid: null,
|
||||
Try: 0,
|
||||
Cat: EmitCategory.Battle,
|
||||
PubSeq: null,
|
||||
PlaySeq: null,
|
||||
Body: new JudgeBody(Spin: ScriptedProfiles.OpponentJudgeSpin));
|
||||
|
||||
private MsgEnvelope BuildBattleFinish(BattleResult result) => new(
|
||||
NetworkBattleUri.BattleFinish,
|
||||
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
|
||||
Uuid: WireConstants.ServerUuid,
|
||||
Bid: null,
|
||||
Try: 0,
|
||||
Cat: EmitCategory.Battle,
|
||||
PubSeq: null,
|
||||
PlaySeq: null,
|
||||
Body: new BattleFinishBody(Result: result));
|
||||
|
||||
private static IReadOnlyList<long> ExtractIdxList(MsgEnvelope env)
|
||||
{
|
||||
if (env.Body is not RawBody rawBody) return Array.Empty<long>();
|
||||
if (rawBody.Entries.TryGetValue("idxList", out var raw) && raw is System.Collections.IEnumerable seq && raw is not string)
|
||||
{
|
||||
var result = new List<long>();
|
||||
foreach (var item in seq)
|
||||
{
|
||||
switch (item)
|
||||
{
|
||||
case long l: result.Add(l); break;
|
||||
case int i: result.Add(i); break;
|
||||
case double d: result.Add((long)d); break;
|
||||
case decimal m: result.Add((long)m); break;
|
||||
case string s when long.TryParse(s, out var p): result.Add(p); break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return Array.Empty<long>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
namespace SVSim.BattleNode.Sessions;
|
||||
|
||||
/// <summary>
|
||||
/// Where we are in the v1 scripted lifecycle. Drives which scripted frames the session pushes
|
||||
/// in response to inbound emits.
|
||||
/// 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
|
||||
{
|
||||
|
||||
@@ -14,9 +14,4 @@ public enum BattleType
|
||||
/// path; matched only in rank rotation / rank unlimited per prod). Server is
|
||||
/// ack-only. <c>p2</c> must be null.</summary>
|
||||
Bot,
|
||||
|
||||
/// <summary>One real player; server scripts the opponent (today's v1.2
|
||||
/// behaviour, preserved as a solo testing harness). <c>p2</c> currently null;
|
||||
/// future server-driven bot config can ride on <c>p2</c>.</summary>
|
||||
Scripted,
|
||||
}
|
||||
|
||||
76
SVSim.BattleNode/Sessions/Dispatch/BattleFrames.cs
Normal file
76
SVSim.BattleNode/Sessions/Dispatch/BattleFrames.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using SVSim.BattleNode.Lifecycle;
|
||||
using SVSim.BattleNode.Protocol;
|
||||
using SVSim.BattleNode.Protocol.Bodies;
|
||||
|
||||
namespace SVSim.BattleNode.Sessions.Dispatch;
|
||||
|
||||
/// <summary>Server-synthesized control/broadcast frames + inbound-body helpers, relocated verbatim
|
||||
/// from BattleSession so the per-URI handlers can build them. Pure: no session state.</summary>
|
||||
internal static class BattleFrames
|
||||
{
|
||||
internal static MsgEnvelope BuildAck(NetworkBattleUri uri) => new(
|
||||
uri,
|
||||
ViewerId: ServerBattleFrames.FakeOpponentViewerId,
|
||||
Uuid: WireConstants.ServerUuid,
|
||||
Bid: null,
|
||||
Try: 0,
|
||||
Cat: EmitCategory.General,
|
||||
PubSeq: null,
|
||||
PlaySeq: null,
|
||||
Body: new ResultCodeOnlyBody());
|
||||
|
||||
internal static MsgEnvelope BuildTurnEndBroadcast() => new(
|
||||
NetworkBattleUri.TurnEnd,
|
||||
ViewerId: ServerBattleFrames.FakeOpponentViewerId,
|
||||
Uuid: WireConstants.ServerUuid,
|
||||
Bid: null,
|
||||
Try: 0,
|
||||
Cat: EmitCategory.Battle,
|
||||
PubSeq: null,
|
||||
PlaySeq: null,
|
||||
Body: new TurnEndBody(TurnState: 0));
|
||||
|
||||
internal static MsgEnvelope BuildJudgeBroadcast() => new(
|
||||
NetworkBattleUri.Judge,
|
||||
ViewerId: ServerBattleFrames.FakeOpponentViewerId,
|
||||
Uuid: WireConstants.ServerUuid,
|
||||
Bid: null,
|
||||
Try: 0,
|
||||
Cat: EmitCategory.Battle,
|
||||
PubSeq: null,
|
||||
PlaySeq: null,
|
||||
Body: new JudgeBody(Spin: BattleFrameDefaults.OpponentJudgeSpin));
|
||||
|
||||
internal static MsgEnvelope BuildBattleFinish(BattleResult result) => new(
|
||||
NetworkBattleUri.BattleFinish,
|
||||
ViewerId: ServerBattleFrames.FakeOpponentViewerId,
|
||||
Uuid: WireConstants.ServerUuid,
|
||||
Bid: null,
|
||||
Try: 0,
|
||||
Cat: EmitCategory.Battle,
|
||||
PubSeq: null,
|
||||
PlaySeq: null,
|
||||
Body: new BattleFinishBody(Result: result));
|
||||
|
||||
internal static IReadOnlyList<long> ExtractIdxList(MsgEnvelope env)
|
||||
{
|
||||
if (env.Body is not RawBody rawBody) return Array.Empty<long>();
|
||||
if (rawBody.Entries.TryGetValue("idxList", out var raw) && raw is System.Collections.IEnumerable seq && raw is not string)
|
||||
{
|
||||
var result = new List<long>();
|
||||
foreach (var item in seq)
|
||||
{
|
||||
switch (item)
|
||||
{
|
||||
case long l: result.Add(l); break;
|
||||
case int i: result.Add(i); break;
|
||||
case double d: result.Add((long)d); break;
|
||||
case decimal m: result.Add((long)m); break;
|
||||
case string s when long.TryParse(s, out var p): result.Add(p); break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return Array.Empty<long>();
|
||||
}
|
||||
}
|
||||
31
SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs
Normal file
31
SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using SVSim.BattleNode.Sessions;
|
||||
|
||||
namespace SVSim.BattleNode.Sessions.Dispatch;
|
||||
|
||||
/// <summary>Mutable per-session state shared across frame handlers. The mulligan barrier's
|
||||
/// post-swap hands, plus (PvP-equivalency, vanilla slice) the per-side idx->cardId map used to
|
||||
/// synthesize the opponent-facing <c>knownList</c>. FUTURE: a token map (cardIds mined from
|
||||
/// orderList <c>add</c> ops, idx>30) + a reveal-gate set land alongside <see cref="IdxToCardId"/>.</summary>
|
||||
internal sealed class BattleSessionState
|
||||
{
|
||||
public BattleSessionPhase SessionPhase { get; set; } = BattleSessionPhase.AwaitingInitNetwork;
|
||||
public Dictionary<IBattleParticipant, long[]> PostSwapHands { get; } = new();
|
||||
|
||||
/// <summary>Per-side idx->cardId, seeded lazily from <see cref="MatchContext.SelfDeckCardIds"/>.
|
||||
/// Deck cards only (idx 1..deckCount); tokens (idx>deckCount) are deferred.</summary>
|
||||
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
|
||||
/// use. <c>BuildPlayerDeck</c> assigns deck idx = position+1, so entry (i+1) -> cardIds[i].</summary>
|
||||
public IReadOnlyDictionary<int, long> GetOrSeedDeckMap(IBattleParticipant side)
|
||||
{
|
||||
if (!IdxToCardId.TryGetValue(side, out var map))
|
||||
{
|
||||
map = new Dictionary<int, long>();
|
||||
var deck = side.Context.SelfDeckCardIds;
|
||||
for (var i = 0; i < deck.Count; i++) map[i + 1] = deck[i];
|
||||
IdxToCardId[side] = map;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
8
SVSim.BattleNode/Sessions/Dispatch/DispatchRoute.cs
Normal file
8
SVSim.BattleNode/Sessions/Dispatch/DispatchRoute.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
using SVSim.BattleNode.Protocol;
|
||||
|
||||
namespace SVSim.BattleNode.Sessions.Dispatch;
|
||||
|
||||
/// <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"/>
|
||||
/// true for control frames (BattleFinish, ack) — bypasses playSeq assignment + archive.</summary>
|
||||
internal readonly record struct DispatchRoute(IBattleParticipant Target, MsgEnvelope Frame, bool NoStock);
|
||||
34
SVSim.BattleNode/Sessions/Dispatch/FrameDispatchContext.cs
Normal file
34
SVSim.BattleNode/Sessions/Dispatch/FrameDispatchContext.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using SVSim.BattleNode.Protocol;
|
||||
using SVSim.BattleNode.Sessions.Participants; // IHasHandshakePhase
|
||||
|
||||
namespace SVSim.BattleNode.Sessions.Dispatch;
|
||||
|
||||
/// <summary>Everything a handler reads or mutates for one inbound frame. <see cref="A"/>/<see cref="B"/>
|
||||
/// are the session's positional participants (preserved so handlers that iterate participants in a
|
||||
/// stable order — e.g. the mulligan barrier — match the legacy switch byte-for-byte). <see cref="From"/>
|
||||
/// is the sender; <see cref="Other"/> is the non-sender.</summary>
|
||||
internal sealed class FrameDispatchContext
|
||||
{
|
||||
internal required IBattleParticipant A { get; init; }
|
||||
internal required IBattleParticipant B { get; init; }
|
||||
internal required IBattleParticipant From { get; init; }
|
||||
internal required IBattleParticipant Other { get; init; }
|
||||
internal required MsgEnvelope Env { get; init; }
|
||||
internal required BattleType Type { get; init; }
|
||||
internal required string BattleId { get; init; }
|
||||
internal required BattleSessionState State { get; init; }
|
||||
|
||||
/// <summary>The dispatching participant's handshake phase (null for a non-IHasHandshakePhase
|
||||
/// participant, e.g. NoOpBot). Setting it advances the sender.</summary>
|
||||
internal BattleSessionPhase? SenderPhase
|
||||
{
|
||||
get => (From as IHasHandshakePhase)?.Phase;
|
||||
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
|
||||
/// result is identical regardless of which side sent the frame — matches legacy BothAfterReady.</summary>
|
||||
internal bool BothAfterReady() =>
|
||||
(A as IHasHandshakePhase)?.Phase == BattleSessionPhase.AfterReady &&
|
||||
(B as IHasHandshakePhase)?.Phase == BattleSessionPhase.AfterReady;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
|
||||
|
||||
/// <summary>Echo is the receiver's per-frame ack; the client has no inbound Echo handler, so the
|
||||
/// node consumes it (bullet-2 audit). Relaying would risk an echo->echo storm.</summary>
|
||||
internal sealed class EchoHandler : IFrameHandler
|
||||
{
|
||||
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx) => Array.Empty<DispatchRoute>();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using SVSim.BattleNode.Protocol;
|
||||
|
||||
namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
|
||||
|
||||
internal sealed class ForwardWhenBothReadyHandler : IFrameHandler
|
||||
{
|
||||
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||
{
|
||||
if (ctx.BothAfterReady())
|
||||
return new[] { new DispatchRoute(ctx.Other, ctx.Env, false) };
|
||||
return Array.Empty<DispatchRoute>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using SVSim.BattleNode.Lifecycle;
|
||||
using SVSim.BattleNode.Protocol;
|
||||
|
||||
namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
|
||||
|
||||
internal sealed class InitBattleHandler : IFrameHandler
|
||||
{
|
||||
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||
{
|
||||
// case 2: Bot — ack only, NO Matched (Matched would corrupt client opponent info).
|
||||
if (ctx.Type == BattleType.Bot && ctx.SenderPhase == BattleSessionPhase.AwaitingInitBattle)
|
||||
{
|
||||
var r = new List<DispatchRoute>
|
||||
{
|
||||
new(ctx.From, BattleFrames.BuildAck(NetworkBattleUri.InitBattle), true),
|
||||
};
|
||||
ctx.SenderPhase = BattleSessionPhase.AwaitingLoaded;
|
||||
return r;
|
||||
}
|
||||
|
||||
// case 5: general — push Matched (per-perspective) to the sender only.
|
||||
if (ctx.SenderPhase == BattleSessionPhase.AwaitingInitBattle)
|
||||
{
|
||||
var r = new List<DispatchRoute>
|
||||
{
|
||||
new(ctx.From, ServerBattleFrames.BuildMatched(
|
||||
ctx.From.Context, ctx.Other.Context, ctx.From.ViewerId, ctx.Other.ViewerId,
|
||||
ctx.BattleId, BattleFrameDefaults.BattleSeed), false),
|
||||
};
|
||||
ctx.SenderPhase = BattleSessionPhase.AwaitingLoaded;
|
||||
return r;
|
||||
}
|
||||
|
||||
return Array.Empty<DispatchRoute>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using SVSim.BattleNode.Lifecycle;
|
||||
using SVSim.BattleNode.Protocol;
|
||||
|
||||
namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
|
||||
|
||||
internal sealed class InitNetworkHandler : IFrameHandler
|
||||
{
|
||||
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||
{
|
||||
if (ctx.SenderPhase != BattleSessionPhase.AwaitingInitNetwork)
|
||||
return Array.Empty<DispatchRoute>();
|
||||
|
||||
var routes = new List<DispatchRoute>
|
||||
{
|
||||
new(ctx.From, BattleFrames.BuildAck(NetworkBattleUri.InitNetwork), true),
|
||||
};
|
||||
ctx.SenderPhase = BattleSessionPhase.AwaitingInitBattle;
|
||||
return routes;
|
||||
}
|
||||
}
|
||||
25
SVSim.BattleNode/Sessions/Dispatch/Handlers/JudgeHandler.cs
Normal file
25
SVSim.BattleNode/Sessions/Dispatch/Handlers/JudgeHandler.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using SVSim.BattleNode.Protocol;
|
||||
using SVSim.BattleNode.Protocol.Bodies;
|
||||
|
||||
namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
|
||||
|
||||
internal sealed class JudgeHandler : IFrameHandler
|
||||
{
|
||||
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||
{
|
||||
// PvP: Judge is the handover gate. The player who sends Judge is the one TAKING OVER the
|
||||
// turn (the client rule is: receive opponent TurnEnd -> SendJudge). Receiving Judge{spin}
|
||||
// fires ControlTurnStartPlayer ("start MY turn"), so the {spin} must REFLECT BACK to the
|
||||
// sender — NOT go to the opponent (that would make the player who just ended their turn
|
||||
// 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}.
|
||||
// battleCode is dropped; spin=0 for the deterministic-turn slice.
|
||||
if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady())
|
||||
{
|
||||
var frame = ctx.Env with { Body = new JudgeBody(Spin: 0) };
|
||||
return new[] { new DispatchRoute(ctx.From, frame, false) };
|
||||
}
|
||||
|
||||
return Array.Empty<DispatchRoute>();
|
||||
}
|
||||
}
|
||||
34
SVSim.BattleNode/Sessions/Dispatch/Handlers/LoadedHandler.cs
Normal file
34
SVSim.BattleNode/Sessions/Dispatch/Handlers/LoadedHandler.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using SVSim.BattleNode.Lifecycle;
|
||||
using SVSim.BattleNode.Protocol;
|
||||
|
||||
namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
|
||||
|
||||
internal sealed class LoadedHandler : IFrameHandler
|
||||
{
|
||||
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||
{
|
||||
// case 3: Bot — silent (client populates opponent state from AIBattleStart HTTP data).
|
||||
if (ctx.Type == BattleType.Bot && ctx.SenderPhase == BattleSessionPhase.AwaitingLoaded)
|
||||
{
|
||||
ctx.SenderPhase = BattleSessionPhase.AwaitingSwap;
|
||||
return Array.Empty<DispatchRoute>();
|
||||
}
|
||||
|
||||
// case 6: general — BattleStart (per-perspective) + Deal to the sender.
|
||||
if (ctx.SenderPhase == BattleSessionPhase.AwaitingLoaded)
|
||||
{
|
||||
// A goes first deterministically (turnState 0); B goes second (turnState 1).
|
||||
var turnState = ReferenceEquals(ctx.From, ctx.A) ? 0 : 1;
|
||||
var r = new List<DispatchRoute>
|
||||
{
|
||||
new(ctx.From, ServerBattleFrames.BuildBattleStart(
|
||||
ctx.From.Context, ctx.Other.Context, ctx.From.ViewerId, turnState), false),
|
||||
new(ctx.From, ServerBattleFrames.BuildDeal(), false),
|
||||
};
|
||||
ctx.SenderPhase = BattleSessionPhase.AwaitingSwap;
|
||||
return r;
|
||||
}
|
||||
|
||||
return Array.Empty<DispatchRoute>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using SVSim.BattleNode.Protocol;
|
||||
using SVSim.BattleNode.Protocol.Bodies;
|
||||
|
||||
namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
|
||||
|
||||
/// <summary>PvP PlayActions translator (vanilla deck-card slice). Synthesizes the opponent-facing
|
||||
/// knownList from the sender's idx->cardId map + the orderList move op, renames targetList ->
|
||||
/// oppoTargetList, drops orderList, consumes keyAction. Token plays (idx>deck) degrade silently to
|
||||
/// {playIdx,type} (no knownList). Bot drop (no rule).</summary>
|
||||
internal sealed class PlayActionsHandler : IFrameHandler
|
||||
{
|
||||
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||
{
|
||||
if (ctx.Type != BattleType.Pvp || !ctx.BothAfterReady())
|
||||
return Array.Empty<DispatchRoute>();
|
||||
|
||||
var entries = (ctx.Env.Body as RawBody)?.Entries ?? new Dictionary<string, object?>();
|
||||
var playIdx = (int)KnownListBuilder.AsLong(entries.GetValueOrDefault("playIdx"));
|
||||
var type = (int)KnownListBuilder.AsLong(entries.GetValueOrDefault("type"));
|
||||
|
||||
var deckMap = ctx.State.GetOrSeedDeckMap(ctx.From);
|
||||
var played = KnownListBuilder.BuildPlayedCard(deckMap, playIdx, entries.GetValueOrDefault("orderList"));
|
||||
var oppoTargets = KnownListBuilder.RenameTargets(entries.GetValueOrDefault("targetList"));
|
||||
|
||||
var body = new PlayActionsBroadcastBody(
|
||||
PlayIdx: playIdx,
|
||||
Type: type,
|
||||
KnownList: played is null ? null : new[] { played },
|
||||
OppoTargetList: oppoTargets);
|
||||
|
||||
var frame = ctx.Env with { Body = body };
|
||||
return new[] { new DispatchRoute(ctx.Other, frame, false) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using SVSim.BattleNode.Protocol;
|
||||
|
||||
namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
|
||||
|
||||
internal sealed class RetireKillHandler : IFrameHandler
|
||||
{
|
||||
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||
{
|
||||
ctx.State.SessionPhase = BattleSessionPhase.Terminal;
|
||||
return new[]
|
||||
{
|
||||
new DispatchRoute(ctx.From, BattleFrames.BuildBattleFinish(BattleResult.RetireLose), true),
|
||||
new DispatchRoute(ctx.Other, BattleFrames.BuildBattleFinish(BattleResult.RetireWin), true),
|
||||
};
|
||||
}
|
||||
}
|
||||
39
SVSim.BattleNode/Sessions/Dispatch/Handlers/SwapHandler.cs
Normal file
39
SVSim.BattleNode/Sessions/Dispatch/Handlers/SwapHandler.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using SVSim.BattleNode.Lifecycle;
|
||||
using SVSim.BattleNode.Protocol;
|
||||
using SVSim.BattleNode.Sessions.Participants; // IHasHandshakePhase
|
||||
|
||||
namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
|
||||
|
||||
internal sealed class SwapHandler : IFrameHandler
|
||||
{
|
||||
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||
{
|
||||
if (ctx.SenderPhase != BattleSessionPhase.AwaitingSwap)
|
||||
return Array.Empty<DispatchRoute>();
|
||||
|
||||
var routes = new List<DispatchRoute>();
|
||||
var hand = ServerBattleFrames.ComputeHandAfterSwap(BattleFrames.ExtractIdxList(ctx.Env));
|
||||
|
||||
// SwapResponse is always immediate — completes the sender's own mulligan UI.
|
||||
routes.Add(new DispatchRoute(ctx.From, ServerBattleFrames.BuildSwapResponse(hand), false));
|
||||
ctx.State.PostSwapHands[ctx.From] = hand;
|
||||
ctx.SenderPhase = BattleSessionPhase.AfterReady;
|
||||
|
||||
// Release Ready to every swapper once all handshake-driving participants have swapped.
|
||||
// IHasHandshakePhase membership IS the "participates in mulligan" set.
|
||||
var swappers = new[] { ctx.A, ctx.B }.Where(p => p is IHasHandshakePhase).ToList();
|
||||
if (swappers.All(ctx.State.PostSwapHands.ContainsKey))
|
||||
{
|
||||
foreach (var p in swappers)
|
||||
{
|
||||
var opponent = ReferenceEquals(p, ctx.A) ? ctx.B : ctx.A;
|
||||
var ready = opponent is IHasHandshakePhase
|
||||
&& ctx.State.PostSwapHands.TryGetValue(opponent, out var oppoHand)
|
||||
? ServerBattleFrames.BuildReady(ctx.State.PostSwapHands[p], oppoHand)
|
||||
: ServerBattleFrames.BuildReady(ctx.State.PostSwapHands[p]);
|
||||
routes.Add(new DispatchRoute(p, ready, false));
|
||||
}
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using SVSim.BattleNode.Protocol;
|
||||
|
||||
namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
|
||||
|
||||
/// <summary>PvP TurnEndActions: the sender's orderList is dropped; the opponent receives an
|
||||
/// empty body (it only flips _sendEcho + runs the opponent's end-of-turn triggers via the
|
||||
/// opponent's own engine). Bot drop.</summary>
|
||||
internal sealed class TurnEndActionsHandler : IFrameHandler
|
||||
{
|
||||
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||
{
|
||||
if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady())
|
||||
{
|
||||
var frame = ctx.Env with { Body = new RawBody(new Dictionary<string, object?>()) };
|
||||
return new[] { new DispatchRoute(ctx.Other, frame, false) };
|
||||
}
|
||||
return Array.Empty<DispatchRoute>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using SVSim.BattleNode.Protocol;
|
||||
|
||||
namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
|
||||
|
||||
internal sealed class TurnEndFinalHandler : IFrameHandler
|
||||
{
|
||||
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||
{
|
||||
// case 4: Bot — Judge to sender only.
|
||||
if (ctx.Type == BattleType.Bot && ctx.SenderPhase == BattleSessionPhase.AfterReady)
|
||||
return new[] { new DispatchRoute(ctx.From, BattleFrames.BuildJudgeBroadcast(), false) };
|
||||
|
||||
// case 9: general — forward the envelope to other + paired BattleFinish + Terminal.
|
||||
if (ctx.SenderPhase == BattleSessionPhase.AfterReady)
|
||||
{
|
||||
ctx.State.SessionPhase = BattleSessionPhase.Terminal;
|
||||
return new[]
|
||||
{
|
||||
new DispatchRoute(ctx.Other, ctx.Env, false),
|
||||
new DispatchRoute(ctx.From, BattleFrames.BuildBattleFinish(BattleResult.LifeWin), true),
|
||||
new DispatchRoute(ctx.Other, BattleFrames.BuildBattleFinish(BattleResult.LifeLose), true),
|
||||
};
|
||||
}
|
||||
|
||||
return Array.Empty<DispatchRoute>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using SVSim.BattleNode.Protocol;
|
||||
using SVSim.BattleNode.Protocol.Bodies;
|
||||
|
||||
namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
|
||||
|
||||
internal sealed class TurnEndHandler : IFrameHandler
|
||||
{
|
||||
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||
{
|
||||
// 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)
|
||||
return new[] { new DispatchRoute(ctx.From, BattleFrames.BuildJudgeBroadcast(), false) };
|
||||
|
||||
// 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.
|
||||
if (ctx.SenderPhase == BattleSessionPhase.AfterReady)
|
||||
{
|
||||
if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady())
|
||||
{
|
||||
// Opponent sees {turnState}; receiving TurnEnd drives ITS SendJudge (handover gate):
|
||||
// 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.
|
||||
var te = ctx.Env with { Body = new TurnEndBody(TurnState: 0) };
|
||||
return new[] { new DispatchRoute(ctx.Other, te, false) };
|
||||
}
|
||||
return Array.Empty<DispatchRoute>(); // Pvp-not-both-ready → drop (Bot already returned above)
|
||||
}
|
||||
|
||||
return Array.Empty<DispatchRoute>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using SVSim.BattleNode.Protocol;
|
||||
using SVSim.BattleNode.Protocol.Bodies;
|
||||
|
||||
namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
|
||||
|
||||
internal sealed class TurnStartHandler : IFrameHandler
|
||||
{
|
||||
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||
{
|
||||
// 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.
|
||||
if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady())
|
||||
{
|
||||
var frame = ctx.Env with { Body = new OpponentTurnStartBody(Spin: 0) };
|
||||
return new[] { new DispatchRoute(ctx.Other, frame, false) };
|
||||
}
|
||||
|
||||
return Array.Empty<DispatchRoute>();
|
||||
}
|
||||
}
|
||||
10
SVSim.BattleNode/Sessions/Dispatch/IFrameHandler.cs
Normal file
10
SVSim.BattleNode/Sessions/Dispatch/IFrameHandler.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace SVSim.BattleNode.Sessions.Dispatch;
|
||||
|
||||
/// <summary>Handles one (or more) inbound URI(s). Pure: returns the routes to dispatch and may
|
||||
/// mutate <see cref="FrameDispatchContext.State"/> / advance <see cref="FrameDispatchContext.SenderPhase"/>,
|
||||
/// but does not touch the wire. Stateless singletons live in BattleSession's registry; a single
|
||||
/// handler may be registered under multiple URIs (e.g. Retire/Kill).</summary>
|
||||
internal interface IFrameHandler
|
||||
{
|
||||
IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx);
|
||||
}
|
||||
74
SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs
Normal file
74
SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs
Normal file
@@ -0,0 +1,74 @@
|
||||
using SVSim.BattleNode.Protocol.Bodies;
|
||||
|
||||
namespace SVSim.BattleNode.Sessions.Dispatch;
|
||||
|
||||
/// <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
|
||||
/// as <c>Dictionary<string,object?></c> / <c>List<object?></c> with numeric leaves boxed
|
||||
/// as long/int/double (see MsgEnvelope.FromJson).</summary>
|
||||
internal static class KnownListBuilder
|
||||
{
|
||||
/// <summary>The played card's knownList entry, or null when its identity can't be synthesized
|
||||
/// (token idx not in the deck map, or no matching move op). spellboost/attachTarget default to
|
||||
/// 0/"" for the vanilla slice; cost/clan/tribe are deferred (receiver re-derives from cardId).</summary>
|
||||
public static KnownCardEntry? BuildPlayedCard(
|
||||
IReadOnlyDictionary<int, long> deckMap, int playIdx, object? orderList)
|
||||
{
|
||||
if (!deckMap.TryGetValue(playIdx, out var cardId)) return null;
|
||||
var to = ExtractMoveTo(orderList, playIdx);
|
||||
if (to is null) return null;
|
||||
return new KnownCardEntry(Idx: playIdx, CardId: cardId, To: to.Value, Spellboost: 0, AttachTarget: "");
|
||||
}
|
||||
|
||||
/// <summary>The <c>to</c> place-state of the FIRST <c>move</c> op whose <c>idx</c> list contains
|
||||
/// <paramref name="playIdx"/> (the played card's own move; later add/alter ops are the deferred
|
||||
/// token slice), or null if absent. NOTE: the sender-side <c>to</c> is passed through verbatim —
|
||||
/// for the vanilla slice we assume send-side and recv-side place-state codes match, pending
|
||||
/// recv-capture confirmation.</summary>
|
||||
public static int? ExtractMoveTo(object? orderList, int playIdx)
|
||||
{
|
||||
if (orderList is not IEnumerable<object?> ops) return null;
|
||||
foreach (var op in ops)
|
||||
{
|
||||
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 (move.TryGetValue("idx", out var idxRaw) && idxRaw is IEnumerable<object?> idxList)
|
||||
{
|
||||
foreach (var i in idxList)
|
||||
if (AsLong(i) == playIdx && move.TryGetValue("to", out var toRaw))
|
||||
return (int)AsLong(toRaw);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Rename <c>targetList</c> -> <c>oppoTargetList</c>; <c>isSelf</c> is actor-relative
|
||||
/// and passes through unchanged (F2). Null for a missing/empty list.</summary>
|
||||
public static IReadOnlyList<OppoTargetEntry>? RenameTargets(object? targetList)
|
||||
{
|
||||
if (targetList is not IEnumerable<object?> entries) return null;
|
||||
var result = new List<OppoTargetEntry>();
|
||||
foreach (var e in entries)
|
||||
{
|
||||
if (e is not IDictionary<string, object?> d) continue;
|
||||
d.TryGetValue("targetIdx", out var targetIdxRaw);
|
||||
d.TryGetValue("isSelf", out var isSelfRaw);
|
||||
result.Add(new OppoTargetEntry(
|
||||
TargetIdx: (int)AsLong(targetIdxRaw),
|
||||
IsSelf: (int)AsLong(isSelfRaw)));
|
||||
}
|
||||
return result.Count == 0 ? null : result;
|
||||
}
|
||||
|
||||
/// <summary>Coerce a boxed RawBody numeric leaf (long/int/double/decimal/string) to long; 0 for
|
||||
/// null/unparseable.</summary>
|
||||
public static long AsLong(object? value) => value switch
|
||||
{
|
||||
long l => l,
|
||||
int i => i,
|
||||
double d => (long)d,
|
||||
decimal m => (long)m,
|
||||
string s when long.TryParse(s, out var p) => p,
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
@@ -5,18 +5,16 @@ namespace SVSim.BattleNode.Sessions;
|
||||
|
||||
/// <summary>
|
||||
/// One side of a battle. Two of these are held by a <c>BattleSession</c>; the session
|
||||
/// brokers between them. Concrete impls (added in subsequent Phase-1 tasks):
|
||||
/// brokers between them. Concrete impls:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>RealParticipant</c> — WS-backed.</item>
|
||||
/// <item><c>RealParticipant</c> — WS-backed (used for <c>BattleType.Pvp</c>).</item>
|
||||
/// <item><c>NoOpBotParticipant</c> — silent; for <c>BattleType.Bot</c> (AI-passive).</item>
|
||||
/// <item><c>ScriptedBotParticipant</c> — wraps the v1.2 lifecycle for
|
||||
/// <c>BattleType.Scripted</c> (solo testing harness).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public interface IBattleParticipant : IAsyncDisposable
|
||||
{
|
||||
/// <summary>Real viewer id, or a synthetic stable id for bots
|
||||
/// (<see cref="Lifecycle.ScriptedLifecycle.FakeOpponentViewerId"/>).</summary>
|
||||
/// (<see cref="Lifecycle.ServerBattleFrames.FakeOpponentViewerId"/>).</summary>
|
||||
long ViewerId { get; }
|
||||
|
||||
/// <summary>Per-battle MatchContext snapshot, used for building Matched/BattleStart
|
||||
@@ -25,19 +23,17 @@ public interface IBattleParticipant : IAsyncDisposable
|
||||
|
||||
/// <summary>Session calls this to deliver a frame from the OTHER participant
|
||||
/// (or a server-synthesized broadcast). Real impl: encode + WS-send.
|
||||
/// NoOp: swallow. Scripted: may emit a response via <see cref="FrameEmitted"/>.</summary>
|
||||
/// NoOp: swallow.</summary>
|
||||
/// <param name="noStock">True for control frames (BattleFinish, JudgeResult, ack);
|
||||
/// bypasses playSeq assignment + archive.</param>
|
||||
Task PushAsync(MsgEnvelope envelope, bool noStock, CancellationToken ct);
|
||||
|
||||
/// <summary>Participant fires this when it has a frame to send TO the session
|
||||
/// (its own gameplay action). Real impl: fires on WS recv. NoOp: never fires.
|
||||
/// Scripted: fires from inside PushAsync when the scripted lifecycle wants to
|
||||
/// respond to an inbound frame.</summary>
|
||||
/// (its own gameplay action). Real impl: fires on WS recv. NoOp: never fires.</summary>
|
||||
event Func<MsgEnvelope, CancellationToken, Task>? FrameEmitted;
|
||||
|
||||
/// <summary>Drives the participant's inbound loop. For Real: the WS read loop
|
||||
/// (returns when the WS closes). For NoOp/Scripted: completes immediately (the
|
||||
/// (returns when the WS closes). For NoOp: completes immediately (the
|
||||
/// session keeps running as long as the OTHER participant's RunAsync is alive).</summary>
|
||||
Task RunAsync(CancellationToken ct);
|
||||
|
||||
|
||||
@@ -8,13 +8,13 @@ namespace SVSim.BattleNode.Sessions.Participants;
|
||||
/// Silent participant — produces no frames, swallows everything pushed to it.
|
||||
/// Used as the "other" participant in <see cref="BattleType.Bot"/> sessions, where
|
||||
/// the real opponent runs in the client and the server has no opponent-side state
|
||||
/// to model. ViewerId is <see cref="ScriptedLifecycle.FakeOpponentViewerId"/>;
|
||||
/// to model. ViewerId is <see cref="ServerBattleFrames.FakeOpponentViewerId"/>;
|
||||
/// Context is a fixed stub (irrelevant — never read because no frames are pushed
|
||||
/// to the other side).
|
||||
/// </summary>
|
||||
public sealed class NoOpBotParticipant : IBattleParticipant
|
||||
{
|
||||
public long ViewerId => ScriptedLifecycle.FakeOpponentViewerId;
|
||||
public long ViewerId => ServerBattleFrames.FakeOpponentViewerId;
|
||||
public MatchContext Context { get; } = new(
|
||||
SelfDeckCardIds: Array.Empty<long>(),
|
||||
ClassId: "0", CharaId: "0", CardMasterName: "card_master_node_10015",
|
||||
|
||||
@@ -287,7 +287,7 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
|
||||
/// (used by its stockEmitMessageMgr.GetSelectData lookup); it's NOT on the wire.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// In scripted/Bot mode the server has no opponent to forward touches to; ack-only is
|
||||
/// In Bot mode the server has no opponent to forward touches to; ack-only is
|
||||
/// correct. PvP-side forwarding semantics are unverified — see
|
||||
/// <c>docs/audits/battle-node-sio-events-2026-06-02.md</c>.
|
||||
/// </para>
|
||||
@@ -369,7 +369,7 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
|
||||
}
|
||||
var aliveEnv = new MsgEnvelope(
|
||||
Uri: NetworkBattleUri.Gungnir,
|
||||
ViewerId: SVSim.BattleNode.Lifecycle.ScriptedLifecycle.FakeOpponentViewerId,
|
||||
ViewerId: SVSim.BattleNode.Lifecycle.ServerBattleFrames.FakeOpponentViewerId,
|
||||
Uuid: WireConstants.ServerUuid,
|
||||
Bid: null,
|
||||
Try: 0,
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
using System.Linq;
|
||||
using SVSim.BattleNode.Bridge;
|
||||
using SVSim.BattleNode.Lifecycle;
|
||||
using SVSim.BattleNode.Protocol;
|
||||
|
||||
namespace SVSim.BattleNode.Sessions.Participants;
|
||||
|
||||
/// <summary>
|
||||
/// Server-scripted opponent (today's v1.2 testing-harness behavior, repackaged).
|
||||
/// On <see cref="PushAsync"/> with <c>TurnEnd</c> or <c>TurnEndFinal</c>, fires
|
||||
/// <see cref="FrameEmitted"/> three times: <c>OpponentTurnStart</c>,
|
||||
/// <c>OpponentTurnEnd</c>, <c>OpponentJudge</c>. All other URIs are swallowed
|
||||
/// (no opponent reaction needed for v1.2 behavior).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ViewerId, Context are fixtures matching <see cref="ScriptedLifecycle.FakeOpponentViewerId"/>
|
||||
/// and a scripted opponent profile. The Context fixture is the source of truth for the
|
||||
/// scripted opponent's half of Matched and BattleStart (cosmetics, deck count, class/chara) —
|
||||
/// <see cref="BattleSession.ComputeFrames"/> reads <c>other.Context</c> for those frames.
|
||||
/// Deal still uses fixed scripted frames that ignore Context.
|
||||
/// </remarks>
|
||||
public sealed class ScriptedBotParticipant : IBattleParticipant
|
||||
{
|
||||
public long ViewerId => ScriptedLifecycle.FakeOpponentViewerId;
|
||||
public MatchContext Context { get; } = new(
|
||||
// 30 dummy card ids so oppoCtx.SelfDeckCardIds.Count == 30 — prod frame[2] (Matched)
|
||||
// shipped OppoDeckCount: 30.
|
||||
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 0L).ToList(),
|
||||
// BattleStart opponent half (frame[5]): ClassId/CharaId both "8" (neutral test class).
|
||||
ClassId: "8", CharaId: "8", CardMasterName: "card_master_node_10015",
|
||||
// Matched opponent half (frame[2]): cosmetic fields from the prod capture.
|
||||
CountryCode: "JPN", UserName: "Opponent", SleeveId: "704141010",
|
||||
EmblemId: "400001100", DegreeId: "120027", FieldId: 5, IsOfficial: 0,
|
||||
BattleType: 0);
|
||||
|
||||
public event Func<MsgEnvelope, CancellationToken, Task>? FrameEmitted;
|
||||
|
||||
public async Task PushAsync(MsgEnvelope envelope, bool noStock, CancellationToken ct)
|
||||
{
|
||||
// React to the player's TurnEnd with the three-frame burst (TurnStart / TurnEnd /
|
||||
// Judge) — that's the v1.2 "scripted bot takes its turn" behavior. Everything else
|
||||
// (including TurnEndFinal) is silently swallowed: TurnEndFinal is the player's
|
||||
// game-end signal and is handled directly by the BattleSession dispatch arm, which
|
||||
// pushes BattleFinish per-side; the bot doesn't need to react.
|
||||
if (envelope.Uri is NetworkBattleUri.TurnEnd)
|
||||
{
|
||||
await EmitAsync(ScriptedLifecycle.BuildOpponentTurnStart(), ct).ConfigureAwait(false);
|
||||
await EmitAsync(ScriptedLifecycle.BuildOpponentTurnEnd(), ct).ConfigureAwait(false);
|
||||
await EmitAsync(ScriptedLifecycle.BuildOpponentJudge(), ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public Task RunAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public Task TerminateAsync(BattleFinishReason reason) => Task.CompletedTask;
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
private Task EmitAsync(MsgEnvelope env, CancellationToken ct) =>
|
||||
FrameEmitted?.Invoke(env, ct) ?? Task.CompletedTask;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Bootstrap.Models.Seed;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Bootstrap.Importers;
|
||||
@@ -36,7 +37,7 @@ public class AchievementCatalogImporter
|
||||
};
|
||||
entry.Name = s.Name;
|
||||
entry.RequireNumber = s.RequireNumber;
|
||||
entry.RewardType = s.RewardType;
|
||||
entry.RewardType = (UserGoodsType)s.RewardType;
|
||||
entry.RewardDetailId = s.RewardDetailId;
|
||||
entry.RewardNumber = s.RewardNumber;
|
||||
entry.OrderNum = s.OrderNum;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Bootstrap.Models.Seed;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Bootstrap.Importers;
|
||||
@@ -27,7 +28,7 @@ public class ArenaTwoPickRewardImporter
|
||||
int upserted = 0;
|
||||
foreach (var s in seeds)
|
||||
{
|
||||
if (existing.TryGetValue((s.WinCount, s.RewardGroup, s.RewardType, s.RewardId, s.RewardNum), out var row))
|
||||
if (existing.TryGetValue((s.WinCount, s.RewardGroup, (UserGoodsType)s.RewardType, s.RewardId, s.RewardNum), out var row))
|
||||
{
|
||||
row.Weight = s.Weight;
|
||||
}
|
||||
@@ -38,7 +39,7 @@ public class ArenaTwoPickRewardImporter
|
||||
WinCount = s.WinCount,
|
||||
RewardGroup = s.RewardGroup,
|
||||
Weight = s.Weight,
|
||||
RewardType = s.RewardType,
|
||||
RewardType = (UserGoodsType)s.RewardType,
|
||||
RewardId = s.RewardId,
|
||||
RewardNum = s.RewardNum,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Bootstrap.Models.Seed;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Bootstrap.Importers;
|
||||
@@ -38,7 +39,7 @@ public class BattlePassMonthlyMissionImporter
|
||||
entry.Name = s.Name;
|
||||
entry.RequireNumber = s.RequireNumber;
|
||||
entry.BattlePassPoint = s.BattlePassPoint;
|
||||
entry.RewardType = s.RewardType;
|
||||
entry.RewardType = (UserGoodsType?)s.RewardType;
|
||||
entry.RewardDetailId = s.RewardDetailId;
|
||||
entry.RewardNumber = s.RewardNumber;
|
||||
entry.EventType = s.EventType;
|
||||
|
||||
@@ -38,7 +38,7 @@ public class BattlePassRewardImporter
|
||||
seenKeys.Add(key);
|
||||
if (dbByKey.TryGetValue(key, out var ex))
|
||||
{
|
||||
ex.RewardType = s.RewardType;
|
||||
ex.RewardType = (UserGoodsType)s.RewardType;
|
||||
ex.RewardDetailId = s.RewardDetailId;
|
||||
ex.RewardNumber = s.RewardNumber;
|
||||
ex.IsAppealExclusion = s.IsAppealExclusion;
|
||||
@@ -50,7 +50,7 @@ public class BattlePassRewardImporter
|
||||
{
|
||||
Id = MakeId(s.SeasonId, track, s.Level),
|
||||
SeasonId = s.SeasonId, Track = track, Level = s.Level,
|
||||
RewardType = s.RewardType, RewardDetailId = s.RewardDetailId,
|
||||
RewardType = (UserGoodsType)s.RewardType, RewardDetailId = s.RewardDetailId,
|
||||
RewardNumber = s.RewardNumber, IsAppealExclusion = s.IsAppealExclusion,
|
||||
});
|
||||
created++;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Bootstrap.Models.Seed;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Bootstrap.Importers;
|
||||
@@ -174,7 +175,7 @@ public class BuildDeckImporter
|
||||
{
|
||||
TierIndex = r.TierIndex,
|
||||
ItemIndex = r.ItemIndex,
|
||||
RewardType = r.RewardType,
|
||||
RewardType = (UserGoodsType)r.RewardType,
|
||||
RewardDetailId = r.RewardDetailId,
|
||||
RewardNumber = r.RewardNumber,
|
||||
MessageId = r.MessageId,
|
||||
@@ -208,7 +209,7 @@ public class BuildDeckImporter
|
||||
productRow.Rewards.Add(new BuildDeckProductRewardEntry
|
||||
{
|
||||
RewardIndex = r.RewardIndex,
|
||||
RewardType = r.RewardType,
|
||||
RewardType = (UserGoodsType)r.RewardType,
|
||||
RewardDetailId = r.RewardDetailId,
|
||||
RewardNumber = r.RewardNumber,
|
||||
MessageId = r.MessageId,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Bootstrap.Models.Seed;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Bootstrap.Importers;
|
||||
@@ -61,7 +62,7 @@ public class LeaderSkinShopImporter
|
||||
series.SetCompletionRewards.Add(new LeaderSkinShopSeriesRewardEntry
|
||||
{
|
||||
OrderIndex = r.OrderIndex,
|
||||
RewardType = r.RewardType,
|
||||
RewardType = (UserGoodsType)r.RewardType,
|
||||
RewardDetailId = r.RewardDetailId,
|
||||
RewardNumber = r.RewardNumber,
|
||||
});
|
||||
@@ -98,7 +99,7 @@ public class LeaderSkinShopImporter
|
||||
product.Rewards.Add(new LeaderSkinShopProductRewardEntry
|
||||
{
|
||||
OrderIndex = r.OrderIndex,
|
||||
RewardType = r.RewardType,
|
||||
RewardType = (UserGoodsType)r.RewardType,
|
||||
RewardDetailId = r.RewardDetailId,
|
||||
RewardNumber = r.RewardNumber,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Bootstrap.Models.Seed;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Bootstrap.Importers;
|
||||
@@ -30,7 +31,7 @@ public class MissionCatalogImporter
|
||||
entry.Name = s.Name;
|
||||
entry.LotType = s.LotType;
|
||||
entry.RequireNumber = s.RequireNumber;
|
||||
entry.RewardType = s.RewardType;
|
||||
entry.RewardType = (UserGoodsType)s.RewardType;
|
||||
entry.RewardDetailId = s.RewardDetailId;
|
||||
entry.RewardNumber = s.RewardNumber;
|
||||
entry.BattlePassPoint = s.BattlePassPoint;
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Bootstrap.Models.Seed;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Bootstrap.Importers;
|
||||
@@ -121,7 +122,7 @@ public class PuzzleImporter
|
||||
entry.RequireNumber = s.RequireNumber;
|
||||
entry.CampaignCommenceTime = s.CampaignCommenceTime;
|
||||
entry.OrderId = s.OrderId;
|
||||
entry.RewardType = s.RewardType;
|
||||
entry.RewardType = (UserGoodsType)s.RewardType;
|
||||
entry.RewardDetailId = s.RewardDetailId;
|
||||
entry.RewardNumber = s.RewardNumber;
|
||||
entry.TargetPuzzleGroupId = s.TargetPuzzleGroupId;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Bootstrap.Models.Seed;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Bootstrap.Importers;
|
||||
@@ -72,7 +73,7 @@ public class SleeveShopImporter
|
||||
product.Rewards.Add(new SleeveShopProductRewardEntry
|
||||
{
|
||||
OrderIndex = r.OrderIndex,
|
||||
RewardType = r.RewardType,
|
||||
RewardType = (UserGoodsType)r.RewardType,
|
||||
RewardDetailId = r.RewardDetailId,
|
||||
RewardNumber = r.RewardNumber,
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Text.Json.Serialization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Entities.Story;
|
||||
using SVSim.Database.Enums;
|
||||
|
||||
namespace SVSim.Bootstrap.Importers;
|
||||
|
||||
@@ -185,7 +186,7 @@ public class StoryImporter
|
||||
foreach (var r in c.StoryReward ?? new())
|
||||
row.Rewards.Add(new StoryChapterReward
|
||||
{
|
||||
RewardType = r.RewardType,
|
||||
RewardType = (UserGoodsType)r.RewardType,
|
||||
RewardDetailId = r.RewardDetailId,
|
||||
RewardNumber = r.RewardNumber,
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using SVSim.Database.Enums;
|
||||
|
||||
namespace SVSim.Database.Entities.Story;
|
||||
|
||||
[Microsoft.EntityFrameworkCore.Owned]
|
||||
public class StoryChapterReward
|
||||
{
|
||||
public int RewardType { get; set; }
|
||||
public UserGoodsType RewardType { get; set; }
|
||||
public long RewardDetailId { get; set; }
|
||||
public int RewardNumber { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using SVSim.Database.Common;
|
||||
using SVSim.Database.Enums;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
@@ -14,7 +15,7 @@ public class AchievementCatalogEntry
|
||||
public int Level { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public int RequireNumber { get; set; }
|
||||
public int RewardType { get; set; }
|
||||
public UserGoodsType RewardType { get; set; }
|
||||
public long RewardDetailId { get; set; }
|
||||
public int RewardNumber { get; set; }
|
||||
public int OrderNum { get; set; }
|
||||
|
||||
@@ -31,7 +31,7 @@ public class ArenaTwoPickReward
|
||||
public int Weight { get; set; } = 1;
|
||||
|
||||
/// <summary><see cref="UserGoodsType"/> on the wire (e.g. Item=4, Rupy=9).</summary>
|
||||
public int RewardType { get; set; }
|
||||
public UserGoodsType RewardType { get; set; }
|
||||
|
||||
/// <summary>Item id for Item; 0 for currencies.</summary>
|
||||
public long RewardId { get; set; }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using SVSim.Database.Common;
|
||||
using SVSim.Database.Enums;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
@@ -20,7 +21,7 @@ public class BattlePassMonthlyMissionEntry : BaseEntity<int>
|
||||
public string Name { get; set; } = "";
|
||||
public int RequireNumber { get; set; }
|
||||
public int BattlePassPoint { get; set; }
|
||||
public int? RewardType { get; set; }
|
||||
public UserGoodsType? RewardType { get; set; }
|
||||
public long? RewardDetailId { get; set; }
|
||||
public int? RewardNumber { get; set; }
|
||||
public string? EventType { get; set; }
|
||||
|
||||
@@ -13,7 +13,7 @@ public class BattlePassRewardEntry : BaseEntity<long>
|
||||
public int SeasonId { get; set; }
|
||||
public BattlePassTrack Track { get; set; }
|
||||
public int Level { get; set; }
|
||||
public int RewardType { get; set; }
|
||||
public UserGoodsType RewardType { get; set; }
|
||||
public long RewardDetailId { get; set; }
|
||||
public int RewardNumber { get; set; }
|
||||
public bool IsAppealExclusion { get; set; }
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace SVSim.Database.Models;
|
||||
///
|
||||
/// Cosmetic ids (sleeve / emblem / degree / field) MUST resolve in
|
||||
/// <c>SBattleLoad.LoadOpponentAssets</c>; placeholder 1s left the client hanging on
|
||||
/// "Waiting for opponent". Prod-verified values come from the Scripted bot fixture.
|
||||
/// "Waiting for opponent". Prod-verified values were captured from live prod traffic.
|
||||
/// </summary>
|
||||
public class BotRosterEntry : BaseEntity<int>
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database.Enums;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
@@ -11,7 +12,7 @@ namespace SVSim.Database.Models;
|
||||
public class BuildDeckProductRewardEntry
|
||||
{
|
||||
public int RewardIndex { get; set; }
|
||||
public int RewardType { get; set; } // Wizard.UserGoods.Type
|
||||
public UserGoodsType RewardType { get; set; }
|
||||
public long RewardDetailId { get; set; }
|
||||
public int RewardNumber { get; set; }
|
||||
public int MessageId { get; set; }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database.Enums;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
@@ -13,7 +14,7 @@ public class BuildDeckSeriesRewardEntry
|
||||
{
|
||||
public int TierIndex { get; set; } // 1, 2, 3, ... — unlock threshold
|
||||
public int ItemIndex { get; set; } // ordinal within tier
|
||||
public int RewardType { get; set; }
|
||||
public UserGoodsType RewardType { get; set; }
|
||||
public long RewardDetailId { get; set; }
|
||||
public int RewardNumber { get; set; }
|
||||
public int MessageId { get; set; }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database.Enums;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
@@ -11,7 +12,7 @@ namespace SVSim.Database.Models;
|
||||
public class LeaderSkinShopProductRewardEntry
|
||||
{
|
||||
public int OrderIndex { get; set; }
|
||||
public int RewardType { get; set; } // Wizard.UserGoods.Type
|
||||
public UserGoodsType RewardType { get; set; }
|
||||
public long RewardDetailId { get; set; }
|
||||
public int RewardNumber { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database.Enums;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
@@ -12,7 +13,7 @@ namespace SVSim.Database.Models;
|
||||
public class LeaderSkinShopSeriesRewardEntry
|
||||
{
|
||||
public int OrderIndex { get; set; }
|
||||
public int RewardType { get; set; }
|
||||
public UserGoodsType RewardType { get; set; }
|
||||
public long RewardDetailId { get; set; }
|
||||
public int RewardNumber { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using SVSim.Database.Common;
|
||||
using SVSim.Database.Enums;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
@@ -14,7 +15,7 @@ public class MissionCatalogEntry : BaseEntity<int>
|
||||
public string Name { get; set; } = "";
|
||||
public int LotType { get; set; }
|
||||
public int RequireNumber { get; set; }
|
||||
public int RewardType { get; set; }
|
||||
public UserGoodsType RewardType { get; set; }
|
||||
public long RewardDetailId { get; set; }
|
||||
public int RewardNumber { get; set; }
|
||||
public int BattlePassPoint { get; set; }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using SVSim.Database.Common;
|
||||
using SVSim.Database.Enums;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
@@ -21,7 +22,7 @@ public class PuzzleMissionEntry : BaseEntity<int>
|
||||
public int OrderId { get; set; }
|
||||
|
||||
// Reward (single-entry per mission)
|
||||
public int RewardType { get; set; } // UserGoodsType
|
||||
public UserGoodsType RewardType { get; set; }
|
||||
public long RewardDetailId { get; set; }
|
||||
public int RewardNumber { get; set; }
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database.Enums;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
@@ -11,7 +12,7 @@ namespace SVSim.Database.Models;
|
||||
public class SleeveShopProductRewardEntry
|
||||
{
|
||||
public int OrderIndex { get; set; }
|
||||
public int RewardType { get; set; } // Wizard.UserGoods.Type
|
||||
public UserGoodsType RewardType { get; set; }
|
||||
public long RewardDetailId { get; set; }
|
||||
public int RewardNumber { get; set; }
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace SVSim.Database.Services;
|
||||
/// <c>/story/*/finish</c>. reward_num is a POST-STATE TOTAL for currencies and a count for
|
||||
/// collection grants — see <see cref="Models.RewardListEntry"/>.
|
||||
/// </summary>
|
||||
public sealed record GrantedReward(int RewardType, long RewardId, int RewardNum);
|
||||
public sealed record GrantedReward(UserGoodsType RewardType, long RewardId, int RewardNum);
|
||||
|
||||
/// <summary>
|
||||
/// Cosmetic projection bundle for /load/index. The four id-lists are "what the viewer owns"
|
||||
|
||||
@@ -309,7 +309,7 @@ internal sealed class InventoryTransaction : IInventoryTransaction
|
||||
var output = new List<GrantedReward>();
|
||||
foreach (var type in orderedTouches)
|
||||
{
|
||||
output.Add(new GrantedReward((int)type, 0, lastCurrencyPost[type]));
|
||||
output.Add(new GrantedReward(type, 0, lastCurrencyPost[type]));
|
||||
}
|
||||
|
||||
// Pass 2 — non-currency grants: one entry per (type, id) using LAST post-state for items
|
||||
@@ -326,7 +326,7 @@ internal sealed class InventoryTransaction : IInventoryTransaction
|
||||
}
|
||||
foreach (var (type, id) in nonCurrencyOrder)
|
||||
{
|
||||
output.Add(new GrantedReward((int)type, id, nonCurrencyKey[(type, id)]));
|
||||
output.Add(new GrantedReward(type, id, nonCurrencyKey[(type, id)]));
|
||||
}
|
||||
return output;
|
||||
}
|
||||
@@ -334,7 +334,7 @@ internal sealed class InventoryTransaction : IInventoryTransaction
|
||||
private IReadOnlyList<GrantedReward> BuildDeltas()
|
||||
=> _ops.OfType<GrantOp>()
|
||||
.Where(o => !o.IsCascade)
|
||||
.Select(o => new GrantedReward((int)o.Type, o.DetailId, o.Num))
|
||||
.Select(o => new GrantedReward(o.Type, o.DetailId, o.Num))
|
||||
.ToList();
|
||||
|
||||
private static bool IsCurrency(UserGoodsType t) =>
|
||||
@@ -353,7 +353,7 @@ internal sealed class InventoryTransaction : IInventoryTransaction
|
||||
};
|
||||
|
||||
private static IReadOnlyList<GrantedReward> Single(UserGoodsType type, long id, int num)
|
||||
=> new[] { new GrantedReward((int)type, id, num) };
|
||||
=> new[] { new GrantedReward(type, id, num) };
|
||||
|
||||
private void ThrowIfCommitted()
|
||||
{
|
||||
@@ -381,7 +381,7 @@ internal sealed class InventoryTransaction : IInventoryTransaction
|
||||
|
||||
var results = new List<GrantedReward>
|
||||
{
|
||||
new((int)UserGoodsType.Card, cardId, postCount),
|
||||
new(UserGoodsType.Card, cardId, postCount),
|
||||
};
|
||||
_ops.Add(new GrantOp(UserGoodsType.Card, cardId, num, postCount, false));
|
||||
|
||||
@@ -394,8 +394,8 @@ internal sealed class InventoryTransaction : IInventoryTransaction
|
||||
{
|
||||
if (TryAddCascadeCosmetic(reward, lookupId))
|
||||
{
|
||||
results.Add(new GrantedReward((int)reward.Type, reward.CosmeticId, 1));
|
||||
_ops.Add(new GrantOp((UserGoodsType)(int)reward.Type, reward.CosmeticId, 1, 1, true));
|
||||
results.Add(new GrantedReward((UserGoodsType)reward.Type, reward.CosmeticId, 1));
|
||||
_ops.Add(new GrantOp((UserGoodsType)reward.Type, reward.CosmeticId, 1, 1, true));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ public class AchievementController : SVSimController
|
||||
await using var tx = await _inv.BeginAsync(viewerId, ct);
|
||||
|
||||
var granted = await tx.GrantAsync(
|
||||
(UserGoodsType)catalogRow.RewardType,
|
||||
catalogRow.RewardType,
|
||||
catalogRow.RewardDetailId,
|
||||
catalogRow.RewardNumber,
|
||||
ct);
|
||||
@@ -108,13 +108,13 @@ public class AchievementController : SVSimController
|
||||
MissionReceiveType = dto.MissionReceiveType,
|
||||
RewardList = granted.Select(g => new RewardGrantDto
|
||||
{
|
||||
RewardType = g.RewardType,
|
||||
RewardType = (int)g.RewardType,
|
||||
RewardId = g.RewardId,
|
||||
RewardNum = g.RewardNum,
|
||||
}).ToList(),
|
||||
TotalReceiveCountList = granted.Select(g => new TotalReceiveCountDto
|
||||
{
|
||||
RewardType = g.RewardType,
|
||||
RewardType = (int)g.RewardType,
|
||||
RewardDetailId = g.RewardId,
|
||||
RewardCount = g.RewardNum,
|
||||
ItemType = 0,
|
||||
|
||||
@@ -27,20 +27,13 @@ public class ArenaTwoPickBattleController : SVSimController
|
||||
[HttpPost("do_matching")]
|
||||
public async Task<IActionResult> DoMatching(
|
||||
[FromBody] DoMatchingRequest req,
|
||||
[FromQuery(Name = "scripted")] string? scripted = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!TryGetViewerId(out var vid)) return Unauthorized();
|
||||
// Accept "1" or "true" (case-insensitive) as per-request opt-in for the Scripted
|
||||
// path. ASP.NET's default bool binder rejects "1", so parse permissively here.
|
||||
// BattleNodeOptions.SoloDefaultsToScripted is the process-wide equivalent and is
|
||||
// applied inside the resolver.
|
||||
var scriptedOptIn = scripted is not null
|
||||
&& (scripted == "1" || string.Equals(scripted, "true", StringComparison.OrdinalIgnoreCase));
|
||||
try
|
||||
{
|
||||
var ctx = await _matchContextBuilder.BuildForTwoPickAsync(vid);
|
||||
var r = await _resolver.ResolveAsync("arena_two_pick_battle", new BattlePlayer(vid, ctx), scriptedOptIn, ct);
|
||||
var r = await _resolver.ResolveAsync("arena_two_pick_battle", new BattlePlayer(vid, ctx), ct);
|
||||
return Ok(new DoMatchingResponseDto
|
||||
{
|
||||
MatchingState = r.MatchingState,
|
||||
|
||||
@@ -64,7 +64,7 @@ public class BattlePassController : SVSimController
|
||||
BattlePassRewardList = outcome.AchievedRewards
|
||||
.Select(g => new BattlePassReceivedRewardDto
|
||||
{
|
||||
RewardType = g.RewardType,
|
||||
RewardType = (int)g.RewardType,
|
||||
RewardDetailId = g.RewardId,
|
||||
RewardNumber = g.RewardNum,
|
||||
}).ToList(),
|
||||
@@ -72,7 +72,7 @@ public class BattlePassController : SVSimController
|
||||
RewardList = outcome.PostStateTotals
|
||||
.Select(g => new BattlePassRewardListEntryDto
|
||||
{
|
||||
RewardType = g.RewardType,
|
||||
RewardType = (int)g.RewardType,
|
||||
RewardId = g.RewardId,
|
||||
RewardNum = g.RewardNum,
|
||||
}).ToList(),
|
||||
|
||||
@@ -94,7 +94,7 @@ public class BuildDeckController : SVSimController
|
||||
.OrderBy(r => r.RewardIndex)
|
||||
.Select(r => new BuildDeckProductRewardDto
|
||||
{
|
||||
RewardType = r.RewardType,
|
||||
RewardType = (int)r.RewardType,
|
||||
RewardDetailId = r.RewardDetailId,
|
||||
RewardNumber = r.RewardNumber,
|
||||
MessageId = r.MessageId,
|
||||
@@ -120,7 +120,7 @@ public class BuildDeckController : SVSimController
|
||||
IsGet = totalSeriesPurchases >= g.Key,
|
||||
RewardList = g.OrderBy(r => r.ItemIndex).Select(r => new BuildDeckProductRewardDto
|
||||
{
|
||||
RewardType = r.RewardType,
|
||||
RewardType = (int)r.RewardType,
|
||||
RewardDetailId = r.RewardDetailId,
|
||||
RewardNumber = r.RewardNumber,
|
||||
MessageId = r.MessageId,
|
||||
@@ -206,7 +206,7 @@ public class BuildDeckController : SVSimController
|
||||
|
||||
// Per-buy rewards
|
||||
foreach (var r in product.Rewards.OrderBy(r => r.RewardIndex))
|
||||
await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
|
||||
await tx.GrantAsync(r.RewardType, r.RewardDetailId, r.RewardNumber);
|
||||
|
||||
// Series-reward tier crossings
|
||||
var crossedTiers = product.Series.SeriesRewards
|
||||
@@ -220,10 +220,10 @@ public class BuildDeckController : SVSimController
|
||||
{
|
||||
foreach (var item in tier.OrderBy(r => r.ItemIndex))
|
||||
{
|
||||
await tx.GrantAsync((UserGoodsType)item.RewardType, item.RewardDetailId, item.RewardNumber);
|
||||
await tx.GrantAsync(item.RewardType, item.RewardDetailId, item.RewardNumber);
|
||||
seriesRewards.Add(new BuildDeckProductRewardDto
|
||||
{
|
||||
RewardType = item.RewardType,
|
||||
RewardType = (int)item.RewardType,
|
||||
RewardDetailId = item.RewardDetailId,
|
||||
RewardNumber = item.RewardNumber,
|
||||
MessageId = item.MessageId,
|
||||
@@ -235,9 +235,7 @@ public class BuildDeckController : SVSimController
|
||||
|
||||
return new BuildDeckBuyResponse
|
||||
{
|
||||
RewardList = result.RewardList
|
||||
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
|
||||
.ToList(),
|
||||
RewardList = result.RewardList.ToRewardList(),
|
||||
SeriesRewards = seriesRewards,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ public class CardController : SVSimController
|
||||
foreach (var (cardId, snapshot) in snapshots)
|
||||
{
|
||||
int requestedNum = createCounts[cardId];
|
||||
int postCount = grants.FirstOrDefault(g => g.RewardType == (int)UserGoodsType.Card && g.RewardId == cardId)?.RewardNum ?? 0;
|
||||
int postCount = grants.FirstOrDefault(g => g.RewardType == UserGoodsType.Card && g.RewardId == cardId)?.RewardNum ?? 0;
|
||||
int reconstructedPre = postCount - requestedNum;
|
||||
if (reconstructedPre != snapshot)
|
||||
{
|
||||
@@ -114,7 +114,7 @@ public class CardController : SVSimController
|
||||
{
|
||||
rewardList.Add(new RewardListEntry
|
||||
{
|
||||
RewardType = grant.RewardType,
|
||||
RewardType = (int)grant.RewardType,
|
||||
RewardId = grant.RewardId,
|
||||
RewardNum = grant.RewardNum,
|
||||
});
|
||||
|
||||
@@ -145,9 +145,7 @@ public class ItemPurchaseController : SVSimController
|
||||
|
||||
return new ItemPurchasePurchaseResponse
|
||||
{
|
||||
RewardList = result.RewardList
|
||||
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
|
||||
.ToList(),
|
||||
RewardList = result.RewardList.ToRewardList(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ public class LeaderSkinController : SVSimController
|
||||
Status = rewardStatus,
|
||||
Items = s.SetCompletionRewards.OrderBy(r => r.OrderIndex).Select(r => new SkinSeriesRewardItemDto
|
||||
{
|
||||
RewardType = r.RewardType,
|
||||
RewardType = (int)r.RewardType,
|
||||
RewardDetailId = r.RewardDetailId,
|
||||
RewardNumber = r.RewardNumber,
|
||||
}).ToList(),
|
||||
@@ -203,14 +203,12 @@ public class LeaderSkinController : SVSimController
|
||||
}
|
||||
|
||||
foreach (var r in product.Rewards.OrderBy(r => r.OrderIndex))
|
||||
await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
|
||||
await tx.GrantAsync(r.RewardType, r.RewardDetailId, r.RewardNumber);
|
||||
|
||||
var result = await tx.CommitAsync(HttpContext.RequestAborted);
|
||||
return new LeaderSkinBuyResponse
|
||||
{
|
||||
RewardList = result.RewardList
|
||||
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
|
||||
.ToList(),
|
||||
RewardList = result.RewardList.ToRewardList(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -260,15 +258,13 @@ public class LeaderSkinController : SVSimController
|
||||
foreach (var p in series.Products.OrderBy(p => p.Id))
|
||||
{
|
||||
foreach (var r in p.Rewards.OrderBy(r => r.OrderIndex))
|
||||
await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
|
||||
await tx.GrantAsync(r.RewardType, r.RewardDetailId, r.RewardNumber);
|
||||
}
|
||||
|
||||
var result = await tx.CommitAsync(HttpContext.RequestAborted);
|
||||
return new LeaderSkinBuyResponse
|
||||
{
|
||||
RewardList = result.RewardList
|
||||
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
|
||||
.ToList(),
|
||||
RewardList = result.RewardList.ToRewardList(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -298,7 +294,7 @@ public class LeaderSkinController : SVSimController
|
||||
return BadRequest(new { error = "series_not_completed" });
|
||||
|
||||
foreach (var r in series.SetCompletionRewards.OrderBy(r => r.OrderIndex))
|
||||
await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
|
||||
await tx.GrantAsync(r.RewardType, r.RewardDetailId, r.RewardNumber);
|
||||
|
||||
_db.ViewerLeaderSkinSetClaims.Add(new ViewerLeaderSkinSetClaim
|
||||
{
|
||||
@@ -310,9 +306,7 @@ public class LeaderSkinController : SVSimController
|
||||
var result = await tx.CommitAsync(HttpContext.RequestAborted);
|
||||
return new LeaderSkinBuyResponse
|
||||
{
|
||||
RewardList = result.RewardList
|
||||
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
|
||||
.ToList(),
|
||||
RewardList = result.RewardList.ToRewardList(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -362,7 +356,7 @@ public class LeaderSkinController : SVSimController
|
||||
},
|
||||
Rewards = p.Rewards.OrderBy(r => r.OrderIndex).Select(r => new SkinProductRewardDto
|
||||
{
|
||||
RewardType = r.RewardType,
|
||||
RewardType = (int)r.RewardType,
|
||||
RewardDetailId = r.RewardDetailId,
|
||||
RewardNumber = r.RewardNumber,
|
||||
IsOwned = IsRewardOwned(r, ownedSkinIds),
|
||||
@@ -379,7 +373,7 @@ public class LeaderSkinController : SVSimController
|
||||
private static bool IsRewardOwned(LeaderSkinShopProductRewardEntry r, IReadOnlySet<int> ownedSkinIds)
|
||||
{
|
||||
// Skin reward: direct check.
|
||||
if (r.RewardType == (int)UserGoodsType.Skin)
|
||||
if (r.RewardType == UserGoodsType.Skin)
|
||||
return ownedSkinIds.Contains((int)r.RewardDetailId);
|
||||
// Other types: we don't have the full cosmetic-owned graph in scope here. The product's
|
||||
// sibling Skin reward tells us whether the bundle was purchased; piggy-back on that by
|
||||
|
||||
@@ -418,9 +418,7 @@ public class PackController : SVSimController
|
||||
// CommitAsync saves all mutations and produces reward_list with currency-collision resolved.
|
||||
// Tutorial path never calls TrySpendAsync so no currency op is in the log — correct.
|
||||
var result = await tx.CommitAsync(HttpContext.RequestAborted);
|
||||
var rewardList = result.RewardList
|
||||
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
|
||||
.ToList();
|
||||
var rewardList = result.RewardList.ToRewardList();
|
||||
|
||||
return new PackOpenResponse
|
||||
{
|
||||
|
||||
@@ -109,7 +109,7 @@ public class PuzzleController : SVSimController
|
||||
RewardList = new List<PuzzleMissionRewardResponse>
|
||||
{
|
||||
new() {
|
||||
RewardType = s.Mission.RewardType,
|
||||
RewardType = (int)s.Mission.RewardType,
|
||||
RewardDetailId = s.Mission.RewardDetailId,
|
||||
RewardNumber = s.Mission.RewardNumber,
|
||||
},
|
||||
@@ -182,7 +182,7 @@ public class PuzzleController : SVSimController
|
||||
try
|
||||
{
|
||||
granted = await tx.GrantAsync(
|
||||
(UserGoodsType)status.Mission.RewardType,
|
||||
status.Mission.RewardType,
|
||||
status.Mission.RewardDetailId,
|
||||
status.Mission.RewardNumber);
|
||||
}
|
||||
@@ -200,7 +200,7 @@ public class PuzzleController : SVSimController
|
||||
});
|
||||
response.AchievedInfo.AchievedMissionRewardList.Add(new PuzzleAchievedMissionReward
|
||||
{
|
||||
MissionRewardType = status.Mission.RewardType,
|
||||
MissionRewardType = (int)status.Mission.RewardType,
|
||||
MissionRewardDetailId = status.Mission.RewardDetailId,
|
||||
MissionRewardNumber = status.Mission.RewardNumber,
|
||||
});
|
||||
@@ -208,7 +208,7 @@ public class PuzzleController : SVSimController
|
||||
{
|
||||
response.RewardList.Add(new TreasureRewardResponse
|
||||
{
|
||||
RewardType = g.RewardType,
|
||||
RewardType = (int)g.RewardType,
|
||||
RewardId = g.RewardId,
|
||||
RewardNum = g.RewardNum,
|
||||
});
|
||||
|
||||
@@ -132,10 +132,7 @@ public sealed class RankBattleController : ControllerBase
|
||||
return Ok(new DoMatchingResponseDto { MatchingState = 3001, NodeServerUrl = "" });
|
||||
}
|
||||
|
||||
// Rank battle has no ?scripted=1 query opt-in (no live capture has shown such a
|
||||
// param on the rank URLs). The process-wide BattleNodeOptions.SoloDefaultsToScripted
|
||||
// toggle is the only scripted entry point and is honored inside the resolver.
|
||||
var r = await _resolver.ResolveAsync(mode, new BattlePlayer(vid, ctx), scriptedOptIn: false, ct);
|
||||
var r = await _resolver.ResolveAsync(mode, new BattlePlayer(vid, ctx), ct);
|
||||
|
||||
return Ok(new DoMatchingResponseDto
|
||||
{
|
||||
|
||||
@@ -68,7 +68,7 @@ public class SleeveController : SVSimController
|
||||
IsPurchasedProduct = IsProductPurchased(p, ownedSleeveIds),
|
||||
Rewards = p.Rewards.OrderBy(r => r.OrderIndex).Select(r => new SleeveProductRewardDto
|
||||
{
|
||||
RewardType = r.RewardType,
|
||||
RewardType = (int)r.RewardType,
|
||||
RewardDetailId = r.RewardDetailId,
|
||||
RewardNumber = r.RewardNumber,
|
||||
}).ToList(),
|
||||
@@ -142,15 +142,13 @@ public class SleeveController : SVSimController
|
||||
|
||||
// Grant each catalog reward through the central dispatcher.
|
||||
foreach (var r in product.Rewards.OrderBy(r => r.OrderIndex))
|
||||
await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
|
||||
await tx.GrantAsync(r.RewardType, r.RewardDetailId, r.RewardNumber);
|
||||
|
||||
var result = await tx.CommitAsync(HttpContext.RequestAborted);
|
||||
|
||||
return new SleeveBuyResponse
|
||||
{
|
||||
RewardList = result.RewardList
|
||||
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
|
||||
.ToList(),
|
||||
RewardList = result.RewardList.ToRewardList(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -164,7 +162,7 @@ public class SleeveController : SVSimController
|
||||
{
|
||||
foreach (var r in product.Rewards)
|
||||
{
|
||||
if (r.RewardType == (int)UserGoodsType.Sleeve && ownedSleeveIds.Contains(r.RewardDetailId))
|
||||
if (r.RewardType == UserGoodsType.Sleeve && ownedSleeveIds.Contains(r.RewardDetailId))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -143,15 +143,7 @@ public class SpotCardExchangeController : SVSimController
|
||||
|
||||
// Grant the card itself via the inventory tx (handles cosmetic cascade).
|
||||
var granted = await tx.GrantAsync(UserGoodsType.Card, entry.Id, 1);
|
||||
foreach (var g in granted)
|
||||
{
|
||||
rewardList.Add(new RewardListEntry
|
||||
{
|
||||
RewardType = g.RewardType,
|
||||
RewardId = g.RewardId,
|
||||
RewardNum = g.RewardNum,
|
||||
});
|
||||
}
|
||||
rewardList.AddRange(granted.ToRewardList());
|
||||
|
||||
_db.ViewerSpotCardExchanges.Add(new ViewerSpotCardExchange
|
||||
{
|
||||
|
||||
@@ -10,10 +10,7 @@ namespace SVSim.EmulatedEntrypoint.Matching;
|
||||
/// regardless of which URL family carried the request:
|
||||
/// </para>
|
||||
/// <list type="number">
|
||||
/// <item>Honor the dev-affordance scripted opt-in (route flag and/or
|
||||
/// <see cref="BattleNodeOptions.SoloDefaultsToScripted"/>) — bypass pair-up,
|
||||
/// register a Scripted match, return immediately.</item>
|
||||
/// <item>Otherwise consult <see cref="IMatchingPairUpService"/> and translate the
|
||||
/// <item>Consult <see cref="IMatchingPairUpService"/> and translate the
|
||||
/// resulting <see cref="PairUpResult"/> into a wire matching_state per the
|
||||
/// 3002 / 3004 / 3007 / 3011 vocabulary.</item>
|
||||
/// </list>
|
||||
@@ -33,15 +30,9 @@ public interface IMatchingResolver
|
||||
/// <c>"rotation_rank_battle"</c>, <c>"unlimited_rank_battle"</c>).
|
||||
/// </param>
|
||||
/// <param name="player">Caller's <see cref="BattlePlayer"/> (viewer-id + built MatchContext).</param>
|
||||
/// <param name="scriptedOptIn">
|
||||
/// Per-request opt-in from a controller-specific signal (e.g. TK2's <c>?scripted=1</c>
|
||||
/// query param). OR'd with <see cref="BattleNodeOptions.SoloDefaultsToScripted"/>;
|
||||
/// either being true short-circuits to a Scripted match.
|
||||
/// </param>
|
||||
Task<MatchingResolution> ResolveAsync(
|
||||
string mode,
|
||||
BattlePlayer player,
|
||||
bool scriptedOptIn,
|
||||
CancellationToken ct);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using SVSim.BattleNode.Bridge;
|
||||
using SVSim.BattleNode.Sessions;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Matching;
|
||||
|
||||
@@ -8,34 +7,20 @@ public sealed class MatchingResolver : IMatchingResolver
|
||||
{
|
||||
private readonly IMatchingBridge _bridge;
|
||||
private readonly IMatchingPairUpService _pairUp;
|
||||
private readonly BattleNodeOptions _options;
|
||||
|
||||
public MatchingResolver(
|
||||
IMatchingBridge bridge,
|
||||
IMatchingPairUpService pairUp,
|
||||
BattleNodeOptions options)
|
||||
IMatchingPairUpService pairUp)
|
||||
{
|
||||
_bridge = bridge;
|
||||
_pairUp = pairUp;
|
||||
_options = options;
|
||||
}
|
||||
|
||||
public Task<MatchingResolution> ResolveAsync(
|
||||
string mode,
|
||||
BattlePlayer player,
|
||||
bool scriptedOptIn,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Dev-affordance short-circuit. Either a per-request flag (e.g. ?scripted=1) or the
|
||||
// process-wide BattleNodeOptions.SoloDefaultsToScripted toggle puts us here.
|
||||
// Registers a Scripted match (server-side scripted opponent in BattleSession) and
|
||||
// returns matching_state=3004 SUCCEEDED so the client opens the WS and proceeds.
|
||||
if (scriptedOptIn || _options.SoloDefaultsToScripted)
|
||||
{
|
||||
var m = _bridge.RegisterBattle(player, p2: null, BattleType.Scripted);
|
||||
return Task.FromResult(new MatchingResolution(3004, m.BattleId, m.NodeServerUrl));
|
||||
}
|
||||
|
||||
return ResolveViaPairUpAsync(mode, player, ct);
|
||||
}
|
||||
|
||||
|
||||
21
SVSim.EmulatedEntrypoint/Models/Dtos/RewardListExtensions.cs
Normal file
21
SVSim.EmulatedEntrypoint/Models/Dtos/RewardListExtensions.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using SVSim.Database.Services;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// Projects inventory <see cref="GrantedReward"/> results (post-state totals or per-grant deltas)
|
||||
/// into the wire <see cref="RewardListEntry"/> shape. The <c>reward_type</c> enum is widened to its
|
||||
/// int wire value at this single boundary. Replaces the per-endpoint copies of this projection
|
||||
/// (pack/open, leader_skin/buy*, build_deck/buy, sleeve/buy, item_purchase, spot_card_exchange,
|
||||
/// gacha-point exchange).
|
||||
/// </summary>
|
||||
public static class RewardListExtensions
|
||||
{
|
||||
public static List<RewardListEntry> ToRewardList(this IEnumerable<GrantedReward> grants) =>
|
||||
grants.Select(g => new RewardListEntry
|
||||
{
|
||||
RewardType = (int)g.RewardType,
|
||||
RewardId = g.RewardId,
|
||||
RewardNum = g.RewardNum,
|
||||
}).ToList();
|
||||
}
|
||||
@@ -125,7 +125,7 @@ public class Program
|
||||
// BestHTTP's SocketManager parses this as the Socket.IO v2 endpoint URL.
|
||||
opt.NodeServerUrl = "localhost:5148/socket.io/";
|
||||
// Any field in BattleNodeOptions can be overridden via the "BattleNode" section
|
||||
// in appsettings*.json — see appsettings.Development.json for SoloDefaultsToScripted.
|
||||
// in appsettings*.json — see appsettings.Development.json for DiagnosticLogging.
|
||||
builder.Configuration.GetSection("BattleNode").Bind(opt);
|
||||
});
|
||||
// In-process FCFS pair-up for TK2 PvP /do_matching, plus rank-battle's AI-fallback
|
||||
@@ -138,9 +138,8 @@ public class Program
|
||||
new ModePolicy("unlimited_rank_battle", PolicyKind.PvpFirstThenAiFallback),
|
||||
}));
|
||||
builder.Services.AddSingleton<IMatchingPairUpService, InProcessPairUp>();
|
||||
// Single resolver shared by every /do_matching family controller. Owns the scripted-
|
||||
// flag short-circuit + the pair-up → matching_state mapping. Singleton: stateless,
|
||||
// all deps are singletons too.
|
||||
// Single resolver shared by every /do_matching family controller. Owns the
|
||||
// pair-up → matching_state mapping. Singleton: stateless, all deps are singletons too.
|
||||
builder.Services.AddSingleton<IMatchingResolver, MatchingResolver>();
|
||||
// Phase 3: bot roster used by RankBattleController.AiStart to compose oppo_info.
|
||||
// Transient because BotRoster depends on the transient IGlobalsRepository.
|
||||
@@ -149,7 +148,18 @@ public class Program
|
||||
builder.Services.AddTransient<ShadowverseTranslationMiddleware>();
|
||||
builder.Services.AddTransient<SessionidMappingMiddleware>();
|
||||
builder.Services.AddSingleton<ShadowverseSessionService>();
|
||||
builder.Services.AddSingleton<ISteamServer, FacepunchSteamServer>();
|
||||
// Steam ticket validation seam. Production uses Facepunch against real Steam. Local dev
|
||||
// can opt into a no-op validator via Auth:BypassSteamTicket so clients without a real
|
||||
// Steam session (e.g. a second same-machine instance for the two-client PvP smoke) can
|
||||
// authenticate. Gate is config-only and ships false everywhere except Development.
|
||||
if (builder.Configuration.GetValue<bool>("Auth:BypassSteamTicket"))
|
||||
{
|
||||
builder.Services.AddSingleton<ISteamServer, DevAlwaysValidSteamServer>();
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddSingleton<ISteamServer, FacepunchSteamServer>();
|
||||
}
|
||||
builder.Services.AddSingleton<SteamSessionService>();
|
||||
builder.Services.AddAuthentication()
|
||||
.AddScheme<SteamAuthenticationHandlerOptions, SteamSessionAuthenticationHandler>(
|
||||
|
||||
@@ -301,7 +301,7 @@ public class ArenaTwoPickService : IArenaTwoPickService
|
||||
// Pre-load item_type for any Item-typed reward so we can populate it on the
|
||||
// per-grant delta entries. Currencies don't need a lookup (item_type stays 0).
|
||||
var itemRewardIds = rewardRows
|
||||
.Where(r => r.RewardType == (int)UserGoodsType.Item)
|
||||
.Where(r => r.RewardType == UserGoodsType.Item)
|
||||
.Select(r => (int)r.RewardId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
@@ -325,10 +325,10 @@ public class ArenaTwoPickService : IArenaTwoPickService
|
||||
// Skip when the rolled outcome is "nothing" (RewardNum == 0).
|
||||
if (pick.RewardNum <= 0) continue;
|
||||
|
||||
await tx.GrantAsync((UserGoodsType)pick.RewardType, pick.RewardId, pick.RewardNum);
|
||||
await tx.GrantAsync(pick.RewardType, pick.RewardId, pick.RewardNum);
|
||||
deltas.Add(new TwoPickRewardReceivedDto
|
||||
{
|
||||
RewardType = pick.RewardType,
|
||||
RewardType = (int)pick.RewardType,
|
||||
RewardDetailId = pick.RewardId,
|
||||
RewardCount = pick.RewardNum,
|
||||
ItemType = itemTypeById.TryGetValue((int)pick.RewardId, out var t) ? t : 0,
|
||||
@@ -339,7 +339,7 @@ public class ArenaTwoPickService : IArenaTwoPickService
|
||||
var result = await tx.CommitAsync();
|
||||
|
||||
var postStates = result.RewardList
|
||||
.Select(g => new RewardEntryDto { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
|
||||
.Select(g => new RewardEntryDto { RewardType = (int)g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
|
||||
.ToList();
|
||||
|
||||
await _runs.DeleteAsync(viewerId);
|
||||
|
||||
@@ -184,7 +184,7 @@ public sealed class BattlePassService : IBattlePassService
|
||||
{
|
||||
if (claimSet.Contains((r.Track, r.Level))) continue;
|
||||
_viewerBp.AddClaim(viewerId, season.Id, r.Track, r.Level, now);
|
||||
await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber, ct);
|
||||
await tx.GrantAsync(r.RewardType, r.RewardDetailId, r.RewardNumber, ct);
|
||||
}
|
||||
|
||||
// CommitAsync handles DB save + currency-collision rule. Crystal spend is the first
|
||||
@@ -240,7 +240,7 @@ public sealed class BattlePassService : IBattlePassService
|
||||
if (r.Track == BattlePassTrack.Premium && !progress.IsPremium) continue;
|
||||
if (claimSet.Contains((r.Track, r.Level))) continue;
|
||||
_viewerBp.AddClaim(viewerId, season.Id, r.Track, r.Level, now);
|
||||
await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber, ct);
|
||||
await tx.GrantAsync(r.RewardType, r.RewardDetailId, r.RewardNumber, ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,7 +297,7 @@ public sealed class BattlePassService : IBattlePassService
|
||||
return new BattlePassRewardDto
|
||||
{
|
||||
RewardLevel = Inv(r.Level),
|
||||
RewardType = Inv(r.RewardType),
|
||||
RewardType = Inv((int)r.RewardType),
|
||||
RewardDetailId = Inv(r.RewardDetailId),
|
||||
RewardNumber = Inv(r.RewardNumber),
|
||||
IsReceived = claimSet.Contains((r.Track, r.Level)),
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Development-only <see cref="ISteamServer"/> that accepts every ticket without contacting
|
||||
/// Steam. Selected in <c>Program.cs</c> when <c>Auth:BypassSteamTicket</c> is true, so clients
|
||||
/// with a synthetic (non-Steam) identity — e.g. a second instance on the same machine for the
|
||||
/// two-client PvP smoke — can authenticate. NEVER select this outside local dev: it turns the
|
||||
/// Steam ticket gate into a no-op for the whole process.
|
||||
/// </summary>
|
||||
public sealed class DevAlwaysValidSteamServer : ISteamServer
|
||||
{
|
||||
private readonly ILogger<DevAlwaysValidSteamServer> _logger;
|
||||
|
||||
public DevAlwaysValidSteamServer(ILogger<DevAlwaysValidSteamServer> logger) => _logger = logger;
|
||||
|
||||
public void Initialize(int appId) { }
|
||||
|
||||
public bool BeginAuthSession(byte[] ticket, ulong steamId)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"DEV Steam bypass: accepting ticket for steamId {SteamId} WITHOUT validation (ticketLen={Len}).",
|
||||
steamId, ticket.Length);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void EndSession(ulong steamId) { }
|
||||
|
||||
public void Shutdown() { }
|
||||
}
|
||||
@@ -210,9 +210,7 @@ public sealed class GachaPointService : IGachaPointService
|
||||
// Emblem (standard legendary) or Skin+Emblem (leader). Convert at the wire boundary
|
||||
// so ExchangeOutcome still carries RewardListEntry for the controller response.
|
||||
var granted = await tx.GrantAsync(UserGoodsType.Card, cardId, 1);
|
||||
var rewardList = granted
|
||||
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
|
||||
.ToList();
|
||||
var rewardList = granted.ToRewardList();
|
||||
|
||||
return ExchangeOutcome.Ok(rewardList);
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ public sealed class MissionAssembler : IMissionAssembler
|
||||
LotType = cat.LotType.ToString(),
|
||||
BattlePassPoint = cat.BattlePassPoint.ToString(),
|
||||
RequireNumber = cat.RequireNumber,
|
||||
RewardType = cat.RewardType,
|
||||
RewardType = (int)cat.RewardType,
|
||||
RewardDetailId = cat.RewardDetailId,
|
||||
RewardNumber = cat.RewardNumber,
|
||||
DefaultFlag = cat.DefaultFlag,
|
||||
@@ -117,7 +117,7 @@ public sealed class MissionAssembler : IMissionAssembler
|
||||
TotalCount = total,
|
||||
AchievementName = catalog.Name,
|
||||
RequireNumber = catalog.RequireNumber,
|
||||
RewardType = catalog.RewardType,
|
||||
RewardType = (int)catalog.RewardType,
|
||||
RewardDetailId = catalog.RewardDetailId,
|
||||
RewardNumber = catalog.RewardNumber,
|
||||
MaxLevel = maxLevel,
|
||||
@@ -164,7 +164,7 @@ public sealed class MissionAssembler : IMissionAssembler
|
||||
{
|
||||
entry.RewardInfo = new BPMonthlyMissionRewardInfoDto
|
||||
{
|
||||
RewardType = mm.RewardType.Value.ToString(),
|
||||
RewardType = ((int)mm.RewardType.Value).ToString(),
|
||||
RewardDetailId = (mm.RewardDetailId ?? 0).ToString(),
|
||||
RewardNumber = (mm.RewardNumber ?? 0).ToString(),
|
||||
};
|
||||
|
||||
@@ -150,7 +150,7 @@ public class StoryService : IStoryService
|
||||
}).ToList(),
|
||||
StoryReward = c.Rewards.Select(r => new RewardDto
|
||||
{
|
||||
RewardType = r.RewardType.ToString(),
|
||||
RewardType = ((int)r.RewardType).ToString(),
|
||||
RewardDetailId = r.RewardDetailId.ToString(),
|
||||
RewardNumber = r.RewardNumber.ToString(),
|
||||
}).ToList(),
|
||||
@@ -539,7 +539,7 @@ public class StoryService : IStoryService
|
||||
{
|
||||
try
|
||||
{
|
||||
await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
|
||||
await tx.GrantAsync(r.RewardType, r.RewardDetailId, r.RewardNumber);
|
||||
}
|
||||
catch (NotSupportedException ex)
|
||||
{
|
||||
@@ -564,7 +564,7 @@ public class StoryService : IStoryService
|
||||
{
|
||||
resp.RewardList.Add(new RewardGrant
|
||||
{
|
||||
RewardType = g.RewardType.ToString(),
|
||||
RewardType = ((int)g.RewardType).ToString(),
|
||||
RewardId = g.RewardId.ToString(),
|
||||
RewardNum = g.RewardNum.ToString(),
|
||||
});
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"Auth": {
|
||||
"BypassSteamTicket": true
|
||||
},
|
||||
"BattleNode": {
|
||||
"SoloDefaultsToScripted": false,
|
||||
"DiagnosticLogging": false
|
||||
"DiagnosticLogging": true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,19 +8,19 @@ namespace SVSim.UnitTests.BattleNode.Bridge;
|
||||
public class MatchingBridgeTests
|
||||
{
|
||||
[Test]
|
||||
public void RegisterBattle_Scripted_stores_pending_and_returns_node_url()
|
||||
public void RegisterBattle_Bot_stores_pending_and_returns_node_url()
|
||||
{
|
||||
var store = new InMemoryBattleSessionStore();
|
||||
var bridge = new MatchingBridge(store, new BattleNodeOptions { NodeServerUrl = "localhost:5148/socket.io/" });
|
||||
var p1 = new BattlePlayer(906243102, FixtureCtx());
|
||||
|
||||
var match = bridge.RegisterBattle(p1, p2: null, BattleType.Scripted);
|
||||
var match = bridge.RegisterBattle(p1, p2: null, BattleType.Bot);
|
||||
|
||||
Assert.That(match.NodeServerUrl, Is.EqualTo("localhost:5148/socket.io/"));
|
||||
Assert.That(match.BattleId, Is.Not.Empty);
|
||||
var pending = store.TryGetPending(match.BattleId);
|
||||
Assert.That(pending, Is.Not.Null);
|
||||
Assert.That(pending!.Type, Is.EqualTo(BattleType.Scripted));
|
||||
Assert.That(pending!.Type, Is.EqualTo(BattleType.Bot));
|
||||
Assert.That(pending.P1.ViewerId, Is.EqualTo(906243102));
|
||||
Assert.That(pending.P2, Is.Null);
|
||||
}
|
||||
@@ -30,8 +30,8 @@ public class MatchingBridgeTests
|
||||
{
|
||||
var bridge = new MatchingBridge(new InMemoryBattleSessionStore(), new BattleNodeOptions());
|
||||
|
||||
var a = bridge.RegisterBattle(new BattlePlayer(1, FixtureCtx()), null, BattleType.Scripted);
|
||||
var b = bridge.RegisterBattle(new BattlePlayer(2, FixtureCtx()), null, BattleType.Scripted);
|
||||
var a = bridge.RegisterBattle(new BattlePlayer(1, FixtureCtx()), null, BattleType.Bot);
|
||||
var b = bridge.RegisterBattle(new BattlePlayer(2, FixtureCtx()), null, BattleType.Bot);
|
||||
|
||||
Assert.That(a.BattleId, Is.Not.EqualTo(b.BattleId));
|
||||
}
|
||||
@@ -41,7 +41,7 @@ public class MatchingBridgeTests
|
||||
{
|
||||
var bridge = new MatchingBridge(new InMemoryBattleSessionStore(), new BattleNodeOptions());
|
||||
|
||||
var match = bridge.RegisterBattle(new BattlePlayer(1, FixtureCtx()), null, BattleType.Scripted);
|
||||
var match = bridge.RegisterBattle(new BattlePlayer(1, FixtureCtx()), null, BattleType.Bot);
|
||||
|
||||
Assert.That(match.BattleId, Has.Length.EqualTo(12));
|
||||
Assert.That(match.BattleId, Does.Match("^[0-9]{12}$"));
|
||||
|
||||
@@ -15,73 +15,6 @@ namespace SVSim.UnitTests.BattleNode.Integration;
|
||||
[TestFixture]
|
||||
public class BattleNodeFlowTests
|
||||
{
|
||||
/// <summary>
|
||||
/// End-to-end smoke for the v1.2 scripted lifecycle. Boots the EmulatedEntrypoint via
|
||||
/// SVSimTestFactory, mints a battle through IMatchingBridge with a fixture MatchContext,
|
||||
/// opens a raw Socket.IO v2 client against the in-process TestServer, and drives
|
||||
/// InitNetwork → Loaded → Swap → TurnEnd × 2, asserting the right scripted frames come
|
||||
/// back in order including the two-cycle three-frame opponent-turn loop (TurnStart +
|
||||
/// TurnEnd + Judge per cycle).
|
||||
/// </summary>
|
||||
[Test]
|
||||
[Timeout(30000)]
|
||||
public async Task ClientWalksHandshakeToReady_ReceivesAllScriptedFrames()
|
||||
{
|
||||
await using var factory = new SVSimTestFactory();
|
||||
var bridge = factory.Services.GetRequiredService<IMatchingBridge>();
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
var ct = cts.Token;
|
||||
var pending = bridge.RegisterBattle(
|
||||
new SVSim.BattleNode.Bridge.BattlePlayer(906243102, FixtureCtx()),
|
||||
p2: null,
|
||||
SVSim.BattleNode.Sessions.BattleType.Scripted);
|
||||
|
||||
var key = MakeKey();
|
||||
var encryptedVid = NodeCrypto.EncryptForNode("906243102", key);
|
||||
var wsUri = new Uri($"ws://localhost/socket.io/?BattleId={pending.BattleId}&viewerId={Uri.EscapeDataString(encryptedVid)}&EIO=3&transport=websocket");
|
||||
|
||||
var wsClient = factory.Server.CreateWebSocketClient();
|
||||
var ws = await wsClient.ConnectAsync(wsUri, ct);
|
||||
await using var client = new RawSocketIoTestClient(ws);
|
||||
await client.ConsumeHandshakeAsync(ct);
|
||||
|
||||
await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.InitNetwork, pubSeq: 1), key, ct);
|
||||
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.InitNetwork));
|
||||
|
||||
await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.InitBattle, pubSeq: 2), key, ct);
|
||||
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.Matched));
|
||||
|
||||
await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.Loaded, pubSeq: 3), key, ct);
|
||||
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.BattleStart));
|
||||
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.Deal));
|
||||
|
||||
await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.Swap, pubSeq: 4,
|
||||
body: new Dictionary<string, object?> { ["idxList"] = new List<object?>() }), key, ct);
|
||||
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.Swap));
|
||||
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.Ready));
|
||||
|
||||
// --- v1.2 opponent turn loop: drive two consecutive cycles ---
|
||||
// Cycle 1: player ends turn -> server pushes opponent TurnStart + TurnEnd + Judge.
|
||||
await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.TurnEnd, pubSeq: 5), key, ct);
|
||||
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.TurnStart));
|
||||
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.TurnEnd));
|
||||
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.Judge));
|
||||
|
||||
// Cycle 2: same burst again -- session phase reset to AfterReady, so the next TurnEnd matches.
|
||||
await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.TurnEnd, pubSeq: 6), key, ct);
|
||||
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.TurnStart));
|
||||
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.TurnEnd));
|
||||
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.Judge));
|
||||
}
|
||||
|
||||
private static MsgEnvelope MakeEnvelope(NetworkBattleUri uri, long pubSeq, Dictionary<string, object?>? body = null) =>
|
||||
new(uri, ViewerId: 906243102, Uuid: "udid-test", Bid: null, Try: 0,
|
||||
Cat: uri == NetworkBattleUri.InitNetwork ? EmitCategory.General
|
||||
: uri == NetworkBattleUri.InitBattle ? EmitCategory.Matching
|
||||
: EmitCategory.Battle,
|
||||
PubSeq: pubSeq, PlaySeq: null, Body: new RawBody(body ?? new Dictionary<string, object?>()));
|
||||
|
||||
private static string MakeKey()
|
||||
{
|
||||
var seq = 0;
|
||||
@@ -101,7 +34,7 @@ public class BattleNodeFlowTests
|
||||
/// against an actual seeded viewer.
|
||||
/// </summary>
|
||||
[Test]
|
||||
[Timeout(30000)]
|
||||
[Timeout(60000)]
|
||||
public async Task Matched_frame_contains_drafted_deck_cards()
|
||||
{
|
||||
await using var factory = new SVSimTestFactory();
|
||||
@@ -133,21 +66,23 @@ public class BattleNodeFlowTests
|
||||
var ctx = await builder.BuildForTwoPickAsync(vid);
|
||||
var bridge = factory.Services.GetRequiredService<IMatchingBridge>();
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(45));
|
||||
var ct = cts.Token;
|
||||
var vidB = vid + 1;
|
||||
var pending = bridge.RegisterBattle(
|
||||
new SVSim.BattleNode.Bridge.BattlePlayer(vid, ctx),
|
||||
p2: null,
|
||||
SVSim.BattleNode.Sessions.BattleType.Scripted);
|
||||
new SVSim.BattleNode.Bridge.BattlePlayer(vidB, FixtureCtx()),
|
||||
SVSim.BattleNode.Sessions.BattleType.Pvp);
|
||||
|
||||
var key = MakeKey();
|
||||
var encryptedVid = NodeCrypto.EncryptForNode(vid.ToString(), key);
|
||||
var wsUri = new Uri($"ws://localhost/socket.io/?BattleId={pending.BattleId}&viewerId={Uri.EscapeDataString(encryptedVid)}&EIO=3&transport=websocket");
|
||||
|
||||
var wsClient = factory.Server.CreateWebSocketClient();
|
||||
var ws = await wsClient.ConnectAsync(wsUri, ct);
|
||||
await using var client = new RawSocketIoTestClient(ws);
|
||||
await client.ConsumeHandshakeAsync(ct);
|
||||
// PvP constructs the BattleSession on the SECOND arriver, so connecting only P1 parks it
|
||||
// forever. Connect BOTH clients, then drive P1 (the seeded viewer) through
|
||||
// InitNetwork/InitBattle to harvest its own Matched — pushed to the sender before the
|
||||
// mulligan barrier, so B's handshake is not needed for P1's Matched to arrive.
|
||||
var (client, clientB) = await ConnectBothAsync(factory, pending.BattleId, vid, vidB, key, ct);
|
||||
await using var _a = client;
|
||||
await using var _b = clientB;
|
||||
await Task.WhenAll(client.ConsumeHandshakeAsync(ct), clientB.ConsumeHandshakeAsync(ct));
|
||||
|
||||
// InitNetwork → ack
|
||||
await client.SendMsgAsync(MakeEnvelopeWith(vid, NetworkBattleUri.InitNetwork, pubSeq: 1), key, ct);
|
||||
@@ -218,24 +153,30 @@ public class BattleNodeFlowTests
|
||||
await using var _b = clientB;
|
||||
await Task.WhenAll(clientA.ConsumeHandshakeAsync(ct), clientB.ConsumeHandshakeAsync(ct));
|
||||
|
||||
await DriveHandshakeAsync(clientA, vidA, key, ct);
|
||||
await DriveHandshakeAsync(clientB, vidB, key, ct);
|
||||
await DrivePvpHandshakeAsync(clientA, vidA, clientB, vidB, key, ct);
|
||||
|
||||
// Both are now AfterReady. A sends TurnEnd; both should receive TurnEnd + Judge.
|
||||
// Both are now AfterReady. Deterministic-turn handover, mirroring the real two-client
|
||||
// capture (2026-06-03 battle_test). A ends its turn; the OPPONENT (B) receives the
|
||||
// translated {turnState:0} TurnEnd. A receives nothing — it already ran the turn locally.
|
||||
await clientA.SendMsgAsync(MakeEnvelopeWith(vidA, NetworkBattleUri.TurnEnd, pubSeq: 5), key, ct);
|
||||
var bTurnEnd = await clientB.ReceiveSynchronizeAsync(ct);
|
||||
Assert.That(bTurnEnd.Uri, Is.EqualTo(NetworkBattleUri.TurnEnd));
|
||||
|
||||
var aFirst = await clientA.ReceiveSynchronizeAsync(ct);
|
||||
var aSecond = await clientA.ReceiveSynchronizeAsync(ct);
|
||||
var bFirst = await clientB.ReceiveSynchronizeAsync(ct);
|
||||
var bSecond = await clientB.ReceiveSynchronizeAsync(ct);
|
||||
// The client rule is: receive opponent TurnEnd -> SendJudge. So B (the taker-over) sends
|
||||
// Judge. The {spin:0} reflects BACK to B (its own ControlTurnStartPlayer gate), NOT to A —
|
||||
// routing it to A would restart A's turn and stall the loop (the live-run bug this fixes).
|
||||
await clientB.SendMsgAsync(MakeEnvelopeWith(vidB, NetworkBattleUri.Judge, pubSeq: 5), key, ct);
|
||||
var bJudge = await clientB.ReceiveSynchronizeAsync(ct);
|
||||
Assert.That(bJudge.Uri, Is.EqualTo(NetworkBattleUri.Judge));
|
||||
|
||||
Assert.That(new[] { aFirst.Uri, aSecond.Uri },
|
||||
Is.EquivalentTo(new[] { NetworkBattleUri.TurnEnd, NetworkBattleUri.Judge }));
|
||||
Assert.That(new[] { bFirst.Uri, bSecond.Uri },
|
||||
Is.EquivalentTo(new[] { NetworkBattleUri.TurnEnd, NetworkBattleUri.Judge }));
|
||||
// B opens its turn: TurnStart relays to the opponent A as {spin:0} ("opponent's turn").
|
||||
await clientB.SendMsgAsync(MakeEnvelopeWith(vidB, NetworkBattleUri.TurnStart, pubSeq: 6), key, ct);
|
||||
var aTurnStart = await clientA.ReceiveSynchronizeAsync(ct);
|
||||
Assert.That(aTurnStart.Uri, Is.EqualTo(NetworkBattleUri.TurnStart));
|
||||
|
||||
// PlayActions forwarding: B sends, A receives.
|
||||
await clientB.SendMsgAsync(MakeEnvelopeWith(vidB, NetworkBattleUri.PlayActions, pubSeq: 6), key, ct);
|
||||
// PlayActions translation: B plays a card; A receives the opponent-facing PlayActions
|
||||
// frame (Uri preserved, body synthesized by PlayActionsHandler).
|
||||
await clientB.SendMsgAsync(MakeEnvelopeWith(vidB, NetworkBattleUri.PlayActions, pubSeq: 7), key, ct);
|
||||
var aForwarded = await clientA.ReceiveSynchronizeAsync(ct);
|
||||
Assert.That(aForwarded.Uri, Is.EqualTo(NetworkBattleUri.PlayActions));
|
||||
}
|
||||
@@ -268,8 +209,7 @@ public class BattleNodeFlowTests
|
||||
await using var _a = clientA;
|
||||
await using var _b = clientB;
|
||||
await Task.WhenAll(clientA.ConsumeHandshakeAsync(ct), clientB.ConsumeHandshakeAsync(ct));
|
||||
await DriveHandshakeAsync(clientA, vidA, key, ct);
|
||||
await DriveHandshakeAsync(clientB, vidB, key, ct);
|
||||
await DrivePvpHandshakeAsync(clientA, vidA, clientB, vidB, key, ct);
|
||||
|
||||
// A retires.
|
||||
await clientA.SendMsgAsync(MakeEnvelopeWith(vidA, NetworkBattleUri.Retire, pubSeq: 5), key, ct);
|
||||
@@ -314,8 +254,7 @@ public class BattleNodeFlowTests
|
||||
var (clientA, clientB) = await ConnectBothAsync(factory, pending.BattleId, vidA, vidB, key, ct);
|
||||
await using var _b = clientB;
|
||||
await Task.WhenAll(clientA.ConsumeHandshakeAsync(ct), clientB.ConsumeHandshakeAsync(ct));
|
||||
await DriveHandshakeAsync(clientA, vidA, key, ct);
|
||||
await DriveHandshakeAsync(clientB, vidB, key, ct);
|
||||
await DrivePvpHandshakeAsync(clientA, vidA, clientB, vidB, key, ct);
|
||||
|
||||
// Abruptly close A's WS (no Retire).
|
||||
await clientA.DisposeAsync();
|
||||
@@ -492,7 +431,10 @@ public class BattleNodeFlowTests
|
||||
|
||||
// -- helpers -------------------------------------------------------------
|
||||
|
||||
private static async Task DriveHandshakeAsync(
|
||||
/// <summary>Drives one PvP client from InitNetwork through Swap, stopping at the
|
||||
/// SwapResponse. Ready is NOT received here — the mulligan barrier withholds it until
|
||||
/// BOTH sides have swapped, so the caller drains it after driving both sides.</summary>
|
||||
private static async Task DriveThroughSwapAsync(
|
||||
RawSocketIoTestClient client, long vid, string key, CancellationToken ct)
|
||||
{
|
||||
long pubSeq = 1;
|
||||
@@ -507,7 +449,23 @@ public class BattleNodeFlowTests
|
||||
await client.SendMsgAsync(MakeEnvelopeWith(vid, NetworkBattleUri.Swap, pubSeq++,
|
||||
body: new Dictionary<string, object?> { ["idxList"] = new List<object?>() }), key, ct);
|
||||
await client.ReceiveSynchronizeAsync(ct); // Swap response
|
||||
await client.ReceiveSynchronizeAsync(ct); // Ready
|
||||
}
|
||||
|
||||
/// <summary>Drives both PvP clients through the full handshake including the mulligan
|
||||
/// barrier: each side swaps first (Ready withheld), then the second swap releases Ready
|
||||
/// to both. Leaves both at AfterReady with pubSeq up to 4 consumed per client.</summary>
|
||||
private static async Task DrivePvpHandshakeAsync(
|
||||
RawSocketIoTestClient clientA, long vidA,
|
||||
RawSocketIoTestClient clientB, long vidB, string key, CancellationToken ct)
|
||||
{
|
||||
await DriveThroughSwapAsync(clientA, vidA, key, ct);
|
||||
await DriveThroughSwapAsync(clientB, vidB, key, ct);
|
||||
|
||||
// B's Swap (the second) releases Ready to both sides.
|
||||
var aReady = await clientA.ReceiveSynchronizeAsync(ct);
|
||||
Assert.That(aReady.Uri, Is.EqualTo(NetworkBattleUri.Ready));
|
||||
var bReady = await clientB.ReceiveSynchronizeAsync(ct);
|
||||
Assert.That(bReady.Uri, Is.EqualTo(NetworkBattleUri.Ready));
|
||||
}
|
||||
|
||||
private static async Task<(RawSocketIoTestClient, RawSocketIoTestClient)> ConnectBothAsync(
|
||||
|
||||
@@ -31,19 +31,18 @@ namespace SVSim.UnitTests.BattleNode.Integration;
|
||||
/// <c>BattleFinish</c> frame is played immediately whether or not it carries a <c>playSeq</c>).
|
||||
/// The check is on *body shape*.</para>
|
||||
///
|
||||
/// <para><b>Coverage:</b> a single Scripted session emits all ten server-authored URIs
|
||||
/// <para><b>Coverage:</b> a two-client PvP session emits all ten server-authored URIs
|
||||
/// (<c>InitNetwork, Matched, BattleStart, Deal, Swap, Ready, TurnStart, TurnEnd, Judge,
|
||||
/// BattleFinish</c>). PvP uses the same <see cref="SVSim.BattleNode.Lifecycle.ScriptedLifecycle"/>
|
||||
/// builders for the handshake/mulligan frames, so this transitively covers the PvP handshake shape
|
||||
/// too. Forwarded frames (<c>PlayActions / TurnEndActions / ChatStamp / TurnEndFinal</c>) relay the
|
||||
/// BattleFinish</c>). PvP authors the handshake/mulligan frames through the same shared
|
||||
/// <see cref="SVSim.BattleNode.Lifecycle.ServerBattleFrames"/> builders, and the turn cycle
|
||||
/// (<c>TurnStart/TurnEnd/Judge</c>) falls out of the real two-client handover. Forwarded frames
|
||||
/// (<c>PlayActions / TurnEndActions / ChatStamp / TurnEndFinal</c>) relay the
|
||||
/// client's own bytes verbatim, so their shape is the client's contract, not ours — out of scope
|
||||
/// here.</para>
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class CaptureConformanceTests
|
||||
{
|
||||
private const long ViewerId = 906243102L;
|
||||
|
||||
// Top-level keys that are envelope/transport, not body shape. Excluded from the comparison
|
||||
// at the root level only (nested objects never contain these).
|
||||
private static readonly HashSet<string> IgnoredEnvelopeKeys = new()
|
||||
@@ -52,51 +51,76 @@ public class CaptureConformanceTests
|
||||
};
|
||||
|
||||
[Test]
|
||||
[Timeout(30000)]
|
||||
[Timeout(60000)]
|
||||
public async Task ServerAuthoredFrames_MatchProdCaptureShapes()
|
||||
{
|
||||
await using var factory = new SVSimTestFactory();
|
||||
var bridge = factory.Services.GetRequiredService<IMatchingBridge>();
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(45));
|
||||
var ct = cts.Token;
|
||||
|
||||
// Two-client PvP drive. PvP authors the same handshake/mulligan frames the old Scripted
|
||||
// path did (via the shared server-frame builders) PLUS the turn-cycle frames
|
||||
// (TurnStart/TurnEnd/Judge) the scripted bot used to fake — so a two-client session
|
||||
// harvests all ten server-authored URIs. The shape check is category-based, so PvP's
|
||||
// spin:0 still matches prod's spin:189.
|
||||
const long vidA = 906243102L;
|
||||
const long vidB = 847666884L;
|
||||
var pending = bridge.RegisterBattle(
|
||||
new BattlePlayer(ViewerId, BattleNodeFlowTests.FixtureCtx()),
|
||||
p2: null,
|
||||
SVSim.BattleNode.Sessions.BattleType.Scripted);
|
||||
new BattlePlayer(vidA, BattleNodeFlowTests.FixtureCtx()),
|
||||
new BattlePlayer(vidB, BattleNodeFlowTests.FixtureCtx()),
|
||||
SVSim.BattleNode.Sessions.BattleType.Pvp);
|
||||
|
||||
var key = MakeKey();
|
||||
var encryptedVid = NodeCrypto.EncryptForNode(ViewerId.ToString(), key);
|
||||
var wsUri = new Uri(
|
||||
$"ws://localhost/socket.io/?BattleId={pending.BattleId}&viewerId={Uri.EscapeDataString(encryptedVid)}&EIO=3&transport=websocket");
|
||||
var (clientA, clientB) = await ConnectBothAsync(factory, pending.BattleId, vidA, vidB, key, ct);
|
||||
await using var _a = clientA;
|
||||
await using var _b = clientB;
|
||||
await Task.WhenAll(clientA.ConsumeHandshakeAsync(ct), clientB.ConsumeHandshakeAsync(ct));
|
||||
|
||||
var wsClient = factory.Server.CreateWebSocketClient();
|
||||
var ws = await wsClient.ConnectAsync(wsUri, ct);
|
||||
await using var client = new RawSocketIoTestClient(ws);
|
||||
await client.ConsumeHandshakeAsync(ct);
|
||||
|
||||
// Drive the full Scripted lifecycle, harvesting every server-pushed frame by URI.
|
||||
var harvested = new Dictionary<NetworkBattleUri, MsgEnvelope>();
|
||||
void Harvest(MsgEnvelope env) => harvested[env.Uri] = env;
|
||||
|
||||
async Task DriveAsync(NetworkBattleUri send, long pubSeq, int expectPushes,
|
||||
Dictionary<string, object?>? body = null)
|
||||
{
|
||||
await client.SendMsgAsync(MakeEnvelope(send, pubSeq, body), key, ct);
|
||||
for (var i = 0; i < expectPushes; i++)
|
||||
{
|
||||
var frame = await client.ReceiveSynchronizeAsync(ct);
|
||||
harvested[frame.Uri] = frame;
|
||||
}
|
||||
}
|
||||
long seqA = 1, seqB = 1;
|
||||
|
||||
await DriveAsync(NetworkBattleUri.InitNetwork, 1, expectPushes: 1);
|
||||
await DriveAsync(NetworkBattleUri.InitBattle, 2, expectPushes: 1); // Matched
|
||||
await DriveAsync(NetworkBattleUri.Loaded, 3, expectPushes: 2); // BattleStart + Deal
|
||||
await DriveAsync(NetworkBattleUri.Swap, 4, expectPushes: 2, // Swap + Ready
|
||||
body: new Dictionary<string, object?> { ["idxList"] = new List<object?>() });
|
||||
await DriveAsync(NetworkBattleUri.TurnEnd, 5, expectPushes: 3); // TurnStart + TurnEnd + Judge
|
||||
await DriveAsync(NetworkBattleUri.Retire, 6, expectPushes: 1); // BattleFinish
|
||||
// A walks the handshake; Ready is withheld by the mulligan barrier until B also swaps.
|
||||
await clientA.SendMsgAsync(MakeEnvelope(vidA, NetworkBattleUri.InitNetwork, seqA++), key, ct);
|
||||
Harvest(await clientA.ReceiveSynchronizeAsync(ct)); // InitNetwork ack
|
||||
await clientA.SendMsgAsync(MakeEnvelope(vidA, NetworkBattleUri.InitBattle, seqA++), key, ct);
|
||||
Harvest(await clientA.ReceiveSynchronizeAsync(ct)); // Matched
|
||||
await clientA.SendMsgAsync(MakeEnvelope(vidA, NetworkBattleUri.Loaded, seqA++), key, ct);
|
||||
Harvest(await clientA.ReceiveSynchronizeAsync(ct)); // BattleStart
|
||||
Harvest(await clientA.ReceiveSynchronizeAsync(ct)); // Deal
|
||||
await clientA.SendMsgAsync(MakeEnvelope(vidA, NetworkBattleUri.Swap, seqA++,
|
||||
new Dictionary<string, object?> { ["idxList"] = new List<object?>() }), key, ct);
|
||||
Harvest(await clientA.ReceiveSynchronizeAsync(ct)); // Swap response
|
||||
|
||||
// B walks the handshake; B's Swap (the second) releases Ready to both sides.
|
||||
await clientB.SendMsgAsync(MakeEnvelope(vidB, NetworkBattleUri.InitNetwork, seqB++), key, ct);
|
||||
await clientB.ReceiveSynchronizeAsync(ct); // ack
|
||||
await clientB.SendMsgAsync(MakeEnvelope(vidB, NetworkBattleUri.InitBattle, seqB++), key, ct);
|
||||
await clientB.ReceiveSynchronizeAsync(ct); // Matched
|
||||
await clientB.SendMsgAsync(MakeEnvelope(vidB, NetworkBattleUri.Loaded, seqB++), key, ct);
|
||||
await clientB.ReceiveSynchronizeAsync(ct); // BattleStart
|
||||
await clientB.ReceiveSynchronizeAsync(ct); // Deal
|
||||
await clientB.SendMsgAsync(MakeEnvelope(vidB, NetworkBattleUri.Swap, seqB++,
|
||||
new Dictionary<string, object?> { ["idxList"] = new List<object?>() }), key, ct);
|
||||
await clientB.ReceiveSynchronizeAsync(ct); // B Swap response
|
||||
Harvest(await clientA.ReceiveSynchronizeAsync(ct)); // Ready (released to A)
|
||||
await clientB.ReceiveSynchronizeAsync(ct); // Ready to B
|
||||
|
||||
// Turn cycle: A ends turn -> B receives TurnEnd{turnState}. B sends Judge -> Judge{spin}
|
||||
// reflects to B. B sends TurnStart -> A receives TurnStart{spin}.
|
||||
await clientA.SendMsgAsync(MakeEnvelope(vidA, NetworkBattleUri.TurnEnd, seqA++), key, ct);
|
||||
Harvest(await clientB.ReceiveSynchronizeAsync(ct)); // TurnEnd
|
||||
await clientB.SendMsgAsync(MakeEnvelope(vidB, NetworkBattleUri.Judge, seqB++), key, ct);
|
||||
Harvest(await clientB.ReceiveSynchronizeAsync(ct)); // Judge
|
||||
await clientB.SendMsgAsync(MakeEnvelope(vidB, NetworkBattleUri.TurnStart, seqB++), key, ct);
|
||||
Harvest(await clientA.ReceiveSynchronizeAsync(ct)); // TurnStart
|
||||
|
||||
// BattleFinish: A retires.
|
||||
await clientA.SendMsgAsync(MakeEnvelope(vidA, NetworkBattleUri.Retire, seqA++), key, ct);
|
||||
Harvest(await clientA.ReceiveSynchronizeAsync(ct)); // BattleFinish
|
||||
|
||||
// Compare each harvested frame's wire JSON against the prod capture fixture.
|
||||
using var fixtures = JsonDocument.Parse(ProdCaptureFixture.Json);
|
||||
@@ -107,7 +131,7 @@ public class CaptureConformanceTests
|
||||
var uri = Enum.Parse<NetworkBattleUri>(uriName);
|
||||
if (!harvested.TryGetValue(uri, out var env))
|
||||
{
|
||||
failures.Add($"[{uriName}] our server never pushed this frame during the Scripted lifecycle.");
|
||||
failures.Add($"[{uriName}] our server never pushed this frame during the PvP lifecycle.");
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -124,6 +148,22 @@ public class CaptureConformanceTests
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<(RawSocketIoTestClient, RawSocketIoTestClient)> ConnectBothAsync(
|
||||
SVSimTestFactory factory, string battleId, long vidA, long vidB, string key, CancellationToken ct)
|
||||
{
|
||||
var encA = NodeCrypto.EncryptForNode(vidA.ToString(), key);
|
||||
var encB = NodeCrypto.EncryptForNode(vidB.ToString(), key);
|
||||
var uriA = new Uri($"ws://localhost/socket.io/?BattleId={battleId}&viewerId={Uri.EscapeDataString(encA)}&EIO=3&transport=websocket");
|
||||
var uriB = new Uri($"ws://localhost/socket.io/?BattleId={battleId}&viewerId={Uri.EscapeDataString(encB)}&EIO=3&transport=websocket");
|
||||
|
||||
var wsClient = factory.Server.CreateWebSocketClient();
|
||||
var connectATask = wsClient.ConnectAsync(uriA, ct);
|
||||
await Task.Delay(50, ct);
|
||||
var wsB = await wsClient.ConnectAsync(uriB, ct);
|
||||
var wsA = await connectATask;
|
||||
return (new RawSocketIoTestClient(wsA), new RawSocketIoTestClient(wsB));
|
||||
}
|
||||
|
||||
private static readonly string[] ExpectedUris =
|
||||
{
|
||||
"InitNetwork", "Matched", "BattleStart", "Deal", "Swap", "Ready",
|
||||
@@ -208,9 +248,9 @@ public class CaptureConformanceTests
|
||||
return s.Length > 40 ? s[..40] + "…" : s;
|
||||
}
|
||||
|
||||
private static MsgEnvelope MakeEnvelope(NetworkBattleUri uri, long pubSeq,
|
||||
private static MsgEnvelope MakeEnvelope(long vid, NetworkBattleUri uri, long pubSeq,
|
||||
Dictionary<string, object?>? body = null) =>
|
||||
new(uri, ViewerId: ViewerId, Uuid: "udid-test", Bid: null, Try: 0,
|
||||
new(uri, ViewerId: vid, Uuid: "udid-test", Bid: null, Try: 0,
|
||||
Cat: uri == NetworkBattleUri.InitNetwork ? EmitCategory.General
|
||||
: uri == NetworkBattleUri.InitBattle ? EmitCategory.Matching
|
||||
: EmitCategory.Battle,
|
||||
@@ -221,6 +261,48 @@ public class CaptureConformanceTests
|
||||
var seq = 0;
|
||||
return NodeCrypto.GenerateKey(() => (seq++ * 13) % 16);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SynthesizedKnownList_matches_prod_recv_PlayActions_entry_shape()
|
||||
{
|
||||
// Prod recv PlayActions knownList entry (battle-traffic_tk2_regular.ndjson:27).
|
||||
const string prodEntry = """
|
||||
{ "idx": 17, "cardId": 128821011, "to": 20, "cost": 2, "clan": 8, "tribe": "7,16", "spellboost": 0, "attachTarget": "" }
|
||||
""";
|
||||
|
||||
// Build the same entry through our synthesizer.
|
||||
var deckMap = new Dictionary<int, long> { [17] = 128821011L };
|
||||
var orderList = new List<object?>
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["move"] = new Dictionary<string, object?>
|
||||
{
|
||||
["idx"] = new List<object?> { 17L }, ["isSelf"] = 1L, ["from"] = 10L, ["to"] = 20L,
|
||||
}
|
||||
}
|
||||
};
|
||||
var entry = SVSim.BattleNode.Sessions.Dispatch.KnownListBuilder.BuildPlayedCard(deckMap, 17, orderList);
|
||||
Assert.That(entry, Is.Not.Null);
|
||||
|
||||
var body = new SVSim.BattleNode.Protocol.Bodies.PlayActionsBroadcastBody(
|
||||
PlayIdx: 17, Type: 30, KnownList: new[] { entry! }, OppoTargetList: null);
|
||||
var env = new MsgEnvelope(NetworkBattleUri.PlayActions, ViewerId: 1, Uuid: "u", Bid: null, Try: 0,
|
||||
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Body: body);
|
||||
|
||||
using var ourDoc = JsonDocument.Parse(MsgEnvelope.ToJson(env));
|
||||
var ourEntry = ourDoc.RootElement.GetProperty("knownList")[0];
|
||||
using var prodDoc = JsonDocument.Parse(prodEntry);
|
||||
|
||||
// We are responsible for idx/cardId/to (+ spellboost/attachTarget). cost/clan/tribe are deferred.
|
||||
foreach (var key in new[] { "idx", "cardId", "to" })
|
||||
{
|
||||
Assert.That(ourEntry.TryGetProperty(key, out var ours), Is.True, $"knownList entry missing '{key}'");
|
||||
var prodVal = prodDoc.RootElement.GetProperty(key);
|
||||
Assert.That(ours.ValueKind, Is.EqualTo(prodVal.ValueKind), $"'{key}' type category mismatch");
|
||||
}
|
||||
Assert.That(ourEntry.GetProperty("cardId").GetInt64(), Is.EqualTo(128821011L));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -7,14 +7,14 @@ using SVSim.BattleNode.Protocol.Bodies;
|
||||
namespace SVSim.UnitTests.BattleNode.Lifecycle;
|
||||
|
||||
[TestFixture]
|
||||
public class ScriptedLifecycleTests
|
||||
public class ServerBattleFramesTests
|
||||
{
|
||||
[Test]
|
||||
public void BuildMatched_PutsOppoIdInSelfInfoEqualToTheRealOpponentVid()
|
||||
{
|
||||
var env = ScriptedLifecycle.BuildMatched(FixtureCtx(), ScriptedBotCtx(),
|
||||
var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(),
|
||||
selfViewerId: 906243102, oppoViewerId: 847666884,
|
||||
battleId: "b", seed: ScriptedProfiles.BattleSeed);
|
||||
battleId: "b", seed: BattleFrameDefaults.BattleSeed);
|
||||
|
||||
Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.Matched));
|
||||
var body = (MatchedBody)env.Body;
|
||||
@@ -26,7 +26,7 @@ public class ScriptedLifecycleTests
|
||||
[Test]
|
||||
public void BuildMatched_ContainsThirtyCardSelfDeck()
|
||||
{
|
||||
var env = ScriptedLifecycle.BuildMatched(FixtureCtx(), ScriptedBotCtx(), 1, 2, "b", ScriptedProfiles.BattleSeed);
|
||||
var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(), 1, 2, "b", BattleFrameDefaults.BattleSeed);
|
||||
var body = (MatchedBody)env.Body;
|
||||
Assert.That(body.SelfDeck.Count, Is.EqualTo(30));
|
||||
}
|
||||
@@ -35,7 +35,7 @@ public class ScriptedLifecycleTests
|
||||
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 env = ScriptedLifecycle.BuildMatched(FixtureCtx(draftedDeck), ScriptedBotCtx(), 1, 2, "b", ScriptedProfiles.BattleSeed);
|
||||
var env = ServerBattleFrames.BuildMatched(FixtureCtx(draftedDeck), FakeOpponentCtx(), 1, 2, "b", BattleFrameDefaults.BattleSeed);
|
||||
var body = (MatchedBody)env.Body;
|
||||
|
||||
for (int i = 0; i < 30; i++)
|
||||
@@ -56,7 +56,7 @@ public class ScriptedLifecycleTests
|
||||
EmblemId = "888", DegreeId = "777", FieldId = 42, IsOfficial = 1,
|
||||
};
|
||||
|
||||
var env = ScriptedLifecycle.BuildMatched(ctx, ScriptedBotCtx(), 1, 2, "b", ScriptedProfiles.BattleSeed);
|
||||
var env = ServerBattleFrames.BuildMatched(ctx, FakeOpponentCtx(), 1, 2, "b", BattleFrameDefaults.BattleSeed);
|
||||
var body = (MatchedBody)env.Body;
|
||||
|
||||
Assert.That(body.SelfInfo.CountryCode, Is.EqualTo("JPN"));
|
||||
@@ -71,7 +71,7 @@ public class ScriptedLifecycleTests
|
||||
[Test]
|
||||
public void BuildBattleStart_HasTurnStateZero_AndUsesContextBattleType()
|
||||
{
|
||||
var env = ScriptedLifecycle.BuildBattleStart(FixtureCtx(), ScriptedBotCtx(), selfViewerId: 1);
|
||||
var env = ServerBattleFrames.BuildBattleStart(FixtureCtx(), FakeOpponentCtx(), selfViewerId: 1, turnState: 0);
|
||||
var body = (BattleStartBody)env.Body;
|
||||
Assert.That(body.TurnState, Is.EqualTo(0));
|
||||
Assert.That(body.BattleType, Is.EqualTo(11));
|
||||
@@ -87,7 +87,7 @@ public class ScriptedLifecycleTests
|
||||
BattleType = 42,
|
||||
};
|
||||
|
||||
var env = ScriptedLifecycle.BuildBattleStart(ctx, ScriptedBotCtx(), selfViewerId: 1);
|
||||
var env = ServerBattleFrames.BuildBattleStart(ctx, FakeOpponentCtx(), selfViewerId: 1, turnState: 0);
|
||||
var body = (BattleStartBody)env.Body;
|
||||
|
||||
Assert.That(body.SelfInfo.ClassId, Is.EqualTo("7"));
|
||||
@@ -99,7 +99,7 @@ public class ScriptedLifecycleTests
|
||||
[Test]
|
||||
public void BuildDeal_HasThreeSelfAndThreeOppoEntries()
|
||||
{
|
||||
var env = ScriptedLifecycle.BuildDeal();
|
||||
var env = ServerBattleFrames.BuildDeal();
|
||||
var body = (DealBody)env.Body;
|
||||
Assert.That(body.Self.Count, Is.EqualTo(3));
|
||||
Assert.That(body.Oppo.Count, Is.EqualTo(3));
|
||||
@@ -108,28 +108,28 @@ public class ScriptedLifecycleTests
|
||||
[Test]
|
||||
public void ComputeHandAfterSwap_NoSwap_ReturnsInitialHand()
|
||||
{
|
||||
var hand = ScriptedLifecycle.ComputeHandAfterSwap(Array.Empty<long>());
|
||||
var hand = ServerBattleFrames.ComputeHandAfterSwap(Array.Empty<long>());
|
||||
Assert.That(hand, Is.EqualTo(new long[] { 1, 2, 3 }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ComputeHandAfterSwap_SwapMiddleCard_ReplacesWithFreshDeckIdx()
|
||||
{
|
||||
var hand = ScriptedLifecycle.ComputeHandAfterSwap(new long[] { 2 });
|
||||
var hand = ServerBattleFrames.ComputeHandAfterSwap(new long[] { 2 });
|
||||
Assert.That(hand, Is.EqualTo(new long[] { 1, 4, 3 }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ComputeHandAfterSwap_SwapAll_ReplacesAllWithFreshDeckIdxs()
|
||||
{
|
||||
var hand = ScriptedLifecycle.ComputeHandAfterSwap(new long[] { 1, 2, 3 });
|
||||
var hand = ServerBattleFrames.ComputeHandAfterSwap(new long[] { 1, 2, 3 });
|
||||
Assert.That(hand, Is.EqualTo(new long[] { 4, 5, 6 }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BuildSwapResponse_RendersGivenHandAsPositions()
|
||||
{
|
||||
var env = ScriptedLifecycle.BuildSwapResponse(new long[] { 1, 4, 3 });
|
||||
var env = ServerBattleFrames.BuildSwapResponse(new long[] { 1, 4, 3 });
|
||||
var body = (SwapResponseBody)env.Body;
|
||||
Assert.That(body.Self.Count, Is.EqualTo(3));
|
||||
Assert.That(body.Self[1].Idx, Is.EqualTo(4));
|
||||
@@ -138,7 +138,7 @@ public class ScriptedLifecycleTests
|
||||
[Test]
|
||||
public void BuildReady_IncludesIdxChangeSeedAndSpin_AndUsesGivenHand()
|
||||
{
|
||||
var env = ScriptedLifecycle.BuildReady(new long[] { 1, 4, 3 });
|
||||
var env = ServerBattleFrames.BuildReady(new long[] { 1, 4, 3 });
|
||||
var body = (ReadyBody)env.Body;
|
||||
Assert.That(body.IdxChangeSeed, Is.EqualTo(771_335_280));
|
||||
Assert.That(body.Spin, Is.EqualTo(243));
|
||||
@@ -146,38 +146,24 @@ public class ScriptedLifecycleTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BuildOpponentTurnStart_HasUriTurnStartAndSpin()
|
||||
public void BuildReady_two_arg_sets_oppo_to_supplied_hand()
|
||||
{
|
||||
var env = ScriptedLifecycle.BuildOpponentTurnStart();
|
||||
Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.TurnStart));
|
||||
var body = (OpponentTurnStartBody)env.Body;
|
||||
Assert.That(body.Spin, Is.EqualTo(100));
|
||||
var env = ServerBattleFrames.BuildReady(new long[] { 1, 4, 3 }, new long[] { 1, 2, 6 });
|
||||
var body = (ReadyBody)env.Body;
|
||||
|
||||
Assert.That(body.Self.Select(p => p.Idx), Is.EqualTo(new[] { 1, 4, 3 }));
|
||||
Assert.That(body.Oppo.Select(p => p.Idx), Is.EqualTo(new[] { 1, 2, 6 }),
|
||||
"oppo must reflect the opponent's post-mulligan hand, not the placeholder InitialHand.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BuildOpponentTurnEnd_emits_TurnEnd_uri_with_turn_state_zero()
|
||||
public void BuildReady_one_arg_defaults_oppo_to_InitialHand()
|
||||
{
|
||||
var env = ScriptedLifecycle.BuildOpponentTurnEnd();
|
||||
var env = ServerBattleFrames.BuildReady(new long[] { 1, 4, 3 });
|
||||
var body = (ReadyBody)env.Body;
|
||||
|
||||
Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.TurnEnd));
|
||||
Assert.That(env.ViewerId, Is.EqualTo(ScriptedLifecycle.FakeOpponentViewerId));
|
||||
Assert.That(env.Cat, Is.EqualTo(EmitCategory.Battle));
|
||||
var body = (TurnEndBody)env.Body;
|
||||
Assert.That(body.TurnState, Is.EqualTo(0));
|
||||
Assert.That(body.ResultCode, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BuildOpponentJudge_emits_Judge_uri_with_spin_and_default_result_code()
|
||||
{
|
||||
var env = ScriptedLifecycle.BuildOpponentJudge();
|
||||
|
||||
Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.Judge));
|
||||
Assert.That(env.ViewerId, Is.EqualTo(ScriptedLifecycle.FakeOpponentViewerId));
|
||||
Assert.That(env.Cat, Is.EqualTo(EmitCategory.Battle));
|
||||
var body = (JudgeBody)env.Body;
|
||||
Assert.That(body.Spin, Is.EqualTo(ScriptedProfiles.OpponentJudgeSpin));
|
||||
Assert.That(body.ResultCode, Is.EqualTo(1));
|
||||
Assert.That(body.Oppo.Select(p => p.Idx), Is.EqualTo(new[] { 1, 2, 3 }),
|
||||
"single-arg overload (non-interactive opponent) keeps the placeholder hand.");
|
||||
}
|
||||
|
||||
private static MatchContext FixtureCtx(IReadOnlyList<long>? deck = null) => new(
|
||||
@@ -187,9 +173,9 @@ public class ScriptedLifecycleTests
|
||||
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
|
||||
BattleType: 11);
|
||||
|
||||
// Mirrors ScriptedBotParticipant.Context — the scripted opponent's MatchContext fixture
|
||||
// that the new BuildMatched/BuildBattleStart helpers read from for the oppo half.
|
||||
private static MatchContext ScriptedBotCtx() => new(
|
||||
// A prod-captured opponent MatchContext fixture that the BuildMatched/BuildBattleStart
|
||||
// helpers read from for the oppo half.
|
||||
private static MatchContext FakeOpponentCtx() => new(
|
||||
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 0L).ToList(),
|
||||
ClassId: "8", CharaId: "8", CardMasterName: "card_master_node_10015",
|
||||
CountryCode: "JPN", UserName: "Opponent", SleeveId: "704141010",
|
||||
@@ -27,8 +27,8 @@ public class TypedBodyWireShapeTests
|
||||
// Matching.StartBattleLoad reads it back, and GetSelfDeck().Select(...) crashes
|
||||
// with "Value cannot be null. Parameter name: source". The prod wire format
|
||||
// emits envelope keys (uri first) before body keys; we must too.
|
||||
var env = ScriptedLifecycle.BuildMatched(FixtureCtx(), ScriptedBotCtx(),
|
||||
selfViewerId: 1, oppoViewerId: 2, battleId: "b", seed: ScriptedProfiles.BattleSeed);
|
||||
var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(),
|
||||
selfViewerId: 1, oppoViewerId: 2, battleId: "b", seed: BattleFrameDefaults.BattleSeed);
|
||||
var json = MsgEnvelope.ToJson(env);
|
||||
|
||||
var uriIdx = json.IndexOf("\"uri\":", StringComparison.Ordinal);
|
||||
@@ -45,9 +45,9 @@ public class TypedBodyWireShapeTests
|
||||
[Test]
|
||||
public void BuildMatched_SerializesAllWireKeysExpectedByTheClient()
|
||||
{
|
||||
var env = ScriptedLifecycle.BuildMatched(FixtureCtx(), ScriptedBotCtx(),
|
||||
var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(),
|
||||
selfViewerId: 906243102, oppoViewerId: 847666884, battleId: "597830888107",
|
||||
seed: ScriptedProfiles.BattleSeed);
|
||||
seed: BattleFrameDefaults.BattleSeed);
|
||||
|
||||
var json = MsgEnvelope.ToJson(env);
|
||||
var node = JsonNode.Parse(json)!.AsObject();
|
||||
@@ -86,7 +86,7 @@ public class TypedBodyWireShapeTests
|
||||
[Test]
|
||||
public void BuildBattleStart_SerializesAllWireKeysAndPreservesBattlePointAsymmetry()
|
||||
{
|
||||
var env = ScriptedLifecycle.BuildBattleStart(FixtureCtx(), ScriptedBotCtx(), selfViewerId: 906243102);
|
||||
var env = ServerBattleFrames.BuildBattleStart(FixtureCtx(), FakeOpponentCtx(), selfViewerId: 906243102, turnState: 0);
|
||||
|
||||
var json = MsgEnvelope.ToJson(env);
|
||||
var node = JsonNode.Parse(json)!.AsObject();
|
||||
@@ -109,7 +109,7 @@ public class TypedBodyWireShapeTests
|
||||
[Test]
|
||||
public void BuildDeal_SerializesSelfAndOppoArraysWithPosIdxShape()
|
||||
{
|
||||
var env = ScriptedLifecycle.BuildDeal();
|
||||
var env = ServerBattleFrames.BuildDeal();
|
||||
var json = MsgEnvelope.ToJson(env);
|
||||
var node = JsonNode.Parse(json)!.AsObject();
|
||||
|
||||
@@ -125,7 +125,7 @@ public class TypedBodyWireShapeTests
|
||||
[Test]
|
||||
public void BuildSwapResponse_SerializesSelfWithoutOppo()
|
||||
{
|
||||
var env = ScriptedLifecycle.BuildSwapResponse(new long[] { 1, 4, 3 });
|
||||
var env = ServerBattleFrames.BuildSwapResponse(new long[] { 1, 4, 3 });
|
||||
var json = MsgEnvelope.ToJson(env);
|
||||
var node = JsonNode.Parse(json)!.AsObject();
|
||||
|
||||
@@ -137,7 +137,7 @@ public class TypedBodyWireShapeTests
|
||||
[Test]
|
||||
public void BuildReady_SerializesAllFieldsIncludingSeedAndSpin()
|
||||
{
|
||||
var env = ScriptedLifecycle.BuildReady(new long[] { 1, 4, 3 });
|
||||
var env = ServerBattleFrames.BuildReady(new long[] { 1, 4, 3 });
|
||||
var json = MsgEnvelope.ToJson(env);
|
||||
var node = JsonNode.Parse(json)!.AsObject();
|
||||
|
||||
@@ -147,42 +147,6 @@ public class TypedBodyWireShapeTests
|
||||
Assert.That(node["oppo"]!.AsArray().Count, Is.EqualTo(3));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BuildOpponentTurnStart_SerializesSpinAndResultCode()
|
||||
{
|
||||
var env = ScriptedLifecycle.BuildOpponentTurnStart();
|
||||
var json = MsgEnvelope.ToJson(env);
|
||||
var node = JsonNode.Parse(json)!.AsObject();
|
||||
|
||||
Assert.That(node["spin"]!.GetValue<int>(), Is.EqualTo(100));
|
||||
Assert.That(node["resultCode"]!.GetValue<int>(), Is.EqualTo(1));
|
||||
Assert.That(node["uri"]!.GetValue<string>(), Is.EqualTo("TurnStart"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BuildOpponentTurnEnd_SerializesTurnStateAndResultCode()
|
||||
{
|
||||
var env = ScriptedLifecycle.BuildOpponentTurnEnd();
|
||||
var json = MsgEnvelope.ToJson(env);
|
||||
var node = JsonNode.Parse(json)!.AsObject();
|
||||
|
||||
Assert.That(node["turnState"]!.GetValue<int>(), Is.EqualTo(0));
|
||||
Assert.That(node["resultCode"]!.GetValue<int>(), Is.EqualTo(1));
|
||||
Assert.That(node["uri"]!.GetValue<string>(), Is.EqualTo("TurnEnd"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BuildOpponentJudge_SerializesSpinAndResultCode()
|
||||
{
|
||||
var env = ScriptedLifecycle.BuildOpponentJudge();
|
||||
var json = MsgEnvelope.ToJson(env);
|
||||
var node = JsonNode.Parse(json)!.AsObject();
|
||||
|
||||
Assert.That(node["spin"]!.GetValue<int>(), Is.EqualTo(100));
|
||||
Assert.That(node["resultCode"]!.GetValue<int>(), Is.EqualTo(1));
|
||||
Assert.That(node["uri"]!.GetValue<string>(), Is.EqualTo("Judge"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wire-shape fixture: 30 copies of the legacy DummyCardId (100_011_010L) so the
|
||||
/// existing literal assertions on <c>selfDeck[0].cardId</c> (line 81 above) keep working
|
||||
@@ -195,11 +159,11 @@ public class TypedBodyWireShapeTests
|
||||
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
|
||||
BattleType: 11);
|
||||
|
||||
// Mirrors ScriptedBotParticipant.Context — 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,
|
||||
// oppoDeckCount=30, etc.) remain byte-identical after the BuildMatched/BuildBattleStart
|
||||
// signature change.
|
||||
private static MatchContext ScriptedBotCtx() => new(
|
||||
private static MatchContext FakeOpponentCtx() => new(
|
||||
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 0L).ToList(),
|
||||
ClassId: "8", CharaId: "8", CardMasterName: "card_master_node_10015",
|
||||
CountryCode: "JPN", UserName: "Opponent", SleeveId: "704141010",
|
||||
|
||||
@@ -13,115 +13,27 @@ namespace SVSim.UnitTests.BattleNode.Sessions;
|
||||
public class BattleSessionDispatchTests
|
||||
{
|
||||
[Test]
|
||||
public void InitNetwork_acks_to_sender_transitions_to_AwaitingInitBattle()
|
||||
public void Pvp_Loaded_from_A_assigns_turnState_0()
|
||||
{
|
||||
var (s, a, b) = NewSession();
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||
|
||||
Assert.That(routes.Count, Is.EqualTo(1));
|
||||
Assert.That(routes[0].Target, Is.SameAs(a));
|
||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.InitNetwork));
|
||||
Assert.That(routes[0].NoStock, Is.True);
|
||||
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitBattle));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void InitBattle_pushes_Matched_to_sender_only()
|
||||
{
|
||||
var (s, a, b) = NewSession();
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
|
||||
|
||||
Assert.That(routes.Count, Is.EqualTo(1));
|
||||
Assert.That(routes[0].Target, Is.SameAs(a));
|
||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.Matched));
|
||||
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingLoaded));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Loaded_pushes_BattleStart_then_Deal_to_sender()
|
||||
{
|
||||
var (s, a, b) = NewSession();
|
||||
var (s, a, _) = NewPvpSession();
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded));
|
||||
|
||||
Assert.That(routes.Select(r => r.Frame.Uri),
|
||||
Is.EqualTo(new[] { NetworkBattleUri.BattleStart, NetworkBattleUri.Deal }));
|
||||
Assert.That(routes.All(r => ReferenceEquals(r.Target, a)), Is.True);
|
||||
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingSwap));
|
||||
var bs = (BattleStartBody)routes[0].Frame.Body;
|
||||
Assert.That(bs.TurnState, Is.EqualTo(0), "A (first arriver) goes first.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Swap_pushes_SwapResponse_then_Ready_to_sender()
|
||||
public void Pvp_Loaded_from_B_assigns_turnState_1()
|
||||
{
|
||||
var (s, a, b) = NewSession();
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded));
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap));
|
||||
var (s, _, b) = NewPvpSession();
|
||||
s.ComputeFrames(b, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||
s.ComputeFrames(b, NewEnvelope(NetworkBattleUri.InitBattle));
|
||||
var routes = s.ComputeFrames(b, NewEnvelope(NetworkBattleUri.Loaded));
|
||||
|
||||
Assert.That(routes.Select(r => r.Frame.Uri),
|
||||
Is.EqualTo(new[] { NetworkBattleUri.Swap, NetworkBattleUri.Ready }));
|
||||
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AfterReady));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TurnEnd_from_real_forwards_to_other_participant()
|
||||
{
|
||||
var (s, a, b) = NewSession();
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded));
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap));
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.TurnEnd));
|
||||
|
||||
Assert.That(routes.Count, Is.EqualTo(1));
|
||||
Assert.That(routes[0].Target, Is.SameAs(b));
|
||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnEnd));
|
||||
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AfterReady));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Scripted_TurnEndFinal_forwards_envelope_and_pushes_paired_BattleFinish()
|
||||
{
|
||||
// Unified TurnEndFinal handling: forward the envelope to other (matches prod
|
||||
// capture battle-traffic_tk2_regular.ndjson:273) + push BattleFinish per-side
|
||||
// with player-perspective codes (LifeWin to winner, LifeLose to loser).
|
||||
// In Scripted mode the "loser" is a ScriptedBotParticipant; the loser-side
|
||||
// BattleFinish push is harmless (bot swallows non-TurnEnd URIs).
|
||||
var (s, a, b) = NewSession();
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded));
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap));
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.TurnEndFinal));
|
||||
|
||||
Assert.That(routes.Count, Is.EqualTo(3),
|
||||
"TurnEndFinal must produce: forwarded envelope + BattleFinish(LifeWin) to from + BattleFinish(LifeLose) to other.");
|
||||
|
||||
// Route 0: forwarded TurnEndFinal envelope to other.
|
||||
Assert.That(routes[0].Target, Is.SameAs(b));
|
||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnEndFinal));
|
||||
|
||||
// Route 1: BattleFinish(LifeWin) to from (the winner who declared the final turn).
|
||||
Assert.That(routes[1].Target, Is.SameAs(a));
|
||||
Assert.That(routes[1].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
||||
Assert.That(routes[1].NoStock, Is.True);
|
||||
var winBody = (SVSim.BattleNode.Protocol.Bodies.BattleFinishBody)routes[1].Frame.Body;
|
||||
Assert.That(winBody.Result, Is.EqualTo(BattleResult.LifeWin),
|
||||
"Winner gets LifeWin (101) — player-perspective: 'I won by life' → WIN UI.");
|
||||
|
||||
// Route 2: BattleFinish(LifeLose) to other (the loser).
|
||||
Assert.That(routes[2].Target, Is.SameAs(b));
|
||||
Assert.That(routes[2].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
||||
Assert.That(routes[2].NoStock, Is.True);
|
||||
var loseBody = (SVSim.BattleNode.Protocol.Bodies.BattleFinishBody)routes[2].Frame.Body;
|
||||
Assert.That(loseBody.Result, Is.EqualTo(BattleResult.LifeLose),
|
||||
"Loser gets LifeLose (102) — player-perspective: 'I lost by life' → LOSE UI.");
|
||||
|
||||
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal),
|
||||
"Session must transition to Terminal so the RunAsync cascade doesn't synthesize a second BattleFinish.");
|
||||
var bs = (BattleStartBody)routes[0].Frame.Body;
|
||||
Assert.That(bs.TurnState, Is.EqualTo(1), "B (second arriver) goes second.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -147,102 +59,10 @@ public class BattleSessionDispatchTests
|
||||
Assert.That(b.Phase, Is.EqualTo(BattleSessionPhase.AwaitingLoaded));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ScriptedBot_emitted_OpponentTurnStart_forwards_to_real()
|
||||
{
|
||||
var (s, a, b) = NewSession();
|
||||
// Bot emits a TurnStart frame (carrying ViewerId == FakeOpponentViewerId per the
|
||||
// ScriptedBotParticipant impl). Session should route it to the real participant.
|
||||
var botFrame = ScriptedLifecycle.BuildOpponentTurnStart();
|
||||
var routes = s.ComputeFrames(b, botFrame);
|
||||
|
||||
Assert.That(routes.Count, Is.EqualTo(1));
|
||||
Assert.That(routes[0].Target, Is.SameAs(a));
|
||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnStart));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ScriptedBot_emitted_Judge_forwards_to_real()
|
||||
{
|
||||
var (s, a, b) = NewSession();
|
||||
var botFrame = ScriptedLifecycle.BuildOpponentJudge();
|
||||
var routes = s.ComputeFrames(b, botFrame);
|
||||
|
||||
Assert.That(routes.Count, Is.EqualTo(1));
|
||||
Assert.That(routes[0].Target, Is.SameAs(a));
|
||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.Judge));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ScriptedBot_emitted_TurnEnd_forwards_to_real()
|
||||
{
|
||||
// TurnEnd from the bot is also one of the burst frames. The case is handled
|
||||
// by the TurnEnd-from-scripted arm (bot ViewerId matches FakeOpponentViewerId).
|
||||
var (s, a, b) = NewSession();
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded));
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap));
|
||||
// Drive player TurnEnd first (transitions Phase if needed) — but TurnEnd from bot
|
||||
// arrives in Phase AfterReady too. The bot's TurnEnd is routed via the dispatch
|
||||
// arm that forwards any frame from the FakeOpponentViewerId participant.
|
||||
|
||||
var botFrame = ScriptedLifecycle.BuildOpponentTurnEnd();
|
||||
var routes = s.ComputeFrames(b, botFrame);
|
||||
|
||||
Assert.That(routes.Count, Is.EqualTo(1));
|
||||
Assert.That(routes[0].Target, Is.SameAs(a));
|
||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnEnd));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Retire_pushes_paired_BattleFinish_RetireLose_to_from_and_RetireWin_to_other()
|
||||
{
|
||||
var (s, a, b) = NewSession();
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Retire));
|
||||
|
||||
Assert.That(routes.Count, Is.EqualTo(2));
|
||||
Assert.That(routes[0].Target, Is.SameAs(a));
|
||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
||||
Assert.That(routes[0].NoStock, Is.True);
|
||||
var loseBody = (SVSim.BattleNode.Protocol.Bodies.BattleFinishBody)routes[0].Frame.Body;
|
||||
Assert.That(loseBody.Result, Is.EqualTo(BattleResult.RetireLose),
|
||||
"Retirer gets RetireLose=106 — player-perspective: 'I lost by retire'.");
|
||||
|
||||
Assert.That(routes[1].Target, Is.SameAs(b));
|
||||
Assert.That(routes[1].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
||||
Assert.That(routes[1].NoStock, Is.True);
|
||||
var winBody = (SVSim.BattleNode.Protocol.Bodies.BattleFinishBody)routes[1].Frame.Body;
|
||||
Assert.That(winBody.Result, Is.EqualTo(BattleResult.RetireWin),
|
||||
"Survivor gets RetireWin=105. In Scripted mode the bot swallows it; in PvP the opponent renders 'opponent retired'.");
|
||||
|
||||
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Kill_pushes_paired_BattleFinish_RetireLose_to_from_and_RetireWin_to_other()
|
||||
{
|
||||
var (s, a, b) = NewSession();
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Kill));
|
||||
|
||||
Assert.That(routes.Count, Is.EqualTo(2));
|
||||
Assert.That(routes[0].Target, Is.SameAs(a));
|
||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
||||
var loseBody = (SVSim.BattleNode.Protocol.Bodies.BattleFinishBody)routes[0].Frame.Body;
|
||||
Assert.That(loseBody.Result, Is.EqualTo(BattleResult.RetireLose));
|
||||
|
||||
Assert.That(routes[1].Target, Is.SameAs(b));
|
||||
Assert.That(routes[1].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
||||
var winBody = (SVSim.BattleNode.Protocol.Bodies.BattleFinishBody)routes[1].Frame.Body;
|
||||
Assert.That(winBody.Result, Is.EqualTo(BattleResult.RetireWin));
|
||||
|
||||
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OutOfOrder_dispatch_returns_empty_and_does_not_advance_phase()
|
||||
{
|
||||
var (s, a, _) = NewSession();
|
||||
var (s, a, _) = NewPvpSession();
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap));
|
||||
|
||||
Assert.That(routes, Is.Empty);
|
||||
@@ -304,7 +124,7 @@ public class BattleSessionDispatchTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Pvp_Swap_from_A_pushes_SwapResponse_plus_Ready_to_A_only()
|
||||
public void Pvp_Swap_from_A_alone_pushes_SwapResponse_only_Ready_withheld()
|
||||
{
|
||||
var (s, a, b) = NewPvpSession();
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||
@@ -312,16 +132,41 @@ public class BattleSessionDispatchTests
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded));
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap));
|
||||
|
||||
Assert.That(routes.Select(r => r.Frame.Uri),
|
||||
Is.EqualTo(new[] { NetworkBattleUri.Swap, NetworkBattleUri.Ready }));
|
||||
Assert.That(routes.All(r => ReferenceEquals(r.Target, a)), Is.True);
|
||||
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AfterReady));
|
||||
Assert.That(b.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitNetwork),
|
||||
"Swap from A doesn't advance B's phase.");
|
||||
Assert.That(routes.Select(r => r.Frame.Uri), Is.EqualTo(new[] { NetworkBattleUri.Swap }),
|
||||
"Ready is withheld until BOTH sides have mulliganed.");
|
||||
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AfterReady),
|
||||
"Phase advances on Swap even though Ready is withheld.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Pvp_TurnStart_from_A_in_BothAfterReady_forwards_to_B()
|
||||
public void Pvp_Swap_from_both_releases_Ready_to_both_with_opponent_hands()
|
||||
{
|
||||
var (s, a, b) = NewPvpSession();
|
||||
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));
|
||||
}
|
||||
|
||||
var aRoutes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap)); // first swapper
|
||||
Assert.That(aRoutes.Select(r => r.Frame.Uri), Is.EqualTo(new[] { NetworkBattleUri.Swap }));
|
||||
|
||||
var bRoutes = s.ComputeFrames(b, NewEnvelope(NetworkBattleUri.Swap)); // second swapper releases both
|
||||
// Expect: B's own SwapResponse, then Ready to B, then Ready to A.
|
||||
Assert.That(bRoutes.Count, Is.EqualTo(3));
|
||||
Assert.That(bRoutes[0].Target, Is.SameAs(b));
|
||||
Assert.That(bRoutes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.Swap));
|
||||
|
||||
var readyToB = bRoutes.Single(r => ReferenceEquals(r.Target, b) && r.Frame.Uri == NetworkBattleUri.Ready);
|
||||
var readyToA = bRoutes.Single(r => ReferenceEquals(r.Target, a) && r.Frame.Uri == NetworkBattleUri.Ready);
|
||||
// Empty mulligans → each hand is the dealt [1,2,3]; oppo mirrors the other side's hand.
|
||||
Assert.That(((ReadyBody)readyToB.Frame.Body).Oppo.Select(p => p.Idx), Is.EqualTo(new[] { 1, 2, 3 }));
|
||||
Assert.That(((ReadyBody)readyToA.Frame.Body).Oppo.Select(p => p.Idx), Is.EqualTo(new[] { 1, 2, 3 }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Pvp_TurnStart_from_A_emits_spin0_to_B()
|
||||
{
|
||||
var (s, a, b) = NewPvpSession();
|
||||
DriveToAfterReady(s, a);
|
||||
@@ -332,24 +177,111 @@ public class BattleSessionDispatchTests
|
||||
Assert.That(routes.Count, Is.EqualTo(1));
|
||||
Assert.That(routes[0].Target, Is.SameAs(b));
|
||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnStart));
|
||||
var body = (SVSim.BattleNode.Protocol.Bodies.OpponentTurnStartBody)routes[0].Frame.Body;
|
||||
Assert.That(body.Spin, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Pvp_PlayActions_from_A_in_BothAfterReady_forwards_to_B()
|
||||
public void Pvp_Judge_from_A_reflects_spin0_back_to_sender()
|
||||
{
|
||||
var (s, a, b) = NewPvpSession();
|
||||
DriveToAfterReady(s, a);
|
||||
DriveToAfterReady(s, b);
|
||||
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.PlayActions));
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Judge));
|
||||
|
||||
// Judge reflects BACK to its sender (the turn taker-over), not to the opponent: receiving
|
||||
// Judge{spin} fires the sender's ControlTurnStartPlayer. Routing to the opponent would
|
||||
// restart the just-ended player's turn (2026-06-03 two-client capture).
|
||||
Assert.That(routes.Count, Is.EqualTo(1));
|
||||
Assert.That(routes[0].Target, Is.SameAs(a));
|
||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.Judge));
|
||||
var body = (SVSim.BattleNode.Protocol.Bodies.JudgeBody)routes[0].Frame.Body;
|
||||
Assert.That(body.Spin, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Pvp_PlayActions_synthesizes_knownList_from_sender_deck()
|
||||
{
|
||||
var (s, a, b) = NewPvpSession();
|
||||
DriveToAfterReady(s, a);
|
||||
DriveToAfterReady(s, b);
|
||||
|
||||
var body = MoveOrderList(idx: 3, from: 10, to: 20);
|
||||
body["playIdx"] = 3L;
|
||||
body["type"] = 30L;
|
||||
|
||||
var routes = s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, body));
|
||||
|
||||
Assert.That(routes.Count, Is.EqualTo(1));
|
||||
Assert.That(routes[0].Target, Is.SameAs(b));
|
||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.PlayActions));
|
||||
var pb = (PlayActionsBroadcastBody)routes[0].Frame.Body;
|
||||
Assert.That(pb.PlayIdx, Is.EqualTo(3));
|
||||
Assert.That(pb.Type, Is.EqualTo(30));
|
||||
Assert.That(pb.KnownList!.Count, Is.EqualTo(1));
|
||||
Assert.That(pb.KnownList[0].Idx, Is.EqualTo(3));
|
||||
Assert.That(pb.KnownList[0].CardId, Is.EqualTo(100_011_010L)); // PlayerACtx deck cardId
|
||||
Assert.That(pb.KnownList[0].To, Is.EqualTo(20));
|
||||
Assert.That(pb.OppoTargetList, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Pvp_Echo_from_A_in_BothAfterReady_forwards_to_B()
|
||||
public void Pvp_PlayActions_renames_targetList_to_oppoTargetList()
|
||||
{
|
||||
var (s, a, b) = NewPvpSession();
|
||||
DriveToAfterReady(s, a);
|
||||
DriveToAfterReady(s, b);
|
||||
|
||||
var body = MoveOrderList(idx: 3, from: 10, to: 20);
|
||||
body["playIdx"] = 3L;
|
||||
body["type"] = 31L;
|
||||
body["targetList"] = new List<object?>
|
||||
{
|
||||
new Dictionary<string, object?> { ["targetIdx"] = 8L, ["isSelf"] = 0L },
|
||||
};
|
||||
|
||||
var routes = s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, body));
|
||||
var pb = (PlayActionsBroadcastBody)routes[0].Frame.Body;
|
||||
|
||||
Assert.That(pb.OppoTargetList!.Count, Is.EqualTo(1));
|
||||
Assert.That(pb.OppoTargetList[0].TargetIdx, Is.EqualTo(8));
|
||||
Assert.That(pb.OppoTargetList[0].IsSelf, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Pvp_PlayActions_token_idx_degrades_to_no_knownList()
|
||||
{
|
||||
var (s, a, b) = NewPvpSession();
|
||||
DriveToAfterReady(s, a);
|
||||
DriveToAfterReady(s, b);
|
||||
|
||||
var body = MoveOrderList(idx: 31, from: 10, to: 20); // idx 31 > 30-card deck → token
|
||||
body["playIdx"] = 31L;
|
||||
body["type"] = 30L;
|
||||
|
||||
var routes = s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, body));
|
||||
var pb = (PlayActionsBroadcastBody)routes[0].Frame.Body;
|
||||
|
||||
Assert.That(routes.Count, Is.EqualTo(1));
|
||||
Assert.That(pb.PlayIdx, Is.EqualTo(31));
|
||||
Assert.That(pb.KnownList, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Pvp_PlayActions_when_B_still_AwaitingSwap_drops()
|
||||
{
|
||||
var (s, a, b) = NewPvpSession();
|
||||
DriveToAfterReady(s, a);
|
||||
// B not AfterReady → not BothAfterReady.
|
||||
var body = MoveOrderList(3, 10, 20);
|
||||
body["playIdx"] = 3L; body["type"] = 30L;
|
||||
var routes = s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, body));
|
||||
Assert.That(routes, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Pvp_Echo_from_A_in_BothAfterReady_is_consumed_not_relayed()
|
||||
{
|
||||
var (s, a, b) = NewPvpSession();
|
||||
DriveToAfterReady(s, a);
|
||||
@@ -357,21 +289,23 @@ public class BattleSessionDispatchTests
|
||||
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Echo));
|
||||
|
||||
Assert.That(routes.Count, Is.EqualTo(1));
|
||||
Assert.That(routes[0].Target, Is.SameAs(b));
|
||||
Assert.That(routes, Is.Empty, "Echo has no inbound handler on the client; relaying risks an echo storm.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Pvp_TurnEndActions_from_A_in_BothAfterReady_forwards_to_B()
|
||||
public void Pvp_TurnEndActions_from_A_emits_empty_body_to_B()
|
||||
{
|
||||
var (s, a, b) = NewPvpSession();
|
||||
DriveToAfterReady(s, a);
|
||||
DriveToAfterReady(s, b);
|
||||
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.TurnEndActions));
|
||||
var body = MoveOrderList(3, 20, 30); // a non-empty orderList that must be dropped
|
||||
var routes = s.ComputeFrames(a, EnvWith(NetworkBattleUri.TurnEndActions, body));
|
||||
|
||||
Assert.That(routes.Count, Is.EqualTo(1));
|
||||
Assert.That(routes[0].Target, Is.SameAs(b));
|
||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnEndActions));
|
||||
Assert.That(((RawBody)routes[0].Frame.Body).Entries, Is.Empty, "orderList is dropped; body is empty.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -388,20 +322,7 @@ public class BattleSessionDispatchTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Pvp_PlayActions_when_B_still_AwaitingSwap_drops()
|
||||
{
|
||||
var (s, a, b) = NewPvpSession();
|
||||
DriveToAfterReady(s, a);
|
||||
// B is still AwaitingInitNetwork — BothAfterReady is false.
|
||||
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.PlayActions));
|
||||
|
||||
Assert.That(routes, Is.Empty,
|
||||
"PvP gameplay forwarding must wait until BOTH sides reach AfterReady.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Pvp_TurnEnd_from_A_in_BothAfterReady_broadcasts_TurnEnd_plus_Judge_to_both()
|
||||
public void Pvp_TurnEnd_from_A_emits_turnState_to_B_only()
|
||||
{
|
||||
var (s, a, b) = NewPvpSession();
|
||||
DriveToAfterReady(s, a);
|
||||
@@ -409,20 +330,17 @@ public class BattleSessionDispatchTests
|
||||
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.TurnEnd));
|
||||
|
||||
Assert.That(routes.Count, Is.EqualTo(4));
|
||||
Assert.That(routes.Select(r => (r.Target, r.Frame.Uri)), Is.EquivalentTo(new[]
|
||||
{
|
||||
((IBattleParticipant)a, NetworkBattleUri.TurnEnd),
|
||||
((IBattleParticipant)b, NetworkBattleUri.TurnEnd),
|
||||
((IBattleParticipant)a, NetworkBattleUri.Judge),
|
||||
((IBattleParticipant)b, NetworkBattleUri.Judge),
|
||||
}));
|
||||
Assert.That(routes.Count, Is.EqualTo(1));
|
||||
Assert.That(routes[0].Target, Is.SameAs(b));
|
||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnEnd));
|
||||
var body = (SVSim.BattleNode.Protocol.Bodies.TurnEndBody)routes[0].Frame.Body;
|
||||
Assert.That(body.TurnState, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Pvp_TurnEndFinal_from_A_forwards_envelope_to_B_and_pushes_paired_BattleFinish()
|
||||
{
|
||||
// Same unified handling as Scripted — A is the winner, B is the loser.
|
||||
// Unified TurnEndFinal handling — A is the winner, B is the loser.
|
||||
var (s, a, b) = NewPvpSession();
|
||||
DriveToAfterReady(s, a);
|
||||
DriveToAfterReady(s, b);
|
||||
@@ -491,27 +409,10 @@ public class BattleSessionDispatchTests
|
||||
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Scripted_Retire_pushes_RetireLose_to_player_and_RetireWin_to_bot()
|
||||
{
|
||||
// Unified with PvP — paired BattleFinish per-side. In Scripted mode the "loser"
|
||||
// is a ScriptedBotParticipant; its loser-side push is swallowed (it only reacts
|
||||
// to TurnEnd). The wire-correct codes are still emitted in case future work
|
||||
// wants to inspect them or run a real two-real-participant session.
|
||||
var (s, a, b) = NewSession();
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Retire));
|
||||
|
||||
Assert.That(routes.Count, Is.EqualTo(2));
|
||||
Assert.That(routes[0].Target, Is.SameAs(a));
|
||||
Assert.That(((BattleFinishBody)routes[0].Frame.Body).Result, Is.EqualTo(BattleResult.RetireLose));
|
||||
Assert.That(routes[1].Target, Is.SameAs(b));
|
||||
Assert.That(((BattleFinishBody)routes[1].Frame.Body).Result, Is.EqualTo(BattleResult.RetireWin));
|
||||
}
|
||||
|
||||
private static (BattleSession, FakeRealParticipant, FakeParticipant) NewBotSession()
|
||||
{
|
||||
var a = new FakeRealParticipant(viewerId: 1001, PlayerACtx());
|
||||
var b = new FakeParticipant(viewerId: ScriptedLifecycle.FakeOpponentViewerId, NoOpBotContext());
|
||||
var b = new FakeParticipant(viewerId: ServerBattleFrames.FakeOpponentViewerId, NoOpBotContext());
|
||||
var s = new BattleSession("bid-bot-1", BattleType.Bot, a, b, NullLogger<BattleSession>.Instance);
|
||||
return (s, a, b);
|
||||
}
|
||||
@@ -572,6 +473,7 @@ public class BattleSessionDispatchTests
|
||||
[Test]
|
||||
public void Bot_Swap_per_sender_SwapResponse_plus_Ready()
|
||||
{
|
||||
// Opponent stub is not IHasHandshakePhase → not a barrier swapper → Ready releases immediately.
|
||||
var (s, a, _) = NewBotSession();
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
|
||||
@@ -637,7 +539,7 @@ public class BattleSessionDispatchTests
|
||||
[Test]
|
||||
public void Bot_Retire_pushes_paired_BattleFinish_RetireLose_to_player_RetireWin_to_bot()
|
||||
{
|
||||
// Unified Retire/Kill dispatch — same paired push as Scripted and PvP.
|
||||
// Unified Retire/Kill dispatch — same paired push as PvP.
|
||||
// NoOpBotParticipant swallows its push.
|
||||
var (s, a, b) = NewBotSession();
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||
@@ -685,14 +587,6 @@ public class BattleSessionDispatchTests
|
||||
EmblemId: "701441022", DegreeId: "300004", FieldId: 44, IsOfficial: 0,
|
||||
BattleType: 11);
|
||||
|
||||
private static (BattleSession, FakeRealParticipant, FakeParticipant) NewSession()
|
||||
{
|
||||
var a = new FakeRealParticipant(viewerId: 1, FixtureCtx());
|
||||
var b = new FakeParticipant(viewerId: ScriptedLifecycle.FakeOpponentViewerId, ScriptedBotContext());
|
||||
var s = new BattleSession("bid-1", BattleType.Scripted, a, b, NullLogger<BattleSession>.Instance);
|
||||
return (s, a, b);
|
||||
}
|
||||
|
||||
private static MatchContext FixtureCtx() => new(
|
||||
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(),
|
||||
ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015",
|
||||
@@ -700,18 +594,30 @@ public class BattleSessionDispatchTests
|
||||
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
|
||||
BattleType: 11);
|
||||
|
||||
private static MatchContext ScriptedBotContext() => new(
|
||||
SelfDeckCardIds: Array.Empty<long>(),
|
||||
ClassId: "0", CharaId: "0", CardMasterName: "card_master_node_10015",
|
||||
CountryCode: "JPN", UserName: "Opponent", SleeveId: "704141010",
|
||||
EmblemId: "400001100", DegreeId: "120027", FieldId: 5, IsOfficial: 0,
|
||||
BattleType: 0);
|
||||
|
||||
private static MsgEnvelope NewEnvelope(NetworkBattleUri uri) =>
|
||||
new(uri, ViewerId: 1, Uuid: "u", Bid: null, Try: 0,
|
||||
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null,
|
||||
Body: new RawBody(new Dictionary<string, object?>()));
|
||||
|
||||
private static MsgEnvelope EnvWith(NetworkBattleUri uri, Dictionary<string, object?> body) =>
|
||||
new(uri, ViewerId: 1, Uuid: "u", Bid: null, Try: 0,
|
||||
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Body: new RawBody(body));
|
||||
|
||||
private static Dictionary<string, object?> MoveOrderList(int idx, int from, int to) => new()
|
||||
{
|
||||
["orderList"] = new List<object?>
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["move"] = new Dictionary<string, object?>
|
||||
{
|
||||
["idx"] = new List<object?> { (long)idx },
|
||||
["isSelf"] = 1L, ["from"] = (long)from, ["to"] = (long)to,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/// <summary>Data-only IBattleParticipant stub for dispatch tests. PushAsync/RunAsync
|
||||
/// are no-ops; FrameEmitted exists but is never invoked by the test.</summary>
|
||||
private sealed class FakeParticipant : IBattleParticipant
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
using NUnit.Framework;
|
||||
using SVSim.BattleNode.Bridge;
|
||||
using SVSim.BattleNode.Sessions;
|
||||
using SVSim.BattleNode.Sessions.Dispatch;
|
||||
|
||||
namespace SVSim.UnitTests.BattleNode.Sessions;
|
||||
|
||||
[TestFixture]
|
||||
public class BattleSessionStateTests
|
||||
{
|
||||
private sealed class StubParticipant : IBattleParticipant
|
||||
{
|
||||
public long ViewerId { get; }
|
||||
public MatchContext Context { get; }
|
||||
public event Func<SVSim.BattleNode.Protocol.MsgEnvelope, CancellationToken, Task>? FrameEmitted;
|
||||
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 RunAsync(CancellationToken c) => Task.CompletedTask;
|
||||
public Task TerminateAsync(BattleFinishReason r) => Task.CompletedTask;
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
private void Touch() => FrameEmitted?.Invoke(null!, default);
|
||||
}
|
||||
|
||||
private static MatchContext Ctx(params long[] deck) => new(
|
||||
SelfDeckCardIds: deck, ClassId: "1", CharaId: "1", CardMasterName: "cm",
|
||||
CountryCode: "KOR", UserName: "P", SleeveId: "0", EmblemId: "0", DegreeId: "0",
|
||||
FieldId: 0, IsOfficial: 0, BattleType: 11);
|
||||
|
||||
[Test]
|
||||
public void GetOrSeedDeckMap_maps_idx_1based_to_deck_cardIds()
|
||||
{
|
||||
var state = new BattleSessionState();
|
||||
var p = new StubParticipant(1, Ctx(900L, 901L, 902L));
|
||||
|
||||
var map = state.GetOrSeedDeckMap(p);
|
||||
|
||||
Assert.That(map[1], Is.EqualTo(900L));
|
||||
Assert.That(map[2], Is.EqualTo(901L));
|
||||
Assert.That(map[3], Is.EqualTo(902L));
|
||||
Assert.That(map.ContainsKey(4), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetOrSeedDeckMap_is_idempotent_same_instance()
|
||||
{
|
||||
var state = new BattleSessionState();
|
||||
var p = new StubParticipant(1, Ctx(900L));
|
||||
Assert.That(state.GetOrSeedDeckMap(p), Is.SameAs(state.GetOrSeedDeckMap(p)));
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,8 @@ namespace SVSim.UnitTests.BattleNode.Sessions;
|
||||
/// <summary>
|
||||
/// Audit Md11 — confirms <see cref="BattleSession.RunAsync"/> drops the per-RealParticipant
|
||||
/// <see cref="SVSim.BattleNode.Reliability.OutboundSequencer"/> archive when the session
|
||||
/// terminates. The Scripted bot has no outbound archive of its own, so the test uses a
|
||||
/// Scripted session (one Real, one ScriptedBot) and asserts only the Real side's archive
|
||||
/// is cleared.
|
||||
/// terminates. The NoOp bot has no outbound archive of its own, so the test uses a Bot
|
||||
/// session (one Real, one NoOpBot) and asserts only the Real side's archive is cleared.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class BattleSessionTerminateCascadeTests
|
||||
@@ -25,7 +24,7 @@ public class BattleSessionTerminateCascadeTests
|
||||
var ws = new TestWebSocket();
|
||||
var real = new RealParticipant(
|
||||
ws, viewerId: 1, MakeFakeContext(), NullLogger<RealParticipant>.Instance);
|
||||
var bot = new ScriptedBotParticipant();
|
||||
var bot = new NoOpBotParticipant();
|
||||
|
||||
// Pre-load the archive so we can prove it was cleared (not just empty).
|
||||
real.Outbound.AssignAndArchive(MakeEnvelope(NetworkBattleUri.Matched));
|
||||
@@ -33,7 +32,7 @@ public class BattleSessionTerminateCascadeTests
|
||||
Assume.That(real.Outbound.Archive.Count, Is.EqualTo(2), "Precondition: archive populated.");
|
||||
|
||||
var session = new BattleSession(
|
||||
battleId: "test-bid", type: BattleType.Scripted,
|
||||
battleId: "test-bid", type: BattleType.Bot,
|
||||
a: real, b: bot, log: NullLogger<BattleSession>.Instance);
|
||||
|
||||
// Drive RunAsync to completion: closing the incoming side causes
|
||||
|
||||
@@ -14,7 +14,7 @@ public class InMemoryBattleSessionStoreTests
|
||||
[Test]
|
||||
public void RegisterThenGet_ReturnsRegisteredBattle()
|
||||
{
|
||||
var battle = new PendingBattle("bid-1", BattleType.Scripted, new BattlePlayer(906243102, FixtureCtx()), null);
|
||||
var battle = new PendingBattle("bid-1", BattleType.Bot, new BattlePlayer(906243102, FixtureCtx()), null);
|
||||
_store.RegisterPending(battle);
|
||||
|
||||
Assert.That(_store.TryGetPending("bid-1"), Is.EqualTo(battle));
|
||||
@@ -29,7 +29,7 @@ public class InMemoryBattleSessionStoreTests
|
||||
[Test]
|
||||
public void Remove_ReturnsTrueWhenPresent_FalseWhenAbsent()
|
||||
{
|
||||
_store.RegisterPending(new PendingBattle("bid", BattleType.Scripted, new BattlePlayer(1, FixtureCtx()), null));
|
||||
_store.RegisterPending(new PendingBattle("bid", BattleType.Bot, new BattlePlayer(1, FixtureCtx()), null));
|
||||
Assert.That(_store.RemovePending("bid"), Is.True);
|
||||
Assert.That(_store.RemovePending("bid"), Is.False);
|
||||
}
|
||||
@@ -37,8 +37,8 @@ public class InMemoryBattleSessionStoreTests
|
||||
[Test]
|
||||
public void Register_DuplicateBattleId_OverwritesPrior()
|
||||
{
|
||||
_store.RegisterPending(new PendingBattle("bid", BattleType.Scripted, new BattlePlayer(1, FixtureCtx()), null));
|
||||
_store.RegisterPending(new PendingBattle("bid", BattleType.Scripted, new BattlePlayer(2, FixtureCtx()), null));
|
||||
_store.RegisterPending(new PendingBattle("bid", BattleType.Bot, new BattlePlayer(1, FixtureCtx()), null));
|
||||
_store.RegisterPending(new PendingBattle("bid", BattleType.Bot, new BattlePlayer(2, FixtureCtx()), null));
|
||||
Assert.That(_store.TryGetPending("bid")!.P1.ViewerId, Is.EqualTo(2));
|
||||
}
|
||||
|
||||
|
||||
114
SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs
Normal file
114
SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
using NUnit.Framework;
|
||||
using SVSim.BattleNode.Sessions.Dispatch;
|
||||
|
||||
namespace SVSim.UnitTests.BattleNode.Sessions;
|
||||
|
||||
[TestFixture]
|
||||
public class KnownListBuilderTests
|
||||
{
|
||||
// orderList as it arrives in a RawBody: a list of single-key op dicts.
|
||||
private static List<object?> OrderListMove(int idx, int from, int to) => new()
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["move"] = new Dictionary<string, object?>
|
||||
{
|
||||
["idx"] = new List<object?> { (long)idx },
|
||||
["isSelf"] = 1L, ["from"] = (long)from, ["to"] = (long)to,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
[Test]
|
||||
public void ExtractMoveTo_returns_to_for_matching_idx()
|
||||
{
|
||||
var to = KnownListBuilder.ExtractMoveTo(OrderListMove(3, 10, 20), playIdx: 3);
|
||||
Assert.That(to, Is.EqualTo(20));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ExtractMoveTo_returns_null_when_no_move_op_matches()
|
||||
{
|
||||
Assert.That(KnownListBuilder.ExtractMoveTo(OrderListMove(3, 10, 20), playIdx: 99), Is.Null);
|
||||
Assert.That(KnownListBuilder.ExtractMoveTo(null, playIdx: 3), Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ExtractMoveTo_returns_first_matching_move_op()
|
||||
{
|
||||
// A real PlayActions can carry several move ops; the played card's move comes first,
|
||||
// later ops (token add/alter) target other idxs. Confirm first-match-wins, not last.
|
||||
var orderList = new List<object?>
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["move"] = new Dictionary<string, object?>
|
||||
{
|
||||
["idx"] = new List<object?> { 3L }, ["isSelf"] = 1L, ["from"] = 10L, ["to"] = 30L,
|
||||
}
|
||||
},
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["move"] = new Dictionary<string, object?>
|
||||
{
|
||||
["idx"] = new List<object?> { 31L, 32L }, ["isSelf"] = 1L, ["from"] = 0L, ["to"] = 40L,
|
||||
}
|
||||
},
|
||||
};
|
||||
Assert.That(KnownListBuilder.ExtractMoveTo(orderList, playIdx: 3), Is.EqualTo(30));
|
||||
Assert.That(KnownListBuilder.ExtractMoveTo(orderList, playIdx: 31), Is.EqualTo(40));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BuildPlayedCard_returns_null_for_deck_card_with_no_matching_move_op()
|
||||
{
|
||||
// idx is in the deck, but the orderList has no move op for it → can't synthesize.
|
||||
var deckMap = new Dictionary<int, long> { [3] = 128821011L };
|
||||
var entry = KnownListBuilder.BuildPlayedCard(deckMap, playIdx: 3, orderList: OrderListMove(7, 10, 20));
|
||||
Assert.That(entry, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BuildPlayedCard_synthesizes_entry_for_deck_card()
|
||||
{
|
||||
var deckMap = new Dictionary<int, long> { [3] = 128821011L };
|
||||
var entry = KnownListBuilder.BuildPlayedCard(deckMap, playIdx: 3, orderList: OrderListMove(3, 10, 20));
|
||||
|
||||
Assert.That(entry, Is.Not.Null);
|
||||
Assert.That(entry!.Idx, Is.EqualTo(3));
|
||||
Assert.That(entry.CardId, Is.EqualTo(128821011L));
|
||||
Assert.That(entry.To, Is.EqualTo(20));
|
||||
Assert.That(entry.Spellboost, Is.EqualTo(0));
|
||||
Assert.That(entry.AttachTarget, Is.EqualTo(""));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BuildPlayedCard_returns_null_for_token_idx_not_in_deck()
|
||||
{
|
||||
var deckMap = new Dictionary<int, long> { [3] = 128821011L };
|
||||
var entry = KnownListBuilder.BuildPlayedCard(deckMap, playIdx: 31, orderList: OrderListMove(31, 10, 20));
|
||||
Assert.That(entry, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RenameTargets_passes_isSelf_through_verbatim()
|
||||
{
|
||||
var targetList = new List<object?>
|
||||
{
|
||||
new Dictionary<string, object?> { ["targetIdx"] = 8L, ["isSelf"] = 0L },
|
||||
};
|
||||
var renamed = KnownListBuilder.RenameTargets(targetList);
|
||||
|
||||
Assert.That(renamed, Is.Not.Null);
|
||||
Assert.That(renamed!.Count, Is.EqualTo(1));
|
||||
Assert.That(renamed[0].TargetIdx, Is.EqualTo(8));
|
||||
Assert.That(renamed[0].IsSelf, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RenameTargets_returns_null_for_missing_or_empty()
|
||||
{
|
||||
Assert.That(KnownListBuilder.RenameTargets(null), Is.Null);
|
||||
Assert.That(KnownListBuilder.RenameTargets(new List<object?>()), Is.Null);
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,6 @@ public class NoOpBotParticipantTests
|
||||
public void ViewerId_is_FakeOpponent()
|
||||
{
|
||||
var p = new NoOpBotParticipant();
|
||||
Assert.That(p.ViewerId, Is.EqualTo(SVSim.BattleNode.Lifecycle.ScriptedLifecycle.FakeOpponentViewerId));
|
||||
Assert.That(p.ViewerId, Is.EqualTo(SVSim.BattleNode.Lifecycle.ServerBattleFrames.FakeOpponentViewerId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
using NUnit.Framework;
|
||||
using SVSim.BattleNode.Bridge;
|
||||
using SVSim.BattleNode.Protocol;
|
||||
using SVSim.BattleNode.Protocol.Bodies;
|
||||
using SVSim.BattleNode.Sessions;
|
||||
using SVSim.BattleNode.Sessions.Participants;
|
||||
|
||||
namespace SVSim.UnitTests.BattleNode.Sessions.Participants;
|
||||
|
||||
[TestFixture]
|
||||
public class ScriptedBotParticipantTests
|
||||
{
|
||||
[Test]
|
||||
public async Task PushAsync_TurnEnd_fires_three_FrameEmitted_in_order()
|
||||
{
|
||||
var p = new ScriptedBotParticipant();
|
||||
var emitted = new List<NetworkBattleUri>();
|
||||
p.FrameEmitted += (env, _) => { emitted.Add(env.Uri); return Task.CompletedTask; };
|
||||
|
||||
await p.PushAsync(NewEnvelope(NetworkBattleUri.TurnEnd), noStock: false, CancellationToken.None);
|
||||
|
||||
Assert.That(emitted, Is.EqualTo(new[]
|
||||
{
|
||||
NetworkBattleUri.TurnStart,
|
||||
NetworkBattleUri.TurnEnd,
|
||||
NetworkBattleUri.Judge,
|
||||
}));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task PushAsync_TurnEndFinal_does_NOT_fire_burst()
|
||||
{
|
||||
// TurnEndFinal is the game-end signal — owned by BattleSession's TurnEndFinal
|
||||
// dispatch arm, which pushes BattleFinish per-side. The bot no longer reacts to
|
||||
// it; reacting would race the BattleFinish with the no-longer-needed 3-frame
|
||||
// burst. Only regular TurnEnd triggers the burst.
|
||||
var p = new ScriptedBotParticipant();
|
||||
var fired = 0;
|
||||
p.FrameEmitted += (_, _) => { fired++; return Task.CompletedTask; };
|
||||
|
||||
await p.PushAsync(NewEnvelope(NetworkBattleUri.TurnEndFinal), noStock: false, CancellationToken.None);
|
||||
|
||||
Assert.That(fired, Is.EqualTo(0),
|
||||
"TurnEndFinal must not trigger the bot's burst — the dispatch arm pushes BattleFinish directly.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task PushAsync_other_uris_do_not_fire()
|
||||
{
|
||||
var p = new ScriptedBotParticipant();
|
||||
var fired = 0;
|
||||
p.FrameEmitted += (_, _) => { fired++; return Task.CompletedTask; };
|
||||
|
||||
foreach (var uri in new[]
|
||||
{
|
||||
NetworkBattleUri.Matched, NetworkBattleUri.BattleStart, NetworkBattleUri.Deal,
|
||||
NetworkBattleUri.Swap, NetworkBattleUri.Ready, NetworkBattleUri.PlayActions,
|
||||
NetworkBattleUri.TurnStart, NetworkBattleUri.TurnEndActions, NetworkBattleUri.Echo,
|
||||
NetworkBattleUri.Judge, NetworkBattleUri.BattleFinish,
|
||||
})
|
||||
{
|
||||
await p.PushAsync(NewEnvelope(uri), noStock: false, CancellationToken.None);
|
||||
}
|
||||
|
||||
Assert.That(fired, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RunAsync_returns_immediately()
|
||||
{
|
||||
var p = new ScriptedBotParticipant();
|
||||
await p.RunAsync(CancellationToken.None);
|
||||
Assert.Pass();
|
||||
}
|
||||
|
||||
private static MsgEnvelope NewEnvelope(NetworkBattleUri uri) =>
|
||||
new(uri, ViewerId: 1, Uuid: "u", Bid: null, Try: 0,
|
||||
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null,
|
||||
Body: new ResultCodeOnlyBody());
|
||||
}
|
||||
@@ -12,18 +12,24 @@ namespace SVSim.UnitTests.Controllers;
|
||||
public class ArenaTwoPickBattleControllerTests
|
||||
{
|
||||
[Test]
|
||||
public async Task DoMatching_AuthenticatedViewer_Returns3004WithBattleIdAndNodeUrl()
|
||||
public async Task DoMatching_joiner_Returns3004WithBattleIdAndNodeUrlAndCardMaster()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
var viewerId = await factory.SeedViewerAsync();
|
||||
await SeedCompleteTwoPickRunAsync(factory, viewerId);
|
||||
var vidA = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_021UL);
|
||||
var vidB = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_022UL);
|
||||
await SeedCompleteTwoPickRunAsync(factory, vidA);
|
||||
await SeedCompleteTwoPickRunAsync(factory, vidB);
|
||||
using var clientA = factory.CreateAuthenticatedClient(vidA);
|
||||
using var clientB = factory.CreateAuthenticatedClient(vidB);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var req = new {
|
||||
deck_no = 1L, need_init = 1, log = 1, excluded_field_id_list = new long[] { }, use_stage_select = 1, is_default_skin = 0,
|
||||
viewer_id = "0", steam_id = 0, steam_session_ticket = "",
|
||||
};
|
||||
var resp = await client.PostAsync("/arena_two_pick_battle/do_matching?scripted=1", JsonContent.Create(req));
|
||||
|
||||
// A parks first; B triggers the pair and gets the 3004 joiner response.
|
||||
await clientA.PostAsync("/arena_two_pick_battle/do_matching", JsonContent.Create(req));
|
||||
var resp = await clientB.PostAsync("/arena_two_pick_battle/do_matching", JsonContent.Create(req));
|
||||
|
||||
Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
@@ -72,29 +78,6 @@ public class ArenaTwoPickBattleControllerTests
|
||||
Assert.That(root.GetProperty("node_server_url").GetString(), Is.EqualTo(""));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DoMatching_with_scripted_flag_returns_3004_Scripted_match_immediately()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
var vid = await factory.SeedViewerAsync();
|
||||
await SeedCompleteTwoPickRunAsync(factory, vid);
|
||||
using var client = factory.CreateAuthenticatedClient(vid);
|
||||
|
||||
var req = new {
|
||||
deck_no = 1L, need_init = 1, log = 1, excluded_field_id_list = new long[] { }, use_stage_select = 1, is_default_skin = 0,
|
||||
viewer_id = "0", steam_id = 0, steam_session_ticket = "",
|
||||
};
|
||||
var resp = await client.PostAsync("/arena_two_pick_battle/do_matching?scripted=1", JsonContent.Create(req));
|
||||
|
||||
Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
|
||||
Assert.That(root.GetProperty("matching_state").GetInt32(), Is.EqualTo(3004));
|
||||
Assert.That(root.GetProperty("battle_id").GetString(), Is.Not.Null.And.Not.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DoMatching_two_pollers_get_3004_joiner_and_3007_owner_with_same_BattleId()
|
||||
{
|
||||
@@ -137,35 +120,6 @@ public class ArenaTwoPickBattleControllerTests
|
||||
"Owner and joiner must see the same node_server_url.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DoMatching_SoloDefaultsToScripted_flag_makes_solo_poll_return_3004_without_query_param()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
// BattleNodeOptions is a singleton in DI; flipping it before the request takes
|
||||
// effect immediately for this factory. Real deployments toggle it via the
|
||||
// "BattleNode:SoloDefaultsToScripted" key in appsettings*.json.
|
||||
factory.Services.GetRequiredService<BattleNodeOptions>().SoloDefaultsToScripted = true;
|
||||
|
||||
var vid = await factory.SeedViewerAsync();
|
||||
await SeedCompleteTwoPickRunAsync(factory, vid);
|
||||
using var client = factory.CreateAuthenticatedClient(vid);
|
||||
|
||||
var req = new {
|
||||
deck_no = 1L, need_init = 1, log = 1, excluded_field_id_list = new long[] { }, use_stage_select = 1, is_default_skin = 0,
|
||||
viewer_id = "0", steam_id = 0, steam_session_ticket = "",
|
||||
};
|
||||
// No ?scripted=1 — the flag alone should drive the Scripted branch.
|
||||
var resp = await client.PostAsync("/arena_two_pick_battle/do_matching", JsonContent.Create(req));
|
||||
|
||||
Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
|
||||
var root = doc.RootElement;
|
||||
|
||||
Assert.That(root.GetProperty("matching_state").GetInt32(), Is.EqualTo(3004),
|
||||
"SoloDefaultsToScripted=true should bypass pair-up and return a Scripted 3004 SUCCEEDED.");
|
||||
Assert.That(root.GetProperty("battle_id").GetString(), Is.Not.Null.And.Not.Empty);
|
||||
Assert.That(root.GetProperty("node_server_url").GetString(), Does.Contain("/socket.io/"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DoMatching_NoActiveRun_Returns400WithErrorCode()
|
||||
|
||||
@@ -37,7 +37,7 @@ public class BattlePassControllerBuyTests
|
||||
db.BattlePassRewards.Add(new BattlePassRewardEntry
|
||||
{
|
||||
Id = MakeRewardId(23, BattlePassTrack.Premium, 2),
|
||||
SeasonId = 23, Track = BattlePassTrack.Premium, Level = 2, RewardType = 9,
|
||||
SeasonId = 23, Track = BattlePassTrack.Premium, Level = 2, RewardType = (UserGoodsType)9,
|
||||
RewardDetailId = 0, RewardNumber = 20, IsAppealExclusion = false,
|
||||
});
|
||||
var v = await db.Viewers.FirstAsync(x => x.Id == viewerId);
|
||||
|
||||
@@ -35,13 +35,13 @@ public class BattlePassControllerInfoTests
|
||||
db.BattlePassRewards.Add(new BattlePassRewardEntry
|
||||
{
|
||||
Id = 23 * 10_000L + 0 * 1_000 + 2, // MakeId(23, Normal=0, 2)
|
||||
SeasonId = 23, Track = BattlePassTrack.Normal, Level = 2, RewardType = 9,
|
||||
SeasonId = 23, Track = BattlePassTrack.Normal, Level = 2, RewardType = (UserGoodsType)9,
|
||||
RewardDetailId = 0, RewardNumber = 50, IsAppealExclusion = false,
|
||||
});
|
||||
db.BattlePassRewards.Add(new BattlePassRewardEntry
|
||||
{
|
||||
Id = 23 * 10_000L + 1 * 1_000 + 2, // MakeId(23, Premium=1, 2)
|
||||
SeasonId = 23, Track = BattlePassTrack.Premium, Level = 2, RewardType = 9,
|
||||
SeasonId = 23, Track = BattlePassTrack.Premium, Level = 2, RewardType = (UserGoodsType)9,
|
||||
RewardDetailId = 0, RewardNumber = 20, IsAppealExclusion = false,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
@@ -113,7 +113,7 @@ public class BuildDeckControllerBuyTests
|
||||
{
|
||||
new BuildDeckProductRewardEntry
|
||||
{
|
||||
RewardIndex = 1, RewardType = 6 /* Sleeve */,
|
||||
RewardIndex = 1, RewardType = (UserGoodsType)6 /* Sleeve */,
|
||||
RewardDetailId = 3000021, RewardNumber = 1, MessageId = 51004,
|
||||
},
|
||||
},
|
||||
@@ -383,13 +383,13 @@ public class BuildDeckControllerBuyTests
|
||||
// Tier 1: one card reward, unlocked on the 1st series purchase.
|
||||
new BuildDeckSeriesRewardEntry
|
||||
{
|
||||
TierIndex = 1, ItemIndex = 0, RewardType = 5,
|
||||
TierIndex = 1, ItemIndex = 0, RewardType = (UserGoodsType)5,
|
||||
RewardDetailId = 10001001L, RewardNumber = 1, MessageId = 51004,
|
||||
},
|
||||
// Tier 2: one card reward, unlocked on the 2nd series purchase.
|
||||
new BuildDeckSeriesRewardEntry
|
||||
{
|
||||
TierIndex = 2, ItemIndex = 0, RewardType = 5,
|
||||
TierIndex = 2, ItemIndex = 0, RewardType = (UserGoodsType)5,
|
||||
RewardDetailId = 10001002L, RewardNumber = 1, MessageId = 51004,
|
||||
},
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user