first commit

This commit is contained in:
2025-07-06 00:23:46 +02:00
commit 38f50c8819
1788 changed files with 112878 additions and 0 deletions

View 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);
}
}

View File

@@ -0,0 +1,8 @@
namespace NitroxServer.GameLogic
{
internal class ConnectionAssets
{
public string ReservationKey { get; set; }
public Player Player { get; set; }
}
}

View 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 };
}
}

View 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);
}
}
}
}

View 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);
}
}

View 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 };
}
}

View 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; }
}

View 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);

View 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;
}
}

View 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}]";
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View 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);
}
}

View File

@@ -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;
}
}

View 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;
}
}

View 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;
}
}

View 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)
};
}
}
}

View 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);
}

View 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);
}
}
}

View 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);
}
}
}

View 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())
};
}
}

View 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 };
}
}
}

View 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);
}
}
}

View 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);
}
}
}
}

View 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
}
}

View 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
};
}
}
}

View 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);
}

View 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());
}
}

View 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));
}
}