From 49cdd3636595d0a0230b598a3bd9dfd8b0a6dc7c Mon Sep 17 00:00:00 2001 From: Daniel Machado Date: Fri, 17 Jul 2026 15:49:33 +0200 Subject: [PATCH] Randomized min-distance spawn placement replaces the mirrored spawn table Tanks could spawn stuck inside each other: SpawnTable mirrored 2 declared points into 8 candidates, centre-ish spawns collapsed under mirroring, and the ring-search nudged duplicates onto ADJACENT cells. SpawnTable is now seeded random placement: pairwise Chebyshev separation targets 10 cells, relaxes one cell at a time to a floor of 3 on cramped maps, and below that farthest-point placement best-effort maximises separation - two tanks share a cell only when the field is smaller than the spawn count. Eligibility is the caller's predicate and must exclude deadly terrain: themed arenas allow floor only; the net scene predicate now also excludes lava explicitly (drivable but lethal; water already blocks movement). Deterministic per seed so peers agree: IArenaBuilder.Build takes the seed (solo passes GameSetup.ArenaSeed, net the shared lobby seed - terrain stays seed-independent), NetArena3DScene derives spawns from the match seed (the now-redundant post-hoc shuffle is gone), and ArenaGenerator seats its tank spawns through the same placement. Respawns still revive at the tank's assigned cell. Co-Authored-By: Claude Fable 5 --- client/src/GameLogic/ArenaBuilders.cs | 59 ++--- client/src/GameLogic/ArenaGenerator.cs | 18 +- client/src/GameLogic/SpawnTable.cs | 209 +++++++++++++----- client/src/Presentation/Arena/Arena3DScene.cs | 4 +- .../src/Presentation/Arena/NetArena3DScene.cs | 47 +--- client/tests/GameLogic/ArenaBuildersTests.cs | 65 +++++- client/tests/GameLogic/ArenaGeneratorTests.cs | 24 ++ client/tests/GameLogic/SpawnTableTests.cs | 114 +++++++--- .../Presentation/NetArena3DSceneTests.cs | 36 +-- 9 files changed, 392 insertions(+), 184 deletions(-) diff --git a/client/src/GameLogic/ArenaBuilders.cs b/client/src/GameLogic/ArenaBuilders.cs index f952d49..a4bd7d0 100644 --- a/client/src/GameLogic/ArenaBuilders.cs +++ b/client/src/GameLogic/ArenaBuilders.cs @@ -19,11 +19,12 @@ public sealed record ArenaLayout( GroundTheme GroundTheme); /// Builds one built-in arena. A content slice implements this per map (a full themed layout -/// of ~2x size with eight spawns and powerup pads); the stubs here return a small valid arena so the -/// game runs and tests pass until then. +/// of ~2x size with eight spawns and powerup pads). The places the spawns +/// (seeded random, min-distance separated) and never touches the terrain, so net peers passing the +/// shared match seed derive identical layouts. public interface IArenaBuilder { - ArenaLayout Build(); + ArenaLayout Build(int seed); } /// Registry of the code-built arenas, keyed by arena id name (matching the Presentation @@ -69,15 +70,15 @@ public static bool TryGet(string arenaId, out IArenaBuilder builder) // ── Hand-authored themed arenas ─────────────────────────────────────────────────────────────── // Each map is a ~76x46 steel-ringed field authored with loops and constants only — no Random, Guid, or -// time — so host and guest calling Build() independently get byte-identical layouts (net-synced). -// Spawns come from SpawnTable (eight symmetric, well-separated starts, each nudged to open floor); the +// time — so host and guest calling Build(seed) independently get byte-identical layouts (net-synced). +// Spawns come from SpawnTable (eight seeded-random, min-distance-separated starts on open floor); the // shared ArenaAuthor.Assemble stitches materials + spawns + pickups into an ArenaLayout. /// Forest Ambush: open clearings dotted with Mountain hills and bush copses that let an /// ambusher lie hidden between the trees. public sealed class ForestArena : IArenaBuilder { - public ArenaLayout Build() + public ArenaLayout Build(int seed) { var (materials, bushes) = ArenaAuthor.Field(); @@ -102,7 +103,7 @@ public ArenaLayout Build() (PowerupKind.Missile, 6, 6), }; - return ArenaAuthor.Assemble(materials, bushes, powerups, GroundTheme.Jungle, (2, 2), (73, 43)); + return ArenaAuthor.Assemble(materials, bushes, powerups, GroundTheme.Jungle, seed); } } @@ -110,7 +111,7 @@ public ArenaLayout Build() /// shots fly over it but destroys any tank that drives onto it — the bridges are the only safe crossings. public sealed class VolcanoArena : IArenaBuilder { - public ArenaLayout Build() + public ArenaLayout Build(int seed) { var (materials, bushes) = ArenaAuthor.Field(); @@ -151,7 +152,7 @@ public ArenaLayout Build() (PowerupKind.SpeedBoost, 70, 22), }; - return ArenaAuthor.Assemble(materials, bushes, powerups, GroundTheme.Volcano, (2, 2), (73, 43)); + return ArenaAuthor.Assemble(materials, bushes, powerups, GroundTheme.Volcano, seed); } } @@ -162,7 +163,7 @@ public sealed class CityArena : IArenaBuilder private const int Period = 12; // one city block (9 wide) plus its 3-wide road private const int RoadWidth = 3; - public ArenaLayout Build() + public ArenaLayout Build(int seed) { var (materials, bushes) = ArenaAuthor.Field(); @@ -187,7 +188,7 @@ public ArenaLayout Build() (PowerupKind.Telephone, 1, 23), }; - return ArenaAuthor.Assemble(materials, bushes, powerups, GroundTheme.ParkingLot, (1, 1), (73, 43)); + return ArenaAuthor.Assemble(materials, bushes, powerups, GroundTheme.ParkingLot, seed); } } @@ -195,7 +196,7 @@ public ArenaLayout Build() /// ponds, each crossed by a Bridge so no pocket is walled off. Long sightlines between the cover. public sealed class FrozenArena : IArenaBuilder { - public ArenaLayout Build() + public ArenaLayout Build(int seed) { var (materials, bushes) = ArenaAuthor.Field(); @@ -228,7 +229,7 @@ public ArenaLayout Build() (PowerupKind.Missile, 14, 14), }; - return ArenaAuthor.Assemble(materials, bushes, powerups, GroundTheme.Sand, (2, 2), (73, 43)); + return ArenaAuthor.Assemble(materials, bushes, powerups, GroundTheme.Sand, seed); } } @@ -236,7 +237,7 @@ public ArenaLayout Build() /// raised central mesa (elevation layer 1) reached only by ramps on its four sides. public sealed class CanyonArena : IArenaBuilder { - public ArenaLayout Build() + public ArenaLayout Build(int seed) { var (materials, bushes) = ArenaAuthor.Field(); var layers = new int[ArenaAuthor.Width, ArenaAuthor.Height]; @@ -291,7 +292,7 @@ public ArenaLayout Build() (PowerupKind.RapidFire, 69, 38), }; - return ArenaAuthor.Assemble(materials, bushes, powerups, GroundTheme.Mars, (2, 2), (73, 43), layers, ramps); + return ArenaAuthor.Assemble(materials, bushes, powerups, GroundTheme.Mars, seed, layers, ramps); } } @@ -304,7 +305,7 @@ public ArenaLayout Build() /// solid too — every fight orbits the ring, with brick outcrops as the only cover. public sealed class DonutArena : IArenaBuilder { - public ArenaLayout Build() + public ArenaLayout Build(int seed) { var (materials, bushes) = ArenaAuthor.Field(); @@ -334,7 +335,7 @@ static double R2(int x, int y) (PowerupKind.Missile, 23, 10), }; - return ArenaAuthor.Assemble(materials, bushes, powerups, GroundTheme.Mars, (37, 3), (6, 22)); + return ArenaAuthor.Assemble(materials, bushes, powerups, GroundTheme.Mars, seed); } } @@ -342,7 +343,7 @@ static double R2(int x, int y) /// chokepoint) — with the corners outside the plus masked solid Steel. public sealed class CrossArena : IArenaBuilder { - public ArenaLayout Build() + public ArenaLayout Build(int seed) { var (materials, bushes) = ArenaAuthor.Field(); @@ -371,7 +372,7 @@ public ArenaLayout Build() (PowerupKind.RapidFire, 65, 22), }; - return ArenaAuthor.Assemble(materials, bushes, powerups, GroundTheme.ParkingLot, (37, 3), (4, 22)); + return ArenaAuthor.Assemble(materials, bushes, powerups, GroundTheme.ParkingLot, seed); } } @@ -379,7 +380,7 @@ public ArenaLayout Build() /// by single-cell Bridge causeways — hold an island or duel across the open water. public sealed class ArchipelagoArena : IArenaBuilder { - public ArenaLayout Build() + public ArenaLayout Build(int seed) { var (materials, bushes) = ArenaAuthor.Field(); @@ -423,14 +424,15 @@ static void Causeway(CellMaterial[,] mats, int x0, int y0, int x1, int y1) (PowerupKind.RapidFire, 62, 31), }; - return ArenaAuthor.Assemble(materials, bushes, powerups, GroundTheme.Sand, (13, 10), (62, 10)); + return ArenaAuthor.Assemble(materials, bushes, powerups, GroundTheme.Sand, seed); } } /// Shared authoring helpers for the built-in themed arenas: a steel-ringed floor field of the /// common size, primitive terrain stampers, and the assembler that turns authored terrain into an -/// with eight spawns. Deterministic — loops and -/// constants only, so a net host and guest build byte-identical maps. +/// with eight spawns. Terrain is deterministic — +/// loops and constants only — and the spawns deterministic per seed, so a net host and guest passing +/// the shared match seed build byte-identical maps. internal static class ArenaAuthor { public const int Width = 76; @@ -517,19 +519,18 @@ public static void Ramp(CellMaterial[,] materials, int[,] layers, bool[,] ramps, ramps[x, y] = true; } - /// Stitches authored terrain into a playable : eight symmetric - /// spawns from (each nudged onto open floor), the player first, the rest as + /// Stitches authored terrain into a playable : eight seeded-random + /// min-distance-separated spawns from , the player first, the rest as /// enemies. Spawn placement treats any non-floor cell as blocked, so no start lands in a wall, on - /// lava, or on a bridge. + /// lava, in water, or on a bridge. public static ArenaLayout Assemble( CellMaterial[,] materials, bool[,] bushes, - (PowerupKind Kind, int X, int Y)[] powerups, GroundTheme theme, - (int X, int Y) primary, (int X, int Y) secondary, + (PowerupKind Kind, int X, int Y)[] powerups, GroundTheme theme, int seed, int[,]? layers = null, bool[,]? ramps = null) { bool Blocked(int x, int y) => materials[x, y] != CellMaterial.Floor; - var spawns = SpawnTable.For(Width, Height, primary, secondary, Blocked); + var spawns = SpawnTable.For(Width, Height, SpawnTable.MaxSpawns, seed, Blocked); var player = spawns[0]; var enemies = spawns.Skip(1).ToList(); var sandbags = new bool[Width, Height]; diff --git a/client/src/GameLogic/ArenaGenerator.cs b/client/src/GameLogic/ArenaGenerator.cs index f6383cc..ae79940 100644 --- a/client/src/GameLogic/ArenaGenerator.cs +++ b/client/src/GameLogic/ArenaGenerator.cs @@ -71,16 +71,18 @@ public GeneratedArena Generate(ArenaGenParams p) // terrain never land on the water or the bridges (the "claim a cell" rule). var (riverCells, claimed, approaches) = CarveRiver(p, rng); - // Spawns first, in opposite corners; then enemies and pickups spread across the field. All are - // picked on unclaimed interior floor so walls scatter around them, never on them or the river. - var playerSpawn = PickInRegion(p, rng, chosen, claimed, 1, 1, Third(p.Width), Third(p.Height)); - var player2Spawn = PickInRegion(p, rng, chosen, claimed, - p.Width - 1 - Third(p.Width), p.Height - 1 - Third(p.Height), p.Width - 2, p.Height - 2); - + // Tank spawns first, via SpawnTable's seeded min-distance placement (target 10, floor 3), so + // no two tanks start packed together; then pickups spread across the field. All land on + // unclaimed interior floor so walls scatter around them, never on them or the river. + var spawnCells = SpawnTable.For(p.Width, p.Height, 2 + p.EnemyCount, rng, + (x, y) => IsBorder(p, x, y) || claimed[x, y]); + chosen.AddRange(spawnCells); + var playerSpawn = spawnCells[0]; + var player2Spawn = spawnCells[1]; var enemySpawns = new List<(int X, int Y)>(); - for (var i = 0; i < p.EnemyCount; i++) + for (var i = 2; i < spawnCells.Count; i++) { - enemySpawns.Add(PickInRegion(p, rng, chosen, claimed, 1, 1, p.Width - 2, p.Height - 2)); + enemySpawns.Add(spawnCells[i]); } var pickupCells = new List<(int X, int Y)>(); diff --git a/client/src/GameLogic/SpawnTable.cs b/client/src/GameLogic/SpawnTable.cs index 159c9e2..c72746a 100644 --- a/client/src/GameLogic/SpawnTable.cs +++ b/client/src/GameLogic/SpawnTable.cs @@ -3,93 +3,186 @@ namespace TankGame.GameLogic; -/// Up to eight spawn cells for a room on a level that only declares one or two: the level's -/// own spawn, the classic second spawn, and their reflections across the field's centre and both -/// axes — eight symmetric points, each nudged to the nearest un-blocked cell not already taken, so -/// eight players never collide and nobody materialises inside a wall. Pure C#: the caller supplies -/// the blocked predicate. +/// Seeded random spawn placement with a pairwise minimum distance. The old fixed/mirrored +/// scheme collapsed centre-ish spawns onto each other and nudged the duplicates to ADJACENT cells, +/// packing eight tanks together; this one scatters them anywhere eligible instead. Placement aims +/// for cells between any two spawns, relaxing one cell at a time down +/// to when the map is too cramped, and below that best-effort maximises +/// whatever separation the field can still give — two tanks share a cell only when the field itself +/// is smaller than the spawn count. Pure C#: the caller supplies the eligibility predicate, which +/// must cover walls AND deadly terrain (lava/water), so no tank materialises inside a wall or on a +/// cell that kills it. Deterministic per seed, so net peers and replays derive identical spawns. public static class SpawnTable { - /// The most spawns yielded — one per player in an eight-tank match. + /// The most spawns requested — one per player in an eight-tank match. public const int MaxSpawns = 8; + /// The pairwise separation (Chebyshev cells) placement aims for. + public const int TargetSeparation = 10; + + /// The smallest separation relaxation may accept before going best-effort. + public const int FloorSeparation = 3; + + // Re-shuffles per separation level: a greedy pass over one shuffle can miss a seating that + // exists, so a few fresh orders are tried before conceding the level and relaxing. + private const int TriesPerSeparation = 4; + + public static IReadOnlyList<(int X, int Y)> For( + int width, int height, int count, int seed, Func isBlocked) => + For(width, height, count, new Random(seed), isBlocked); + + /// As , but drawing from a + /// caller-owned mid-stream (the arena generator's per-attempt rng). public static IReadOnlyList<(int X, int Y)> For( - int width, int height, (int X, int Y) primary, (int X, int Y) secondary, - Func isBlocked) + int width, int height, int count, Random rng, Func isBlocked) { - // Each seed plus its three reflections (across the centre, the horizontal axis, the vertical - // axis) spreads eight starts symmetrically over the field. The two declared spawns lead so - // they never move; the reflections fill the rest of the ring. - var candidates = new[] + var open = new List<(int X, int Y)>(); + for (var y = 0; y < height; y++) { - primary, - secondary, - Mirror(primary, width, height), - Mirror(secondary, width, height), - (X: primary.X, Y: height - 1 - primary.Y), - (X: width - 1 - primary.X, Y: primary.Y), - (X: secondary.X, Y: height - 1 - secondary.Y), - (X: width - 1 - secondary.X, Y: secondary.Y), - }; - - var taken = new HashSet<(int X, int Y)>(); - var spawns = new List<(int X, int Y)>(MaxSpawns); - foreach (var candidate in candidates) + for (var x = 0; x < width; x++) + { + if (!isBlocked(x, y)) + { + open.Add((x, y)); + } + } + } + + for (var separation = TargetSeparation; separation >= FloorSeparation; separation--) { - var cell = NearestOpen(candidate, width, height, isBlocked, taken); - taken.Add(cell); - spawns.Add(cell); + for (var attempt = 0; attempt < TriesPerSeparation; attempt++) + { + if (TryPlace(open, count, separation, rng) is { } placed) + { + return placed; + } + } } - return spawns; + return BestEffort(open, width, height, count, rng); } - private static (int X, int Y) Mirror((int X, int Y) c, int width, int height) => - (width - 1 - c.X, height - 1 - c.Y); - - // Ring search outward from the candidate until an un-blocked, in-bounds, not-yet-taken cell turns - // up. The candidate itself wins when open and free (ring 0), so an unobstructed declared spawn is - // never moved; the taken set keeps the eight distinct. - private static (int X, int Y) NearestOpen( - (int X, int Y) from, int width, int height, Func isBlocked, - HashSet<(int X, int Y)> taken) + // One greedy pass over a fresh shuffle: take each cell that keeps the separation to everything + // already placed. Null when the pass cannot seat everyone at this separation. + private static List<(int X, int Y)>? TryPlace( + List<(int X, int Y)> open, int count, int separation, Random rng) { - for (var ring = 0; ring < Math.Max(width, height); ring++) + if (open.Count < count) + { + return null; + } + + var placed = new List<(int X, int Y)>(count); + foreach (var cell in Shuffled(open, rng)) { - for (var dy = -ring; dy <= ring; dy++) + if (FarEnough(cell, placed, separation)) { - for (var dx = -ring; dx <= ring; dx++) + placed.Add(cell); + if (placed.Count == count) { - if (Math.Max(Math.Abs(dx), Math.Abs(dy)) != ring) - { - continue; // only the ring's shell — inner cells were checked already - } + return placed; + } + } + } + + return null; + } - var x = from.X + dx; - var y = from.Y + dy; - if (x >= 0 && x < width && y >= 0 && y < height - && !isBlocked(x, y) && !taken.Contains((x, y))) + // Below the floor: farthest-point placement squeezes out whatever separation the field still + // gives. Blocked cells join the pool only when the open ones cannot seat everyone (distinctness + // beats openness); cells repeat only when the whole field is smaller than the spawn count. + private static List<(int X, int Y)> BestEffort( + List<(int X, int Y)> open, int width, int height, int count, Random rng) + { + var pool = open; + if (pool.Count < count) + { + var openSet = new HashSet<(int X, int Y)>(open); + pool = new List<(int X, int Y)>(open); + for (var y = 0; y < height; y++) + { + for (var x = 0; x < width; x++) + { + if (!openSet.Contains((x, y))) { - return (x, y); + pool.Add((x, y)); } } } } - // Everything within reach was blocked or already taken. Rather than hand back a duplicate (two - // tanks materialising on one cell), scan row-major for any distinct in-bounds cell — on a nearly - // full map, distinctness matters more than landing on a perfectly open tile. - for (var y = 0; y < height; y++) + var placed = new List<(int X, int Y)>(count); + if (pool.Count == 0) { - for (var x = 0; x < width; x++) + return placed; // a zero-area field — nothing to place on + } + + placed.Add(pool[rng.Next(pool.Count)]); + while (placed.Count < count) + { + var best = default((int X, int Y)); + var bestDistance = -1; + foreach (var cell in pool) { - if (!taken.Contains((x, y))) + if (placed.Contains(cell)) + { + continue; + } + + var distance = int.MaxValue; + foreach (var other in placed) + { + distance = Math.Min(distance, Chebyshev(cell, other)); + } + + if (distance > bestDistance) { - return (x, y); + bestDistance = distance; + best = cell; } } + + if (bestDistance < 0) + { + break; // every distinct cell is taken — the field is smaller than the spawn count + } + + placed.Add(best); + } + + for (var i = 0; placed.Count < count; i = (i + 1) % placed.Count) + { + placed.Add(placed[i]); // reuse cells round-robin — only reachable on a too-small field + } + + return placed; + } + + private static List<(int X, int Y)> Shuffled(List<(int X, int Y)> cells, Random rng) + { + var copy = new List<(int X, int Y)>(cells); + for (var i = copy.Count - 1; i > 0; i--) + { + var j = rng.Next(i + 1); + (copy[i], copy[j]) = (copy[j], copy[i]); + } + + return copy; + } + + private static bool FarEnough((int X, int Y) cell, List<(int X, int Y)> placed, int separation) + { + foreach (var other in placed) + { + if (Chebyshev(cell, other) < separation) + { + return false; + } } - return from; // truly nowhere left (field smaller than the spawn count) — accept the overlap + return true; } + + private static int Chebyshev((int X, int Y) a, (int X, int Y) b) => + Math.Max(Math.Abs(a.X - b.X), Math.Abs(a.Y - b.Y)); } diff --git a/client/src/Presentation/Arena/Arena3DScene.cs b/client/src/Presentation/Arena/Arena3DScene.cs index 177f151..57aedb2 100644 --- a/client/src/Presentation/Arena/Arena3DScene.cs +++ b/client/src/Presentation/Arena/Arena3DScene.cs @@ -232,8 +232,8 @@ public override void _Ready() else if (ArenaBuilders.TryGet(GameSetup.Arena.ToString(), out var builder)) { // A themed code arena (Forest/Volcano/City/Frozen/Canyon): the builder seam returns a whole - // layout the same shape as Cliffs, so guests and host build the identical level. - var layout = builder.Build(); + // layout the same shape as Cliffs; the per-match arena seed places the spawns. + var layout = builder.Build(GameSetup.ArenaSeed); level = layout.Map; sandbags = layout.Sandbags; _playerSpawn = layout.PlayerSpawn; diff --git a/client/src/Presentation/Arena/NetArena3DScene.cs b/client/src/Presentation/Arena/NetArena3DScene.cs index 2be1d86..4d0d712 100644 --- a/client/src/Presentation/Arena/NetArena3DScene.cs +++ b/client/src/Presentation/Arena/NetArena3DScene.cs @@ -33,8 +33,6 @@ public partial class NetArena3DScene : Node3D private const byte HostSlot = 0; private const float TeleportPadRadius = 40f; // a tank centred within this of a pad warps (as solo) - // The guest spawns where the local game's Player 2 does (mirrors the 2D net scene). - private static readonly (int X, int Y) GuestSpawn = (25, 7); private static readonly NVector2 GridOrigin = NVector2.Zero; // Camera: the same eyeballed ¾ ortho as Arena3DScene. @@ -62,7 +60,7 @@ public partial class NetArena3DScene : Node3D private IReadOnlyList _authoredPads = System.Array.Empty(); private float _accumulator; private byte? _localSlot; - private int _matchSeed; // the lobby's match seed: drives the spawn shuffle and per-bot AI seeding + private int _matchSeed; // the lobby's match seed: drives spawn placement and per-bot AI seeding // The loading handshake (issue #2): when we enter during the lobby's "loading" phase we report our // arena is built and then hold — no ticks, no snapshots — until the server flips to "started" @@ -70,12 +68,9 @@ public partial class NetArena3DScene : Node3D private bool _awaitingStart; // The seating plan from the lobby's final roster (placeholder-named AI on empty seats), the - // shared four-cell spawn table both roles derive identically, and the host's authoritative - // round state. + // seeded spawn table both roles derive identically, and the host's authoritative round state. private IReadOnlyList _roster = System.Array.Empty(); private IReadOnlyList<(int X, int Y)> _spawns = System.Array.Empty<(int, int)>(); - private (int X, int Y) _primarySpawn; - private (int X, int Y) _secondarySpawn; private readonly List<(byte Slot, ITank Tank)> _rosterTanks = new(); private readonly List<(int Team, bool Alive)> _roundStatus = new(); @@ -154,18 +149,15 @@ public override void _Ready() var cliffs = CliffsArena.Create(); _level = cliffs.Map; sandbags = cliffs.Sandbags; - _primarySpawn = cliffs.PlayerSpawn; - _secondarySpawn = cliffs.EnemySpawns[0]; _authoredPads = cliffs.Pads; // the cross-layer valley↔plateau pair (teleport pads T3) break; case NetMapPick.BuiltIn builtIn when ArenaBuilders.TryGet(builtIn.ArenaId, out var builder): // A themed code arena (Forest/Volcano/City/…): every member builds the identical layout - // from its id, so host truth and guest rendering agree without map bytes on the wire. - var themed = builder.Build(); + // from its id + the shared match seed, so host truth and guest rendering agree without + // map bytes on the wire. + var themed = builder.Build(lobby.Seed); _level = themed.Map; sandbags = themed.Sandbags; - _primarySpawn = themed.PlayerSpawn; - _secondarySpawn = themed.EnemySpawns[0]; _authoredPads = themed.Pads; // themed arenas may author pad pairs too break; default: @@ -177,8 +169,6 @@ public override void _Ready() new ArenaGenParams(dim, dim, seed, EnemyCount: 0, PickupCount: 0)); _level = layout.Map; sandbags = layout.Sandbags; - _primarySpawn = layout.PlayerSpawn; - _secondarySpawn = layout.Player2Spawn; break; } } @@ -186,8 +176,6 @@ public override void _Ready() { _level = LevelMap.Parse(Battlefield01.Text); sandbags = new bool[_level.Width, _level.Height]; - _primarySpawn = (_level.SpawnX, _level.SpawnY); - _secondarySpawn = GuestSpawn; } _grid = _level.BuildGrid(); @@ -382,12 +370,13 @@ private void OnWelcome(byte slot) _localSlot = slot; _roster = NetRoster.Build( NetworkSession.StartedLobby, slot, NetworkSession.ActiveCode, LobbyProtocol.MaxPlayers); - _spawns = SpawnTable.For(_level.Width, _level.Height, _primarySpawn, _secondarySpawn, - (x, y) => _arena.IsBlocked(CellCentre(x, y))); - // Shuffle spawn assignment with the shared match seed so no slot is nailed to the same cell every - // match (issue #4). Every peer derives the same spawn list + seed, so host and guest agree. + // Seeded random placement, min-distance separated: every peer derives the same spawn list from + // the shared match seed, so host truth and guest prediction agree — and the seed varies the + // cells each match (issue #4). Lava is drivable (IsBlocked false) yet lethal, so spawn + // eligibility excludes it explicitly; water already blocks movement. _matchSeed = NetworkSession.StartedLobby?.Seed ?? 0; - _spawns = Shuffled(_spawns, _matchSeed); + _spawns = SpawnTable.For(_level.Width, _level.Height, SpawnTable.MaxSpawns, _matchSeed, + (x, y) => _arena.IsBlocked(CellCentre(x, y)) || CellMaterials.IsLethal(_grid.GetCell(x, y).Material)); foreach (var seat in _roster) { _netStats.Register(seat.Slot, seat.Name, seat.Team); // both roles book the same rows @@ -1002,18 +991,4 @@ private void BuildGround() private static NVector2 CellCentre(int x, int y) => new(GridOrigin.X + ((x + 0.5f) * TileSize), GridOrigin.Y + ((y + 0.5f) * TileSize)); - - // Deterministic Fisher-Yates: same list + same seed → same order on every peer. - private static IReadOnlyList<(int X, int Y)> Shuffled(IReadOnlyList<(int X, int Y)> spawns, int seed) - { - var shuffled = new List<(int X, int Y)>(spawns); - var rng = new System.Random(seed); - for (var i = shuffled.Count - 1; i > 0; i--) - { - var j = rng.Next(i + 1); - (shuffled[i], shuffled[j]) = (shuffled[j], shuffled[i]); - } - - return shuffled; - } } diff --git a/client/tests/GameLogic/ArenaBuildersTests.cs b/client/tests/GameLogic/ArenaBuildersTests.cs index 04725f2..2b09973 100644 --- a/client/tests/GameLogic/ArenaBuildersTests.cs +++ b/client/tests/GameLogic/ArenaBuildersTests.cs @@ -23,7 +23,7 @@ public void EachRegisteredArena_BuildsAValidPlayableArena(string arenaId) { Assert.True(ArenaBuilders.TryGet(arenaId, out var builder), $"{arenaId} must have a builder"); - var layout = builder.Build(); + var layout = builder.Build(seed: 7); var grid = layout.Map.BuildGrid(); // Eight starts (player + seven enemies), all distinct and standing on open, non-blocked ground. @@ -193,7 +193,8 @@ public void Archipelago_IsFloorIslands_InAWaterSea_JoinedByBridgeCauseways() Assert.True(bridgeOverWater, "archipelago causeways must bridge over the water sea"); } - // Net sync: host and guest build independently, so two builds must be byte-identical terrain. + // Net sync: host and guest build independently from the shared match seed, so two builds with + // the same seed must be byte-identical terrain AND the same spawns. [Theory] [InlineData("Forest")] [InlineData("Volcano")] @@ -220,10 +221,66 @@ public void ThemedArena_IsDeterministic(string arenaId) } } - private static ArenaLayout BuildOrFail(string arenaId) + // The seed moves the spawns (no more fixed starts every match) but must never touch the terrain + // — the map id alone decides the level a net host and guest both build. + [Theory] + [InlineData("Forest")] + [InlineData("Volcano")] + [InlineData("City")] + [InlineData("Frozen")] + [InlineData("Canyon")] + [InlineData("Donut")] + [InlineData("Cross")] + [InlineData("Archipelago")] + public void ThemedArena_SeedMovesTheSpawns_ButNeverTheTerrain(string arenaId) + { + var a = BuildOrFail(arenaId, seed: 1); + var b = BuildOrFail(arenaId, seed: 2); + + Assert.NotEqual(Starts(a), Starts(b)); + for (var x = 0; x < a.Map.Width; x++) + { + for (var y = 0; y < a.Map.Height; y++) + { + Assert.Equal(a.Map.Materials[x, y], b.Map.Materials[x, y]); + Assert.Equal(a.Map.Bushes[x, y], b.Map.Bushes[x, y]); + } + } + } + + // Randomised placement must still keep tanks apart — at least the floor separation on every + // themed map, whatever the seed (the stuck-inside-each-other bug this scheme replaces). + [Theory] + [InlineData("Forest")] + [InlineData("Volcano")] + [InlineData("City")] + [InlineData("Frozen")] + [InlineData("Canyon")] + [InlineData("Donut")] + [InlineData("Cross")] + [InlineData("Archipelago")] + public void ThemedArena_KeepsSpawnsSeparated_ForAnySeed(string arenaId) + { + for (var seed = 0; seed < 5; seed++) + { + var starts = Starts(BuildOrFail(arenaId, seed)); + for (var i = 0; i < starts.Count; i++) + { + for (var j = i + 1; j < starts.Count; j++) + { + var d = System.Math.Max(System.Math.Abs(starts[i].X - starts[j].X), + System.Math.Abs(starts[i].Y - starts[j].Y)); + Assert.True(d >= SpawnTable.FloorSeparation, + $"{arenaId} seed {seed}: spawns {starts[i]} and {starts[j]} are only {d} apart"); + } + } + } + } + + private static ArenaLayout BuildOrFail(string arenaId, int seed = 7) { Assert.True(ArenaBuilders.TryGet(arenaId, out var builder), $"{arenaId} must have a builder"); - return builder.Build(); + return builder.Build(seed); } private static List<(int X, int Y)> Starts(ArenaLayout layout) diff --git a/client/tests/GameLogic/ArenaGeneratorTests.cs b/client/tests/GameLogic/ArenaGeneratorTests.cs index 3624a9f..971bfd3 100644 --- a/client/tests/GameLogic/ArenaGeneratorTests.cs +++ b/client/tests/GameLogic/ArenaGeneratorTests.cs @@ -353,6 +353,30 @@ public void Generate_AtDoubleSize_YieldsEightDistinctReachableSpawns_ThatValidat Assert.True(result.IsValid, string.Join(", ", result.Errors)); } + // Tank starts (player + player2 + enemies) must never be packed together — at least the + // SpawnTable floor separation apart, whatever the seed (the stuck-inside-each-other bug). + [Fact] + public void Generate_KeepsTankSpawnsSeparated_ForAnySeed() + { + for (var seed = 0; seed < 10; seed++) + { + var arena = new ArenaGenerator().Generate(new ArenaGenParams(32, 32, seed, EnemyCount: 6, PickupCount: 4)); + var starts = new List<(int X, int Y)> { arena.PlayerSpawn, arena.Player2Spawn }; + starts.AddRange(arena.EnemySpawns); + + for (var i = 0; i < starts.Count; i++) + { + for (var j = i + 1; j < starts.Count; j++) + { + var d = System.Math.Max(System.Math.Abs(starts[i].X - starts[j].X), + System.Math.Abs(starts[i].Y - starts[j].Y)); + Assert.True(d >= SpawnTable.FloorSeparation, + $"seed {seed}: starts {starts[i]} and {starts[j]} are only {d} apart"); + } + } + } + } + [Fact] public void Generate_AtDoubleSize_IsDeterministic_ForAGivenSeed() { diff --git a/client/tests/GameLogic/SpawnTableTests.cs b/client/tests/GameLogic/SpawnTableTests.cs index 23127a0..4245907 100644 --- a/client/tests/GameLogic/SpawnTableTests.cs +++ b/client/tests/GameLogic/SpawnTableTests.cs @@ -1,4 +1,6 @@ +using System; using System.Collections.Generic; +using System.Linq; using TankGame.GameLogic; using Xunit; @@ -6,64 +8,114 @@ namespace TankGame.Tests.GameLogic; public class SpawnTableTests { + private static int Chebyshev((int X, int Y) a, (int X, int Y) b) => + Math.Max(Math.Abs(a.X - b.X), Math.Abs(a.Y - b.Y)); + + private static int MinPairwise(IReadOnlyList<(int X, int Y)> spawns) + { + var min = int.MaxValue; + for (var i = 0; i < spawns.Count; i++) + { + for (var j = i + 1; j < spawns.Count; j++) + { + min = Math.Min(min, Chebyshev(spawns[i], spawns[j])); + } + } + + return min; + } + [Fact] - public void AnOpenField_YieldsEightDistinctSpawns_LedByTheDeclaredCells() + public void AnOpenField_MeetsTheTargetSeparation() { - var spawns = SpawnTable.For(30, 16, primary: (2, 7), secondary: (25, 7), (_, _) => false); + for (var seed = 0; seed < 10; seed++) + { + var spawns = SpawnTable.For(76, 46, SpawnTable.MaxSpawns, seed, (_, _) => false); - // The two declared cells lead; the rest are their reflections across the centre and both axes. - Assert.Equal(new List<(int X, int Y)> + Assert.Equal(8, spawns.Count); + Assert.Equal(8, new HashSet<(int, int)>(spawns).Count); + Assert.All(spawns, s => Assert.True(s.X is >= 0 and < 76 && s.Y is >= 0 and < 46)); + Assert.True(MinPairwise(spawns) >= SpawnTable.TargetSeparation, + $"seed {seed}: pairwise separation {MinPairwise(spawns)} is under the target"); + } + } + + [Fact] + public void ACrampedField_RelaxesGradually_ButNeverUnderTheFloor() + { + // 12x12 cannot seat eight tanks ten apart; the placement must relax — yet never under three. + for (var seed = 0; seed < 10; seed++) { - (2, 7), (25, 7), (27, 8), (4, 8), (2, 8), (27, 7), (25, 8), (4, 7), - }, spawns); + var spawns = SpawnTable.For(12, 12, 8, seed, (_, _) => false); + + Assert.Equal(8, spawns.Count); + Assert.Equal(8, new HashSet<(int, int)>(spawns).Count); + Assert.True(MinPairwise(spawns) >= SpawnTable.FloorSeparation, + $"seed {seed}: pairwise separation {MinPairwise(spawns)} is under the floor"); + } } [Fact] - public void EightPlayers_AllGetADistinctOpenCell_EvenWhenCandidatesCollide() + public void ATinyField_BestEffort_StillKeepsEverySpawnOnItsOwnCell() { - // A tight field where several reflections land on the same cell: the taken-set still nudges - // each to its own open cell, so eight tanks never share a spawn. - var spawns = SpawnTable.For(8, 8, primary: (1, 1), secondary: (6, 6), (_, _) => false); + // 3x3 can never give separation three, but its nine cells still seat eight tanks distinctly. + var spawns = SpawnTable.For(3, 3, 8, seed: 5, (_, _) => false); Assert.Equal(8, spawns.Count); - Assert.Equal(8, new HashSet<(int, int)>(spawns).Count); // all distinct - Assert.All(spawns, s => Assert.True(s.X is >= 0 and < 8 && s.Y is >= 0 and < 8)); + Assert.Equal(8, new HashSet<(int, int)>(spawns).Count); + Assert.All(spawns, s => Assert.True(s.X is >= 0 and < 3 && s.Y is >= 0 and < 3)); } [Fact] - public void ABlockedCandidate_NudgesToTheNearestOpenCell() + public void AFieldSmallerThanTheSpawnCount_IsTheOnlyTimeCellsAreShared() { - var blocked = new HashSet<(int, int)> { (2, 7) }; + var spawns = SpawnTable.For(2, 2, 8, seed: 5, (_, _) => false); + + Assert.Equal(8, spawns.Count); + Assert.Equal(4, new HashSet<(int, int)>(spawns).Count); // all four cells used, then reused + Assert.All(spawns, s => Assert.True(s.X is >= 0 and < 2 && s.Y is >= 0 and < 2)); + } - var spawns = SpawnTable.For(30, 16, primary: (2, 7), secondary: (25, 7), - (x, y) => blocked.Contains((x, y))); + [Fact] + public void BlockedCells_AreNeverChosen_WhileOpenOnesRemain() + { + // The caller's predicate covers walls AND deadly terrain (lava/water); with the whole left + // half blocked, every spawn must sit in the open right half. + for (var seed = 0; seed < 10; seed++) + { + var spawns = SpawnTable.For(40, 40, 8, seed, (x, _) => x < 20); - Assert.NotEqual((2, 7), spawns[0]); - var (dx, dy) = (spawns[0].X - 2, spawns[0].Y - 7); - Assert.True(System.Math.Max(System.Math.Abs(dx), System.Math.Abs(dy)) == 1, - $"the nudge must land on an adjacent cell; landed {spawns[0]}"); - Assert.Equal((25, 7), spawns[1]); // the open candidates never move + Assert.Equal(8, spawns.Count); + Assert.All(spawns, s => Assert.True(s.X >= 20, $"seed {seed}: spawn {s} sits on a blocked cell")); + } } [Fact] - public void AFullyBlockedField_StillYieldsDistinctSpawns_RatherThanDuplicates() + public void AFullyBlockedField_StillYieldsDistinctInBoundsSpawns() { - // Every cell blocked: the ring search finds nothing, so the fallthrough must still hand out - // eight DISTINCT cells — two tanks sharing one spawn is worse than a spawn on a blocked tile. - var spawns = SpawnTable.For(8, 8, primary: (1, 1), secondary: (6, 6), (_, _) => true); + // Nowhere is open: distinctness still beats openness — two tanks must never share a cell. + var spawns = SpawnTable.For(8, 8, 8, seed: 3, (_, _) => true); Assert.Equal(8, spawns.Count); Assert.Equal(8, new HashSet<(int, int)>(spawns).Count); + Assert.All(spawns, s => Assert.True(s.X is >= 0 and < 8 && s.Y is >= 0 and < 8)); + } + + [Fact] + public void TheSameSeed_YieldsTheSameSpawns() + { + var a = SpawnTable.For(76, 46, 8, seed: 42, (_, _) => false); + var b = SpawnTable.For(76, 46, 8, seed: 42, (_, _) => false); + + Assert.Equal(a, b); } [Fact] - public void ACandidateOutsideTheGrid_IsPulledInBounds() + public void DifferentSeeds_YieldDifferentSpawns() { - // A mirrored spawn can land out of bounds on an asymmetric level; the ring search only - // accepts in-bounds cells. - var spawns = SpawnTable.For(10, 10, primary: (0, 0), secondary: (12, 5), (_, _) => false); + var a = SpawnTable.For(76, 46, 8, seed: 1, (_, _) => false); + var b = SpawnTable.For(76, 46, 8, seed: 2, (_, _) => false); - Assert.All(spawns, s => Assert.True( - s.X is >= 0 and < 10 && s.Y is >= 0 and < 10, $"spawn {s} must be in bounds")); + Assert.NotEqual(a.ToList(), b.ToList()); } } diff --git a/client/tests/Presentation/NetArena3DSceneTests.cs b/client/tests/Presentation/NetArena3DSceneTests.cs index a604d97..7cfbff8 100644 --- a/client/tests/Presentation/NetArena3DSceneTests.cs +++ b/client/tests/Presentation/NetArena3DSceneTests.cs @@ -649,7 +649,8 @@ public void CliffsNetMatch_BuildsTheCrossLayerPadRings() // End-to-end host-side warp: a guest tank relayed onto the valley pad (cell 2,2) must come out // on the plateau pad (cell 22,18) — across the map AND one layer up — through the authoritative - // world alone. Deterministic: every seat is human, so no AI moves or shoots. + // world alone. Deterministic: every seat is human, so no AI moves or shoots, and the seeded + // spawn placement is fixed per lobby seed. [Test] public void CliffsNetHost_WarpsARelayedTank_AcrossTheMapAndUpALayer() { @@ -661,34 +662,37 @@ public void CliffsNetHost_WarpsARelayedTank_AcrossTheMapAndUpALayer() try { - // The spawn shuffle is seeded by the lobby seed, so WHICH slot starts at the pad-adjacent - // primary spawn (1,1) is fixed but opaque — find it. It must be a guest (relayed input); - // if a shuffle change ever hands it to slot 0, pick a different seed above. + // Spawns are seeded-random, so WHICH guest starts nearest the valley pad is fixed per + // seed but opaque — find it and drive it. The host (slot 0) has no relayed input, so it + // is excluded; if a placement change ever strands every guest behind a wall on the way + // to the pad, pick a different seed above. + var padA = NetCellCentre(2, 2); byte? padSlot = null; + var best = float.MaxValue; foreach (var (slot, seated) in scene.Tanks) { - if (seated.Position == NetCellCentre(1, 1)) + var d = System.Numerics.Vector2.Distance(seated.Position, padA); + if (slot != 0 && d < best) { + best = d; padSlot = slot; } } if (padSlot is not byte driven) { - throw new Exception("Expected a tank on the Cliffs primary spawn (1,1)."); + throw new Exception("Expected at least one guest tank to drive onto the pad."); } - if (driven == 0) - { - throw new Exception("Seed 1 put the HOST on the pad-adjacent spawn — choose another seed."); - } - - // Drive diagonally from (1,1) toward the valley pad at (2,2); ~6 ticks reach its trigger - // radius, the warp fires inside World.Step, and the remaining ticks roll on the plateau. - _transport.DeliverInput(new InputFrame(Seq: 1, MoveX: 0.707f, MoveY: 0.707f, Aim: 0f, - Buttons: 0, Slot: driven)); - for (var i = 0; i < 12; i++) + // Home toward the valley pad, re-aiming each tick; the warp fires inside World.Step the + // moment the tank enters the pad's trigger radius, lifting it to the plateau layer. + uint seq = 0; + for (var i = 0; i < 400 && scene.Tanks[driven].Layer != 1; i++) { + var pos = scene.Tanks[driven].Position; + var dir = System.Numerics.Vector2.Normalize(padA - pos); + _transport.DeliverInput(new InputFrame(Seq: ++seq, MoveX: dir.X, MoveY: dir.Y, + Aim: 0f, Buttons: 0, Slot: driven)); scene.Tick(0.05f); }