From 11c98bf67b0877546553d99084efd289c8a9f877 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Thu, 4 Jun 2026 18:13:06 -0400 Subject: [PATCH] =?UTF-8?q?feat(battle-node):=20BattleSeeds=20=E2=80=94=20?= =?UTF-8?q?stable=20per-battle=20seed=20derivation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- SVSim.BattleNode/Lifecycle/BattleSeeds.cs | 41 +++++++++++++++++ .../BattleNode/Lifecycle/BattleSeedsTests.cs | 46 +++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 SVSim.BattleNode/Lifecycle/BattleSeeds.cs create mode 100644 SVSim.UnitTests/BattleNode/Lifecycle/BattleSeedsTests.cs diff --git a/SVSim.BattleNode/Lifecycle/BattleSeeds.cs b/SVSim.BattleNode/Lifecycle/BattleSeeds.cs new file mode 100644 index 0000000..0d67b0c --- /dev/null +++ b/SVSim.BattleNode/Lifecycle/BattleSeeds.cs @@ -0,0 +1,41 @@ +namespace SVSim.BattleNode.Lifecycle; + +/// +/// Deterministic per-battle seed derivation. Given one random master seed (chosen once per battle +/// on ), 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. +/// +internal static class BattleSeeds +{ + /// Shared effect-RNG seed; identical for both sides (it seeds the synced stream). + public static int Stable(int master) => Derive(master, "stable"); + + /// Per-side Ready.idxChangeSeed (client XorShift for mid-battle card-into-deck). + public static int IdxChange(int master, long viewerId) => Derive(master, "idx", viewerId); + + /// Per-side deck-shuffle RNG seed (node-side Fisher–Yates). + public static int DeckShuffle(int master, long viewerId) => Derive(master, "deck", viewerId); + + /// Derive a stable non-negative int from (master, tag, discriminator). Pure arithmetic + /// — reproducible across process runs and platforms. + 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); + } +} diff --git a/SVSim.UnitTests/BattleNode/Lifecycle/BattleSeedsTests.cs b/SVSim.UnitTests/BattleNode/Lifecycle/BattleSeedsTests.cs new file mode 100644 index 0000000..7554240 --- /dev/null +++ b/SVSim.UnitTests/BattleNode/Lifecycle/BattleSeedsTests.cs @@ -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)); + } +}