feat(battle-node): BattleSeeds — stable per-battle seed derivation
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
41
SVSim.BattleNode/Lifecycle/BattleSeeds.cs
Normal file
41
SVSim.BattleNode/Lifecycle/BattleSeeds.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
namespace SVSim.BattleNode.Lifecycle;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic per-battle seed derivation. Given one random master seed (chosen once per battle
|
||||
/// on <see cref="Sessions.Dispatch.BattleSessionState"/>), derives every RNG value the node hands
|
||||
/// the clients: the shared effect seed (Matched.seed), each side's deck-shuffle RNG seed, and each
|
||||
/// side's Ready.idxChangeSeed.
|
||||
///
|
||||
/// IMPORTANT: uses a fixed splitmix64-style bit-mix, NOT System.HashCode / string.GetHashCode
|
||||
/// (those are randomized per process). Stability across process runs is what makes "same master
|
||||
/// seed reproduces the same battle" — the foundation of replay — actually hold.
|
||||
/// </summary>
|
||||
internal static class BattleSeeds
|
||||
{
|
||||
/// <summary>Shared effect-RNG seed; identical for both sides (it seeds the synced stream).</summary>
|
||||
public static int Stable(int master) => Derive(master, "stable");
|
||||
|
||||
/// <summary>Per-side Ready.idxChangeSeed (client XorShift for mid-battle card-into-deck).</summary>
|
||||
public static int IdxChange(int master, long viewerId) => Derive(master, "idx", viewerId);
|
||||
|
||||
/// <summary>Per-side deck-shuffle RNG seed (node-side Fisher–Yates).</summary>
|
||||
public static int DeckShuffle(int master, long viewerId) => Derive(master, "deck", viewerId);
|
||||
|
||||
/// <summary>Derive a stable non-negative int from (master, tag, discriminator). Pure arithmetic
|
||||
/// — reproducible across process runs and platforms.</summary>
|
||||
public static int Derive(int master, string tag, long disc = 0)
|
||||
{
|
||||
ulong h = Mix((uint)master);
|
||||
foreach (char c in tag) h = Mix(h ^ c);
|
||||
h = Mix(h ^ (ulong)disc);
|
||||
return (int)(h & 0x7FFFFFFFUL);
|
||||
}
|
||||
|
||||
private static ulong Mix(ulong x)
|
||||
{
|
||||
x += 0x9E3779B97F4A7C15UL;
|
||||
x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9UL;
|
||||
x = (x ^ (x >> 27)) * 0x94D049BB133111EBUL;
|
||||
return x ^ (x >> 31);
|
||||
}
|
||||
}
|
||||
46
SVSim.UnitTests/BattleNode/Lifecycle/BattleSeedsTests.cs
Normal file
46
SVSim.UnitTests/BattleNode/Lifecycle/BattleSeedsTests.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using NUnit.Framework;
|
||||
using SVSim.BattleNode.Lifecycle;
|
||||
|
||||
namespace SVSim.UnitTests.BattleNode.Lifecycle;
|
||||
|
||||
[TestFixture]
|
||||
public class BattleSeedsTests
|
||||
{
|
||||
// Golden values pin cross-run/cross-platform stability. They were computed from the exact
|
||||
// splitmix64 mix specified in BattleSeeds. If these ever change, replay reproducibility broke —
|
||||
// do NOT "update them to match"; find what changed the algorithm (e.g. someone slipped in
|
||||
// GetHashCode, which is per-process randomized).
|
||||
[Test]
|
||||
public void Derive_golden_values_are_stable()
|
||||
{
|
||||
Assert.That(BattleSeeds.Stable(12345), Is.EqualTo(1577307848));
|
||||
Assert.That(BattleSeeds.IdxChange(12345, 906243102), Is.EqualTo(1638231407));
|
||||
Assert.That(BattleSeeds.DeckShuffle(12345, 906243102), Is.EqualTo(355953180));
|
||||
Assert.That(BattleSeeds.IdxChange(12345, 847666884), Is.EqualTo(518125159));
|
||||
Assert.That(BattleSeeds.Stable(99999), Is.EqualTo(323349150));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Derive_is_deterministic_for_same_inputs()
|
||||
{
|
||||
Assert.That(BattleSeeds.Derive(7, "x", 42), Is.EqualTo(BattleSeeds.Derive(7, "x", 42)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Derive_differs_across_tag_master_and_discriminator()
|
||||
{
|
||||
var baseline = BattleSeeds.Derive(7, "x", 42);
|
||||
Assert.That(BattleSeeds.Derive(8, "x", 42), Is.Not.EqualTo(baseline), "different master");
|
||||
Assert.That(BattleSeeds.Derive(7, "y", 42), Is.Not.EqualTo(baseline), "different tag");
|
||||
Assert.That(BattleSeeds.Derive(7, "x", 43), Is.Not.EqualTo(baseline), "different disc");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Derive_is_always_non_negative()
|
||||
{
|
||||
// System.Random tolerates any int, but a non-negative seed keeps parity with prod's
|
||||
// positive seed values and avoids surprises.
|
||||
Assert.That(BattleSeeds.Stable(int.MinValue), Is.GreaterThanOrEqualTo(0));
|
||||
Assert.That(BattleSeeds.Stable(-1), Is.GreaterThanOrEqualTo(0));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user