feat(battle-node): BattleSeeds — stable per-battle seed derivation

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-04 18:13:06 -04:00
parent 75f3d8ea5b
commit 11c98bf67b
2 changed files with 87 additions and 0 deletions

View File

@@ -0,0 +1,41 @@
namespace SVSim.BattleNode.Lifecycle;
/// <summary>
/// Deterministic per-battle seed derivation. Given one random master seed (chosen once per battle
/// on <see cref="Sessions.Dispatch.BattleSessionState"/>), derives every RNG value the node hands
/// the clients: the shared effect seed (Matched.seed), each side's deck-shuffle RNG seed, and each
/// side's Ready.idxChangeSeed.
///
/// IMPORTANT: uses a fixed splitmix64-style bit-mix, NOT System.HashCode / string.GetHashCode
/// (those are randomized per process). Stability across process runs is what makes "same master
/// seed reproduces the same battle" — the foundation of replay — actually hold.
/// </summary>
internal static class BattleSeeds
{
/// <summary>Shared effect-RNG seed; identical for both sides (it seeds the synced stream).</summary>
public static int Stable(int master) => Derive(master, "stable");
/// <summary>Per-side Ready.idxChangeSeed (client XorShift for mid-battle card-into-deck).</summary>
public static int IdxChange(int master, long viewerId) => Derive(master, "idx", viewerId);
/// <summary>Per-side deck-shuffle RNG seed (node-side FisherYates).</summary>
public static int DeckShuffle(int master, long viewerId) => Derive(master, "deck", viewerId);
/// <summary>Derive a stable non-negative int from (master, tag, discriminator). Pure arithmetic
/// — reproducible across process runs and platforms.</summary>
public static int Derive(int master, string tag, long disc = 0)
{
ulong h = Mix((uint)master);
foreach (char c in tag) h = Mix(h ^ c);
h = Mix(h ^ (ulong)disc);
return (int)(h & 0x7FFFFFFFUL);
}
private static ulong Mix(ulong x)
{
x += 0x9E3779B97F4A7C15UL;
x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9UL;
x = (x ^ (x >> 27)) * 0x94D049BB133111EBUL;
return x ^ (x >> 31);
}
}

View File

@@ -0,0 +1,46 @@
using NUnit.Framework;
using SVSim.BattleNode.Lifecycle;
namespace SVSim.UnitTests.BattleNode.Lifecycle;
[TestFixture]
public class BattleSeedsTests
{
// Golden values pin cross-run/cross-platform stability. They were computed from the exact
// splitmix64 mix specified in BattleSeeds. If these ever change, replay reproducibility broke —
// do NOT "update them to match"; find what changed the algorithm (e.g. someone slipped in
// GetHashCode, which is per-process randomized).
[Test]
public void Derive_golden_values_are_stable()
{
Assert.That(BattleSeeds.Stable(12345), Is.EqualTo(1577307848));
Assert.That(BattleSeeds.IdxChange(12345, 906243102), Is.EqualTo(1638231407));
Assert.That(BattleSeeds.DeckShuffle(12345, 906243102), Is.EqualTo(355953180));
Assert.That(BattleSeeds.IdxChange(12345, 847666884), Is.EqualTo(518125159));
Assert.That(BattleSeeds.Stable(99999), Is.EqualTo(323349150));
}
[Test]
public void Derive_is_deterministic_for_same_inputs()
{
Assert.That(BattleSeeds.Derive(7, "x", 42), Is.EqualTo(BattleSeeds.Derive(7, "x", 42)));
}
[Test]
public void Derive_differs_across_tag_master_and_discriminator()
{
var baseline = BattleSeeds.Derive(7, "x", 42);
Assert.That(BattleSeeds.Derive(8, "x", 42), Is.Not.EqualTo(baseline), "different master");
Assert.That(BattleSeeds.Derive(7, "y", 42), Is.Not.EqualTo(baseline), "different tag");
Assert.That(BattleSeeds.Derive(7, "x", 43), Is.Not.EqualTo(baseline), "different disc");
}
[Test]
public void Derive_is_always_non_negative()
{
// System.Random tolerates any int, but a non-negative seed keeps parity with prod's
// positive seed values and avoids surprises.
Assert.That(BattleSeeds.Stable(int.MinValue), Is.GreaterThanOrEqualTo(0));
Assert.That(BattleSeeds.Stable(-1), Is.GreaterThanOrEqualTo(0));
}
}