diff --git a/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml b/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml
index ba34d5b976d..93dbf2cad84 100644
--- a/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml
+++ b/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml
@@ -115,14 +115,14 @@
-
+
-
+
diff --git a/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml.cs b/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml.cs
index 8bd3d7d768f..a957f4041ae 100644
--- a/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml.cs
+++ b/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml.cs
@@ -405,7 +405,7 @@ public HumanoidProfileEditor(
HeightSlider.OnValueChanged += args =>
{
- SetHeight((float)args.Value);
+ SetProfileHeight((float)args.Value);
};
HeightResetButton.OnPressed += _ =>
@@ -419,7 +419,7 @@ public HumanoidProfileEditor(
WidthSlider.OnValueChanged += args =>
{
- SetWidth((float)args.Value);
+ SetProfileWidth((float)args.Value);
};
WidthResetButton.OnPressed += _ =>
@@ -1937,29 +1937,29 @@ private void SetSpawnPriority(SpawnPriorityPreference newSpawnPriority)
SetDirty();
}
- private void SetHeight(float newHeight)
+ private void SetProfileHeight(float newHeight)
{
- Profile = Profile?.WithCharacterAppearance(Profile.Appearance.WithHeight(newHeight));
+ Profile = Profile?.WithCharacterAppearance(Profile.Appearance.WithHeight(Math.Clamp(newHeight, HumanoidCharacterAppearance.MinScale, HumanoidCharacterAppearance.MaxScale)));
SetDirty();
ReloadPreview();
}
private void ResetHeight()
{
- SetHeight(1.0f);
+ SetProfileHeight(HumanoidCharacterAppearance.DefaultScale);
UpdateHeightControls();
}
- private void SetWidth(float newWidth)
+ private void SetProfileWidth(float newWidth)
{
- Profile = Profile?.WithCharacterAppearance(Profile.Appearance.WithWidth(newWidth));
+ Profile = Profile?.WithCharacterAppearance(Profile.Appearance.WithWidth(Math.Clamp(newWidth, HumanoidCharacterAppearance.MinScale, HumanoidCharacterAppearance.MaxScale)));
SetDirty();
ReloadPreview();
}
private void ResetWidth()
{
- SetWidth(1.0f);
+ SetProfileWidth(HumanoidCharacterAppearance.DefaultScale);
UpdateWidthControls();
}
diff --git a/Content.Client/Overlays/StencilOverlay.Weather.cs b/Content.Client/Overlays/StencilOverlay.Weather.cs
index 0a41eecf814..013ec72878e 100644
--- a/Content.Client/Overlays/StencilOverlay.Weather.cs
+++ b/Content.Client/Overlays/StencilOverlay.Weather.cs
@@ -1,9 +1,9 @@
using System.Numerics;
using Content.Shared.Light.Components;
+using Content.Shared.StatusEffectNew.Components;
using Content.Shared.Weather;
using Robust.Client.Graphics;
using Robust.Shared.Map.Components;
-using Robust.Shared.Physics.Components;
namespace Content.Client.Overlays;
@@ -14,8 +14,7 @@ public sealed partial class StencilOverlay
private void DrawWeather(
in OverlayDrawArgs args,
CachedResources res,
- WeatherPrototype weatherProto,
- float alpha,
+ HashSet> weathers,
Matrix3x2 invMatrix)
{
var worldHandle = args.WorldHandle;
@@ -27,47 +26,57 @@ private void DrawWeather(
// Cut out the irrelevant bits via stencil
// This is why we don't just use parallax; we might want specific tiles to get drawn over
// particularly for planet maps or stations.
- worldHandle.RenderInRenderTarget(res.Blep!, () =>
- {
- var xformQuery = _entManager.GetEntityQuery();
- _grids.Clear();
-
- // idk if this is safe to cache in a field and clear sloth help
- _mapManager.FindGridsIntersecting(mapId, worldAABB, ref _grids);
-
- foreach (var grid in _grids)
+ worldHandle.RenderInRenderTarget(res.Blep!,
+ () =>
{
- var matrix = _transform.GetWorldMatrix(grid, xformQuery);
- var matty = Matrix3x2.Multiply(matrix, invMatrix);
- worldHandle.SetTransform(matty);
- _entManager.TryGetComponent(grid.Owner, out RoofComponent? roofComp);
+ var xformQuery = _entManager.GetEntityQuery();
+ _grids.Clear();
- foreach (var tile in _map.GetTilesIntersecting(grid.Owner, grid, worldAABB))
+ // idk if this is safe to cache in a field and clear sloth help
+ _mapManager.FindGridsIntersecting(mapId, worldAABB, ref _grids);
+
+ foreach (var grid in _grids)
{
- // Ignored tiles for stencil
- if (_weather.CanWeatherAffect(grid.Owner, grid, tile, roofComp))
+ var matrix = _transform.GetWorldMatrix(grid, xformQuery);
+ var matty = Matrix3x2.Multiply(matrix, invMatrix);
+ worldHandle.SetTransform(matty);
+ _entManager.TryGetComponent(grid.Owner, out RoofComponent? roofComp);
+
+ foreach (var tile in _map.GetTilesIntersecting(grid.Owner, grid, worldAABB))
{
- continue;
- }
+ // Ignored tiles for stencil
+ if (_weather.CanWeatherAffect((grid.Owner, grid, roofComp), tile))
+ continue;
- var gridTile = new Box2(tile.GridIndices * grid.Comp.TileSize,
- (tile.GridIndices + Vector2i.One) * grid.Comp.TileSize);
+ var gridTile = new Box2(tile.GridIndices * grid.Comp.TileSize,
+ (tile.GridIndices + Vector2i.One) * grid.Comp.TileSize);
- worldHandle.DrawRect(gridTile, Color.White);
+ worldHandle.DrawRect(gridTile, Color.White);
+ }
}
- }
-
- }, Color.Transparent);
+ },
+ Color.Transparent);
worldHandle.SetTransform(Matrix3x2.Identity);
worldHandle.UseShader(_protoManager.Index(StencilMaskId).Instance());
worldHandle.DrawTextureRect(res.Blep!.Texture, worldBounds);
var curTime = _timing.RealTime;
- var sprite = _sprite.GetFrame(weatherProto.Sprite, curTime);
- // Draw the rain
- worldHandle.UseShader(_protoManager.Index(StencilDrawId).Instance());
- _parallax.DrawParallax(worldHandle, worldAABB, sprite, curTime, position, Vector2.Zero, modulate: (weatherProto.Color ?? Color.White).WithAlpha(alpha));
+ foreach (var (uid, weather, status) in weathers)
+ {
+ var alpha = _weather.GetWeatherPercent((uid, status));
+ var sprite = _sprite.GetFrame(weather.Sprite, curTime);
+
+ // Draw the rain
+ worldHandle.UseShader(_protoManager.Index(StencilDrawId).Instance());
+ _parallax.DrawParallax(worldHandle,
+ worldAABB,
+ sprite,
+ curTime,
+ position,
+ weather.Scrolling ?? Vector2.Zero,
+ modulate: (weather.Color ?? Color.White).WithAlpha(alpha));
+ }
worldHandle.SetTransform(Matrix3x2.Identity);
worldHandle.UseShader(null);
diff --git a/Content.Client/Overlays/StencilOverlay.cs b/Content.Client/Overlays/StencilOverlay.cs
index 2bd6331ed46..c9d8ad094de 100644
--- a/Content.Client/Overlays/StencilOverlay.cs
+++ b/Content.Client/Overlays/StencilOverlay.cs
@@ -3,6 +3,8 @@
using Content.Client.Parallax;
using Content.Client.Weather;
using Content.Shared.Salvage;
+using Content.Shared.StatusEffectNew;
+using Content.Shared.StatusEffectNew.Components;
using Content.Shared.Weather;
using Robust.Client.GameObjects;
using Robust.Client.Graphics;
@@ -28,6 +30,8 @@ public sealed partial class StencilOverlay : Overlay
private readonly SharedMapSystem _map;
private readonly SpriteSystem _sprite;
private readonly WeatherSystem _weather;
+ private readonly StatusEffectsSystem _statusEffects;
+ private HashSet>? _weatherSet = new();
public override OverlaySpace Space => OverlaySpace.WorldSpaceBelowFOV;
@@ -38,7 +42,7 @@ public sealed partial class StencilOverlay : Overlay
private static readonly ProtoId StencilMaskId = "StencilMask";
private static readonly ProtoId StencilDrawId = "StencilDraw";
- public StencilOverlay(ParallaxSystem parallax, SharedTransformSystem transform, SharedMapSystem map, SpriteSystem sprite, WeatherSystem weather)
+ public StencilOverlay(ParallaxSystem parallax, SharedTransformSystem transform, SharedMapSystem map, SpriteSystem sprite, WeatherSystem weather, StatusEffectsSystem statusEffects)
{
ZIndex = ParallaxSystem.ParallaxZIndex + 1;
_parallax = parallax;
@@ -46,13 +50,14 @@ public StencilOverlay(ParallaxSystem parallax, SharedTransformSystem transform,
_map = map;
_sprite = sprite;
_weather = weather;
+ _statusEffects = statusEffects;
IoCManager.InjectDependencies(this);
_shader = _protoManager.Index(WorldGradientCircleId).InstanceUnique();
}
protected override void Draw(in OverlayDrawArgs args)
{
- var mapUid = _mapManager.GetMapEntityId(args.MapId);
+ var mapUid = _map.GetMapOrInvalid(args.MapId);
var invMatrix = args.Viewport.GetWorldToLocalMatrix();
var res = _resources.GetForViewport(args.Viewport, static _ => new CachedResources());
@@ -63,22 +68,11 @@ protected override void Draw(in OverlayDrawArgs args)
res.Blep = _clyde.CreateRenderTarget(args.Viewport.Size, new RenderTargetFormatParameters(RenderTargetColorFormat.Rgba8Srgb), name: "weather-stencil");
}
- if (_entManager.TryGetComponent(mapUid, out var comp))
- {
- foreach (var (proto, weather) in comp.Weather)
- {
- if (!_protoManager.TryIndex(proto, out var weatherProto))
- continue;
-
- var alpha = _weather.GetPercent(weather, mapUid);
- DrawWeather(args, res, weatherProto, alpha, invMatrix);
- }
- }
+ if (_statusEffects.TryEffectsWithComp(mapUid, out _weatherSet))
+ DrawWeather(args, res, _weatherSet, invMatrix);
if (_entManager.TryGetComponent(mapUid, out var restrictedRangeComponent))
- {
DrawRestrictedRange(args, res, restrictedRangeComponent, invMatrix);
- }
args.WorldHandle.UseShader(null);
args.WorldHandle.SetTransform(Matrix3x2.Identity);
diff --git a/Content.Client/Overlays/StencilOverlaySystem.cs b/Content.Client/Overlays/StencilOverlaySystem.cs
index 364ec0fddbf..95243fe6f99 100644
--- a/Content.Client/Overlays/StencilOverlaySystem.cs
+++ b/Content.Client/Overlays/StencilOverlaySystem.cs
@@ -1,5 +1,6 @@
using Content.Client.Parallax;
using Content.Client.Weather;
+using Content.Shared.StatusEffectNew;
using Robust.Client.GameObjects;
using Robust.Client.Graphics;
@@ -13,11 +14,12 @@ public sealed class StencilOverlaySystem : EntitySystem
[Dependency] private readonly SharedMapSystem _map = default!;
[Dependency] private readonly SpriteSystem _sprite = default!;
[Dependency] private readonly WeatherSystem _weather = default!;
+ [Dependency] private readonly StatusEffectsSystem _status = default!;
public override void Initialize()
{
base.Initialize();
- _overlay.AddOverlay(new StencilOverlay(_parallax, _transform, _map, _sprite, _weather));
+ _overlay.AddOverlay(new StencilOverlay(_parallax, _transform, _map, _sprite, _weather, _status));
}
public override void Shutdown()
diff --git a/Content.Client/Shuttles/UI/BaseShuttleControl.xaml.cs b/Content.Client/Shuttles/UI/BaseShuttleControl.xaml.cs
index dc66f02522e..3cea308710b 100644
--- a/Content.Client/Shuttles/UI/BaseShuttleControl.xaml.cs
+++ b/Content.Client/Shuttles/UI/BaseShuttleControl.xaml.cs
@@ -12,6 +12,8 @@
using Content.Shared._Mono.GridEdgeMarker; // Mono
using Content.Shared.Maps; // Mono
using Content.Shared.Shuttles.Components;
+using Content.Shared.StepTrigger.Components;
+using Content.Shared.Light.Components;
using Robust.Client.AutoGenerated;
using Robust.Client.Graphics;
using Robust.Client.ResourceManagement;
@@ -55,6 +57,9 @@ public partial class BaseShuttleControl : MapGridControl
private Vector2 CenterVec = new Vector2(0.5f, 0.5f); // Mono
private EntityQuery _xformQuery; // Mono
+ private EntityQuery _radarMaskQuery;
+ private EntityQuery _stepTriggerQuery;
+ private EntityQuery _tileEmissionQuery;
private Vector2[] _allVertices = Array.Empty();
@@ -70,6 +75,9 @@ public BaseShuttleControl(float minRange, float maxRange, float range) : base(mi
Maps = EntManager.System();
_lookup = EntManager.System(); // Mono
_xformQuery = EntManager.GetEntityQuery(); // Mono
+ _radarMaskQuery = EntManager.GetEntityQuery();
+ _stepTriggerQuery = EntManager.GetEntityQuery();
+ _tileEmissionQuery = EntManager.GetEntityQuery();
Font = new VectorFont(IoCManager.Resolve().GetResource("/Fonts/NotoSans/NotoSans-Regular.ttf"), 12);
_drawJob = new GridDrawJob()
@@ -160,8 +168,14 @@ protected void DrawGrid(DrawingHandleScreen handle, Matrix3x2 gridToView, Entity
if (gridData.LastBuild < grid.Comp.LastTileModifiedTick)
{
gridData.Vertices.Clear();
+ gridData.HazardVertices.Clear();
_gridTileList.Clear();
_gridNeighborSet.Clear();
+ var maskedGrid = _radarMaskQuery.TryGetComponent(grid.Owner, out var radarMask);
+ HashSet? hiddenTiles = null;
+
+ if (maskedGrid && radarMask != null)
+ hiddenTiles = radarMask.HiddenTileIds.Count > 0 ? new HashSet(radarMask.HiddenTileIds) : null;
// Okay so there's 2 steps to this
// 1. Is that get we get a set of all tiles. This is used to decompose into triangle-strips
@@ -172,6 +186,12 @@ protected void DrawGrid(DrawingHandleScreen handle, Matrix3x2 gridToView, Entity
// Mono - drawing logic rewritten
var def = (ContentTileDefinition)_tileDef[tileRef.Value.Tile.TypeId];
+ var hiddenTile = hiddenTiles != null && hiddenTiles.Contains(def.ID);
+ var hazardTile = hiddenTile && IsRadarHazardTile(grid, index);
+
+ if (hiddenTile && !hazardTile)
+ continue;
+
_gridTileList.Add((index, def));
// since our shape has to be convex, just draw it by taking our first vertex as origin
@@ -181,9 +201,10 @@ protected void DrawGrid(DrawingHandleScreen handle, Matrix3x2 gridToView, Entity
for (var i = 2; i < def.Vertices.Count; i++)
{
var vert = bl + def.Vertices[i] * tileSize;
- gridData.Vertices.Add(origin);
- gridData.Vertices.Add(prev);
- gridData.Vertices.Add(vert);
+ var verts = hazardTile ? gridData.HazardVertices : gridData.Vertices;
+ verts.Add(origin);
+ verts.Add(prev);
+ verts.Add(vert);
prev = vert;
}
@@ -339,9 +360,36 @@ protected void DrawGrid(DrawingHandleScreen handle, Matrix3x2 gridToView, Entity
handle.DrawPrimitives(DrawPrimitiveTopology.TriangleList, new Span(_allVertices, start, count), color.WithAlpha(alpha));
}
+ if (gridData.HazardVertices.Count > 0)
+ {
+ var hazardVerts = new Vector2[gridData.HazardVertices.Count];
+ var hazardJob = new GridDrawJob
+ {
+ Matrix = gridToView,
+ Vertices = gridData.HazardVertices,
+ ScaledVertices = hazardVerts,
+ };
+
+ _parallel.ProcessNow(hazardJob, hazardVerts.Length);
+ handle.DrawPrimitives(DrawPrimitiveTopology.TriangleList, hazardVerts, Color.Red.WithAlpha(0.2f));
+ }
+
handle.DrawPrimitives(DrawPrimitiveTopology.LineList, new Span(_allVertices, gridData.EdgeIndex, edgeCount), color);
}
+ private bool IsRadarHazardTile(Entity grid, Vector2i indices)
+ {
+ var anchored = Maps.GetAnchoredEntitiesEnumerator(grid, grid.Comp, indices);
+
+ while (anchored.MoveNext(out var entity))
+ {
+ if (_stepTriggerQuery.HasComp(entity.Value) || _tileEmissionQuery.HasComp(entity.Value))
+ return true;
+ }
+
+ return false;
+ }
+
private record struct GridDrawJob : IParallelRobustJob
{
public int BatchSize => 64;
@@ -365,6 +413,7 @@ public sealed class GridDrawData
*/
public List Vertices = new();
+ public List HazardVertices = new();
///
/// Vertices index from when edges start.
diff --git a/Content.Client/Weather/WeatherSystem.cs b/Content.Client/Weather/WeatherSystem.cs
index 26def25a15f..7e65b048fcc 100644
--- a/Content.Client/Weather/WeatherSystem.cs
+++ b/Content.Client/Weather/WeatherSystem.cs
@@ -1,15 +1,15 @@
using System.Numerics;
using Content.Shared.Light.Components;
+using Content.Shared.StatusEffectNew.Components;
using Content.Shared.Weather;
using Robust.Client.Audio;
using Robust.Client.GameObjects;
using Robust.Client.Player;
+using Robust.Shared.Audio.Components;
using Robust.Shared.Audio.Systems;
-using Robust.Shared.GameStates;
using Robust.Shared.Map;
using Robust.Shared.Map.Components;
using Robust.Shared.Player;
-using AudioComponent = Robust.Shared.Audio.Components.AudioComponent;
namespace Content.Client.Weather;
@@ -20,149 +20,114 @@ public sealed class WeatherSystem : SharedWeatherSystem
[Dependency] private readonly MapSystem _mapSystem = default!;
[Dependency] private readonly SharedTransformSystem _transform = default!;
+ private EntityQuery _audioQuery;
+ private EntityQuery _gridQuery;
+ private EntityQuery _roofQuery;
+
public override void Initialize()
{
base.Initialize();
- SubscribeLocalEvent(OnWeatherHandleState);
+
+ SubscribeLocalEvent(OnComponentShutdown);
+
+ _audioQuery = GetEntityQuery();
+ _gridQuery = GetEntityQuery();
+ _roofQuery = GetEntityQuery();
}
- protected override void Run(EntityUid uid, WeatherData weather, WeatherPrototype weatherProto, float frameTime)
+ private void OnComponentShutdown(Entity ent, ref ComponentShutdown args)
{
- base.Run(uid, weather, weatherProto, frameTime);
+ ent.Comp.Stream = _audio.Stop(ent.Comp.Stream);
+ }
- var ent = _playerManager.LocalEntity;
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
- if (ent == null)
+ if (!Timing.IsFirstTimePredicted)
return;
- var mapUid = Transform(uid).MapUid;
- var entXform = Transform(ent.Value);
+ var player = _playerManager.LocalEntity;
- // Maybe have the viewports manage this?
- if (mapUid == null || entXform.MapUid != mapUid)
- {
- weather.Stream = _audio.Stop(weather.Stream);
+ if (player == null)
return;
- }
- if (!Timing.IsFirstTimePredicted || weatherProto.Sound == null)
- return;
+ var playerXform = Transform(player.Value);
+
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out var weather, out var status))
+ {
+ if (weather.Sound == null || status.AppliedTo != playerXform.MapUid)
+ {
+ weather.Stream = _audio.Stop(weather.Stream);
+ continue;
+ }
- weather.Stream ??= _audio.PlayGlobal(weatherProto.Sound, Filter.Local(), true)?.Entity;
+ weather.Stream ??= _audio.PlayGlobal(weather.Sound, Filter.Local(), true)?.Entity;
- if (!TryComp(weather.Stream, out AudioComponent? comp))
- return;
+ if (!_audioQuery.TryComp(weather.Stream, out var audio))
+ continue;
- var occlusion = 0f;
+ var occlusion = 0f;
- // Work out tiles nearby to determine volume.
- if (TryComp(entXform.GridUid, out var grid))
- {
- TryComp(entXform.GridUid, out RoofComponent? roofComp);
- var gridId = entXform.GridUid.Value;
- // FloodFill to the nearest tile and use that for audio.
- var seed = _mapSystem.GetTileRef(gridId, grid, entXform.Coordinates);
- var frontier = new Queue();
- frontier.Enqueue(seed);
- // If we don't have a nearest node don't play any sound.
- EntityCoordinates? nearestNode = null;
- var visited = new HashSet();
-
- while (frontier.TryDequeue(out var node))
+ if (_gridQuery.TryComp(playerXform.GridUid, out var grid))
{
- if (!visited.Add(node.GridIndices))
- continue;
-
- if (!CanWeatherAffect(entXform.GridUid.Value, grid, node, roofComp))
+ _roofQuery.TryComp(playerXform.GridUid, out var roofComp);
+ var gridId = playerXform.GridUid.Value;
+ var seed = _mapSystem.GetTileRef(gridId, grid, playerXform.Coordinates);
+ var frontier = new Queue();
+ frontier.Enqueue(seed);
+ EntityCoordinates? nearestNode = null;
+ var visited = new HashSet();
+
+ while (frontier.TryDequeue(out var node))
{
- // Add neighbors
- // TODO: Ideally we pick some deterministically random direction and use that
- // We can't just do that naively here because it will flicker between nearby tiles.
- for (var x = -1; x <= 1; x++)
+ if (!visited.Add(node.GridIndices))
+ continue;
+
+ if (!CanWeatherAffect((playerXform.GridUid.Value, grid, roofComp), node))
{
- for (var y = -1; y <= 1; y++)
+ for (var x = -1; x <= 1; x++)
{
- if (Math.Abs(x) == 1 && Math.Abs(y) == 1 ||
- x == 0 && y == 0 ||
- (new Vector2(x, y) + node.GridIndices - seed.GridIndices).Length() > 3)
+ for (var y = -1; y <= 1; y++)
{
- continue;
+ if (Math.Abs(x) == 1 && Math.Abs(y) == 1 ||
+ x == 0 && y == 0 ||
+ (new Vector2(x, y) + node.GridIndices - seed.GridIndices).Length() > 3)
+ {
+ continue;
+ }
+
+ frontier.Enqueue(_mapSystem.GetTileRef(gridId, grid, new Vector2i(x, y) + node.GridIndices));
}
-
- frontier.Enqueue(_mapSystem.GetTileRef(gridId, grid, new Vector2i(x, y) + node.GridIndices));
}
+
+ continue;
}
- continue;
+ nearestNode = new EntityCoordinates(playerXform.GridUid.Value,
+ node.GridIndices + grid.TileSizeHalfVector);
+ break;
}
- nearestNode = new EntityCoordinates(entXform.GridUid.Value,
- node.GridIndices + grid.TileSizeHalfVector);
- break;
- }
-
- // Get occlusion to the targeted node if it exists, otherwise set a default occlusion.
- if (nearestNode != null)
- {
- var entPos = _transform.GetMapCoordinates(entXform);
- var nodePosition = _transform.ToMapCoordinates(nearestNode.Value).Position;
- var delta = nodePosition - entPos.Position;
- var distance = delta.Length();
- occlusion = _audio.GetOcclusion(entPos, delta, distance);
- }
- else
- {
- occlusion = 3f;
- }
- }
-
- var alpha = GetPercent(weather, uid);
- alpha *= SharedAudioSystem.VolumeToGain(weatherProto.Sound.Params.Volume);
- _audio.SetGain(weather.Stream, alpha, comp);
- comp.Occlusion = occlusion;
- }
-
- protected override bool SetState(EntityUid uid, WeatherState state, WeatherComponent comp, WeatherData weather, WeatherPrototype weatherProto)
- {
- if (!base.SetState(uid, state, comp, weather, weatherProto))
- return false;
-
- if (!Timing.IsFirstTimePredicted)
- return true;
-
- // TODO: Fades (properly)
- weather.Stream = _audio.Stop(weather.Stream);
- weather.Stream = _audio.PlayGlobal(weatherProto.Sound, Filter.Local(), true)?.Entity;
- return true;
- }
-
- private void OnWeatherHandleState(EntityUid uid, WeatherComponent component, ref ComponentHandleState args)
- {
- if (args.Current is not WeatherComponentState state)
- return;
-
- foreach (var (proto, weather) in component.Weather)
- {
- // End existing one
- if (!state.Weather.TryGetValue(proto, out var stateData))
- {
- EndWeather(uid, component, proto);
- continue;
+ if (nearestNode != null)
+ {
+ var entPos = _transform.GetMapCoordinates(playerXform);
+ var nodePosition = _transform.ToMapCoordinates(nearestNode.Value).Position;
+ var delta = nodePosition - entPos.Position;
+ var distance = delta.Length();
+ occlusion = _audio.GetOcclusion(entPos, delta, distance);
+ }
+ else
+ {
+ occlusion = 3f;
+ }
}
- // Data update?
- weather.StartTime = stateData.StartTime;
- weather.EndTime = stateData.EndTime;
- weather.State = stateData.State;
- }
-
- foreach (var (proto, weather) in state.Weather)
- {
- if (component.Weather.ContainsKey(proto))
- continue;
-
- // New weather
- StartWeather(uid, component, ProtoMan.Index(proto), weather.EndTime);
+ var alpha = GetWeatherPercent((uid, status));
+ alpha *= SharedAudioSystem.VolumeToGain(weather.Sound.Params.Volume);
+ _audio.SetGain(weather.Stream, alpha, audio);
+ audio.Occlusion = occlusion;
}
}
}
diff --git a/Content.IntegrationTests/Tests/SectorWorldExpeditionIntegrationTest.cs b/Content.IntegrationTests/Tests/SectorWorldExpeditionIntegrationTest.cs
new file mode 100644
index 00000000000..fd20af78885
--- /dev/null
+++ b/Content.IntegrationTests/Tests/SectorWorldExpeditionIntegrationTest.cs
@@ -0,0 +1,261 @@
+using System.Collections.Generic;
+using System.Numerics;
+using System.Linq;
+using Content.Server.Atmos.Components;
+using Content.Server.Atmos.EntitySystems;
+using Content.Server.Salvage;
+using Content.Server.Salvage.Expeditions;
+using Content.Server.Parallax;
+using Content.Server.Shuttles.Events;
+using Content.Server.Worldgen;
+using Content.Server.Worldgen.Components;
+using Content.Server.Worldgen.Systems;
+using Content.Shared.Atmos;
+using Content.Shared.Gravity;
+using Content.Shared.Maps;
+using Content.Shared.Mobs.Components;
+using Content.Shared.Parallax.Biomes;
+using Content.Shared.Shuttles.Components;
+using Robust.Shared.GameObjects;
+using Robust.Shared.Map;
+using Robust.Shared.Map.Components;
+using Robust.Shared.Maths;
+using Robust.Shared.Prototypes;
+
+namespace Content.IntegrationTests.Tests;
+
+[TestFixture]
+public sealed class SectorWorldExpeditionIntegrationTest
+{
+ private static readonly ProtoId SnowBiomeTemplateId = "NFVGRoidSnow";
+
+ private static List CreatePlanetTypes() =>
+ [
+ new SectorPlanetTypeDefinition
+ {
+ Id = "lava",
+ Name = "Lava",
+ BiomeTemplate = "NFVGRoidLava",
+ SurfaceTiles = ["FloorBasalt"],
+ MinTemperature = 700f,
+ MaxTemperature = 700f,
+ MinOxygen = 0f,
+ MaxOxygen = 0f,
+ MinNitrogen = 0f,
+ MaxNitrogen = 0f,
+ MinCarbonDioxide = 18f,
+ MaxCarbonDioxide = 18f,
+ },
+ new SectorPlanetTypeDefinition
+ {
+ Id = "tundra",
+ Name = "Tundra",
+ BiomeTemplate = "NFVGRoidSnow",
+ SurfaceTiles = ["FloorSnow"],
+ MinTemperature = 255f,
+ MaxTemperature = 255f,
+ MinOxygen = 18f,
+ MaxOxygen = 18f,
+ MinNitrogen = 60f,
+ MaxNitrogen = 60f,
+ MinCarbonDioxide = 1f,
+ MaxCarbonDioxide = 1f,
+ }
+ ];
+
+ [Test]
+ public async Task PersistentPlanetTypeMapsHaveConfiguredAtmosphereTest()
+ {
+ await using var pair = await PoolManager.GetServerClient();
+ var server = pair.Server;
+ EntityUid sectorMap = default;
+
+ await server.WaitPost(() =>
+ {
+ var entMan = server.ResolveDependency();
+ var mapSystem = entMan.System();
+ var sectorWorld = entMan.System();
+ sectorMap = mapSystem.CreateMap(out _);
+ var sector = entMan.EnsureComponent(sectorMap);
+ sector.UniverseSeed = 1337;
+ sector.PlanetTypes = CreatePlanetTypes();
+ sectorWorld.TryGetPlanetAtPosition(sectorMap, Vector2.Zero, out _, sector);
+ });
+
+ await pair.RunTicksSync(1);
+
+ await server.WaitPost(() =>
+ {
+ var entMan = server.ResolveDependency();
+ var atmos = entMan.System();
+ var sector = entMan.GetComponent(sectorMap);
+
+ Assert.That(sector.SpaceMap, Is.EqualTo(sectorMap));
+ Assert.That(sector.FtlMap, Is.Not.Null);
+ Assert.That(sector.ColCommMap, Is.Not.Null);
+ Assert.That(sector.PlanetTypeMaps.Count, Is.EqualTo(sector.PlanetTypes.Count));
+
+ foreach (var planet in sector.Planets)
+ {
+ Assert.That(sector.PlanetTypeMaps.TryGetValue(planet.PlanetTypeId, out var layerMap), Is.True, planet.PlanetTypeId);
+ Assert.That(entMan.TryGetComponent(layerMap, out var mapAtmos), Is.True, planet.PlanetTypeId);
+ Assert.That(entMan.TryGetComponent(layerMap, out var gravity), Is.True, planet.PlanetTypeId);
+ var mix = atmos.GetTileMixture(null, (layerMap, mapAtmos), Vector2i.Zero);
+
+ Assert.That(gravity.Enabled, Is.True, planet.PlanetTypeId);
+ Assert.That(mapAtmos!.Space, Is.False, planet.PlanetTypeId);
+ Assert.That(mix, Is.Not.Null, planet.PlanetTypeId);
+ Assert.That(mix!.Temperature, Is.EqualTo(planet.Temperature).Within(0.01f), planet.PlanetTypeId);
+ Assert.That(mix.GetMoles(Gas.Oxygen), Is.EqualTo(planet.Oxygen).Within(0.01f), planet.PlanetTypeId);
+ Assert.That(mix.GetMoles(Gas.Nitrogen), Is.EqualTo(planet.Nitrogen).Within(0.01f), planet.PlanetTypeId);
+ Assert.That(mix.GetMoles(Gas.CarbonDioxide), Is.EqualTo(planet.CarbonDioxide).Within(0.01f), planet.PlanetTypeId);
+ }
+
+ Assert.That(entMan.TryGetComponent(sector.FtlMap!.Value, out var ftlAtmos), Is.True);
+ Assert.That(ftlAtmos!.Space, Is.True);
+ });
+
+ await pair.CleanReturnAsync();
+ }
+
+ [Test]
+ public async Task SharedPlanetMapExpeditionsDoNotBlockOtherConsoleFtlAttemptTest()
+ {
+ await using var pair = await PoolManager.GetServerClient();
+ var server = pair.Server;
+ EntityUid sectorMap = default;
+
+ await server.WaitPost(() =>
+ {
+ var entMan = server.ResolveDependency();
+ var mapSystem = entMan.System();
+ var sectorWorld = entMan.System();
+ sectorMap = mapSystem.CreateMap(out _);
+ var sector = entMan.EnsureComponent(sectorMap);
+ sector.UniverseSeed = 7331;
+ sector.PlanetTypes = CreatePlanetTypes();
+ sectorWorld.TryGetPlanetAtPosition(sectorMap, Vector2.Zero, out _, sector);
+ });
+
+ await pair.RunTicksSync(1);
+
+ await server.WaitPost(() =>
+ {
+ var entMan = server.ResolveDependency();
+ var mapMan = server.ResolveDependency();
+ var xform = entMan.System();
+ var sector = entMan.GetComponent(sectorMap);
+
+ var hostPlanet = sector.Planets.First();
+ var hostMap = sector.PlanetTypeMaps[hostPlanet.PlanetTypeId];
+ var hostMapId = entMan.GetComponent(hostMap).MapId;
+
+ var expeditionA = mapMan.CreateGridEntity(hostMapId);
+ var expeditionB = mapMan.CreateGridEntity(hostMapId);
+
+ xform.SetCoordinates(expeditionA.Owner, new EntityCoordinates(hostMap, Vector2.Zero));
+ xform.SetCoordinates(expeditionB.Owner, new EntityCoordinates(hostMap, new Vector2(1024f, 0f)));
+
+ entMan.AddComponent(expeditionA.Owner);
+ entMan.AddComponent(expeditionB.Owner);
+
+ var siteA = entMan.EnsureComponent(expeditionA.Owner);
+ siteA.SectorMap = hostMap;
+ siteA.PlanetId = hostPlanet.PlanetId;
+ siteA.Center = Vector2.Zero;
+ siteA.Radius = 196f;
+
+ var siteB = entMan.EnsureComponent(expeditionB.Owner);
+ siteB.SectorMap = hostMap;
+ siteB.PlanetId = hostPlanet.PlanetId;
+ siteB.Center = new Vector2(1024f, 0f);
+ siteB.Radius = 196f;
+
+ var crew = entMan.SpawnEntity("MobHuman", new EntityCoordinates(expeditionB.Owner, Vector2.Zero));
+ Assert.That(entMan.HasComponent(crew), Is.True);
+
+ var ev = new ConsoleFTLAttemptEvent(expeditionA.Owner, false, string.Empty);
+ entMan.EventBus.RaiseLocalEvent(expeditionA.Owner, ref ev);
+
+ Assert.That(ev.Cancelled, Is.False, "Crew on a different expedition in the same host map should not block FTL.");
+ });
+
+ await pair.CleanReturnAsync();
+ }
+
+ [Test]
+ public async Task WorldLoaderKeepsBiomeChunkLoadedUntilChunkLeavesLoaderRadiusTest()
+ {
+ await using var pair = await PoolManager.GetServerClient();
+ var server = pair.Server;
+ EntityUid mapUid = default;
+ EntityUid loaderUid = default;
+ var targetChunk = new Vector2i(WorldGen.ChunkSize, 0);
+
+ await server.WaitPost(() =>
+ {
+ var entMan = server.ResolveDependency();
+ var proto = server.ResolveDependency();
+ var biomeSystem = entMan.System();
+ var mapSystem = entMan.System();
+ var worldController = entMan.System();
+
+ mapUid = mapSystem.CreateMap(out _);
+ var biomeTemplate = proto.Index(SnowBiomeTemplateId);
+ biomeSystem.EnsurePlanet(mapUid, biomeTemplate, seed: 1337);
+
+ loaderUid = entMan.SpawnEntity(null, new EntityCoordinates(mapUid, new Vector2(WorldGen.ChunkSize / 2f, 0f)));
+ entMan.EnsureComponent(loaderUid);
+ worldController.SetLoaderRadius(loaderUid, WorldGen.ChunkSize);
+ });
+
+ await pair.RunTicksSync(5);
+
+ await server.WaitPost(() =>
+ {
+ var entMan = server.ResolveDependency();
+ var biomeSystem = entMan.System();
+
+ Assert.That(biomeSystem.IsChunkLoaded(mapUid, targetChunk), Is.True,
+ "The biome chunk should load from world-loader coverage even without player PVS.");
+ });
+
+ await server.WaitPost(() =>
+ {
+ var entMan = server.ResolveDependency();
+ var xform = entMan.System();
+ xform.SetCoordinates(loaderUid, new EntityCoordinates(mapUid, new Vector2(WorldGen.ChunkSize + WorldGen.ChunkSize / 2f, 0f)));
+ });
+
+ await pair.RunTicksSync(5);
+
+ await server.WaitPost(() =>
+ {
+ var entMan = server.ResolveDependency();
+ var biomeSystem = entMan.System();
+
+ Assert.That(biomeSystem.IsChunkLoaded(mapUid, targetChunk), Is.True,
+ "Crossing a chunk edge should not unload a chunk while the world loader still covers it.");
+ });
+
+ await server.WaitPost(() =>
+ {
+ var entMan = server.ResolveDependency();
+ var xform = entMan.System();
+ xform.SetCoordinates(loaderUid, new EntityCoordinates(mapUid, new Vector2(WorldGen.ChunkSize * 3 + WorldGen.ChunkSize / 8f, 0f)));
+ });
+
+ await pair.RunTicksSync(5);
+
+ await server.WaitPost(() =>
+ {
+ var entMan = server.ResolveDependency();
+ var biomeSystem = entMan.System();
+
+ Assert.That(biomeSystem.IsChunkLoaded(mapUid, targetChunk), Is.False,
+ "The biome chunk should unload once it leaves world-loader coverage.");
+ });
+
+ await pair.CleanReturnAsync();
+ }
+}
\ No newline at end of file
diff --git a/Content.Server/Administration/Commands/PersistenceSaveCommand.cs b/Content.Server/Administration/Commands/PersistenceSaveCommand.cs
index cae507f6d8e..eb6a2009417 100644
--- a/Content.Server/Administration/Commands/PersistenceSaveCommand.cs
+++ b/Content.Server/Administration/Commands/PersistenceSaveCommand.cs
@@ -12,8 +12,7 @@ namespace Content.Server.Administration.Commands;
public sealed class PersistenceSave : IConsoleCommand
{
[Dependency] private readonly IConfigurationManager _config = default!;
- [Dependency] private readonly IEntitySystemManager _system = default!;
- [Dependency] private readonly IMapManager _map = default!;
+ [Dependency] private readonly IEntityManager _entManager = default!;
public string Command => "persistencesave";
public string Description => "Saves server data to a persistence file to be loaded later.";
@@ -21,6 +20,9 @@ public sealed class PersistenceSave : IConsoleCommand
public void Execute(IConsoleShell shell, string argStr, string[] args)
{
+ var mapSystem = _entManager.System();
+ var mapLoader = _entManager.System();
+
if (args.Length < 1 || args.Length > 2)
{
shell.WriteError(Loc.GetString("shell-wrong-arguments-number"));
@@ -34,7 +36,7 @@ public void Execute(IConsoleShell shell, string argStr, string[] args)
}
var mapId = new MapId(intMapId);
- if (!_map.MapExists(mapId))
+ if (!mapSystem.MapExists(mapId))
{
shell.WriteError(Loc.GetString("cmd-savemap-not-exist"));
return;
@@ -47,7 +49,6 @@ public void Execute(IConsoleShell shell, string argStr, string[] args)
return;
}
- var mapLoader = _system.GetEntitySystem();
mapLoader.TrySaveMap(mapId, new ResPath(saveFilePath));
shell.WriteLine(Loc.GetString("cmd-savemap-success"));
}
diff --git a/Content.Server/Administration/Commands/WeatherCommands.cs b/Content.Server/Administration/Commands/WeatherCommands.cs
new file mode 100644
index 00000000000..27e7bfd1a63
--- /dev/null
+++ b/Content.Server/Administration/Commands/WeatherCommands.cs
@@ -0,0 +1,362 @@
+using System.Linq;
+using Content.Server.Administration;
+using Content.Server.Shuttles.Systems;
+using Content.Server.Weather;
+using Content.Server.Worldgen.Components;
+using Content.Server.Worldgen.Systems;
+using Content.Shared.Administration;
+using Robust.Shared.Console;
+using Robust.Shared.Enums;
+using Robust.Shared.Map;
+using Robust.Shared.Map.Components;
+using Robust.Shared.Prototypes;
+
+namespace Content.Server.Administration.Commands;
+
+[AdminCommand(AdminFlags.Debug)]
+public sealed class WeatherHereCommand : IConsoleCommand
+{
+ [Dependency] private readonly IEntityManager _entManager = default!;
+ [Dependency] private readonly IPrototypeManager _proto = default!;
+
+ public string Command => "weatherhere";
+ public string Description => "Sets weather on the map you are currently on. Use 'none' to clear it.";
+ public string Help => "weatherhere ";
+
+ public void Execute(IConsoleShell shell, string argStr, string[] args)
+ {
+ if (args.Length != 1)
+ {
+ shell.WriteError("Expected 1 argument: ");
+ return;
+ }
+
+ var player = shell.Player;
+ if (player == null || player.Status != SessionStatus.InGame || player.AttachedEntity is not { Valid: true } attached)
+ {
+ shell.WriteError("You must be in-game to use weatherhere.");
+ return;
+ }
+
+ var mapId = _entManager.GetComponent(attached).MapID;
+ if (mapId == MapId.Nullspace)
+ {
+ shell.WriteError("You are not on a valid map.");
+ return;
+ }
+
+ if (!WeatherCommandHelpers.TryResolveWeather(_proto, args[0], out var weatherId, out var error))
+ {
+ shell.WriteError(error);
+ return;
+ }
+
+ var weather = _entManager.System();
+ if (!weather.TrySetWeather(mapId, weatherId, out _))
+ {
+ shell.WriteError($"Failed to set weather on map {mapId}.");
+ return;
+ }
+
+ shell.WriteLine(weatherId == null
+ ? $"Cleared weather on map {mapId}."
+ : $"Set weather on map {mapId} to {weatherId}.");
+ }
+
+ public CompletionResult GetCompletion(IConsoleShell shell, string[] args)
+ {
+ return args.Length == 1
+ ? CompletionResult.FromHint("weatherPrototype or 'none'")
+ : CompletionResult.Empty;
+ }
+}
+
+[AdminCommand(AdminFlags.Debug)]
+public sealed class WeatherMapCommand : IConsoleCommand
+{
+ [Dependency] private readonly IEntityManager _entManager = default!;
+ [Dependency] private readonly IMapManager _maps = default!;
+ [Dependency] private readonly IPrototypeManager _proto = default!;
+
+ public string Command => "weathermap";
+ public string Description => "Sets weather on a specific map ID. Use 'none' to clear it.";
+ public string Help => "weathermap ";
+
+ public void Execute(IConsoleShell shell, string argStr, string[] args)
+ {
+ if (args.Length != 2)
+ {
+ shell.WriteError("Expected 2 arguments: ");
+ return;
+ }
+
+ if (!int.TryParse(args[0], out var mapInt))
+ {
+ shell.WriteError($"Invalid map id '{args[0]}'.");
+ return;
+ }
+
+ var mapId = new MapId(mapInt);
+ if (!_maps.MapExists(mapId))
+ {
+ shell.WriteError($"Map {mapId} does not exist.");
+ return;
+ }
+
+ if (!WeatherCommandHelpers.TryResolveWeather(_proto, args[1], out var weatherId, out var error))
+ {
+ shell.WriteError(error);
+ return;
+ }
+
+ var weather = _entManager.System();
+ if (!weather.TrySetWeather(mapId, weatherId, out _))
+ {
+ shell.WriteError($"Failed to set weather on map {mapId}.");
+ return;
+ }
+
+ shell.WriteLine(weatherId == null
+ ? $"Cleared weather on map {mapId}."
+ : $"Set weather on map {mapId} to {weatherId}.");
+ }
+
+ public CompletionResult GetCompletion(IConsoleShell shell, string[] args)
+ {
+ return args.Length switch
+ {
+ 1 => CompletionResult.FromHintOptions(CompletionHelper.MapIds(_entManager), "Map ID"),
+ 2 => CompletionResult.FromHint("weatherPrototype or 'none'"),
+ _ => CompletionResult.Empty,
+ };
+ }
+}
+
+[AdminCommand(AdminFlags.Debug)]
+public sealed class WeatherPlanetCommand : IConsoleCommand
+{
+ [Dependency] private readonly IEntityManager _entManager = default!;
+ [Dependency] private readonly IPrototypeManager _proto = default!;
+
+ public string Command => "weatherplanet";
+ public string Description => "Sets weather on a persistent sector layer: planet type id, space, ftl, or colcomm.";
+ public string Help => "weatherplanet ";
+
+ public void Execute(IConsoleShell shell, string argStr, string[] args)
+ {
+ if (args.Length != 2)
+ {
+ shell.WriteError("Expected 2 arguments: ");
+ return;
+ }
+
+ var sectorWorld = _entManager.System();
+ if (!WeatherCommandHelpers.TryResolvePersistentMap(_entManager, sectorWorld, args[0], out var mapUid, out var targetLabel, out var targetError))
+ {
+ shell.WriteError(targetError);
+ return;
+ }
+
+ if (!WeatherCommandHelpers.TryResolveWeather(_proto, args[1], out var weatherId, out var error))
+ {
+ shell.WriteError(error);
+ return;
+ }
+
+ var weather = _entManager.System();
+ if (!_entManager.TryGetComponent(mapUid, out var mapComp) || !weather.TrySetWeather(mapComp.MapId, weatherId, out _))
+ {
+ shell.WriteError($"Failed to set weather for target '{targetLabel}'.");
+ return;
+ }
+
+ shell.WriteLine(weatherId == null
+ ? $"Cleared weather on {targetLabel} (map {mapComp.MapId})."
+ : $"Set weather on {targetLabel} (map {mapComp.MapId}) to {weatherId}.");
+ }
+
+ public CompletionResult GetCompletion(IConsoleShell shell, string[] args)
+ {
+ if (args.Length == 1)
+ {
+ var sectorWorld = _entManager.System();
+ var options = WeatherCommandHelpers.GetPersistentMapTargets(_entManager, sectorWorld);
+ return CompletionResult.FromOptions(options);
+ }
+
+ return args.Length == 2
+ ? CompletionResult.FromHint("weatherPrototype or 'none'")
+ : CompletionResult.Empty;
+ }
+}
+
+[AdminCommand(AdminFlags.Debug)]
+public sealed class ListPlanetMapsCommand : IConsoleCommand
+{
+ [Dependency] private readonly IEntityManager _entManager = default!;
+
+ public string Command => "listplanetmaps";
+ public string Description => "Lists persistent sector layer targets and their map IDs.";
+ public string Help => "listplanetmaps";
+
+ public void Execute(IConsoleShell shell, string argStr, string[] args)
+ {
+ if (args.Length != 0)
+ {
+ shell.WriteError("This command takes no arguments.");
+ return;
+ }
+
+ var sectorWorld = _entManager.System();
+ if (!sectorWorld.TryGetDefaultSectorMap(out var sectorMap, out var sector))
+ {
+ shell.WriteError("Default sector map is not available.");
+ return;
+ }
+
+ shell.WriteLine($"sector: map {GetMapIdText(sectorMap)}");
+
+ if (sector.SpaceMap is { } spaceMap)
+ shell.WriteLine($"space: map {GetMapIdText(spaceMap)}");
+
+ if (sector.FtlMap is { } ftlMap)
+ shell.WriteLine($"ftl: map {GetMapIdText(ftlMap)}");
+
+ if (WeatherCommandHelpers.TryGetColcommMap(_entManager, out var colCommMap))
+ shell.WriteLine($"colcomm: map {GetMapIdText(colCommMap)}");
+
+ foreach (var planetType in sector.PlanetTypes)
+ {
+ if (!sector.PlanetTypeMaps.TryGetValue(planetType.Id, out var mapUid))
+ continue;
+
+ shell.WriteLine($"{planetType.Id}: map {GetMapIdText(mapUid)} ({planetType.Name})");
+ }
+ }
+
+ private string GetMapIdText(EntityUid mapUid)
+ {
+ return _entManager.TryGetComponent(mapUid, out var mapComp)
+ ? mapComp.MapId.ToString()
+ : "unknown";
+ }
+}
+
+internal static class WeatherCommandHelpers
+{
+ public static bool TryResolveWeather(IPrototypeManager proto, string input, out string? weatherId, out string error)
+ {
+ error = string.Empty;
+ weatherId = null;
+
+ if (string.Equals(input, "none", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(input, "clear", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(input, "off", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ if (proto.HasIndex(input))
+ {
+ weatherId = input;
+ return true;
+ }
+
+ var prefixed = $"Weather{input}";
+ if (proto.HasIndex(prefixed))
+ {
+ weatherId = prefixed;
+ return true;
+ }
+
+ error = $"Unknown weather prototype '{input}'. Try an entity id like WeatherRain, or use 'none' to clear.";
+ return false;
+ }
+
+ public static bool TryResolvePersistentMap(IEntityManager entManager, SectorWorldSystem sectorWorld, string target, out EntityUid mapUid, out string targetLabel, out string error)
+ {
+ mapUid = EntityUid.Invalid;
+ targetLabel = target;
+ error = string.Empty;
+
+ if (!sectorWorld.TryGetDefaultSectorMap(out var sectorMap, out var sector))
+ {
+ error = "Default sector map is not available.";
+ return false;
+ }
+
+ switch (target.ToLowerInvariant())
+ {
+ case "sector":
+ mapUid = sectorMap;
+ targetLabel = "sector";
+ return true;
+ case "space":
+ mapUid = sector.SpaceMap ?? sectorMap;
+ targetLabel = "space";
+ return true;
+ case "ftl" when sector.FtlMap is { } ftlMap:
+ mapUid = ftlMap;
+ targetLabel = "ftl";
+ return true;
+ case "colcomm" when TryGetColcommMap(entManager, out var colCommMap):
+ mapUid = colCommMap;
+ targetLabel = "colcomm";
+ return true;
+ }
+
+ if (sector.PlanetTypeMaps.TryGetValue(target, out var planetMap))
+ {
+ mapUid = planetMap;
+ targetLabel = target;
+ return true;
+ }
+
+ error = $"Unknown persistent map target '{target}'. Use listplanetmaps to see valid planet targets.";
+ return false;
+ }
+
+ public static List GetPersistentMapTargets(IEntityManager entManager, SectorWorldSystem sectorWorld)
+ {
+ var options = new List();
+ if (!sectorWorld.TryGetDefaultSectorMap(out var sectorMap, out var sector))
+ return options;
+
+ options.Add(new CompletionOption("sector", DescribeMap(entManager, sectorMap)));
+ options.Add(new CompletionOption("space", DescribeMap(entManager, sector.SpaceMap ?? sectorMap)));
+
+ if (sector.FtlMap is { } ftlMap)
+ options.Add(new CompletionOption("ftl", DescribeMap(entManager, ftlMap)));
+
+ if (TryGetColcommMap(entManager, out var colCommMap))
+ options.Add(new CompletionOption("colcomm", DescribeMap(entManager, colCommMap)));
+
+ foreach (var planetType in sector.PlanetTypes.Where(pt => sector.PlanetTypeMaps.ContainsKey(pt.Id)))
+ {
+ sector.PlanetTypeMaps.TryGetValue(planetType.Id, out var mapUid);
+ options.Add(new CompletionOption(planetType.Id, $"{planetType.Name} - {DescribeMap(entManager, mapUid)}"));
+ }
+
+ return options;
+ }
+
+ private static string DescribeMap(IEntityManager entManager, EntityUid mapUid)
+ {
+ return entManager.TryGetComponent(mapUid, out var mapComp)
+ ? $"Map {mapComp.MapId}"
+ : "Map unknown";
+ }
+
+ public static bool TryGetColcommMap(IEntityManager entManager, out EntityUid mapUid)
+ {
+ mapUid = EntityUid.Invalid;
+
+ var emergencyShuttle = entManager.System();
+ var colcommMaps = emergencyShuttle.GetColcommMaps();
+ if (colcommMaps.Count == 0)
+ return false;
+
+ mapUid = colcommMaps.First();
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/Content.Server/GameTicking/GameTicker.RoundFlow.cs b/Content.Server/GameTicking/GameTicker.RoundFlow.cs
index 4cc35a74774..ffd1bf1344d 100644
--- a/Content.Server/GameTicking/GameTicker.RoundFlow.cs
+++ b/Content.Server/GameTicking/GameTicker.RoundFlow.cs
@@ -13,6 +13,7 @@
using Content.Server.Shuttles.Systems;
using Content.Server.Station.Components;
using Content.Server.Station.Systems; // Add this if missing
+using Content.Server.Worldgen.Systems;
using Content.Shared._NF.Shipyard.Components;
using Content.Shared.CCVar;
using Content.Shared.Database;
@@ -45,6 +46,7 @@ public sealed partial class GameTicker
[Dependency] private readonly ITaskManager _taskManager = default!;
[Dependency] private readonly ArrivalsSystem _arrivalsSystem = default!;
[Dependency] private readonly ShuttleSystem _shuttleSystem = default!;
+ [Dependency] private readonly SectorWorldSystem _sectorWorld = default!;
private static readonly Counter RoundNumberMetric = Metrics.CreateCounter(
"ss14_round_number",
@@ -615,23 +617,38 @@ void QueueShuttle(EntityUid shuttleUid, ShuttleComponent shuttle, TransformCompo
// HardLight end
// --- End Corrected Colcomm logic ---
- // Aggressively delete the default map after a 30 second delay
+ // Aggressively delete the default map and any generated layer maps after a 30 second delay.
var defaultMapEntityUid = _mapManager.GetMapEntityId(DefaultMap);
+ var roundEndCleanupMaps = _sectorWorld.GetRoundEndCleanupMapIds();
if (DefaultMap != null)
{
Timer.Spawn(TimeSpan.FromSeconds(30), () =>
{
- // Send all players on the default map to the lobby before deleting the map
+ // Send all players on round-end cleanup maps to the lobby before deleting those maps.
foreach (var session in _playerManager.Sessions)
{
var attachedEntity = session.AttachedEntity;
- if (attachedEntity != null && Transform(attachedEntity.Value).MapID == DefaultMap)
+ if (attachedEntity == null)
+ continue;
+
+ var playerMap = Transform(attachedEntity.Value).MapID;
+ if (playerMap == DefaultMap || roundEndCleanupMaps.Contains(playerMap))
{
PlayerJoinLobby(session);
}
}
QueueDel(defaultMapEntityUid);
+
+ foreach (var mapId in roundEndCleanupMaps)
+ {
+ if (!_map.MapExists(mapId))
+ continue;
+
+ var mapUid = _map.GetMapOrInvalid(mapId);
+ if (mapUid.IsValid())
+ QueueDel(mapUid);
+ }
});
}
diff --git a/Content.Server/Gateway/Systems/GatewayGeneratorSystem.cs b/Content.Server/Gateway/Systems/GatewayGeneratorSystem.cs
index f2a0368db98..43f9941020f 100644
--- a/Content.Server/Gateway/Systems/GatewayGeneratorSystem.cs
+++ b/Content.Server/Gateway/Systems/GatewayGeneratorSystem.cs
@@ -1,14 +1,18 @@
using System.Linq;
+using System.Threading.Tasks;
using Content.Server.Gateway.Components;
using Content.Server.Parallax;
using Content.Server.Procedural;
+using Content.Server.Shuttles.Components;
+using Content.Server.Worldgen;
+using Content.Server.Worldgen.Components;
+using Content.Server.Worldgen.Systems;
using Content.Shared.CCVar;
using Content.Shared.Dataset;
using Content.Shared.Gateway.Components;
using Content.Shared.Maps;
using Content.Shared.Parallax.Biomes;
using Content.Shared.Procedural;
-using Content.Shared.Salvage;
using Robust.Shared.Configuration;
using Robust.Shared.Map;
using Robust.Shared.Map.Components;
@@ -16,6 +20,7 @@
using Robust.Shared.Random;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
+using Robust.Shared.GameObjects;
namespace Content.Server.Gateway.Systems;
@@ -26,7 +31,6 @@ public sealed class GatewayGeneratorSystem : EntitySystem
{
[Dependency] private readonly IConfigurationManager _cfgManager = default!;
[Dependency] private readonly IGameTiming _timing = default!;
- [Dependency] private readonly IMapManager _mapManager = default!;
[Dependency] private readonly IPrototypeManager _protoManager = default!;
[Dependency] private readonly IRobustRandom _random = default!;
[Dependency] private readonly ITileDefinitionManager _tileDefManager = default!;
@@ -35,11 +39,11 @@ public sealed class GatewayGeneratorSystem : EntitySystem
[Dependency] private readonly GatewaySystem _gateway = default!;
[Dependency] private readonly MetaDataSystem _metadata = default!;
[Dependency] private readonly SharedMapSystem _maps = default!;
- [Dependency] private readonly SharedSalvageSystem _salvage = default!;
[Dependency] private readonly TileSystem _tile = default!;
+ [Dependency] private readonly SectorWorldSystem _sectorWorld = default!;
+ [Dependency] private readonly SharedTransformSystem _xform = default!;
+ [Dependency] private readonly WorldControllerSystem _worldController = default!;
- private static readonly ProtoId PlanetNamesId = "NamesBorer";
- private static readonly ProtoId ContinentalId = "Continental";
private static readonly ProtoId ExperimentDungeonId = "Experiment";
// TODO:
@@ -97,48 +101,62 @@ private void GenerateDestination(EntityUid uid, GatewayGeneratorComponent? gener
if (!Resolve(uid, ref generator))
return;
- var tileDef = _tileDefManager["FloorSteel"];
- const int MaxOffset = 256;
- var tiles = new List<(Vector2i Index, Tile Tile)>();
+ if (generator == null)
+ return;
+
+ var generatorComp = generator;
+
var seed = _random.Next();
var random = new Random(seed);
- var mapId = _mapManager.CreateMap();
- var mapUid = _mapManager.GetMapEntityId(mapId);
- var gatewayName = _salvage.GetFTLName(_protoManager.Index(PlanetNamesId), seed);
- _metadata.SetEntityName(mapUid, gatewayName);
+ if (!_sectorWorld.TryGetDefaultSectorMap(out _, out var sector) || sector.Planets.Count == 0)
+ return;
- var origin = new Vector2i(random.Next(-MaxOffset, MaxOffset), random.Next(-MaxOffset, MaxOffset));
+ var availablePlanets = sector.Planets
+ .Where(planet => _sectorWorld.TryGetPersistentMap(planet.PlanetTypeId, out _, out _, sector))
+ .ToList();
- _biome.EnsurePlanet(mapUid, _protoManager.Index(ContinentalId), seed);
+ if (availablePlanets.Count == 0)
+ return;
- var grid = Comp(mapUid);
+ var hostPlanet = availablePlanets[random.Next(availablePlanets.Count)];
+ if (!_sectorWorld.TryGetPersistentMap(hostPlanet.PlanetTypeId, out var hostMapUid, out _))
+ return;
- for (var x = -2; x <= 2; x++)
+ var hostGridUid = hostMapUid;
+ var grid = EnsureComp(hostGridUid);
+ var gatewayUid = EntityManager.SpawnEntity(generatorComp.Proto, MapCoordinates.Nullspace);
+
+ if (!_sectorWorld.TryReserveExpeditionSite(seed, gatewayUid, hostPlanet.PlanetTypeId, out var placement))
{
- for (var y = -2; y <= 2; y++)
- {
- tiles.Add((new Vector2i(x, y) + origin, new Tile(tileDef.TileId, variant: _tile.PickVariant((ContentTileDefinition) tileDef, random))));
- }
+ QueueDel(gatewayUid);
+ return;
}
- // Clear area nearby as a sort of landing pad.
- _maps.SetTiles(mapUid, grid, tiles);
+ var gatewayName = placement.Planet.Name;
+ _metadata.SetEntityName(gatewayUid, gatewayName);
+ _xform.SetCoordinates(gatewayUid, new EntityCoordinates(hostGridUid, placement.Center));
- _metadata.SetEntityName(mapUid, gatewayName);
- var originCoords = new EntityCoordinates(mapUid, origin);
+ var site = EnsureComp(gatewayUid);
+ site.SectorMap = placement.SectorMap;
+ site.PlanetId = placement.Planet.PlanetId;
+ site.Center = placement.Center;
+ site.Radius = placement.ReservationRadius;
- var genDest = AddComp(mapUid);
+ EnsureComp(gatewayUid);
+ _worldController.SetLoaderEnabled(gatewayUid, false);
+
+ var origin = placement.Center.Floored();
+
+ var genDest = AddComp(gatewayUid);
genDest.Origin = origin;
genDest.Seed = seed;
genDest.Generator = uid;
- // Create the gateway.
- var gatewayUid = SpawnAtPosition(generator.Proto, originCoords);
var gatewayComp = Comp(gatewayUid);
_gateway.SetDestinationName(gatewayUid, FormattedMessage.FromMarkupOrThrow($"[color=#D381C996]{gatewayName}[/color]"), gatewayComp);
_gateway.SetEnabled(gatewayUid, true, gatewayComp);
- generator.Generated.Add(mapUid);
+ generatorComp.Generated.Add(gatewayUid);
}
private void OnGeneratorAttemptOpen(Entity ent, ref AttemptGatewayOpenEvent args)
@@ -168,28 +186,49 @@ private void OnGeneratorOpen(Entity ent, r
GenerateDestination(ent.Comp.Generator);
}
- if (!TryComp(args.MapUid, out MapGridComponent? grid))
+ var xform = Transform(ent.Owner);
+ var gridUid = xform.GridUid ?? xform.MapUid;
+ if (gridUid is not { } resolvedGridUid || !TryComp(resolvedGridUid, out MapGridComponent? grid))
return;
+ if (TryComp(ent.Owner, out var siteComp))
+ {
+ PrepareDestinationSite(ent, (ent.Owner, siteComp), xform.MapUid ?? resolvedGridUid, resolvedGridUid, grid);
+ var loadRadius = (int) MathF.Ceiling((siteComp.ContentRadius > 0f ? siteComp.ContentRadius : siteComp.Radius) + WorldGen.ChunkSize);
+ _worldController.EnsureChunksLoaded(xform.MapUid ?? resolvedGridUid, siteComp.Center, loadRadius, ent.Owner);
+ }
+
ent.Comp.Locked = false;
ent.Comp.Loaded = true;
// Do dungeon
var seed = ent.Comp.Seed;
var origin = ent.Comp.Origin;
+ var dungeonPosition = origin;
+ _ = FinishGeneratorOpenAsync(ent, resolvedGridUid, grid, xform.MapUid ?? resolvedGridUid, dungeonPosition, seed, generatorComp);
+ }
+
+ private async Task FinishGeneratorOpenAsync(
+ Entity ent,
+ EntityUid gridUid,
+ MapGridComponent grid,
+ EntityUid hostMapUid,
+ Vector2i dungeonPosition,
+ int seed,
+ GatewayGeneratorComponent? generatorComp)
+ {
var random = new Random(seed);
- var dungeonDistance = random.Next(3, 6);
- var dungeonRotation = _dungeon.GetDungeonRotation(seed);
- var dungeonPosition = (origin + dungeonRotation.RotateVec(new Vector2i(0, dungeonDistance))).Floored();
- _dungeon.GenerateDungeon(_protoManager.Index(ExperimentDungeonId), "Experiment", args.MapUid, grid, dungeonPosition, seed); // Frontier: add "Experiment" arg
+ await _dungeon.GenerateDungeonAsync(_protoManager.Index(ExperimentDungeonId), "Experiment", gridUid, grid, dungeonPosition, seed);
+
+ if (TryComp(ent.Owner, out var siteComp))
+ _sectorWorld.CaptureHostedSiteGeneratedEntities((ent.Owner, siteComp), hostMapUid, siteComp.Center, siteComp.ContentRadius > 0f ? siteComp.ContentRadius : siteComp.Radius);
// TODO: Dungeon mobs + loot.
// Do markers on the map.
- if (TryComp(ent.Owner, out BiomeComponent? biomeComp) && generatorComp != null)
+ if (TryComp(gridUid, out BiomeComponent? biomeComp) && generatorComp != null)
{
- // - Loot
var lootLayers = generatorComp.LootLayers.ToList();
for (var i = 0; i < generatorComp.LootLayerCount; i++)
@@ -198,10 +237,9 @@ private void OnGeneratorOpen(Entity ent, r
var layer = lootLayers[layerIdx];
lootLayers.RemoveSwap(layerIdx);
- _biome.AddMarkerLayer(ent.Owner, biomeComp, layer.Id);
+ _biome.AddMarkerLayer(gridUid, biomeComp, layer.Id);
}
- // - Mobs
var mobLayers = generatorComp.MobLayers.ToList();
for (var i = 0; i < generatorComp.MobLayerCount; i++)
@@ -210,8 +248,47 @@ private void OnGeneratorOpen(Entity ent, r
var layer = mobLayers[layerIdx];
mobLayers.RemoveSwap(layerIdx);
- _biome.AddMarkerLayer(ent.Owner, biomeComp, layer.Id);
+ _biome.AddMarkerLayer(gridUid, biomeComp, layer.Id);
}
+
+ if (TryComp(ent.Owner, out siteComp))
+ _sectorWorld.CaptureHostedSiteGeneratedEntities((ent.Owner, siteComp), hostMapUid, siteComp.Center, siteComp.ContentRadius > 0f ? siteComp.ContentRadius : siteComp.Radius);
}
}
+
+ private void PrepareDestinationSite(
+ Entity ent,
+ Entity site,
+ EntityUid hostMapUid,
+ EntityUid hostGridUid,
+ MapGridComponent grid)
+ {
+ if (site.Comp.HostGridUid != EntityUid.Invalid)
+ return;
+
+ var captureRadius = site.Comp.Radius + 32f;
+ var loadRadius = (int) MathF.Ceiling(captureRadius + WorldGen.ChunkSize);
+ _worldController.SetLoaderRadius(ent.Owner, loadRadius);
+ _worldController.SetLoaderEnabled(ent.Owner, true);
+ _worldController.EnsureChunksLoaded(hostMapUid, site.Comp.Center, loadRadius, ent.Owner);
+ _sectorWorld.CaptureHostedSiteBaseline(site, hostGridUid, grid, site.Comp.Center, captureRadius);
+ StampLandingPad(hostGridUid, grid, ent.Comp.Origin, ent.Comp.Seed);
+ }
+
+ private void StampLandingPad(EntityUid hostGridUid, MapGridComponent grid, Vector2i origin, int seed)
+ {
+ var tileDef = _tileDefManager["FloorSteel"];
+ var random = new Random(seed);
+ var tiles = new List<(Vector2i Index, Tile Tile)>(25);
+
+ for (var x = -2; x <= 2; x++)
+ {
+ for (var y = -2; y <= 2; y++)
+ {
+ tiles.Add((new Vector2i(x, y) + origin, new Tile(tileDef.TileId, variant: _tile.PickVariant((ContentTileDefinition) tileDef, random))));
+ }
+ }
+
+ _maps.SetTiles(hostGridUid, grid, tiles);
+ }
}
diff --git a/Content.Server/Gateway/Systems/GatewaySystem.cs b/Content.Server/Gateway/Systems/GatewaySystem.cs
index dcc1de80fdc..6b8d6ef06d2 100644
--- a/Content.Server/Gateway/Systems/GatewaySystem.cs
+++ b/Content.Server/Gateway/Systems/GatewaySystem.cs
@@ -95,8 +95,11 @@ private void UpdateUserInterface(EntityUid uid, GatewayComponent comp, Transform
// - If our map is a generated destination then use the generator that made it
if (TryComp(_stations.GetOwningStation(uid), out GatewayGeneratorComponent? generatorComp) ||
- (TryComp(xform.MapUid, out GatewayGeneratorDestinationComponent? generatorDestination) &&
- TryComp(generatorDestination.Generator, out generatorComp)))
+ (xform.GridUid != null &&
+ TryComp(xform.GridUid.Value, out GatewayGeneratorDestinationComponent? generatorDestination) &&
+ TryComp(generatorDestination.Generator, out generatorComp)) ||
+ (TryComp(xform.MapUid, out GatewayGeneratorDestinationComponent? mapGeneratorDestination) &&
+ TryComp(mapGeneratorDestination.Generator, out generatorComp)))
{
nextUnlock = generatorComp.NextUnlock;
unlockTime = generatorComp.UnlockCooldown;
@@ -111,7 +114,11 @@ private void UpdateUserInterface(EntityUid uid, GatewayComponent comp, Transform
continue;
// Show destination if either no destination comp on the map or it's ours.
- TryComp(destXform.MapUid, out var gatewayDestination);
+ GatewayGeneratorDestinationComponent? gatewayDestination = CompOrNull(destUid);
+ if (destXform.GridUid != null)
+ gatewayDestination ??= CompOrNull(destXform.GridUid.Value);
+
+ gatewayDestination ??= CompOrNull(destXform.MapUid);
var isDockingArm = TryComp(destUid, out var dockingArmDestination);
if (isDockingArm)
@@ -197,6 +204,7 @@ private void OnOpenPortal(EntityUid uid, GatewayComponent comp, GatewayOpenPorta
// TODO: admin log???
ClosePortal(uid, comp, false);
+ ClosePortal(desto, dest, false);
OpenPortal(uid, comp, desto, dest);
}
@@ -270,13 +278,17 @@ private void OpenPortal(EntityUid uid, GatewayComponent comp, EntityUid dest, Ga
if (!Resolve(dest, ref destXform) || destXform.MapUid == null)
return;
+ var destinationTarget = destXform.GridUid ?? destXform.MapUid.Value;
var ev = new AttemptGatewayOpenEvent(destXform.MapUid.Value, dest);
- RaiseLocalEvent(destXform.MapUid.Value, ref ev);
+ RaiseLocalEvent(dest, ref ev);
+
+ if (!ev.Cancelled)
+ RaiseLocalEvent(destinationTarget, ref ev);
if (ev.Cancelled)
return;
- _linkedEntity.OneWayLink(uid, dest);
+ _linkedEntity.TryLink(uid, dest);
var sourcePortal = EnsureComp(uid);
var targetPortal = EnsureComp(dest);
@@ -288,7 +300,8 @@ private void OpenPortal(EntityUid uid, GatewayComponent comp, EntityUid dest, Ga
targetPortal.RandomTeleport = false;
var openEv = new GatewayOpenEvent(destXform.MapUid.Value, dest);
- RaiseLocalEvent(destXform.MapUid.Value, ref openEv);
+ RaiseLocalEvent(dest, ref openEv);
+ RaiseLocalEvent(destinationTarget, ref openEv);
// for ui
comp.NextReady = _timing.CurTime + comp.Cooldown;
diff --git a/Content.Server/GridSplit/OrphanedGridCleanupSystem.cs b/Content.Server/GridSplit/OrphanedGridCleanupSystem.cs
index 9768007a099..0ca82f794ba 100644
--- a/Content.Server/GridSplit/OrphanedGridCleanupSystem.cs
+++ b/Content.Server/GridSplit/OrphanedGridCleanupSystem.cs
@@ -1,6 +1,9 @@
using System.Linq;
using Content.Server.Power.Components;
using Content.Server.Procedural;
+using Content.Server._Mono.Cleanup;
+using Content.Server.Salvage.Expeditions;
+using Content.Server.Worldgen.Components;
using Content.Server.Station.Components;
using Content.Shared.CCVar;
using Content.Shared.Doors.Components;
@@ -163,6 +166,9 @@ private int CleanupEmptyGrids(TimeSpan curTime)
///
private bool ShouldCleanupEmptyGrid(EntityUid gridUid, MapGridComponent grid, MetaDataComponent meta)
{
+ if (ShouldPreserveGrid(gridUid))
+ return false;
+
// Count tiles
var tileCount = _mapSystem.GetAllTiles(gridUid, grid).Count();
@@ -227,6 +233,9 @@ private bool ShouldDeleteGrid(EntityUid gridUid)
if (!TryComp(gridUid, out var grid))
return false;
+ if (ShouldPreserveGrid(gridUid))
+ return false;
+
// Count total tiles by iterating through all tiles
var tileCount = _mapSystem.GetAllTiles(gridUid, grid).Count();
@@ -259,6 +268,9 @@ private bool HasImportantEntities(EntityUid gridUid)
while (children.MoveNext(out var child))
{
+ if (HasComp(child))
+ return true;
+
// Check for players
if (HasComp(child))
return true;
@@ -291,6 +303,23 @@ private bool HasImportantEntities(EntityUid gridUid)
return false;
}
+ private bool ShouldPreserveGrid(EntityUid gridUid)
+ {
+ if (HasComp(gridUid))
+ return true;
+
+ if (HasComp(gridUid))
+ return true;
+
+ if (HasComp(gridUid))
+ return true;
+
+ if (HasComp(gridUid))
+ return true;
+
+ return HasImportantEntities(gridUid);
+ }
+
///
/// Sets the minimum tile count threshold for grid cleanup.
diff --git a/Content.Server/Medical/SuitSensors/SuitSensorSystem.cs b/Content.Server/Medical/SuitSensors/SuitSensorSystem.cs
index 7a6d04ec06f..a6c21940092 100644
--- a/Content.Server/Medical/SuitSensors/SuitSensorSystem.cs
+++ b/Content.Server/Medical/SuitSensors/SuitSensorSystem.cs
@@ -26,6 +26,7 @@
using Robust.Shared.Timing;
using Content.Shared.DeviceNetwork.Components;
using Content.Server.Salvage.Expeditions; // Frontier
+using Content.Server.Salvage;
using Content.Server._NF.Medical.SuitSensors; // Frontier
using Content.Shared.Emp;
using Content.Shared.FloofStation;
@@ -50,6 +51,7 @@ public sealed class SuitSensorSystem : EntitySystem
[Dependency] private readonly ActionBlockerSystem _actionBlocker = default!;
[Dependency] private readonly IPrototypeManager _proto = default!;
[Dependency] private readonly InventorySystem _inventory = default!;
+ [Dependency] private readonly SalvageSystem _salvage = default!;
public override void Initialize()
{
@@ -475,7 +477,7 @@ public void SetAllSensors(EntityUid target, SuitSensorMode mode, SlotFlags slots
_transform.GetInvWorldMatrix(xformQuery.GetComponent(transform.GridUid.Value), xformQuery)));
// Frontier: check if sensor is on expedition
- if (TryComp(transform.MapUid, out var salvageComp))
+ if (_salvage.IsOnExpedition(uid, transform))
locationName = Loc.GetString("suit-sensor-location-expedition");
else if (TryComp(transform.GridUid, out MetaDataComponent? meta))
locationName = meta.EntityName;
diff --git a/Content.Server/Parallax/BiomeSystem.cs b/Content.Server/Parallax/BiomeSystem.cs
index 8dac0bbb758..71509f203f5 100644
--- a/Content.Server/Parallax/BiomeSystem.cs
+++ b/Content.Server/Parallax/BiomeSystem.cs
@@ -1,6 +1,7 @@
using System.Linq;
using System.Numerics;
using System.Threading.Tasks;
+using Content.Server._Mono.Cleanup;
using Content.Server.Atmos;
using Content.Server.Atmos.Components;
using Content.Server.Atmos.EntitySystems;
@@ -8,6 +9,8 @@
using Content.Server.Ghost.Roles.Components;
using Content.Server.Shuttles.Events;
using Content.Server.Shuttles.Systems;
+using Content.Server.Worldgen.Components;
+using Content.Server.Worldgen.Systems;
using Content.Shared.Atmos;
using Content.Shared.Decals;
using Content.Shared.Ghost;
@@ -49,6 +52,7 @@ public sealed partial class BiomeSystem : SharedBiomeSystem
[Dependency] private readonly DecalSystem _decals = default!;
[Dependency] private readonly SharedMapSystem _mapSystem = default!;
[Dependency] private readonly SharedPhysicsSystem _physics = default!;
+ [Dependency] private readonly SectorWorldSystem _sectorWorld = default!;
[Dependency] private readonly SharedTransformSystem _transform = default!;
[Dependency] private readonly ShuttleSystem _shuttles = default!;
[Dependency] private readonly TagSystem _tags = default!;
@@ -90,6 +94,7 @@ public override void Initialize()
_ghostQuery = GetEntityQuery();
_xformQuery = GetEntityQuery();
SubscribeLocalEvent(OnBiomeMapInit);
+ SubscribeLocalEvent(OnBiomeShutdown);
SubscribeLocalEvent(OnFTLStarted);
SubscribeLocalEvent(OnShuttleFlatten);
Subs.CVar(_configManager, CVars.NetMaxUpdateRange, SetLoadRange, true);
@@ -160,15 +165,34 @@ private void OnBiomeMapInit(EntityUid uid, BiomeComponent component, MapInitEven
}
}
+ private void OnBiomeShutdown(EntityUid uid, BiomeComponent component, ComponentShutdown args)
+ {
+ if (!TryComp(uid, out var grid) || component.LoadedChunks.Count == 0 || component.Seed == -1)
+ return;
+
+ FlushLoadedChunks(component, uid, grid, component.Seed);
+ }
+
public void SetEnabled(Entity ent, bool enabled = true)
{
if (!Resolve(ent, ref ent.Comp) || ent.Comp.Enabled == enabled)
return;
+ if (!enabled && ent.Comp.LoadedChunks.Count > 0 && ent.Comp.Seed != -1 && TryComp(ent.Owner, out var grid))
+ FlushLoadedChunks(ent.Comp, ent.Owner, grid, ent.Comp.Seed);
+
ent.Comp.Enabled = enabled;
Dirty(ent, ent.Comp);
}
+ public bool IsChunkLoaded(EntityUid uid, Vector2i chunkOrigin, BiomeComponent? component = null)
+ {
+ if (!Resolve(uid, ref component, false) || component == null)
+ return false;
+
+ return component.LoadedChunks.Contains(chunkOrigin);
+ }
+
public void SetSeed(EntityUid uid, BiomeComponent component, int seed, bool dirty = true)
{
component.Seed = seed;
@@ -382,6 +406,27 @@ public override void Update(float frameTime)
}
}
+ var loaderEnum = EntityQueryEnumerator();
+
+ while (loaderEnum.MoveNext(out _, out var loader, out var xform))
+ {
+ if (loader.Disabled ||
+ !_biomeQuery.TryGetComponent(xform.MapUid, out var biome) ||
+ !biome.Enabled)
+ {
+ continue;
+ }
+
+ var worldPos = _transform.GetWorldPosition(xform);
+ AddChunksInRange(biome, worldPos, loader.Radius);
+
+ foreach (var layer in biome.MarkerLayers)
+ {
+ var layerProto = ProtoManager.Index(layer);
+ AddMarkerChunksInRange(biome, worldPos, layerProto, loader.Radius);
+ }
+ }
+
var loadBiomes = AllEntityQuery();
while (loadBiomes.MoveNext(out var gridUid, out var biome, out var grid))
@@ -412,7 +457,14 @@ public override void Update(float frameTime)
private void AddChunksInRange(BiomeComponent biome, Vector2 worldPos)
{
- var enumerator = new ChunkIndicesEnumerator(_loadArea.Translated(worldPos), ChunkSize);
+ AddChunksInRange(biome, worldPos, _loadRange);
+ }
+
+ private void AddChunksInRange(BiomeComponent biome, Vector2 worldPos, float range)
+ {
+ var loadRange = MathF.Ceiling(range / ChunkSize) * ChunkSize;
+ var loadArea = new Box2(-loadRange, -loadRange, loadRange, loadRange);
+ var enumerator = new ChunkIndicesEnumerator(loadArea.Translated(worldPos), ChunkSize);
while (enumerator.MoveNext(out var chunkOrigin))
{
@@ -421,9 +473,15 @@ private void AddChunksInRange(BiomeComponent biome, Vector2 worldPos)
}
private void AddMarkerChunksInRange(BiomeComponent biome, Vector2 worldPos, IBiomeMarkerLayer layer)
+ {
+ AddMarkerChunksInRange(biome, worldPos, layer, _loadRange);
+ }
+
+ private void AddMarkerChunksInRange(BiomeComponent biome, Vector2 worldPos, IBiomeMarkerLayer layer, float range)
{
// Offset the load area so it's centralised.
- var loadArea = new Box2(0, 0, layer.Size, layer.Size);
+ var loadRange = MathF.Ceiling(range / ChunkSize) * ChunkSize;
+ var loadArea = new Box2(-loadRange, -loadRange, loadRange, loadRange);
var halfLayer = new Vector2(layer.Size / 2f);
var enumerator = new ChunkIndicesEnumerator(loadArea.Translated(worldPos - halfLayer), layer.Size);
@@ -865,6 +923,8 @@ private void LoadChunk(
}
}
+ _sectorWorld.RestoreHostedChunkContent(gridUid, grid, chunk, ChunkSize);
+
if (modified.Count == 0)
{
_tilePool.Return(modified);
@@ -899,6 +959,20 @@ private void UnloadChunks(BiomeComponent component, EntityUid gridUid, MapGridCo
}
}
+ private void FlushLoadedChunks(BiomeComponent component, EntityUid gridUid, MapGridComponent grid, int seed)
+ {
+ if (component.LoadedChunks.Count == 0)
+ return;
+
+ var tiles = new List<(Vector2i, Tile)>(ChunkSize * ChunkSize);
+ var chunks = component.LoadedChunks.ToArray();
+
+ foreach (var chunk in chunks.Where(component.LoadedChunks.Contains))
+ {
+ UnloadChunk(component, gridUid, grid, chunk, seed, tiles);
+ }
+ }
+
///
/// Unloads a specific biome chunk.
///
@@ -908,6 +982,8 @@ private void UnloadChunk(BiomeComponent component, EntityUid gridUid, MapGridCom
component.ModifiedTiles.TryGetValue(chunk, out var modified);
modified ??= new HashSet();
+ _sectorWorld.SaveHostedChunkContent(gridUid, grid, chunk, ChunkSize);
+
// Delete decals
foreach (var (dec, indices) in component.LoadedDecals[chunk])
{
@@ -1010,7 +1086,9 @@ public void EnsurePlanet(EntityUid mapUid, BiomeTemplatePrototype biomeTemplate,
if (!Resolve(mapUid, ref metadata))
return;
- EnsureComp(mapUid);
+ var mapGrid = EnsureComp(mapUid);
+ mapGrid.CanSplit = false;
+ EnsureComp(mapUid);
var biome = (BiomeComponent) EntityManager.ComponentFactory.GetComponent(typeof(BiomeComponent));
seed ??= _random.Next();
SetSeed(mapUid, biome, seed.Value, false);
diff --git a/Content.Server/Polymorph/Systems/PolymorphSystem.cs b/Content.Server/Polymorph/Systems/PolymorphSystem.cs
index e57914db67d..1c627d5526a 100644
--- a/Content.Server/Polymorph/Systems/PolymorphSystem.cs
+++ b/Content.Server/Polymorph/Systems/PolymorphSystem.cs
@@ -20,7 +20,6 @@
using Robust.Server.Audio;
using Robust.Server.Containers;
using Robust.Server.GameObjects;
-using Robust.Shared.Map;
using Robust.Shared.Prototypes;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
diff --git a/Content.Server/Procedural/DungeonAtlasTemplateDespawnSystem.cs b/Content.Server/Procedural/DungeonAtlasTemplateDespawnSystem.cs
index 95e21620498..76bc84de067 100644
--- a/Content.Server/Procedural/DungeonAtlasTemplateDespawnSystem.cs
+++ b/Content.Server/Procedural/DungeonAtlasTemplateDespawnSystem.cs
@@ -1,24 +1,14 @@
using Robust.Shared.GameObjects;
-using TimedDespawnComponent = Robust.Shared.Spawners.TimedDespawnComponent;
namespace Content.Server.Procedural;
///
-/// Ensures dungeon atlas template entities despawn after a fixed delay.
+/// Dungeon atlas template entities now persist until explicitly cleaned up.
///
public sealed class DungeonAtlasTemplateDespawnSystem : EntitySystem
{
- private static readonly TimeSpan DespawnDelay = TimeSpan.FromMinutes(30);
-
public override void Initialize()
{
base.Initialize();
- SubscribeLocalEvent(OnInit);
- }
-
- private void OnInit(Entity ent, ref ComponentInit args)
- {
- var despawn = EnsureComp(ent);
- despawn.Lifetime = (float) DespawnDelay.TotalSeconds;
}
}
diff --git a/Content.Server/Procedural/DungeonJob/DungeonJob.PostGen.cs b/Content.Server/Procedural/DungeonJob/DungeonJob.PostGen.cs
index 84e7563f338..3a251395c5f 100644
--- a/Content.Server/Procedural/DungeonJob/DungeonJob.PostGen.cs
+++ b/Content.Server/Procedural/DungeonJob/DungeonJob.PostGen.cs
@@ -1,7 +1,9 @@
+using System.Linq;
using System.Numerics;
using Content.Shared.Procedural;
using Content.Shared.Tag;
using Robust.Shared.Collections;
+using Robust.Shared.Map;
using Robust.Shared.Map.Components;
using Robust.Shared.Physics.Components;
using Robust.Shared.Prototypes;
@@ -16,6 +18,67 @@ public sealed partial class DungeonJob
private static readonly ProtoId WallTag = "Wall";
+ private HashSet GetTileEntities(Vector2i tile)
+ {
+ var tileRef = _maps.GetTileRef(_gridUid, _grid, tile);
+ var entities = new HashSet();
+
+ foreach (var entity in _lookup.GetLocalEntitiesIntersecting(tileRef, flags: LookupFlags.Dynamic | LookupFlags.Static | LookupFlags.StaticSundries | LookupFlags.Sundries | LookupFlags.Approximate))
+ {
+ entities.Add(entity);
+ }
+
+ return entities;
+ }
+
+ private void SpawnAnchoredStructureCollection(Vector2i tile, List prototypes)
+ {
+ var before = GetTileEntities(tile);
+ _entManager.SpawnEntities(_maps.GridTileToLocal(_gridUid, _grid, tile), prototypes);
+ AnchorNewStructuralTileEntities(tile, before);
+ }
+
+ private void SpawnAnchoredStructure(Vector2i tile, string prototype)
+ {
+ var entity = _entManager.SpawnEntity(prototype, _maps.GridTileToLocal(_gridUid, _grid, tile));
+
+ if (ShouldAnchorDungeonStructure(entity))
+ {
+ var xform = _xformQuery.Comp(entity);
+ if (!xform.Anchored)
+ _transform.AnchorEntity((entity, xform), (_gridUid, _grid), tile);
+ }
+ }
+
+ private void AnchorNewStructuralTileEntities(Vector2i tile, HashSet before)
+ {
+ var after = GetTileEntities(tile);
+
+ foreach (var entity in after.Where(entity => !before.Contains(entity) && ShouldAnchorDungeonStructure(entity)))
+ {
+ var xform = _xformQuery.Comp(entity);
+ if (!xform.Anchored)
+ _transform.AnchorEntity((entity, xform), (_gridUid, _grid), tile);
+ }
+ }
+
+ private bool ShouldAnchorDungeonStructure(EntityUid entity)
+ {
+ if (!_entManager.TryGetComponent(entity, out var meta) || meta.EntityPrototype == null)
+ return false;
+
+ var prototypeId = meta.EntityPrototype.ID;
+ return prototypeId.Contains("Wall", StringComparison.OrdinalIgnoreCase)
+ || prototypeId.Contains("Window", StringComparison.OrdinalIgnoreCase)
+ || prototypeId.Contains("Windoor", StringComparison.OrdinalIgnoreCase)
+ || prototypeId.Contains("Door", StringComparison.OrdinalIgnoreCase)
+ || prototypeId.Contains("Airlock", StringComparison.OrdinalIgnoreCase)
+ || prototypeId.Contains("Grille", StringComparison.OrdinalIgnoreCase)
+ || (prototypeId.Contains("Cable", StringComparison.OrdinalIgnoreCase)
+ && !prototypeId.Contains("Stack", StringComparison.OrdinalIgnoreCase)
+ && !prototypeId.Contains("Placer", StringComparison.OrdinalIgnoreCase));
+ }
+
private bool HasWall(Vector2i tile)
{
var anchored = _maps.GetAnchoredEntitiesEnumerator(_gridUid, _grid, tile);
diff --git a/Content.Server/Procedural/DungeonJob/DungeonJob.PostGenAutoCabling.cs b/Content.Server/Procedural/DungeonJob/DungeonJob.PostGenAutoCabling.cs
index 090e5ee8cc0..0b4d96949e2 100644
--- a/Content.Server/Procedural/DungeonJob/DungeonJob.PostGenAutoCabling.cs
+++ b/Content.Server/Procedural/DungeonJob/DungeonJob.PostGenAutoCabling.cs
@@ -160,7 +160,7 @@ private async Task PostGen(AutoCablingDunGen gen, DungeonData data, Dungeon dung
if (found)
continue;
- _entManager.SpawnEntity(ent, _maps.GridTileToLocal(_gridUid, _grid, tile));
+ SpawnAnchoredStructure(tile, ent);
}
}
}
diff --git a/Content.Server/Procedural/DungeonJob/DungeonJob.PostGenBoundaryWall.cs b/Content.Server/Procedural/DungeonJob/DungeonJob.PostGenBoundaryWall.cs
index 84697a56bc7..ad61735030b 100644
--- a/Content.Server/Procedural/DungeonJob/DungeonJob.PostGenBoundaryWall.cs
+++ b/Content.Server/Procedural/DungeonJob/DungeonJob.PostGenBoundaryWall.cs
@@ -96,10 +96,10 @@ private async Task PostGen(BoundaryWallDunGen gen, DungeonData data, Dungeon dun
}
if (isCorner)
- _entManager.SpawnEntity(cornerWall, _maps.GridTileToLocal(_gridUid, _grid, index.Index));
+ SpawnAnchoredStructure(index.Index, cornerWall);
if (!isCorner)
- _entManager.SpawnEntity(wall, _maps.GridTileToLocal(_gridUid, _grid, index.Index));
+ SpawnAnchoredStructure(index.Index, wall);
if (i % 20 == 0)
{
diff --git a/Content.Server/Procedural/DungeonJob/DungeonJob.PostGenEntranceFlank.cs b/Content.Server/Procedural/DungeonJob/DungeonJob.PostGenEntranceFlank.cs
index 3a1c7a37793..9497e6011da 100644
--- a/Content.Server/Procedural/DungeonJob/DungeonJob.PostGenEntranceFlank.cs
+++ b/Content.Server/Procedural/DungeonJob/DungeonJob.PostGenEntranceFlank.cs
@@ -52,7 +52,7 @@ private async Task PostGen(EntranceFlankDunGen gen, DungeonData data, Dungeon du
foreach (var entrance in spawnPositions)
{
- _entManager.SpawnEntities(_maps.GridTileToLocal(_gridUid, _grid, entrance), EntitySpawnCollection.GetSpawns(entGroup.Entries, random));
+ SpawnAnchoredStructureCollection(entrance, EntitySpawnCollection.GetSpawns(entGroup.Entries, random));
}
}
}
diff --git a/Content.Server/Procedural/DungeonJob/DungeonJob.PostGenExternalWindow.cs b/Content.Server/Procedural/DungeonJob/DungeonJob.PostGenExternalWindow.cs
index 9a1b44ec91b..09ca0953b36 100644
--- a/Content.Server/Procedural/DungeonJob/DungeonJob.PostGenExternalWindow.cs
+++ b/Content.Server/Procedural/DungeonJob/DungeonJob.PostGenExternalWindow.cs
@@ -125,10 +125,8 @@ private async Task PostGen(ExternalWindowDunGen gen, DungeonData data, Dungeon d
foreach (var tile in tiles)
{
- var gridPos = _maps.GridTileToLocal(_gridUid, _grid, tile.Item1);
-
index += spawnEntry.Entries.Count;
- _entManager.SpawnEntities(gridPos, EntitySpawnCollection.GetSpawns(spawnEntry.Entries, random));
+ SpawnAnchoredStructureCollection(tile.Item1, EntitySpawnCollection.GetSpawns(spawnEntry.Entries, random));
await SuspendDungeon();
if (!ValidateResume())
diff --git a/Content.Server/Procedural/DungeonJob/DungeonJob.PostGenInternalWindow.cs b/Content.Server/Procedural/DungeonJob/DungeonJob.PostGenInternalWindow.cs
index d3b8c6d2f5d..0b0e10e9665 100644
--- a/Content.Server/Procedural/DungeonJob/DungeonJob.PostGenInternalWindow.cs
+++ b/Content.Server/Procedural/DungeonJob/DungeonJob.PostGenInternalWindow.cs
@@ -87,10 +87,9 @@ private async Task PostGen(InternalWindowDunGen gen, DungeonData data, Dungeon d
for (var j = 0; j < Math.Min(validTiles.Count, 3); j++)
{
var tile = validTiles[j];
- var gridPos = _maps.GridTileToLocal(_gridUid, _grid, tile);
_maps.SetTile(_gridUid, _grid, tile, _tile.GetVariantTile((ContentTileDefinition) tileDef, random));
- _entManager.SpawnEntities(gridPos, EntitySpawnCollection.GetSpawns(window.Entries, random));
+ SpawnAnchoredStructureCollection(tile, EntitySpawnCollection.GetSpawns(window.Entries, random));
}
if (validTiles.Count > 0)
diff --git a/Content.Server/Procedural/DungeonJob/DungeonJob.PostGenJunction.cs b/Content.Server/Procedural/DungeonJob/DungeonJob.PostGenJunction.cs
index 700406eb894..2acd88f4caf 100644
--- a/Content.Server/Procedural/DungeonJob/DungeonJob.PostGenJunction.cs
+++ b/Content.Server/Procedural/DungeonJob/DungeonJob.PostGenJunction.cs
@@ -122,8 +122,7 @@ private async Task PostGen(JunctionDunGen gen, DungeonData data, Dungeon dungeon
_maps.SetTile(_gridUid, _grid, weh, _tile.GetVariantTile((ContentTileDefinition) tileDef, random));
- var coords = _maps.GridTileToLocal(_gridUid, _grid, weh);
- _entManager.SpawnEntities(coords, EntitySpawnCollection.GetSpawns(entranceGroup.Entries, random));
+ SpawnAnchoredStructureCollection(weh, EntitySpawnCollection.GetSpawns(entranceGroup.Entries, random));
}
break;
diff --git a/Content.Server/Procedural/DungeonJob/DungeonJob.PostGenMiddleConnection.cs b/Content.Server/Procedural/DungeonJob/DungeonJob.PostGenMiddleConnection.cs
index 15d0f634232..ceff1bbb98a 100644
--- a/Content.Server/Procedural/DungeonJob/DungeonJob.PostGenMiddleConnection.cs
+++ b/Content.Server/Procedural/DungeonJob/DungeonJob.PostGenMiddleConnection.cs
@@ -120,14 +120,14 @@ private async Task PostGen(MiddleConnectionDunGen gen, DungeonData data, Dungeon
if (flank != null && nodeDistances.Count - i <= 2)
{
- _entManager.SpawnEntities(gridPos, EntitySpawnCollection.GetSpawns(flank.Entries, random));
+ SpawnAnchoredStructureCollection(node, EntitySpawnCollection.GetSpawns(flank.Entries, random));
}
else
{
// Iterate neighbors and check for blockers, if so bulldoze
ClearDoor(dungeon, _grid, node);
- _entManager.SpawnEntities(gridPos, EntitySpawnCollection.GetSpawns(entrance.Entries, random));
+ SpawnAnchoredStructureCollection(node, EntitySpawnCollection.GetSpawns(entrance.Entries, random));
}
if (width == 0)
diff --git a/Content.Server/Procedural/DungeonJob/DungeonJob.PostGenRoomEntrance.cs b/Content.Server/Procedural/DungeonJob/DungeonJob.PostGenRoomEntrance.cs
index 09d223e86cf..4c02df62d67 100644
--- a/Content.Server/Procedural/DungeonJob/DungeonJob.PostGenRoomEntrance.cs
+++ b/Content.Server/Procedural/DungeonJob/DungeonJob.PostGenRoomEntrance.cs
@@ -39,9 +39,7 @@ private async Task PostGen(RoomEntranceDunGen gen, DungeonData data, Dungeon dun
{
foreach (var entrance in room.Entrances)
{
- _entManager.SpawnEntities(
- _maps.GridTileToLocal(_gridUid, _grid, entrance),
- EntitySpawnCollection.GetSpawns(entranceIn.Entries, random));
+ SpawnAnchoredStructureCollection(entrance, EntitySpawnCollection.GetSpawns(entranceIn.Entries, random));
}
}
}
diff --git a/Content.Server/Salvage/Expeditions/SalvageExpeditionComponent.cs b/Content.Server/Salvage/Expeditions/SalvageExpeditionComponent.cs
index 24ab38b74db..c6412a30830 100644
--- a/Content.Server/Salvage/Expeditions/SalvageExpeditionComponent.cs
+++ b/Content.Server/Salvage/Expeditions/SalvageExpeditionComponent.cs
@@ -1,7 +1,10 @@
+using System.Collections.Generic;
using System.Numerics;
using Content.Shared.Salvage;
using Content.Shared.Salvage.Expeditions;
using Robust.Shared.Audio;
+using Robust.Shared.Maths;
+using Robust.Shared.Map;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.List;
@@ -27,7 +30,7 @@ public sealed partial class SalvageExpeditionComponent : SharedSalvageExpedition
///
[ViewVariables(VVAccess.ReadWrite), DataField("endTime", customTypeSerializer: typeof(TimeOffsetSerializer))]
[AutoPausedField]
- public TimeSpan EndTime;
+ public TimeSpan? EndTime;
///
/// Station whose mission this is.
@@ -50,6 +53,24 @@ public sealed partial class SalvageExpeditionComponent : SharedSalvageExpedition
[ViewVariables]
public bool ReturnTriggered = false;
+ ///
+ /// Persistent grid that physically hosts the expedition content.
+ ///
+ [ViewVariables]
+ public EntityUid HostGridUid = EntityUid.Invalid;
+
+ ///
+ /// Runtime-only entity snapshot for content stamped onto a persistent host grid.
+ ///
+ [ViewVariables]
+ public HashSet GeneratedEntities = new();
+
+ ///
+ /// Runtime-only tile snapshot used to restore the host grid when the expedition is removed.
+ ///
+ [ViewVariables]
+ public Dictionary OriginalTiles = new();
+
// Frontier: moved to Client
///
/// Countdown audio stream.
diff --git a/Content.Server/Salvage/SalvageSystem.ExpeditionConsole.cs b/Content.Server/Salvage/SalvageSystem.ExpeditionConsole.cs
index 2eb32a0d5f3..c822376b035 100644
--- a/Content.Server/Salvage/SalvageSystem.ExpeditionConsole.cs
+++ b/Content.Server/Salvage/SalvageSystem.ExpeditionConsole.cs
@@ -1,6 +1,7 @@
using Content.Shared.Shuttles.Components;
using Content.Shared.Procedural;
using Content.Shared.Salvage.Expeditions;
+using Content.Shared.Salvage.Expeditions.Modifiers;
using Content.Shared.Dataset;
using Robust.Shared.Prototypes;
using Content.Shared.Popups; // Frontier
@@ -166,8 +167,23 @@ private void OnSalvageClaimMessage(EntityUid uid, SalvageExpeditionConsoleCompon
{
var filter = Filter.Empty().AddInGrid(consoleXform.GridUid.Value);
var announcement = Loc.GetString("salvage-expedition-announcement-claimed");
+ var biomeProto = _prototypeManager.Index(mission.Biome);
+ var biome = string.IsNullOrWhiteSpace(Loc.GetString(biomeProto.Description))
+ ? Loc.GetString(biomeProto.ID)
+ : Loc.GetString(biomeProto.Description);
+ var objective = Loc.GetString($"salvage-expedition-type-{missionparams.MissionType}");
+ var difficulty = Loc.GetString($"salvage-expedition-difficulty-{missionparams.Difficulty}");
_chatSystem.DispatchFilteredAnnouncement(filter, announcement, uid,
sender: "Expedition Console", colorOverride: Color.LightBlue);
+ _chatSystem.DispatchFilteredAnnouncement(
+ filter,
+ Loc.GetString("salvage-expedition-announcement-briefing",
+ ("objective", objective),
+ ("difficulty", difficulty),
+ ("biome", biome)),
+ uid,
+ sender: "Expedition Console",
+ colorOverride: Color.LightBlue);
}
Log.Info($"Mission {args.Index} successfully claimed on independent console {ToPrettyString(uid)}");
@@ -257,11 +273,10 @@ private void OnSalvageFinishMessage(EntityUid entity, SalvageExpeditionConsoleCo
///
public bool TryEndExpeditionEarlyFromConsole(EntityUid consoleUid)
{
- if (!TryComp(consoleUid, out TransformComponent? xform) || xform.MapUid == null)
+ if (!TryComp(consoleUid, out TransformComponent? xform))
return false;
- var expeditionMapUid = xform.MapUid.Value;
- if (!TryComp(expeditionMapUid, out SalvageExpeditionComponent? expedition))
+ if (!TryGetExpeditionForEntity(consoleUid, out var expeditionMapUid, out SalvageExpeditionComponent? expedition, xform))
return false;
// HardLight: Return has already been queued; treat this as handled to avoid duplicate countdown timers.
@@ -318,7 +333,7 @@ private void TriggerExpeditionFTLHome(EntityUid expeditionMapUid, SalvageExpedit
// Find shuttles on the expedition map and FTL them home
while (shuttleQuery.MoveNext(out var shuttleUid, out var shuttle, out var shuttleXform, out _))
{
- if (shuttleXform.MapUid != expeditionMapUid || TryComp(shuttleUid, out FTLComponent? _))
+ if (!IsEntityOnExpedition(shuttleUid, expeditionMapUid, shuttleXform) || TryComp(shuttleUid, out FTLComponent? _))
continue;
var dropLocation = PickExpeditionReturnDropLocation(existingPositions); // HardLight
@@ -434,14 +449,17 @@ private void SpawnMissionForConsole(SalvageMissionParams missionParams, EntityUi
EntityManager,
_timing,
_logManager,
+ _mapManager,
_prototypeManager,
_anchorable,
+ _audio,
_biome,
_dungeon,
_metaData,
_mapSystem,
_station,
_shuttle,
+ _sectorWorld,
this,
missionStation,
consoleUid, // HARDLIGHT: Pass console reference for FTL targeting
diff --git a/Content.Server/Salvage/SalvageSystem.Expeditions.cs b/Content.Server/Salvage/SalvageSystem.Expeditions.cs
index f9d1476762e..2d182cd44a2 100644
--- a/Content.Server/Salvage/SalvageSystem.Expeditions.cs
+++ b/Content.Server/Salvage/SalvageSystem.Expeditions.cs
@@ -2,10 +2,12 @@
using System.Threading;
using Content.Server.Salvage.Expeditions;
using Content.Server.Salvage.Expeditions.Structure;
+using Content.Server.Worldgen.Components;
using Content.Shared.CCVar;
using Content.Shared.Examine;
using Content.Shared.Random.Helpers;
using Content.Shared.Salvage.Expeditions;
+using Content.Shared.Salvage.Expeditions.Modifiers;
using Robust.Shared.Audio;
using Robust.Shared.CPUJob.JobQueues;
using Robust.Shared.CPUJob.JobQueues.Queues;
@@ -22,7 +24,6 @@
using Robust.Shared.Configuration;
using Content.Shared.Ghost;
using System.Numerics; // Frontier
-using TimedDespawnComponent = Robust.Shared.Spawners.TimedDespawnComponent;
namespace Content.Server.Salvage;
@@ -129,9 +130,6 @@ private void SetProximityCheck(bool obj)
private void OnExpeditionMapInit(EntityUid uid, SalvageExpeditionComponent component, MapInitEvent args)
{
component.SelectedSong = _audio.ResolveSound(component.Sound);
-
- var despawn = EnsureComp(uid);
- despawn.Lifetime = (float) TimeSpan.FromMinutes(30).TotalSeconds;
}
private void OnExpeditionShutdown(EntityUid uid, SalvageExpeditionComponent component, ComponentShutdown args)
@@ -147,6 +145,11 @@ private void OnExpeditionShutdown(EntityUid uid, SalvageExpeditionComponent comp
}
}
+ if (TryComp(uid, out var site))
+ _sectorWorld.CleanupHostedSite(uid, site);
+
+ CleanupHostedExpeditionContent(component);
+
// HARDLIGHT: Handle round persistence - station might be deleted during round transitions
if (Deleted(component.Station))
{
@@ -169,6 +172,34 @@ private void OnExpeditionShutdown(EntityUid uid, SalvageExpeditionComponent comp
}
}
+ private void CleanupHostedExpeditionContent(SalvageExpeditionComponent component)
+ {
+ foreach (var generated in component.GeneratedEntities.Where(generated => Exists(generated)))
+ {
+ QueueDel(generated);
+ }
+
+ component.GeneratedEntities.Clear();
+
+ if (component.HostGridUid == EntityUid.Invalid || component.OriginalTiles.Count == 0)
+ return;
+
+ if (!TryComp(component.HostGridUid, out var grid))
+ {
+ component.OriginalTiles.Clear();
+ return;
+ }
+
+ var tiles = new List<(Vector2i, Tile)>(component.OriginalTiles.Count);
+ foreach (var (indices, tile) in component.OriginalTiles)
+ {
+ tiles.Add((indices, tile));
+ }
+
+ _mapSystem.SetTiles(component.HostGridUid, grid, tiles);
+ component.OriginalTiles.Clear();
+ }
+
private void UpdateExpeditions()
{
var currentTime = _timing.CurTime;
@@ -370,14 +401,8 @@ private void GenerateMissions(SalvageExpeditionDataComponent component)
var missionIndex = 0;
for (var i = 0; i < MissionLimit; i++)
{
- var mission = new SalvageMissionParams
- {
- Index = (ushort)missionIndex,
- // Pick a valid mission type; Max is a sentinel and must be excluded.
- MissionType = (SalvageMissionType)_random.NextByte((byte)SalvageMissionType.Max),
- Seed = _random.Next(),
- Difficulty = difficulties[i].id,
- };
+ if (!TryGenerateSectorMission(difficulties[i].id, (ushort) missionIndex, out var mission))
+ continue;
component.Missions[(ushort)missionIndex] = mission;
missionIndex++;
@@ -396,6 +421,43 @@ private void GenerateMissions(SalvageExpeditionDataComponent component)
}
}
+ private bool TryGenerateSectorMission(string difficultyId, ushort missionIndex, out SalvageMissionParams mission)
+ {
+ const int maxAttempts = 32;
+ mission = default!;
+
+ if (!_prototypeManager.TryIndex(difficultyId, out var difficultyProto))
+ return false;
+
+ for (var attempt = 0; attempt < maxAttempts; attempt++)
+ {
+ var seed = _random.Next();
+ var missionType = (SalvageMissionType) _random.NextByte((byte) SalvageMissionType.Max);
+ var generatedMission = GetMission(missionType, difficultyProto, seed);
+ var biomeProto = _prototypeManager.Index(generatedMission.Biome);
+
+ if (!_sectorWorld.TryResolvePlanetTypeForBiome(biomeProto.BiomePrototype, out var planetTypeId) ||
+ string.IsNullOrWhiteSpace(planetTypeId) ||
+ !_sectorWorld.TryGetPersistentMap(planetTypeId, out _, out _))
+ {
+ continue;
+ }
+
+ mission = new SalvageMissionParams
+ {
+ Index = missionIndex,
+ Seed = seed,
+ Difficulty = difficultyId,
+ MissionType = missionType,
+ };
+
+ return true;
+ }
+
+ Log.Warning($"Failed to generate sector-valid salvage mission for difficulty {difficultyId} after {maxAttempts} attempts.");
+ return false;
+ }
+
// HARDLIGHT: Public method for round persistence system to properly regenerate missions
public void ForceGenerateMissions(SalvageExpeditionDataComponent component)
{
@@ -416,14 +478,17 @@ private void SpawnMission(SalvageMissionParams missionParams, EntityUid station,
EntityManager,
_timing,
_logManager,
+ _mapManager,
_prototypeManager,
_anchorable,
+ _audio,
_biome,
_dungeon,
_metaData,
_mapSystem,
_station, // Frontier
_shuttle, // Frontier
+ _sectorWorld,
this, // Frontier
station,
console,
@@ -500,7 +565,7 @@ private void OnMapTerminating(EntityUid uid, SalvageExpeditionComponent componen
var newCoords = new MapCoordinates(Vector2.Zero, _gameTicker.DefaultMap);
while (ghosts.MoveNext(out var ghostUid, out _, out var xform))
{
- if (xform.MapUid == uid)
+ if (IsEntityOnExpedition(ghostUid, uid, xform))
_transform.SetMapCoordinates(ghostUid, newCoords);
}
}
diff --git a/Content.Server/Salvage/SalvageSystem.Runner.cs b/Content.Server/Salvage/SalvageSystem.Runner.cs
index 9bf41a29f03..12089a25390 100644
--- a/Content.Server/Salvage/SalvageSystem.Runner.cs
+++ b/Content.Server/Salvage/SalvageSystem.Runner.cs
@@ -16,6 +16,8 @@
using Content.Server.GameTicking; // Frontier
using Content.Server._NF.Salvage.Expeditions.Structure; // Frontier
using Content.Server._NF.Salvage.Expeditions;
+using Content.Shared.Ghost;
+using Content.Shared.Mind.Components;
using Content.Shared.Salvage; // Frontier
using RobustTimer = Robust.Shared.Timing.Timer; // HardLight
@@ -46,7 +48,7 @@ private void InitializeRunner()
private void OnConsoleFTLAttempt(ref ConsoleFTLAttemptEvent ev)
{
if (!TryComp(ev.Uid, out TransformComponent? xform) ||
- !TryComp(xform.MapUid, out var salvage))
+ !TryGetExpeditionForEntity(ev.Uid, out var expeditionUid, out _, xform))
{
return;
}
@@ -56,7 +58,7 @@ private void OnConsoleFTLAttempt(ref ConsoleFTLAttemptEvent ev)
while (query.MoveNext(out var uid, out _, out var mobState, out var mobXform))
{
- if (mobXform.MapUid != xform.MapUid)
+ if (!IsEntityOnExpedition(uid, expeditionUid, mobXform))
continue;
// Don't count unidentified humans (loot) or anyone you murdered so you can still maroon them once dead.
@@ -83,8 +85,14 @@ private void Announce(EntityUid mapUid, string text)
// gone" and "MapId no longer registered" are normal during cleanup, so log at Debug.
if (!TryComp(mapUid, out var map))
{
- Log.Debug($"Skipping salvage announcement for {ToPrettyString(mapUid)} because the map component is no longer available.");
- return;
+ var xform = Transform(mapUid);
+ if (xform.MapUid is not { } parentMap || !TryComp(parentMap, out map))
+ {
+ Log.Debug($"Skipping salvage announcement for {ToPrettyString(mapUid)} because the map component is no longer available.");
+ return;
+ }
+
+ mapUid = parentMap;
}
var mapId = map.MapId;
@@ -127,7 +135,7 @@ private void OnFTLRequest(ref FTLRequestEvent ev)
private void OnFTLCompleted(ref FTLCompletedEvent args)
{
- if (!TryComp(args.MapUid, out var component))
+ if (!TryGetExpeditionForEntity(args.Entity, out var expeditionUid, out var component))
return;
EnsureComp(args.Entity);
@@ -149,21 +157,22 @@ private void OnFTLCompleted(ref FTLCompletedEvent args)
}
else
{
- Log.Warning($"FTL completed but no valid console reference found for expedition on {args.MapUid}");
+ Log.Warning($"FTL completed but no valid console reference found for expedition on {expeditionUid}");
}
- Announce(args.MapUid, Loc.GetString("salvage-expedition-announcement-countdown-minutes", ("duration", (component.EndTime - _timing.CurTime).Minutes)));
+ if (component.EndTime is { } endTime)
+ Announce(expeditionUid, Loc.GetString("salvage-expedition-announcement-countdown-minutes", ("duration", (endTime - _timing.CurTime).Minutes)));
var directionLocalization = ContentLocalizationManager.FormatDirection(component.DungeonLocation.GetDir()).ToLower();
if (component.DungeonLocation != Vector2.Zero)
- Announce(args.MapUid, Loc.GetString("salvage-expedition-announcement-dungeon", ("direction", directionLocalization)));
+ Announce(expeditionUid, Loc.GetString("salvage-expedition-announcement-dungeon", ("direction", directionLocalization)));
// Frontier: type-specific announcement
switch (component.MissionParams.MissionType)
{
case SalvageMissionType.Destruction:
- if (TryComp(args.MapUid, out var destruction)
+ if (TryComp(expeditionUid, out var destruction)
&& destruction.Structures.Count > 0
&& TryComp(destruction.Structures[0], out MetaDataComponent? structureMeta)
&& structureMeta.EntityPrototype != null)
@@ -172,11 +181,11 @@ private void OnFTLCompleted(ref FTLCompletedEvent args)
if (string.IsNullOrWhiteSpace(name))
name = Loc.GetString("salvage-expedition-announcement-destruction-entity-fallback");
// Assuming all structures are of the same type.
- Announce(args.MapUid, Loc.GetString("salvage-expedition-announcement-destruction", ("structure", name), ("count", destruction.Structures.Count)));
+ Announce(expeditionUid, Loc.GetString("salvage-expedition-announcement-destruction", ("structure", name), ("count", destruction.Structures.Count)));
}
break;
case SalvageMissionType.Elimination:
- if (TryComp(args.MapUid, out var elimination)
+ if (TryComp(expeditionUid, out var elimination)
&& elimination.Megafauna.Count > 0
&& TryComp(elimination.Megafauna[0], out MetaDataComponent? targetMeta)
&& targetMeta.EntityPrototype != null)
@@ -185,7 +194,7 @@ private void OnFTLCompleted(ref FTLCompletedEvent args)
if (string.IsNullOrWhiteSpace(name))
name = Loc.GetString("salvage-expedition-announcement-elimination-entity-fallback");
// Assuming all megafauna are of the same type.
- Announce(args.MapUid, Loc.GetString("salvage-expedition-announcement-elimination", ("target", name), ("count", elimination.Megafauna.Count)));
+ Announce(expeditionUid, Loc.GetString("salvage-expedition-announcement-elimination", ("target", name), ("count", elimination.Megafauna.Count)));
}
break;
default:
@@ -194,12 +203,12 @@ private void OnFTLCompleted(ref FTLCompletedEvent args)
// End Frontier
component.Stage = ExpeditionStage.Running;
- Dirty(args.MapUid, component);
+ Dirty(expeditionUid, component);
}
private void OnFTLStarted(ref FTLStartedEvent ev)
{
- if (ev.FromMapUid is not { } expeditionMapUid || !TryComp(expeditionMapUid, out var expedition))
+ if (!TryGetExpeditionForEntity(ev.Entity, out var expeditionMapUid, out var expedition))
return;
// HardLight: only the wall SalvageExpeditionConsole flow keeps station-side
@@ -220,7 +229,7 @@ private void OnFTLStarted(ref FTLStartedEvent ev)
if (HasComp(ev.Entity))
RemComp(ev.Entity);
- if (HasExpeditionParticipantShuttlesOnMap(expeditionMapUid))
+ if (HasExpeditionParticipantShuttlesOnMap(expeditionMapUid) || HasActivePlayersOnExpedition(expeditionMapUid))
return;
// Last shuttle has left so finish the mission.
@@ -241,7 +250,10 @@ private void UpdateRunner()
// Run the basic mission timers (e.g. announcements, auto-FTL, completion, etc)
while (query.MoveNext(out var uid, out var comp))
{
- var remaining = comp.EndTime - _timing.CurTime;
+ if (comp.EndTime == null)
+ continue;
+
+ var remaining = comp.EndTime.Value - _timing.CurTime;
var audioLength = _audio.GetAudioLength(comp.SelectedSong);
if (comp.Stage < ExpeditionStage.FinalCountdown && remaining < TimeSpan.FromSeconds(45))
@@ -293,7 +305,7 @@ private void UpdateRunner()
// This ensures shuttles get sent home even with the new console system
while (shuttleQuery.MoveNext(out var shuttleUid, out var shuttle, out var shuttleXform, out _))
{
- if (shuttleXform.MapUid != uid || HasComp(shuttleUid))
+ if (!IsEntityOnExpedition(shuttleUid, uid, shuttleXform) || HasComp(shuttleUid))
continue;
var dropLocation = PickExpeditionReturnDropLocation(existingPositions); // HardLight
@@ -551,9 +563,26 @@ private bool HasExpeditionParticipantShuttlesOnMap(EntityUid expeditionMapUid)
{
var shuttleQuery = EntityQueryEnumerator();
- while (shuttleQuery.MoveNext(out _, out _, out var shuttleXform, out _))
+ while (shuttleQuery.MoveNext(out var shuttleUid, out _, out var shuttleXform, out _))
+ {
+ if (IsEntityOnExpedition(shuttleUid, expeditionMapUid, shuttleXform))
+ return true;
+ }
+
+ return false;
+ }
+
+ private bool HasActivePlayersOnExpedition(EntityUid expeditionMapUid)
+ {
+ var ghostQuery = GetEntityQuery();
+ var mindQuery = EntityQueryEnumerator();
+
+ while (mindQuery.MoveNext(out var uid, out var mind, out var xform))
{
- if (shuttleXform.MapUid == expeditionMapUid)
+ if (!mind.HasMind || ghostQuery.HasComponent(uid))
+ continue;
+
+ if (IsEntityOnExpedition(uid, expeditionMapUid, xform))
return true;
}
@@ -579,7 +608,7 @@ private void FTLAllShuttlesHome(EntityUid expeditionMapUid, float? hyperspaceTim
while (shuttleQuery.MoveNext(out var shuttleUid, out var shuttle, out var shuttleXform, out _))
{
- if (shuttleXform.MapUid != expeditionMapUid || HasComp(shuttleUid))
+ if (!IsEntityOnExpedition(shuttleUid, expeditionMapUid, shuttleXform) || HasComp(shuttleUid))
continue;
var dropLocation = PickExpeditionReturnDropLocation(existingPositions);
@@ -598,11 +627,11 @@ private void QueueExpeditionDeletionWhenEmpty(EntityUid expeditionMapUid, int at
if (!Exists(expeditionMapUid) || !TryComp(expeditionMapUid, out _))
return;
- if (HasExpeditionParticipantShuttlesOnMap(expeditionMapUid))
+ if (HasExpeditionParticipantShuttlesOnMap(expeditionMapUid) || HasActivePlayersOnExpedition(expeditionMapUid))
{
if (attempt >= 24)
{
- Log.Warning($"Expedition {expeditionMapUid} still has expedition participant shuttles after cleanup retries; skipping forced map deletion to avoid deleting active players.");
+ Log.Warning($"Expedition {expeditionMapUid} still has expedition occupants after cleanup retries; skipping forced map deletion to avoid deleting active players.");
return;
}
diff --git a/Content.Server/Salvage/SalvageSystem.SectorExpeditionResolver.cs b/Content.Server/Salvage/SalvageSystem.SectorExpeditionResolver.cs
new file mode 100644
index 00000000000..9e758b63a85
--- /dev/null
+++ b/Content.Server/Salvage/SalvageSystem.SectorExpeditionResolver.cs
@@ -0,0 +1,87 @@
+using Content.Server.Salvage.Expeditions;
+using Content.Server.Worldgen.Components;
+
+namespace Content.Server.Salvage;
+
+public sealed partial class SalvageSystem
+{
+ private const float ExpeditionResolvePadding = 256f;
+
+ public bool IsOnExpedition(EntityUid entity, TransformComponent? xform = null)
+ {
+ return TryGetExpeditionForEntity(entity, out _, out _, xform);
+ }
+
+ private bool TryGetExpeditionForEntity(EntityUid entity, out EntityUid expeditionUid, out SalvageExpeditionComponent expedition, TransformComponent? xform = null)
+ {
+ expeditionUid = EntityUid.Invalid;
+ expedition = default!;
+
+ if (TryComp(entity, out var expeditionComp) && expeditionComp != null)
+ {
+ expeditionUid = entity;
+ expedition = expeditionComp;
+ return true;
+ }
+
+ if (!Resolve(entity, ref xform, false))
+ return false;
+
+ if (xform.GridUid is { } gridUid && TryComp(gridUid, out expeditionComp) && expeditionComp != null)
+ {
+ expeditionUid = gridUid;
+ expedition = expeditionComp;
+ return true;
+ }
+
+ if (xform.MapUid is not { } mapUid)
+ return false;
+
+ if (TryComp(mapUid, out expeditionComp) && expeditionComp != null)
+ {
+ expeditionUid = mapUid;
+ expedition = expeditionComp;
+ return true;
+ }
+
+ var worldPos = _transform.GetWorldPosition(xform, _xformQuery);
+ var maxDistanceSquared = float.MaxValue;
+ var query = EntityQueryEnumerator();
+
+ while (query.MoveNext(out var uid, out var nearbyExpedition, out var site, out var expeditionXform))
+ {
+ if (expeditionXform.MapUid != mapUid)
+ continue;
+
+ var resolveRadius = site.Radius + ExpeditionResolvePadding;
+ var distanceSquared = (site.Center - worldPos).LengthSquared();
+ if (distanceSquared > resolveRadius * resolveRadius || distanceSquared >= maxDistanceSquared)
+ continue;
+
+ maxDistanceSquared = distanceSquared;
+ expeditionUid = uid;
+ expedition = nearbyExpedition;
+ }
+
+ return expeditionUid != EntityUid.Invalid;
+ }
+
+ private bool IsEntityOnExpedition(EntityUid entity, EntityUid expeditionUid, TransformComponent? xform = null)
+ {
+ if (!Resolve(entity, ref xform, false))
+ return false;
+
+ if (xform.GridUid == expeditionUid)
+ return true;
+
+ if (!TryComp(expeditionUid, out SectorExpeditionSiteComponent? site))
+ return xform.MapUid == expeditionUid;
+
+ if (xform.MapUid != site.SectorMap)
+ return false;
+
+ var worldPos = _transform.GetWorldPosition(xform, _xformQuery);
+ var resolveRadius = site.Radius + ExpeditionResolvePadding;
+ return (site.Center - worldPos).LengthSquared() <= resolveRadius * resolveRadius;
+ }
+}
\ No newline at end of file
diff --git a/Content.Server/Salvage/SalvageSystem.cs b/Content.Server/Salvage/SalvageSystem.cs
index 4e66938feb1..665e465be22 100644
--- a/Content.Server/Salvage/SalvageSystem.cs
+++ b/Content.Server/Salvage/SalvageSystem.cs
@@ -20,6 +20,7 @@
using Content.Shared.Labels.EntitySystems;
using Content.Server.GameTicking;
using Robust.Shared.EntitySerialization.Systems;
+using Content.Server.Worldgen.Systems;
namespace Content.Server.Salvage
{
@@ -43,6 +44,7 @@ public sealed partial class SalvageSystem : SharedSalvageSystem
[Dependency] private readonly SharedMapSystem _mapSystem = default!;
[Dependency] private readonly ShuttleSystem _shuttle = default!;
[Dependency] private readonly StationSystem _station = default!;
+ [Dependency] private readonly SectorWorldSystem _sectorWorld = default!;
[Dependency] private readonly UserInterfaceSystem _ui = default!;
private EntityQuery _gridQuery;
diff --git a/Content.Server/Salvage/SpawnSalvageMissionJob.cs b/Content.Server/Salvage/SpawnSalvageMissionJob.cs
index 089bc6e5967..9422b22fd60 100644
--- a/Content.Server/Salvage/SpawnSalvageMissionJob.cs
+++ b/Content.Server/Salvage/SpawnSalvageMissionJob.cs
@@ -3,6 +3,7 @@
using System.Numerics;
using System.Threading;
using System.Threading.Tasks;
+using Content.Server._Mono.Cleanup;
using Content.Server.Atmos;
using Content.Server.Atmos.Components;
using Content.Server.Atmos.EntitySystems;
@@ -17,6 +18,7 @@
using Content.Shared.Dataset;
using Content.Shared.Gravity;
using Content.Shared.Parallax.Biomes;
+using Content.Shared.Parallax.Biomes.Markers;
using Content.Shared.Physics;
using Content.Shared.Procedural;
using Content.Shared.Procedural.Loot;
@@ -29,6 +31,7 @@
using Robust.Shared.Collections;
using Robust.Shared.Map;
using Robust.Shared.Map.Components;
+using Robust.Shared.Maths;
using Robust.Shared.Prototypes;
using Robust.Shared.Random;
using Robust.Shared.Timing;
@@ -39,6 +42,10 @@
using Content.Server.Station.Systems; // Frontier
using Content.Server.Shuttles.Systems;
using Content.Server._NF.Salvage.Expeditions.Structure; // Frontier
+using Content.Server.Worldgen;
+using Content.Server.Worldgen.Components;
+using Content.Server.Worldgen.Systems;
+using Robust.Shared.Audio.Systems;
namespace Content.Server.Salvage;
@@ -46,14 +53,17 @@ public sealed class SpawnSalvageMissionJob : Job
{
private readonly IEntityManager _entManager;
private readonly IGameTiming _timing;
+ private readonly IMapManager _mapManager;
private readonly IPrototypeManager _prototypeManager;
private readonly AnchorableSystem _anchorable;
+ private readonly SharedAudioSystem _audio;
private readonly BiomeSystem _biome;
private readonly DungeonSystem _dungeon;
private readonly MetaDataSystem _metaData;
private readonly SharedMapSystem _map;
private readonly StationSystem _station; // Frontier
private readonly ShuttleSystem _shuttle; // Frontier
+ private readonly SectorWorldSystem _sectorWorld;
private readonly SalvageSystem _salvage; // Frontier
public readonly EntityUid Station;
@@ -66,6 +76,7 @@ public sealed class SpawnSalvageMissionJob : Job
// Frontier: Used for saving state between async job
#pragma warning disable IDE1006 // suppressing prefix warnings to reduce merge conflict area
private EntityUid mapUid = EntityUid.Invalid;
+ private EntityUid hostGridUid = EntityUid.Invalid;
#pragma warning restore IDE1006
private static readonly ProtoId FallbackDifficulty = "NFModerate";
private static readonly ProtoId PlanetNamesId = "NamesBorer";
@@ -76,14 +87,17 @@ public SpawnSalvageMissionJob(
IEntityManager entManager,
IGameTiming timing,
ILogManager logManager,
+ IMapManager mapManager,
IPrototypeManager protoManager,
AnchorableSystem anchorable,
+ SharedAudioSystem audio,
BiomeSystem biome,
DungeonSystem dungeon,
MetaDataSystem metaData,
SharedMapSystem map,
StationSystem stationSystem, // Frontier
ShuttleSystem shuttleSystem, // Frontier
+ SectorWorldSystem sectorWorld,
SalvageSystem salvageSystem, // Frontier
EntityUid station,
EntityUid? console, // HARDLIGHT: Console that initiated this mission
@@ -93,14 +107,17 @@ public SpawnSalvageMissionJob(
{
_entManager = entManager;
_timing = timing;
+ _mapManager = mapManager;
_prototypeManager = protoManager;
_anchorable = anchorable;
+ _audio = audio;
_biome = biome;
_dungeon = dungeon;
_metaData = metaData;
_map = map;
_station = stationSystem; // Frontier
_shuttle = shuttleSystem; // Frontier
+ _sectorWorld = sectorWorld;
_salvage = salvageSystem; // Frontier
Station = station;
Console = console; // HARDLIGHT: Store console reference
@@ -118,19 +135,22 @@ public SpawnSalvageMissionJob(
IEntityManager entManager,
IGameTiming timing,
ILogManager logManager,
+ IMapManager mapManager,
IPrototypeManager protoManager,
AnchorableSystem anchorable,
+ SharedAudioSystem audio,
BiomeSystem biome,
DungeonSystem dungeon,
MetaDataSystem metaData,
SharedMapSystem map,
StationSystem stationSystem,
ShuttleSystem shuttleSystem,
+ SectorWorldSystem sectorWorld,
SalvageSystem salvageSystem,
EntityUid station,
SalvageMissionParams missionParams,
CancellationToken cancellation = default)
- : this(maxTime, entManager, timing, logManager, protoManager, anchorable, biome, dungeon, metaData, map, stationSystem, shuttleSystem, salvageSystem, station, null, null, missionParams, cancellation)
+ : this(maxTime, entManager, timing, logManager, mapManager, protoManager, anchorable, audio, biome, dungeon, metaData, map, stationSystem, shuttleSystem, sectorWorld, salvageSystem, station, null, null, missionParams, cancellation)
{
// Intentionally empty
}
@@ -185,28 +205,9 @@ protected override async Task Process()
private async Task InternalProcess() // Frontier: make process an internal function (for a try block indenting an entire), add "out EntityUid mapUid" param
{
_sawmill.Debug($"Spawning salvage mission with seed {_missionParams.Seed}");
- mapUid = _map.CreateMap(out var mapId, runMapInit: false); // Frontier: remove var
+
MetaDataComponent? metadata = null;
- var grid = _entManager.EnsureComponent(mapUid);
var random = new Random(_missionParams.Seed);
- // HARDLIGHT: Make expedition destination globally FTL-accessible without requiring disks or beacons.
- // Previously this required a coordinates disk and was beacon-limited which prevented ad-hoc rescue / support.
- var destComp = _entManager.AddComponent(mapUid);
- destComp.BeaconsOnly = false; // Allow direct FTL targeting anywhere on the expedition map.
- destComp.RequireCoordinateDisk = CoordinatesDisk.HasValue; // Disk missions require a coordinates disk.
- destComp.Enabled = true; // Keep enabled for entire expedition so multiple ships can jump in.
- _metaData.SetEntityName(
- mapUid,
- _entManager.System().GetFTLName(_prototypeManager.Index(PlanetNamesId), _missionParams.Seed));
- _entManager.AddComponent(mapUid);
-
- // Saving the mission mapUid to a CD is made optional, in case one is somehow made in a process without a CD entity
- if (CoordinatesDisk.HasValue)
- {
- var cd = _entManager.EnsureComponent(CoordinatesDisk.Value);
- cd.Destination = _entManager.GetNetEntity(mapUid);
- _entManager.Dirty(CoordinatesDisk.Value, cd);
- }
// Setup mission configs
// As we go through the config the rating will deplete so we'll go for most important to least important.
@@ -219,46 +220,67 @@ private async Task InternalProcess() // Frontier: make process an internal
.GetMission(_missionParams.MissionType, difficultyProto, _missionParams.Seed); // Frontier: add MissionType
var missionBiome = _prototypeManager.Index(mission.Biome);
+ _sectorWorld.TryResolvePlanetTypeForBiome(missionBiome.BiomePrototype, out var planetTypeId);
+
+ if (!_sectorWorld.TryGetPersistentMap(planetTypeId, out var hostMapUid, out var hostPlanet))
+ return false;
+
+ var mapId = _entManager.GetComponent(hostMapUid).MapId;
+ hostGridUid = hostMapUid;
+ var grid = _entManager.EnsureComponent(hostGridUid);
+ mapUid = _entManager.SpawnEntity(null, new MapCoordinates(Vector2.Zero, mapId));
- if (missionBiome.BiomePrototype != null)
+ if (!_sectorWorld.TryReserveExpeditionSite(_missionParams.Seed, mapUid, planetTypeId, out var placement))
{
- var biome = _entManager.AddComponent(mapUid);
- var biomeSystem = _entManager.System();
- biomeSystem.SetTemplate(mapUid, biome, _prototypeManager.Index(missionBiome.BiomePrototype));
- biomeSystem.SetSeed(mapUid, biome, mission.Seed);
- _entManager.Dirty(mapUid, biome);
-
- // Gravity
- var gravity = _entManager.EnsureComponent(mapUid);
- gravity.Enabled = true;
- _entManager.Dirty(mapUid, gravity, metadata);
-
- // Atmos
- var air = _prototypeManager.Index(mission.Air);
- // copy into a new array since the yml deserialization discards the fixed length
- var moles = new float[Atmospherics.AdjustedNumberOfGases];
- air.Gases.CopyTo(moles, 0);
- var atmos = _entManager.EnsureComponent(mapUid);
- _entManager.System().SetMapSpace(mapUid, air.Space, atmos);
- _entManager.System().SetMapGasMixture(mapUid, new GasMixture(moles, mission.Temperature), atmos);
-
- if (mission.Color != null)
- {
- var lighting = _entManager.EnsureComponent(mapUid);
- lighting.AmbientLightColor = mission.Color.Value;
- _entManager.Dirty(mapUid, lighting);
- }
+ _entManager.QueueDeleteEntity(mapUid);
+ return false;
}
- _map.InitializeMap(mapId);
- _map.SetPaused(mapUid, true);
+ _entManager.System().SetCoordinates(mapUid, new EntityCoordinates(hostGridUid, placement.Center));
+ _entManager.EnsureComponent(mapUid);
+ var site = _entManager.EnsureComponent(mapUid);
+ site.SectorMap = placement.SectorMap;
+ site.PlanetId = placement.Planet.PlanetId;
+ site.Center = placement.Center;
+ site.Radius = placement.ReservationRadius;
+
+ // HARDLIGHT: Make expedition destination globally FTL-accessible without requiring disks or beacons.
+ // Previously this required a coordinates disk and was beacon-limited which prevented ad-hoc rescue / support.
+ var destComp = _entManager.AddComponent(mapUid);
+ destComp.BeaconsOnly = false;
+ destComp.RequireCoordinateDisk = CoordinatesDisk.HasValue;
+ destComp.Enabled = true;
+ _metaData.SetEntityName(
+ mapUid,
+ $"{placement.Planet.Name} Expedition {_missionParams.Index}");
+ _entManager.AddComponent(mapUid);
+
+ if (CoordinatesDisk.HasValue)
+ {
+ var cd = _entManager.EnsureComponent(CoordinatesDisk.Value);
+ cd.Destination = _entManager.GetNetEntity(mapUid);
+ _entManager.Dirty(CoordinatesDisk.Value, cd);
+ }
// Setup expedition
var expedition = _entManager.AddComponent(mapUid);
expedition.Station = Station;
expedition.Console = Console; // HARDLIGHT: Store console reference for FTL targeting
- expedition.EndTime = _timing.CurTime + mission.Duration;
expedition.MissionParams = _missionParams;
+ expedition.SelectedSong = _audio.ResolveSound(expedition.Sound);
+ expedition.HostGridUid = hostGridUid;
+
+ var captureRadius = placement.ReservationRadius + 32f;
+ _entManager.EnsureComponent(mapUid);
+ var worldController = _entManager.System();
+ var loadRadius = (int) MathF.Ceiling(captureRadius + WorldGen.ChunkSize);
+ worldController.SetLoaderRadius(mapUid, loadRadius);
+ worldController.SetLoaderEnabled(mapUid, true);
+ worldController.EnsureChunksLoaded(hostMapUid, placement.Center, loadRadius, mapUid);
+
+ _sectorWorld.CaptureHostedSiteBaseline((mapUid, site), hostGridUid, grid, placement.Center, captureRadius);
+ CaptureOriginalTiles(expedition, hostGridUid, grid, placement.Center, captureRadius);
+ var existingEntities = CaptureNearbyEntities(hostMapUid, placement.Center, captureRadius);
var landingPadRadius = 4; // Frontier: 24<4 - using this as a margin (4-16), not a radius
var minDungeonOffset = landingPadRadius + 4;
@@ -270,9 +292,11 @@ private async Task InternalProcess() // Frontier: make process an internal
var dungeonOffsetDistance = minDungeonOffset + (maxDungeonOffset - minDungeonOffset) * random.NextFloat();
var dungeonOffset = new Vector2(0f, dungeonOffsetDistance);
dungeonOffset = dungeonRotation.RotateVec(dungeonOffset);
+ var expeditionOrigin = placement.Center.Rounded();
+ var dungeonOrigin = expeditionOrigin + dungeonOffset;
var dungeonMod = _prototypeManager.Index(mission.Dungeon);
var dungeonConfig = _prototypeManager.Index(dungeonMod.Proto);
- var dungeons = await WaitAsyncTask(_dungeon.GenerateDungeonAsync(dungeonConfig, dungeonMod.Proto, mapUid, grid, (Vector2i)dungeonOffset, // Frontier: add dungeonMod.Proto
+ var dungeons = await WaitAsyncTask(_dungeon.GenerateDungeonAsync(dungeonConfig, dungeonMod.Proto, hostGridUid, grid, (Vector2i)dungeonOrigin, // Frontier: add dungeonMod.Proto
_missionParams.Seed));
var dungeon = dungeons.First();
@@ -283,13 +307,13 @@ private async Task InternalProcess() // Frontier: make process an internal
return false;
}
- expedition.DungeonLocation = dungeonOffset;
+ expedition.DungeonLocation = dungeonOrigin - expeditionOrigin;
// Frontier: map generation and offset
#region Frontier map generation
// Get map bounding box
- Box2 dungeonBox = new Box2(dungeonOffset, dungeonOffset);
+ Box2 dungeonBox = new Box2(dungeonOrigin, dungeonOrigin);
foreach (var tile in dungeon.AllTiles)
{
dungeonBox = dungeonBox.ExtendToContain(tile);
@@ -378,7 +402,7 @@ private async Task InternalProcess() // Frontier: make process an internal
try
{
- await SpawnDungeonLoot(lootProto, mapUid);
+ await SpawnDungeonLoot(lootProto, (hostGridUid, grid), dungeon, random);
}
catch (Exception e)
{
@@ -415,7 +439,7 @@ private async Task InternalProcess() // Frontier: make process an internal
try
{
- await SpawnRandomEntry((mapUid, grid), entry, dungeon, random);
+ await SpawnRandomEntry((hostGridUid, grid), entry, dungeon, random);
}
catch (Exception e)
{
@@ -450,7 +474,7 @@ private async Task InternalProcess() // Frontier: make process an internal
break;
_sawmill.Debug($"Spawning dungeon loot {entry.Proto}");
- await SpawnRandomEntry((mapUid, grid), entry, dungeon, random);
+ await SpawnRandomEntry((hostGridUid, grid), entry, dungeon, random);
}
break;
default:
@@ -458,6 +482,9 @@ private async Task InternalProcess() // Frontier: make process an internal
}
}
+ CaptureGeneratedEntities(expedition, existingEntities, hostMapUid, placement.Center, captureRadius);
+ _sectorWorld.CaptureHostedSiteGeneratedEntities((mapUid, site), hostMapUid, placement.Center, captureRadius);
+
// Frontier: delay ship FTL
if (shuttleUid is { Valid: true })
{
@@ -470,7 +497,7 @@ private async Task InternalProcess() // Frontier: make process an internal
}
else
{
- _shuttle.FTLToCoordinates(shuttleUid.Value, shuttle, new EntityCoordinates(mapUid, coords), 0f, 5.5f, _salvage.TravelTime);
+ _shuttle.FTLToCoordinates(shuttleUid.Value, shuttle, new EntityCoordinates(hostGridUid, coords), 0f, 5.5f, _salvage.TravelTime);
}
}
// End Frontier
@@ -478,6 +505,63 @@ private async Task InternalProcess() // Frontier: make process an internal
return true;
}
+ private void CaptureOriginalTiles(SalvageExpeditionComponent expedition, EntityUid gridUid, MapGridComponent grid, Vector2 center, float radius)
+ {
+ expedition.OriginalTiles.Clear();
+
+ foreach (var tile in _map.GetTilesIntersecting(gridUid, grid, new Circle(center, radius), false))
+ {
+ expedition.OriginalTiles[tile.GridIndices] = tile.Tile;
+ }
+ }
+
+ private HashSet CaptureNearbyEntities(EntityUid mapUid, Vector2 center, float radius)
+ {
+ var entities = new HashSet();
+ var radiusSquared = radius * radius;
+ var query = _entManager.AllEntityQueryEnumerator();
+
+ while (query.MoveNext(out var uid, out var xform))
+ {
+ if (uid == mapUid || uid == hostGridUid || uid == this.mapUid)
+ continue;
+
+ if (xform.MapUid != mapUid)
+ continue;
+
+ var pos = xform.Coordinates.Position;
+ if ((pos - center).LengthSquared() > radiusSquared)
+ continue;
+
+ entities.Add(uid);
+ }
+
+ return entities;
+ }
+
+ private void CaptureGeneratedEntities(SalvageExpeditionComponent expedition, HashSet existingEntities, EntityUid mapUid, Vector2 center, float radius)
+ {
+ expedition.GeneratedEntities.Clear();
+
+ var radiusSquared = radius * radius;
+ var query = _entManager.AllEntityQueryEnumerator();
+
+ while (query.MoveNext(out var uid, out var xform))
+ {
+ if (uid == mapUid || uid == hostGridUid || uid == this.mapUid || existingEntities.Contains(uid))
+ continue;
+
+ if (xform.MapUid != mapUid)
+ continue;
+
+ var pos = xform.Coordinates.Position;
+ if ((pos - center).LengthSquared() > radiusSquared)
+ continue;
+
+ expedition.GeneratedEntities.Add(uid);
+ }
+ }
+
private async Task SpawnRandomEntry(Entity grid, IBudgetEntry entry, Dungeon dungeon, Random random)
{
await SuspendIfOutOfTime();
@@ -512,7 +596,7 @@ private async Task SpawnRandomEntry(Entity grid, IBudgetEntry
// oh noooooooooooo
}
- private async Task SpawnDungeonLoot(SalvageLootPrototype loot, EntityUid gridUid)
+ private async Task SpawnDungeonLoot(SalvageLootPrototype loot, Entity grid, Dungeon dungeon, Random random)
{
for (var i = 0; i < loot.LootRules.Count; i++)
{
@@ -522,17 +606,14 @@ private async Task SpawnDungeonLoot(SalvageLootPrototype loot, EntityUid gridUid
{
case BiomeMarkerLoot biomeLoot:
{
- if (_entManager.TryGetComponent(gridUid, out var biome))
- {
- _biome.AddMarkerLayer(gridUid, biome, biomeLoot.Prototype);
- }
+ await SpawnDungeonMarkerLoot(grid, dungeon, biomeLoot.Prototype, new Random(_missionParams.Seed + i));
}
break;
case BiomeTemplateLoot biomeLoot:
{
- if (_entManager.TryGetComponent(gridUid, out var biome))
+ if (_entManager.TryGetComponent(grid.Owner, out var biome))
{
- _biome.AddTemplate(gridUid, biome, "Loot", _prototypeManager.Index(biomeLoot.Prototype), i);
+ _biome.AddTemplate(grid.Owner, biome, "Loot", _prototypeManager.Index(biomeLoot.Prototype), i);
}
}
break;
@@ -540,6 +621,138 @@ private async Task SpawnDungeonLoot(SalvageLootPrototype loot, EntityUid gridUid
}
}
+ private async Task SpawnDungeonMarkerLoot(Entity grid, Dungeon dungeon, string markerId, Random random)
+ {
+ if (!_prototypeManager.TryIndex(markerId, out var markerTemplate))
+ return;
+
+ Box2i? bounds = null;
+ foreach (var tile in dungeon.RoomTiles)
+ {
+ bounds = bounds == null ? new Box2i(tile, tile + Vector2i.One) : bounds.Value.UnionTile(tile);
+ }
+
+ if (bounds == null)
+ return;
+
+ var availableTiles = new List();
+ var replaceEntities = new Dictionary();
+
+ for (var x = bounds.Value.Left; x < bounds.Value.Right; x++)
+ {
+ for (var y = bounds.Value.Bottom; y < bounds.Value.Top; y++)
+ {
+ var tile = new Vector2i(x, y);
+
+ if (!_map.TryGetTileRef(grid.Owner, grid.Comp, tile, out var tileRef) || tileRef.Tile.IsEmpty)
+ continue;
+
+ var enumerator = _map.GetAnchoredEntitiesEnumerator(grid.Owner, grid.Comp, tile);
+
+ if (markerTemplate.EntityMask.Count > 0)
+ {
+ var found = false;
+ var blocked = false;
+
+ while (enumerator.MoveNext(out var uid))
+ {
+ var prototype = _entManager.GetComponent(uid.Value).EntityPrototype?.ID;
+ if (prototype == null)
+ continue;
+
+ if (markerTemplate.EntityMask.ContainsKey(prototype))
+ {
+ if (!found)
+ replaceEntities[tile] = uid.Value;
+
+ found = true;
+ continue;
+ }
+
+ blocked = true;
+ break;
+ }
+
+ if (!found || blocked)
+ continue;
+ }
+ else
+ {
+ if (!_anchorable.TileFree(grid, tile, DungeonSystem.CollisionLayer, DungeonSystem.CollisionMask))
+ continue;
+
+ if (enumerator.MoveNext(out _))
+ continue;
+ }
+
+ availableTiles.Add(tile);
+ }
+ }
+
+ if (availableTiles.Count == 0)
+ return;
+
+ var area = Math.Max(bounds.Value.Area, 1);
+ var count = Math.Min(markerTemplate.MaxCount, Math.Max(1, (int) (area / Math.Max(markerTemplate.Radius * markerTemplate.Radius, 1f))));
+ var frontier = new ValueList(32);
+
+ for (var i = 0; i < count; i++)
+ {
+ await SuspendIfOutOfTime();
+
+ var groupSize = random.Next(markerTemplate.MinGroupSize, markerTemplate.MaxGroupSize + 1);
+
+ while (groupSize > 0 && availableTiles.Count > 0)
+ {
+ var startNode = availableTiles.RemoveSwap(random.Next(availableTiles.Count));
+ frontier.Clear();
+ frontier.Add(startNode);
+
+ while (frontier.Count > 0 && groupSize > 0)
+ {
+ var frontierIndex = random.Next(frontier.Count);
+ var node = frontier[frontierIndex];
+ frontier.RemoveSwap(frontierIndex);
+ availableTiles.Remove(node);
+
+ for (var x = -1; x <= 1; x++)
+ {
+ for (var y = -1; y <= 1; y++)
+ {
+ var neighbor = new Vector2i(node.X + x, node.Y + y);
+
+ if (frontier.Contains(neighbor) || !availableTiles.Contains(neighbor))
+ continue;
+
+ frontier.Add(neighbor);
+ }
+ }
+
+ string? prototype = markerTemplate.Prototype;
+
+ if (replaceEntities.TryGetValue(node, out var existingEnt))
+ {
+ var existingProto = _entManager.GetComponent(existingEnt).EntityPrototype?.ID;
+ _entManager.DeleteEntity(existingEnt);
+
+ if (existingProto != null && markerTemplate.EntityMask.TryGetValue(existingProto, out var remapped))
+ prototype = remapped;
+ }
+
+ if (!string.IsNullOrEmpty(prototype))
+ {
+ var spawned = _entManager.SpawnAtPosition(prototype, _map.GridTileToLocal(grid.Owner, grid.Comp, node));
+ var xform = _entManager.GetComponent(spawned);
+ if (!xform.Anchored)
+ _entManager.System().AnchorEntity(spawned, xform);
+ }
+
+ groupSize--;
+ }
+ }
+ }
+ }
+
// Frontier: mission-specific setup functions
private async Task SetupStructure(
SalvageMission mission,
@@ -575,7 +788,7 @@ private async Task SetupStructure(
continue;
}
- var uid = _entManager.SpawnEntity(shaggy, _map.GridTileToLocal(mapUid, grid, tile));
+ var uid = _entManager.SpawnEntity(shaggy, _map.GridTileToLocal(hostGridUid, grid, tile));
_entManager.AddComponent(uid);
structureComp.Structures.Add(uid);
break;
@@ -616,7 +829,7 @@ private async Task SetupElimination(
continue;
}
- uid = _entManager.SpawnAtPosition(prototype, _map.GridTileToLocal(mapUid, grid, tile));
+ uid = _entManager.SpawnAtPosition(prototype, _map.GridTileToLocal(hostGridUid, grid, tile));
break;
}
}
diff --git a/Content.Server/Trash/TrashCleanupSystem.cs b/Content.Server/Trash/TrashCleanupSystem.cs
index 08cbad1d013..82365813f78 100644
--- a/Content.Server/Trash/TrashCleanupSystem.cs
+++ b/Content.Server/Trash/TrashCleanupSystem.cs
@@ -1,3 +1,4 @@
+using Content.Server._Mono.Cleanup;
using Content.Server.Station.Components;
using System.Linq;
using Content.Server.Worldgen.Components;
@@ -155,8 +156,7 @@ private List GetProtectedZones()
{
if (session.AttachedEntity is not { } playerEntity)
continue;
- if (!TryComp(playerEntity, out var xform))
- continue;
+ var xform = Transform(playerEntity);
if (HasComp(playerEntity))
continue;
@@ -203,6 +203,9 @@ private List GetProtectedZones()
///
private bool ShouldProtectGrid(EntityUid gridUid)
{
+ if (HasComp(gridUid))
+ return true;
+
// Protect station grids
if (HasComp(gridUid))
return true;
diff --git a/Content.Server/Weather/WeatherSystem.cs b/Content.Server/Weather/WeatherSystem.cs
index ec377809133..58e1eb2b988 100644
--- a/Content.Server/Weather/WeatherSystem.cs
+++ b/Content.Server/Weather/WeatherSystem.cs
@@ -1,92 +1,30 @@
-using Content.Server.Administration;
-using Content.Shared.Administration;
using Content.Shared.Weather;
-using Robust.Shared.Console;
-using Robust.Shared.GameStates;
-using Robust.Shared.Map;
-using System.Linq;
+using Robust.Server.GameStates;
namespace Content.Server.Weather;
public sealed class WeatherSystem : SharedWeatherSystem
{
- [Dependency] private readonly IConsoleHost _console = default!;
- [Dependency] private readonly SharedMapSystem _mapSystem = default!;
+ //I dont really like to PVS override weather entities, but map status effect containers dont PVS-ing out of the box
+ [Dependency] private readonly PvsOverrideSystem _pvs = default!;
public override void Initialize()
{
base.Initialize();
- SubscribeLocalEvent(OnWeatherGetState);
- _console.RegisterCommand("weather",
- Loc.GetString("cmd-weather-desc"),
- Loc.GetString("cmd-weather-help"),
- WeatherTwo,
- WeatherCompletion);
- }
- private void OnWeatherGetState(EntityUid uid, WeatherComponent component, ref ComponentGetState args)
- {
- args.State = new WeatherComponentState(component.Weather);
+ SubscribeLocalEvent(OnCompInit);
+ SubscribeLocalEvent(OnCompShutdown);
}
- [AdminCommand(AdminFlags.Fun)]
- private void WeatherTwo(IConsoleShell shell, string argStr, string[] args)
+ private void OnCompInit(Entity ent, ref ComponentInit args)
{
- if (args.Length < 2)
- {
- shell.WriteError(Loc.GetString("cmd-weather-error-no-arguments"));
- return;
- }
-
- if (!int.TryParse(args[0], out var mapInt))
- return;
-
- var mapId = new MapId(mapInt);
-
- if (!MapManager.MapExists(mapId))
- return;
-
- if (!_mapSystem.TryGetMap(mapId, out var mapUid))
- return;
-
- var weatherComp = EnsureComp(mapUid.Value);
-
- //Weather Proto parsing
- WeatherPrototype? weather = null;
- if (!args[1].Equals("null"))
- {
- if (!ProtoMan.TryIndex(args[1], out weather))
- {
- shell.WriteError(Loc.GetString("cmd-weather-error-unknown-proto"));
- return;
- }
- }
-
- //Time parsing
- TimeSpan? endTime = null;
- if (args.Length == 3)
- {
- var curTime = Timing.CurTime;
- if (int.TryParse(args[2], out var durationInt))
- {
- endTime = curTime + TimeSpan.FromSeconds(durationInt);
- }
- else
- {
- shell.WriteError(Loc.GetString("cmd-weather-error-wrong-time"));
- }
- }
-
- SetWeather(mapId, weather, endTime);
+ // The map entitiy itself is networked by PVS if the player is on that map but not anything inside a container,
+ // So we need to add an overridce to make sure the client sees it.
+ _pvs.AddGlobalOverride(ent);
}
- private CompletionResult WeatherCompletion(IConsoleShell shell, string[] args)
+ private void OnCompShutdown(Entity ent, ref ComponentShutdown args)
{
- if (args.Length == 1)
- return CompletionResult.FromHintOptions(CompletionHelper.MapIds(EntityManager), "Map Id");
-
- var a = CompletionHelper.PrototypeIDs(true, ProtoMan);
- var b = a.Concat(new[] { new CompletionOption("null", Loc.GetString("cmd-weather-null")) });
- return CompletionResult.FromHintOptions(b, Loc.GetString("cmd-weather-hint"));
+ _pvs.RemoveGlobalOverride(ent);
}
}
diff --git a/Content.Server/Worldgen/Components/ChunkEntityMutationRecord.cs b/Content.Server/Worldgen/Components/ChunkEntityMutationRecord.cs
new file mode 100644
index 00000000000..7080d2a205c
--- /dev/null
+++ b/Content.Server/Worldgen/Components/ChunkEntityMutationRecord.cs
@@ -0,0 +1,26 @@
+using System.Numerics;
+
+namespace Content.Server.Worldgen.Components;
+
+public sealed record ChunkEntityMutationRecord(
+ Vector2 LocalPosition,
+ string PrototypeId,
+ double Rotation,
+ bool Anchored);
+
+public static class ChunkEntityMutationRules
+{
+ public static bool ShouldAnchor(string prototypeId)
+ {
+ return prototypeId.StartsWith("Wall", StringComparison.OrdinalIgnoreCase)
+ || prototypeId.StartsWith("NFWall", StringComparison.OrdinalIgnoreCase)
+ || prototypeId.Contains("Door", StringComparison.OrdinalIgnoreCase)
+ || prototypeId.Contains("Airlock", StringComparison.OrdinalIgnoreCase)
+ || prototypeId.Contains("Window", StringComparison.OrdinalIgnoreCase)
+ || prototypeId.Contains("Mineral", StringComparison.OrdinalIgnoreCase)
+ || (prototypeId.Contains("Cable", StringComparison.OrdinalIgnoreCase)
+ && !prototypeId.Contains("Stack", StringComparison.OrdinalIgnoreCase)
+ && !prototypeId.Contains("Placer", StringComparison.OrdinalIgnoreCase))
+ || string.Equals(prototypeId, "Grille", StringComparison.OrdinalIgnoreCase);
+ }
+}
\ No newline at end of file
diff --git a/Content.Server/Worldgen/Components/SectorChunkCarverComponent.cs b/Content.Server/Worldgen/Components/SectorChunkCarverComponent.cs
new file mode 100644
index 00000000000..55add17cda0
--- /dev/null
+++ b/Content.Server/Worldgen/Components/SectorChunkCarverComponent.cs
@@ -0,0 +1,82 @@
+using System.Numerics;
+
+namespace Content.Server.Worldgen.Components;
+
+///
+/// Carves solid asteroid mass into the shared sector grid for a streamed chunk.
+///
+[RegisterComponent]
+public sealed partial class SectorChunkCarverComponent : Component
+{
+ [DataField]
+ public string DensityNoiseChannel = "Density";
+
+ [DataField]
+ public string CarveNoiseChannel = "Carver";
+
+ [DataField]
+ public string IslandNoiseChannel = "Wreck";
+
+ [DataField]
+ public float SparseFieldScale = 104f;
+
+ [DataField]
+ public float ChunkFieldScale = 5f;
+
+ [DataField]
+ public float IslandFieldScale = 22f;
+
+ [DataField]
+ public float DetailFieldScale = 9f;
+
+ [DataField]
+ public float SparseThreshold = 0.978f;
+
+ [DataField]
+ public float ChunkThreshold = 0.45f;
+
+ [DataField]
+ public float DensityThreshold = 0.79f;
+
+ [DataField]
+ public Vector2 CarveRange = new(0.46f, 0.54f);
+
+ [DataField]
+ public float IslandThreshold = 0.9f;
+
+ [DataField]
+ public float DensitySharpness = 2.35f;
+
+ [DataField]
+ public float PlanetFalloff = 0.08f;
+
+ [DataField]
+ public List Biomes =
+ [
+ "SectorRock",
+ "SectorIce",
+ "SectorAndesite",
+ "SectorBasalt",
+ "SectorSand",
+ "SectorChromite",
+ "SectorRust",
+ "SectorScrap",
+ "SectorWreck",
+ "SectorBrass",
+ ];
+
+ [ViewVariables]
+ public HashSet GeneratedTiles = new();
+
+ [ViewVariables]
+ public HashSet GeneratedEntities = new();
+
+ [ViewVariables]
+ public bool Materialized;
+
+ [ViewVariables]
+ public string? CacheFilePath;
+
+ [ViewVariables]
+ public EntityUid MaterializedGrid = EntityUid.Invalid;
+}
\ No newline at end of file
diff --git a/Content.Server/Worldgen/Components/SectorExpeditionSiteComponent.cs b/Content.Server/Worldgen/Components/SectorExpeditionSiteComponent.cs
new file mode 100644
index 00000000000..7096a2ada11
--- /dev/null
+++ b/Content.Server/Worldgen/Components/SectorExpeditionSiteComponent.cs
@@ -0,0 +1,38 @@
+using System.Collections.Generic;
+using System.Numerics;
+using Robust.Shared.Map;
+
+namespace Content.Server.Worldgen.Components;
+
+///
+/// Marks an expedition grid as occupying a reserved site in the streamed sector.
+///
+[RegisterComponent]
+public sealed partial class SectorExpeditionSiteComponent : Component
+{
+ [DataField]
+ public EntityUid SectorMap;
+
+ [DataField]
+ public string PlanetId = string.Empty;
+
+ [DataField]
+ public Vector2 Center;
+
+ [DataField]
+ public float Radius;
+
+ public EntityUid HostGridUid = EntityUid.Invalid;
+
+ public float ContentRadius;
+
+ public Dictionary OriginalTiles = new();
+
+ public HashSet OriginalEntities = new();
+
+ public HashSet GeneratedEntities = new();
+
+ public Dictionary> CachedChunkTiles = new();
+
+ public Dictionary> CachedChunkEntities = new();
+}
\ No newline at end of file
diff --git a/Content.Server/Worldgen/Components/SectorWorldComponent.cs b/Content.Server/Worldgen/Components/SectorWorldComponent.cs
new file mode 100644
index 00000000000..2307544b7c2
--- /dev/null
+++ b/Content.Server/Worldgen/Components/SectorWorldComponent.cs
@@ -0,0 +1,190 @@
+using System.Numerics;
+using Content.Shared.Parallax.Biomes;
+
+namespace Content.Server.Worldgen.Components;
+
+///
+/// Authoritative runtime state for a streamed sector map.
+///
+[RegisterComponent]
+public sealed partial class SectorWorldComponent : Component
+{
+ [DataField]
+ public int UniverseSeed;
+
+ [DataField]
+ public List PlanetTypes = new();
+
+ [DataField]
+ public float MissionReservationRadius = 196f;
+
+ [DataField]
+ public float MissionReservationPadding = 128f;
+
+ [DataField]
+ public float CentralClearRadius = 500f;
+
+ [DataField]
+ [ViewVariables]
+ public EntityUid? SectorGrid;
+
+ [DataField]
+ [ViewVariables]
+ public EntityUid? SpaceMap;
+
+ [DataField]
+ [ViewVariables]
+ public EntityUid? FtlMap;
+
+ [DataField]
+ [ViewVariables]
+ public EntityUid? ColCommMap;
+
+ [DataField]
+ [ViewVariables]
+ public Dictionary PlanetTypeMaps = new();
+
+ [DataField]
+ [ViewVariables]
+ public List Planets = new();
+
+ [DataField]
+ [ViewVariables]
+ public Dictionary Reservations = new();
+
+ [ViewVariables]
+ public List StartupLoaders = new();
+}
+
+[DataDefinition]
+public sealed partial class SectorPlanetTypeDefinition
+{
+ [DataField(required: true)]
+ public string Id = string.Empty;
+
+ [DataField(required: true)]
+ public string Name = string.Empty;
+
+ [DataField(required: true)]
+ public string BiomeTemplate = string.Empty;
+
+ [DataField]
+ public List BiomeAliases = new();
+
+ [DataField(required: true)]
+ public List SurfaceTiles = new();
+
+ [DataField]
+ public float MinRadius = 900f;
+
+ [DataField]
+ public float MaxRadius = 1400f;
+
+ [DataField]
+ public float MinTemperature = 240f;
+
+ [DataField]
+ public float MaxTemperature = 360f;
+
+ [DataField]
+ public float MinOxygen = 0f;
+
+ [DataField]
+ public float MaxOxygen = 24f;
+
+ [DataField]
+ public float MinNitrogen = 0f;
+
+ [DataField]
+ public float MaxNitrogen = 80f;
+
+ [DataField]
+ public float MinCarbonDioxide = 0f;
+
+ [DataField]
+ public float MaxCarbonDioxide = 8f;
+
+ [DataField]
+ public string? WeatherPrototype;
+}
+
+[DataDefinition]
+public sealed partial class SectorPlanetDescriptor
+{
+ [DataField]
+ public string PlanetId = string.Empty;
+
+ [DataField]
+ public string Name = string.Empty;
+
+ [DataField]
+ public string PlanetTypeId = string.Empty;
+
+ [DataField]
+ public string BiomeTemplate = string.Empty;
+
+ [DataField]
+ public string SurfaceTile = "FloorSteel";
+
+ [DataField]
+ public Vector2 Center;
+
+ [DataField]
+ public float Radius;
+
+ [DataField]
+ public int Seed;
+
+ [DataField]
+ public float Temperature;
+
+ [DataField]
+ public float Oxygen;
+
+ [DataField]
+ public float Nitrogen;
+
+ [DataField]
+ public float CarbonDioxide;
+
+ [DataField]
+ public string TimeOfDay = "Dawn";
+
+ [DataField]
+ public string? WeatherPrototype;
+}
+
+[DataDefinition]
+public sealed partial class SectorExpeditionReservation
+{
+ [DataField]
+ public EntityUid ExpeditionUid;
+
+ [DataField]
+ public string PlanetId = string.Empty;
+
+ [DataField]
+ public Vector2 Center;
+
+ [DataField]
+ public float Radius;
+}
+
+[DataDefinition]
+public sealed partial class SectorExpeditionPlacement
+{
+ [DataField]
+ public EntityUid SectorMap;
+
+ [DataField]
+ public string PlanetTypeId = string.Empty;
+
+ [DataField]
+ public Vector2 Center;
+
+ [DataField]
+ public float ReservationRadius;
+
+ [DataField]
+ public SectorPlanetDescriptor Planet = new();
+}
\ No newline at end of file
diff --git a/Content.Server/Worldgen/Components/WorldLoaderComponent.cs b/Content.Server/Worldgen/Components/WorldLoaderComponent.cs
index 7848aac469f..348e92b0d53 100644
--- a/Content.Server/Worldgen/Components/WorldLoaderComponent.cs
+++ b/Content.Server/Worldgen/Components/WorldLoaderComponent.cs
@@ -6,14 +6,15 @@ namespace Content.Server.Worldgen.Components;
/// This is used for allowing some objects to load the game world.
///
[RegisterComponent]
-[Access(typeof(WorldControllerSystem))]
+[Access(typeof(WorldControllerSystem), typeof(SectorWorldSystem))]
public sealed partial class WorldLoaderComponent : Component
{
///
/// The radius in which the loader loads the world.
///
+ [Access(typeof(WorldControllerSystem), typeof(SectorWorldSystem))]
[ViewVariables(VVAccess.ReadWrite)] [DataField("radius")]
- public int Radius = 64;
+ public int Radius = 24;
///
/// Frontier: if true, this loader is disabled, and will not be used
diff --git a/Content.Server/Worldgen/Components/WorldSeedComponent.cs b/Content.Server/Worldgen/Components/WorldSeedComponent.cs
new file mode 100644
index 00000000000..e5743184c76
--- /dev/null
+++ b/Content.Server/Worldgen/Components/WorldSeedComponent.cs
@@ -0,0 +1,14 @@
+namespace Content.Server.Worldgen.Components;
+
+///
+/// Stores the root seed for a streamed world map so chunk noise can be reproduced deterministically.
+///
+[RegisterComponent]
+public sealed partial class WorldSeedComponent : Component
+{
+ ///
+ /// Root seed for this world map. A value of 0 means it has not been initialized yet.
+ ///
+ [DataField]
+ public int Seed;
+}
\ No newline at end of file
diff --git a/Content.Server/Worldgen/Systems/NoiseIndexSystem.cs b/Content.Server/Worldgen/Systems/NoiseIndexSystem.cs
index 5a7e02c803a..9fdfdc4cf5d 100644
--- a/Content.Server/Worldgen/Systems/NoiseIndexSystem.cs
+++ b/Content.Server/Worldgen/Systems/NoiseIndexSystem.cs
@@ -26,11 +26,57 @@ public NoiseGenerator Get(EntityUid holder, string protoId)
if (idx.Generators.TryGetValue(protoId, out var generator))
return generator;
var proto = _prototype.Index(protoId);
- var gen = new NoiseGenerator(proto, _random.Next());
+ var gen = new NoiseGenerator(proto, GetSeed(holder, protoId));
idx.Generators[protoId] = gen;
return gen;
}
+ private int GetSeed(EntityUid holder, string protoId)
+ {
+ if (TryComp(holder, out var chunk))
+ {
+ var worldSeed = GetWorldSeed(chunk.Map);
+ return HashCode.Combine(worldSeed, StableHash(protoId));
+ }
+
+ var xform = Transform(holder);
+ if (xform.MapUid is { } mapUid)
+ {
+ var worldSeed = GetWorldSeed(mapUid);
+ return HashCode.Combine(worldSeed, StableHash(protoId));
+ }
+
+ return HashCode.Combine(_random.Next(), holder.GetHashCode(), StableHash(protoId));
+ }
+
+ private int GetWorldSeed(EntityUid mapUid)
+ {
+ var worldSeed = EnsureComp(mapUid);
+
+ if (worldSeed.Seed == 0)
+ worldSeed.Seed = _random.Next(1, int.MaxValue);
+
+ return worldSeed.Seed;
+ }
+
+ private static int StableHash(string value)
+ {
+ unchecked
+ {
+ const int offsetBasis = unchecked((int) 2166136261);
+ const int prime = 16777619;
+
+ var hash = offsetBasis;
+ foreach (var ch in value)
+ {
+ hash ^= ch;
+ hash *= prime;
+ }
+
+ return hash;
+ }
+ }
+
///
/// Attempts to evaluate the given noise channel using the generator on the given entity.
///
diff --git a/Content.Server/Worldgen/Systems/SectorChunkCarverSystem.cs b/Content.Server/Worldgen/Systems/SectorChunkCarverSystem.cs
new file mode 100644
index 00000000000..a283eab5286
--- /dev/null
+++ b/Content.Server/Worldgen/Systems/SectorChunkCarverSystem.cs
@@ -0,0 +1,699 @@
+using System.Numerics;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Globalization;
+using Robust.Server.GameObjects;
+using Content.Server._NF.RoundNotifications.Events;
+using Content.Server.Worldgen.Components;
+using Content.Server.Worldgen.Tools;
+using Content.Shared.GameTicking;
+using Content.Shared.Maps;
+using Content.Shared.Storage;
+using Content.Shared.Worldgen.Prototypes;
+using Robust.Shared.Maths;
+using Robust.Shared.Map;
+using Robust.Shared.Map.Components;
+using Robust.Shared.Physics;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Random;
+
+namespace Content.Server.Worldgen.Systems;
+
+///
+/// Materializes streamed sector chunk geometry into a single persistent grid on the sector map.
+///
+public sealed class SectorChunkCarverSystem : EntitySystem
+{
+ private static readonly string[] OreSuffixes =
+ [
+ "Coal",
+ "Tin",
+ "Quartz",
+ "Salt",
+ "Gold",
+ "Silver",
+ "Plasma",
+ "Uranium",
+ "Bananium",
+ "ArtifactFragment",
+ "Bluespace",
+ ];
+
+ [Dependency] private readonly SectorWorldSystem _sectorWorld = default!;
+ [Dependency] private readonly SharedMapSystem _mapSystem = default!;
+ [Dependency] private readonly ITileDefinitionManager _tileDefs = default!;
+ [Dependency] private readonly IPrototypeManager _proto = default!;
+ [Dependency] private readonly IMapManager _mapManager = default!;
+ [Dependency] private readonly IRobustRandom _random = default!;
+ [Dependency] private readonly EntityLookupSystem _lookup = default!;
+ [Dependency] private readonly SharedTransformSystem _transform = default!;
+
+ private string _cacheDirectory = string.Empty;
+ private readonly Dictionary> _biomeCaches = new();
+ private bool _roundRestartCleanupActive;
+
+ public override void Initialize()
+ {
+ ResetCacheDirectory();
+
+ SubscribeLocalEvent(OnChunkLoaded);
+ SubscribeLocalEvent(OnChunkUnloaded);
+ SubscribeLocalEvent(OnChunkShutdown);
+ SubscribeLocalEvent(OnRoundRestartCleanup);
+ SubscribeLocalEvent(OnRoundStarted);
+ }
+
+ public override void Shutdown()
+ {
+ base.Shutdown();
+
+ DeleteCacheDirectory();
+ }
+
+ private void OnRoundRestartCleanup(RoundRestartCleanupEvent ev)
+ {
+ _roundRestartCleanupActive = true;
+ ResetAllChunkCachePaths();
+ ResetCacheDirectory();
+ }
+
+ private void OnRoundStarted(RoundStartedEvent ev)
+ {
+ _roundRestartCleanupActive = false;
+ }
+
+ private void ResetAllChunkCachePaths()
+ {
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out _, out var carver))
+ {
+ carver.CacheFilePath = null;
+ }
+ }
+
+ private void ResetCacheDirectory()
+ {
+ DeleteCacheDirectory();
+
+ var cacheRunId = Path.GetFileName(Guid.NewGuid().ToString("N"));
+ _cacheDirectory = Path.Combine(Path.GetTempPath(), "HardLight", "sector-chunk-cache", cacheRunId);
+ Directory.CreateDirectory(_cacheDirectory);
+ }
+
+ private void DeleteCacheDirectory()
+ {
+
+ try
+ {
+ if (!string.IsNullOrWhiteSpace(_cacheDirectory) && Directory.Exists(_cacheDirectory))
+ Directory.Delete(_cacheDirectory, true);
+ }
+ catch (IOException)
+ {
+ // Temp cache cleanup is best-effort.
+ }
+ catch (UnauthorizedAccessException)
+ {
+ // Temp cache cleanup is best-effort.
+ }
+ }
+
+ private void OnChunkLoaded(Entity ent, ref WorldChunkLoadedEvent args)
+ {
+ if (ent.Comp.Materialized)
+ return;
+
+ if (!TryComp(args.Chunk, out var chunk))
+ return;
+
+ if (!TryComp(chunk.Map, out var sector))
+ return;
+
+ if (!_sectorWorld.TryGetSectorGrid(chunk.Map, out var gridUid, sector))
+ return;
+
+ var grid = EnsureComp(gridUid);
+ ent.Comp.MaterializedGrid = gridUid;
+ ent.Comp.GeneratedTiles.Clear();
+ ent.Comp.GeneratedEntities.Clear();
+ var blockedGrids = GetBlockingGrids(chunk.Map, gridUid, chunk);
+ var chunkBiome = GetChunkBiome(ent.Comp, sector, chunk.Coordinates);
+
+ if (!TryComp(chunk.Map, out MapComponent? mapComp) || mapComp == null)
+ return;
+
+ var sectorMapId = mapComp.MapId;
+ var chunkOrigin = chunk.Coordinates * WorldGen.ChunkSize;
+
+ if (TryRestoreChunkFromCache((ent.Owner, ent.Comp), chunk, gridUid, grid, chunkBiome))
+ {
+ _sectorWorld.RestoreHostedChunkContent(gridUid, grid, chunkOrigin, WorldGen.ChunkSize);
+ ent.Comp.Materialized = true;
+ return;
+ }
+
+ var tiles = new List<(Vector2i, Tile)>(WorldGen.ChunkSize * WorldGen.ChunkSize / 3);
+
+ for (var x = 0; x < WorldGen.ChunkSize; x++)
+ {
+ for (var y = 0; y < WorldGen.ChunkSize; y++)
+ {
+ var indices = chunkOrigin + new Vector2i(x, y);
+ var worldPos = indices + new Vector2(0.5f, 0.5f);
+
+ if (IsBlockedByOtherGrid(worldPos, sectorMapId, blockedGrids))
+ continue;
+
+ if (!_sectorWorld.IsSolidAt(chunk.Map, ent.Owner, ent.Comp, worldPos, out _))
+ continue;
+
+ var tileId = GetChunkFloorTileId(chunkBiome, sector, indices);
+
+ var tileDef = (ContentTileDefinition) _tileDefs[tileId];
+ tiles.Add((indices, new Tile(tileDef.TileId)));
+ ent.Comp.GeneratedTiles.Add(indices);
+ }
+ }
+
+ if (tiles.Count > 0)
+ _mapSystem.SetTiles(gridUid, grid, tiles);
+
+ SpawnChunkEntities((ent.Owner, ent.Comp), gridUid, grid, chunkBiome);
+ _sectorWorld.RestoreHostedChunkContent(gridUid, grid, chunkOrigin, WorldGen.ChunkSize);
+
+ ent.Comp.Materialized = true;
+ }
+
+ private void OnChunkUnloaded(Entity ent, ref WorldChunkUnloadedEvent args)
+ {
+ if (_roundRestartCleanupActive)
+ return;
+
+ if (!ent.Comp.Materialized || ent.Comp.GeneratedTiles.Count == 0)
+ return;
+
+ if (!TryComp(args.Chunk, out var chunk))
+ return;
+
+ if (!TryComp(chunk.Map, out var sector))
+ return;
+
+ if (!_sectorWorld.TryGetSectorGrid(chunk.Map, out var gridUid, sector))
+ return;
+
+ var grid = EnsureComp(gridUid);
+ var chunkOrigin = chunk.Coordinates * WorldGen.ChunkSize;
+
+ _sectorWorld.SaveHostedChunkContent(gridUid, grid, chunkOrigin, WorldGen.ChunkSize);
+
+ SaveChunkToCache((ent.Owner, ent.Comp), gridUid, grid, chunk);
+
+ foreach (var generated in ent.Comp.GeneratedEntities)
+ {
+ if (Exists(generated))
+ QueueDel(generated);
+ }
+
+ ent.Comp.GeneratedEntities.Clear();
+
+ var tiles = new List<(Vector2i, Tile)>(ent.Comp.GeneratedTiles.Count);
+ foreach (var indices in ent.Comp.GeneratedTiles)
+ {
+ tiles.Add((indices, Tile.Empty));
+ }
+
+ _mapSystem.SetTiles(gridUid, grid, tiles);
+ ent.Comp.GeneratedTiles.Clear();
+ ent.Comp.Materialized = false;
+ ent.Comp.MaterializedGrid = EntityUid.Invalid;
+ }
+
+ private void OnChunkShutdown(Entity ent, ref ComponentShutdown args)
+ {
+ if (_roundRestartCleanupActive)
+ return;
+
+ if (!ent.Comp.Materialized || ent.Comp.GeneratedTiles.Count == 0)
+ return;
+
+ if (!TryComp(ent.Owner, out var chunk))
+ return;
+
+ if (ent.Comp.MaterializedGrid == EntityUid.Invalid || !TryComp(ent.Comp.MaterializedGrid, out MapGridComponent? grid))
+ return;
+
+ SaveChunkToCache(ent, ent.Comp.MaterializedGrid, grid, chunk);
+ }
+
+ private void SaveChunkToCache(Entity ent, EntityUid gridUid, MapGridComponent grid, WorldChunkComponent chunk)
+ {
+ var cachePath = GetCachePath(ent.Owner, chunk);
+ ent.Comp.CacheFilePath = cachePath;
+
+ Directory.CreateDirectory(Path.GetDirectoryName(cachePath)!);
+
+ var builder = new StringBuilder();
+ builder.AppendLine("v3");
+
+ foreach (var indices in ent.Comp.GeneratedTiles)
+ {
+ var tileRef = _mapSystem.GetTileRef(gridUid, grid, indices);
+ if (tileRef.Tile.IsEmpty)
+ continue;
+
+ var tileId = tileRef.Tile.GetContentTileDefinition(_tileDefs).ID;
+ builder.Append('t')
+ .Append(',')
+ .Append(indices.X)
+ .Append(',')
+ .Append(indices.Y)
+ .Append(',')
+ .Append(tileId)
+ .AppendLine();
+ }
+
+ foreach (var generated in ent.Comp.GeneratedEntities)
+ {
+ if (!Exists(generated))
+ continue;
+
+ var meta = MetaData(generated);
+ if (meta.EntityPrototype == null || meta.EntityLifeStage >= EntityLifeStage.Terminating)
+ continue;
+
+ var xform = Transform(generated);
+ if (xform.GridUid != gridUid)
+ continue;
+
+ builder.Append('e')
+ .Append(',')
+ .Append(xform.Coordinates.Position.X.ToString(CultureInfo.InvariantCulture))
+ .Append(',')
+ .Append(xform.Coordinates.Position.Y.ToString(CultureInfo.InvariantCulture))
+ .Append(',')
+ .Append(meta.EntityPrototype.ID)
+ .Append(',')
+ .Append(xform.LocalRotation.Theta.ToString(CultureInfo.InvariantCulture))
+ .Append(',')
+ .Append(xform.Anchored)
+ .AppendLine();
+ }
+
+ File.WriteAllText(cachePath, builder.ToString());
+ }
+
+ private bool TryRestoreChunkFromCache(Entity ent, WorldChunkComponent chunk, EntityUid gridUid, MapGridComponent grid, SectorAsteroidBiomePrototype? chunkBiome)
+ {
+ var cachePath = ent.Comp.CacheFilePath ?? GetCachePath(ent.Owner, chunk);
+ ent.Comp.CacheFilePath = cachePath;
+
+ if (!File.Exists(cachePath))
+ return false;
+
+ var tilePlacements = new List<(Vector2i, Tile)>();
+ var entityPlacements = new List();
+ var cacheVersion = 1;
+
+ foreach (var line in File.ReadLines(cachePath))
+ {
+ if (string.IsNullOrWhiteSpace(line))
+ continue;
+
+ if (line is "v1" or "v2" or "v3")
+ {
+ cacheVersion = line[1] - '0';
+ continue;
+ }
+
+ var parts = line.Split(',');
+
+ if (parts.Length == 3)
+ {
+ RestoreCachedTile(parts[0], parts[1], parts[2], tilePlacements, ent.Comp.GeneratedTiles);
+ continue;
+ }
+
+ switch (parts[0])
+ {
+ case "t":
+ if (parts.Length != 4)
+ continue;
+
+ RestoreCachedTile(parts[1], parts[2], parts[3], tilePlacements, ent.Comp.GeneratedTiles);
+ break;
+ case "e":
+ if (cacheVersion >= 3)
+ {
+ if (parts.Length != 6
+ || !float.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var entityPosX)
+ || !float.TryParse(parts[2], NumberStyles.Float, CultureInfo.InvariantCulture, out var entityPosY)
+ || !float.TryParse(parts[4], NumberStyles.Float, CultureInfo.InvariantCulture, out var entityRotation)
+ || !bool.TryParse(parts[5], out var entityAnchored))
+ {
+ continue;
+ }
+
+ entityPlacements.Add(new ChunkEntityMutationRecord(
+ new Vector2(entityPosX, entityPosY),
+ parts[3],
+ entityRotation,
+ entityAnchored));
+ continue;
+ }
+
+ if (parts.Length != 4
+ || !int.TryParse(parts[1], out var entityX)
+ || !int.TryParse(parts[2], out var entityY))
+ {
+ continue;
+ }
+
+ entityPlacements.Add(new ChunkEntityMutationRecord(
+ new Vector2(entityX + 0.5f, entityY + 0.5f),
+ parts[3],
+ 0f,
+ ChunkEntityMutationRules.ShouldAnchor(parts[3])));
+ break;
+ }
+ }
+
+ var authoritativeSnapshot = cacheVersion >= 3;
+ if (!authoritativeSnapshot && tilePlacements.Count == 0)
+ return false;
+
+ if (tilePlacements.Count > 0)
+ _mapSystem.SetTiles(gridUid, grid, tilePlacements);
+
+ if (entityPlacements.Count > 0)
+ {
+ foreach (var entityPlacement in entityPlacements.Where(ep => _proto.HasIndex(ep.PrototypeId)))
+ {
+ var indices = _mapSystem.TileIndicesFor(gridUid, grid, new EntityCoordinates(gridUid, entityPlacement.LocalPosition));
+ ClearChunkMaterialEntitiesAtTile((ent.Owner, ent.Comp), gridUid, grid, indices);
+ SpawnTrackedChunkEntity((ent.Owner, ent.Comp), gridUid, grid, entityPlacement.LocalPosition, entityPlacement.PrototypeId, new Angle(entityPlacement.Rotation), entityPlacement.Anchored);
+ }
+ }
+ else if (!authoritativeSnapshot)
+ {
+ SpawnChunkEntities((ent.Owner, ent.Comp), gridUid, grid, chunkBiome);
+ }
+
+ return true;
+ }
+
+ private void RestoreCachedTile(string xText, string yText, string tileId, List<(Vector2i, Tile)> tilePlacements, HashSet generatedTiles)
+ {
+ if (!int.TryParse(xText, out var x) || !int.TryParse(yText, out var y))
+ return;
+
+ if (!_tileDefs.TryGetDefinition(tileId, out var tileDefBase) || tileDefBase is not ContentTileDefinition tileDef)
+ return;
+
+ var indices = new Vector2i(x, y);
+ tilePlacements.Add((indices, new Tile(tileDef.TileId)));
+ generatedTiles.Add(indices);
+ }
+
+ private string GetCachePath(EntityUid chunkUid, WorldChunkComponent chunk)
+ {
+ return Path.Join(_cacheDirectory, $"chunk_{chunkUid}_{chunk.Coordinates.X}_{chunk.Coordinates.Y}.cache");
+ }
+
+ private SectorAsteroidBiomePrototype? GetChunkBiome(SectorChunkCarverComponent carver, SectorWorldComponent sector, Vector2i chunkCoords)
+ {
+ if (carver.Biomes.Count == 0)
+ return null;
+
+ var index = Math.Abs(HashCode.Combine(sector.UniverseSeed, chunkCoords.X, chunkCoords.Y) % carver.Biomes.Count);
+ var biomeId = carver.Biomes[index];
+ return _proto.TryIndex(biomeId, out var biome) ? biome : null;
+ }
+
+ private string GetChunkFloorTileId(SectorAsteroidBiomePrototype? biome, SectorWorldComponent sector, Vector2i indices)
+ {
+ if (biome == null || biome.FloorTiles.Count == 0)
+ return "FloorSteel";
+
+ var index = Math.Abs(HashCode.Combine(sector.UniverseSeed, indices.X, indices.Y) % biome.FloorTiles.Count);
+ return biome.FloorTiles[index];
+ }
+
+ private void SpawnChunkEntities(Entity ent, EntityUid gridUid, MapGridComponent grid, SectorAsteroidBiomePrototype? biome)
+ {
+ var spawns = new List(4);
+
+ foreach (var indices in ent.Comp.GeneratedTiles)
+ {
+ var tile = _mapSystem.GetTileRef(gridUid, grid, indices);
+ if (tile.Tile.IsEmpty)
+ continue;
+
+ var tileId = tile.Tile.GetContentTileDefinition(_tileDefs).ID;
+ var handledByBiome = false;
+ ClearChunkMaterialEntitiesAtTile(ent, gridUid, grid, indices);
+
+ var biomeCache = GetBiomeCache(biome);
+ if (biomeCache != null && biomeCache.TryGetValue(tileId, out var cache))
+ {
+ handledByBiome = true;
+ spawns.Clear();
+ cache.GetSpawns(_random, ref spawns);
+
+ foreach (var prototype in spawns)
+ {
+ if (prototype is not { } prototypeId || !_proto.HasIndex(prototypeId))
+ continue;
+
+ SpawnTrackedTileEntity(ent, gridUid, grid, indices, prototypeId);
+ }
+ }
+
+ if (handledByBiome)
+ continue;
+
+ if (!TryGetPlanetWallPrototype(gridUid, grid, indices, out var wallPrototype))
+ continue;
+
+ SpawnTrackedTileEntity(ent, gridUid, grid, indices, wallPrototype);
+ }
+ }
+
+ private void ClearChunkMaterialEntitiesAtTile(Entity ent, EntityUid gridUid, MapGridComponent grid, Vector2i indices)
+ {
+ var tileRef = _mapSystem.GetTileRef(gridUid, grid, indices);
+
+ foreach (var entity in _lookup.GetLocalEntitiesIntersecting(tileRef, flags: LookupFlags.Dynamic | LookupFlags.Static | LookupFlags.StaticSundries | LookupFlags.Sundries | LookupFlags.Approximate))
+ {
+ if (entity == gridUid)
+ continue;
+
+ var meta = MetaData(entity);
+ if (meta.EntityPrototype == null)
+ continue;
+
+ if (!IsChunkMaterialPrototype(meta.EntityPrototype.ID))
+ continue;
+
+ ent.Comp.GeneratedEntities.Remove(entity);
+
+ if (Exists(entity))
+ QueueDel(entity);
+ }
+ }
+
+ private void SpawnTrackedTileEntity(Entity ent, EntityUid gridUid, MapGridComponent grid, Vector2i indices, string prototypeId)
+ {
+ SpawnTrackedChunkEntity(ent, gridUid, grid, indices + new Vector2(0.5f, 0.5f), prototypeId);
+ }
+
+ private void SpawnTrackedChunkEntity(
+ Entity ent,
+ EntityUid gridUid,
+ MapGridComponent grid,
+ Vector2 localPosition,
+ string prototypeId,
+ Angle? rotation = null,
+ bool? anchored = null)
+ {
+ var coordinates = new EntityCoordinates(gridUid, localPosition);
+ var indices = _mapSystem.TileIndicesFor(gridUid, grid, coordinates);
+ var before = GetTileEntities(gridUid, grid, indices);
+ var spawned = Spawn(prototypeId, _transform.ToMapCoordinates(coordinates));
+ _transform.SetCoordinates(spawned, coordinates);
+ if (rotation != null)
+ _transform.SetLocalRotation(spawned, rotation.Value);
+
+ var after = GetTileEntities(gridUid, grid, indices);
+
+ foreach (var entity in after.Where(entity => !before.Contains(entity)))
+ {
+ var meta = MetaData(entity);
+ if (meta.EntityPrototype == null)
+ continue;
+
+ if (IsTransientChunkSpawnerPrototype(meta.EntityPrototype.ID))
+ {
+ if (Exists(entity))
+ QueueDel(entity);
+
+ continue;
+ }
+
+ ApplyChunkEntityAnchoring(entity, meta.EntityPrototype.ID, anchored);
+
+ ent.Comp.GeneratedEntities.Add(entity);
+ }
+
+ if (!ent.Comp.GeneratedEntities.Contains(spawned) && Exists(spawned))
+ {
+ var spawnedMeta = MetaData(spawned);
+ if (spawnedMeta.EntityPrototype != null)
+ {
+ if (IsTransientChunkSpawnerPrototype(spawnedMeta.EntityPrototype.ID))
+ {
+ QueueDel(spawned);
+ return;
+ }
+
+ ApplyChunkEntityAnchoring(spawned, spawnedMeta.EntityPrototype.ID, anchored);
+ }
+
+ ent.Comp.GeneratedEntities.Add(spawned);
+ }
+ }
+
+ private HashSet GetTileEntities(EntityUid gridUid, MapGridComponent grid, Vector2i indices)
+ {
+ var tileRef = _mapSystem.GetTileRef(gridUid, grid, indices);
+ return _lookup.GetLocalEntitiesIntersecting(tileRef, flags: LookupFlags.Dynamic | LookupFlags.Static | LookupFlags.StaticSundries | LookupFlags.Sundries | LookupFlags.Approximate).ToHashSet();
+ }
+
+ private void ApplyChunkEntityAnchoring(EntityUid entity, string prototypeId, bool? anchored)
+ {
+ var xform = Transform(entity);
+ var shouldAnchor = anchored ?? ChunkEntityMutationRules.ShouldAnchor(prototypeId);
+
+ if (shouldAnchor)
+ {
+ if (!xform.Anchored)
+ _transform.AnchorEntity(entity, xform);
+
+ return;
+ }
+
+ if (xform.Anchored)
+ _transform.Unanchor(entity, xform);
+ }
+
+ private static bool IsTransientChunkSpawnerPrototype(string prototypeId)
+ {
+ return prototypeId.Contains("Mineral", StringComparison.OrdinalIgnoreCase)
+ || prototypeId.EndsWith("RoomMarker", StringComparison.OrdinalIgnoreCase)
+ || prototypeId.EndsWith("Spawner", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool IsChunkMaterialPrototype(string prototypeId)
+ {
+ return prototypeId.StartsWith("Wall", StringComparison.OrdinalIgnoreCase)
+ || prototypeId.StartsWith("NFWall", StringComparison.OrdinalIgnoreCase)
+ || prototypeId.EndsWith("RoomMarker", StringComparison.OrdinalIgnoreCase)
+ || prototypeId.Contains("Mineral", StringComparison.OrdinalIgnoreCase)
+ || prototypeId.EndsWith("Spawner", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(prototypeId, "Grille", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private Dictionary? GetBiomeCache(SectorAsteroidBiomePrototype? biome)
+ {
+ if (biome == null)
+ return null;
+
+ if (_biomeCaches.TryGetValue(biome.ID, out var cache))
+ return cache;
+
+ cache = biome.Entries.ToDictionary(pair => pair.Key, pair => new EntitySpawnCollectionCache(pair.Value));
+ _biomeCaches[biome.ID] = cache;
+ return cache;
+ }
+
+ private List> GetBlockingGrids(EntityUid sectorMap, EntityUid sectorGridUid, WorldChunkComponent chunk)
+ {
+ var results = new List>();
+
+ if (!TryComp(sectorMap, out var mapComp))
+ return results;
+
+ var chunkOrigin = chunk.Coordinates * WorldGen.ChunkSize;
+ var worldBounds = Box2.FromDimensions(chunkOrigin, new Vector2(WorldGen.ChunkSize, WorldGen.ChunkSize));
+ _mapManager.FindGridsIntersecting(mapComp.MapId, worldBounds, ref results, includeMap: false);
+ results.RemoveAll(grid => grid.Owner == sectorGridUid);
+ return results;
+ }
+
+ private bool IsBlockedByOtherGrid(Vector2 worldPos, MapId mapId, List> blockedGrids)
+ {
+ if (blockedGrids.Count == 0)
+ return false;
+
+ var coords = new MapCoordinates(worldPos, mapId);
+ return blockedGrids.Any(grid => !_mapSystem.GetTileRef(grid.Owner, grid.Comp, coords).Tile.IsEmpty);
+ }
+
+ private bool TryGetPlanetWallPrototype(EntityUid gridUid, MapGridComponent grid, Vector2i indices, out string prototype)
+ {
+ prototype = string.Empty;
+
+ var tile = _mapSystem.GetTileRef(gridUid, grid, indices);
+ if (tile.Tile.IsEmpty)
+ return false;
+
+ var tileId = tile.Tile.GetContentTileDefinition(_tileDefs).ID;
+ var baseWall = GetBaseWallPrototype(tileId);
+ if (baseWall == null)
+ return false;
+
+ var hash = HashCode.Combine(tileId, indices.X, indices.Y);
+ if ((hash & 0xF) < 12)
+ {
+ prototype = baseWall;
+ return _proto.HasIndex(prototype);
+ }
+
+ var suffix = OreSuffixes[Math.Abs(hash) % OreSuffixes.Length];
+ var oreWall = $"{baseWall}{suffix}";
+ if (_proto.HasIndex(oreWall))
+ {
+ prototype = oreWall;
+ return true;
+ }
+
+ prototype = baseWall;
+ return _proto.HasIndex(prototype);
+ }
+
+ private static string? GetBaseWallPrototype(string tileId)
+ {
+ if (tileId.Contains("Basalt", StringComparison.OrdinalIgnoreCase))
+ return "NFWallBasaltCobblebrick";
+
+ if (tileId.Contains("Chromite", StringComparison.OrdinalIgnoreCase))
+ return "NFWallChromiteCobblebrick";
+
+ if (tileId.Contains("Andesite", StringComparison.OrdinalIgnoreCase) || tileId.Contains("Drought", StringComparison.OrdinalIgnoreCase))
+ return "NFWallAndesiteCobblebrick";
+
+ if (tileId.Contains("Snow", StringComparison.OrdinalIgnoreCase))
+ return "NFWallSnowCobblebrick";
+
+ if (tileId.Contains("Ice", StringComparison.OrdinalIgnoreCase))
+ return "NFWallIce";
+
+ if (tileId.Contains("Sand", StringComparison.OrdinalIgnoreCase))
+ return "NFWallSandCobblebrick";
+
+ if (tileId.Contains("Asteroid", StringComparison.OrdinalIgnoreCase))
+ return "NFWallAsteroidCobblebrick";
+
+ return "NFWallCobblebrick";
+ }
+}
\ No newline at end of file
diff --git a/Content.Server/Worldgen/Systems/SectorWorldSystem.cs b/Content.Server/Worldgen/Systems/SectorWorldSystem.cs
new file mode 100644
index 00000000000..7fd8ecfee4e
--- /dev/null
+++ b/Content.Server/Worldgen/Systems/SectorWorldSystem.cs
@@ -0,0 +1,1091 @@
+using System.Numerics;
+using System.Linq;
+using Content.Server._Mono.Cleanup;
+using Content.Server._NF.RoundNotifications.Events;
+using Content.Server._NF.Shuttles.Components;
+using Content.Server.Atmos.EntitySystems;
+using Content.Server.GameTicking;
+using Content.Server.Parallax;
+using Content.Server.Shuttles.Components;
+using Content.Server.Shuttles.Systems;
+using Content.Server.Weather;
+using Content.Server.Worldgen.Components;
+using Content.Shared.GameTicking;
+using Content.Shared.Atmos;
+using Content.Shared.Gravity;
+using Content.Shared.Light.Components;
+using Content.Shared.Maps;
+using Content.Shared.Parallax.Biomes;
+using Content.Shared.Shuttles.Components;
+using Content.Shared.Weather;
+using Robust.Server.GameObjects;
+using Robust.Shared.Map;
+using Robust.Shared.Map.Components;
+using Robust.Shared.Maths;
+using Robust.Shared.Physics;
+using Robust.Shared.Physics.Components;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Random;
+using Robust.Shared.Utility;
+using Content.Server.Worldgen;
+
+namespace Content.Server.Worldgen.Systems;
+
+///
+/// Owns streamed sector metadata, deterministic planet descriptors, and expedition site reservations.
+///
+public sealed class SectorWorldSystem : EntitySystem
+{
+ [Dependency] private readonly GameTicker _gameTicker = default!;
+ [Dependency] private readonly IMapManager _mapManager = default!;
+ [Dependency] private readonly NoiseIndexSystem _noiseIndex = default!;
+ [Dependency] private readonly IPrototypeManager _proto = default!;
+ [Dependency] private readonly IRobustRandom _random = default!;
+ [Dependency] private readonly AtmosphereSystem _atmosphere = default!;
+ [Dependency] private readonly BiomeSystem _biome = default!;
+ [Dependency] private readonly MetaDataSystem _metaData = default!;
+ [Dependency] private readonly SharedMapSystem _mapSystem = default!;
+ [Dependency] private readonly PhysicsSystem _physics = default!;
+ [Dependency] private readonly ShuttleSystem _shuttle = default!;
+ [Dependency] private readonly ITileDefinitionManager _tileDefs = default!;
+ [Dependency] private readonly SharedTransformSystem _transform = default!;
+ [Dependency] private readonly WeatherSystem _weather = default!;
+ [Dependency] private readonly WorldControllerSystem _worldController = default!;
+
+ private static readonly string[] TimeOfDayStates = ["Dawn", "Day", "Dusk", "Night"];
+ private bool _roundRestartCleanupActive;
+
+ public override void Initialize()
+ {
+ SubscribeLocalEvent(OnSectorStartup);
+ SubscribeLocalEvent(OnExpeditionSiteShutdown);
+ SubscribeLocalEvent(OnRoundRestartCleanup);
+ SubscribeLocalEvent(OnRoundStarted);
+ }
+
+ private void OnRoundRestartCleanup(RoundRestartCleanupEvent ev)
+ {
+ _roundRestartCleanupActive = true;
+ CleanupHostedSiteCachesForRoundRestart();
+ CleanupLayerMapsForRoundRestart();
+ }
+
+ private void OnRoundStarted(RoundStartedEvent ev)
+ {
+ _roundRestartCleanupActive = false;
+ }
+
+ private void OnSectorStartup(Entity ent, ref ComponentStartup args)
+ {
+ EnsureInitialized(ent);
+ }
+
+ private void OnExpeditionSiteShutdown(Entity ent, ref ComponentShutdown args)
+ {
+ CleanupHostedSite(ent);
+
+ if (!TryComp(ent.Comp.SectorMap, out SectorWorldComponent? sector))
+ return;
+
+ sector.Reservations.Remove(ent.Owner);
+ }
+
+ public void CaptureHostedSiteBaseline(Entity ent, EntityUid hostGridUid, MapGridComponent grid, Vector2 center, float radius)
+ {
+ ent.Comp.HostGridUid = hostGridUid;
+ ent.Comp.ContentRadius = radius;
+ ent.Comp.OriginalTiles.Clear();
+ ent.Comp.OriginalEntities.Clear();
+ ent.Comp.GeneratedEntities.Clear();
+ ent.Comp.CachedChunkTiles.Clear();
+ ent.Comp.CachedChunkEntities.Clear();
+
+ foreach (var tile in _mapSystem.GetTilesIntersecting(hostGridUid, grid, new Circle(center, radius), false))
+ {
+ ent.Comp.OriginalTiles[tile.GridIndices] = tile.Tile;
+ }
+
+ if (!TryGetHostedSiteMapUid(hostGridUid, out var mapUid))
+ return;
+
+ var radiusSquared = radius * radius;
+ var query = AllEntityQuery();
+
+ while (query.MoveNext(out var uid, out var xform))
+ {
+ if (uid == ent.Owner || uid == hostGridUid || uid == mapUid)
+ continue;
+
+ if (xform.MapUid != mapUid)
+ continue;
+
+ if ((xform.Coordinates.Position - center).LengthSquared() > radiusSquared)
+ continue;
+
+ ent.Comp.OriginalEntities.Add(uid);
+ }
+ }
+
+ public void CaptureHostedSiteGeneratedEntities(Entity ent, EntityUid hostMapUid, Vector2 center, float radius)
+ {
+ ent.Comp.GeneratedEntities.Clear();
+
+ var radiusSquared = radius * radius;
+ var query = AllEntityQuery();
+
+ while (query.MoveNext(out var uid, out var xform))
+ {
+ if (uid == ent.Owner || uid == ent.Comp.HostGridUid || uid == hostMapUid || ent.Comp.OriginalEntities.Contains(uid))
+ continue;
+
+ if (xform.MapUid != hostMapUid)
+ continue;
+
+ if ((xform.Coordinates.Position - center).LengthSquared() > radiusSquared)
+ continue;
+
+ ent.Comp.GeneratedEntities.Add(uid);
+ }
+ }
+
+ public void SaveHostedChunkContent(EntityUid gridUid, MapGridComponent grid, Vector2i chunkOrigin, int chunkSize)
+ {
+ if (_roundRestartCleanupActive)
+ return;
+
+ var query = EntityQueryEnumerator();
+
+ while (query.MoveNext(out var uid, out var site))
+ {
+ if (site.HostGridUid != gridUid)
+ continue;
+
+ if (!DoesSiteIntersectChunk(site, chunkOrigin, chunkSize))
+ continue;
+
+ SaveHostedChunkContent((uid, site), gridUid, grid, chunkOrigin, chunkSize);
+ }
+ }
+
+ public void RestoreHostedChunkContent(EntityUid gridUid, MapGridComponent grid, Vector2i chunkOrigin, int chunkSize)
+ {
+ var query = EntityQueryEnumerator();
+
+ while (query.MoveNext(out var uid, out var site))
+ {
+ if (site.HostGridUid != gridUid)
+ continue;
+
+ if (!site.CachedChunkTiles.ContainsKey(chunkOrigin) && !site.CachedChunkEntities.ContainsKey(chunkOrigin))
+ continue;
+
+ if (!DoesSiteIntersectChunk(site, chunkOrigin, chunkSize))
+ continue;
+
+ RestoreHostedChunkContent((uid, site), gridUid, grid, chunkOrigin);
+ }
+ }
+
+ public void CleanupHostedSite(Entity ent)
+ {
+ var liveEntities = CollectHostedSiteEntities(ent.Comp);
+
+ foreach (var generated in ent.Comp.GeneratedEntities.ToArray())
+ {
+ liveEntities.Add(generated);
+ }
+
+ foreach (var generated in liveEntities)
+ {
+ if (Exists(generated))
+ QueueDel(generated);
+ }
+
+ ent.Comp.GeneratedEntities.Clear();
+
+ if (ent.Comp.HostGridUid != EntityUid.Invalid
+ && ent.Comp.OriginalTiles.Count > 0
+ && TryComp(ent.Comp.HostGridUid, out var grid))
+ {
+ var tiles = new List<(Vector2i, Tile)>(ent.Comp.OriginalTiles.Count);
+ foreach (var (indices, tile) in ent.Comp.OriginalTiles)
+ {
+ tiles.Add((indices, tile));
+ }
+
+ _mapSystem.SetTiles(ent.Comp.HostGridUid, grid, tiles);
+ }
+
+ ent.Comp.OriginalTiles.Clear();
+ ent.Comp.OriginalEntities.Clear();
+ ent.Comp.CachedChunkTiles.Clear();
+ ent.Comp.CachedChunkEntities.Clear();
+ }
+
+ public void CleanupHostedSite(EntityUid uid, SectorExpeditionSiteComponent component)
+ {
+ CleanupHostedSite((uid, component));
+ }
+
+ private void CleanupHostedSiteCachesForRoundRestart()
+ {
+ var siteQuery = EntityQueryEnumerator();
+ while (siteQuery.MoveNext(out var uid, out var site))
+ {
+ CleanupHostedSite((uid, site));
+ }
+ }
+
+ private void CleanupLayerMapsForRoundRestart()
+ {
+ var defaultMapUid = _mapSystem.GetMapOrInvalid(_gameTicker.DefaultMap);
+ var sectorQuery = EntityQueryEnumerator();
+
+ while (sectorQuery.MoveNext(out var sectorUid, out var sector))
+ {
+ foreach (var loader in sector.StartupLoaders.ToArray())
+ {
+ if (Exists(loader))
+ QueueDel(loader);
+ }
+
+ sector.StartupLoaders.Clear();
+ sector.Reservations.Clear();
+
+ foreach (var mapUid in sector.PlanetTypeMaps.Values.Distinct().ToArray())
+ {
+ DeleteLayerMapForRoundRestart(sectorUid, defaultMapUid, mapUid);
+ }
+
+ sector.PlanetTypeMaps.Clear();
+
+ if (sector.FtlMap is { } ftlMap)
+ DeleteLayerMapForRoundRestart(sectorUid, defaultMapUid, ftlMap);
+
+ if (sector.ColCommMap is { } colCommMap)
+ DeleteLayerMapForRoundRestart(sectorUid, defaultMapUid, colCommMap);
+
+ sector.FtlMap = null;
+ sector.ColCommMap = null;
+ sector.SpaceMap = sectorUid;
+ }
+ }
+
+ private void DeleteLayerMapForRoundRestart(EntityUid sectorUid, EntityUid defaultMapUid, EntityUid mapUid)
+ {
+ if (!Exists(mapUid) || mapUid == sectorUid || mapUid == defaultMapUid)
+ return;
+
+ if (!TryComp(mapUid, out var mapComp))
+ return;
+
+ if (mapComp.MapId == _gameTicker.DefaultMap || !_mapSystem.MapExists(mapComp.MapId))
+ return;
+
+ _mapSystem.DeleteMap(mapComp.MapId);
+ }
+
+ private void SaveHostedChunkContent(Entity ent, EntityUid gridUid, MapGridComponent grid, Vector2i chunkOrigin, int chunkSize)
+ {
+ var cachedTiles = new Dictionary();
+
+ for (var x = 0; x < chunkSize; x++)
+ {
+ for (var y = 0; y < chunkSize; y++)
+ {
+ var indices = new Vector2i(chunkOrigin.X + x, chunkOrigin.Y + y);
+
+ if (!IsTileWithinHostedSite(ent.Comp, indices))
+ continue;
+
+ if (!_mapSystem.TryGetTileRef(gridUid, grid, indices, out var tileRef))
+ continue;
+
+ cachedTiles[indices] = tileRef.Tile;
+ }
+ }
+
+ ent.Comp.CachedChunkTiles[chunkOrigin] = cachedTiles;
+
+ if (TryGetHostedSiteMapUid(gridUid, out var mapUid))
+ {
+ var generated = CollectHostedChunkEntities(ent.Comp, mapUid, gridUid, grid, chunkOrigin, chunkSize);
+ var cachedEntities = new List(generated.Count);
+
+ foreach (var entity in generated)
+ {
+ if (!Exists(entity))
+ continue;
+
+ var meta = MetaData(entity);
+ if (meta.EntityPrototype == null || meta.EntityLifeStage >= EntityLifeStage.Terminating)
+ continue;
+
+ var xform = Transform(entity);
+ if (xform.GridUid != gridUid)
+ continue;
+
+ cachedEntities.Add(new ChunkEntityMutationRecord(
+ xform.Coordinates.Position,
+ meta.EntityPrototype.ID,
+ xform.LocalRotation.Theta,
+ xform.Anchored));
+ }
+
+ ent.Comp.CachedChunkEntities[chunkOrigin] = cachedEntities;
+
+ foreach (var entity in generated)
+ {
+ ent.Comp.GeneratedEntities.Remove(entity);
+ if (Exists(entity))
+ QueueDel(entity);
+ }
+ }
+ else
+ {
+ ent.Comp.CachedChunkEntities[chunkOrigin] = new List();
+ }
+
+ if (cachedTiles.Count == 0)
+ return;
+
+ var restoreTiles = new List<(Vector2i, Tile)>(cachedTiles.Count);
+ foreach (var indices in cachedTiles.Keys)
+ {
+ restoreTiles.Add((indices, ent.Comp.OriginalTiles.TryGetValue(indices, out var original) ? original : Tile.Empty));
+ }
+
+ _mapSystem.SetTiles(gridUid, grid, restoreTiles);
+ }
+
+ private void RestoreHostedChunkContent(Entity ent, EntityUid gridUid, MapGridComponent grid, Vector2i chunkOrigin)
+ {
+ if (ent.Comp.CachedChunkTiles.Remove(chunkOrigin, out var tiles))
+ {
+ var restoreTiles = new List<(Vector2i, Tile)>(tiles.Count);
+ foreach (var (indices, tile) in tiles)
+ {
+ restoreTiles.Add((indices, tile));
+ }
+
+ _mapSystem.SetTiles(gridUid, grid, restoreTiles);
+ }
+
+ if (!ent.Comp.CachedChunkEntities.Remove(chunkOrigin, out var cachedEntities))
+ return;
+
+ foreach (var record in cachedEntities)
+ {
+ if (!_proto.HasIndex(record.PrototypeId))
+ continue;
+
+ var coordinates = new EntityCoordinates(gridUid, record.LocalPosition);
+ var entity = Spawn(record.PrototypeId, _transform.ToMapCoordinates(coordinates));
+ _transform.SetCoordinates(entity, coordinates);
+ var xform = Transform(entity);
+ _transform.SetLocalRotation(entity, new Angle(record.Rotation), xform);
+
+ if (record.Anchored)
+ _transform.AnchorEntity(entity, xform);
+ else if (xform.Anchored)
+ _transform.Unanchor(entity, xform);
+
+ ent.Comp.GeneratedEntities.Add(entity);
+ }
+ }
+
+ private HashSet CollectHostedSiteEntities(SectorExpeditionSiteComponent site)
+ {
+ var entities = new HashSet();
+
+ if (site.HostGridUid == EntityUid.Invalid || !TryGetHostedSiteMapUid(site.HostGridUid, out var mapUid))
+ return entities;
+
+ var radius = GetHostedSiteRadius(site);
+ var radiusSquared = radius * radius;
+ var query = AllEntityQuery();
+
+ while (query.MoveNext(out var uid, out var xform))
+ {
+ if (uid == site.HostGridUid || uid == mapUid || site.OriginalEntities.Contains(uid))
+ continue;
+
+ if (xform.MapUid != mapUid)
+ continue;
+
+ if ((xform.Coordinates.Position - site.Center).LengthSquared() > radiusSquared)
+ continue;
+
+ entities.Add(uid);
+ }
+
+ return entities;
+ }
+
+ private HashSet CollectHostedChunkEntities(
+ SectorExpeditionSiteComponent site,
+ EntityUid mapUid,
+ EntityUid gridUid,
+ MapGridComponent grid,
+ Vector2i chunkOrigin,
+ int chunkSize)
+ {
+ var entities = new HashSet();
+
+ foreach (var uid in site.GeneratedEntities.ToArray())
+ {
+ if (!Exists(uid))
+ continue;
+
+ var xform = Transform(uid);
+
+ if (xform.MapUid != mapUid || xform.GridUid != gridUid)
+ continue;
+
+ var tile = _mapSystem.LocalToTile(gridUid, grid, xform.Coordinates);
+ if (!IsChunkTile(tile, chunkOrigin, chunkSize) || !IsTileWithinHostedSite(site, tile))
+ continue;
+
+ entities.Add(uid);
+ }
+
+ foreach (var entity in entities)
+ {
+ site.GeneratedEntities.Add(entity);
+ }
+
+ return entities;
+ }
+
+ private bool DoesSiteIntersectChunk(SectorExpeditionSiteComponent site, Vector2i chunkOrigin, int chunkSize)
+ {
+ var minX = chunkOrigin.X;
+ var maxX = chunkOrigin.X + chunkSize;
+ var minY = chunkOrigin.Y;
+ var maxY = chunkOrigin.Y + chunkSize;
+ var clampedX = Math.Clamp(site.Center.X, minX, maxX);
+ var clampedY = Math.Clamp(site.Center.Y, minY, maxY);
+ var delta = site.Center - new Vector2(clampedX, clampedY);
+ var radius = GetHostedSiteRadius(site);
+ return delta.LengthSquared() <= radius * radius;
+ }
+
+ private bool IsTileWithinHostedSite(SectorExpeditionSiteComponent site, Vector2i indices)
+ {
+ var delta = site.Center - (indices + new Vector2(0.5f, 0.5f));
+ var radius = GetHostedSiteRadius(site);
+ return delta.LengthSquared() <= radius * radius;
+ }
+
+ private float GetHostedSiteRadius(SectorExpeditionSiteComponent site)
+ {
+ return site.ContentRadius > 0f ? site.ContentRadius : site.Radius;
+ }
+
+ private bool TryGetHostedSiteMapUid(EntityUid gridUid, out EntityUid mapUid)
+ {
+ mapUid = EntityUid.Invalid;
+
+ if (HasComp(gridUid))
+ {
+ mapUid = gridUid;
+ return true;
+ }
+
+ var xform = Transform(gridUid);
+ if (xform.MapUid is not { } resolved)
+ return false;
+
+ mapUid = resolved;
+ return true;
+ }
+
+ private static bool IsChunkTile(Vector2i tile, Vector2i chunkOrigin, int chunkSize)
+ {
+ return tile.X >= chunkOrigin.X
+ && tile.Y >= chunkOrigin.Y
+ && tile.X < chunkOrigin.X + chunkSize
+ && tile.Y < chunkOrigin.Y + chunkSize;
+ }
+
+ public HashSet GetRoundEndCleanupMapIds()
+ {
+ var mapIds = new HashSet();
+
+ var sectorQuery = EntityQueryEnumerator();
+ while (sectorQuery.MoveNext(out _, out var sector))
+ {
+ foreach (var mapUid in sector.PlanetTypeMaps.Values)
+ {
+ TryAddRoundEndCleanupMapId(mapIds, mapUid);
+ }
+ }
+
+ return mapIds;
+ }
+
+ private void TryAddRoundEndCleanupMapId(HashSet mapIds, EntityUid mapUid)
+ {
+ if (!Exists(mapUid) || !TryComp(mapUid, out var mapComp))
+ return;
+
+ if (mapComp.MapId == _gameTicker.DefaultMap)
+ return;
+
+ mapIds.Add(mapComp.MapId);
+ }
+
+ public bool TryGetDefaultSectorMap(out EntityUid sectorMap, out SectorWorldComponent sector)
+ {
+ sectorMap = EntityUid.Invalid;
+ sector = default!;
+
+ if (!_mapSystem.TryGetMap(_gameTicker.DefaultMap, out var mapUid) || mapUid is not { } resolved)
+ return false;
+
+ if (!TryComp(resolved, out var resolvedSector) || resolvedSector == null)
+ return false;
+
+ sector = resolvedSector;
+ sectorMap = resolved;
+ EnsureInitialized((sectorMap, sector));
+ return true;
+ }
+
+ public bool TryGetPersistentMap(string? planetTypeId, out EntityUid mapUid, out SectorPlanetDescriptor? planet, SectorWorldComponent? sector = null)
+ {
+ mapUid = EntityUid.Invalid;
+ planet = null;
+
+ if (!TryGetDefaultSectorMap(out var sectorMap, out sector))
+ return false;
+
+ EnsureInitialized((sectorMap, sector));
+
+ if (string.IsNullOrWhiteSpace(planetTypeId))
+ {
+ mapUid = sector.SpaceMap ?? sectorMap;
+ return true;
+ }
+
+ planet = sector.Planets.FirstOrDefault(candidate => candidate.PlanetTypeId == planetTypeId);
+ if (planet == null)
+ return false;
+
+ if (!sector.PlanetTypeMaps.TryGetValue(planetTypeId, out mapUid))
+ return false;
+
+ return true;
+ }
+
+ public bool TryResolvePlanetTypeForBiome(string? biomeTemplateId, out string? planetTypeId, SectorWorldComponent? sector = null)
+ {
+ planetTypeId = null;
+
+ if (string.IsNullOrWhiteSpace(biomeTemplateId))
+ return false;
+
+ if (!TryGetDefaultSectorMap(out var sectorMap, out sector))
+ return false;
+
+ EnsureInitialized((sectorMap, sector));
+ var match = sector.PlanetTypes.FirstOrDefault(candidate =>
+ string.Equals(candidate.BiomeTemplate, biomeTemplateId, StringComparison.OrdinalIgnoreCase)
+ || candidate.BiomeAliases.Any(alias => string.Equals(alias, biomeTemplateId, StringComparison.OrdinalIgnoreCase)));
+
+ if (match == null)
+ return false;
+
+ planetTypeId = match.Id;
+ return true;
+ }
+
+ public bool TryGetPlanetAtPosition(EntityUid sectorMap, Vector2 worldPos, out SectorPlanetDescriptor planet, SectorWorldComponent? sector = null)
+ {
+ planet = default!;
+
+ if (!Resolve(sectorMap, ref sector, false))
+ return false;
+
+ if (sector == null)
+ return false;
+
+ EnsureInitialized((sectorMap, sector));
+
+ foreach (var candidate in sector.Planets)
+ {
+ if ((worldPos - candidate.Center).LengthSquared() <= candidate.Radius * candidate.Radius)
+ {
+ planet = candidate;
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public bool TryGetSectorGrid(EntityUid sectorMap, out EntityUid gridUid, SectorWorldComponent? sector = null)
+ {
+ gridUid = EntityUid.Invalid;
+
+ if (!Resolve(sectorMap, ref sector, false))
+ return false;
+
+ EnsureInitialized((sectorMap, sector));
+
+ if (sector == null || sector.SectorGrid is not { } resolvedGrid || !Exists(resolvedGrid))
+ return false;
+
+ gridUid = resolvedGrid;
+ return true;
+ }
+
+ public bool TryGetSurfaceTile(SectorPlanetDescriptor planet, out string tileId)
+ {
+ tileId = planet.SurfaceTile;
+ return _tileDefs.TryGetDefinition(tileId, out _);
+ }
+
+ public bool IsSolidAt(EntityUid sectorMap, EntityUid noiseHolder, SectorChunkCarverComponent carver, Vector2 worldPos, out SectorPlanetDescriptor planet)
+ {
+ if (TryComp(sectorMap, out SectorWorldComponent? sectorComp) &&
+ sectorComp != null &&
+ worldPos.LengthSquared() <= sectorComp.CentralClearRadius * sectorComp.CentralClearRadius)
+ {
+ planet = default!;
+ return false;
+ }
+
+ var chunkCoords = SharedMapSystem.GetChunkIndices(worldPos, WorldGen.ChunkSize);
+ if (sectorComp == null)
+ {
+ planet = default!;
+ return false;
+ }
+
+ planet = GetSectorAsteroidDescriptor(sectorComp, chunkCoords);
+
+ var localPos = worldPos;
+ var chunkSample = new Vector2(chunkCoords.X + 0.5f, chunkCoords.Y + 0.5f) / MathF.Max(carver.ChunkFieldScale, 1f);
+ var sparseSample = localPos / MathF.Max(carver.SparseFieldScale, 1f);
+ var islandSample = localPos / MathF.Max(carver.IslandFieldScale, 1f);
+ var detailSample = localPos / MathF.Max(carver.DetailFieldScale, 1f);
+
+ var chunkPresence = _noiseIndex.Evaluate(noiseHolder, carver.IslandNoiseChannel, chunkSample * 0.57f + new Vector2(17.5f, -12.25f));
+ if (chunkPresence < carver.ChunkThreshold)
+ return false;
+
+ var sparse = _noiseIndex.Evaluate(noiseHolder, carver.IslandNoiseChannel, sparseSample * 0.73f + new Vector2(-11.75f, 6.25f));
+ if (sparse < carver.SparseThreshold)
+ return false;
+
+ var density = _noiseIndex.Evaluate(noiseHolder, carver.DensityNoiseChannel, islandSample * 1.07f + new Vector2(3.25f, -1.75f));
+ var carve = _noiseIndex.Evaluate(noiseHolder, carver.CarveNoiseChannel, detailSample * 1.33f + new Vector2(7.5f, -4.25f));
+ var islands = _noiseIndex.Evaluate(noiseHolder, carver.IslandNoiseChannel, islandSample * 1.61f + new Vector2(-3.75f, 5.5f));
+
+ var densityBias = 0f;
+
+ var sparseStrength = (sparse - carver.SparseThreshold) / MathF.Max(1f - carver.SparseThreshold, 0.001f);
+ sparseStrength = Math.Clamp(sparseStrength, 0f, 1f);
+ sparseStrength = MathF.Pow(sparseStrength, carver.DensitySharpness);
+
+ var ridge = 1f - MathF.Abs(carve - 0.5f) * 2f;
+ ridge = Math.Clamp(ridge, 0f, 1f);
+
+ var islandMass = islands * 0.42f + sparseStrength * 0.26f + ridge * 0.12f;
+ var signedDensity = density * 0.38f + islandMass - densityBias - 0.12f;
+ var baseMass = islands >= carver.IslandThreshold - 0.08f
+ && signedDensity >= carver.DensityThreshold - 0.04f;
+ var carvedOut = carve >= carver.CarveRange.X && carve <= carver.CarveRange.Y && islandMass < 0.92f;
+
+ return baseMass && !carvedOut;
+ }
+
+ private SectorPlanetDescriptor GetSectorAsteroidDescriptor(SectorWorldComponent sector, Vector2i chunkCoords)
+ {
+ if (sector.Planets.Count == 0)
+ {
+ return new SectorPlanetDescriptor
+ {
+ SurfaceTile = "FloorSteel",
+ };
+ }
+
+ var hash = HashCode.Combine(sector.UniverseSeed, chunkCoords.X, chunkCoords.Y);
+ var index = Math.Abs(hash % sector.Planets.Count);
+ return sector.Planets[index];
+ }
+
+ public bool TryReserveExpeditionSite(int seed, EntityUid expeditionUid, string? planetTypeId, out SectorExpeditionPlacement placement)
+ {
+ placement = default!;
+
+ if (!TryGetDefaultSectorMap(out var sectorMap, out var sector))
+ return false;
+
+ var rng = new Random(seed);
+ var planets = sector.Planets
+ .Where(planet => string.IsNullOrWhiteSpace(planetTypeId) || planet.PlanetTypeId == planetTypeId)
+ .OrderBy(_ => rng.Next())
+ .ToList();
+ var reservationRadius = sector.MissionReservationRadius;
+
+ foreach (var planet in planets)
+ {
+ if (!TryGetPersistentMap(planet.PlanetTypeId, out var targetMap, out _ , sector))
+ continue;
+
+ var placementOrigin = targetMap == (sector.SpaceMap ?? sectorMap)
+ ? planet.Center
+ : Vector2.Zero;
+
+ for (var attempt = 0; attempt < 32; attempt++)
+ {
+ var angle = rng.NextSingle() * MathF.Tau;
+ var distance = MathF.Sqrt(rng.NextSingle()) * MathF.Max(planet.Radius - reservationRadius, 64f);
+ var candidate = placementOrigin + new Vector2(MathF.Cos(angle), MathF.Sin(angle)) * distance;
+
+ if (!IsReservationFree(sector, candidate, reservationRadius))
+ continue;
+
+ var reservation = new SectorExpeditionReservation
+ {
+ ExpeditionUid = expeditionUid,
+ PlanetId = planet.PlanetId,
+ Center = candidate,
+ Radius = reservationRadius,
+ };
+
+ sector.Reservations[expeditionUid] = reservation;
+ placement = new SectorExpeditionPlacement
+ {
+ SectorMap = targetMap,
+ PlanetTypeId = planet.PlanetTypeId,
+ Center = candidate,
+ ReservationRadius = reservationRadius,
+ Planet = planet,
+ };
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private bool IsReservationFree(SectorWorldComponent sector, Vector2 center, float radius)
+ {
+ foreach (var reservation in sector.Reservations.Values)
+ {
+ var minDistance = radius + reservation.Radius + sector.MissionReservationPadding;
+ if ((reservation.Center - center).LengthSquared() < minDistance * minDistance)
+ return false;
+ }
+
+ return true;
+ }
+
+ private void EnsureInitialized(Entity ent)
+ {
+ if (ent.Comp.SpaceMap == null || !Exists(ent.Comp.SpaceMap.Value))
+ ent.Comp.SpaceMap = ent.Owner;
+
+ if ((ent.Comp.SectorGrid == null || !Exists(ent.Comp.SectorGrid.Value)) && TryComp(ent.Owner, out var mapComp))
+ {
+ var sectorGrid = _mapManager.CreateGridEntity(mapComp.MapId);
+ ent.Comp.SectorGrid = sectorGrid.Owner;
+ sectorGrid.Comp.CanSplit = false;
+ EnsureComp(sectorGrid.Owner);
+ _metaData.SetEntityName(sectorGrid.Owner, $"{MetaData(ent.Owner).EntityName} Sector Grid");
+ }
+
+ if (ent.Comp.SectorGrid is { } existingSectorGrid)
+ EnsurePersistentWorldGrid(existingSectorGrid);
+
+ if (ent.Comp.UniverseSeed == 0)
+ ent.Comp.UniverseSeed = _random.Next(1, int.MaxValue);
+
+ if (ent.Comp.Planets.Count == 0 && ent.Comp.PlanetTypes.Count > 0)
+ {
+ var rng = new Random(ent.Comp.UniverseSeed);
+ var ringStep = 2400f;
+
+ for (var index = 0; index < ent.Comp.PlanetTypes.Count; index++)
+ {
+ var type = ent.Comp.PlanetTypes[index];
+ var radius = MathHelper.Lerp(type.MinRadius, type.MaxRadius, rng.NextSingle());
+ var distance = 1800f + index * ringStep + rng.NextSingle() * 900f;
+ var angle = (MathF.Tau / ent.Comp.PlanetTypes.Count) * index + (rng.NextSingle() - 0.5f) * 0.45f;
+ var center = new Vector2(MathF.Cos(angle), MathF.Sin(angle)) * distance;
+ var tileId = type.SurfaceTiles.Count > 0
+ ? type.SurfaceTiles[rng.Next(type.SurfaceTiles.Count)]
+ : "FloorSteel";
+
+ if (!_proto.TryIndex(type.BiomeTemplate, out _))
+ continue;
+
+ ent.Comp.Planets.Add(new SectorPlanetDescriptor
+ {
+ PlanetId = $"{type.Id}-{index + 1}",
+ Name = $"{type.Name} {index + 1}",
+ PlanetTypeId = type.Id,
+ BiomeTemplate = type.BiomeTemplate,
+ SurfaceTile = tileId,
+ Center = center,
+ Radius = radius,
+ Seed = rng.Next(),
+ Temperature = MathHelper.Lerp(type.MinTemperature, type.MaxTemperature, rng.NextSingle()),
+ Oxygen = MathHelper.Lerp(type.MinOxygen, type.MaxOxygen, rng.NextSingle()),
+ Nitrogen = MathHelper.Lerp(type.MinNitrogen, type.MaxNitrogen, rng.NextSingle()),
+ CarbonDioxide = MathHelper.Lerp(type.MinCarbonDioxide, type.MaxCarbonDioxide, rng.NextSingle()),
+ TimeOfDay = TimeOfDayStates[rng.Next(TimeOfDayStates.Length)],
+ WeatherPrototype = rng.NextSingle() < 0.5f ? null : type.WeatherPrototype,
+ });
+ }
+ }
+
+ EnsurePersistentLayerMaps(ent);
+ EnsureStartupPlanetLoaders(ent);
+ }
+
+ private void EnsureStartupPlanetLoaders(Entity ent)
+ {
+ var desiredMaps = new HashSet();
+
+ if (ent.Comp.FtlMap is { } ftlMap && Exists(ftlMap))
+ desiredMaps.Add(ftlMap);
+
+ foreach (var mapUid in ent.Comp.PlanetTypeMaps.Values)
+ {
+ if (Exists(mapUid))
+ desiredMaps.Add(mapUid);
+ }
+
+ var retainedLoaders = new List(desiredMaps.Count);
+
+ foreach (var loader in ent.Comp.StartupLoaders)
+ {
+ if (!Exists(loader))
+ continue;
+
+ var xform = Transform(loader);
+ if (xform.MapUid is not { } mapUid || !desiredMaps.Remove(mapUid))
+ {
+ QueueDel(loader);
+ continue;
+ }
+
+ _worldController.SetLoaderRadius(loader, WorldGen.ChunkSize * 2);
+ _worldController.SetLoaderEnabled(loader, true);
+ retainedLoaders.Add(loader);
+ }
+
+ foreach (var mapUid in desiredMaps)
+ {
+ var loader = Spawn(null, new MapCoordinates(Vector2.Zero, Comp(mapUid).MapId));
+ EnsureComp(loader);
+ EnsureComp(loader);
+ _worldController.SetLoaderRadius(loader, WorldGen.ChunkSize * 2);
+ _worldController.SetLoaderEnabled(loader, true);
+ retainedLoaders.Add(loader);
+ }
+
+ ent.Comp.StartupLoaders.Clear();
+ ent.Comp.StartupLoaders.AddRange(retainedLoaders);
+ }
+
+ private void EnsurePersistentLayerMaps(Entity ent)
+ {
+ if (ent.Comp.FtlMap is { } ftlMap && !Exists(ftlMap))
+ ent.Comp.FtlMap = null;
+
+ CleanupLegacyColCommLayerMap(ent);
+
+ var invalidPlanetMaps = ent.Comp.PlanetTypeMaps
+ .Where(pair => !Exists(pair.Value))
+ .Select(pair => pair.Key)
+ .ToArray();
+
+ foreach (var planetTypeId in invalidPlanetMaps)
+ {
+ ent.Comp.PlanetTypeMaps.Remove(planetTypeId);
+ }
+
+ ent.Comp.FtlMap ??= CreateLayerMap($"{MetaData(ent.Owner).EntityName} FTL", space: true, gravity: false);
+
+ EnsurePersistentWorldGrid(ent.Comp.FtlMap.Value);
+
+ foreach (var planet in ent.Comp.Planets)
+ {
+ if (ent.Comp.PlanetTypeMaps.ContainsKey(planet.PlanetTypeId))
+ {
+ EnsurePersistentWorldGrid(ent.Comp.PlanetTypeMaps[planet.PlanetTypeId]);
+ continue;
+ }
+
+ var planetType = ent.Comp.PlanetTypes.FirstOrDefault(type => type.Id == planet.PlanetTypeId);
+
+ ent.Comp.PlanetTypeMaps[planet.PlanetTypeId] = CreateLayerMap(
+ $"{planet.Name} Surface",
+ space: false,
+ gravity: true,
+ createFtlDestination: true,
+ mixture: CreatePlanetMixture(planet),
+ timeOfDay: planet.TimeOfDay,
+ weatherPrototype: planet.WeatherPrototype,
+ biomeTemplateId: planet.BiomeTemplate,
+ biomeSeed: planet.Seed,
+ hiddenSurfaceTileIds: planetType?.SurfaceTiles);
+
+ EnsurePersistentWorldGrid(ent.Comp.PlanetTypeMaps[planet.PlanetTypeId]);
+ }
+ }
+
+ private void CleanupLegacyColCommLayerMap(Entity ent)
+ {
+ if (ent.Comp.ColCommMap is not { } colCommMap)
+ return;
+
+ ent.Comp.ColCommMap = null;
+
+ if (!Exists(colCommMap) || colCommMap == ent.Owner)
+ return;
+
+ var defaultMapUid = _mapSystem.GetMapOrInvalid(_gameTicker.DefaultMap);
+ if (colCommMap == defaultMapUid)
+ return;
+
+ if (TryComp(colCommMap, out var mapComp))
+ {
+ _mapSystem.DeleteMap(mapComp.MapId);
+ return;
+ }
+
+ QueueDel(colCommMap);
+ }
+
+ private void EnsurePersistentWorldGrid(EntityUid mapOrGridUid)
+ {
+ if (!Exists(mapOrGridUid))
+ return;
+
+ EnsureComp(mapOrGridUid);
+ EnsureComp(mapOrGridUid);
+
+ var physics = EnsureComp(mapOrGridUid);
+ if (physics.BodyType != BodyType.Static)
+ _physics.SetBodyType(mapOrGridUid, BodyType.Static, body: physics);
+
+ _physics.SetBodyStatus(mapOrGridUid, physics, BodyStatus.OnGround);
+ _physics.SetFixedRotation(mapOrGridUid, true, body: physics);
+
+ if (TryComp(mapOrGridUid, out var grid))
+ grid.CanSplit = false;
+ }
+
+ private EntityUid CreateLayerMap(
+ string name,
+ bool space,
+ bool gravity,
+ bool createFtlDestination = false,
+ GasMixture? mixture = null,
+ string? timeOfDay = null,
+ string? weatherPrototype = null,
+ string? biomeTemplateId = null,
+ int? biomeSeed = null,
+ IReadOnlyList? hiddenSurfaceTileIds = null)
+ {
+ var mapUid = _mapSystem.CreateMap(out _);
+ EnsureComp(mapUid);
+ EnsureComp(mapUid);
+ EnsureComp(mapUid);
+ _metaData.SetEntityName(mapUid, name);
+
+ if (!space && !string.IsNullOrWhiteSpace(biomeTemplateId) && _proto.TryIndex(biomeTemplateId, out var biomeTemplate))
+ {
+ _biome.EnsurePlanet(mapUid, biomeTemplate, biomeSeed, mapLight: GetAmbientLightForTimeOfDay(timeOfDay));
+ }
+
+ if (mixture != null)
+ _atmosphere.SetMapAtmosphere(mapUid, space, mixture);
+ else if (space)
+ _atmosphere.SetMapAtmosphere(mapUid, true, GasMixture.SpaceGas);
+
+ var gravityComp = EnsureComp(mapUid);
+ gravityComp.Enabled = gravity;
+ gravityComp.Inherent = gravity;
+
+ var light = EnsureComp(mapUid);
+ light.AmbientLightColor = GetAmbientLightForTimeOfDay(timeOfDay);
+
+ EnsureComp(mapUid);
+ EnsureComp(mapUid);
+ EnsureComp(mapUid);
+
+ if (hiddenSurfaceTileIds is { Count: > 0 })
+ {
+ var mask = EnsureComp(mapUid);
+ mask.HiddenTileIds.Clear();
+ mask.HiddenTileIds.AddRange(hiddenSurfaceTileIds);
+ Dirty(mapUid, mask);
+ }
+
+ if (createFtlDestination && TryComp(mapUid, out var ftlMapComp))
+ {
+ _shuttle.TryAddFTLDestination(ftlMapComp.MapId, true, requireDisk: false, beaconsOnly: false, out _);
+ EnsureComp(mapUid);
+ }
+
+ var resolvedWeatherPrototype = ResolveWeatherPrototype(weatherPrototype);
+
+ if (!string.IsNullOrWhiteSpace(resolvedWeatherPrototype) &&
+ TryComp(mapUid, out var mapComp))
+ {
+ _weather.TrySetWeather(mapComp.MapId, resolvedWeatherPrototype, out _);
+ }
+
+ return mapUid;
+ }
+
+ private static GasMixture CreateStandardAirMixture()
+ {
+ var moles = new float[Atmospherics.AdjustedNumberOfGases];
+ moles[(int) Gas.Oxygen] = 21.824779f;
+ moles[(int) Gas.Nitrogen] = 82.10312f;
+ return new GasMixture(moles, Atmospherics.T20C);
+ }
+
+ private static GasMixture CreatePlanetMixture(SectorPlanetDescriptor planet)
+ {
+ var moles = new float[Atmospherics.AdjustedNumberOfGases];
+ moles[(int) Gas.Oxygen] = MathF.Max(planet.Oxygen, 0f);
+ moles[(int) Gas.Nitrogen] = MathF.Max(planet.Nitrogen, 0f);
+ moles[(int) Gas.CarbonDioxide] = MathF.Max(planet.CarbonDioxide, 0f);
+ return new GasMixture(moles, MathF.Max(planet.Temperature, Atmospherics.TCMB));
+ }
+
+ private static Color GetAmbientLightForTimeOfDay(string? timeOfDay)
+ {
+ return timeOfDay switch
+ {
+ "Night" => Color.FromHex("#2B3143"),
+ "Dusk" => Color.FromHex("#A34931"),
+ "Day" => Color.FromHex("#E6CB8B"),
+ _ => Color.FromHex("#D8B059"),
+ };
+ }
+
+ private string? ResolveWeatherPrototype(string? weatherPrototype)
+ {
+ if (string.IsNullOrWhiteSpace(weatherPrototype))
+ return null;
+
+ if (_proto.HasIndex(weatherPrototype))
+ return weatherPrototype;
+
+ var weatherEntityId = $"Weather{weatherPrototype}";
+ if (_proto.HasIndex(weatherEntityId))
+ return weatherEntityId;
+
+ return weatherPrototype;
+ }
+}
\ No newline at end of file
diff --git a/Content.Server/Worldgen/Systems/WorldControllerSystem.cs b/Content.Server/Worldgen/Systems/WorldControllerSystem.cs
index 3d4da865861..fbb0bb73932 100644
--- a/Content.Server/Worldgen/Systems/WorldControllerSystem.cs
+++ b/Content.Server/Worldgen/Systems/WorldControllerSystem.cs
@@ -1,4 +1,5 @@
using System.Linq;
+using System.Numerics;
using Content.Server.Worldgen.Components;
using Content.Shared.Ghost;
using Content.Shared.Mind.Components;
@@ -34,6 +35,48 @@ public override void Initialize()
SubscribeLocalEvent(OnChunkShutdown);
}
+ public void SetLoaderRadius(EntityUid uid, int radius, WorldLoaderComponent? loader = null)
+ {
+ if (!Resolve(uid, ref loader, false) || loader == null)
+ return;
+
+ loader.Radius = radius;
+ Dirty(uid, loader);
+ }
+
+ public void SetLoaderEnabled(EntityUid uid, bool enabled, WorldLoaderComponent? loader = null)
+ {
+ if (!Resolve(uid, ref loader, false) || loader == null)
+ return;
+
+ loader.Disabled = !enabled;
+ Dirty(uid, loader);
+ }
+
+ public void EnsureChunksLoaded(EntityUid mapUid, Vector2 worldPos, int radius, EntityUid? loaderUid = null, WorldControllerComponent? controller = null)
+ {
+ if (!Resolve(mapUid, ref controller))
+ return;
+
+ var loadedQuery = GetEntityQuery();
+ var coords = WorldGen.WorldToChunkCoords(worldPos);
+ var chunkRadius = (int) Math.Ceiling(radius / (float) WorldGen.ChunkSize) + 1;
+ var chunks = new GridPointsNearEnumerator(coords.Floored(), chunkRadius);
+
+ while (chunks.MoveNext(out var chunk))
+ {
+ var ent = GetOrCreateChunk(chunk.Value, mapUid, controller);
+ if (ent is not { } chunkUid)
+ continue;
+
+ if (!loadedQuery.TryGetComponent(chunkUid, out var loaded))
+ loaded = AddComp(chunkUid);
+
+ if (loaderUid is { } loader && Exists(loader))
+ loaded.Loaders = [loader];
+ }
+ }
+
///
/// Handles deleting chunks properly.
///
diff --git a/Content.Server/Worldgen/WorldGen.cs b/Content.Server/Worldgen/WorldGen.cs
index 5f0148b6c04..1ed20b9f1fa 100644
--- a/Content.Server/Worldgen/WorldGen.cs
+++ b/Content.Server/Worldgen/WorldGen.cs
@@ -12,7 +12,7 @@ public static class WorldGen
/// The size of each chunk (isn't that self-explanatory.)
/// Be careful about how small you make this.
///
- public const int ChunkSize = 256;
+ public const int ChunkSize = 128;
///
/// Converts world coordinates to chunk coordinates.
diff --git a/Content.Server/_Mono/FireControl/FireControlSystem.cs b/Content.Server/_Mono/FireControl/FireControlSystem.cs
index 406df1a7e7f..5fc0911aff6 100644
--- a/Content.Server/_Mono/FireControl/FireControlSystem.cs
+++ b/Content.Server/_Mono/FireControl/FireControlSystem.cs
@@ -19,7 +19,7 @@
using Content.Shared.Interaction;
using Content.Shared._Mono.ShipGuns;
using Content.Shared.Examine;
-using Content.Server.Salvage.Expeditions;
+using Content.Server.Salvage;
namespace Content.Server._Mono.FireControl;
@@ -31,6 +31,7 @@ public sealed partial class FireControlSystem : EntitySystem
[Dependency] private readonly IGameTiming _timing = default!;
[Dependency] private readonly PowerReceiverSystem _power = default!;
[Dependency] private readonly RotateToFaceSystem _rotateToFace = default!;
+ [Dependency] private readonly SalvageSystem _salvage = default!;
///
/// Dictionary of entities that have visualization enabled
@@ -430,7 +431,7 @@ public bool CanFireWeapons(EntityUid grid)
var gridXform = Transform(grid);
// Check if the weapon is an expedition
- if (gridXform.MapUid != null && HasComp(gridXform.MapUid.Value))
+ if (_salvage.IsOnExpedition(grid, gridXform))
return false;
return true;
@@ -533,7 +534,7 @@ public bool AttemptFire(EntityUid weapon, EntityUid user, EntityCoordinates coor
var weaponXform = Transform(weapon);
var weaponCoords = _xform.GetMapCoordinates(weaponXform);
var weaponPos = weaponCoords.Position;
- var targetCoords = coords.ToMap(EntityManager, _xform);
+ var targetCoords = _xform.ToMapCoordinates(coords);
var targetPos = targetCoords.Position;
// Calculate direction
diff --git a/Content.Shared/Humanoid/HumanoidCharacterAppearance.cs b/Content.Shared/Humanoid/HumanoidCharacterAppearance.cs
index c1e0774862c..ea0420a231f 100644
--- a/Content.Shared/Humanoid/HumanoidCharacterAppearance.cs
+++ b/Content.Shared/Humanoid/HumanoidCharacterAppearance.cs
@@ -11,6 +11,10 @@ namespace Content.Shared.Humanoid;
[Serializable, NetSerializable]
public sealed partial class HumanoidCharacterAppearance : ICharacterAppearance, IEquatable
{
+ public const float DefaultScale = 1.0f;
+ public const float MinScale = 0.4f;
+ public const float MaxScale = 1.5f;
+
[DataField("hair")]
public string HairStyleId { get; set; } = HairStyles.DefaultHairStyle;
@@ -39,10 +43,10 @@ public sealed partial class HumanoidCharacterAppearance : ICharacterAppearance,
public List Markings { get; set; } = new();
[DataField]
- public float Height { get; set; } = 1.0f;
+ public float Height { get; set; } = DefaultScale;
[DataField]
- public float Width { get; set; } = 1.0f;
+ public float Width { get; set; } = DefaultScale;
public HumanoidCharacterAppearance(string hairStyleId,
Color hairColor,
@@ -54,8 +58,8 @@ public HumanoidCharacterAppearance(string hairStyleId,
bool eyeGlowing, //starlight
Color skinColor,
List markings,
- float height = 1.0f,
- float width = 1.0f)
+ float height = DefaultScale,
+ float width = DefaultScale)
{
HairStyleId = hairStyleId;
HairColor = ClampColor(hairColor);
@@ -64,8 +68,8 @@ public HumanoidCharacterAppearance(string hairStyleId,
EyeColor = ClampColor(eyeColor);
SkinColor = ClampColor(skinColor);
Markings = markings;
- Height = height <= 0.005f ? 1.0f : height;
- Width = width <= 0.005f ? 1.0f : width;
+ Height = ClampScale(height);
+ Width = ClampScale(width);
HairGlowing = hairGlowing; //starlight
FacialHairGlowing = facialHairGlowing; //starlight
EyeGlowing = eyeGlowing; //starlight
@@ -175,8 +179,8 @@ public static HumanoidCharacterAppearance DefaultWithSpecies(string species)
false, //starlight
skinColor,
new (),
- 1.0f,
- 1.0f
+ DefaultScale,
+ DefaultScale
);
}
@@ -250,8 +254,8 @@ public static HumanoidCharacterAppearance Random(string species, Sex sex)
break;
}
- var newHeight = random.NextFloat(0.8f, 1.2f); // Random height between 80% and 120% of normal
- var newWidth = random.NextFloat(0.8f, 1.2f); // Random width between 80% and 120% of normal
+ var newHeight = random.NextFloat(MinScale, MaxScale);
+ var newWidth = random.NextFloat(MinScale, MaxScale);
return new HumanoidCharacterAppearance(newHairStyle, newHairColor, false, newFacialHairStyle, newHairColor, false, newEyeColor, false, newSkinColor, new (), newHeight, newWidth); //starlight, glowing
float RandomizeColor(float channel)
@@ -260,6 +264,14 @@ float RandomizeColor(float channel)
}
}
+ private static float ClampScale(float scale)
+ {
+ if (scale <= 0.005f)
+ return DefaultScale;
+
+ return Math.Clamp(scale, MinScale, MaxScale);
+ }
+
public static Color ClampColor(Color color)
{
return new(color.RByte, color.GByte, color.BByte);
diff --git a/Content.Shared/Salvage/Expeditions/Modifiers/SalvageWeatherMod.cs b/Content.Shared/Salvage/Expeditions/Modifiers/SalvageWeatherMod.cs
index 1f3b13daee6..432dd279e9a 100644
--- a/Content.Shared/Salvage/Expeditions/Modifiers/SalvageWeatherMod.cs
+++ b/Content.Shared/Salvage/Expeditions/Modifiers/SalvageWeatherMod.cs
@@ -21,8 +21,8 @@ public sealed partial class SalvageWeatherMod : IPrototype, IBiomeSpecificMod
public List? Biomes { get; private set; } = null;
///
- /// Weather prototype to use on the planet.
+ /// Weather status effect prototype to use on the planet.
///
- [DataField("weather", required: true, customTypeSerializer:typeof(PrototypeIdSerializer))]
- public string WeatherPrototype = string.Empty;
+ [DataField("weather", required: true)]
+ public EntProtoId WeatherPrototype = string.Empty;
}
diff --git a/Content.Shared/Shuttles/Components/RadarTileMaskComponent.cs b/Content.Shared/Shuttles/Components/RadarTileMaskComponent.cs
new file mode 100644
index 00000000000..d3baeb12280
--- /dev/null
+++ b/Content.Shared/Shuttles/Components/RadarTileMaskComponent.cs
@@ -0,0 +1,10 @@
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.Shuttles.Components;
+
+[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
+public sealed partial class RadarTileMaskComponent : Component
+{
+ [DataField, AutoNetworkedField]
+ public List HiddenTileIds = new();
+}
\ No newline at end of file
diff --git a/Content.Shared/StatusEffectNew/Components/StatusEffectComponent.cs b/Content.Shared/StatusEffectNew/Components/StatusEffectComponent.cs
new file mode 100644
index 00000000000..6cb0975b557
--- /dev/null
+++ b/Content.Shared/StatusEffectNew/Components/StatusEffectComponent.cs
@@ -0,0 +1,20 @@
+using Robust.Shared.GameStates;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
+
+namespace Content.Shared.StatusEffectNew.Components;
+
+[RegisterComponent, NetworkedComponent, AutoGenerateComponentState(fieldDeltas: true), AutoGenerateComponentPause]
+public sealed partial class StatusEffectComponent : Component
+{
+ [DataField, AutoNetworkedField]
+ public EntityUid? AppliedTo;
+
+ [DataField(customTypeSerializer: typeof(TimeOffsetSerializer)), AutoPausedField, AutoNetworkedField]
+ public TimeSpan StartEffectTime;
+
+ [DataField(customTypeSerializer: typeof(TimeOffsetSerializer)), AutoPausedField, AutoNetworkedField]
+ public TimeSpan? EndEffectTime;
+
+ [ViewVariables]
+ public TimeSpan Duration => EndEffectTime == null ? TimeSpan.MaxValue : EndEffectTime.Value - StartEffectTime;
+}
diff --git a/Content.Shared/StatusEffectNew/Components/StatusEffectContainerComponent.cs b/Content.Shared/StatusEffectNew/Components/StatusEffectContainerComponent.cs
new file mode 100644
index 00000000000..6b8f102a84f
--- /dev/null
+++ b/Content.Shared/StatusEffectNew/Components/StatusEffectContainerComponent.cs
@@ -0,0 +1,13 @@
+using Robust.Shared.Containers;
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.StatusEffectNew.Components;
+
+[RegisterComponent, NetworkedComponent]
+public sealed partial class StatusEffectContainerComponent : Component
+{
+ public const string ContainerId = "status-effects";
+
+ [ViewVariables]
+ public Container? ActiveStatusEffects;
+}
diff --git a/Content.Shared/StatusEffectNew/StatusEffectsSystem.cs b/Content.Shared/StatusEffectNew/StatusEffectsSystem.cs
new file mode 100644
index 00000000000..20f91fdb60c
--- /dev/null
+++ b/Content.Shared/StatusEffectNew/StatusEffectsSystem.cs
@@ -0,0 +1,149 @@
+using System.Diagnostics.CodeAnalysis;
+using Content.Shared.StatusEffectNew.Components;
+using Robust.Shared.Containers;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Timing;
+
+namespace Content.Shared.StatusEffectNew;
+
+public sealed class StatusEffectsSystem : EntitySystem
+{
+ [Dependency] private readonly IGameTiming _timing = default!;
+ [Dependency] private readonly SharedContainerSystem _container = default!;
+
+ private EntityQuery _containerQuery;
+ private EntityQuery _statusQuery;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnStatusContainerInit);
+ SubscribeLocalEvent(OnStatusContainerShutdown);
+
+ _containerQuery = GetEntityQuery();
+ _statusQuery = GetEntityQuery();
+ }
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out var status))
+ {
+ if (status.EndEffectTime is not { } endTime)
+ continue;
+
+ if (_timing.CurTime < endTime)
+ continue;
+
+ PredictedQueueDel(uid);
+ }
+ }
+
+ private void OnStatusContainerInit(Entity ent, ref ComponentInit args)
+ {
+ ent.Comp.ActiveStatusEffects = _container.EnsureContainer(ent, StatusEffectContainerComponent.ContainerId);
+ ent.Comp.ActiveStatusEffects.ShowContents = true;
+ ent.Comp.ActiveStatusEffects.OccludesLight = false;
+ }
+
+ private void OnStatusContainerShutdown(Entity ent, ref ComponentShutdown args)
+ {
+ if (ent.Comp.ActiveStatusEffects is { } container)
+ _container.ShutdownContainer(container);
+ }
+
+ public bool TryGetStatusEffect(EntityUid target, EntProtoId effectProto, [NotNullWhen(true)] out EntityUid? statusEffect)
+ {
+ statusEffect = null;
+
+ if (!_containerQuery.TryComp(target, out var containerComp) || containerComp.ActiveStatusEffects == null)
+ return false;
+
+ foreach (var contained in containerComp.ActiveStatusEffects.ContainedEntities)
+ {
+ if (!_statusQuery.TryComp(contained, out var status) || status.AppliedTo != target)
+ continue;
+
+ var containedProto = Prototype(contained);
+ if (containedProto == null || containedProto != effectProto)
+ continue;
+
+ statusEffect = contained;
+ return true;
+ }
+
+ return false;
+ }
+
+ public bool TrySetStatusEffectDuration(EntityUid target, EntProtoId effectProto, TimeSpan? duration)
+ {
+ return TrySetStatusEffectDuration(target, effectProto, out _, duration);
+ }
+
+ public bool TrySetStatusEffectDuration(EntityUid target, EntProtoId effectProto, [NotNullWhen(true)] out EntityUid? statusEffect, TimeSpan? duration = null)
+ {
+ statusEffect = null;
+
+ if (TryGetStatusEffect(target, effectProto, out statusEffect))
+ {
+ if (statusEffect == null)
+ return false;
+
+ var existingUid = statusEffect.Value;
+ if (!_statusQuery.TryComp(existingUid, out var existing))
+ return false;
+
+ existing.AppliedTo = target;
+ existing.StartEffectTime = _timing.CurTime;
+ existing.EndEffectTime = duration == null ? null : _timing.CurTime + duration.Value;
+ Dirty(existingUid, existing);
+ return true;
+ }
+
+ EnsureComp(target);
+
+ if (!PredictedTrySpawnInContainer(effectProto, target, StatusEffectContainerComponent.ContainerId, out var spawned))
+ return false;
+
+ if (!_statusQuery.TryComp(spawned, out var status))
+ return false;
+
+ status.AppliedTo = target;
+ status.StartEffectTime = _timing.CurTime;
+ status.EndEffectTime = duration == null ? null : _timing.CurTime + duration.Value;
+ Dirty(spawned.Value, status);
+
+ statusEffect = spawned;
+ return true;
+ }
+
+ public bool TryEffectsWithComp(EntityUid target, [NotNullWhen(true)] out HashSet>? effects)
+ where T : IComponent
+ {
+ effects = null;
+
+ if (!_containerQuery.TryComp(target, out var containerComp) || containerComp.ActiveStatusEffects == null)
+ return false;
+
+ var set = new HashSet>();
+ foreach (var contained in containerComp.ActiveStatusEffects.ContainedEntities)
+ {
+ if (!TryComp(contained, out var comp) || !_statusQuery.TryComp(contained, out var status))
+ continue;
+
+ if (status.AppliedTo != target)
+ continue;
+
+ set.Add((contained, comp, status));
+ }
+
+ if (set.Count == 0)
+ return false;
+
+ effects = set;
+ return true;
+ }
+}
diff --git a/Content.Shared/Weather/SharedWeatherSystem.cs b/Content.Shared/Weather/SharedWeatherSystem.cs
index 0387f50aab1..6a7452bb7af 100644
--- a/Content.Shared/Weather/SharedWeatherSystem.cs
+++ b/Content.Shared/Weather/SharedWeatherSystem.cs
@@ -1,10 +1,13 @@
+using System.Diagnostics.CodeAnalysis;
using Content.Shared.Light.Components;
using Content.Shared.Light.EntitySystems;
+using Content.Shared.Maps;
+using Content.Shared.StatusEffectNew;
+using Content.Shared.StatusEffectNew.Components;
using Robust.Shared.Audio.Systems;
using Robust.Shared.Map;
using Robust.Shared.Map.Components;
using Robust.Shared.Prototypes;
-using Robust.Shared.Serialization;
using Robust.Shared.Timing;
namespace Content.Shared.Weather;
@@ -12,230 +15,140 @@ namespace Content.Shared.Weather;
public abstract class SharedWeatherSystem : EntitySystem
{
[Dependency] protected readonly IGameTiming Timing = default!;
- [Dependency] protected readonly IMapManager MapManager = default!;
[Dependency] protected readonly IPrototypeManager ProtoMan = default!;
- [Dependency] private readonly MetaDataSystem _metadata = default!;
- [Dependency] private readonly SharedAudioSystem _audio = default!;
+ [Dependency] protected readonly SharedAudioSystem Audio = default!;
+ [Dependency] private readonly ITileDefinitionManager _tileDefManager = default!;
[Dependency] private readonly SharedMapSystem _mapSystem = default!;
[Dependency] private readonly SharedRoofSystem _roof = default!;
+ [Dependency] private readonly StatusEffectsSystem _statusEffects = default!;
private EntityQuery _blockQuery;
+ private EntityQuery _weatherQuery;
+
+ public static readonly TimeSpan StartupTime = TimeSpan.FromSeconds(15);
+ public static readonly TimeSpan ShutdownTime = TimeSpan.FromSeconds(15);
public override void Initialize()
{
base.Initialize();
- _blockQuery = GetEntityQuery();
- SubscribeLocalEvent(OnWeatherUnpaused);
- }
- private void OnWeatherUnpaused(EntityUid uid, WeatherComponent component, ref EntityUnpausedEvent args)
- {
- foreach (var weather in component.Weather.Values)
- {
- weather.StartTime += args.PausedTime;
-
- if (weather.EndTime != null)
- weather.EndTime = weather.EndTime.Value + args.PausedTime;
- }
+ _blockQuery = GetEntityQuery();
+ _weatherQuery = GetEntityQuery();
}
- public bool CanWeatherAffect(EntityUid uid, MapGridComponent grid, TileRef tileRef, RoofComponent? roofComp = null)
+ public bool CanWeatherAffect(Entity ent, TileRef tileRef)
{
if (tileRef.Tile.IsEmpty)
return true;
- if (Resolve(uid, ref roofComp, false) && _roof.IsWeatherOccluding((uid, grid, roofComp), tileRef.GridIndices))
+ if (!Resolve(ent, ref ent.Comp1))
+ return false;
+
+ if (Resolve(ent, ref ent.Comp2, false) && _roof.IsRooved((ent, ent.Comp1, ent.Comp2), tileRef.GridIndices))
return false;
- if (HasComp(uid))
+ var tileDef = (ContentTileDefinition) _tileDefManager[tileRef.Tile.TypeId];
+
+ if (!tileDef.MapAtmosphere)
return false;
- var anchoredEntities = _mapSystem.GetAnchoredEntitiesEnumerator(uid, grid, tileRef.GridIndices);
+ var anchoredEntities = _mapSystem.GetAnchoredEntitiesEnumerator(ent, ent.Comp1, tileRef.GridIndices);
- while (anchoredEntities.MoveNext(out var ent))
+ while (anchoredEntities.MoveNext(out var anchored))
{
- if (_blockQuery.HasComponent(ent.Value))
+ if (_blockQuery.HasComponent(anchored.Value))
return false;
}
return true;
-
}
- public float GetPercent(WeatherData component, EntityUid mapUid)
+ public float GetWeatherPercent(Entity ent)
{
- var pauseTime = _metadata.GetPauseTime(mapUid);
- var elapsed = Timing.CurTime - (component.StartTime + pauseTime);
- var duration = component.Duration;
+ var elapsed = Timing.CurTime - ent.Comp.StartEffectTime;
+ var duration = ent.Comp.Duration;
var remaining = duration - elapsed;
- float alpha;
- if (remaining < WeatherComponent.ShutdownTime)
- {
- alpha = (float) (remaining / WeatherComponent.ShutdownTime);
- }
- else if (elapsed < WeatherComponent.StartupTime)
- {
- alpha = (float) (elapsed / WeatherComponent.StartupTime);
- }
- else
- {
- alpha = 1f;
- }
+ if (remaining < ShutdownTime)
+ return (float) (remaining / ShutdownTime);
+ if (elapsed < StartupTime)
+ return (float) (elapsed / StartupTime);
- return alpha;
+ return 1f;
}
-
- public override void Update(float frameTime)
+ public bool TryAddWeather(MapId mapId, EntProtoId weatherProto, [NotNullWhen(true)] out EntityUid? weatherEnt, TimeSpan? duration = null)
{
- base.Update(frameTime);
-
- if (!Timing.IsFirstTimePredicted)
- return;
-
- var curTime = Timing.CurTime;
-
- var query = EntityQueryEnumerator();
- while (query.MoveNext(out var uid, out var comp))
- {
- if (comp.Weather.Count == 0)
- continue;
+ weatherEnt = null;
- foreach (var (proto, weather) in comp.Weather)
- {
- var endTime = weather.EndTime;
-
- // Ended
- if (endTime != null && endTime < curTime)
- {
- EndWeather(uid, comp, proto);
- continue;
- }
+ if (!_mapSystem.TryGetMap(mapId, out var mapUid))
+ return false;
- var remainingTime = endTime - curTime;
+ return TryAddWeather(mapUid.Value, weatherProto, out weatherEnt, duration);
+ }
- // Admin messed up or the likes.
- if (!ProtoMan.TryIndex(proto, out var weatherProto))
- {
- Log.Error($"Unable to find weather prototype for {comp.Weather}, ending!");
- EndWeather(uid, comp, proto);
- continue;
- }
-
- // Shutting down
- if (endTime != null && remainingTime < WeatherComponent.ShutdownTime)
- {
- SetState(uid, WeatherState.Ending, comp, weather, weatherProto);
- }
- // Starting up
- else
- {
- var startTime = weather.StartTime;
- var elapsed = Timing.CurTime - startTime;
-
- if (elapsed < WeatherComponent.StartupTime)
- {
- SetState(uid, WeatherState.Starting, comp, weather, weatherProto);
- }
- }
-
- // Run whatever code we need.
- Run(uid, weather, weatherProto, frameTime);
- }
- }
+ public bool TryAddWeather(EntityUid mapUid, EntProtoId weatherProto, [NotNullWhen(true)] out EntityUid? weatherEnt, TimeSpan? duration = null)
+ {
+ return _statusEffects.TrySetStatusEffectDuration(mapUid, weatherProto, out weatherEnt, duration);
}
- ///
- /// Shuts down all existing weather and starts the new one if applicable.
- ///
- public void SetWeather(MapId mapId, WeatherPrototype? proto, TimeSpan? endTime)
+ public bool HasWeather(MapId mapId, EntProtoId weatherProto)
{
if (!_mapSystem.TryGetMap(mapId, out var mapUid))
- return;
-
- var weatherComp = EnsureComp(mapUid.Value);
-
- foreach (var (eProto, weather) in weatherComp.Weather)
- {
- // if we turn off the weather, we don't want endTime = null
- if (proto == null)
- endTime ??= Timing.CurTime + WeatherComponent.ShutdownTime;
-
- // Reset cooldown if it's an existing one.
- if (proto is not null && eProto == proto.ID)
- {
- weather.EndTime = endTime;
- if (weather.State == WeatherState.Ending)
- weather.State = WeatherState.Running;
-
- Dirty(mapUid.Value, weatherComp);
- continue;
- }
-
- // Speedrun
- var end = Timing.CurTime + WeatherComponent.ShutdownTime;
-
- if (weather.EndTime == null || weather.EndTime > end)
- {
- weather.EndTime = end;
- Dirty(mapUid.Value, weatherComp);
- }
- }
+ return false;
- if (proto != null)
- StartWeather(mapUid.Value, weatherComp, proto, endTime);
+ return _statusEffects.TryGetStatusEffect(mapUid.Value, weatherProto, out _);
}
- ///
- /// Run every tick when the weather is running.
- ///
- protected virtual void Run(EntityUid uid, WeatherData weather, WeatherPrototype weatherProto, float frameTime) { }
-
- protected void StartWeather(EntityUid uid, WeatherComponent component, WeatherPrototype weather, TimeSpan? endTime)
+ public bool TryRemoveWeather(MapId mapId, EntProtoId weatherProto)
{
- if (component.Weather.ContainsKey(weather.ID))
- return;
-
- var data = new WeatherData()
- {
- StartTime = Timing.CurTime,
- EndTime = endTime,
- };
+ if (!_mapSystem.TryGetMap(mapId, out var mapUid))
+ return false;
- component.Weather.Add(weather.ID, data);
- Dirty(uid, component);
+ return TryRemoveWeather(mapUid.Value, weatherProto);
}
- protected virtual void EndWeather(EntityUid uid, WeatherComponent component, string proto)
+ public bool TryRemoveWeather(EntityUid mapUid, EntProtoId weatherProto)
{
- if (!component.Weather.TryGetValue(proto, out var data))
- return;
+ if (!_statusEffects.TryGetStatusEffect(mapUid, weatherProto, out var weatherEnt))
+ return false;
+
+ if (!_weatherQuery.HasComp(weatherEnt))
+ return false;
- _audio.Stop(data.Stream);
- data.Stream = null;
- component.Weather.Remove(proto);
- Dirty(uid, component);
+ return _statusEffects.TrySetStatusEffectDuration(mapUid, weatherProto, ShutdownTime);
}
- protected virtual bool SetState(EntityUid uid, WeatherState state, WeatherComponent component, WeatherData weather, WeatherPrototype weatherProto)
+ public bool TrySetWeather(MapId mapId, EntProtoId? weatherProto, out EntityUid? weatherEnt, TimeSpan? duration = null)
{
- if (weather.State.Equals(state))
+ weatherEnt = null;
+ if (!_mapSystem.TryGetMap(mapId, out var mapUid))
return false;
- weather.State = state;
- Dirty(uid, component);
- return true;
- }
+ if (_statusEffects.TryEffectsWithComp(mapUid.Value, out var effects))
+ {
+ foreach (var effect in effects)
+ {
+ var effectProto = Prototype(effect);
+ if (effectProto == null)
+ continue;
- [Serializable, NetSerializable]
- protected sealed class WeatherComponentState : ComponentState
- {
- public Dictionary, WeatherData> Weather;
+ if (effectProto != weatherProto)
+ TryRemoveWeather(mapUid.Value, effectProto);
+ else
+ weatherEnt = effect;
+ }
+ }
- public WeatherComponentState(Dictionary, WeatherData> weather)
+ if (weatherProto == null)
+ return true;
+
+ if (weatherEnt != null)
{
- Weather = weather;
+ TryAddWeather(mapUid.Value, weatherProto.Value, out weatherEnt, duration);
+ return true;
}
+
+ return TryAddWeather(mapUid.Value, weatherProto.Value, out weatherEnt, duration);
}
}
diff --git a/Content.Shared/Weather/WeatherComponent.cs b/Content.Shared/Weather/WeatherComponent.cs
deleted file mode 100644
index eaf901fb424..00000000000
--- a/Content.Shared/Weather/WeatherComponent.cs
+++ /dev/null
@@ -1,53 +0,0 @@
-using Robust.Shared.GameStates;
-using Robust.Shared.Prototypes;
-using Robust.Shared.Serialization;
-using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
-
-namespace Content.Shared.Weather;
-
-[RegisterComponent, NetworkedComponent]
-public sealed partial class WeatherComponent : Component
-{
- ///
- /// Currently running weathers
- ///
- [DataField]
- public Dictionary, WeatherData> Weather = new();
-
- public static readonly TimeSpan StartupTime = TimeSpan.FromSeconds(15);
- public static readonly TimeSpan ShutdownTime = TimeSpan.FromSeconds(15);
-}
-
-[DataDefinition, Serializable, NetSerializable]
-public sealed partial class WeatherData
-{
- // Client audio stream.
- [NonSerialized]
- public EntityUid? Stream;
-
- ///
- /// When the weather started if relevant.
- ///
- [DataField(customTypeSerializer: typeof(TimeOffsetSerializer))] //TODO: Remove Custom serializer
- public TimeSpan StartTime = TimeSpan.Zero;
-
- ///
- /// When the applied weather will end.
- ///
- [DataField(customTypeSerializer: typeof(TimeOffsetSerializer))] //TODO: Remove Custom serializer
- public TimeSpan? EndTime;
-
- [ViewVariables]
- public TimeSpan Duration => EndTime == null ? TimeSpan.MaxValue : EndTime.Value - StartTime;
-
- [DataField]
- public WeatherState State = WeatherState.Invalid;
-}
-
-public enum WeatherState : byte
-{
- Invalid = 0,
- Starting,
- Running,
- Ending,
-}
diff --git a/Content.Shared/Weather/WeatherPrototype.cs b/Content.Shared/Weather/WeatherPrototype.cs
deleted file mode 100644
index 246e929dcef..00000000000
--- a/Content.Shared/Weather/WeatherPrototype.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-using Robust.Shared.Audio;
-using Robust.Shared.Prototypes;
-using Robust.Shared.Utility;
-
-namespace Content.Shared.Weather;
-
-[Prototype]
-public sealed partial class WeatherPrototype : IPrototype
-{
- [IdDataField] public string ID { get; private set; } = default!;
-
- [ViewVariables(VVAccess.ReadWrite), DataField("sprite", required: true)]
- public SpriteSpecifier Sprite = default!;
-
- [ViewVariables(VVAccess.ReadWrite), DataField("color")]
- public Color? Color;
-
- ///
- /// Sound to play on the affected areas.
- ///
- [ViewVariables(VVAccess.ReadWrite), DataField("sound")]
- public SoundSpecifier? Sound;
-}
diff --git a/Content.Shared/Weather/WeatherStatusEffectComponent.cs b/Content.Shared/Weather/WeatherStatusEffectComponent.cs
new file mode 100644
index 00000000000..177766dace8
--- /dev/null
+++ b/Content.Shared/Weather/WeatherStatusEffectComponent.cs
@@ -0,0 +1,26 @@
+using System.Numerics;
+using Content.Shared.StatusEffectNew.Components;
+using Robust.Shared.Audio;
+using Robust.Shared.GameStates;
+using Robust.Shared.Utility;
+
+namespace Content.Shared.Weather;
+
+[RegisterComponent, NetworkedComponent, Access(typeof(SharedWeatherSystem))]
+public sealed partial class WeatherStatusEffectComponent : Component
+{
+ [DataField(required: true)]
+ public SpriteSpecifier Sprite = default!;
+
+ [DataField]
+ public Color? Color;
+
+ [DataField]
+ public Vector2? Scrolling;
+
+ [DataField]
+ public SoundSpecifier? Sound;
+
+ [ViewVariables]
+ public EntityUid? Stream;
+}
diff --git a/Content.Shared/Worldgen/Prototypes/SectorAsteroidBiomePrototype.cs b/Content.Shared/Worldgen/Prototypes/SectorAsteroidBiomePrototype.cs
new file mode 100644
index 00000000000..fed6efded6c
--- /dev/null
+++ b/Content.Shared/Worldgen/Prototypes/SectorAsteroidBiomePrototype.cs
@@ -0,0 +1,20 @@
+using Content.Shared.Maps;
+using Content.Shared.Storage;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.Dictionary;
+
+namespace Content.Shared.Worldgen.Prototypes;
+
+[Prototype("sectorAsteroidBiome")]
+public sealed partial class SectorAsteroidBiomePrototype : IPrototype
+{
+ [IdDataField]
+ public string ID { get; private set; } = string.Empty;
+
+ [DataField("floorTiles", required: true)]
+ public List FloorTiles = new();
+
+ [DataField("entries", required: true,
+ customTypeSerializer: typeof(PrototypeIdDictionarySerializer, ContentTileDefinition>))]
+ public Dictionary> Entries = default!;
+}
\ No newline at end of file
diff --git a/Resources/Locale/en-US/_NF/procedural/expeditions.ftl b/Resources/Locale/en-US/_NF/procedural/expeditions.ftl
index 99c1a252898..5872e2a53b6 100644
--- a/Resources/Locale/en-US/_NF/procedural/expeditions.ftl
+++ b/Resources/Locale/en-US/_NF/procedural/expeditions.ftl
@@ -1,6 +1,7 @@
salvage-expedition-window-finish = Finish expedition
salvage-expedition-window-refresh = Refresh
salvage-expedition-announcement-early-finish = The expedition was completed ahead of schedule. Shuttle will depart in {$departTime} seconds.
+salvage-expedition-announcement-briefing = Objective: {$objective}. Difficulty: {$difficulty}. Biome: {$biome}.
salvage-expedition-announcement-destruction = { $count ->
[1] Destroy the {$structure} before the expedition ends.
*[others] Destroy {$count} {MAKEPLURAL($structure)} before the expedition ends.
diff --git a/Resources/Locale/en-US/commands/persistence-save-command.ftl b/Resources/Locale/en-US/commands/persistence-save-command.ftl
new file mode 100644
index 00000000000..d12ff8cbee3
--- /dev/null
+++ b/Resources/Locale/en-US/commands/persistence-save-command.ftl
@@ -0,0 +1,3 @@
+cmd-persistencesave-desc = Saves server data to a persistence file to be loaded later.
+cmd-persistencesave-usage = persistencesave [mapId] [filePath - default: game.map (CCVar) ]
+cmd-persistencesave-no-path = filePath was not specified and CCVar {$cvar} is not set. Manually set the filePath param in order to save the map.
diff --git a/Resources/Locale/en-US/persistence/command.ftl b/Resources/Locale/en-US/persistence/command.ftl
deleted file mode 100644
index b070aee1159..00000000000
--- a/Resources/Locale/en-US/persistence/command.ftl
+++ /dev/null
@@ -1 +0,0 @@
-cmd-persistencesave-no-path = filePath was not specified and CCVar {$cvar} is not set. Manually set the filePath param in order to save the map.
diff --git a/Resources/Locale/en-US/weather/weather.ftl b/Resources/Locale/en-US/weather/weather.ftl
index 0c67b6f66bf..146364136fe 100644
--- a/Resources/Locale/en-US/weather/weather.ftl
+++ b/Resources/Locale/en-US/weather/weather.ftl
@@ -1,8 +1,17 @@
-cmd-weather-desc = Sets the weather for the current map.
-cmd-weather-help = weather
-cmd-weather-hint = Weather prototype
-cmd-weather-null = Clears the weather
+cmd-weatherremove-desc = Remove specific weather from map.
+cmd-weatherset-desc = Removes all weather except the specified one. If the specified weather does not exist on the map, it adds it.
+cmd-weatheradd-desc = Add specific weather to map.
+
+cmd-weatherremove-help = weatherremove
+cmd-weatherset-help = weatherset
+cmd-weatheradd-help = weatheradd
cmd-weather-error-no-arguments = Not enough arguments!
cmd-weather-error-unknown-proto = Unknown Weather prototype!
-cmd-weather-error-wrong-time = Time is in the wrong format!
\ No newline at end of file
+cmd-weather-error-wrong-time = Time is in the wrong format!
+cmd-weather-error-wrong-map = Map with MapId {$id} doesn't exist!
+cmd-weather-error-no-weather = This weather does not exist on the selected map!
+
+cmd-weather-hint-map-id = Map Id
+cmd-weather-hint-prototype = Weather entity prototype
+cmd-weather-hint-time = Duration in seconds (leave empty for infinite duration)
diff --git a/Resources/Prototypes/Entities/StatusEffects/weather.yml b/Resources/Prototypes/Entities/StatusEffects/weather.yml
new file mode 100644
index 00000000000..c0442203af7
--- /dev/null
+++ b/Resources/Prototypes/Entities/StatusEffects/weather.yml
@@ -0,0 +1,179 @@
+- type: entity
+ id: StatusEffectBase
+ abstract: true
+
+- type: entity
+ parent: StatusEffectBase
+ id: WeatherBase
+ abstract: true
+ components:
+ - type: WeatherStatusEffect
+ - type: StatusEffect
+
+- type: entity
+ parent: WeatherBase
+ id: WeatherRain
+ components:
+ - type: WeatherStatusEffect
+ sprite:
+ sprite: /Textures/Effects/weather.rsi
+ state: rain
+ sound:
+ collection: Rain
+ params:
+ loop: true
+ volume: -6
+
+- type: entity
+ parent: WeatherBase
+ id: WeatherAshfall
+ components:
+ - type: WeatherStatusEffect
+ sprite:
+ sprite: /Textures/Effects/weather.rsi
+ state: ashfall
+ sound:
+ path: /Audio/Effects/Weather/snowstorm_weak.ogg
+ params:
+ loop: true
+ volume: -6
+
+- type: entity
+ parent: WeatherBase
+ id: WeatherAshfallLight
+ components:
+ - type: WeatherStatusEffect
+ sprite:
+ sprite: /Textures/Effects/weather.rsi
+ state: ashfall_light
+ sound:
+ path: /Audio/Effects/Weather/snowstorm_weak.ogg
+ params:
+ loop: true
+ volume: -6
+
+- type: entity
+ parent: WeatherBase
+ id: WeatherAshfallHeavy
+ components:
+ - type: WeatherStatusEffect
+ sprite:
+ sprite: /Textures/Effects/weather.rsi
+ state: ashfall_heavy
+ sound:
+ path: /Audio/Effects/Weather/snowstorm.ogg
+ params:
+ loop: true
+ volume: -6
+
+- type: entity
+ parent: WeatherBase
+ id: WeatherFallout
+ components:
+ - type: WeatherStatusEffect
+ sprite:
+ sprite: /Textures/Effects/weather.rsi
+ state: fallout
+ sound:
+ path: /Audio/Effects/Weather/snowstorm_weak.ogg
+ params:
+ loop: true
+ volume: -6
+
+- type: entity
+ parent: WeatherBase
+ id: WeatherHail
+ components:
+ - type: WeatherStatusEffect
+ sprite:
+ sprite: /Textures/Effects/weather.rsi
+ state: hail
+ sound:
+ path: /Audio/Effects/Weather/rain.ogg
+ params:
+ loop: true
+ volume: -6
+
+- type: entity
+ parent: WeatherBase
+ id: WeatherSandstorm
+ components:
+ - type: WeatherStatusEffect
+ sprite:
+ sprite: /Textures/Effects/weather.rsi
+ state: sandstorm
+ sound:
+ path: /Audio/Effects/Weather/snowstorm_weak.ogg
+ params:
+ loop: true
+ volume: -6
+
+- type: entity
+ parent: WeatherBase
+ id: WeatherSandstormHeavy
+ components:
+ - type: WeatherStatusEffect
+ sprite:
+ sprite: /Textures/Effects/weather.rsi
+ state: sandstorm_heavy
+ sound:
+ path: /Audio/Effects/Weather/snowstorm.ogg
+ params:
+ loop: true
+ volume: -6
+
+- type: entity
+ parent: WeatherBase
+ id: WeatherSnowfallLight
+ components:
+ - type: WeatherStatusEffect
+ sprite:
+ sprite: /Textures/Effects/weather.rsi
+ state: snowfall_light
+ sound:
+ path: /Audio/Effects/Weather/snowstorm_weak.ogg
+ params:
+ loop: true
+ volume: -6
+
+- type: entity
+ parent: WeatherBase
+ id: WeatherSnowfallMedium
+ components:
+ - type: WeatherStatusEffect
+ sprite:
+ sprite: /Textures/Effects/weather.rsi
+ state: snowfall_med
+ sound:
+ path: /Audio/Effects/Weather/snowstorm_weak.ogg
+ params:
+ loop: true
+ volume: -6
+
+- type: entity
+ parent: WeatherBase
+ id: WeatherSnowfallHeavy
+ components:
+ - type: WeatherStatusEffect
+ sprite:
+ sprite: /Textures/Effects/weather.rsi
+ state: snowfall_heavy
+ sound:
+ path: /Audio/Effects/Weather/snowstorm.ogg
+ params:
+ loop: true
+ volume: -6
+
+- type: entity
+ parent: WeatherBase
+ id: WeatherStorm
+ components:
+ - type: WeatherStatusEffect
+ sprite:
+ sprite: /Textures/Effects/weather.rsi
+ state: storm
+ sound:
+ path: /Audio/Effects/Weather/rain_heavy.ogg
+ params:
+ loop: true
+ volume: -6
diff --git a/Resources/Prototypes/Entities/Structures/Machines/Computers/computers.yml b/Resources/Prototypes/Entities/Structures/Machines/Computers/computers.yml
index 500adb8e35d..08facf50fad 100644
--- a/Resources/Prototypes/Entities/Structures/Machines/Computers/computers.yml
+++ b/Resources/Prototypes/Entities/Structures/Machines/Computers/computers.yml
@@ -134,7 +134,7 @@
type: WiresBoundUserInterface
- type: RadarConsole
- type: WorldLoader
- radius: 64 # Mono
+ radius: 64 # Mono
- type: PointLight
radius: 1.5
energy: 1.6
diff --git a/Resources/Prototypes/Entities/World/chunk.yml b/Resources/Prototypes/Entities/World/chunk.yml
index 718139dcc76..ddc5c9218a3 100644
--- a/Resources/Prototypes/Entities/World/chunk.yml
+++ b/Resources/Prototypes/Entities/World/chunk.yml
@@ -8,6 +8,30 @@
categories: [ HideSpawnMenu ]
components:
- type: WorldChunk
+ - type: SectorChunkCarver
+ densityNoiseChannel: SectorDensity
+ islandNoiseChannel: SectorSparse
+ biomes:
+ - SectorRock
+ - SectorIce
+ - SectorAndesite
+ - SectorBasalt
+ - SectorSand
+ - SectorChromite
+ - SectorRust
+ - SectorScrap
+ - SectorWreck
+ - SectorBrass
+ sparseFieldScale: 54
+ chunkFieldScale: 5
+ chunkThreshold: 0.47
+ islandFieldScale: 18
+ detailFieldScale: 10
+ sparseThreshold: 0.66
+ densityThreshold: 0.5
+ islandThreshold: 0.56
+ densitySharpness: 2.6
+ planetFalloff: 0.04
- type: Sprite
sprite: Markers/cross.rsi
layers:
diff --git a/Resources/Prototypes/SoundCollections/weather.yml b/Resources/Prototypes/SoundCollections/weather.yml
new file mode 100644
index 00000000000..64efea8c613
--- /dev/null
+++ b/Resources/Prototypes/SoundCollections/weather.yml
@@ -0,0 +1,5 @@
+- type: soundCollection
+ id: Rain
+ files:
+ - /Audio/Effects/Weather/rain.ogg
+ - /Audio/Effects/Weather/rain2.ogg
diff --git a/Resources/Prototypes/World/noise_channels.yml b/Resources/Prototypes/World/noise_channels.yml
index 27d853eeb7e..10b42a084de 100644
--- a/Resources/Prototypes/World/noise_channels.yml
+++ b/Resources/Prototypes/World/noise_channels.yml
@@ -37,6 +37,23 @@
inputMultiplier: 16 # Makes wreck concentration very low noise at scale.
outputMultiplier: 0.35 # Frontier: fewer wrecks
+- type: noiseChannel
+ id: SectorDensity
+ noiseType: Perlin
+ fractalLacunarityByPi: 0.666666666
+ remapTo0Through1: true
+ inputMultiplier: 6
+
+- type: noiseChannel
+ id: SectorSparse
+ noiseType: Perlin
+ fractalLacunarityByPi: 0.666666666
+ clippingRanges:
+ - 0.0, 0.4
+ clippedValue: 0
+ remapTo0Through1: true
+ inputMultiplier: 16
+
- type: noiseChannel
id: Temperature
noiseType: Perlin
diff --git a/Resources/Prototypes/World/sector_biomes.yml b/Resources/Prototypes/World/sector_biomes.yml
new file mode 100644
index 00000000000..7a2258aecbd
--- /dev/null
+++ b/Resources/Prototypes/World/sector_biomes.yml
@@ -0,0 +1,252 @@
+- type: sectorAsteroidBiome
+ id: SectorRock
+ floorTiles: [FloorAsteroidSand]
+ entries:
+ FloorAsteroidSand:
+ - id: NFRockMineralSoft
+ orGroup: rock
+ prob: 1.025
+ - id: NFRockMineralHard
+ orGroup: rock
+ prob: 0.1223
+ - id: NFAsteroidRoomMarker
+ orGroup: rock
+ prob: 0.0002
+
+- type: sectorAsteroidBiome
+ id: SectorIce
+ floorTiles: [FloorIce]
+ entries:
+ FloorIce:
+ - id: NFIceMineralSoft
+ orGroup: rock
+ prob: 0.807
+ - id: NFIceMineralHard
+ orGroup: rock
+ prob: 0.1305
+ - id: NFSnowRoomMarker
+ orGroup: rock
+ prob: 0.0002
+
+- type: sectorAsteroidBiome
+ id: SectorAndesite
+ floorTiles: [FloorCaveDrought]
+ entries:
+ FloorCaveDrought:
+ - id: NFAndesiteMineralSoft
+ orGroup: rock
+ prob: 0.929
+ - id: NFAndesiteMineralHard
+ orGroup: rock
+ prob: 0.1317
+ - id: NFAndesiteRoomMarker
+ orGroup: rock
+ prob: 0.0003
+
+- type: sectorAsteroidBiome
+ id: SectorBasalt
+ floorTiles: [FloorBasalt]
+ entries:
+ FloorBasalt:
+ - id: NFBasaltMineralSoft
+ orGroup: rock
+ prob: 0.905
+ - id: NFBasaltMineralHard
+ orGroup: rock
+ prob: 0.115
+ - id: NFBasaltRoomMarker
+ orGroup: rock
+ prob: 0.0002
+
+- type: sectorAsteroidBiome
+ id: SectorSand
+ floorTiles: [FloorLowDesert]
+ entries:
+ FloorLowDesert:
+ - id: NFSandMineralSoft
+ orGroup: rock
+ prob: 0.899
+ - id: NFSandMineralHard
+ orGroup: rock
+ prob: 0.1227
+ - id: NFSandRoomMarker
+ orGroup: rock
+ prob: 0.0002
+
+- type: sectorAsteroidBiome
+ id: SectorChromite
+ floorTiles: [FloorChromite]
+ entries:
+ FloorChromite:
+ - id: NFChromiteMineralSoft
+ orGroup: rock
+ prob: 0.8225
+ - id: NFChromiteMineralHard
+ orGroup: rock
+ prob: 0.11175
+ - id: NFChromiteRoomMarker
+ orGroup: rock
+ prob: 0.0002
+
+- type: sectorAsteroidBiome
+ id: SectorRust
+ floorTiles: [Lattice]
+ entries:
+ Lattice:
+ - id: NFScrapMineralSoft
+ orGroup: rock
+ prob: 0.77
+ - id: WallSolidRust
+ orGroup: rock
+ prob: 0.05
+
+- type: sectorAsteroidBiome
+ id: SectorScrap
+ floorTiles: [Plating, Plating, FloorSteel, Lattice]
+ entries:
+ Plating:
+ - prob: 3
+ - id: SalvageMaterialCrateSpawner
+ prob: 1
+ - id: SalvageCanisterSpawner
+ prob: 0.2
+ - id: SalvageMobSpawner
+ prob: 0.7
+ - id: WallSolid
+ prob: 1
+ - id: Grille
+ prob: 0.5
+ Lattice:
+ - prob: 2
+ - id: Grille
+ prob: 0.2
+ - id: SalvageMaterialCrateSpawner
+ prob: 0.3
+ - id: SalvageCanisterSpawner
+ prob: 0.2
+ FloorSteel:
+ - prob: 3
+ - id: SalvageMaterialCrateSpawner
+ prob: 1
+ - id: SalvageCanisterSpawner
+ prob: 0.2
+ - id: SalvageMobSpawner
+ prob: 0.7
+
+- type: sectorAsteroidBiome
+ id: SectorWreck
+ floorTiles: [Plating, Plating, Plating, FloorSteel, Lattice]
+ entries:
+ FloorSteel: &wreckFloor
+ - prob: 1
+ - id: Girder
+ prob: 0.3
+ - id: WallSolid
+ prob: 0.3
+ - id: WallReinforced
+ prob: 0.2
+ - id: AirlockMaint
+ prob: 0.01
+ - id: Barricade
+ prob: 0.01
+ - id: SalvageSpawnerScrapCommon
+ prob: 3.5
+ - id: SalvageSpawnerTreasure
+ prob: 0.3
+ - id: SpawnDungeonLootSeed
+ prob: 0.1
+ - id: SalvageSpawnerTreasureValuable
+ prob: 0.05
+ - id: SalvageSpawnerEquipment
+ prob: 0.05
+ - id: SalvageSpawnerEquipmentValuable
+ prob: 0.03
+ - id: SpawnDungeonClutterMedsSingle
+ prob: 0.03
+ - id: SpawnDungeonLootBureaucracy
+ prob: 0.03
+ - id: SpawnDungeonLootToolsHydroponics
+ prob: 0.03
+ - id: SalvagePartsT2Spawner
+ prob: 0.01
+ - id: SalvagePartsT3Spawner
+ prob: 0.005
+ - id: SalvagePartsT4Spawner
+ prob: 0.002
+ - id: NFSalvageMaterialCrateSpawner
+ prob: 0.9
+ - id: NFSalvageChemicalBarrelSpawner
+ prob: 0.08
+ - id: NFSalvageServiceBarrelSpawner
+ prob: 0.02
+ - id: NFSalvageDrinkableBarrelSpawner
+ prob: 0.02
+ - id: NFSalvageEmptyBarrelSpawner
+ prob: 0.03
+ - id: SalvageCanisterSpawner
+ prob: 0.2
+ - id: NFSalvageLockerSpawner
+ prob: 0.2
+ - id: NFSalvageGeneratorSpawner
+ prob: 0.1
+ - id: NFSalvageFurnitureSpawner
+ prob: 0.1
+ - id: NFSalvageSuitStorageSpawner
+ prob: 0.1
+ - id: RandomArtifactSpawner
+ prob: 0.05
+ - id: NFSalvageTankSpawnerHighCapacity
+ prob: 0.0005
+ - id: MedicalPodFilled
+ prob: 0.03
+ - id: NFSalvageMobSpawner
+ prob: 0.1
+ - id: NFWreckRoomMarker
+ prob: 0.01
+ Plating: *wreckFloor
+ Lattice:
+ - prob: 1
+ - id: Grille
+ prob: 0.75
+ - id: GrilleBroken
+ prob: 0.25
+
+- type: sectorAsteroidBiome
+ id: SectorBrass
+ floorTiles: [PlatingBrass, PlatingBrass, PlatingBrass, FloorBrassFilled, FloorBrassReebe]
+ entries:
+ FloorBrassFilled: &brassFloor
+ - prob: 3
+ - id: ClockworkGirder
+ prob: 0.3
+ - id: WallClock
+ prob: 0.3
+ - id: ClockworkWindow
+ prob: 0.2
+ - id: PinionAirlock
+ prob: 0.01
+ - id: WindoorClockwork
+ prob: 0.005
+ - id: SalvageSpawnerScrapBrass75
+ prob: 1
+ - id: SalvagePartsT2Spawner
+ prob: 0.1
+ - id: SalvagePartsT3Spawner
+ prob: 0.05
+ - id: SalvagePartsT4Spawner
+ prob: 0.01
+ - id: NFSalvageBrassFurnitureSpawner
+ prob: 0.03
+ - id: AltarTechnology
+ prob: 0.03
+ - id: RipleyChassis
+ prob: 0.03
+ - id: PlushieRatvar
+ prob: 0.005
+ PlatingBrass: *brassFloor
+ FloorBrassReebe:
+ - prob: 1
+ - id: ClockworkGrille
+ prob: 0.75
+ - id: ClockworkGrilleBroken
+ prob: 0.25
\ No newline at end of file
diff --git a/Resources/Prototypes/World/worldgen_default.yml b/Resources/Prototypes/World/worldgen_default.yml
index 45e9773d63f..1c7ba6c89ce 100644
--- a/Resources/Prototypes/World/worldgen_default.yml
+++ b/Resources/Prototypes/World/worldgen_default.yml
@@ -1,9 +1,69 @@
- type: worldgenConfig
id: Default
components:
+ - type: WorldSeed
- type: WorldController
- - type: BiomeSelection
- biomes:
- - AsteroidsFallback
- - Failsafe
- - AsteroidsStandard
+ - type: SectorWorld
+ planetTypes:
+ - id: grassland
+ name: Verdure
+ biomeTemplate: Grasslands
+ biomeAliases: [Grasslands]
+ surfaceTiles: [FloorPlanetGrass]
+ weatherPrototype: Rain
+ minTemperature: 270
+ maxTemperature: 320
+ minOxygen: 12
+ maxOxygen: 24
+ minNitrogen: 32
+ maxNitrogen: 82
+ - id: caves
+ name: Hollow
+ biomeTemplate: NFVGRoidCaves
+ biomeAliases: [Caves]
+ surfaceTiles: [FloorCaveDrought]
+ minTemperature: 250
+ maxTemperature: 310
+ minOxygen: 2
+ maxOxygen: 16
+ minNitrogen: 10
+ maxNitrogen: 42
+ - id: lava
+ name: Cinder
+ biomeTemplate: NFVGRoidLava
+ biomeAliases: [Lava]
+ surfaceTiles: [FloorBasalt]
+ weatherPrototype: Ashfall
+ minTemperature: 340
+ maxTemperature: 500
+ - id: tundra
+ name: Rime
+ biomeTemplate: NFVGRoidSnow
+ biomeAliases: [Snow]
+ surfaceTiles: [FloorSnow, FloorIce]
+ weatherPrototype: SnowfallHeavy
+ minTemperature: 190
+ maxTemperature: 255
+ minOxygen: 2
+ maxOxygen: 12
+ minNitrogen: 8
+ maxNitrogen: 36
+ - id: shadow
+ name: Umbra
+ biomeTemplate: NFVGRoidShadow
+ biomeAliases: [Shadow]
+ surfaceTiles: [FloorChromite]
+ minTemperature: 250
+ maxTemperature: 320
+ minOxygen: 0
+ maxOxygen: 8
+ minNitrogen: 0
+ maxNitrogen: 18
+ - id: scrap
+ name: Husk
+ biomeTemplate: NFVGRoidScrapyard
+ biomeAliases: [Scrapyard]
+ surfaceTiles: [Lattice]
+ weatherPrototype: Rain
+ minTemperature: 260
+ maxTemperature: 335
diff --git a/Resources/Prototypes/_Mono/Entities/Structures/Machines/FireControl/gunnery.yml b/Resources/Prototypes/_Mono/Entities/Structures/Machines/FireControl/gunnery.yml
index 7f1beb2aa33..80eda75b4cd 100644
--- a/Resources/Prototypes/_Mono/Entities/Structures/Machines/FireControl/gunnery.yml
+++ b/Resources/Prototypes/_Mono/Entities/Structures/Machines/FireControl/gunnery.yml
@@ -227,7 +227,7 @@
- type: RadarConsole
maxRange: 1500
- type: WorldLoader
- radius: 256 # Mono
+ radius: 64 # Mono
- type: Computer
board: GunneryControlComputerCircuitboard
- type: PointLight
diff --git a/Resources/Prototypes/_NF/Procedural/salvage_mods.yml b/Resources/Prototypes/_NF/Procedural/salvage_mods.yml
index 5e9a6b8db93..a3b3fb42feb 100644
--- a/Resources/Prototypes/_NF/Procedural/salvage_mods.yml
+++ b/Resources/Prototypes/_NF/Procedural/salvage_mods.yml
@@ -105,13 +105,13 @@
- type: salvageWeatherMod
id: SnowfallHeavy
desc: salvage-weather-mod-heavy-snowfall
- weather: SnowfallHeavy
+ weather: WeatherSnowfallHeavy
cost: 1
- type: salvageWeatherMod
id: Rain
desc: salvage-weather-mod-rain
- weather: Rain
+ weather: WeatherRain
# Air mixtures
- type: salvageAirMod
diff --git a/Resources/Prototypes/_NF/World/worldgen_default.yml b/Resources/Prototypes/_NF/World/worldgen_default.yml
index 0edb1d7c34c..539dc46f27d 100644
--- a/Resources/Prototypes/_NF/World/worldgen_default.yml
+++ b/Resources/Prototypes/_NF/World/worldgen_default.yml
@@ -1,12 +1,69 @@
- type: worldgenConfig
id: NFDefault
components:
+ - type: WorldSeed
- type: WorldController
- - type: BiomeSelection
- biomes:
- - NFAsteroidsNear
- - NFAsteroidsMid
- - NFAsteroidsFar
- - MonoWorldgenVeryFarT1 # Mono
- - MonoWorldgenVeryFarT2Inner # Mono
- - MonoWorldgenVeryFarT2Middle # Mono
+ - type: SectorWorld
+ planetTypes:
+ - id: grassland
+ name: Verdure
+ biomeTemplate: Grasslands
+ biomeAliases: [Grasslands]
+ surfaceTiles: [FloorPlanetGrass]
+ weatherPrototype: Rain
+ minTemperature: 270
+ maxTemperature: 320
+ minOxygen: 12
+ maxOxygen: 24
+ minNitrogen: 32
+ maxNitrogen: 82
+ - id: caves
+ name: Hollow
+ biomeTemplate: NFVGRoidCaves
+ biomeAliases: [Caves]
+ surfaceTiles: [FloorCaveDrought]
+ minTemperature: 250
+ maxTemperature: 310
+ minOxygen: 2
+ maxOxygen: 16
+ minNitrogen: 10
+ maxNitrogen: 42
+ - id: lava
+ name: Cinder
+ biomeTemplate: NFVGRoidLava
+ biomeAliases: [Lava]
+ surfaceTiles: [FloorBasalt]
+ weatherPrototype: Ashfall
+ minTemperature: 340
+ maxTemperature: 500
+ - id: tundra
+ name: Rime
+ biomeTemplate: NFVGRoidSnow
+ biomeAliases: [Snow]
+ surfaceTiles: [FloorSnow, FloorIce]
+ weatherPrototype: SnowfallHeavy
+ minTemperature: 190
+ maxTemperature: 255
+ minOxygen: 2
+ maxOxygen: 12
+ minNitrogen: 8
+ maxNitrogen: 36
+ - id: shadow
+ name: Umbra
+ biomeTemplate: NFVGRoidShadow
+ biomeAliases: [Shadow]
+ surfaceTiles: [FloorChromite]
+ minTemperature: 250
+ maxTemperature: 320
+ minOxygen: 0
+ maxOxygen: 8
+ minNitrogen: 0
+ maxNitrogen: 18
+ - id: scrap
+ name: Husk
+ biomeTemplate: NFVGRoidScrapyard
+ biomeAliases: [Scrapyard]
+ surfaceTiles: [Lattice]
+ weatherPrototype: Rain
+ minTemperature: 260
+ maxTemperature: 335
diff --git a/Resources/Prototypes/weather.yml b/Resources/Prototypes/weather.yml
deleted file mode 100644
index a71e59354af..00000000000
--- a/Resources/Prototypes/weather.yml
+++ /dev/null
@@ -1,138 +0,0 @@
-- type: weather
- id: Ashfall
- sprite:
- sprite: /Textures/Effects/weather.rsi
- state: ashfall
- sound:
- path: /Audio/Effects/Weather/snowstorm_weak.ogg
- params:
- loop: true
- volume: -6
-
-- type: weather
- id: AshfallLight
- sprite:
- sprite: /Textures/Effects/weather.rsi
- state: ashfall_light
- sound:
- path: /Audio/Effects/Weather/snowstorm_weak.ogg
- params:
- loop: true
- volume: -6
-
-- type: weather
- id: AshfallHeavy
- sprite:
- sprite: /Textures/Effects/weather.rsi
- state: ashfall_heavy
- sound:
- path: /Audio/Effects/Weather/snowstorm.ogg
- params:
- loop: true
- volume: -6
-
-- type: weather
- id: Fallout
- sprite:
- sprite: /Textures/Effects/weather.rsi
- state: fallout
- sound:
- path: /Audio/Effects/Weather/snowstorm_weak.ogg
- params:
- loop: true
- volume: -6
-
-- type: weather
- id: Hail
- sprite:
- sprite: /Textures/Effects/weather.rsi
- state: hail
- sound:
- path:
- /Audio/Effects/Weather/rain.ogg
- params:
- loop: true
- volume: -6
-
-- type: weather
- id: Rain
- sprite:
- sprite: /Textures/Effects/weather.rsi
- state: rain
- sound:
- collection: Rain
- params:
- loop: true
- volume: -6
-
-- type: soundCollection
- id: Rain
- files:
- - /Audio/Effects/Weather/rain.ogg
- - /Audio/Effects/Weather/rain2.ogg
-
-- type: weather
- id: Sandstorm
- sprite:
- sprite: /Textures/Effects/weather.rsi
- state: sandstorm
- sound:
- path: /Audio/Effects/Weather/snowstorm_weak.ogg
- params:
- loop: true
- volume: -6
-
-- type: weather
- id: SandstormHeavy
- sprite:
- sprite: /Textures/Effects/weather.rsi
- state: sandstorm_heavy
- sound:
- path: /Audio/Effects/Weather/snowstorm.ogg
- params:
- loop: true
- volume: -6
-
-- type: weather
- id: SnowfallLight
- sprite:
- sprite: /Textures/Effects/weather.rsi
- state: snowfall_light
- sound:
- path: /Audio/Effects/Weather/snowstorm_weak.ogg
- params:
- loop: true
- volume: -6
-
-- type: weather
- id: SnowfallMedium
- sprite:
- sprite: /Textures/Effects/weather.rsi
- state: snowfall_med
- sound:
- path: /Audio/Effects/Weather/snowstorm_weak.ogg
- params:
- loop: true
- volume: -6
-
-- type: weather
- id: SnowfallHeavy
- sprite:
- sprite: /Textures/Effects/weather.rsi
- state: snowfall_heavy
- sound:
- path: /Audio/Effects/Weather/snowstorm.ogg
- params:
- loop: true
- volume: -6
-
-- type: weather
- id: Storm
- sprite:
- sprite: /Textures/Effects/weather.rsi
- state: storm
- sound:
- path: /Audio/Effects/Weather/rain_heavy.ogg
- params:
- loop: true
- volume: -6
diff --git a/SECTOR_WORLDGEN_REWRITE.md b/SECTOR_WORLDGEN_REWRITE.md
new file mode 100644
index 00000000000..e18b05139b7
--- /dev/null
+++ b/SECTOR_WORLDGEN_REWRITE.md
@@ -0,0 +1,198 @@
+# Sector Worldgen Rewrite
+
+## Goal
+
+Replace separate per-expedition map spawning with a single seeded sector model that:
+
+- builds explorable space from deterministic noise and carvers,
+- keeps negative space usable for ship travel and construction,
+- streams chunks in and out around players,
+- preserves discovered and modified areas server-side,
+- spawns expeditions and dungeons inside seeded planetary regions instead of on isolated maps.
+
+## Existing Systems To Reuse
+
+The current codebase already has the core of a streaming world pipeline:
+
+- `WorldControllerSystem` creates and loads chunk entities around players and `WorldLoaderComponent` entities.
+- `BiomeSelectionSystem` selects chunk behavior from noise channels.
+- `DebrisFeaturePlacerSystem` progressively populates chunks as they load.
+- `LocalityLoaderSystem` delays structure activation until nearby chunks are actually loaded.
+- `RoundPersistenceSystem` already stores expedition-related state and can be extended for sector discovery and chunk deltas.
+
+The current salvage path is still separate-map based:
+
+- `SpawnSalvageMissionJob` creates a new map for each expedition.
+- `DungeonSystem` generates a dungeon directly into that map.
+
+That job should become a consumer of sector coordinates, not the owner of procedural map creation.
+
+## Recommended Architecture
+
+### 1. Sector root state
+
+Add a server-side sector authority for each world map.
+
+- `SectorWorldComponent`
+ - `UniverseSeed`
+ - `ChunkSize`
+ - `PlanetRegions`
+ - `GeneratedChunks`
+ - `DirtyChunks`
+- `SectorWorldSystem`
+ - generates the root seed on server start,
+ - creates stable planet descriptors on startup,
+ - answers queries for chunk content, planet conditions, and valid landing zones.
+
+This should sit on the same world map that already uses `WorldControllerComponent`.
+
+### 2. Planet descriptors instead of ad hoc expedition maps
+
+Each planet should be a deterministic descriptor, not a separate always-loaded map.
+
+- `PlanetDescriptor`
+ - `PlanetId`
+ - `DisplayName`
+ - `PlanetSeed`
+ - `Bounds`
+ - `PrimaryBiome`
+ - `Temperature`
+ - `Atmosphere`
+ - `TimeOfDay`
+ - `WeatherBands`
+
+Generate all planet descriptors at server startup from `UniverseSeed`.
+Expeditions then target a descriptor plus a coordinate inside that descriptor.
+
+### 3. Chunk pipeline
+
+Replace debris-only chunk population with a layered chunk generation pass:
+
+1. Evaluate macro noise for region ownership.
+2. Evaluate density and connectivity noise.
+3. Run a carver pass to form continuous asteroid belts, caverns, void lanes, and approach corridors.
+4. Materialize tiles, anchored rocks, hazards, and landmarks for that chunk.
+5. Apply saved deltas from persistence.
+
+Suggested chunk stages:
+
+- `EmptySpace`
+- `RegionResolved`
+- `BaseGeometryGenerated`
+- `StructuresPlaced`
+- `PersistenceApplied`
+
+### 4. Carver model
+
+Do not treat each asteroid as a separate procgen result.
+Instead, build chunk geometry from a signed-density field:
+
+- positive density = solid asteroid mass,
+- near-zero density = edge band,
+- negative density = traversable space.
+
+Recommended inputs:
+
+- continent noise for large asteroid landmasses,
+- ridge noise for belts and spines,
+- warp noise to break grid regularity,
+- corridor noise to guarantee flyable paths,
+- exclusion masks for POIs, dungeons, and player-built protected areas.
+
+The current `NoiseRangeCarverSystem` and distance-based carvers can remain as secondary filters, but the new primary carver should operate on chunk geometry rather than point-cancelling debris spawns.
+
+### 5. Persistence model
+
+Do not save full maps for the sector.
+Save chunk deltas.
+
+Each persisted chunk record should contain only what differs from deterministic generation:
+
+- removed generated entities,
+- spawned player entities,
+- tile changes,
+- anchored structure changes,
+- dungeon placements,
+- discovery metadata,
+- selected landing zones.
+
+Recommended file layout:
+
+- `data/sector//world.json`
+- `data/sector//chunks/_.yml`
+- `data/sector//planets/.json`
+
+### 6. Expeditions as sector placements
+
+Expeditions should become placements inside a planet region.
+
+Flow:
+
+1. Player picks a planet.
+2. Server resolves visible and already-discovered landing zones.
+3. If a new mission is needed, the server finds an unoccupied valid area inside the planet bounds.
+4. `DungeonSystem` generates into a chunk-backed sector location instead of a separate map.
+5. The placement is recorded as a persistent region reservation.
+
+This preserves concurrent expeditions and allows the map to become progressively discovered.
+
+### 7. UI map support
+
+A landing-zone UI should read from server-side sector discovery, not client-side scans.
+
+The UI state should expose:
+
+- planet list,
+- discovered regions,
+- blocked regions,
+- active expeditions,
+- recommended landing zones,
+- atmospheric and thermal warnings.
+
+## Migration Plan
+
+### Phase 1: deterministic seed foundation
+
+- ensure world chunk noise is reproducible,
+- add map-level world seed,
+- audit every worldgen system that currently uses ad hoc random seeds.
+
+### Phase 2: sector authority
+
+- add `SectorWorldComponent` and `SectorWorldSystem`,
+- create startup planet descriptors,
+- expose APIs for chunk queries and planet metadata.
+
+### Phase 3: chunk geometry generation
+
+- add a geometry-first chunk generator,
+- stop using debris placement as the primary asteroid model,
+- store generated chunk metadata and apply chunk deltas.
+
+### Phase 4: expedition migration
+
+- change `SpawnSalvageMissionJob` to request a sector placement,
+- generate dungeons into the live sector,
+- persist reservations and expedition footprints.
+
+### Phase 5: discovery and landing UI
+
+- track explored chunks per planet,
+- expose landing-zone selection,
+- show persistent expedition markers and discovered sites.
+
+## First Implementation Targets
+
+The safest next code changes are:
+
+1. Add `SectorWorldComponent` to worldgen maps.
+2. Add a `SectorWorldSystem` that creates planet descriptors on startup.
+3. Extend chunk load events so a sector generator can populate chunk geometry before debris placement.
+4. Move salvage mission destination selection from `SpawnSalvageMissionJob` into `SectorWorldSystem`.
+
+## Notes
+
+- The existing world chunk controller is the correct base abstraction. Reuse it.
+- The existing separate expedition map flow should be treated as a compatibility layer and removed last.
+- Full-map saves for an infinite world will become too expensive. Save deltas only.
+- Deterministic noise and stable seeds are non-negotiable for chunk unload/reload and server restarts.
\ No newline at end of file