first commit
This commit is contained in:
402
NitroxServer/GameLogic/Bases/BuildingManager.cs
Normal file
402
NitroxServer/GameLogic/Bases/BuildingManager.cs
Normal file
@@ -0,0 +1,402 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using NitroxModel.DataStructures;
|
||||
using NitroxModel.DataStructures.GameLogic;
|
||||
using NitroxModel.DataStructures.GameLogic.Bases;
|
||||
using NitroxModel.DataStructures.GameLogic.Entities;
|
||||
using NitroxModel.DataStructures.GameLogic.Entities.Bases;
|
||||
using NitroxModel.Packets;
|
||||
using NitroxModel.Serialization;
|
||||
using NitroxServer.GameLogic.Entities;
|
||||
|
||||
namespace NitroxServer.GameLogic.Bases;
|
||||
|
||||
public class BuildingManager
|
||||
{
|
||||
private readonly EntityRegistry entityRegistry;
|
||||
private readonly WorldEntityManager worldEntityManager;
|
||||
private readonly SubnauticaServerConfig config;
|
||||
|
||||
public BuildingManager(EntityRegistry entityRegistry, WorldEntityManager worldEntityManager, SubnauticaServerConfig config)
|
||||
{
|
||||
this.entityRegistry = entityRegistry;
|
||||
this.worldEntityManager = worldEntityManager;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
public bool AddGhost(PlaceGhost placeGhost)
|
||||
{
|
||||
GhostEntity ghostEntity = placeGhost.GhostEntity;
|
||||
if (ghostEntity.ParentId == null)
|
||||
{
|
||||
if (entityRegistry.GetEntityById(ghostEntity.Id).HasValue)
|
||||
{
|
||||
Log.Error($"Trying to add a ghost to Global Root but another entity with the same id already exists (GhostId: {ghostEntity.Id})");
|
||||
return false;
|
||||
}
|
||||
|
||||
worldEntityManager.AddOrUpdateGlobalRootEntity(ghostEntity);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!entityRegistry.TryGetEntityById(ghostEntity.ParentId, out Entity parentEntity))
|
||||
{
|
||||
Log.Error($"Trying to add a ghost to a build that isn't registered (ParentId: {ghostEntity.ParentId})");
|
||||
return false;
|
||||
}
|
||||
if (parentEntity is not BuildEntity)
|
||||
{
|
||||
Log.Error($"Trying to add a ghost to an entity that is not a building (ParentId: {ghostEntity.ParentId})");
|
||||
return false;
|
||||
}
|
||||
if (parentEntity.ChildEntities.Any(childEntity => childEntity.Id.Equals(ghostEntity.Id)))
|
||||
{
|
||||
Log.Error($"Trying to add a ghost to a building but another child with the same id already exists (GhostId: {ghostEntity.Id})");
|
||||
return false;
|
||||
}
|
||||
|
||||
worldEntityManager.AddOrUpdateGlobalRootEntity(ghostEntity);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool AddModule(PlaceModule placeModule)
|
||||
{
|
||||
ModuleEntity moduleEntity = placeModule.ModuleEntity;
|
||||
if (moduleEntity.ParentId == null)
|
||||
{
|
||||
if (entityRegistry.GetEntityById(moduleEntity.Id).HasValue)
|
||||
{
|
||||
Log.Error($"Trying to add a module to Global Root but another entity with the same id already exists ({moduleEntity.Id})");
|
||||
return false;
|
||||
}
|
||||
|
||||
worldEntityManager.AddOrUpdateGlobalRootEntity(moduleEntity);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!entityRegistry.TryGetEntityById(moduleEntity.ParentId, out Entity parentEntity))
|
||||
{
|
||||
Log.Error($"Trying to add a module to a build that isn't registered (ParentId: {moduleEntity.ParentId})");
|
||||
return false;
|
||||
}
|
||||
if (parentEntity is not BuildEntity && parentEntity is not VehicleWorldEntity)
|
||||
{
|
||||
Log.Error($"Trying to add a module to an entity that is not a building/vehicle (ParentId: {moduleEntity.ParentId})");
|
||||
return false;
|
||||
}
|
||||
if (parentEntity.ChildEntities.Any(childEntity => childEntity.Id.Equals(moduleEntity.Id)))
|
||||
{
|
||||
Log.Error($"Trying to add a module to a building but another child with the same id already exists (ModuleId: {moduleEntity.Id})");
|
||||
return false;
|
||||
}
|
||||
|
||||
worldEntityManager.AddOrUpdateGlobalRootEntity(moduleEntity);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ModifyConstructedAmount(ModifyConstructedAmount modifyConstructedAmount)
|
||||
{
|
||||
if (!entityRegistry.TryGetEntityById(modifyConstructedAmount.GhostId, out Entity entity))
|
||||
{
|
||||
Log.Error($"Trying to modify the constructed amount of a non-registered object (GhostId: {modifyConstructedAmount.GhostId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Certain entities with a Constructable are just "regular" WorldEntities (e.g. starship boxes) and for simplicity we'll just not persist their progress
|
||||
// since their only use is to be deconstructed to give materials to players
|
||||
if (entity is not GhostEntity && entity is not ModuleEntity)
|
||||
{
|
||||
// In case the entity was fully deconstructed
|
||||
if (modifyConstructedAmount.ConstructedAmount == 0f)
|
||||
{
|
||||
if (entity is GlobalRootEntity)
|
||||
{
|
||||
worldEntityManager.RemoveGlobalRootEntity(entity.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
entityRegistry.RemoveEntity(entity.Id);
|
||||
}
|
||||
}
|
||||
|
||||
// In any case we'll broadcast the packet
|
||||
return true;
|
||||
}
|
||||
if (modifyConstructedAmount.ConstructedAmount == 0f)
|
||||
{
|
||||
worldEntityManager.RemoveGlobalRootEntity(entity.Id);
|
||||
return true;
|
||||
}
|
||||
|
||||
switch (entity)
|
||||
{
|
||||
case GhostEntity ghostEntity:
|
||||
ghostEntity.ConstructedAmount = modifyConstructedAmount.ConstructedAmount;
|
||||
break;
|
||||
case ModuleEntity moduleEntity:
|
||||
moduleEntity.ConstructedAmount = modifyConstructedAmount.ConstructedAmount;
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool CreateBase(PlaceBase placeBase)
|
||||
{
|
||||
if (!entityRegistry.TryGetEntityById(placeBase.FormerGhostId, out Entity entity))
|
||||
{
|
||||
Log.Error($"Trying to place a base from a non-registered ghost (Id: {placeBase.FormerGhostId})");
|
||||
return false;
|
||||
}
|
||||
if (entity is not GhostEntity)
|
||||
{
|
||||
Log.Error($"Trying to add a new build to Global Root but another build with the same id already exists (GhostId: {placeBase.FormerGhostId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
worldEntityManager.RemoveGlobalRootEntity(entity.Id);
|
||||
worldEntityManager.AddOrUpdateGlobalRootEntity(placeBase.BuildEntity);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool UpdateBase(Player player, UpdateBase updateBase, out int operationId)
|
||||
{
|
||||
if (!entityRegistry.TryGetEntityById<GhostEntity>(updateBase.FormerGhostId, out _))
|
||||
{
|
||||
Log.Error($"Trying to place a base from a non-registered ghost (GhostId: {updateBase.FormerGhostId})");
|
||||
operationId = -1;
|
||||
return false;
|
||||
}
|
||||
if (!entityRegistry.TryGetEntityById(updateBase.BaseId, out BuildEntity buildEntity))
|
||||
{
|
||||
Log.Error($"Trying to update a non-registered build (BaseId: {updateBase.BaseId})");
|
||||
operationId = -1;
|
||||
return false;
|
||||
}
|
||||
int deltaOperations = buildEntity.OperationId + 1 - updateBase.OperationId;
|
||||
if (deltaOperations != 0 && config.SafeBuilding)
|
||||
{
|
||||
Log.Warn($"Ignoring an {nameof(UpdateBase)} packet from [{player.Name}] which is {Math.Abs(deltaOperations) + (deltaOperations > 0 ? " operations ahead" : " operations late")}");
|
||||
NotifyPlayerDesync(player);
|
||||
operationId = -1;
|
||||
return false;
|
||||
}
|
||||
|
||||
worldEntityManager.RemoveGlobalRootEntity(updateBase.FormerGhostId);
|
||||
buildEntity.BaseData = updateBase.BaseData;
|
||||
|
||||
foreach (KeyValuePair<NitroxId, NitroxBaseFace> updatedChild in updateBase.UpdatedChildren)
|
||||
{
|
||||
if (entityRegistry.TryGetEntityById(updatedChild.Key, out InteriorPieceEntity childEntity))
|
||||
{
|
||||
childEntity.BaseFace = updatedChild.Value;
|
||||
}
|
||||
}
|
||||
foreach (KeyValuePair<NitroxId, NitroxInt3> updatedMoonpool in updateBase.UpdatedMoonpools)
|
||||
{
|
||||
if (entityRegistry.TryGetEntityById(updatedMoonpool.Key, out MoonpoolEntity childEntity))
|
||||
{
|
||||
childEntity.Cell = updatedMoonpool.Value;
|
||||
}
|
||||
}
|
||||
foreach (KeyValuePair<NitroxId, NitroxInt3> updatedMapRoom in updateBase.UpdatedMapRooms)
|
||||
{
|
||||
if (entityRegistry.TryGetEntityById(updatedMapRoom.Key, out MapRoomEntity childEntity))
|
||||
{
|
||||
childEntity.Cell = updatedMapRoom.Value;
|
||||
}
|
||||
}
|
||||
|
||||
if (updateBase.BuiltPieceEntity != null && updateBase.BuiltPieceEntity is GlobalRootEntity builtPieceEntity)
|
||||
{
|
||||
worldEntityManager.AddOrUpdateGlobalRootEntity(builtPieceEntity);
|
||||
}
|
||||
|
||||
NitroxId transferFromId = updateBase.ChildrenTransfer.Item1;
|
||||
NitroxId transferToId = updateBase.ChildrenTransfer.Item2;
|
||||
if (transferFromId != null && transferToId != null)
|
||||
{
|
||||
// NB: we don't want certain entities to be transferred (e.g. planters)
|
||||
entityRegistry.TransferChildren(transferFromId, transferToId, entity => entity is not PlanterEntity);
|
||||
|
||||
// Edge case for when you place a new water park under another one
|
||||
if (transferFromId == transferToId && entityRegistry.TryGetEntityById(updateBase.ChildrenTransfer.Item1, out InteriorPieceEntity interiorPieceEntity) &&
|
||||
interiorPieceEntity.IsWaterPark)
|
||||
{
|
||||
// Cleaning all elements from the planter because plants are not kept in this case
|
||||
CleanPlanterChildren(interiorPieceEntity);
|
||||
}
|
||||
}
|
||||
|
||||
// After transferring required children, we need to clean the waterparks that were potentially removed when being merged
|
||||
List<NitroxId> removedChildIds = buildEntity.ChildEntities.OfType<InteriorPieceEntity>()
|
||||
.Where(entity => entity.IsWaterPark).Select(childEntity => childEntity.Id)
|
||||
.Except(updateBase.UpdatedChildren.Keys).ToList();
|
||||
|
||||
foreach (NitroxId removedChildId in removedChildIds)
|
||||
{
|
||||
if (entityRegistry.GetEntityById(removedChildId).HasValue)
|
||||
{
|
||||
worldEntityManager.RemoveGlobalRootEntity(removedChildId);
|
||||
}
|
||||
}
|
||||
buildEntity.OperationId++;
|
||||
operationId = buildEntity.OperationId;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes all children from the planter(s) of the param water park (<paramref name="interiorPieceEntity"/>)
|
||||
/// </summary>
|
||||
private void CleanPlanterChildren(InteriorPieceEntity interiorPieceEntity)
|
||||
{
|
||||
NitroxId leftPlanterId = interiorPieceEntity.Id.Increment();
|
||||
NitroxId rightPlanterId = leftPlanterId.Increment();
|
||||
|
||||
// We don't care if either one of those doesn't exist, we just treat both possibility
|
||||
// which avoids an o(n) search in interiorPieceEntity's children, and makes it into a o(1) get
|
||||
if (entityRegistry.TryGetEntityById(leftPlanterId, out PlanterEntity leftPlanterEntity))
|
||||
{
|
||||
entityRegistry.CleanChildren(leftPlanterEntity);
|
||||
}
|
||||
if (entityRegistry.TryGetEntityById(rightPlanterId, out PlanterEntity rightPlanterEntity))
|
||||
{
|
||||
entityRegistry.CleanChildren(rightPlanterEntity);
|
||||
}
|
||||
}
|
||||
|
||||
public bool ReplaceBaseByGhost(BaseDeconstructed baseDeconstructed)
|
||||
{
|
||||
if (!entityRegistry.TryGetEntityById(baseDeconstructed.FormerBaseId, out BuildEntity _))
|
||||
{
|
||||
Log.Error($"Trying to replace a non-registered build (BaseId: {baseDeconstructed.FormerBaseId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
worldEntityManager.RemoveGlobalRootEntity(baseDeconstructed.FormerBaseId);
|
||||
worldEntityManager.AddOrUpdateGlobalRootEntity(baseDeconstructed.ReplacerGhost);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ReplacePieceByGhost(Player player, PieceDeconstructed pieceDeconstructed, out Entity removedEntity, out int operationId)
|
||||
{
|
||||
if (!entityRegistry.TryGetEntityById(pieceDeconstructed.BaseId, out BuildEntity buildEntity))
|
||||
{
|
||||
Log.Error($"Trying to replace a non-registered build (BaseId: {pieceDeconstructed.BaseId})");
|
||||
removedEntity = null;
|
||||
operationId = -1;
|
||||
return false;
|
||||
}
|
||||
if (entityRegistry.TryGetEntityById(pieceDeconstructed.PieceId, out GhostEntity _))
|
||||
{
|
||||
Log.Error($"Trying to add a ghost to a building but another ghost child with the same id already exists (GhostId: {pieceDeconstructed.PieceId})");
|
||||
removedEntity = null;
|
||||
operationId = -1;
|
||||
return false;
|
||||
}
|
||||
|
||||
int deltaOperations = buildEntity.OperationId + 1 - pieceDeconstructed.OperationId;
|
||||
if (deltaOperations != 0 && config.SafeBuilding)
|
||||
{
|
||||
Log.Warn($"Ignoring a {nameof(PieceDeconstructed)} packet from [{player.Name}] which is {Math.Abs(deltaOperations) + (deltaOperations > 0 ? " operations ahead" : " operations late")}");
|
||||
NotifyPlayerDesync(player);
|
||||
removedEntity = null;
|
||||
operationId = -1;
|
||||
return false;
|
||||
}
|
||||
|
||||
removedEntity = worldEntityManager.RemoveGlobalRootEntity(pieceDeconstructed.PieceId).Value;
|
||||
GhostEntity ghostEntity = pieceDeconstructed.ReplacerGhost;
|
||||
|
||||
worldEntityManager.AddOrUpdateGlobalRootEntity(ghostEntity);
|
||||
buildEntity.BaseData = pieceDeconstructed.BaseData;
|
||||
buildEntity.OperationId++;
|
||||
operationId = buildEntity.OperationId;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool CreateWaterParkPiece(WaterParkDeconstructed waterParkDeconstructed, Entity removedEntity)
|
||||
{
|
||||
if (!entityRegistry.TryGetEntityById(waterParkDeconstructed.BaseId, out BuildEntity buildEntity))
|
||||
{
|
||||
Log.Error($"Trying to create a WaterPark piece in a non-registered build ({waterParkDeconstructed.BaseId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
InteriorPieceEntity newPiece = waterParkDeconstructed.NewWaterPark;
|
||||
worldEntityManager.AddOrUpdateGlobalRootEntity(newPiece);
|
||||
|
||||
// This part doesn't need all the below register again code because in this very case
|
||||
// (a water park split between a below and an above one) the below water park is still the same
|
||||
foreach (NitroxId childId in waterParkDeconstructed.MovedChildrenIds)
|
||||
{
|
||||
entityRegistry.ReparentEntity(childId, newPiece);
|
||||
}
|
||||
|
||||
if (removedEntity != null && waterParkDeconstructed.Transfer)
|
||||
{
|
||||
// Children are all removed by BuildingManager.ReplacePieceByGhost which is called before
|
||||
// so we need to re-register them
|
||||
// NB: must copy the list before because the below functions do modify newPiece.ChildEntities
|
||||
|
||||
List<Entity> childEntities = [.. removedEntity.ChildEntities.Where(e => e is not PlanterEntity)];
|
||||
|
||||
foreach (Entity childEntity in childEntities)
|
||||
{
|
||||
entityRegistry.RemoveFromParent(childEntity);
|
||||
childEntity.ParentId = newPiece.Id;
|
||||
if (childEntity is GlobalRootEntity childGlobalRootEntity)
|
||||
{
|
||||
worldEntityManager.AddOrUpdateGlobalRootEntity(childGlobalRootEntity);
|
||||
}
|
||||
else
|
||||
{
|
||||
entityRegistry.AddOrUpdate(childEntity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool SeparateChildrenToWaterParks(LargeWaterParkDeconstructed packet)
|
||||
{
|
||||
if (packet.MovedChildrenIdsByNewHostId.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (packet.PieceId == null || !entityRegistry.TryGetEntityById(packet.PieceId, out InteriorPieceEntity deconstructedPiece))
|
||||
{
|
||||
Log.Error($"Could not find {nameof(InteriorPieceEntity)} with id {packet.PieceId} to be deconstructed");
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (KeyValuePair<NitroxId, List<NitroxId>> entry in packet.MovedChildrenIdsByNewHostId)
|
||||
{
|
||||
if (!entityRegistry.TryGetEntityById(entry.Key, out InteriorPieceEntity waterPark))
|
||||
{
|
||||
Log.Error($"Could not find {nameof(InteriorPieceEntity)} id {entry.Key} to move {entry.Value} entities to");
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (NitroxId entityId in entry.Value)
|
||||
{
|
||||
entityRegistry.ReparentEntity(entityId, waterPark);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void NotifyPlayerDesync(Player player)
|
||||
{
|
||||
Dictionary<NitroxId, int> operations = GetEntitiesOperations(worldEntityManager.GetGlobalRootEntities(true));
|
||||
player.SendPacket(new BuildingDesyncWarning(operations));
|
||||
}
|
||||
|
||||
public static Dictionary<NitroxId, int> GetEntitiesOperations(List<GlobalRootEntity> entities)
|
||||
{
|
||||
return entities.OfType<BuildEntity>().ToDictionary(entity => entity.Id, entity => entity.OperationId);
|
||||
}
|
||||
}
|
||||
8
NitroxServer/GameLogic/ConnectionAssets.cs
Normal file
8
NitroxServer/GameLogic/ConnectionAssets.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace NitroxServer.GameLogic
|
||||
{
|
||||
internal class ConnectionAssets
|
||||
{
|
||||
public string ReservationKey { get; set; }
|
||||
public Player Player { get; set; }
|
||||
}
|
||||
}
|
||||
77
NitroxServer/GameLogic/Entities/EntityData.cs
Normal file
77
NitroxServer/GameLogic/Entities/EntityData.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.Serialization;
|
||||
using NitroxModel.DataStructures;
|
||||
using NitroxModel.DataStructures.GameLogic;
|
||||
using NitroxModel.DataStructures.GameLogic.Entities;
|
||||
using NitroxModel.DataStructures.Unity;
|
||||
using ProtoBufNet;
|
||||
|
||||
namespace NitroxServer.GameLogic.Entities;
|
||||
|
||||
[DataContract]
|
||||
public class EntityData
|
||||
{
|
||||
[DataMember(Order = 1)]
|
||||
public List<Entity> Entities = [];
|
||||
|
||||
[ProtoAfterDeserialization]
|
||||
private void ProtoAfterDeserialization()
|
||||
{
|
||||
// After deserialization, we want to assign all of the
|
||||
// children to their respective parent entities.
|
||||
Dictionary<NitroxId, Entity> entitiesById = Entities.ToDictionary(entity => entity.Id);
|
||||
|
||||
foreach (Entity entity in Entities)
|
||||
{
|
||||
if (entity is WorldEntity we)
|
||||
{
|
||||
NitroxVector3 pos = we.Transform.LocalPosition;
|
||||
if (float.IsNaN(pos.X) || float.IsNaN(pos.Y) || float.IsNaN(pos.Z) ||
|
||||
float.IsInfinity(pos.X) || float.IsInfinity(pos.Y) || float.IsInfinity(pos.Z))
|
||||
{
|
||||
Log.Error("Found WorldEntity with NaN or infinite position. Teleporting it to world origin.");
|
||||
we.Transform.LocalPosition = NitroxVector3.Zero;
|
||||
}
|
||||
|
||||
NitroxQuaternion rot = we.Transform.LocalRotation;
|
||||
if (float.IsNaN(rot.X) || float.IsNaN(rot.Y) || float.IsNaN(rot.Z) || float.IsNaN(rot.W) ||
|
||||
float.IsInfinity(rot.X) || float.IsInfinity(rot.Y) || float.IsInfinity(rot.Z) || float.IsInfinity(rot.W))
|
||||
{
|
||||
Log.Error("Found WorldEntity with NaN or infinite rotation. Resetting rotation.");
|
||||
we.Transform.LocalRotation = NitroxQuaternion.Identity;
|
||||
}
|
||||
}
|
||||
|
||||
// We will re-build the child hierarchy below and want to avoid duplicates.
|
||||
// TODO: Rework system to no longer persist children entities because they are duplicates.
|
||||
entity.ChildEntities.Clear();
|
||||
|
||||
if (entity.ParentId == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entitiesById.TryGetValue(entity.ParentId, out Entity parent))
|
||||
{
|
||||
parent.ChildEntities.Add(entity);
|
||||
|
||||
if (entity is WorldEntity we2 && parent is WorldEntity weParent)
|
||||
{
|
||||
we2.Transform.SetParent(weParent.Transform, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[OnDeserialized]
|
||||
private void JsonAfterDeserialization(StreamingContext context)
|
||||
{
|
||||
ProtoAfterDeserialization();
|
||||
}
|
||||
|
||||
public static EntityData From(List<Entity> entities)
|
||||
{
|
||||
return new EntityData { Entities = entities };
|
||||
}
|
||||
}
|
||||
246
NitroxServer/GameLogic/Entities/EntityRegistry.cs
Normal file
246
NitroxServer/GameLogic/Entities/EntityRegistry.cs
Normal file
@@ -0,0 +1,246 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using NitroxModel.DataStructures;
|
||||
using NitroxModel.DataStructures.GameLogic;
|
||||
using NitroxModel.DataStructures.GameLogic.Entities;
|
||||
using NitroxModel.DataStructures.Util;
|
||||
|
||||
namespace NitroxServer.GameLogic.Entities
|
||||
{
|
||||
public class EntityRegistry
|
||||
{
|
||||
private readonly ConcurrentDictionary<NitroxId, Entity> entitiesById = new();
|
||||
|
||||
public Optional<T> GetEntityById<T>(NitroxId id) where T : Entity
|
||||
{
|
||||
TryGetEntityById(id, out T entity);
|
||||
|
||||
return Optional.OfNullable(entity);
|
||||
}
|
||||
|
||||
public Optional<Entity> GetEntityById(NitroxId id)
|
||||
{
|
||||
return GetEntityById<Entity>(id);
|
||||
}
|
||||
|
||||
public bool TryGetEntityById<T>(NitroxId id, out T entity) where T : Entity
|
||||
{
|
||||
if (entitiesById.TryGetValue(id, out Entity _entity) && _entity is T typedEntity)
|
||||
{
|
||||
entity = typedEntity;
|
||||
return true;
|
||||
}
|
||||
entity = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
public List<Entity> GetAllEntities(bool exceptGlobalRoot = false)
|
||||
{
|
||||
if (exceptGlobalRoot)
|
||||
{
|
||||
return new(entitiesById.Values.Where(entity => entity is not GlobalRootEntity));
|
||||
}
|
||||
return new List<Entity>(entitiesById.Values);
|
||||
}
|
||||
|
||||
public List<Entity> GetEntities(List<NitroxId> ids)
|
||||
{
|
||||
return entitiesById.Join(ids,
|
||||
entity => entity.Value.Id,
|
||||
id => id,
|
||||
(entity, id) => entity.Value)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public List<T> GetEntities<T>()
|
||||
{
|
||||
return entitiesById.Values.OfType<T>().ToList();
|
||||
}
|
||||
|
||||
public void AddEntity(Entity entity)
|
||||
{
|
||||
if (!entitiesById.TryAdd(entity.Id, entity))
|
||||
{
|
||||
// Log an error to show stack trace but don't halt execution.
|
||||
Log.Error(new InvalidOperationException(), $"Trying to add duplicate entity {entity.Id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers or updates an entity and its children.
|
||||
/// </summary>
|
||||
public void AddOrUpdate(Entity entity)
|
||||
{
|
||||
if (!entitiesById.TryAdd(entity.Id, entity))
|
||||
{
|
||||
Entity current = entitiesById[entity.Id];
|
||||
|
||||
RemoveFromParent(current);
|
||||
|
||||
entitiesById.TryUpdate(entity.Id, entity, current);
|
||||
}
|
||||
|
||||
AddToParent(entity);
|
||||
AddEntitiesIgnoringDuplicate(entity.ChildEntities);
|
||||
}
|
||||
|
||||
public void AddEntities(IEnumerable<Entity> entities)
|
||||
{
|
||||
foreach(Entity entity in entities)
|
||||
{
|
||||
AddEntity(entity);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Used for situations when some children may be new but others may not be. For
|
||||
/// example a dropped InventoryEntity turns into a WorldEntity but keeps its
|
||||
/// battery inside (already known).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Updates entities if they already exist
|
||||
/// </remarks>
|
||||
public void AddEntitiesIgnoringDuplicate(IEnumerable<Entity> entities)
|
||||
{
|
||||
foreach (Entity entity in entities)
|
||||
{
|
||||
if (entitiesById.TryGetValue(entity.Id, out Entity currentEntity))
|
||||
{
|
||||
entitiesById.TryUpdate(entity.Id, entity, currentEntity);
|
||||
}
|
||||
else
|
||||
{
|
||||
entitiesById.TryAdd(entity.Id, entity);
|
||||
}
|
||||
AddEntitiesIgnoringDuplicate(entity.ChildEntities);
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<Entity> RemoveEntity(NitroxId id)
|
||||
{
|
||||
if (entitiesById.TryRemove(id, out Entity entity))
|
||||
{
|
||||
RemoveFromParent(entity);
|
||||
|
||||
foreach (Entity child in entity.ChildEntities)
|
||||
{
|
||||
RemoveEntity(child.Id);
|
||||
}
|
||||
}
|
||||
|
||||
return Optional.OfNullable(entity);
|
||||
}
|
||||
|
||||
public void AddToParent(Entity entity)
|
||||
{
|
||||
if (entity.ParentId != null)
|
||||
{
|
||||
Optional<Entity> parent = GetEntityById(entity.ParentId);
|
||||
|
||||
if (parent.HasValue)
|
||||
{
|
||||
parent.Value.ChildEntities.Add(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveFromParent(Entity entity)
|
||||
{
|
||||
if (entity.ParentId != null && TryGetEntityById(entity.ParentId, out Entity parentEntity))
|
||||
{
|
||||
parentEntity.ChildEntities.RemoveAll(childEntity => childEntity.Id.Equals(entity.Id));
|
||||
entity.ParentId = null;
|
||||
if (entity is WorldEntity worldEntity && worldEntity.Transform != null)
|
||||
{
|
||||
worldEntity.Transform.SetParent(null, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Removes all children from <paramref name="entity"/>
|
||||
/// </summary>
|
||||
public void CleanChildren(Entity entity)
|
||||
{
|
||||
for (int i = entity.ChildEntities.Count - 1; i >= 0; i--)
|
||||
{
|
||||
RemoveEntity(entity.ChildEntities[i].Id);
|
||||
}
|
||||
}
|
||||
|
||||
public void ReparentEntity(NitroxId entityId, NitroxId newParentId)
|
||||
{
|
||||
if (entityId == null || !TryGetEntityById(entityId, out Entity entity))
|
||||
{
|
||||
Log.Error($"Could not find entity to reparent: {entityId}");
|
||||
return;
|
||||
}
|
||||
ReparentEntity(entity, newParentId);
|
||||
}
|
||||
|
||||
public void ReparentEntity(NitroxId entityId, Entity newParent)
|
||||
{
|
||||
if (entityId == null || !TryGetEntityById(entityId, out Entity entity))
|
||||
{
|
||||
Log.Error($"Could not find entity to reparent: {entityId}");
|
||||
return;
|
||||
}
|
||||
ReparentEntity(entity, newParent);
|
||||
}
|
||||
|
||||
public void ReparentEntity(Entity entity, NitroxId newParentId)
|
||||
{
|
||||
Entity parentEntity = newParentId != null ? GetEntityById(newParentId).Value : null;
|
||||
ReparentEntity(entity, parentEntity);
|
||||
}
|
||||
|
||||
public void ReparentEntity(Entity entity, Entity newParent)
|
||||
{
|
||||
RemoveFromParent(entity);
|
||||
if (newParent == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (entity is WorldEntity worldEntity && worldEntity.Transform != null &&
|
||||
newParent is WorldEntity parentWorldEntity && parentWorldEntity.Transform != null)
|
||||
{
|
||||
worldEntity.Transform.SetParent(parentWorldEntity.Transform, true);
|
||||
}
|
||||
entity.ParentId = newParent.Id;
|
||||
newParent.ChildEntities.Add(entity);
|
||||
}
|
||||
|
||||
public void TransferChildren(NitroxId parentId, NitroxId newParentId, Func<Entity, bool> filter = null)
|
||||
{
|
||||
if (!TryGetEntityById(parentId, out Entity parentEntity))
|
||||
{
|
||||
Log.Error($"[{nameof(EntityRegistry.TransferChildren)}] Couldn't find origin parent entity for {parentId}");
|
||||
return;
|
||||
}
|
||||
if (!TryGetEntityById(newParentId, out Entity newParentEntity))
|
||||
{
|
||||
Log.Error($"[{nameof(EntityRegistry.TransferChildren)}] Couldn't find new parent entity for {newParentId}");
|
||||
return;
|
||||
}
|
||||
TransferChildren(parentEntity, newParentEntity, filter);
|
||||
}
|
||||
|
||||
public void TransferChildren(Entity parent, Entity newParent, Func<Entity, bool> filter = null)
|
||||
{
|
||||
List<Entity> childrenToMove = filter != null ?
|
||||
[.. parent.ChildEntities.Where(filter)] : parent.ChildEntities;
|
||||
|
||||
// In case parent == newParent (which is actually a case used) we need removal to happen before adding the entities back
|
||||
parent.ChildEntities.RemoveAll(entity => filter(entity));
|
||||
|
||||
foreach (Entity childEntity in childrenToMove)
|
||||
{
|
||||
childEntity.ParentId = newParent.Id;
|
||||
newParent.ChildEntities.Add(childEntity);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
162
NitroxServer/GameLogic/Entities/EntitySimulation.cs
Normal file
162
NitroxServer/GameLogic/Entities/EntitySimulation.cs
Normal file
@@ -0,0 +1,162 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using NitroxModel.DataStructures;
|
||||
using NitroxModel.DataStructures.GameLogic;
|
||||
using NitroxModel.DataStructures.GameLogic.Entities;
|
||||
using NitroxModel.Packets;
|
||||
|
||||
namespace NitroxServer.GameLogic.Entities;
|
||||
|
||||
public class EntitySimulation
|
||||
{
|
||||
private const SimulationLockType DEFAULT_ENTITY_SIMULATION_LOCKTYPE = SimulationLockType.TRANSIENT;
|
||||
|
||||
private readonly EntityRegistry entityRegistry;
|
||||
private readonly WorldEntityManager worldEntityManager;
|
||||
private readonly PlayerManager playerManager;
|
||||
private readonly ISimulationWhitelist simulationWhitelist;
|
||||
private readonly SimulationOwnershipData simulationOwnershipData;
|
||||
|
||||
public EntitySimulation(EntityRegistry entityRegistry, WorldEntityManager worldEntityManager, SimulationOwnershipData simulationOwnershipData, PlayerManager playerManager, ISimulationWhitelist simulationWhitelist)
|
||||
{
|
||||
this.entityRegistry = entityRegistry;
|
||||
this.worldEntityManager = worldEntityManager;
|
||||
this.simulationOwnershipData = simulationOwnershipData;
|
||||
this.playerManager = playerManager;
|
||||
this.simulationWhitelist = simulationWhitelist;
|
||||
}
|
||||
|
||||
public List<SimulatedEntity> GetSimulationChangesForCell(Player player, AbsoluteEntityCell cell)
|
||||
{
|
||||
List<WorldEntity> entities = worldEntityManager.GetEntities(cell);
|
||||
List<WorldEntity> addedEntities = FilterSimulatableEntities(player, entities);
|
||||
|
||||
List<SimulatedEntity> ownershipChanges = new();
|
||||
|
||||
foreach (WorldEntity entity in addedEntities)
|
||||
{
|
||||
bool doesEntityMove = ShouldSimulateEntityMovement(entity);
|
||||
ownershipChanges.Add(new SimulatedEntity(entity.Id, player.Id, doesEntityMove, DEFAULT_ENTITY_SIMULATION_LOCKTYPE));
|
||||
}
|
||||
|
||||
return ownershipChanges;
|
||||
}
|
||||
|
||||
public void FillWithRemovedCells(Player player, AbsoluteEntityCell removedCell, List<SimulatedEntity> ownershipChanges)
|
||||
{
|
||||
List<WorldEntity> entities = worldEntityManager.GetEntities(removedCell);
|
||||
IEnumerable<WorldEntity> revokedEntities = entities.Where(entity => !player.CanSee(entity) && simulationOwnershipData.RevokeIfOwner(entity.Id, player));
|
||||
AssignEntitiesToOtherPlayers(player, revokedEntities, ownershipChanges);
|
||||
}
|
||||
|
||||
public void BroadcastSimulationChanges(List<SimulatedEntity> ownershipChanges)
|
||||
{
|
||||
if (ownershipChanges.Count > 0)
|
||||
{
|
||||
SimulationOwnershipChange ownershipChange = new(ownershipChanges);
|
||||
playerManager.SendPacketToAllPlayers(ownershipChange);
|
||||
}
|
||||
}
|
||||
|
||||
public List<SimulatedEntity> CalculateSimulationChangesFromPlayerDisconnect(Player player)
|
||||
{
|
||||
List<SimulatedEntity> ownershipChanges = new();
|
||||
|
||||
List<NitroxId> revokedEntityIds = simulationOwnershipData.RevokeAllForOwner(player);
|
||||
List<Entity> revokedEntities = entityRegistry.GetEntities(revokedEntityIds);
|
||||
|
||||
AssignEntitiesToOtherPlayers(player, revokedEntities, ownershipChanges);
|
||||
|
||||
return ownershipChanges;
|
||||
}
|
||||
|
||||
public SimulatedEntity AssignNewEntityToPlayer(Entity entity, Player player, bool shouldEntityMove = true)
|
||||
{
|
||||
if (simulationOwnershipData.TryToAcquire(entity.Id, player, DEFAULT_ENTITY_SIMULATION_LOCKTYPE))
|
||||
{
|
||||
bool doesEntityMove = shouldEntityMove && entity is WorldEntity worldEntity && ShouldSimulateEntityMovement(worldEntity);
|
||||
return new SimulatedEntity(entity.Id, player.Id, doesEntityMove, DEFAULT_ENTITY_SIMULATION_LOCKTYPE);
|
||||
}
|
||||
|
||||
throw new Exception($"New entity was already being simulated by someone else: {entity.Id}");
|
||||
}
|
||||
|
||||
public List<SimulatedEntity> AssignGlobalRootEntitiesAndGetData(Player player)
|
||||
{
|
||||
List<SimulatedEntity> simulatedEntities = new();
|
||||
foreach (GlobalRootEntity entity in worldEntityManager.GetGlobalRootEntities())
|
||||
{
|
||||
simulationOwnershipData.TryToAcquire(entity.Id, player, SimulationLockType.TRANSIENT);
|
||||
if (!simulationOwnershipData.TryGetLock(entity.Id, out SimulationOwnershipData.PlayerLock playerLock))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
bool doesEntityMove = ShouldSimulateEntityMovement(entity);
|
||||
SimulatedEntity simulatedEntity = new(entity.Id, playerLock.Player.Id, doesEntityMove, playerLock.LockType);
|
||||
simulatedEntities.Add(simulatedEntity);
|
||||
}
|
||||
return simulatedEntities;
|
||||
}
|
||||
|
||||
private void AssignEntitiesToOtherPlayers(Player oldPlayer, IEnumerable<Entity> entities, List<SimulatedEntity> ownershipChanges)
|
||||
{
|
||||
List<Player> otherPlayers = playerManager.GetConnectedPlayersExcept(oldPlayer);
|
||||
|
||||
foreach (Entity entity in entities)
|
||||
{
|
||||
if (TryAssignEntityToPlayers(otherPlayers, entity, out SimulatedEntity simulatedEntity))
|
||||
{
|
||||
ownershipChanges.Add(simulatedEntity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryAssignEntityToPlayers(List<Player> players, Entity entity, out SimulatedEntity simulatedEntity)
|
||||
{
|
||||
NitroxId id = entity.Id;
|
||||
|
||||
foreach (Player player in players)
|
||||
{
|
||||
if (player.CanSee(entity) && simulationOwnershipData.TryToAcquire(id, player, DEFAULT_ENTITY_SIMULATION_LOCKTYPE))
|
||||
{
|
||||
bool doesEntityMove = entity is WorldEntity worldEntity && ShouldSimulateEntityMovement(worldEntity);
|
||||
|
||||
Log.Verbose($"Player {player.Name} has taken over simulating {id}");
|
||||
simulatedEntity = new(id, player.Id, doesEntityMove, DEFAULT_ENTITY_SIMULATION_LOCKTYPE);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
simulatedEntity = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
private List<WorldEntity> FilterSimulatableEntities(Player player, List<WorldEntity> entities)
|
||||
{
|
||||
return entities.Where(entity => {
|
||||
bool isEligibleForSimulation = player.CanSee(entity) && ShouldSimulateEntity(entity);
|
||||
return isEligibleForSimulation && simulationOwnershipData.TryToAcquire(entity.Id, player, DEFAULT_ENTITY_SIMULATION_LOCKTYPE);
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
public bool ShouldSimulateEntity(WorldEntity entity)
|
||||
{
|
||||
return simulationWhitelist.UtilityWhitelist.Contains(entity.TechType) || ShouldSimulateEntityMovement(entity);
|
||||
}
|
||||
|
||||
public bool ShouldSimulateEntityMovement(WorldEntity entity)
|
||||
{
|
||||
return !entity.SpawnedByServer || simulationWhitelist.MovementWhitelist.Contains(entity.TechType);
|
||||
}
|
||||
|
||||
public bool ShouldSimulateEntityMovement(NitroxId entityId)
|
||||
{
|
||||
return entityRegistry.TryGetEntityById(entityId, out WorldEntity worldEntity) && ShouldSimulateEntityMovement(worldEntity);
|
||||
}
|
||||
|
||||
public void EntityDestroyed(NitroxId id)
|
||||
{
|
||||
simulationOwnershipData.RevokeOwnerOfId(id);
|
||||
}
|
||||
}
|
||||
50
NitroxServer/GameLogic/Entities/GlobalRootData.cs
Normal file
50
NitroxServer/GameLogic/Entities/GlobalRootData.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.Serialization;
|
||||
using NitroxModel.DataStructures.GameLogic;
|
||||
using NitroxModel.DataStructures.GameLogic.Entities;
|
||||
using ProtoBufNet;
|
||||
|
||||
namespace NitroxServer.GameLogic.Entities;
|
||||
|
||||
[DataContract]
|
||||
public class GlobalRootData
|
||||
{
|
||||
[DataMember(Order = 1)]
|
||||
public List<GlobalRootEntity> Entities = new();
|
||||
|
||||
[ProtoAfterDeserialization]
|
||||
private void ProtoAfterDeserialization()
|
||||
{
|
||||
foreach (GlobalRootEntity entity in Entities)
|
||||
{
|
||||
EnsureChildrenTransformAreParented(entity);
|
||||
}
|
||||
}
|
||||
|
||||
[OnDeserialized]
|
||||
private void JsonAfterDeserialization(StreamingContext context)
|
||||
{
|
||||
ProtoAfterDeserialization();
|
||||
}
|
||||
|
||||
private static void EnsureChildrenTransformAreParented(WorldEntity entity)
|
||||
{
|
||||
if (entity.Transform == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
foreach (Entity child in entity.ChildEntities)
|
||||
{
|
||||
if (child is WorldEntity childWE && childWE.Transform != null)
|
||||
{
|
||||
childWE.Transform.SetParent(entity.Transform, false);
|
||||
EnsureChildrenTransformAreParented(childWE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static GlobalRootData From(List<GlobalRootEntity> entities)
|
||||
{
|
||||
return new GlobalRootData { Entities = entities };
|
||||
}
|
||||
}
|
||||
20
NitroxServer/GameLogic/Entities/ISimulationWhitelist.cs
Normal file
20
NitroxServer/GameLogic/Entities/ISimulationWhitelist.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using System.Collections.Generic;
|
||||
using NitroxModel.DataStructures.GameLogic;
|
||||
|
||||
namespace NitroxServer.GameLogic.Entities;
|
||||
|
||||
public interface ISimulationWhitelist
|
||||
{
|
||||
/// <summary>
|
||||
/// We don't want to give out simulation to all entities that the server sent out because there is a lot of stationary items and junk (TechType.None).
|
||||
/// It is easier to maintain a list of items we should simulate than try to blacklist items. This list should not be checked for non-server spawned items
|
||||
/// as they were probably dropped by the player and are mostly guaranteed to move.
|
||||
/// </summary>
|
||||
HashSet<NitroxTechType> MovementWhitelist { get; }
|
||||
|
||||
/// <summary>
|
||||
/// We differentiate the entities which should be simulated because of one of their behaviour (ie for utility)
|
||||
/// from those are simulated for their movements.
|
||||
/// </summary>
|
||||
HashSet<NitroxTechType> UtilityWhitelist { get; }
|
||||
}
|
||||
7
NitroxServer/GameLogic/Entities/NitroxEntitySlot.cs
Normal file
7
NitroxServer/GameLogic/Entities/NitroxEntitySlot.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace NitroxServer.GameLogic.Entities;
|
||||
|
||||
[Serializable]
|
||||
public record struct NitroxEntitySlot(string BiomeType, List<string> AllowedTypes);
|
||||
458
NitroxServer/GameLogic/Entities/Spawning/BatchEntitySpawner.cs
Normal file
458
NitroxServer/GameLogic/Entities/Spawning/BatchEntitySpawner.cs
Normal file
@@ -0,0 +1,458 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using NitroxModel.DataStructures;
|
||||
using NitroxModel.DataStructures.GameLogic;
|
||||
using NitroxModel.DataStructures.GameLogic.Entities;
|
||||
using NitroxModel.DataStructures.Unity;
|
||||
using NitroxServer.GameLogic.Unlockables;
|
||||
using NitroxServer.Helper;
|
||||
using NitroxServer.Resources;
|
||||
using NitroxServer.Serialization;
|
||||
|
||||
namespace NitroxServer.GameLogic.Entities.Spawning;
|
||||
|
||||
public class BatchEntitySpawner : IEntitySpawner
|
||||
{
|
||||
private readonly BatchCellsParser batchCellsParser;
|
||||
|
||||
private readonly HashSet<NitroxInt3> emptyBatches = [];
|
||||
private readonly Dictionary<string, PrefabPlaceholdersGroupAsset> placeholdersGroupsByClassId;
|
||||
private readonly RandomSpawnSpoofer randomSpawnSpoofer;
|
||||
private readonly IUwePrefabFactory prefabFactory;
|
||||
private readonly IEntityBootstrapperManager entityBootstrapperManager;
|
||||
private readonly PDAStateData pdaStateData;
|
||||
|
||||
private readonly string seed;
|
||||
|
||||
private readonly IUweWorldEntityFactory worldEntityFactory;
|
||||
|
||||
private readonly Lock parsedBatchesLock = new();
|
||||
private readonly Lock emptyBatchesLock = new();
|
||||
private HashSet<NitroxInt3> parsedBatches;
|
||||
|
||||
public List<NitroxInt3> SerializableParsedBatches
|
||||
{
|
||||
get
|
||||
{
|
||||
List<NitroxInt3> parsed;
|
||||
List<NitroxInt3> empty;
|
||||
|
||||
lock (parsedBatchesLock)
|
||||
{
|
||||
parsed = [.. parsedBatches];
|
||||
}
|
||||
|
||||
lock (emptyBatchesLock)
|
||||
{
|
||||
empty = [.. emptyBatches];
|
||||
}
|
||||
|
||||
return [.. parsed.Except(empty)];
|
||||
}
|
||||
set
|
||||
{
|
||||
lock (parsedBatchesLock)
|
||||
{
|
||||
parsedBatches = [.. value];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly NitroxQuaternion prefabZUpRotation = NitroxQuaternion.FromEuler(new(-90f, 0f, 0f));
|
||||
|
||||
public BatchEntitySpawner(
|
||||
EntitySpawnPointFactory entitySpawnPointFactory,
|
||||
IUweWorldEntityFactory worldEntityFactory,
|
||||
IUwePrefabFactory prefabFactory,
|
||||
List<NitroxInt3> loadedPreviousParsed,
|
||||
ServerProtoBufSerializer serializer,
|
||||
IEntityBootstrapperManager entityBootstrapperManager,
|
||||
Dictionary<string, PrefabPlaceholdersGroupAsset> placeholdersGroupsByClassId,
|
||||
PDAStateData pdaStateData,
|
||||
RandomSpawnSpoofer randomSpawnSpoofer,
|
||||
string seed
|
||||
)
|
||||
{
|
||||
parsedBatches = [.. loadedPreviousParsed];
|
||||
this.worldEntityFactory = worldEntityFactory;
|
||||
this.prefabFactory = prefabFactory;
|
||||
this.entityBootstrapperManager = entityBootstrapperManager;
|
||||
this.placeholdersGroupsByClassId = placeholdersGroupsByClassId;
|
||||
this.pdaStateData = pdaStateData;
|
||||
batchCellsParser = new BatchCellsParser(entitySpawnPointFactory, serializer);
|
||||
this.randomSpawnSpoofer = randomSpawnSpoofer;
|
||||
this.seed = seed;
|
||||
}
|
||||
|
||||
public bool IsBatchSpawned(NitroxInt3 batchId)
|
||||
{
|
||||
lock (parsedBatches)
|
||||
{
|
||||
return parsedBatches.Contains(batchId);
|
||||
}
|
||||
}
|
||||
|
||||
public List<Entity> LoadUnspawnedEntities(NitroxInt3 batchId, bool fullCacheCreation = false)
|
||||
{
|
||||
lock (parsedBatches)
|
||||
{
|
||||
if (parsedBatches.Contains(batchId))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
parsedBatches.Add(batchId);
|
||||
}
|
||||
|
||||
DeterministicGenerator deterministicBatchGenerator = new(seed, batchId);
|
||||
List<EntitySpawnPoint> spawnPoints = batchCellsParser.ParseBatchData(batchId);
|
||||
List<Entity> entities = SpawnEntities(spawnPoints, deterministicBatchGenerator);
|
||||
|
||||
if (entities.Count == 0)
|
||||
{
|
||||
lock (emptyBatchesLock)
|
||||
{
|
||||
emptyBatches.Add(batchId);
|
||||
}
|
||||
}
|
||||
else if (!fullCacheCreation)
|
||||
{
|
||||
Log.Info($"Spawning {entities.Count} entities from {spawnPoints.Count} spawn points in batch {batchId}");
|
||||
}
|
||||
|
||||
for (int x = 0; x < entities.Count; x++) // Throws on duplicate Entities already but nice to know which ones
|
||||
{
|
||||
for (int y = 0; y < entities.Count; y++)
|
||||
{
|
||||
if (entities[x] == entities[y] && x != y)
|
||||
{
|
||||
Log.Error($"Duplicate Entity detected! {entities[x]}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="CreateEntityWithChildren" />
|
||||
private IEnumerable<Entity> SpawnEntitiesUsingRandomDistribution(EntitySpawnPoint entitySpawnPoint, List<UwePrefab> prefabs, DeterministicGenerator deterministicBatchGenerator, Entity parentEntity = null)
|
||||
{
|
||||
// See CSVEntitySpawner.GetPrefabForSlot for reference
|
||||
List<UwePrefab> allowedPrefabs = FilterAllowedPrefabs(prefabs, entitySpawnPoint, out float fragmentProbability, out float completeFragmentProbability);
|
||||
|
||||
bool areFragmentProbabilitiesNonNull = fragmentProbability > 0f && completeFragmentProbability > 0f;
|
||||
float probabilityMultiplier = areFragmentProbabilitiesNonNull ? (completeFragmentProbability + fragmentProbability) / fragmentProbability : 1f;
|
||||
float weightedFragmentProbability = 0f;
|
||||
for (int i = 0; i < allowedPrefabs.Count; i++)
|
||||
{
|
||||
UwePrefab prefab = allowedPrefabs[i];
|
||||
if (areFragmentProbabilitiesNonNull && prefab.IsFragment)
|
||||
{
|
||||
prefab = prefab with { Probability = prefab.Probability * probabilityMultiplier };
|
||||
allowedPrefabs[i] = prefab;
|
||||
}
|
||||
weightedFragmentProbability += prefab.Probability;
|
||||
}
|
||||
|
||||
UwePrefab chosenPrefab = default;
|
||||
if (weightedFragmentProbability > 0f)
|
||||
{
|
||||
float probabilityThreshold = XORRandom.NextFloat();
|
||||
if (weightedFragmentProbability > 1f)
|
||||
{
|
||||
probabilityThreshold *= weightedFragmentProbability;
|
||||
}
|
||||
float currentProbability = 0f;
|
||||
foreach (UwePrefab prefab in allowedPrefabs)
|
||||
{
|
||||
currentProbability += prefab.Probability;
|
||||
if (currentProbability >= probabilityThreshold)
|
||||
{
|
||||
chosenPrefab = prefab;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (chosenPrefab.Count == 0)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (worldEntityFactory.TryFind(chosenPrefab.ClassId, out UweWorldEntity uweWorldEntity))
|
||||
{
|
||||
for (int i = 0; i < chosenPrefab.Count; i++)
|
||||
{
|
||||
// Random position in sphere is only possible after first spawn, see EntitySlot.Spawn
|
||||
IEnumerable<Entity> entities = CreateEntityWithChildren(entitySpawnPoint,
|
||||
chosenPrefab.ClassId,
|
||||
uweWorldEntity.TechType,
|
||||
uweWorldEntity.PrefabZUp,
|
||||
uweWorldEntity.CellLevel,
|
||||
uweWorldEntity.LocalScale,
|
||||
deterministicBatchGenerator,
|
||||
parentEntity,
|
||||
i > 0);
|
||||
foreach (Entity entity in entities)
|
||||
{
|
||||
yield return entity;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<UwePrefab> FilterAllowedPrefabs(List<UwePrefab> prefabs, EntitySpawnPoint entitySpawnPoint, out float fragmentProbability, out float completeFragmentProbability)
|
||||
{
|
||||
List<UwePrefab> allowedPrefabs = [];
|
||||
|
||||
fragmentProbability = 0;
|
||||
completeFragmentProbability = 0;
|
||||
for (int i = 0; i < prefabs.Count; i++)
|
||||
{
|
||||
UwePrefab prefab = prefabs[i];
|
||||
// Adapted code from the while loop in CSVEntitySpawner.GetPrefabForSlot
|
||||
if (prefab.ClassId != "None" && worldEntityFactory.TryFind(prefab.ClassId, out UweWorldEntity uweWorldEntity) &&
|
||||
entitySpawnPoint.AllowedTypes.Contains(uweWorldEntity.SlotType))
|
||||
{
|
||||
float weightedProbability = prefab.Probability / entitySpawnPoint.Density;
|
||||
if (weightedProbability > 0)
|
||||
{
|
||||
if (prefab.IsFragment)
|
||||
{
|
||||
if (pdaStateData.ScannerComplete.Contains(uweWorldEntity.TechType))
|
||||
{
|
||||
completeFragmentProbability += weightedProbability;
|
||||
continue;
|
||||
}
|
||||
else
|
||||
{
|
||||
fragmentProbability += weightedProbability;
|
||||
}
|
||||
}
|
||||
prefab = prefab with { Probability = weightedProbability };
|
||||
allowedPrefabs.Add(prefab);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allowedPrefabs;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spawns the regular (can be children of PrefabPlaceholdersGroup) which are always the same thus context independent.
|
||||
/// </summary>
|
||||
/// <inheritdoc cref="CreateEntityWithChildren" />
|
||||
private IEnumerable<Entity> SpawnEntitiesStaticly(EntitySpawnPoint entitySpawnPoint, DeterministicGenerator deterministicBatchGenerator, WorldEntity parentEntity = null)
|
||||
{
|
||||
if (worldEntityFactory.TryFind(entitySpawnPoint.ClassId, out UweWorldEntity uweWorldEntity))
|
||||
{
|
||||
// prefabZUp should not be taken into account for statically spawned entities
|
||||
IEnumerable<Entity> entities = CreateEntityWithChildren(entitySpawnPoint,
|
||||
entitySpawnPoint.ClassId,
|
||||
uweWorldEntity.TechType,
|
||||
false,
|
||||
uweWorldEntity.CellLevel,
|
||||
entitySpawnPoint.Scale,
|
||||
deterministicBatchGenerator,
|
||||
parentEntity);
|
||||
foreach (Entity entity in entities)
|
||||
{
|
||||
yield return entity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <returns>The first entity is a <see cref="WorldEntity"/> and the following are its children</returns>
|
||||
private IEnumerable<Entity> CreateEntityWithChildren(EntitySpawnPoint entitySpawnPoint, string classId, NitroxTechType techType, bool prefabZUp, int cellLevel, NitroxVector3 localScale, DeterministicGenerator deterministicBatchGenerator, Entity parentEntity = null, bool randomPosition = false)
|
||||
{
|
||||
WorldEntity spawnedEntity;
|
||||
NitroxVector3 position = entitySpawnPoint.LocalPosition;
|
||||
NitroxQuaternion rotation = entitySpawnPoint.LocalRotation;
|
||||
if (prefabZUp)
|
||||
{
|
||||
// See EntitySlot.SpawnVirtualEntities use of WorldEntityInfo.prefabZUp
|
||||
rotation *= prefabZUpRotation;
|
||||
}
|
||||
if (randomPosition)
|
||||
{
|
||||
position += XORRandom.NextInsideSphere(4f);
|
||||
}
|
||||
|
||||
if (classId == CellRootEntity.CLASS_ID)
|
||||
{
|
||||
spawnedEntity = new CellRootEntity(position,
|
||||
rotation,
|
||||
localScale,
|
||||
techType,
|
||||
cellLevel,
|
||||
classId,
|
||||
true,
|
||||
deterministicBatchGenerator.NextId());
|
||||
}
|
||||
else
|
||||
{
|
||||
randomSpawnSpoofer.PickRandomClassIdIfRequired(ref classId);
|
||||
spawnedEntity = new WorldEntity(position,
|
||||
rotation,
|
||||
localScale,
|
||||
techType,
|
||||
cellLevel,
|
||||
classId,
|
||||
true,
|
||||
deterministicBatchGenerator.NextId(),
|
||||
parentEntity);
|
||||
}
|
||||
|
||||
// See EntitySlotsPlaceholder.Spawn
|
||||
if (!TryCreatePrefabPlaceholdersGroupWithChildren(ref spawnedEntity, classId, deterministicBatchGenerator))
|
||||
{
|
||||
spawnedEntity.ChildEntities = SpawnEntities(entitySpawnPoint.Children, deterministicBatchGenerator, spawnedEntity);
|
||||
}
|
||||
|
||||
entityBootstrapperManager.PrepareEntityIfRequired(ref spawnedEntity, deterministicBatchGenerator);
|
||||
|
||||
yield return spawnedEntity;
|
||||
|
||||
if (parentEntity == null) // Ensures children are only returned at the top level
|
||||
{
|
||||
// Children are yielded as well so they can be indexed at the top level (for use by simulation
|
||||
// ownership and various other consumers). The parent should always be yielded before the children
|
||||
foreach (Entity childEntity in AllChildren(spawnedEntity))
|
||||
{
|
||||
yield return childEntity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<Entity> AllChildren(Entity entity)
|
||||
{
|
||||
foreach (Entity child in entity.ChildEntities)
|
||||
{
|
||||
yield return child;
|
||||
|
||||
if (child.ChildEntities.Count > 0)
|
||||
{
|
||||
foreach (Entity childOfChild in AllChildren(child))
|
||||
{
|
||||
yield return childOfChild;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<Entity> SpawnEntities(List<EntitySpawnPoint> entitySpawnPoints, DeterministicGenerator deterministicBatchGenerator, WorldEntity parentEntity = null)
|
||||
{
|
||||
List<Entity> entities = [];
|
||||
foreach (EntitySpawnPoint esp in entitySpawnPoints)
|
||||
{
|
||||
if (esp is SerializedEntitySpawnPoint serializedEsp)
|
||||
{
|
||||
// We add the cell's coordinate because this entity isn't parented so it needs to know about its global position
|
||||
NitroxTransform transform = new(serializedEsp.LocalPosition + serializedEsp.AbsoluteEntityCell.Position, serializedEsp.LocalRotation, serializedEsp.Scale);
|
||||
SerializedWorldEntity entity = new(serializedEsp.SerializedComponents, serializedEsp.Layer, transform, deterministicBatchGenerator.NextId(), parentEntity?.Id, serializedEsp.AbsoluteEntityCell);
|
||||
entities.Add(entity);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (esp.Density > 0)
|
||||
{
|
||||
if (prefabFactory.TryGetPossiblePrefabs(esp.BiomeType, out List<UwePrefab> prefabs) && prefabs.Count > 0)
|
||||
{
|
||||
entities.AddRange(SpawnEntitiesUsingRandomDistribution(esp, prefabs, deterministicBatchGenerator, parentEntity));
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(esp.ClassId))
|
||||
{
|
||||
entities.AddRange(SpawnEntitiesStaticly(esp, deterministicBatchGenerator, parentEntity));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check to see if this entity is a PrefabPlaceholderGroup.
|
||||
/// If it is, we want to add the PrefabPlaceholders that would be spawned here.
|
||||
/// This is suppressed on the client so we don't get virtual entities that the server doesn't know about.
|
||||
/// </summary>
|
||||
/// <returns>If this Entity is a PrefabPlaceholdersGroup</returns>
|
||||
private bool TryCreatePrefabPlaceholdersGroupWithChildren(ref WorldEntity entity, string classId, DeterministicGenerator deterministicBatchGenerator)
|
||||
{
|
||||
if (!placeholdersGroupsByClassId.TryGetValue(classId, out PrefabPlaceholdersGroupAsset groupAsset))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
entity = new PlaceholderGroupWorldEntity(entity);
|
||||
|
||||
// Adapted from PrefabPlaceholdersGroup.Spawn
|
||||
for (int i = 0; i < groupAsset.PrefabAssets.Length; i++)
|
||||
{
|
||||
// Fix positioning of children
|
||||
IPrefabAsset prefabAsset = groupAsset.PrefabAssets[i];
|
||||
|
||||
// Two cases, either the PrefabPlaceholder holds a visible GameObject or an EntitySlot (a MB which has a chance of spawning a prefab)
|
||||
if (prefabAsset is PrefabPlaceholderAsset placeholderAsset && placeholderAsset.EntitySlot.HasValue)
|
||||
{
|
||||
WorldEntity spawnedEntity = SpawnPrefabAssetInEntitySlot(placeholderAsset.Transform, placeholderAsset.EntitySlot.Value, deterministicBatchGenerator, entity.AbsoluteEntityCell, entity);
|
||||
|
||||
if (spawnedEntity != null)
|
||||
{
|
||||
// Spawned child will not be of the same type as the current prefabAsset
|
||||
if (placeholdersGroupsByClassId.ContainsKey(spawnedEntity.ClassId))
|
||||
{
|
||||
spawnedEntity = new PlaceholderGroupWorldEntity(spawnedEntity, i);
|
||||
}
|
||||
else
|
||||
{
|
||||
spawnedEntity = new PrefabPlaceholderEntity(spawnedEntity, i);
|
||||
}
|
||||
entity.ChildEntities.Add(spawnedEntity);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Regular visible GameObject
|
||||
string prefabClassId = prefabAsset.ClassId;
|
||||
if (prefabAsset is PrefabPlaceholderRandomAsset randomAsset && randomAsset.ClassIds.Count > 0)
|
||||
{
|
||||
int randomIndex = XORRandom.NextIntRange(0, randomAsset.ClassIds.Count);
|
||||
prefabClassId = randomAsset.ClassIds[randomIndex];
|
||||
}
|
||||
|
||||
EntitySpawnPoint esp = new(entity.AbsoluteEntityCell, prefabAsset.Transform.LocalPosition, prefabAsset.Transform.LocalRotation, prefabAsset.Transform.LocalScale, prefabClassId);
|
||||
WorldEntity spawnedEntity = (WorldEntity)SpawnEntitiesStaticly(esp, deterministicBatchGenerator, entity).First();
|
||||
if (prefabAsset is PrefabPlaceholdersGroupAsset)
|
||||
{
|
||||
spawnedEntity = new PlaceholderGroupWorldEntity(spawnedEntity, i);
|
||||
}
|
||||
else
|
||||
{
|
||||
spawnedEntity = new PrefabPlaceholderEntity(spawnedEntity, i);
|
||||
}
|
||||
|
||||
entity.ChildEntities.Add(spawnedEntity);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private WorldEntity SpawnPrefabAssetInEntitySlot(NitroxTransform transform, NitroxEntitySlot entitySlot, DeterministicGenerator deterministicBatchGenerator, AbsoluteEntityCell cell, Entity parentEntity)
|
||||
{
|
||||
if (!prefabFactory.TryGetPossiblePrefabs(entitySlot.BiomeType, out List<UwePrefab> prefabs) || prefabs.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
List<Entity> entities = [];
|
||||
|
||||
EntitySpawnPoint entitySpawnPoint = new(cell, transform.LocalPosition, transform.LocalRotation, entitySlot.AllowedTypes.ToList(), 1f, entitySlot.BiomeType);
|
||||
entities.AddRange(SpawnEntitiesUsingRandomDistribution(entitySpawnPoint, prefabs, deterministicBatchGenerator, parentEntity));
|
||||
if (entities.Count > 0)
|
||||
{
|
||||
return (WorldEntity)entities[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
44
NitroxServer/GameLogic/Entities/Spawning/EntitySpawnPoint.cs
Normal file
44
NitroxServer/GameLogic/Entities/Spawning/EntitySpawnPoint.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using System.Collections.Generic;
|
||||
using NitroxModel.DataStructures.GameLogic;
|
||||
using NitroxModel.DataStructures.Unity;
|
||||
|
||||
namespace NitroxServer.GameLogic.Entities.Spawning;
|
||||
|
||||
public class EntitySpawnPoint
|
||||
{
|
||||
// Fields from EntitySlotData
|
||||
public string BiomeType { get; }
|
||||
public List<string> AllowedTypes { get; }
|
||||
public float Density { get; }
|
||||
public NitroxVector3 LocalPosition { get; set; }
|
||||
public NitroxQuaternion LocalRotation { get; set; }
|
||||
|
||||
public readonly List<EntitySpawnPoint> Children = new List<EntitySpawnPoint>();
|
||||
public AbsoluteEntityCell AbsoluteEntityCell { get; }
|
||||
public NitroxVector3 Scale { get; protected set; }
|
||||
public string ClassId { get; }
|
||||
public bool CanSpawnCreature { get; private set; }
|
||||
public EntitySpawnPoint Parent { get; set; }
|
||||
|
||||
public EntitySpawnPoint(AbsoluteEntityCell absoluteEntityCell, NitroxVector3 localPosition, NitroxQuaternion localRotation, List<string> allowedTypes, float density, string biomeType)
|
||||
{
|
||||
AbsoluteEntityCell = absoluteEntityCell;
|
||||
LocalPosition = localPosition;
|
||||
LocalRotation = localRotation;
|
||||
BiomeType = biomeType;
|
||||
Density = density;
|
||||
AllowedTypes = allowedTypes;
|
||||
}
|
||||
|
||||
public EntitySpawnPoint(AbsoluteEntityCell absoluteEntityCell, NitroxVector3 localPosition, NitroxQuaternion localRotation, NitroxVector3 scale, string classId)
|
||||
{
|
||||
AbsoluteEntityCell = absoluteEntityCell;
|
||||
ClassId = classId;
|
||||
Density = 1;
|
||||
LocalPosition = localPosition;
|
||||
Scale = scale;
|
||||
LocalRotation = localRotation;
|
||||
}
|
||||
|
||||
public override string ToString() => $"[EntitySpawnPoint - {AbsoluteEntityCell}, Local Position: {LocalPosition}, Local Rotation: {LocalRotation}, Scale: {Scale}, Class Id: {ClassId}, Biome Type: {BiomeType}, Density: {Density}, Can Spawn Creature: {CanSpawnCreature}]";
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Collections.Generic;
|
||||
using NitroxModel.DataStructures.GameLogic;
|
||||
using NitroxModel.DataStructures.Unity;
|
||||
using NitroxServer.UnityStubs;
|
||||
|
||||
namespace NitroxServer.GameLogic.Entities.Spawning;
|
||||
|
||||
public abstract class EntitySpawnPointFactory
|
||||
{
|
||||
public abstract List<EntitySpawnPoint> From(AbsoluteEntityCell absoluteEntityCell, NitroxTransform transform, GameObject gameObject);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using NitroxModel.DataStructures.GameLogic.Entities;
|
||||
using NitroxServer.Helper;
|
||||
|
||||
namespace NitroxServer.GameLogic.Entities.Spawning;
|
||||
|
||||
public interface IEntityBootstrapper
|
||||
{
|
||||
public void Prepare(ref WorldEntity spawnedEntity, DeterministicGenerator generator);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using NitroxModel.DataStructures.GameLogic.Entities;
|
||||
using NitroxServer.Helper;
|
||||
|
||||
namespace NitroxServer.GameLogic.Entities.Spawning;
|
||||
|
||||
public interface IEntityBootstrapperManager
|
||||
{
|
||||
public void PrepareEntityIfRequired(ref WorldEntity spawnedEntity, DeterministicGenerator generator);
|
||||
}
|
||||
11
NitroxServer/GameLogic/Entities/Spawning/IEntitySpawner.cs
Normal file
11
NitroxServer/GameLogic/Entities/Spawning/IEntitySpawner.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using System.Collections.Generic;
|
||||
using NitroxModel.DataStructures;
|
||||
using NitroxModel.DataStructures.GameLogic;
|
||||
|
||||
namespace NitroxServer.GameLogic.Entities.Spawning
|
||||
{
|
||||
public interface IEntitySpawner
|
||||
{
|
||||
List<Entity> LoadUnspawnedEntities(NitroxInt3 batchId, bool fullCacheCreation = false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Collections.Generic;
|
||||
using NitroxModel.DataStructures;
|
||||
using NitroxModel.DataStructures.GameLogic;
|
||||
using NitroxModel.DataStructures.GameLogic.Entities;
|
||||
using NitroxModel.DataStructures.Unity;
|
||||
|
||||
namespace NitroxServer.GameLogic.Entities.Spawning;
|
||||
|
||||
/// <summary>
|
||||
/// Specific type of <see cref="EntitySpawnPoint"/> for spawning <see cref="SerializedWorldEntity"/>
|
||||
/// </summary>
|
||||
public class SerializedEntitySpawnPoint : EntitySpawnPoint
|
||||
{
|
||||
public List<SerializedComponent> SerializedComponents { get; }
|
||||
public int Layer { get; }
|
||||
|
||||
public SerializedEntitySpawnPoint(List<SerializedComponent> serializedComponents, int layer, AbsoluteEntityCell absoluteEntityCell, NitroxTransform transform) : base(absoluteEntityCell, transform.LocalPosition, transform.LocalRotation, null, 1, null)
|
||||
{
|
||||
SerializedComponents = serializedComponents;
|
||||
Layer = layer;
|
||||
Scale = transform.LocalScale;
|
||||
}
|
||||
}
|
||||
381
NitroxServer/GameLogic/Entities/WorldEntityManager.cs
Normal file
381
NitroxServer/GameLogic/Entities/WorldEntityManager.cs
Normal file
@@ -0,0 +1,381 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using NitroxModel.Core;
|
||||
using NitroxModel.DataStructures;
|
||||
using NitroxModel.DataStructures.GameLogic;
|
||||
using NitroxModel.DataStructures.GameLogic.Entities;
|
||||
using NitroxModel.DataStructures.GameLogic.Entities.Metadata;
|
||||
using NitroxModel.DataStructures.Unity;
|
||||
using NitroxModel.DataStructures.Util;
|
||||
using NitroxModel.Helper;
|
||||
using NitroxModel.Packets;
|
||||
using NitroxServer.GameLogic.Entities.Spawning;
|
||||
|
||||
namespace NitroxServer.GameLogic.Entities;
|
||||
|
||||
/// <remarks>
|
||||
/// Regular <see cref="WorldEntity"/> are held in cells and should be registered in <see cref="worldEntitiesByBatchId"/> and <see cref="worldEntitiesByCell"/>.
|
||||
/// But <see cref="GlobalRootEntity"/> are held in their own root object (GlobalRoot) so they should never be registered in cells (they're seeable at all times).
|
||||
/// </remarks>
|
||||
public class WorldEntityManager
|
||||
{
|
||||
private readonly EntityRegistry entityRegistry;
|
||||
|
||||
/// <summary>
|
||||
/// World entities can disappear if you go out of range.
|
||||
/// </summary>
|
||||
private readonly Dictionary<AbsoluteEntityCell, Dictionary<NitroxId, WorldEntity>> worldEntitiesByCell;
|
||||
|
||||
/// <summary>
|
||||
/// Global root entities that are always visible.
|
||||
/// </summary>
|
||||
private readonly Dictionary<NitroxId, GlobalRootEntity> globalRootEntitiesById;
|
||||
|
||||
private readonly BatchEntitySpawner batchEntitySpawner;
|
||||
private readonly PlayerManager playerManager;
|
||||
|
||||
private readonly object worldEntitiesLock;
|
||||
private readonly object globalRootEntitiesLock;
|
||||
|
||||
public WorldEntityManager(EntityRegistry entityRegistry, BatchEntitySpawner batchEntitySpawner, PlayerManager playerManager)
|
||||
{
|
||||
List<WorldEntity> worldEntities = entityRegistry.GetEntities<WorldEntity>();
|
||||
|
||||
globalRootEntitiesById = entityRegistry.GetEntities<GlobalRootEntity>().ToDictionary(entity => entity.Id);
|
||||
|
||||
worldEntitiesByCell = worldEntities.Where(entity => entity is not GlobalRootEntity)
|
||||
.GroupBy(entity => entity.AbsoluteEntityCell)
|
||||
.ToDictionary(group => group.Key, group => group.ToDictionary(entity => entity.Id, entity => entity));
|
||||
this.entityRegistry = entityRegistry;
|
||||
this.batchEntitySpawner = batchEntitySpawner;
|
||||
this.playerManager = playerManager;
|
||||
|
||||
worldEntitiesLock = new();
|
||||
globalRootEntitiesLock = new();
|
||||
}
|
||||
|
||||
public List<GlobalRootEntity> GetGlobalRootEntities(bool rootOnly = false)
|
||||
{
|
||||
if (rootOnly)
|
||||
{
|
||||
return GetGlobalRootEntities<GlobalRootEntity>().Where(entity => entity.ParentId == null).ToList();
|
||||
}
|
||||
return GetGlobalRootEntities<GlobalRootEntity>();
|
||||
}
|
||||
|
||||
public List<T> GetGlobalRootEntities<T>() where T : GlobalRootEntity
|
||||
{
|
||||
lock (globalRootEntitiesLock)
|
||||
{
|
||||
return new(globalRootEntitiesById.Values.OfType<T>());
|
||||
}
|
||||
}
|
||||
|
||||
public List<GlobalRootEntity> GetPersistentGlobalRootEntities()
|
||||
{
|
||||
// TODO: refactor if there are more entities that should not be persisted
|
||||
return GetGlobalRootEntities(true).Where(entity =>
|
||||
{
|
||||
if (entity.Metadata is CyclopsMetadata cyclopsMetadata)
|
||||
{
|
||||
// Do not save cyclops wrecks
|
||||
return !cyclopsMetadata.IsDestroyed;
|
||||
}
|
||||
|
||||
return true;
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
public List<WorldEntity> GetEntities(AbsoluteEntityCell cell)
|
||||
{
|
||||
lock (worldEntitiesLock)
|
||||
{
|
||||
if (worldEntitiesByCell.TryGetValue(cell, out Dictionary<NitroxId, WorldEntity> batchEntities))
|
||||
{
|
||||
return batchEntities.Values.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
public bool TryUpdateEntityPosition(NitroxId id, NitroxVector3 position, NitroxQuaternion rotation, out AbsoluteEntityCell newCell, out WorldEntity worldEntity)
|
||||
{
|
||||
lock (worldEntitiesLock)
|
||||
{
|
||||
if (!entityRegistry.TryGetEntityById(id, out worldEntity))
|
||||
{
|
||||
Log.WarnOnce($"[{nameof(WorldEntityManager)}] Can't update entity position of {id} because it isn't registered");
|
||||
newCell = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
AbsoluteEntityCell oldCell = worldEntity.AbsoluteEntityCell;
|
||||
|
||||
worldEntity.Transform.Position = position;
|
||||
worldEntity.Transform.Rotation = rotation;
|
||||
|
||||
newCell = worldEntity.AbsoluteEntityCell;
|
||||
|
||||
if (oldCell != newCell)
|
||||
{
|
||||
EntitySwitchedCells(worldEntity, oldCell, newCell);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<Entity> RemoveGlobalRootEntity(NitroxId entityId, bool removeFromRegistry = true)
|
||||
{
|
||||
Optional<Entity> removedEntity = Optional.Empty;
|
||||
lock (globalRootEntitiesLock)
|
||||
{
|
||||
if (removeFromRegistry)
|
||||
{
|
||||
// In case there were player entities under the removed entity, we need to reparent them to the GlobalRoot
|
||||
// to make sure that they won't be removed
|
||||
if (entityRegistry.TryGetEntityById(entityId, out GlobalRootEntity globalRootEntity))
|
||||
{
|
||||
MovePlayerChildrenToRoot(globalRootEntity);
|
||||
}
|
||||
removedEntity = entityRegistry.RemoveEntity(entityId);
|
||||
}
|
||||
globalRootEntitiesById.Remove(entityId);
|
||||
}
|
||||
return removedEntity;
|
||||
}
|
||||
|
||||
public void MovePlayerChildrenToRoot(GlobalRootEntity globalRootEntity)
|
||||
{
|
||||
List<PlayerWorldEntity> playerEntities = FindPlayerEntitiesInChildren(globalRootEntity);
|
||||
foreach (PlayerWorldEntity childPlayerEntity in playerEntities)
|
||||
{
|
||||
// Reparent the entity on top of GlobalRoot
|
||||
globalRootEntity.ChildEntities.Remove(childPlayerEntity);
|
||||
childPlayerEntity.ParentId = null;
|
||||
|
||||
// Make sure the PlayerEntity is correctly registered
|
||||
AddOrUpdateGlobalRootEntity(childPlayerEntity);
|
||||
}
|
||||
}
|
||||
|
||||
public void TrackEntityInTheWorld(WorldEntity entity)
|
||||
{
|
||||
if (entity is GlobalRootEntity globalRootEntity)
|
||||
{
|
||||
AddOrUpdateGlobalRootEntity(globalRootEntity, false);
|
||||
return;
|
||||
}
|
||||
|
||||
RegisterWorldEntity(entity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Automatically registers a WorldEntity in its AbsoluteEntityCell
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The provided should NOT be a GlobalRootEntity (they don't stand in cells)
|
||||
/// </remarks>
|
||||
public void RegisterWorldEntity(WorldEntity entity)
|
||||
{
|
||||
RegisterWorldEntityInCell(entity, entity.AbsoluteEntityCell);
|
||||
}
|
||||
|
||||
public void RegisterWorldEntityInCell(WorldEntity entity, AbsoluteEntityCell cell)
|
||||
{
|
||||
lock (worldEntitiesLock)
|
||||
{
|
||||
if (!worldEntitiesByCell.TryGetValue(cell, out Dictionary<NitroxId, WorldEntity> worldEntitiesInCell))
|
||||
{
|
||||
worldEntitiesInCell = worldEntitiesByCell[cell] = [];
|
||||
}
|
||||
worldEntitiesInCell[entity.Id] = entity;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Automatically unregisters a WorldEntity in its AbsoluteEntityCell
|
||||
/// </summary>
|
||||
public void UnregisterWorldEntity(WorldEntity entity)
|
||||
{
|
||||
UnregisterWorldEntityFromCell(entity.Id, entity.AbsoluteEntityCell);
|
||||
}
|
||||
|
||||
public void UnregisterWorldEntityFromCell(NitroxId entityId, AbsoluteEntityCell cell)
|
||||
{
|
||||
lock (worldEntitiesLock)
|
||||
{
|
||||
if (worldEntitiesByCell.TryGetValue(cell, out Dictionary<NitroxId, WorldEntity> worldEntitiesInCell))
|
||||
{
|
||||
worldEntitiesInCell.Remove(entityId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void LoadAllUnspawnedEntities(System.Threading.CancellationToken token)
|
||||
{
|
||||
IMap map = NitroxServiceLocator.LocateService<IMap>();
|
||||
|
||||
int totalBatches = map.DimensionsInBatches.X * map.DimensionsInBatches.Y * map.DimensionsInBatches.Z;
|
||||
int batchesLoaded = 0;
|
||||
|
||||
for (int x = 0; x < map.DimensionsInBatches.X; x++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
for (int y = 0; y < map.DimensionsInBatches.Y; y++)
|
||||
{
|
||||
for (int z = 0; z < map.DimensionsInBatches.Z; z++)
|
||||
{
|
||||
int spawned = LoadUnspawnedEntities(new(x, y, z), true);
|
||||
|
||||
Log.Debug($"Loaded {spawned} entities from batch ({x}, {y}, {z})");
|
||||
|
||||
batchesLoaded++;
|
||||
}
|
||||
}
|
||||
|
||||
if (batchesLoaded > 0)
|
||||
{
|
||||
Log.Info($"Loading : {(int)(100f * batchesLoaded / totalBatches)}%");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int LoadUnspawnedEntities(NitroxInt3 batchId, bool suppressLogs)
|
||||
{
|
||||
List<Entity> spawnedEntities = batchEntitySpawner.LoadUnspawnedEntities(batchId, suppressLogs);
|
||||
|
||||
List<WorldEntity> entitiesInCells = spawnedEntities.Where(entity => typeof(WorldEntity).IsAssignableFrom(entity.GetType()) &&
|
||||
entity.GetType() != typeof(CellRootEntity) &&
|
||||
entity.GetType() != typeof(GlobalRootEntity))
|
||||
.Cast<WorldEntity>()
|
||||
.ToList();
|
||||
|
||||
// UWE stores entities serialized with a handful of parent cell roots. These only represent a small fraction of all possible cell
|
||||
// roots that could exist. There is no reason for the server to know about these and much easier to consider top-level world entities
|
||||
// as positioned globally and not locally. Thus, we promote cell root children to top level and throw the cell roots away.
|
||||
foreach (CellRootEntity cellRoot in spawnedEntities.OfType<CellRootEntity>())
|
||||
{
|
||||
foreach (WorldEntity worldEntity in cellRoot.ChildEntities.Cast<WorldEntity>())
|
||||
{
|
||||
worldEntity.ParentId = null;
|
||||
worldEntity.Transform.SetParent(null, true);
|
||||
entitiesInCells.Add(worldEntity);
|
||||
}
|
||||
|
||||
cellRoot.ChildEntities = new List<Entity>();
|
||||
}
|
||||
// Specific type of entities which is not parented to a CellRootEntity
|
||||
entitiesInCells.AddRange(spawnedEntities.OfType<SerializedWorldEntity>());
|
||||
|
||||
entityRegistry.AddEntitiesIgnoringDuplicate(entitiesInCells.OfType<Entity>().ToList());
|
||||
|
||||
foreach (WorldEntity entity in entitiesInCells)
|
||||
{
|
||||
RegisterWorldEntity(entity);
|
||||
}
|
||||
|
||||
return entitiesInCells.Count;
|
||||
}
|
||||
|
||||
private void EntitySwitchedCells(WorldEntity entity, AbsoluteEntityCell oldCell, AbsoluteEntityCell newCell)
|
||||
{
|
||||
if (entity is GlobalRootEntity)
|
||||
{
|
||||
return; // We don't care what cell a global root entity resides in. Only phasing entities.
|
||||
}
|
||||
|
||||
if (oldCell != newCell)
|
||||
{
|
||||
lock (worldEntitiesLock)
|
||||
{
|
||||
// Specifically remove entity from oldCell
|
||||
UnregisterWorldEntityFromCell(entity.Id, oldCell);
|
||||
|
||||
// Automatically add entity to its new cell
|
||||
RegisterWorldEntityInCell(entity, newCell);
|
||||
|
||||
// It can happen for some players that the entity moves to a loaded cell of theirs, but that they hadn't spawned it in the first place
|
||||
foreach (Player player in playerManager.ConnectedPlayers())
|
||||
{
|
||||
if (player.HasCellLoaded(newCell) && !player.HasCellLoaded(oldCell))
|
||||
{
|
||||
player.SendPacket(new SpawnEntities(entity));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void StopTrackingEntity(WorldEntity entity)
|
||||
{
|
||||
if (entity is GlobalRootEntity)
|
||||
{
|
||||
RemoveGlobalRootEntity(entity.Id, false);
|
||||
}
|
||||
else
|
||||
{
|
||||
UnregisterWorldEntity(entity);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryDestroyEntity(NitroxId entityId, out Entity entity)
|
||||
{
|
||||
Optional<Entity> optEntity = entityRegistry.RemoveEntity(entityId);
|
||||
|
||||
if (!optEntity.HasValue)
|
||||
{
|
||||
entity = null;
|
||||
return false;
|
||||
}
|
||||
entity = optEntity.Value;
|
||||
|
||||
if (entity is WorldEntity worldEntity)
|
||||
{
|
||||
StopTrackingEntity(worldEntity);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// To avoid risking not having the same entity in <see cref="globalRootEntitiesById"/> and in EntityRegistry, we update both at the same time.
|
||||
/// </summary>
|
||||
public void AddOrUpdateGlobalRootEntity(GlobalRootEntity entity, bool addOrUpdateRegistry = true)
|
||||
{
|
||||
lock (globalRootEntitiesLock)
|
||||
{
|
||||
if (addOrUpdateRegistry)
|
||||
{
|
||||
entityRegistry.AddOrUpdate(entity);
|
||||
}
|
||||
globalRootEntitiesById[entity.Id] = entity;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Iterative breadth-first search which gets all children player entities in <paramref name="parentEntity"/>'s hierarchy.
|
||||
/// </summary>
|
||||
private List<PlayerWorldEntity> FindPlayerEntitiesInChildren(Entity parentEntity)
|
||||
{
|
||||
List<PlayerWorldEntity> playerWorldEntities = [];
|
||||
List<Entity> entitiesToSearch = [parentEntity];
|
||||
|
||||
while (entitiesToSearch.Count > 0)
|
||||
{
|
||||
Entity currentEntity = entitiesToSearch[^1];
|
||||
entitiesToSearch.RemoveAt(entitiesToSearch.Count - 1);
|
||||
|
||||
if (currentEntity is PlayerWorldEntity playerWorldEntity)
|
||||
{
|
||||
playerWorldEntities.Add(playerWorldEntity);
|
||||
}
|
||||
else
|
||||
{
|
||||
entitiesToSearch.InsertRange(0, currentEntity.ChildEntities);
|
||||
}
|
||||
}
|
||||
return playerWorldEntities;
|
||||
}
|
||||
}
|
||||
155
NitroxServer/GameLogic/EscapePodManager.cs
Normal file
155
NitroxServer/GameLogic/EscapePodManager.cs
Normal file
@@ -0,0 +1,155 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using NitroxModel.DataStructures;
|
||||
using NitroxModel.DataStructures.GameLogic;
|
||||
using NitroxModel.DataStructures.GameLogic.Entities;
|
||||
using NitroxModel.DataStructures.GameLogic.Entities.Metadata;
|
||||
using NitroxModel.DataStructures.Unity;
|
||||
using NitroxModel.DataStructures.Util;
|
||||
using NitroxServer.GameLogic.Entities;
|
||||
|
||||
namespace NitroxServer.GameLogic;
|
||||
|
||||
public class EscapePodManager
|
||||
{
|
||||
private const int PLAYERS_PER_ESCAPEPOD = 50;
|
||||
|
||||
private readonly EntityRegistry entityRegistry;
|
||||
private readonly ThreadSafeDictionary<ushort, EscapePodWorldEntity> escapePodsByPlayerId = new();
|
||||
private EscapePodWorldEntity podForNextPlayer;
|
||||
private readonly string seed;
|
||||
|
||||
private readonly RandomStartGenerator randomStart;
|
||||
|
||||
public EscapePodManager(EntityRegistry entityRegistry, RandomStartGenerator randomStart, string seed)
|
||||
{
|
||||
this.seed = seed;
|
||||
this.randomStart = randomStart;
|
||||
this.entityRegistry = entityRegistry;
|
||||
|
||||
List<EscapePodWorldEntity> escapePods = entityRegistry.GetEntities<EscapePodWorldEntity>();
|
||||
|
||||
InitializePodForNextPlayer(escapePods);
|
||||
InitializeEscapePodsByPlayerId(escapePods);
|
||||
}
|
||||
|
||||
public NitroxId AssignPlayerToEscapePod(ushort playerId, out Optional<EscapePodWorldEntity> newlyCreatedPod)
|
||||
{
|
||||
newlyCreatedPod = Optional.Empty;
|
||||
|
||||
if (escapePodsByPlayerId.TryGetValue(playerId, out EscapePodWorldEntity podEntity))
|
||||
{
|
||||
return podEntity.Id;
|
||||
}
|
||||
|
||||
if (IsPodFull(podForNextPlayer))
|
||||
{
|
||||
newlyCreatedPod = Optional.Of(CreateNewEscapePod());
|
||||
podForNextPlayer = newlyCreatedPod.Value;
|
||||
}
|
||||
|
||||
podForNextPlayer.Players.Add(playerId);
|
||||
escapePodsByPlayerId[playerId] = podForNextPlayer;
|
||||
|
||||
return podForNextPlayer.Id;
|
||||
}
|
||||
|
||||
private EscapePodWorldEntity CreateNewEscapePod()
|
||||
{
|
||||
EscapePodWorldEntity escapePod = new(GetStartPosition(), new NitroxId(), new EscapePodMetadata(false, false));
|
||||
|
||||
escapePod.ChildEntities.Add(new PrefabChildEntity(new NitroxId(), "5c06baec-0539-4f26-817d-78443548cc52", new NitroxTechType("Radio"), 0, null, escapePod.Id));
|
||||
escapePod.ChildEntities.Add(new PrefabChildEntity(new NitroxId(), "c0175cf7-0b6a-4a1d-938f-dad0dbb6fa06", new NitroxTechType("MedicalCabinet"), 0, null, escapePod.Id));
|
||||
escapePod.ChildEntities.Add(new PrefabChildEntity(new NitroxId(), "9f16d82b-11f4-4eeb-aedf-f2fa2bfca8e3", new NitroxTechType("Fabricator"), 0, null, escapePod.Id));
|
||||
escapePod.ChildEntities.Add(new InventoryEntity(0, new NitroxId(), new NitroxTechType("SmallStorage"), null, escapePod.Id, []));
|
||||
|
||||
entityRegistry.AddOrUpdate(escapePod);
|
||||
|
||||
return escapePod;
|
||||
}
|
||||
|
||||
private NitroxVector3 GetStartPosition()
|
||||
{
|
||||
List<EscapePodWorldEntity> escapePods = entityRegistry.GetEntities<EscapePodWorldEntity>();
|
||||
|
||||
Random rnd = new(seed.GetHashCode());
|
||||
NitroxVector3 position = randomStart.GenerateRandomStartPosition(rnd);
|
||||
|
||||
if (escapePods.Count == 0)
|
||||
{
|
||||
return position;
|
||||
}
|
||||
|
||||
foreach (EscapePodWorldEntity escapePodModel in escapePods)
|
||||
{
|
||||
if (position == NitroxVector3.Zero)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (escapePodModel.Transform.Position != position)
|
||||
{
|
||||
return position;
|
||||
}
|
||||
}
|
||||
|
||||
float xNormed = (float)rnd.NextDouble();
|
||||
float zNormed = (float)rnd.NextDouble();
|
||||
|
||||
if (xNormed < 0.3f)
|
||||
{
|
||||
xNormed = 0.3f;
|
||||
}
|
||||
else if (xNormed > 0.7f)
|
||||
{
|
||||
xNormed = 0.7f;
|
||||
}
|
||||
|
||||
if (zNormed < 0.3f)
|
||||
{
|
||||
zNormed = 0.3f;
|
||||
}
|
||||
else if (zNormed > 0.7f)
|
||||
{
|
||||
zNormed = 0.7f;
|
||||
}
|
||||
|
||||
NitroxVector3 lastEscapePodPosition = escapePods[escapePods.Count - 1].Transform.Position;
|
||||
|
||||
float x = xNormed * 100 - 50;
|
||||
float z = zNormed * 100 - 50;
|
||||
|
||||
return new NitroxVector3(lastEscapePodPosition.X + x, 0, lastEscapePodPosition.Z + z);
|
||||
}
|
||||
|
||||
private void InitializePodForNextPlayer(List<EscapePodWorldEntity> escapePods)
|
||||
{
|
||||
foreach (EscapePodWorldEntity pod in escapePods)
|
||||
{
|
||||
if (!IsPodFull(pod))
|
||||
{
|
||||
podForNextPlayer = pod;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
podForNextPlayer = CreateNewEscapePod();
|
||||
}
|
||||
|
||||
private void InitializeEscapePodsByPlayerId(List<EscapePodWorldEntity> escapePods)
|
||||
{
|
||||
escapePodsByPlayerId.Clear();
|
||||
foreach (EscapePodWorldEntity pod in escapePods)
|
||||
{
|
||||
foreach (ushort playerId in pod.Players)
|
||||
{
|
||||
escapePodsByPlayerId[playerId] = pod;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsPodFull(EscapePodWorldEntity pod)
|
||||
{
|
||||
return pod.Players.Count >= PLAYERS_PER_ESCAPEPOD;
|
||||
}
|
||||
}
|
||||
30
NitroxServer/GameLogic/GameData.cs
Normal file
30
NitroxServer/GameLogic/GameData.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using System;
|
||||
using System.Runtime.Serialization;
|
||||
using NitroxServer.GameLogic.Unlockables;
|
||||
|
||||
namespace NitroxServer.GameLogic.Bases
|
||||
{
|
||||
[Serializable]
|
||||
[DataContract]
|
||||
public class GameData
|
||||
{
|
||||
[DataMember(Order = 1)]
|
||||
public PDAStateData PDAState { get; set; }
|
||||
|
||||
[DataMember(Order = 2)]
|
||||
public StoryGoalData StoryGoals { get; set; }
|
||||
|
||||
[DataMember(Order = 3)]
|
||||
public StoryTimingData StoryTiming { get; set; }
|
||||
|
||||
public static GameData From(PDAStateData pdaState, StoryGoalData storyGoals, ScheduleKeeper scheduleKeeper, StoryManager storyManager, TimeKeeper timeKeeper)
|
||||
{
|
||||
return new GameData
|
||||
{
|
||||
PDAState = pdaState,
|
||||
StoryGoals = StoryGoalData.From(storyGoals, scheduleKeeper),
|
||||
StoryTiming = StoryTimingData.From(storyManager, timeKeeper)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
11
NitroxServer/GameLogic/IWorldModifier.cs
Normal file
11
NitroxServer/GameLogic/IWorldModifier.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using NitroxServer.Serialization.World;
|
||||
|
||||
namespace NitroxServer.GameLogic;
|
||||
|
||||
/// <summary>
|
||||
/// Holds a set of instructions to be ran when a world is created. There should be one Subnautica and one for BZ.
|
||||
/// </summary>
|
||||
public interface IWorldModifier
|
||||
{
|
||||
public void ModifyWorld(World world);
|
||||
}
|
||||
35
NitroxServer/GameLogic/InitialSyncTimerData.cs
Normal file
35
NitroxServer/GameLogic/InitialSyncTimerData.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
using NitroxModel.MultiplayerSession;
|
||||
using NitroxServer.Communication;
|
||||
|
||||
namespace NitroxServer.GameLogic
|
||||
{
|
||||
/// <summary>
|
||||
/// Contains data used in InitialSyncTimer callback
|
||||
///
|
||||
/// For use with <see cref="System.Threading.Timer"/>
|
||||
/// </summary>
|
||||
internal class InitialSyncTimerData
|
||||
{
|
||||
public readonly INitroxConnection Connection;
|
||||
public readonly AuthenticationContext Context;
|
||||
public readonly int MaxCounter;
|
||||
|
||||
/// <summary>
|
||||
/// Keeps track of how many times the timer has elapsed
|
||||
/// </summary>
|
||||
public int Counter = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Set to true if disposing the timer
|
||||
/// </summary>
|
||||
public bool Disposing = false;
|
||||
|
||||
public InitialSyncTimerData(INitroxConnection connection, AuthenticationContext context, int initialSyncTimeout)
|
||||
{
|
||||
Connection = connection;
|
||||
Context = context;
|
||||
MaxCounter = (int)Math.Ceiling(initialSyncTimeout / 200f);
|
||||
}
|
||||
}
|
||||
}
|
||||
359
NitroxServer/GameLogic/PlayerManager.cs
Normal file
359
NitroxServer/GameLogic/PlayerManager.cs
Normal file
@@ -0,0 +1,359 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using NitroxModel.DataStructures;
|
||||
using NitroxModel.DataStructures.GameLogic;
|
||||
using NitroxModel.DataStructures.Unity;
|
||||
using NitroxModel.DataStructures.Util;
|
||||
using NitroxModel.MultiplayerSession;
|
||||
using NitroxModel.Packets;
|
||||
using NitroxModel.Server;
|
||||
using NitroxModel.Serialization;
|
||||
using NitroxServer.Communication;
|
||||
|
||||
namespace NitroxServer.GameLogic
|
||||
{
|
||||
// TODO: These methods are a little chunky. Need to look at refactoring just to clean them up and get them around 30 lines a piece.
|
||||
public class PlayerManager
|
||||
{
|
||||
private readonly ThreadSafeDictionary<string, Player> allPlayersByName;
|
||||
private readonly ThreadSafeDictionary<ushort, Player> connectedPlayersById = [];
|
||||
private readonly ThreadSafeDictionary<INitroxConnection, ConnectionAssets> assetsByConnection = new();
|
||||
private readonly ThreadSafeDictionary<string, PlayerContext> reservations = new();
|
||||
private readonly ThreadSafeSet<string> reservedPlayerNames = new("Player"); // "Player" is often used to identify the local player and should not be used by any user
|
||||
|
||||
private ThreadSafeQueue<KeyValuePair<INitroxConnection, MultiplayerSessionReservationRequest>> JoinQueue { get; set; } = new();
|
||||
private bool PlayerCurrentlyJoining { get; set; }
|
||||
|
||||
private Timer initialSyncTimer;
|
||||
|
||||
private readonly SubnauticaServerConfig serverConfig;
|
||||
private ushort currentPlayerId;
|
||||
|
||||
public PlayerManager(List<Player> players, SubnauticaServerConfig serverConfig)
|
||||
{
|
||||
allPlayersByName = new ThreadSafeDictionary<string, Player>(players.ToDictionary(x => x.Name), false);
|
||||
currentPlayerId = players.Count == 0 ? (ushort)0 : players.Max(x => x.Id);
|
||||
|
||||
this.serverConfig = serverConfig;
|
||||
}
|
||||
|
||||
public List<Player> GetConnectedPlayers()
|
||||
{
|
||||
return ConnectedPlayers().ToList();
|
||||
}
|
||||
|
||||
public List<Player> GetConnectedPlayersExcept(Player excludePlayer)
|
||||
{
|
||||
return ConnectedPlayers().Where(player => player != excludePlayer).ToList();
|
||||
}
|
||||
|
||||
public IEnumerable<Player> GetAllPlayers()
|
||||
{
|
||||
return allPlayersByName.Values;
|
||||
}
|
||||
|
||||
public MultiplayerSessionReservation ReservePlayerContext(
|
||||
INitroxConnection connection,
|
||||
PlayerSettings playerSettings,
|
||||
AuthenticationContext authenticationContext,
|
||||
string correlationId)
|
||||
{
|
||||
if (reservedPlayerNames.Count >= serverConfig.MaxConnections)
|
||||
{
|
||||
MultiplayerSessionReservationState rejectedState = MultiplayerSessionReservationState.REJECTED | MultiplayerSessionReservationState.SERVER_PLAYER_CAPACITY_REACHED;
|
||||
return new MultiplayerSessionReservation(correlationId, rejectedState);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(serverConfig.ServerPassword) && (!authenticationContext.ServerPassword.HasValue || authenticationContext.ServerPassword.Value != serverConfig.ServerPassword))
|
||||
{
|
||||
MultiplayerSessionReservationState rejectedState = MultiplayerSessionReservationState.REJECTED | MultiplayerSessionReservationState.AUTHENTICATION_FAILED;
|
||||
return new MultiplayerSessionReservation(correlationId, rejectedState);
|
||||
}
|
||||
|
||||
//https://regex101.com/r/eTWiEs/2/
|
||||
if (!Regex.IsMatch(authenticationContext.Username, @"^[a-zA-Z0-9._-]{3,25}$"))
|
||||
{
|
||||
MultiplayerSessionReservationState rejectedState = MultiplayerSessionReservationState.REJECTED | MultiplayerSessionReservationState.INCORRECT_USERNAME;
|
||||
return new MultiplayerSessionReservation(correlationId, rejectedState);
|
||||
}
|
||||
|
||||
if (PlayerCurrentlyJoining)
|
||||
{
|
||||
if (JoinQueue.Any(pair => ReferenceEquals(pair.Key, connection)))
|
||||
{
|
||||
// Don't enqueue the request if there is already another enqueued request by the same user
|
||||
return new MultiplayerSessionReservation(correlationId, MultiplayerSessionReservationState.REJECTED);
|
||||
}
|
||||
|
||||
JoinQueue.Enqueue(new KeyValuePair<INitroxConnection, MultiplayerSessionReservationRequest>(
|
||||
connection,
|
||||
new MultiplayerSessionReservationRequest(correlationId, playerSettings, authenticationContext)));
|
||||
|
||||
return new MultiplayerSessionReservation(correlationId, MultiplayerSessionReservationState.ENQUEUED_IN_JOIN_QUEUE);
|
||||
}
|
||||
|
||||
string playerName = authenticationContext.Username;
|
||||
|
||||
allPlayersByName.TryGetValue(playerName, out Player player);
|
||||
if (player?.IsPermaDeath == true && serverConfig.IsHardcore())
|
||||
{
|
||||
MultiplayerSessionReservationState rejectedState = MultiplayerSessionReservationState.REJECTED | MultiplayerSessionReservationState.HARDCORE_PLAYER_DEAD;
|
||||
return new MultiplayerSessionReservation(correlationId, rejectedState);
|
||||
}
|
||||
|
||||
if (reservedPlayerNames.Contains(playerName))
|
||||
{
|
||||
MultiplayerSessionReservationState rejectedState = MultiplayerSessionReservationState.REJECTED | MultiplayerSessionReservationState.UNIQUE_PLAYER_NAME_CONSTRAINT_VIOLATED;
|
||||
return new MultiplayerSessionReservation(correlationId, rejectedState);
|
||||
}
|
||||
|
||||
assetsByConnection.TryGetValue(connection, out ConnectionAssets assetPackage);
|
||||
if (assetPackage == null)
|
||||
{
|
||||
assetPackage = new ConnectionAssets();
|
||||
assetsByConnection.Add(connection, assetPackage);
|
||||
reservedPlayerNames.Add(playerName);
|
||||
}
|
||||
|
||||
bool hasSeenPlayerBefore = player != null;
|
||||
ushort playerId = hasSeenPlayerBefore ? player.Id : ++currentPlayerId;
|
||||
NitroxId playerNitroxId = hasSeenPlayerBefore ? player.GameObjectId : new NitroxId();
|
||||
NitroxGameMode gameMode = hasSeenPlayerBefore ? player.GameMode : serverConfig.GameMode;
|
||||
IntroCinematicMode introCinematicMode = hasSeenPlayerBefore ? IntroCinematicMode.COMPLETED : IntroCinematicMode.LOADING;
|
||||
|
||||
// TODO: At some point, store the muted state of a player
|
||||
PlayerContext playerContext = new(playerName, playerId, playerNitroxId, !hasSeenPlayerBefore, playerSettings, false, gameMode, null, introCinematicMode);
|
||||
string reservationKey = Guid.NewGuid().ToString();
|
||||
|
||||
reservations.Add(reservationKey, playerContext);
|
||||
assetPackage.ReservationKey = reservationKey;
|
||||
|
||||
PlayerCurrentlyJoining = true;
|
||||
|
||||
InitialSyncTimerData timerData = new InitialSyncTimerData(connection, authenticationContext, serverConfig.InitialSyncTimeout);
|
||||
initialSyncTimer = new Timer(InitialSyncTimerElapsed, timerData, 0, 200);
|
||||
|
||||
|
||||
return new MultiplayerSessionReservation(correlationId, playerId, reservationKey);
|
||||
}
|
||||
|
||||
private void InitialSyncTimerElapsed(object state)
|
||||
{
|
||||
if (state is InitialSyncTimerData timerData && !timerData.Disposing)
|
||||
{
|
||||
allPlayersByName.TryGetValue(timerData.Context.Username, out Player player);
|
||||
|
||||
if (timerData.Connection.State < NitroxConnectionState.Connected)
|
||||
{
|
||||
if (player == null) // player can cancel the joining process before this timer elapses
|
||||
{
|
||||
Log.Error("Player was nulled while joining");
|
||||
PlayerDisconnected(timerData.Connection);
|
||||
}
|
||||
else
|
||||
{
|
||||
player.SendPacket(new PlayerKicked("An error occured while loading, Initial sync took too long to complete"));
|
||||
PlayerDisconnected(player.Connection);
|
||||
SendPacketToOtherPlayers(new Disconnect(player.Id), player);
|
||||
}
|
||||
timerData.Disposing = true;
|
||||
FinishProcessingReservation();
|
||||
}
|
||||
|
||||
if (timerData.Counter >= timerData.MaxCounter)
|
||||
{
|
||||
Log.Error("An unexpected Error occured during InitialSync");
|
||||
PlayerDisconnected(timerData.Connection);
|
||||
|
||||
timerData.Disposing = true;
|
||||
initialSyncTimer.Dispose(); // Looped long enough to require an override
|
||||
}
|
||||
|
||||
timerData.Counter++;
|
||||
}
|
||||
}
|
||||
|
||||
public void NonPlayerDisconnected(INitroxConnection connection)
|
||||
{
|
||||
// Remove any requests sent by the connection from the join queue
|
||||
JoinQueue = new(JoinQueue.Where(pair => !Equals(pair.Key, connection)));
|
||||
}
|
||||
|
||||
public Player PlayerConnected(INitroxConnection connection, string reservationKey, out bool wasBrandNewPlayer)
|
||||
{
|
||||
PlayerContext playerContext = reservations[reservationKey];
|
||||
Validate.NotNull(playerContext);
|
||||
ConnectionAssets assetPackage = assetsByConnection[connection];
|
||||
Validate.NotNull(assetPackage);
|
||||
|
||||
wasBrandNewPlayer = playerContext.WasBrandNewPlayer;
|
||||
|
||||
if (!allPlayersByName.TryGetValue(playerContext.PlayerName, out Player player))
|
||||
{
|
||||
player = new Player(playerContext.PlayerId,
|
||||
playerContext.PlayerName,
|
||||
false,
|
||||
playerContext,
|
||||
connection,
|
||||
NitroxVector3.Zero,
|
||||
NitroxQuaternion.Identity,
|
||||
playerContext.PlayerNitroxId,
|
||||
Optional.Empty,
|
||||
serverConfig.DefaultPlayerPerm,
|
||||
serverConfig.DefaultPlayerStats,
|
||||
serverConfig.GameMode,
|
||||
new List<NitroxTechType>(),
|
||||
Array.Empty<Optional<NitroxId>>(),
|
||||
new Dictionary<string, NitroxId>(),
|
||||
new Dictionary<string, float>(),
|
||||
new Dictionary<string, PingInstancePreference>(),
|
||||
new List<int>()
|
||||
);
|
||||
allPlayersByName[playerContext.PlayerName] = player;
|
||||
}
|
||||
|
||||
connectedPlayersById.Add(playerContext.PlayerId, player);
|
||||
|
||||
// TODO: make a ConnectedPlayer wrapper so this is not stateful
|
||||
player.PlayerContext = playerContext;
|
||||
player.Connection = connection;
|
||||
|
||||
// reconnecting players need to have their cell visibility refreshed
|
||||
player.ClearVisibleCells();
|
||||
|
||||
assetPackage.Player = player;
|
||||
assetPackage.ReservationKey = null;
|
||||
reservations.Remove(reservationKey);
|
||||
|
||||
return player;
|
||||
}
|
||||
|
||||
public void PlayerDisconnected(INitroxConnection connection)
|
||||
{
|
||||
assetsByConnection.TryGetValue(connection, out ConnectionAssets assetPackage);
|
||||
if (assetPackage == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (assetPackage.ReservationKey != null)
|
||||
{
|
||||
PlayerContext playerContext = reservations[assetPackage.ReservationKey];
|
||||
reservedPlayerNames.Remove(playerContext.PlayerName);
|
||||
reservations.Remove(assetPackage.ReservationKey);
|
||||
}
|
||||
|
||||
if (assetPackage.Player != null)
|
||||
{
|
||||
Player player = assetPackage.Player;
|
||||
reservedPlayerNames.Remove(player.Name);
|
||||
connectedPlayersById.Remove(player.Id);
|
||||
}
|
||||
|
||||
assetsByConnection.Remove(connection);
|
||||
|
||||
if (!ConnectedPlayers().Any())
|
||||
{
|
||||
Server.Instance.PauseServer();
|
||||
Server.Instance.Save();
|
||||
}
|
||||
}
|
||||
|
||||
public void FinishProcessingReservation(Player player = null)
|
||||
{
|
||||
initialSyncTimer.Dispose();
|
||||
PlayerCurrentlyJoining = false;
|
||||
if (player != null)
|
||||
{
|
||||
BroadcastPlayerJoined(player);
|
||||
}
|
||||
|
||||
Log.Info($"Finished processing reservation. Remaining requests: {JoinQueue.Count}");
|
||||
|
||||
// Tell next client that it can start joining.
|
||||
if (JoinQueue.Count > 0)
|
||||
{
|
||||
KeyValuePair<INitroxConnection, MultiplayerSessionReservationRequest> keyValuePair = JoinQueue.Dequeue();
|
||||
INitroxConnection requestConnection = keyValuePair.Key;
|
||||
MultiplayerSessionReservationRequest reservationRequest = keyValuePair.Value;
|
||||
|
||||
MultiplayerSessionReservation reservation = ReservePlayerContext(requestConnection,
|
||||
reservationRequest.PlayerSettings,
|
||||
reservationRequest.AuthenticationContext,
|
||||
reservationRequest.CorrelationId);
|
||||
|
||||
requestConnection.SendPacket(reservation);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGetPlayerByName(string playerName, out Player foundPlayer)
|
||||
{
|
||||
foundPlayer = null;
|
||||
foreach (Player player in ConnectedPlayers())
|
||||
{
|
||||
if (player.Name == playerName)
|
||||
{
|
||||
foundPlayer = player;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryGetPlayerById(ushort playerId, out Player player)
|
||||
{
|
||||
return connectedPlayersById.TryGetValue(playerId, out player);
|
||||
}
|
||||
|
||||
public Player GetPlayer(INitroxConnection connection)
|
||||
{
|
||||
if (!assetsByConnection.TryGetValue(connection, out ConnectionAssets assetPackage))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return assetPackage.Player;
|
||||
}
|
||||
|
||||
public Optional<Player> GetPlayer(string playerName)
|
||||
{
|
||||
allPlayersByName.TryGetValue(playerName, out Player player);
|
||||
return Optional.OfNullable(player);
|
||||
}
|
||||
|
||||
public void SendPacketToAllPlayers(Packet packet)
|
||||
{
|
||||
foreach (Player player in ConnectedPlayers())
|
||||
{
|
||||
player.SendPacket(packet);
|
||||
}
|
||||
}
|
||||
|
||||
public void SendPacketToOtherPlayers(Packet packet, Player sendingPlayer)
|
||||
{
|
||||
foreach (Player player in ConnectedPlayers())
|
||||
{
|
||||
if (player != sendingPlayer)
|
||||
{
|
||||
player.SendPacket(packet);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public IEnumerable<Player> ConnectedPlayers()
|
||||
{
|
||||
return assetsByConnection.Values
|
||||
.Where(assetPackage => assetPackage.Player != null)
|
||||
.Select(assetPackage => assetPackage.Player);
|
||||
}
|
||||
|
||||
public void BroadcastPlayerJoined(Player player)
|
||||
{
|
||||
PlayerJoinedMultiplayerSession playerJoinedPacket = new(player.PlayerContext, player.SubRootId, player.Entity);
|
||||
SendPacketToOtherPlayers(playerJoinedPacket, player);
|
||||
}
|
||||
}
|
||||
}
|
||||
107
NitroxServer/GameLogic/Players/PersistedPlayerData.cs
Normal file
107
NitroxServer/GameLogic/Players/PersistedPlayerData.cs
Normal file
@@ -0,0 +1,107 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.Serialization;
|
||||
using NitroxModel.DataStructures;
|
||||
using NitroxModel.DataStructures.GameLogic;
|
||||
using NitroxModel.DataStructures.Unity;
|
||||
using NitroxModel.DataStructures.Util;
|
||||
using NitroxModel.Server;
|
||||
|
||||
namespace NitroxServer.GameLogic.Players;
|
||||
|
||||
[DataContract]
|
||||
public class PersistedPlayerData
|
||||
{
|
||||
[DataMember(Order = 1)]
|
||||
public string Name { get; set; }
|
||||
|
||||
[DataMember(Order = 2)]
|
||||
public List<NitroxTechType> UsedItems { get; set; } = [];
|
||||
|
||||
[DataMember(Order = 3)]
|
||||
public Optional<NitroxId>[] QuickSlotsBindingIds { get; set; } = [];
|
||||
|
||||
[DataMember(Order = 4)]
|
||||
public Dictionary<string, NitroxId> EquippedItems { get; set; } = [];
|
||||
|
||||
[DataMember(Order = 5)]
|
||||
public ushort Id { get; set; }
|
||||
|
||||
[DataMember(Order = 6)]
|
||||
public NitroxVector3 SpawnPosition { get; set; }
|
||||
|
||||
[DataMember(Order = 7)]
|
||||
public NitroxQuaternion SpawnRotation { get; set; }
|
||||
|
||||
[DataMember(Order = 8)]
|
||||
public PlayerStatsData CurrentStats { get; set; }
|
||||
|
||||
[DataMember(Order = 9)]
|
||||
public NitroxGameMode GameMode { get; set; }
|
||||
|
||||
[DataMember(Order = 10)]
|
||||
public NitroxId SubRootId { get; set; }
|
||||
|
||||
[DataMember(Order = 11)]
|
||||
public Perms Permissions { get; set; }
|
||||
|
||||
[DataMember(Order = 12)]
|
||||
public NitroxId NitroxId { get; set; }
|
||||
|
||||
[DataMember(Order = 13)]
|
||||
public bool IsPermaDeath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Those goals are unlocked individually (e.g. opening PDA, eating, picking up a fire extinguisher for the first time)
|
||||
/// </summary>
|
||||
[DataMember(Order = 15)]
|
||||
public Dictionary<string, float> PersonalCompletedGoalsWithTimestamp { get; set; } = [];
|
||||
|
||||
[DataMember(Order = 16)]
|
||||
public SubnauticaPlayerPreferences PlayerPreferences { get; set; }
|
||||
|
||||
public Player ToPlayer()
|
||||
{
|
||||
return new Player(Id,
|
||||
Name,
|
||||
IsPermaDeath,
|
||||
null, //no connection/context as this player is not connected.
|
||||
null,
|
||||
SpawnPosition,
|
||||
SpawnRotation,
|
||||
NitroxId,
|
||||
Optional.OfNullable(SubRootId),
|
||||
Permissions,
|
||||
CurrentStats,
|
||||
GameMode,
|
||||
UsedItems,
|
||||
QuickSlotsBindingIds,
|
||||
EquippedItems,
|
||||
PersonalCompletedGoalsWithTimestamp,
|
||||
PlayerPreferences.PingPreferences,
|
||||
PlayerPreferences.PinnedTechTypes);
|
||||
}
|
||||
|
||||
public static PersistedPlayerData FromPlayer(Player player)
|
||||
{
|
||||
return new PersistedPlayerData
|
||||
{
|
||||
Name = player.Name,
|
||||
UsedItems = player.UsedItems?.ToList(),
|
||||
QuickSlotsBindingIds = player.QuickSlotsBindingIds,
|
||||
EquippedItems = new(player.EquippedItems),
|
||||
Id = player.Id,
|
||||
SpawnPosition = player.Position,
|
||||
SpawnRotation = player.Rotation,
|
||||
CurrentStats = player.Stats,
|
||||
GameMode = player.GameMode,
|
||||
SubRootId = player.SubRootId.OrNull(),
|
||||
Permissions = player.Permissions,
|
||||
NitroxId = player.GameObjectId,
|
||||
IsPermaDeath = player.IsPermaDeath,
|
||||
PersonalCompletedGoalsWithTimestamp = new(player.PersonalCompletedGoalsWithTimestamp),
|
||||
PlayerPreferences = new(player.PingInstancePreferences.ToDictionary(m => m.Key, m => m.Value), player.PinnedRecipePreferences.ToList())
|
||||
};
|
||||
}
|
||||
}
|
||||
25
NitroxServer/GameLogic/Players/PlayerData.cs
Normal file
25
NitroxServer/GameLogic/Players/PlayerData.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.Serialization;
|
||||
|
||||
namespace NitroxServer.GameLogic.Players
|
||||
{
|
||||
[DataContract]
|
||||
public class PlayerData
|
||||
{
|
||||
[DataMember(Order = 1)]
|
||||
public List<PersistedPlayerData> Players = [];
|
||||
|
||||
public List<Player> GetPlayers()
|
||||
{
|
||||
return Players.Select(playerData => playerData.ToPlayer()).ToList();
|
||||
}
|
||||
|
||||
public static PlayerData From(IEnumerable<Player> players)
|
||||
{
|
||||
List<PersistedPlayerData> persistedPlayers = players.Select(PersistedPlayerData.FromPlayer).ToList();
|
||||
|
||||
return new PlayerData { Players = persistedPlayers };
|
||||
}
|
||||
}
|
||||
}
|
||||
99
NitroxServer/GameLogic/ScheduleKeeper.cs
Normal file
99
NitroxServer/GameLogic/ScheduleKeeper.cs
Normal file
@@ -0,0 +1,99 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using NitroxModel.DataStructures;
|
||||
using NitroxModel.DataStructures.GameLogic;
|
||||
using NitroxModel.Packets;
|
||||
using NitroxServer.GameLogic.Unlockables;
|
||||
|
||||
namespace NitroxServer.GameLogic
|
||||
{
|
||||
public class ScheduleKeeper
|
||||
{
|
||||
private readonly ThreadSafeDictionary<string, NitroxScheduledGoal> scheduledGoals = new();
|
||||
private readonly PDAStateData pdaStateData;
|
||||
private readonly StoryGoalData storyGoalData;
|
||||
private readonly TimeKeeper timeKeeper;
|
||||
private readonly PlayerManager playerManager;
|
||||
|
||||
private float ElapsedSecondsFloat => (float)timeKeeper.ElapsedSeconds;
|
||||
|
||||
public ScheduleKeeper(PDAStateData pdaStateData, StoryGoalData storyGoalData, TimeKeeper timeKeeper, PlayerManager playerManager)
|
||||
{
|
||||
this.pdaStateData = pdaStateData;
|
||||
this.storyGoalData = storyGoalData;
|
||||
this.timeKeeper = timeKeeper;
|
||||
this.playerManager = playerManager;
|
||||
|
||||
// We still want to get a "replicated" list in memory
|
||||
for (int i = storyGoalData.ScheduledGoals.Count - 1; i >= 0; i--)
|
||||
{
|
||||
NitroxScheduledGoal scheduledGoal = storyGoalData.ScheduledGoals[i];
|
||||
// In the unlikely case that there's a duplicated entry
|
||||
if (scheduledGoals.TryGetValue(scheduledGoal.GoalKey, out NitroxScheduledGoal alreadyInGoal))
|
||||
{
|
||||
// We remove the goal that's already in if it's planned for later than the first one
|
||||
if (scheduledGoal.TimeExecute <= alreadyInGoal.TimeExecute)
|
||||
{
|
||||
UnScheduleGoal(alreadyInGoal.GoalKey);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
scheduledGoals.Add(scheduledGoal.GoalKey, scheduledGoal);
|
||||
}
|
||||
}
|
||||
|
||||
public List<NitroxScheduledGoal> GetScheduledGoals()
|
||||
{
|
||||
return scheduledGoals.Values.ToList();
|
||||
}
|
||||
|
||||
public bool ContainsScheduledGoal(string goalKey)
|
||||
{
|
||||
return scheduledGoals.ContainsKey(goalKey);
|
||||
}
|
||||
|
||||
public void ScheduleGoal(NitroxScheduledGoal scheduledGoal)
|
||||
{
|
||||
// Only add if it's not in already
|
||||
if (!scheduledGoals.ContainsKey(scheduledGoal.GoalKey))
|
||||
{
|
||||
// If it's not already in any PDA stuff (completed goals or PDALog)
|
||||
if (!IsAlreadyRegistered(scheduledGoal.GoalKey))
|
||||
{
|
||||
if (scheduledGoal.TimeExecute > ElapsedSecondsFloat)
|
||||
{
|
||||
scheduledGoals.Add(scheduledGoal.GoalKey, scheduledGoal);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <param name="becauseOfTime">
|
||||
/// When the server starts, it happens that there are still some goals that were supposed to happen
|
||||
/// but didn't, so to make sure that they happen on at least one client, we postpone its execution
|
||||
/// </param>
|
||||
public void UnScheduleGoal(string goalKey, bool becauseOfTime = false)
|
||||
{
|
||||
if (!scheduledGoals.TryGetValue(goalKey, out NitroxScheduledGoal scheduledGoal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
// The best solution, to ensure any bad simulation of client side, is to postpone the execution
|
||||
// If the goal is already done, no need to check anything
|
||||
if (becauseOfTime && !IsAlreadyRegistered(goalKey))
|
||||
{
|
||||
scheduledGoal.TimeExecute = ElapsedSecondsFloat + 15;
|
||||
playerManager.SendPacketToAllPlayers(new Schedule(scheduledGoal.TimeExecute, goalKey, scheduledGoal.GoalType));
|
||||
return;
|
||||
}
|
||||
scheduledGoals.Remove(goalKey);
|
||||
}
|
||||
|
||||
public bool IsAlreadyRegistered(string goalKey)
|
||||
{
|
||||
return pdaStateData.PdaLog.Any(entry => entry.Key == goalKey)
|
||||
|| storyGoalData.CompletedGoals.Contains(goalKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
119
NitroxServer/GameLogic/SimulationOwnership.cs
Normal file
119
NitroxServer/GameLogic/SimulationOwnership.cs
Normal file
@@ -0,0 +1,119 @@
|
||||
using System.Collections.Generic;
|
||||
using NitroxModel.DataStructures;
|
||||
|
||||
namespace NitroxServer.GameLogic
|
||||
{
|
||||
public class SimulationOwnershipData
|
||||
{
|
||||
public struct PlayerLock
|
||||
{
|
||||
public Player Player { get; }
|
||||
public SimulationLockType LockType { get; set; }
|
||||
|
||||
public PlayerLock(Player player, SimulationLockType lockType)
|
||||
{
|
||||
Player = player;
|
||||
LockType = lockType;
|
||||
}
|
||||
}
|
||||
|
||||
Dictionary<NitroxId, PlayerLock> playerLocksById = new Dictionary<NitroxId, PlayerLock>();
|
||||
|
||||
public bool TryToAcquire(NitroxId id, Player player, SimulationLockType requestedLock)
|
||||
{
|
||||
lock (playerLocksById)
|
||||
{
|
||||
// If no one is simulating then aquire a lock for this player
|
||||
if (!playerLocksById.TryGetValue(id, out PlayerLock playerLock))
|
||||
{
|
||||
playerLocksById[id] = new PlayerLock(player, requestedLock);
|
||||
return true;
|
||||
}
|
||||
|
||||
// If this player owns the lock then they are already simulating
|
||||
if (playerLock.Player == player)
|
||||
{
|
||||
// update the lock type in case they are attempting to downgrade
|
||||
playerLocksById[id] = new PlayerLock(player, requestedLock);
|
||||
return true;
|
||||
}
|
||||
|
||||
// If the current lock owner has a transient lock then only override if we are requesting exclusive access
|
||||
if (playerLock.LockType == SimulationLockType.TRANSIENT && requestedLock == SimulationLockType.EXCLUSIVE)
|
||||
{
|
||||
playerLocksById[id] = new PlayerLock(player, requestedLock);
|
||||
return true;
|
||||
}
|
||||
|
||||
// We must be requesting a transient lock and the owner already has a lock (either transient or exclusive).
|
||||
// there is no way to break it so we will return false.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool RevokeIfOwner(NitroxId id, Player player)
|
||||
{
|
||||
lock (playerLocksById)
|
||||
{
|
||||
if (playerLocksById.TryGetValue(id, out PlayerLock playerLock) && playerLock.Player == player)
|
||||
{
|
||||
playerLocksById.Remove(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public List<NitroxId> RevokeAllForOwner(Player player)
|
||||
{
|
||||
lock (playerLocksById)
|
||||
{
|
||||
List<NitroxId> revokedIds = new List<NitroxId>();
|
||||
|
||||
foreach (KeyValuePair<NitroxId, PlayerLock> idWithPlayerLock in playerLocksById)
|
||||
{
|
||||
if (idWithPlayerLock.Value.Player == player)
|
||||
{
|
||||
revokedIds.Add(idWithPlayerLock.Key);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (NitroxId id in revokedIds)
|
||||
{
|
||||
playerLocksById.Remove(id);
|
||||
}
|
||||
|
||||
return revokedIds;
|
||||
}
|
||||
}
|
||||
|
||||
public bool RevokeOwnerOfId(NitroxId id)
|
||||
{
|
||||
lock (playerLocksById)
|
||||
{
|
||||
return playerLocksById.Remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
public Player GetPlayerForLock(NitroxId id)
|
||||
{
|
||||
lock (playerLocksById)
|
||||
{
|
||||
if (playerLocksById.TryGetValue(id, out PlayerLock playerLock))
|
||||
{
|
||||
return playerLock.Player;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public bool TryGetLock(NitroxId id, out PlayerLock playerLock)
|
||||
{
|
||||
lock (playerLocksById)
|
||||
{
|
||||
return playerLocksById.TryGetValue(id, out playerLock);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
210
NitroxServer/GameLogic/StoryManager.cs
Normal file
210
NitroxServer/GameLogic/StoryManager.cs
Normal file
@@ -0,0 +1,210 @@
|
||||
using System;
|
||||
using NitroxModel.DataStructures.GameLogic;
|
||||
using NitroxModel.Packets;
|
||||
using NitroxServer.Helper;
|
||||
using NitroxServer.GameLogic.Unlockables;
|
||||
using NitroxModel.Helper;
|
||||
using NitroxModel;
|
||||
|
||||
namespace NitroxServer.GameLogic;
|
||||
|
||||
/// <summary>
|
||||
/// Keeps track of time and Aurora-related events.
|
||||
/// </summary>
|
||||
public class StoryManager : IDisposable
|
||||
{
|
||||
private readonly PlayerManager playerManager;
|
||||
private readonly PDAStateData pdaStateData;
|
||||
private readonly StoryGoalData storyGoalData;
|
||||
private readonly TimeKeeper timeKeeper;
|
||||
private readonly string seed;
|
||||
|
||||
/// <summary>
|
||||
/// Time at which the Aurora explosion countdown will start (last warning is sent).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// It is required to calculate the time at which the Aurora warnings will be sent (along with <see cref="AuroraWarningTimeMs"/>, look into AuroraWarnings.cs and CrashedShipExploder.cs for more information).
|
||||
/// </remarks>
|
||||
public double AuroraCountdownTimeMs;
|
||||
/// <summary>
|
||||
/// Time at which the Aurora Events start (you start receiving warnings).
|
||||
/// </summary>
|
||||
public double AuroraWarningTimeMs;
|
||||
|
||||
/// <summary>
|
||||
/// In seconds
|
||||
/// </summary>
|
||||
public double AuroraRealExplosionTime;
|
||||
|
||||
private double ElapsedMilliseconds => timeKeeper.ElapsedMilliseconds;
|
||||
private double ElapsedSeconds => timeKeeper.ElapsedSeconds;
|
||||
|
||||
public StoryManager(PlayerManager playerManager, PDAStateData pdaStateData, StoryGoalData storyGoalData, TimeKeeper timeKeeper, string seed, double? auroraExplosionTime, double? auroraWarningTime, double? auroraRealExplosionTime)
|
||||
{
|
||||
this.playerManager = playerManager;
|
||||
this.pdaStateData = pdaStateData;
|
||||
this.storyGoalData = storyGoalData;
|
||||
this.timeKeeper = timeKeeper;
|
||||
this.seed = seed;
|
||||
|
||||
AuroraCountdownTimeMs = auroraExplosionTime ?? GenerateDeterministicAuroraTime(seed);
|
||||
AuroraWarningTimeMs = auroraWarningTime ?? ElapsedMilliseconds;
|
||||
// +27 is from CrashedShipExploder.IsExploded, -480 is from the default time (see TimeKeeper)
|
||||
AuroraRealExplosionTime = auroraRealExplosionTime ?? AuroraCountdownTimeMs * 0.001 + 27 - TimeKeeper.DEFAULT_TIME;
|
||||
|
||||
timeKeeper.TimeSkipped += ReadjustAuroraRealExplosionTime;
|
||||
}
|
||||
|
||||
public void ReadjustAuroraRealExplosionTime(double skipAmount)
|
||||
{
|
||||
// Readjust the aurora real explosion time when time skipping because it's based on in-game time
|
||||
if (AuroraRealExplosionTime > timeKeeper.RealTimeElapsed)
|
||||
{
|
||||
double newTime = timeKeeper.RealTimeElapsed + skipAmount;
|
||||
if (newTime > AuroraRealExplosionTime)
|
||||
{
|
||||
AuroraRealExplosionTime = timeKeeper.RealTimeElapsed;
|
||||
}
|
||||
else
|
||||
{
|
||||
AuroraRealExplosionTime -= skipAmount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <param name="instantaneous">Whether we should make Aurora explode instantly or after a short countdown</param>
|
||||
public void BroadcastExplodeAurora(bool instantaneous)
|
||||
{
|
||||
// Calculations from CrashedShipExploder.OnConsoleCommand_countdownship()
|
||||
// We add 3 seconds to the cooldown (Subnautica adds only 1) so that players have enough time to receive the packet and process it
|
||||
AuroraCountdownTimeMs = ElapsedMilliseconds + 3000;
|
||||
AuroraWarningTimeMs = AuroraCountdownTimeMs;
|
||||
AuroraRealExplosionTime = timeKeeper.RealTimeElapsed + 30; // 27 + 3
|
||||
|
||||
if (instantaneous)
|
||||
{
|
||||
// Calculations from CrashedShipExploder.OnConsoleCommand_explodeship()
|
||||
// Removes 25 seconds to the countdown time, jumping to the exact moment of the explosion
|
||||
AuroraCountdownTimeMs -= 25000;
|
||||
// Is 1 second less than countdown time to have the game understand that we only want the explosion.
|
||||
AuroraWarningTimeMs = AuroraCountdownTimeMs - 1000;
|
||||
AuroraRealExplosionTime -= 25;
|
||||
Log.Info("Aurora's explosion initiated");
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Info("Aurora's explosion countdown will start in 3 seconds");
|
||||
}
|
||||
|
||||
playerManager.SendPacketToAllPlayers(new AuroraAndTimeUpdate(GetTimeData(), false));
|
||||
}
|
||||
|
||||
public void BroadcastRestoreAurora()
|
||||
{
|
||||
AuroraWarningTimeMs = ElapsedMilliseconds;
|
||||
AuroraCountdownTimeMs = GenerateDeterministicAuroraTime(seed);
|
||||
// Current time + deltaTime before countdown + 27 seconds before explosion
|
||||
AuroraRealExplosionTime = timeKeeper.RealTimeElapsed + (AuroraCountdownTimeMs - timeKeeper.ElapsedMilliseconds) * 0.001 + 27;
|
||||
|
||||
// We need to clear these entries from PdaLog and CompletedGoals to make sure that the client, when reconnecting, doesn't have false information
|
||||
foreach (string eventKey in AuroraEventData.GoalNames)
|
||||
{
|
||||
pdaStateData.PdaLog.RemoveAll(entry => entry.Key == eventKey);
|
||||
storyGoalData.CompletedGoals.Remove(eventKey);
|
||||
}
|
||||
|
||||
playerManager.SendPacketToAllPlayers(new AuroraAndTimeUpdate(GetTimeData(), true));
|
||||
Log.Info($"Restored Aurora, will explode again in {GetMinutesBeforeAuroraExplosion()} minutes");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculate the time at Aurora's explosion countdown will begin.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Takes the current time into account.
|
||||
/// </remarks>
|
||||
private double GenerateDeterministicAuroraTime(string seed)
|
||||
{
|
||||
// Copied from CrashedShipExploder.SetExplodeTime() and changed from seconds to ms
|
||||
DeterministicGenerator generator = new(seed, nameof(StoryManager));
|
||||
return ElapsedMilliseconds + generator.NextDouble(2.3d, 4d) * 1200d * 1000d;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the already completed sunbeam events to come and broadcasts it to all players along with the rescheduling of the specified sunbeam event.
|
||||
/// </summary>
|
||||
public void StartSunbeamEvent(string sunbeamEventKey)
|
||||
{
|
||||
int beginIndex = PlaySunbeamEvent.SunbeamGoals.GetIndex(sunbeamEventKey);
|
||||
if (beginIndex == -1)
|
||||
{
|
||||
Log.Error($"Couldn't find the corresponding sunbeam event in {nameof(PlaySunbeamEvent.SunbeamGoals)} for key {sunbeamEventKey}");
|
||||
return;
|
||||
}
|
||||
for (int i = beginIndex; i < PlaySunbeamEvent.SunbeamGoals.Length; i++)
|
||||
{
|
||||
storyGoalData.CompletedGoals.Remove(PlaySunbeamEvent.SunbeamGoals[i]);
|
||||
}
|
||||
playerManager.SendPacketToAllPlayers(new PlaySunbeamEvent(sunbeamEventKey));
|
||||
}
|
||||
|
||||
/// <returns>Either the time in before Aurora explodes or -1 if it has already exploded.</returns>
|
||||
private double GetMinutesBeforeAuroraExplosion()
|
||||
{
|
||||
return AuroraCountdownTimeMs > ElapsedMilliseconds ? Math.Round((AuroraCountdownTimeMs - ElapsedMilliseconds) / 60000) : -1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Makes a nice status of the Aurora events progress for the summary command.
|
||||
/// </summary>
|
||||
public string GetAuroraStateSummary()
|
||||
{
|
||||
double minutesBeforeExplosion = GetMinutesBeforeAuroraExplosion();
|
||||
if (minutesBeforeExplosion < 0)
|
||||
{
|
||||
return "already exploded";
|
||||
}
|
||||
// Based on AuroraWarnings.Update calculations
|
||||
// auroraWarningNumber is the amount of received Aurora warnings (there are 4 in total)
|
||||
int auroraWarningNumber = 0;
|
||||
if (ElapsedMilliseconds >= AuroraCountdownTimeMs)
|
||||
{
|
||||
auroraWarningNumber = 4;
|
||||
}
|
||||
else if (ElapsedMilliseconds >= Mathf.Lerp((float)AuroraWarningTimeMs, (float)AuroraCountdownTimeMs, 0.8f))
|
||||
{
|
||||
auroraWarningNumber = 3;
|
||||
}
|
||||
else if (ElapsedMilliseconds >= Mathf.Lerp((float)AuroraWarningTimeMs, (float)AuroraCountdownTimeMs, 0.5f))
|
||||
{
|
||||
auroraWarningNumber = 2;
|
||||
}
|
||||
else if (ElapsedMilliseconds >= Mathf.Lerp((float)AuroraWarningTimeMs, (float)AuroraCountdownTimeMs, 0.2f))
|
||||
{
|
||||
auroraWarningNumber = 1;
|
||||
}
|
||||
|
||||
return $"explodes in {minutesBeforeExplosion} minutes [{auroraWarningNumber}/4]";
|
||||
}
|
||||
|
||||
public AuroraEventData MakeAuroraData()
|
||||
{
|
||||
return new((float)AuroraCountdownTimeMs * 0.001f, (float)AuroraWarningTimeMs * 0.001f, (float)AuroraRealExplosionTime);
|
||||
}
|
||||
|
||||
public TimeData GetTimeData()
|
||||
{
|
||||
return new(timeKeeper.MakeTimePacket(), MakeAuroraData());
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
timeKeeper.TimeSkipped -= ReadjustAuroraRealExplosionTime;
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
public enum TimeModification
|
||||
{
|
||||
DAY, NIGHT, SKIP
|
||||
}
|
||||
}
|
||||
37
NitroxServer/GameLogic/StoryTimingData.cs
Normal file
37
NitroxServer/GameLogic/StoryTimingData.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using System.Runtime.Serialization;
|
||||
|
||||
namespace NitroxServer.GameLogic
|
||||
{
|
||||
[Serializable]
|
||||
[DataContract]
|
||||
public class StoryTimingData
|
||||
{
|
||||
[DataMember(Order = 1)]
|
||||
public double ElapsedSeconds { get; set; }
|
||||
|
||||
[DataMember(Order = 2)]
|
||||
public double? AuroraCountdownTime { get; set; }
|
||||
|
||||
[DataMember(Order = 3)]
|
||||
public double? AuroraWarningTime { get; set; }
|
||||
|
||||
[DataMember(Order = 4)]
|
||||
public double RealTimeElapsed { get; set; }
|
||||
|
||||
[DataMember(Order = 5)]
|
||||
public double? AuroraRealExplosionTime { get; set; }
|
||||
|
||||
public static StoryTimingData From(StoryManager storyManager, TimeKeeper timeKeeper)
|
||||
{
|
||||
return new StoryTimingData
|
||||
{
|
||||
ElapsedSeconds = timeKeeper.ElapsedSeconds,
|
||||
AuroraCountdownTime = storyManager.AuroraCountdownTimeMs,
|
||||
AuroraWarningTime = storyManager.AuroraWarningTimeMs,
|
||||
RealTimeElapsed = timeKeeper.RealTimeElapsed,
|
||||
AuroraRealExplosionTime = storyManager.AuroraRealExplosionTime
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
197
NitroxServer/GameLogic/TimeKeeper.cs
Normal file
197
NitroxServer/GameLogic/TimeKeeper.cs
Normal file
@@ -0,0 +1,197 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Timers;
|
||||
using NitroxModel.Networking;
|
||||
using NitroxModel.Packets;
|
||||
using static NitroxServer.GameLogic.StoryManager;
|
||||
|
||||
namespace NitroxServer.GameLogic;
|
||||
|
||||
public class TimeKeeper
|
||||
{
|
||||
private readonly PlayerManager playerManager;
|
||||
private readonly NtpSyncer ntpSyncer;
|
||||
|
||||
private readonly Stopwatch stopWatch = new();
|
||||
|
||||
/// <summary>
|
||||
/// Default time in Base SN is 480s
|
||||
/// </summary>
|
||||
public const int DEFAULT_TIME = 480;
|
||||
|
||||
/// <summary>
|
||||
/// Latest registered time without taking the current stopwatch time in account.
|
||||
/// </summary>
|
||||
private double elapsedTimeOutsideStopWatchMs;
|
||||
|
||||
private readonly double realTimeElapsed;
|
||||
|
||||
/// <summary>
|
||||
/// Total elapsed time in milliseconds (adding the current stopwatch time with the latest registered time <see cref="elapsedTimeOutsideStopWatchMs"/>).
|
||||
/// </summary>
|
||||
public double ElapsedMilliseconds
|
||||
{
|
||||
get => stopWatch.ElapsedMilliseconds + elapsedTimeOutsideStopWatchMs;
|
||||
internal set
|
||||
{
|
||||
elapsedTimeOutsideStopWatchMs = value - stopWatch.ElapsedMilliseconds;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Total elapsed time in seconds (converted from <see cref="ElapsedMilliseconds"/>).
|
||||
/// </summary>
|
||||
public double ElapsedSeconds
|
||||
{
|
||||
get => ElapsedMilliseconds * 0.001;
|
||||
set => ElapsedMilliseconds = value * 1000;
|
||||
}
|
||||
|
||||
public double RealTimeElapsed => stopWatch.ElapsedMilliseconds * 0.001 + realTimeElapsed;
|
||||
|
||||
/// <summary>
|
||||
/// Subnautica's equivalent of days.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Uses ceiling because days count start at 1 and not 0.
|
||||
/// </remarks>
|
||||
public int Day => (int)Math.Ceiling(ElapsedMilliseconds / TimeSpan.FromMinutes(20).TotalMilliseconds);
|
||||
|
||||
/// <summary>
|
||||
/// Timer responsible for periodically sending time resync packets.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Is created by <see cref="MakeResyncTimer"/>.
|
||||
/// </remarks>
|
||||
public Timer ResyncTimer;
|
||||
/// <summary>
|
||||
/// Time in seconds between each resync packet sending.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// AKA Interval of <see cref="ResyncTimer"/>.
|
||||
/// </remarks>
|
||||
private const int RESYNC_INTERVAL = 60;
|
||||
|
||||
public TimeSkippedEventHandler TimeSkipped;
|
||||
|
||||
/// <summary>
|
||||
/// Time in seconds between each ntp connection attempt.
|
||||
/// </summary>
|
||||
private const int NTP_RETRY_INTERVAL = 60;
|
||||
|
||||
public TimeKeeper(PlayerManager playerManager, NtpSyncer ntpSyncer, double elapsedSeconds, double realTimeElapsed)
|
||||
{
|
||||
this.playerManager = playerManager;
|
||||
this.ntpSyncer = ntpSyncer;
|
||||
|
||||
// We only need the correction offset to be calculated once
|
||||
ntpSyncer.Setup(true, (onlineMode, _) => // TODO: set to false after tests
|
||||
{
|
||||
if (!onlineMode)
|
||||
{
|
||||
// until we get online even once, we'll retry the ntp sync sequence every NTP_RETRY_INTERVAL
|
||||
StartNtpTimer();
|
||||
}
|
||||
});
|
||||
ntpSyncer.RequestNtpService();
|
||||
|
||||
elapsedTimeOutsideStopWatchMs = elapsedSeconds == 0 ? TimeSpan.FromSeconds(DEFAULT_TIME).TotalMilliseconds : elapsedSeconds * 1000;
|
||||
this.realTimeElapsed = realTimeElapsed;
|
||||
ResyncTimer = MakeResyncTimer();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a timer that periodically sends resync packets to players.
|
||||
/// </summary>
|
||||
public Timer MakeResyncTimer()
|
||||
{
|
||||
Timer resyncTimer = new()
|
||||
{
|
||||
Interval = TimeSpan.FromSeconds(RESYNC_INTERVAL).TotalMilliseconds,
|
||||
AutoReset = true
|
||||
};
|
||||
resyncTimer.Elapsed += delegate
|
||||
{
|
||||
playerManager.SendPacketToAllPlayers(MakeTimePacket());
|
||||
};
|
||||
return resyncTimer;
|
||||
}
|
||||
|
||||
private void StartNtpTimer()
|
||||
{
|
||||
Timer retryTimer = new(TimeSpan.FromSeconds(NTP_RETRY_INTERVAL).TotalMilliseconds)
|
||||
{
|
||||
AutoReset = true,
|
||||
};
|
||||
|
||||
retryTimer.Elapsed += delegate
|
||||
{
|
||||
// Reset the syncer before starting another round of it
|
||||
ntpSyncer.Dispose();
|
||||
ntpSyncer.Setup(true, (onlineMode, _) => // TODO: set to false after tests
|
||||
{
|
||||
if (onlineMode)
|
||||
{
|
||||
retryTimer.Close();
|
||||
}
|
||||
});
|
||||
ntpSyncer.RequestNtpService();
|
||||
};
|
||||
|
||||
retryTimer.Start();
|
||||
}
|
||||
|
||||
public void StartCounting()
|
||||
{
|
||||
stopWatch.Start();
|
||||
ResyncTimer.Start();
|
||||
playerManager.SendPacketToAllPlayers(MakeTimePacket());
|
||||
}
|
||||
|
||||
public void ResetCount()
|
||||
{
|
||||
stopWatch.Reset();
|
||||
}
|
||||
|
||||
public void StopCounting()
|
||||
{
|
||||
stopWatch.Stop();
|
||||
ResyncTimer.Stop();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set current time depending on the current time in the day (replication of SN's system, see DayNightCycle.cs commands for more information).
|
||||
/// </summary>
|
||||
/// <param name="type">Time to which you want to get to.</param>
|
||||
public void ChangeTime(TimeModification type)
|
||||
{
|
||||
double skipAmount = 0;
|
||||
switch (type)
|
||||
{
|
||||
case TimeModification.DAY:
|
||||
skipAmount = 1200 - (ElapsedSeconds % 1200) + 600;
|
||||
break;
|
||||
case TimeModification.NIGHT:
|
||||
skipAmount = 1200 - (ElapsedSeconds % 1200);
|
||||
break;
|
||||
case TimeModification.SKIP:
|
||||
skipAmount = 600 - (ElapsedSeconds % 600);
|
||||
break;
|
||||
}
|
||||
|
||||
if (skipAmount > 0)
|
||||
{
|
||||
ElapsedSeconds += skipAmount;
|
||||
TimeSkipped?.Invoke(skipAmount);
|
||||
|
||||
playerManager.SendPacketToAllPlayers(MakeTimePacket());
|
||||
}
|
||||
}
|
||||
|
||||
public TimeChange MakeTimePacket()
|
||||
{
|
||||
return new(ElapsedSeconds, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), RealTimeElapsed, ntpSyncer.OnlineMode, ntpSyncer.CorrectionOffset.Ticks);
|
||||
}
|
||||
|
||||
public delegate void TimeSkippedEventHandler(double skipAmount);
|
||||
}
|
||||
144
NitroxServer/GameLogic/Unlockables/PDAStateData.cs
Normal file
144
NitroxServer/GameLogic/Unlockables/PDAStateData.cs
Normal file
@@ -0,0 +1,144 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.Serialization;
|
||||
using NitroxModel.DataStructures;
|
||||
using NitroxModel.DataStructures.GameLogic;
|
||||
|
||||
namespace NitroxServer.GameLogic.Unlockables;
|
||||
|
||||
[DataContract]
|
||||
public class PDAStateData
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the KnownTech construct which powers the popup shown to the user when a new TechType is discovered ("New Creature Discovered!")
|
||||
/// The KnownTech construct uses both <see cref='NitroxModel.Packets.KnownTechEntryAdd.EntryCategory.KNOWN'>KnownTech.knownTech</see> and <see cref='NitroxModel.Packets.KnownTechEntryAdd.EntryCategory.ANALYZED'>KnownTech.analyzedTech</see>
|
||||
/// </summary>
|
||||
[DataMember(Order = 1)]
|
||||
public ThreadSafeList<NitroxTechType> KnownTechTypes { get; } = [];
|
||||
|
||||
[DataMember(Order = 2)]
|
||||
public ThreadSafeList<NitroxTechType> AnalyzedTechTypes { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the log of story events present in the PDA
|
||||
/// </summary>
|
||||
[DataMember(Order = 3)]
|
||||
public ThreadSafeList<PDALogEntry> PdaLog { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the entries that show up the the PDA's Encyclopedia
|
||||
/// </summary>
|
||||
[DataMember(Order = 4)]
|
||||
public ThreadSafeList<string> EncyclopediaEntries { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// The ids of the already scanned entities.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// In Subnautica, this is a Dictionary, but the value is not used, the only important thing is whether a key is stored or not.
|
||||
/// We can therefore use it as a list.
|
||||
/// </remarks>
|
||||
[DataMember(Order = 5)]
|
||||
public ThreadSafeSet<NitroxId> ScannerFragments { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Partially unlocked PDA entries (e.g. fragments)
|
||||
/// </summary>
|
||||
[DataMember(Order = 6)]
|
||||
public ThreadSafeList<PDAEntry> ScannerPartial { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Fully unlocked PDA entries
|
||||
/// </summary>
|
||||
[DataMember(Order = 7)]
|
||||
public ThreadSafeList<NitroxTechType> ScannerComplete { get; } = [];
|
||||
|
||||
public void AddKnownTechType(NitroxTechType techType, List<NitroxTechType> partialTechTypesToRemove)
|
||||
{
|
||||
ScannerPartial.RemoveAll(entry => partialTechTypesToRemove.Contains(entry.TechType));
|
||||
if (!KnownTechTypes.Contains(techType))
|
||||
{
|
||||
KnownTechTypes.Add(techType);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Debug($"There was an attempt of adding a duplicated entry in the KnownTechTypes: [{techType.Name}]");
|
||||
}
|
||||
}
|
||||
|
||||
public void AddAnalyzedTechType(NitroxTechType techType)
|
||||
{
|
||||
if (!AnalyzedTechTypes.Contains(techType))
|
||||
{
|
||||
AnalyzedTechTypes.Add(techType);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Debug($"There was an attempt of adding a duplicated entry in the AnalyzedTechTypes: [{techType.Name}]");
|
||||
}
|
||||
}
|
||||
|
||||
public void AddEncyclopediaEntry(string entry)
|
||||
{
|
||||
if (!EncyclopediaEntries.Contains(entry))
|
||||
{
|
||||
EncyclopediaEntries.Add(entry);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Debug($"There was an attempt of adding a duplicated entry in the EncyclopediaEntries: [{entry}]");
|
||||
}
|
||||
}
|
||||
|
||||
public void AddPDALogEntry(PDALogEntry entry)
|
||||
{
|
||||
if (!PdaLog.Any(logEntry => logEntry.Key == entry.Key))
|
||||
{
|
||||
PdaLog.Add(entry);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Debug($"There was an attempt of adding a duplicated entry in the PDALog: [{entry.Key}]");
|
||||
}
|
||||
}
|
||||
|
||||
public void AddScannerFragment(NitroxId id)
|
||||
{
|
||||
ScannerFragments.Add(id);
|
||||
}
|
||||
|
||||
public void UpdateEntryUnlockedProgress(NitroxTechType techType, int unlockedAmount, bool fullyResearched)
|
||||
{
|
||||
if (fullyResearched)
|
||||
{
|
||||
ScannerPartial.RemoveAll(entry => entry.TechType.Equals(techType));
|
||||
ScannerComplete.Add(techType);
|
||||
}
|
||||
else
|
||||
{
|
||||
lock (ScannerPartial)
|
||||
{
|
||||
IEnumerable<PDAEntry> entries = ScannerPartial.Where(e => e.TechType.Equals(techType));
|
||||
if (entries.Any())
|
||||
{
|
||||
entries.First().Unlocked = unlockedAmount;
|
||||
}
|
||||
else
|
||||
{
|
||||
ScannerPartial.Add(new(techType, unlockedAmount));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public InitialPDAData GetInitialPDAData()
|
||||
{
|
||||
return new(KnownTechTypes.ToList(),
|
||||
AnalyzedTechTypes.ToList(),
|
||||
PdaLog.ToList(),
|
||||
EncyclopediaEntries.ToList(),
|
||||
ScannerFragments.ToList(),
|
||||
ScannerPartial.ToList(),
|
||||
ScannerComplete.ToList());
|
||||
}
|
||||
}
|
||||
45
NitroxServer/GameLogic/Unlockables/StoryGoalData.cs
Normal file
45
NitroxServer/GameLogic/Unlockables/StoryGoalData.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.Serialization;
|
||||
using NitroxModel.DataStructures;
|
||||
using NitroxModel.DataStructures.GameLogic;
|
||||
|
||||
namespace NitroxServer.GameLogic.Unlockables;
|
||||
|
||||
[DataContract]
|
||||
public class StoryGoalData
|
||||
{
|
||||
[DataMember(Order = 1)]
|
||||
public ThreadSafeSet<string> CompletedGoals { get; } = [];
|
||||
|
||||
[DataMember(Order = 2)]
|
||||
public ThreadSafeQueue<string> RadioQueue { get; } = [];
|
||||
|
||||
[DataMember(Order = 3)]
|
||||
public ThreadSafeList<NitroxScheduledGoal> ScheduledGoals { get; set; } = [];
|
||||
|
||||
public bool RemovedLatestRadioMessage()
|
||||
{
|
||||
if (RadioQueue.Count <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string message = RadioQueue.Dequeue();
|
||||
|
||||
// Just like StoryGoalManager.ExecutePendingRadioMessage
|
||||
CompletedGoals.Add($"OnPlay{message}");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static StoryGoalData From(StoryGoalData storyGoals, ScheduleKeeper scheduleKeeper)
|
||||
{
|
||||
storyGoals.ScheduledGoals = new ThreadSafeList<NitroxScheduledGoal>(scheduleKeeper.GetScheduledGoals());
|
||||
return storyGoals;
|
||||
}
|
||||
|
||||
public InitialStoryGoalData GetInitialStoryGoalData(ScheduleKeeper scheduleKeeper, Player player)
|
||||
{
|
||||
return new InitialStoryGoalData(new List<string>(CompletedGoals), new List<string>(RadioQueue), scheduleKeeper.GetScheduledGoals(), new(player.PersonalCompletedGoalsWithTimestamp));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user