diff --git a/SVSim.BattleNode/Bridge/MatchingBridge.cs b/SVSim.BattleNode/Bridge/MatchingBridge.cs
index b1940b8..2d4e419 100644
--- a/SVSim.BattleNode/Bridge/MatchingBridge.cs
+++ b/SVSim.BattleNode/Bridge/MatchingBridge.cs
@@ -1,3 +1,4 @@
+using System.Security.Cryptography;
using SVSim.BattleNode.Sessions;
namespace SVSim.BattleNode.Bridge;
@@ -23,8 +24,13 @@ public sealed class MatchingBridge : IMatchingBridge
public PendingMatch RegisterPendingBattle(long viewerId)
{
// 12-digit decimal battle id mirrors the captures (e.g. "975695075012").
- // Cast to long before Math.Abs to avoid OverflowException on int.MinValue.
- var battleId = (Math.Abs((long)Guid.NewGuid().GetHashCode()) % 1_000_000_000_000L).ToString("D12");
+ // Two unbiased 6-digit draws concatenated — RandomNumberGenerator.GetInt32 uses
+ // rejection sampling so the result is uniform on [0, 10^6). The previous
+ // implementation (Guid.GetHashCode + mod) collapsed 128 bits into ~32 of entropy
+ // and tripped Math.Abs(int.MinValue) UB on the unlucky hash.
+ var hi = RandomNumberGenerator.GetInt32(0, 1_000_000);
+ var lo = RandomNumberGenerator.GetInt32(0, 1_000_000);
+ var battleId = $"{hi:D6}{lo:D6}";
_store.RegisterPending(new PendingBattle(battleId, viewerId));
return new PendingMatch(battleId, _options.NodeServerUrl);
}
diff --git a/SVSim.BattleNode/Lifecycle/ScriptedLifecycle.cs b/SVSim.BattleNode/Lifecycle/ScriptedLifecycle.cs
index 71e287e..c88d7cf 100644
--- a/SVSim.BattleNode/Lifecycle/ScriptedLifecycle.cs
+++ b/SVSim.BattleNode/Lifecycle/ScriptedLifecycle.cs
@@ -117,7 +117,7 @@ public static class ScriptedLifecycle
private static MsgEnvelope EnvelopeForPush(NetworkBattleUri uri, IMsgBody body, string? bid = null) =>
new(uri,
ViewerId: FakeOpponentViewerId,
- Uuid: "node-stub",
+ Uuid: WireConstants.ServerUuid,
Bid: bid,
Try: 0,
Cat: EmitCategory.Battle,
diff --git a/SVSim.BattleNode/Protocol/WireConstants.cs b/SVSim.BattleNode/Protocol/WireConstants.cs
new file mode 100644
index 0000000..78a0504
--- /dev/null
+++ b/SVSim.BattleNode/Protocol/WireConstants.cs
@@ -0,0 +1,27 @@
+namespace SVSim.BattleNode.Protocol;
+
+///
+/// String constants that show up on the wire as opaque tags. Lifting them out of
+/// inline string literals gives each one a single source of truth and a name that
+/// reads at the use site.
+///
+internal static class WireConstants
+{
+ /// SIO event name for ordered server-pushed frames (the lifecycle channel).
+ public const string SynchronizeEvent = "synchronize";
+
+ /// SIO event name for client-emitted msg frames + their ack-responses.
+ public const string MsgEvent = "msg";
+
+ /// SIO event name for Gungnir keepalive frames (both directions).
+ public const string AliveEvent = "alive";
+
+ ///
+ /// Placeholder UUID we stamp on every server-originated envelope. Prod servers stamp a
+ /// real per-request UUID; the client doesn't validate it.
+ ///
+ public const string ServerUuid = "node-stub";
+
+ /// Gungnir scs/ocs value the v1 server reports unconditionally.
+ public const string OnlineStatus = "ONLINE";
+}
diff --git a/SVSim.BattleNode/Sessions/BattleSession.cs b/SVSim.BattleNode/Sessions/BattleSession.cs
index a5afdd3..43b89c9 100644
--- a/SVSim.BattleNode/Sessions/BattleSession.cs
+++ b/SVSim.BattleNode/Sessions/BattleSession.cs
@@ -112,10 +112,10 @@ public sealed class BattleSession
{
switch (frame.EventName)
{
- case "msg" when frame.BinaryAttachments.Count == 1:
+ case WireConstants.MsgEvent when frame.BinaryAttachments.Count == 1:
await HandleMsgEventAsync(frame);
return;
- case "alive" when frame.BinaryAttachments.Count == 1:
+ case WireConstants.AliveEvent when frame.BinaryAttachments.Count == 1:
await HandleAliveEventAsync(frame);
return;
}
@@ -178,14 +178,14 @@ public sealed class BattleSession
var aliveEnv = new MsgEnvelope(
Uri: NetworkBattleUri.Gungnir,
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
- Uuid: "node-stub",
+ Uuid: WireConstants.ServerUuid,
Bid: null,
Try: 0,
Cat: EmitCategory.General,
PubSeq: null,
PlaySeq: null,
- Body: new AlivePushBody(Scs: "ONLINE", Ocs: "ONLINE"));
- await PushNoStockAsync(aliveEnv, eventName: "alive");
+ Body: new AlivePushBody(Scs: WireConstants.OnlineStatus, Ocs: WireConstants.OnlineStatus));
+ await PushNoStockAsync(aliveEnv, eventName: WireConstants.AliveEvent);
}
catch (Exception ex)
{
@@ -272,7 +272,7 @@ public sealed class BattleSession
private MsgEnvelope BuildAckedEnvelope(NetworkBattleUri uri) => new(
uri,
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
- Uuid: "node-stub",
+ Uuid: WireConstants.ServerUuid,
Bid: null,
Try: 0,
Cat: EmitCategory.General,
@@ -283,7 +283,7 @@ public sealed class BattleSession
private MsgEnvelope BuildBattleFinishNoContest() => new(
NetworkBattleUri.BattleFinish,
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
- Uuid: "node-stub",
+ Uuid: WireConstants.ServerUuid,
Bid: null,
Try: 0,
Cat: EmitCategory.Battle,
@@ -317,10 +317,10 @@ public sealed class BattleSession
return Array.Empty();
}
- private Task PushOrderedAsync(MsgEnvelope env, string eventName = "synchronize") =>
+ private Task PushOrderedAsync(MsgEnvelope env, string eventName = WireConstants.SynchronizeEvent) =>
EncodeAndSendAsync(Outbound.AssignAndArchive(env), eventName);
- private Task PushNoStockAsync(MsgEnvelope env, string eventName = "synchronize") =>
+ private Task PushNoStockAsync(MsgEnvelope env, string eventName = WireConstants.SynchronizeEvent) =>
EncodeAndSendAsync(Outbound.WrapNoStock(env), eventName);
private async Task EncodeAndSendAsync(MsgEnvelope env, string eventName)