diff --git a/OpenRA.Game/Player.cs b/OpenRA.Game/Player.cs index c2537e016a..1938382181 100644 --- a/OpenRA.Game/Player.cs +++ b/OpenRA.Game/Player.cs @@ -17,6 +17,7 @@ using Eluant.ObjectBinding; using OpenRA.Network; using OpenRA.Primitives; using OpenRA.Scripting; +using OpenRA.Support; using OpenRA.Traits; using OpenRA.Widgets; @@ -100,19 +101,19 @@ namespace OpenRA readonly StanceColors stanceColors; - static FactionInfo ChooseFaction(World world, string name, bool requireSelectable = true) + public static FactionInfo ResolveFaction(string factionName, IEnumerable factionInfos, MersenneTwister playerRandom, bool requireSelectable = true) { - var selectableFactions = world.Map.Rules.Actors["world"].TraitInfos() + var selectableFactions = factionInfos .Where(f => !requireSelectable || f.Selectable) .ToList(); - var selected = selectableFactions.FirstOrDefault(f => f.InternalName == name) - ?? selectableFactions.Random(world.SharedRandom); + var selected = selectableFactions.FirstOrDefault(f => f.InternalName == factionName) + ?? selectableFactions.Random(playerRandom); // Don't loop infinite for (var i = 0; i <= 10 && selected.RandomFactionMembers.Any(); i++) { - var faction = selected.RandomFactionMembers.Random(world.SharedRandom); + var faction = selected.RandomFactionMembers.Random(playerRandom); selected = selectableFactions.FirstOrDefault(f => f.InternalName == faction); if (selected == null) @@ -122,7 +123,13 @@ namespace OpenRA return selected; } - static FactionInfo ChooseDisplayFaction(World world, string factionName) + static FactionInfo ResolveFaction(World world, string factionName, MersenneTwister playerRandom, bool requireSelectable) + { + var factionInfos = world.Map.Rules.Actors["world"].TraitInfos(); + return ResolveFaction(factionName, factionInfos, playerRandom, requireSelectable); + } + + static FactionInfo ResolveDisplayFaction(World world, string factionName) { var factions = world.Map.Rules.Actors["world"].TraitInfos().ToArray(); @@ -141,7 +148,7 @@ namespace OpenRA return client.Name; } - public Player(World world, Session.Client client, PlayerReference pr) + public Player(World world, Session.Client client, PlayerReference pr, MersenneTwister playerRandom) { World = world; InternalName = pr.Name; @@ -157,11 +164,11 @@ namespace OpenRA PlayerName = ResolvePlayerName(client, world.LobbyInfo.Clients, world.Map.Rules.Actors["player"].TraitInfos()); BotType = client.Bot; - Faction = ChooseFaction(world, client.Faction, !pr.LockFaction); - DisplayFaction = ChooseDisplayFaction(world, client.Faction); + Faction = ResolveFaction(world, client.Faction, playerRandom, !pr.LockFaction); + DisplayFaction = ResolveDisplayFaction(world, client.Faction); var assignSpawnPoints = world.WorldActor.TraitOrDefault(); - HomeLocation = assignSpawnPoints?.AssignHomeLocation(world, client) ?? pr.HomeLocation; + HomeLocation = assignSpawnPoints?.AssignHomeLocation(world, client, playerRandom) ?? pr.HomeLocation; SpawnPoint = assignSpawnPoints?.SpawnPointForPlayer(this) ?? client.SpawnPoint; DisplaySpawnPoint = client.SpawnPoint; } @@ -175,8 +182,8 @@ namespace OpenRA Playable = pr.Playable; Spectating = pr.Spectating; BotType = pr.Bot; - Faction = ChooseFaction(world, pr.Faction, false); - DisplayFaction = ChooseDisplayFaction(world, pr.Faction); + Faction = ResolveFaction(world, pr.Faction, playerRandom, false); + DisplayFaction = ResolveDisplayFaction(world, pr.Faction); HomeLocation = pr.HomeLocation; SpawnPoint = DisplaySpawnPoint = 0; } diff --git a/OpenRA.Game/Server/Server.cs b/OpenRA.Game/Server/Server.cs index f7d46d2112..b459fcd82d 100644 --- a/OpenRA.Game/Server/Server.cs +++ b/OpenRA.Game/Server/Server.cs @@ -1203,8 +1203,9 @@ namespace OpenRA.Server // HACK: NonCombatant and non-Playable players are set to null to simplify replay tracking // The null padding is needed to keep the player indexes in sync with world.Players on the clients // This will need to change if future code wants to use worldPlayers for other purposes + var playerRandom = new MersenneTwister(LobbyInfo.GlobalSettings.RandomSeed); foreach (var cmpi in Map.Rules.Actors["world"].TraitInfos()) - cmpi.CreateServerPlayers(Map, LobbyInfo, worldPlayers); + cmpi.CreateServerPlayers(Map, LobbyInfo, worldPlayers, playerRandom); if (recorder != null) { diff --git a/OpenRA.Game/Traits/TraitsInterfaces.cs b/OpenRA.Game/Traits/TraitsInterfaces.cs index d56e21f9f7..79ce28ef36 100644 --- a/OpenRA.Game/Traits/TraitsInterfaces.cs +++ b/OpenRA.Game/Traits/TraitsInterfaces.cs @@ -18,6 +18,7 @@ using OpenRA.GameRules; using OpenRA.Graphics; using OpenRA.Network; using OpenRA.Primitives; +using OpenRA.Support; namespace OpenRA.Traits { @@ -365,21 +366,28 @@ namespace OpenRA.Traits } [RequireExplicitImplementation] - public interface ICreatePlayers { void CreatePlayers(World w); } + public interface ICreatePlayers { void CreatePlayers(World w, MersenneTwister playerRandom); } [RequireExplicitImplementation] public interface ICreatePlayersInfo : ITraitInfoInterface { - void CreateServerPlayers(MapPreview map, Session lobbyInfo, List players); + void CreateServerPlayers(MapPreview map, Session lobbyInfo, List players, MersenneTwister playerRandom); } [RequireExplicitImplementation] public interface IAssignSpawnPoints { - CPos AssignHomeLocation(World world, Session.Client client); + CPos AssignHomeLocation(World world, Session.Client client, MersenneTwister playerRandom); int SpawnPointForPlayer(Player player); } + [RequireExplicitImplementation] + public interface IAssignSpawnPointsInfo : ITraitInfoInterface + { + object InitializeState(MapPreview map, Session lobbyInfo); + int AssignSpawnPoint(object state, Session lobbyInfo, Session.Client client, MersenneTwister playerRandom); + } + public interface IBotInfo : ITraitInfoInterface { string Type { get; } diff --git a/OpenRA.Game/World.cs b/OpenRA.Game/World.cs index 59a0e536f6..1e0255ba68 100644 --- a/OpenRA.Game/World.cs +++ b/OpenRA.Game/World.cs @@ -211,8 +211,10 @@ namespace OpenRA LongBitSet.Reset(); // Add players + // Create an isolated RNG to simplify synchronization between client and server player faction/spawn assignments + var playerRandom = new MersenneTwister(orderManager.LobbyInfo.GlobalSettings.RandomSeed); foreach (var cmp in WorldActor.TraitsImplementing()) - cmp.CreatePlayers(this); + cmp.CreatePlayers(this, playerRandom); // Set defaults for any unset stances foreach (var p in Players) diff --git a/OpenRA.Mods.Common/Traits/World/CreateMPPlayers.cs b/OpenRA.Mods.Common/Traits/World/CreateMPPlayers.cs index 7f4fc45100..06906180fc 100644 --- a/OpenRA.Mods.Common/Traits/World/CreateMPPlayers.cs +++ b/OpenRA.Mods.Common/Traits/World/CreateMPPlayers.cs @@ -13,6 +13,7 @@ using System; using System.Collections.Generic; using System.Linq; using OpenRA.Network; +using OpenRA.Support; using OpenRA.Traits; namespace OpenRA.Mods.Common.Traits @@ -24,14 +25,22 @@ namespace OpenRA.Mods.Common.Traits /// Returns a list of GameInformation.Players that matches the indexing of ICreatePlayers.CreatePlayers. /// Non-playable players appear as null in the list. /// - void ICreatePlayersInfo.CreateServerPlayers(MapPreview map, Session lobbyInfo, List players) + void ICreatePlayersInfo.CreateServerPlayers(MapPreview map, Session lobbyInfo, List players, MersenneTwister playerRandom) { + var worldInfo = map.Rules.Actors["world"]; + var factions = worldInfo.TraitInfos().ToArray(); + var assignSpawnLocations = worldInfo.TraitInfoOrDefault(); + var spawnState = assignSpawnLocations?.InitializeState(map, lobbyInfo); + // Create the unplayable map players -- neutral, shellmap, scripted, etc. foreach (var p in map.Players.Players.Where(p => !p.Value.Playable)) + { + // We need to resolve the faction, even though we don't use it, to match the RNG state with clients + Player.ResolveFaction(p.Value.Faction, factions, playerRandom, false); players.Add(null); + } // Create the regular playable players. - var factions = map.Rules.Actors["world"].TraitInfos().ToArray(); var bots = map.Rules.Actors["player"].TraitInfos().ToArray(); foreach (var kv in lobbyInfo.Slots) @@ -41,19 +50,19 @@ namespace OpenRA.Mods.Common.Traits continue; var clientFaction = factions.First(f => client.Faction == f.InternalName); - - // TODO: Resolve random SpawnPoint and Faction to real values + var resolvedFaction = Player.ResolveFaction(client.Faction, factions, playerRandom, !kv.Value.LockFaction); + var resolvedSpawnPoint = assignSpawnLocations?.AssignSpawnPoint(spawnState, lobbyInfo, client, playerRandom) ?? 0; var player = new GameInformation.Player { ClientIndex = client.Index, Name = Player.ResolvePlayerName(client, lobbyInfo.Clients, bots), IsHuman = client.Bot == null, IsBot = client.Bot != null, - FactionName = clientFaction.Name, - FactionId = clientFaction.InternalName, + FactionName = resolvedFaction.Name, + FactionId = resolvedFaction.InternalName, Color = client.Color, Team = client.Team, - SpawnPoint = client.SpawnPoint, + SpawnPoint = resolvedSpawnPoint, IsRandomFaction = clientFaction.RandomFactionMembers.Any(), IsRandomSpawnPoint = client.SpawnPoint == 0, Fingerprint = client.Fingerprint, @@ -63,13 +72,15 @@ namespace OpenRA.Mods.Common.Traits } // Create a player that is allied with everyone for shared observer shroud. + // We need to resolve the faction, even though we don't use it, to match the RNG state with clients + Player.ResolveFaction("Random", factions, playerRandom, false); players.Add(null); } } public class CreateMPPlayers : ICreatePlayers { - void ICreatePlayers.CreatePlayers(World w) + void ICreatePlayers.CreatePlayers(World w, MersenneTwister playerRandom) { var players = new MapPlayers(w.Map.PlayerDefinitions).Players; var worldPlayers = new List(); @@ -78,7 +89,7 @@ namespace OpenRA.Mods.Common.Traits // Create the unplayable map players -- neutral, shellmap, scripted, etc. foreach (var kv in players.Where(p => !p.Value.Playable)) { - var player = new Player(w, null, kv.Value); + var player = new Player(w, null, kv.Value, playerRandom); worldPlayers.Add(player); if (kv.Value.OwnsWorld) @@ -100,7 +111,7 @@ namespace OpenRA.Mods.Common.Traits if (client == null) continue; - var player = new Player(w, client, players[kv.Value.PlayerReference]); + var player = new Player(w, client, players[kv.Value.PlayerReference], playerRandom); worldPlayers.Add(player); if (client.Index == Game.LocalClientId) @@ -115,7 +126,7 @@ namespace OpenRA.Mods.Common.Traits Spectating = true, Faction = "Random", Allies = worldPlayers.Where(p => !p.NonCombatant && p.Playable).Select(p => p.InternalName).ToArray() - })); + }, playerRandom)); w.SetPlayers(worldPlayers, localPlayer); diff --git a/OpenRA.Mods.Common/Traits/World/EditorActorLayer.cs b/OpenRA.Mods.Common/Traits/World/EditorActorLayer.cs index 476e03679b..235594df58 100644 --- a/OpenRA.Mods.Common/Traits/World/EditorActorLayer.cs +++ b/OpenRA.Mods.Common/Traits/World/EditorActorLayer.cs @@ -16,6 +16,7 @@ using OpenRA.Graphics; using OpenRA.Mods.Common.Traits.Render; using OpenRA.Network; using OpenRA.Primitives; +using OpenRA.Support; using OpenRA.Traits; namespace OpenRA.Mods.Common.Traits @@ -26,7 +27,7 @@ namespace OpenRA.Mods.Common.Traits [Desc("Size of partition bins (world pixels)")] public readonly int BinSize = 250; - void ICreatePlayersInfo.CreateServerPlayers(MapPreview map, Session lobbyInfo, List players) + void ICreatePlayersInfo.CreateServerPlayers(MapPreview map, Session lobbyInfo, List players, MersenneTwister playerRandom) { throw new NotImplementedException("EditorActorLayer must not be defined on the world actor"); } @@ -50,7 +51,7 @@ namespace OpenRA.Mods.Common.Traits this.info = info; } - void ICreatePlayers.CreatePlayers(World w) + void ICreatePlayers.CreatePlayers(World w, MersenneTwister playerRandom) { if (w.Type != WorldType.Editor) return; @@ -58,7 +59,7 @@ namespace OpenRA.Mods.Common.Traits Players = new MapPlayers(w.Map.PlayerDefinitions); var worldOwner = Players.Players.Select(kvp => kvp.Value).First(p => !p.Playable && p.OwnsWorld); - w.SetWorldOwner(new Player(w, null, worldOwner)); + w.SetWorldOwner(new Player(w, null, worldOwner, playerRandom)); } public void WorldLoaded(World world, WorldRenderer wr) diff --git a/OpenRA.Mods.Common/Traits/World/MPStartLocations.cs b/OpenRA.Mods.Common/Traits/World/MPStartLocations.cs index ce7e009eb2..a91e1aeaf8 100644 --- a/OpenRA.Mods.Common/Traits/World/MPStartLocations.cs +++ b/OpenRA.Mods.Common/Traits/World/MPStartLocations.cs @@ -13,12 +13,13 @@ using System.Collections.Generic; using System.Linq; using OpenRA.Graphics; using OpenRA.Network; +using OpenRA.Support; using OpenRA.Traits; namespace OpenRA.Mods.Common.Traits { [Desc("Allows the map to have working spawnpoints. Also controls the 'Separate Team Spawns' checkbox in the lobby options.")] - public class MPStartLocationsInfo : TraitInfo, ILobbyOptions + public class MPStartLocationsInfo : TraitInfo, ILobbyOptions, IAssignSpawnPointsInfo { public readonly WDist InitialExploreRange = WDist.FromCells(5); @@ -55,6 +56,52 @@ namespace OpenRA.Mods.Common.Traits SeparateTeamSpawnsCheckboxEnabled, SeparateTeamSpawnsCheckboxLocked); } + + class AssignSpawnLocationsState + { + public CPos[] SpawnLocations; + public List AvailableSpawnPoints; + public readonly Dictionary OccupiedSpawnPoints = new Dictionary(); + } + + object IAssignSpawnPointsInfo.InitializeState(MapPreview map, Session lobbyInfo) + { + var state = new AssignSpawnLocationsState(); + + // Initialize the list of unoccupied spawn points for AssignSpawnLocations to pick from + state.SpawnLocations = map.SpawnPoints; + state.AvailableSpawnPoints = Enumerable.Range(1, map.SpawnPoints.Length).ToList(); + foreach (var kv in lobbyInfo.Slots) + { + var client = lobbyInfo.ClientInSlot(kv.Key); + if (client == null || client.SpawnPoint == 0) + continue; + + state.AvailableSpawnPoints.Remove(client.SpawnPoint); + state.OccupiedSpawnPoints.Add(client.SpawnPoint, client); + } + + return state; + } + + int IAssignSpawnPointsInfo.AssignSpawnPoint(object stateObject, Session lobbyInfo, Session.Client client, MersenneTwister playerRandom) + { + var state = (AssignSpawnLocationsState)stateObject; + var separateTeamSpawns = lobbyInfo.GlobalSettings.OptionOrDefault("separateteamspawns", SeparateTeamSpawnsCheckboxEnabled); + + if (client.SpawnPoint > 0 && client.SpawnPoint <= state.SpawnLocations.Length) + return client.SpawnPoint; + + var spawnPoint = state.OccupiedSpawnPoints.Count == 0 || !separateTeamSpawns + ? state.AvailableSpawnPoints.Random(playerRandom) + : state.AvailableSpawnPoints // pick the most distant spawnpoint from everyone else + .Select(s => (Cell: state.SpawnLocations[s - 1], Index: s)) + .MaxBy(s => state.OccupiedSpawnPoints.Sum(kv => (state.SpawnLocations[kv.Key - 1] - s.Cell).LengthSquared)).Index; + + state.AvailableSpawnPoints.Remove(spawnPoint); + state.OccupiedSpawnPoints.Add(spawnPoint, client); + return spawnPoint; + } } public class MPStartLocations : IWorldLoaded, INotifyCreated, IAssignSpawnPoints @@ -95,13 +142,13 @@ namespace OpenRA.Mods.Common.Traits } } - CPos IAssignSpawnPoints.AssignHomeLocation(World world, Session.Client client) + CPos IAssignSpawnPoints.AssignHomeLocation(World world, Session.Client client, MersenneTwister playerRandom) { if (client.SpawnPoint > 0 && client.SpawnPoint <= spawnLocations.Length) return spawnLocations[client.SpawnPoint - 1]; var spawnPoint = occupiedSpawnPoints.Count == 0 || !separateTeamSpawns - ? availableSpawnPoints.Random(world.SharedRandom) + ? availableSpawnPoints.Random(playerRandom) : availableSpawnPoints // pick the most distant spawnpoint from everyone else .Select(s => (Cell: spawnLocations[s - 1], Index: s)) .MaxBy(s => occupiedSpawnPoints.Sum(kv => (spawnLocations[kv.Key - 1] - s.Cell).LengthSquared)).Index; diff --git a/OpenRA.Mods.Common/Widgets/Logic/ReplayBrowserLogic.cs b/OpenRA.Mods.Common/Widgets/Logic/ReplayBrowserLogic.cs index 60d416ec11..94f0949369 100644 --- a/OpenRA.Mods.Common/Widgets/Logic/ReplayBrowserLogic.cs +++ b/OpenRA.Mods.Common/Widgets/Logic/ReplayBrowserLogic.cs @@ -77,7 +77,15 @@ namespace OpenRA.Mods.Common.Widgets.Logic panel.Get("REPLAY_INFO").IsVisible = () => selectedReplay != null; var spawnOccupants = new CachedTransform>(r => - r.GameInfo.Players.ToDictionary(c => c.SpawnPoint, c => new SpawnOccupant(c))); + { + // Avoid using .ToDictionary to improve robustness against replays defining duplicate spawn assignments + var occupants = new Dictionary(); + foreach (var p in r.GameInfo.Players) + if (p.SpawnPoint != 0) + occupants[p.SpawnPoint] = new SpawnOccupant(p); + + return occupants; + }); Ui.LoadWidget("MAP_PREVIEW", mapPreviewRoot, new WidgetArgs {