feat(headless): hydrate isolated collision worlds
This commit is contained in:
parent
9569dadb57
commit
b6547ff38c
20 changed files with 1960 additions and 101 deletions
|
|
@ -69,28 +69,6 @@ internal sealed class LocalPlayerModeState : ILocalPlayerModeSource
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Session-scoped server-authoritative movement skills. Player-description
|
||||
/// delivery and player-mode construction share this owner so rebuilding the
|
||||
/// local physics controller cannot fall back to stale defaults.
|
||||
/// </summary>
|
||||
internal static class LocalPlayerSkillProjection
|
||||
{
|
||||
public static bool ApplyTo(
|
||||
AcDream.Runtime.Gameplay.RuntimeMovementSkillState skills,
|
||||
PlayerMovementController? controller)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(skills);
|
||||
AcDream.Runtime.Gameplay.RuntimeMovementSkillSnapshot snapshot =
|
||||
skills.Snapshot;
|
||||
if (controller is null || !snapshot.IsComplete)
|
||||
return false;
|
||||
|
||||
controller.SetCharacterSkills(snapshot.RunSkill, snapshot.JumpSkill);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
internal interface IViewportAspectSource
|
||||
{
|
||||
float Aspect { get; }
|
||||
|
|
|
|||
|
|
@ -361,7 +361,9 @@ internal sealed class PlayerModeController :
|
|||
exactMovement.CancelMoveTo(WeenieError.ActionCancelled);
|
||||
};
|
||||
|
||||
if (LocalPlayerSkillProjection.ApplyTo(_skills, controller))
|
||||
if (RuntimeMovementSkillProjection.ApplyTo(
|
||||
_skills,
|
||||
controller))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"live: {loggingTag} — applied server skills "
|
||||
|
|
|
|||
|
|
@ -277,7 +277,7 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
ResolveSkillFormulaBonus: skillCreditResolver.Resolve,
|
||||
OnSkillsUpdated: (runSkill, jumpSkill) =>
|
||||
{
|
||||
if (LocalPlayerSkillProjection.ApplyTo(
|
||||
if (RuntimeMovementSkillProjection.ApplyTo(
|
||||
_domain.Character.MovementSkills,
|
||||
_player.Controller.Controller))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -164,11 +164,19 @@ public sealed class LandblockBuildFactory
|
|||
|
||||
// Task 8: merge stabs + scenery + interior into one entity list.
|
||||
var merged = new List<AcDream.Core.World.WorldEntity>(hydrated);
|
||||
merged.AddRange(BuildSceneryEntitiesForStreaming(
|
||||
merged.AddRange(
|
||||
_dumpSceneryZ
|
||||
? BuildSceneryEntitiesForStreaming(
|
||||
baseLoaded,
|
||||
lbX,
|
||||
lbY,
|
||||
request.Origin));
|
||||
request.Origin)
|
||||
: LandblockPhysicsContentBuilder
|
||||
.HydrateProceduralScenery(
|
||||
_dats,
|
||||
baseLoaded,
|
||||
worldOffset,
|
||||
_heightTable));
|
||||
var envCellBuild = new AcDream.App.Rendering.Wb.EnvCellLandblockBuildBuilder(landblockId);
|
||||
merged.AddRange(BuildInteriorEntitiesForStreaming(
|
||||
landblockId,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using AcDream.Core.Meshing;
|
|||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
using DatEnvironment = DatReaderWriter.DBObjs.Environment;
|
||||
|
||||
namespace AcDream.Content;
|
||||
|
|
@ -16,10 +17,16 @@ namespace AcDream.Content;
|
|||
/// </summary>
|
||||
public static class LandblockPhysicsContentBuilder
|
||||
{
|
||||
public readonly record struct StaticCollisionPublication(
|
||||
int BspOwnerCount,
|
||||
int SetupOwnerCount,
|
||||
int NoCollisionCount);
|
||||
|
||||
public static IReadOnlyList<WorldEntity> HydrateStaticEntities(
|
||||
IDatReaderWriter dats,
|
||||
LoadedLandblock source,
|
||||
Vector3 worldOffset)
|
||||
Vector3 worldOffset,
|
||||
bool includeVisualBounds = true)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dats);
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
|
|
@ -35,13 +42,12 @@ public static class LandblockPhysicsContentBuilder
|
|||
GfxObj? gfx = dats.Get<GfxObj>(sourceId);
|
||||
if (gfx is not null)
|
||||
{
|
||||
(Vector3 Min, Vector3 Max)? partBounds =
|
||||
GfxObjBounds.Get(gfx);
|
||||
if (partBounds is not null)
|
||||
if (includeVisualBounds
|
||||
&& GfxObjBounds.Get(gfx) is { } partBounds)
|
||||
{
|
||||
bounds.Add(
|
||||
Matrix4x4.Identity,
|
||||
partBounds.Value);
|
||||
partBounds);
|
||||
}
|
||||
meshRefs.Add(new MeshRef(
|
||||
sourceId,
|
||||
|
|
@ -59,13 +65,12 @@ public static class LandblockPhysicsContentBuilder
|
|||
dats.Get<GfxObj>(meshRef.GfxObjId);
|
||||
if (gfx is null)
|
||||
continue;
|
||||
(Vector3 Min, Vector3 Max)? partBounds =
|
||||
GfxObjBounds.Get(gfx);
|
||||
if (partBounds is not null)
|
||||
if (includeVisualBounds
|
||||
&& GfxObjBounds.Get(gfx) is { } partBounds)
|
||||
{
|
||||
bounds.Add(
|
||||
meshRef.PartTransform,
|
||||
partBounds.Value);
|
||||
partBounds);
|
||||
}
|
||||
meshRefs.Add(meshRef);
|
||||
}
|
||||
|
|
@ -94,6 +99,151 @@ public static class LandblockPhysicsContentBuilder
|
|||
return hydrated;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hydrates retail's deterministic Region/Scene-generated outdoor
|
||||
/// scenery into the same immutable entity shape consumed by graphical
|
||||
/// and direct hosts. The supplied offset is the host's current
|
||||
/// landblock-relative world origin; DAT placement remains local.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<WorldEntity> HydrateProceduralScenery(
|
||||
IDatReaderWriter dats,
|
||||
LoadedLandblock source,
|
||||
Vector3 worldOffset,
|
||||
ReadOnlySpan<float> heightTable,
|
||||
bool includeVisualBounds = true)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dats);
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
if (heightTable.Length < 256)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"The retail terrain height table must contain at least 256 entries.",
|
||||
nameof(heightTable));
|
||||
}
|
||||
|
||||
Region? region = dats.Get<Region>(0x13000000u);
|
||||
if (region is null)
|
||||
return [];
|
||||
|
||||
HashSet<int>? buildingCells = null;
|
||||
LandBlockInfo? info = dats.Get<LandBlockInfo>(
|
||||
(source.LandblockId & 0xFFFF0000u) | 0xFFFEu);
|
||||
if (info is not null)
|
||||
{
|
||||
buildingCells = [];
|
||||
foreach (BuildingInfo building in info.Buildings)
|
||||
{
|
||||
int cellX = Math.Clamp(
|
||||
(int)(building.Frame.Origin.X / 24f),
|
||||
0,
|
||||
8);
|
||||
int cellY = Math.Clamp(
|
||||
(int)(building.Frame.Origin.Y / 24f),
|
||||
0,
|
||||
8);
|
||||
buildingCells.Add(cellX * 9 + cellY);
|
||||
}
|
||||
}
|
||||
|
||||
IReadOnlyList<SceneryGenerator.ScenerySpawn> spawns =
|
||||
SceneryGenerator.Generate(
|
||||
dats,
|
||||
region,
|
||||
source.Heightmap,
|
||||
source.LandblockId,
|
||||
buildingCells);
|
||||
if (spawns.Count == 0)
|
||||
return [];
|
||||
|
||||
var entities = new List<WorldEntity>(spawns.Count);
|
||||
uint landblockX =
|
||||
(source.LandblockId >> 24) & 0xFFu;
|
||||
uint landblockY =
|
||||
(source.LandblockId >> 16) & 0xFFu;
|
||||
uint sceneryCounter = 0u;
|
||||
foreach (SceneryGenerator.ScenerySpawn spawn in spawns)
|
||||
{
|
||||
var meshRefs = new List<MeshRef>();
|
||||
var bounds = new LocalBoundsAccumulator();
|
||||
Matrix4x4 scale =
|
||||
Matrix4x4.CreateScale(spawn.Scale);
|
||||
if ((spawn.ObjectId & 0xFF000000u) == 0x01000000u)
|
||||
{
|
||||
GfxObj? gfx = dats.Get<GfxObj>(spawn.ObjectId);
|
||||
if (gfx is not null)
|
||||
{
|
||||
if (includeVisualBounds
|
||||
&& GfxObjBounds.Get(gfx) is { } partBounds)
|
||||
{
|
||||
bounds.Add(scale, partBounds);
|
||||
}
|
||||
meshRefs.Add(new MeshRef(spawn.ObjectId, scale));
|
||||
}
|
||||
}
|
||||
else if ((spawn.ObjectId & 0xFF000000u) == 0x02000000u)
|
||||
{
|
||||
Setup? setup = dats.Get<Setup>(spawn.ObjectId);
|
||||
if (setup is not null)
|
||||
{
|
||||
foreach (MeshRef meshRef in SetupMesh.Flatten(setup))
|
||||
{
|
||||
GfxObj? gfx =
|
||||
dats.Get<GfxObj>(meshRef.GfxObjId);
|
||||
if (gfx is null)
|
||||
continue;
|
||||
Matrix4x4 partTransform =
|
||||
meshRef.PartTransform * scale;
|
||||
if (includeVisualBounds
|
||||
&& GfxObjBounds.Get(gfx) is { } partBounds)
|
||||
{
|
||||
bounds.Add(partTransform, partBounds);
|
||||
}
|
||||
meshRefs.Add(new MeshRef(
|
||||
meshRef.GfxObjId,
|
||||
partTransform));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (meshRefs.Count == 0)
|
||||
continue;
|
||||
|
||||
float localX = spawn.LocalPosition.X;
|
||||
float localY = spawn.LocalPosition.Y;
|
||||
float groundZ = TerrainSurface.SampleZFromHeightmap(
|
||||
source.Heightmap.Height,
|
||||
heightTable,
|
||||
landblockX,
|
||||
landblockY,
|
||||
localX,
|
||||
localY);
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = ProceduralSceneryIdAllocator.Allocate(
|
||||
landblockX,
|
||||
landblockY,
|
||||
ref sceneryCounter),
|
||||
SourceGfxObjOrSetupId = spawn.ObjectId,
|
||||
Position = new Vector3(
|
||||
localX,
|
||||
localY,
|
||||
groundZ + spawn.LocalPosition.Z)
|
||||
+ worldOffset,
|
||||
Rotation = spawn.Rotation,
|
||||
MeshRefs = meshRefs,
|
||||
Scale = spawn.Scale,
|
||||
EffectCellId =
|
||||
TerrainSurface.ComputeOutdoorCellId(
|
||||
source.LandblockId,
|
||||
localX,
|
||||
localY),
|
||||
};
|
||||
if (bounds.TryGet(out Vector3 min, out Vector3 max))
|
||||
entity.SetLocalBounds(min, max);
|
||||
entities.Add(entity);
|
||||
}
|
||||
return entities;
|
||||
}
|
||||
|
||||
public static PhysicsDatBundle BuildDatBundle(
|
||||
IDatReaderWriter dats,
|
||||
uint landblockId,
|
||||
|
|
@ -247,6 +397,353 @@ public static class LandblockPhysicsContentBuilder
|
|||
envCellIds);
|
||||
}
|
||||
|
||||
public static TerrainSurface BuildTerrainSurface(
|
||||
LoadedLandblock landblock,
|
||||
ReadOnlySpan<float> heightTable)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(landblock);
|
||||
if (heightTable.Length < 256)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"The retail terrain height table must contain at least 256 entries.",
|
||||
nameof(heightTable));
|
||||
}
|
||||
|
||||
uint landblockX = (landblock.LandblockId >> 24) & 0xFFu;
|
||||
uint landblockY = (landblock.LandblockId >> 16) & 0xFFu;
|
||||
var terrainBytes = new byte[81];
|
||||
for (int index = 0; index < terrainBytes.Length; index++)
|
||||
{
|
||||
terrainBytes[index] =
|
||||
(byte)(ushort)landblock.Heightmap.Terrain[index];
|
||||
}
|
||||
return new TerrainSurface(
|
||||
landblock.Heightmap.Height,
|
||||
heightTable,
|
||||
landblockX,
|
||||
landblockY,
|
||||
terrainBytes);
|
||||
}
|
||||
|
||||
public static void PublishPreparedCells(
|
||||
PhysicsDataCache cache,
|
||||
LoadedLandblock landblock,
|
||||
LandblockCollisionBuild collisions,
|
||||
Vector3 origin,
|
||||
ICollection<CellSurface> cellSurfaces,
|
||||
ICollection<PortalPlane> portalPlanes)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(cache);
|
||||
ArgumentNullException.ThrowIfNull(landblock);
|
||||
ArgumentNullException.ThrowIfNull(collisions);
|
||||
ArgumentNullException.ThrowIfNull(cellSurfaces);
|
||||
ArgumentNullException.ThrowIfNull(portalPlanes);
|
||||
|
||||
PhysicsDatBundle dats =
|
||||
landblock.PhysicsDats ?? PhysicsDatBundle.Empty;
|
||||
uint count = dats.Info?.NumCells ?? 0u;
|
||||
for (uint offset = 0; offset < count; offset++)
|
||||
{
|
||||
uint envCellId =
|
||||
(landblock.LandblockId & 0xFFFF0000u)
|
||||
| (0x0100u + offset);
|
||||
if (!dats.EnvCells.TryGetValue(
|
||||
envCellId,
|
||||
out EnvCell? envCell)
|
||||
|| !collisions.CellStructures.TryGetValue(
|
||||
envCellId,
|
||||
out FlatCellStructureCollisionAsset? structure)
|
||||
|| !collisions.EnvCells.TryGetValue(
|
||||
envCellId,
|
||||
out FlatEnvCellTopology? topology))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Quaternion rotation = envCell.Position.Orientation;
|
||||
Vector3 cellOriginWorld =
|
||||
envCell.Position.Origin + origin;
|
||||
Matrix4x4 transform =
|
||||
Matrix4x4.CreateFromQuaternion(rotation)
|
||||
* Matrix4x4.CreateTranslation(cellOriginWorld);
|
||||
cache.CacheCellStruct(
|
||||
envCellId,
|
||||
envCell,
|
||||
transform,
|
||||
structure,
|
||||
topology);
|
||||
|
||||
FlatPolygonTable portalPolygons =
|
||||
structure.PortalPolygons;
|
||||
for (int portalIndex = 0;
|
||||
portalIndex < topology.Portals.Length;
|
||||
portalIndex++)
|
||||
{
|
||||
FlatEnvCellPortal portal =
|
||||
topology.Portals[portalIndex];
|
||||
if ((uint)portal.PolygonIndex
|
||||
>= (uint)portalPolygons.Polygons.Length)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
FlatCollisionPolygon polygon =
|
||||
portalPolygons.Polygons[portal.PolygonIndex];
|
||||
if (polygon.VertexRange.Count < 3)
|
||||
continue;
|
||||
|
||||
var vertices =
|
||||
new Vector3[polygon.VertexRange.Count];
|
||||
for (int index = 0; index < vertices.Length; index++)
|
||||
{
|
||||
Vector3 local = portalPolygons.Vertices[
|
||||
polygon.VertexRange.Start + index];
|
||||
vertices[index] =
|
||||
Vector3.Transform(local, rotation)
|
||||
+ cellOriginWorld;
|
||||
}
|
||||
portalPlanes.Add(PortalPlane.FromVertices(
|
||||
vertices.AsSpan(),
|
||||
portal.OtherCellId,
|
||||
envCellId & 0xFFFFu,
|
||||
portal.Flags));
|
||||
}
|
||||
|
||||
cellSurfaces.Add(new CellSurface(
|
||||
envCellId,
|
||||
structure.PhysicsBsp.PolygonTable,
|
||||
rotation,
|
||||
cellOriginWorld));
|
||||
}
|
||||
}
|
||||
|
||||
public static void CacheBuildings(
|
||||
PhysicsDataCache cache,
|
||||
LoadedLandblock landblock,
|
||||
TerrainSurface terrain,
|
||||
Vector3 origin)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(cache);
|
||||
ArgumentNullException.ThrowIfNull(landblock);
|
||||
ArgumentNullException.ThrowIfNull(terrain);
|
||||
PhysicsDatBundle dats =
|
||||
landblock.PhysicsDats ?? PhysicsDatBundle.Empty;
|
||||
if (dats.Info is not { } info)
|
||||
return;
|
||||
|
||||
uint prefix = landblock.LandblockId & 0xFFFF0000u;
|
||||
foreach (BuildingInfo building in info.Buildings)
|
||||
{
|
||||
var portals =
|
||||
new List<BldPortalInfo>(building.Portals.Count);
|
||||
foreach (var portal in building.Portals)
|
||||
{
|
||||
portals.Add(new BldPortalInfo(
|
||||
prefix | (uint)portal.OtherCellId,
|
||||
unchecked((short)portal.OtherPortalId),
|
||||
(ushort)portal.Flags));
|
||||
}
|
||||
|
||||
Vector3 buildingOrigin = building.Frame.Origin + origin;
|
||||
Matrix4x4 transform =
|
||||
Matrix4x4.CreateFromQuaternion(
|
||||
building.Frame.Orientation)
|
||||
* Matrix4x4.CreateTranslation(buildingOrigin);
|
||||
uint landcellId = prefix
|
||||
| terrain.ComputeOutdoorCellId(
|
||||
building.Frame.Origin.X,
|
||||
building.Frame.Origin.Y);
|
||||
uint shellPartZero = building.ModelId;
|
||||
if ((shellPartZero & 0xFF000000u) == 0x02000000u)
|
||||
{
|
||||
dats.Setups.TryGetValue(
|
||||
building.ModelId,
|
||||
out Setup? setup);
|
||||
shellPartZero =
|
||||
setup is not null && setup.Parts.Count > 0
|
||||
? setup.Parts[0]
|
||||
: 0u;
|
||||
}
|
||||
cache.CacheBuilding(
|
||||
landcellId,
|
||||
portals,
|
||||
transform,
|
||||
shellPartZero);
|
||||
}
|
||||
}
|
||||
|
||||
public static void CachePreparedObjects(
|
||||
PhysicsDataCache cache,
|
||||
LandblockCollisionBuild collisions)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(cache);
|
||||
ArgumentNullException.ThrowIfNull(collisions);
|
||||
foreach ((uint id, FlatGfxObjCollisionAsset asset) in
|
||||
collisions.GfxObjs)
|
||||
{
|
||||
cache.CacheGfxObj(id, asset);
|
||||
}
|
||||
foreach ((uint id, FlatSetupCollision setup) in
|
||||
collisions.Setups)
|
||||
{
|
||||
cache.CacheSetup(id, setup);
|
||||
}
|
||||
}
|
||||
|
||||
public static StaticCollisionPublication PublishStaticCollision(
|
||||
PhysicsEngine engine,
|
||||
PhysicsDataCache cache,
|
||||
LoadedLandblock landblock,
|
||||
LandblockCollisionBuild collisions,
|
||||
Vector3 origin)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(engine);
|
||||
ArgumentNullException.ThrowIfNull(cache);
|
||||
ArgumentNullException.ThrowIfNull(landblock);
|
||||
ArgumentNullException.ThrowIfNull(collisions);
|
||||
|
||||
int bspOwners = 0;
|
||||
int setupOwners = 0;
|
||||
int noCollision = 0;
|
||||
foreach (WorldEntity entity in landblock.Entities)
|
||||
{
|
||||
if (entity.IsBuildingShell)
|
||||
continue;
|
||||
|
||||
IReadOnlyList<ShadowShape> bspShapes =
|
||||
ShadowShapeBuilder.FromLandblockBspParts(
|
||||
entity.MeshRefs,
|
||||
entity.IsBuildingShell,
|
||||
cache.GetGfxObj);
|
||||
if (bspShapes.Count > 0)
|
||||
{
|
||||
engine.ShadowObjects.RegisterMultiPart(
|
||||
entity.Id,
|
||||
entity.Position,
|
||||
entity.Rotation,
|
||||
bspShapes,
|
||||
0u,
|
||||
EntityCollisionFlags.None,
|
||||
worldOffsetX: origin.X,
|
||||
worldOffsetY: origin.Y,
|
||||
landblockId: landblock.LandblockId,
|
||||
seedCellId: entity.ParentCellId ?? 0u,
|
||||
isStatic: true);
|
||||
bspOwners++;
|
||||
continue;
|
||||
}
|
||||
|
||||
FlatSetupCollision? setup =
|
||||
cache.GetFlatSetup(
|
||||
entity.SourceGfxObjOrSetupId);
|
||||
if (setup is null)
|
||||
{
|
||||
noCollision++;
|
||||
continue;
|
||||
}
|
||||
|
||||
float scale = entity.Scale > 0f ? entity.Scale : 1f;
|
||||
var setupShapes = new List<ShadowShape>();
|
||||
for (int index = 0;
|
||||
index < setup.Cylinders.Length;
|
||||
index++)
|
||||
{
|
||||
FlatCollisionCylinder cylinder =
|
||||
setup.Cylinders[index];
|
||||
float radius = cylinder.Radius * scale;
|
||||
float height = (cylinder.Height > 0f
|
||||
? cylinder.Height
|
||||
: cylinder.Radius * 4f) * scale;
|
||||
if (radius <= 0f)
|
||||
continue;
|
||||
setupShapes.Add(new ShadowShape(
|
||||
entity.SourceGfxObjOrSetupId,
|
||||
cylinder.Origin * scale,
|
||||
Quaternion.Identity,
|
||||
scale,
|
||||
ShadowCollisionType.Cylinder,
|
||||
radius,
|
||||
height));
|
||||
}
|
||||
|
||||
if (setup.Cylinders.Length == 0)
|
||||
{
|
||||
for (int index = 0;
|
||||
index < setup.Spheres.Length;
|
||||
index++)
|
||||
{
|
||||
FlatCollisionSphere sphere = setup.Spheres[index];
|
||||
if (sphere.Radius <= 0f)
|
||||
continue;
|
||||
float radius = sphere.Radius * scale;
|
||||
Vector3 localOffset = sphere.Origin * scale;
|
||||
Vector3 localBaseOffset = localOffset
|
||||
+ Vector3.Transform(
|
||||
-Vector3.UnitZ * radius,
|
||||
Quaternion.Inverse(entity.Rotation));
|
||||
setupShapes.Add(new ShadowShape(
|
||||
entity.SourceGfxObjOrSetupId,
|
||||
localBaseOffset,
|
||||
Quaternion.Identity,
|
||||
scale,
|
||||
ShadowCollisionType.Cylinder,
|
||||
radius,
|
||||
radius * 2f));
|
||||
}
|
||||
}
|
||||
|
||||
if (setup.Cylinders.Length == 0
|
||||
&& setup.Spheres.Length == 0
|
||||
&& setup.Radius > 0f)
|
||||
{
|
||||
float radius = setup.Radius * scale;
|
||||
float height = (setup.Height > 0f
|
||||
? setup.Height
|
||||
: setup.Radius * 2f) * scale;
|
||||
setupShapes.Add(new ShadowShape(
|
||||
entity.SourceGfxObjOrSetupId,
|
||||
Vector3.Zero,
|
||||
Quaternion.Identity,
|
||||
scale,
|
||||
ShadowCollisionType.Cylinder,
|
||||
radius,
|
||||
height));
|
||||
}
|
||||
|
||||
if (setupShapes.Count == 0)
|
||||
{
|
||||
noCollision++;
|
||||
continue;
|
||||
}
|
||||
engine.ShadowObjects.RegisterMultiPart(
|
||||
entity.Id,
|
||||
entity.Position,
|
||||
entity.Rotation,
|
||||
setupShapes,
|
||||
0u,
|
||||
EntityCollisionFlags.None,
|
||||
worldOffsetX: origin.X,
|
||||
worldOffsetY: origin.Y,
|
||||
landblockId: landblock.LandblockId,
|
||||
seedCellId: entity.ParentCellId ?? 0u,
|
||||
isStatic: true);
|
||||
setupOwners++;
|
||||
}
|
||||
|
||||
uint[] reflood =
|
||||
engine.ShadowObjects.CaptureRefloodOwnersForLandblock(
|
||||
landblock.LandblockId);
|
||||
foreach (uint ownerId in reflood)
|
||||
{
|
||||
engine.ShadowObjects.RefloodOwnerForLandblock(
|
||||
ownerId,
|
||||
landblock.LandblockId);
|
||||
}
|
||||
return new StaticCollisionPublication(
|
||||
bspOwners,
|
||||
setupOwners,
|
||||
noCollision);
|
||||
}
|
||||
|
||||
private static T Require<T>(
|
||||
PreparedCollisionReadResult<T> result,
|
||||
string kind,
|
||||
|
|
|
|||
|
|
@ -56,9 +56,25 @@ public sealed class TerrainSurface
|
|||
public TerrainSurface(byte[] heights, float[] heightTable,
|
||||
uint landblockX = 0, uint landblockY = 0,
|
||||
byte[]? terrainTypes = null)
|
||||
: this(
|
||||
heights,
|
||||
(heightTable
|
||||
?? throw new ArgumentNullException(nameof(heightTable)))
|
||||
.AsSpan(),
|
||||
landblockX,
|
||||
landblockY,
|
||||
terrainTypes)
|
||||
{
|
||||
}
|
||||
|
||||
public TerrainSurface(
|
||||
byte[] heights,
|
||||
ReadOnlySpan<float> heightTable,
|
||||
uint landblockX = 0,
|
||||
uint landblockY = 0,
|
||||
byte[]? terrainTypes = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(heights);
|
||||
ArgumentNullException.ThrowIfNull(heightTable);
|
||||
if (heights.Length < 81)
|
||||
throw new ArgumentException("heights must have 81 entries", nameof(heights));
|
||||
if (heightTable.Length < 256)
|
||||
|
|
@ -191,8 +207,25 @@ public sealed class TerrainSurface
|
|||
uint landblockX, uint landblockY,
|
||||
float localX, float localY)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(heights);
|
||||
ArgumentNullException.ThrowIfNull(heightTable);
|
||||
return SampleZFromHeightmap(
|
||||
heights,
|
||||
heightTable.AsSpan(),
|
||||
landblockX,
|
||||
landblockY,
|
||||
localX,
|
||||
localY);
|
||||
}
|
||||
|
||||
public static float SampleZFromHeightmap(
|
||||
byte[] heights,
|
||||
ReadOnlySpan<float> heightTable,
|
||||
uint landblockX,
|
||||
uint landblockY,
|
||||
float localX,
|
||||
float localY)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(heights);
|
||||
if (heights.Length < 81)
|
||||
throw new ArgumentException("heights must have 81 entries", nameof(heights));
|
||||
if (heightTable.Length < 256)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
using System.Collections.Immutable;
|
||||
using AcDream.Content;
|
||||
using AcDream.Headless.Configuration;
|
||||
using DatReaderWriter.DBObjs;
|
||||
|
||||
namespace AcDream.Headless.Hosting;
|
||||
|
||||
|
|
@ -25,7 +27,8 @@ internal interface IHeadlessProcessContentFactory
|
|||
internal sealed record HeadlessOpenedProcessContent(
|
||||
IDatReaderWriter Dats,
|
||||
IPreparedAssetSource Prepared,
|
||||
MagicCatalog Magic);
|
||||
MagicCatalog Magic,
|
||||
ImmutableArray<float> HeightTable);
|
||||
|
||||
internal sealed class ProductionHeadlessProcessContentFactory
|
||||
: IHeadlessProcessContentFactory
|
||||
|
|
@ -50,10 +53,24 @@ internal sealed class ProductionHeadlessProcessContentFactory
|
|||
dats,
|
||||
diagnostic);
|
||||
MagicCatalog magic = MagicCatalog.Load(dats);
|
||||
Region region = dats.Get<Region>(0x13000000u)
|
||||
?? throw new InvalidOperationException(
|
||||
"Region dat id 0x13000000 is missing.");
|
||||
float[]? sourceHeightTable =
|
||||
region.LandDefs.LandHeightTable;
|
||||
if (sourceHeightTable is null
|
||||
|| sourceHeightTable.Length < 256)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Region.LandDefs.LandHeightTable is missing or truncated.");
|
||||
}
|
||||
ImmutableArray<float> heightTable =
|
||||
ImmutableArray.CreateRange(sourceHeightTable);
|
||||
return new HeadlessOpenedProcessContent(
|
||||
dats,
|
||||
prepared,
|
||||
magic);
|
||||
magic,
|
||||
heightTable);
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
|
@ -80,6 +97,7 @@ internal sealed class HeadlessProcessContentOwner : IDisposable
|
|||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly MagicCatalog _magic;
|
||||
private readonly ImmutableArray<float> _heightTable;
|
||||
private IDatReaderWriter? _dats;
|
||||
private IPreparedAssetSource? _prepared;
|
||||
private SharedPreparedCollisionCache? _collision;
|
||||
|
|
@ -105,8 +123,13 @@ internal sealed class HeadlessProcessContentOwner : IDisposable
|
|||
IDatReaderWriter? dats = opened.Dats;
|
||||
IPreparedAssetSource? prepared = opened.Prepared;
|
||||
MagicCatalog? magic = opened.Magic;
|
||||
ImmutableArray<float> heightTable = opened.HeightTable;
|
||||
bool incomplete =
|
||||
dats is null || prepared is null || magic is null;
|
||||
dats is null
|
||||
|| prepared is null
|
||||
|| magic is null
|
||||
|| heightTable.IsDefaultOrEmpty
|
||||
|| heightTable.Length < 256;
|
||||
if (incomplete || prepared is not IPreparedCollisionSource)
|
||||
{
|
||||
try
|
||||
|
|
@ -127,6 +150,7 @@ internal sealed class HeadlessProcessContentOwner : IDisposable
|
|||
_dats = dats!;
|
||||
_prepared = prepared!;
|
||||
_magic = magic!;
|
||||
_heightTable = heightTable;
|
||||
_collision = new SharedPreparedCollisionCache(
|
||||
(IPreparedCollisionSource)prepared!);
|
||||
}
|
||||
|
|
@ -149,7 +173,8 @@ internal sealed class HeadlessProcessContentOwner : IDisposable
|
|||
_dats!,
|
||||
_prepared!,
|
||||
_collision!,
|
||||
_magic);
|
||||
_magic,
|
||||
_heightTable);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -220,6 +245,7 @@ internal sealed class HeadlessProcessContentOwner : IDisposable
|
|||
private readonly IPreparedAssetSource _prepared;
|
||||
private readonly IPreparedCollisionSource _collision;
|
||||
private readonly MagicCatalog _magic;
|
||||
private readonly ImmutableArray<float> _heightTable;
|
||||
|
||||
internal HeadlessProcessContentLease(
|
||||
HeadlessProcessContentOwner owner,
|
||||
|
|
@ -227,7 +253,8 @@ internal sealed class HeadlessProcessContentOwner : IDisposable
|
|||
IDatReaderWriter dats,
|
||||
IPreparedAssetSource prepared,
|
||||
IPreparedCollisionSource collision,
|
||||
MagicCatalog magic)
|
||||
MagicCatalog magic,
|
||||
ImmutableArray<float> heightTable)
|
||||
{
|
||||
_owner = owner;
|
||||
SessionId = sessionId;
|
||||
|
|
@ -235,6 +262,7 @@ internal sealed class HeadlessProcessContentOwner : IDisposable
|
|||
_prepared = prepared;
|
||||
_collision = collision;
|
||||
_magic = magic;
|
||||
_heightTable = heightTable;
|
||||
}
|
||||
|
||||
internal string SessionId { get; }
|
||||
|
|
@ -275,6 +303,15 @@ internal sealed class HeadlessProcessContentOwner : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
internal ImmutableArray<float> HeightTable
|
||||
{
|
||||
get
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_owner is null, this);
|
||||
return _heightTable;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
HeadlessProcessContentOwner? owner =
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ internal sealed class HeadlessProcessHost : IDisposable
|
|||
throw new HeadlessConfigurationException(
|
||||
"Headless run mode requires at least one session.");
|
||||
}
|
||||
HeadlessStaticStateAudit.ValidateProcessIsolation();
|
||||
|
||||
_diagnostics = new HeadlessDiagnosticWriter(diagnostics);
|
||||
var credentials = new HeadlessCredentialResolver(
|
||||
|
|
@ -150,7 +151,9 @@ internal sealed class HeadlessProcessHost : IDisposable
|
|||
return HeadlessExitCode.RuntimeError;
|
||||
}
|
||||
|
||||
return HeadlessExitCode.Success;
|
||||
return _scheduler.CaptureSnapshot().FaultedSessionCount > 0
|
||||
? HeadlessExitCode.RuntimeError
|
||||
: HeadlessExitCode.Success;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
|
|
|||
|
|
@ -75,9 +75,12 @@ internal sealed class HeadlessProcessScheduler
|
|||
internal HeadlessSchedulerSnapshot CaptureSnapshot()
|
||||
{
|
||||
int activeSessionCount = 0;
|
||||
int faultedSessionCount = 0;
|
||||
for (int index = 0; index < _slots.Length; index++)
|
||||
{
|
||||
if (!_slots[index].Session.IsPolicyComplete)
|
||||
if (_slots[index].Session.IsFaulted)
|
||||
faultedSessionCount++;
|
||||
else if (!_slots[index].Session.IsPolicyComplete)
|
||||
activeSessionCount++;
|
||||
}
|
||||
|
||||
|
|
@ -87,6 +90,7 @@ internal sealed class HeadlessProcessScheduler
|
|||
Interlocked.Read(ref _turnCount),
|
||||
Interlocked.Read(ref _catchUpCollapseCount),
|
||||
activeSessionCount,
|
||||
faultedSessionCount,
|
||||
NextDeadline());
|
||||
}
|
||||
|
||||
|
|
@ -121,6 +125,8 @@ internal sealed class HeadlessProcessScheduler
|
|||
if (session.IsPolicyComplete)
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
if (session.IsReconnectPending)
|
||||
{
|
||||
if (nowTimestamp < session.ReconnectDeadline)
|
||||
|
|
@ -149,6 +155,12 @@ internal sealed class HeadlessProcessScheduler
|
|||
DispatchSessionDue(slot, nowTimestamp);
|
||||
dispatched = true;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
session.Quarantine(error);
|
||||
dispatched = true;
|
||||
}
|
||||
}
|
||||
return dispatched;
|
||||
}
|
||||
|
||||
|
|
@ -246,4 +258,5 @@ internal readonly record struct HeadlessSchedulerSnapshot(
|
|||
long TurnCount,
|
||||
long CatchUpCollapseCount,
|
||||
int ActiveSessionCount,
|
||||
int FaultedSessionCount,
|
||||
long NextDeadline);
|
||||
|
|
|
|||
|
|
@ -124,6 +124,8 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
private bool _reconnectPending;
|
||||
private ulong _stoppedGeneration;
|
||||
private string _accountName = string.Empty;
|
||||
private Exception? _fault;
|
||||
private bool _faulted;
|
||||
private bool _disposed;
|
||||
|
||||
internal HeadlessSessionHost(
|
||||
|
|
@ -134,7 +136,8 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
TimeProvider? timeProvider = null,
|
||||
TimeSpan? reconnectQuiescence = null,
|
||||
HeadlessProcessContentOwner.HeadlessProcessContentLease?
|
||||
contentLease = null)
|
||||
contentLease = null,
|
||||
IHeadlessBotPolicy? policyOverride = null)
|
||||
{
|
||||
_descriptor = descriptor
|
||||
?? throw new ArgumentNullException(nameof(descriptor));
|
||||
|
|
@ -235,8 +238,8 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
|
||||
hostLease = runtime.AcquireHostLease(
|
||||
$"headless:{descriptor.Id}");
|
||||
policy =
|
||||
HeadlessBotPolicyFactory.Create(descriptor.Policy.Id);
|
||||
policy = policyOverride
|
||||
?? HeadlessBotPolicyFactory.Create(descriptor.Policy.Id);
|
||||
policySubscription = runtime.Subscribe(policy);
|
||||
diagnostics.Lifecycle(
|
||||
descriptor.Id,
|
||||
|
|
@ -264,7 +267,10 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
internal string SessionId => _descriptor.Id;
|
||||
internal string ActiveCharacterName { get; private set; } =
|
||||
string.Empty;
|
||||
internal bool IsPolicyComplete => _policy.IsComplete;
|
||||
internal bool IsPolicyComplete =>
|
||||
_faulted || _policy.IsComplete;
|
||||
internal bool IsFaulted => _faulted;
|
||||
internal Exception? Fault => _fault;
|
||||
internal bool IsReconnectPending => _reconnectPending;
|
||||
internal HeadlessProcessContentOwner.HeadlessProcessContentLease?
|
||||
Content => _contentLease;
|
||||
|
|
@ -296,6 +302,42 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
internal RuntimeTeardownAcknowledgement Stop() =>
|
||||
Commands.Session.Stop(Runtime.Generation);
|
||||
|
||||
internal void Quarantine(Exception error)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(error);
|
||||
if (_faulted)
|
||||
return;
|
||||
|
||||
_faulted = true;
|
||||
_fault = error;
|
||||
_reconnectPending = false;
|
||||
_reconnectDeadline = 0L;
|
||||
_diagnostics.Failure(
|
||||
_descriptor.Id,
|
||||
"quarantined",
|
||||
error);
|
||||
try
|
||||
{
|
||||
// A policy may have faulted from an event callback. Detach it
|
||||
// before Runtime publishes teardown deltas so the quarantined
|
||||
// observer cannot poison the canonical stop transaction.
|
||||
_policySubscription.Dispose();
|
||||
RuntimeTeardownAcknowledgement stopped = Stop();
|
||||
if (!stopped.IsComplete)
|
||||
{
|
||||
_fault = new AggregateException(
|
||||
error,
|
||||
stopped.Error
|
||||
?? new InvalidOperationException(
|
||||
$"Headless session '{_descriptor.Id}' did not quiesce after a fault."));
|
||||
}
|
||||
}
|
||||
catch (Exception teardownError)
|
||||
{
|
||||
_fault = new AggregateException(error, teardownError);
|
||||
}
|
||||
}
|
||||
|
||||
internal RuntimeSessionStartResult CompletePendingReconnect(
|
||||
long nowTimestamp)
|
||||
{
|
||||
|
|
@ -482,13 +524,18 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
private LiveSessionEventRouter CreateEventRoute(
|
||||
AcDream.Core.Net.WorldSession session)
|
||||
{
|
||||
IRuntimeDirectWorldProjection? worldProjection =
|
||||
_contentLease is { } content
|
||||
? new HeadlessSessionWorldProjection(Runtime, content)
|
||||
: null;
|
||||
var entities = new RuntimeLiveEntitySessionController(
|
||||
Runtime,
|
||||
session,
|
||||
message => _diagnostics.Message(
|
||||
_descriptor.Id,
|
||||
message,
|
||||
Runtime.Generation.Value));
|
||||
Runtime.Generation.Value),
|
||||
worldProjection);
|
||||
return new LiveSessionEventRouter(
|
||||
session,
|
||||
entities.CreateSink(),
|
||||
|
|
|
|||
433
src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs
Normal file
433
src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs
Normal file
|
|
@ -0,0 +1,433 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Content;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Entities;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.Runtime.Physics;
|
||||
using AcDream.Runtime.Session;
|
||||
|
||||
namespace AcDream.Headless.Hosting;
|
||||
|
||||
internal interface IHeadlessCollisionNeighborhood
|
||||
{
|
||||
void CenterOn(uint fullCellId);
|
||||
|
||||
bool IsReady(uint fullCellId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-session mutable collision publication over process-shared immutable
|
||||
/// DAT and pak inputs. Every session retains its own engine, data cache,
|
||||
/// cell graph, shadow registry, and publication ledger.
|
||||
/// </summary>
|
||||
internal sealed class HeadlessCollisionNeighborhood
|
||||
: IHeadlessCollisionNeighborhood
|
||||
{
|
||||
private readonly GameRuntime _runtime;
|
||||
private readonly HeadlessProcessContentOwner
|
||||
.HeadlessProcessContentLease _content;
|
||||
private readonly HashSet<uint> _resident = [];
|
||||
private uint _centerLandblock;
|
||||
|
||||
internal HeadlessCollisionNeighborhood(
|
||||
GameRuntime runtime,
|
||||
HeadlessProcessContentOwner.HeadlessProcessContentLease content)
|
||||
{
|
||||
_runtime = runtime
|
||||
?? throw new ArgumentNullException(nameof(runtime));
|
||||
_content = content
|
||||
?? throw new ArgumentNullException(nameof(content));
|
||||
}
|
||||
|
||||
public void CenterOn(uint fullCellId)
|
||||
{
|
||||
uint center = CanonicalLandblock(fullCellId);
|
||||
if (center == 0x0000FFFFu)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(fullCellId),
|
||||
"A collision neighborhood requires a real destination cell.");
|
||||
}
|
||||
if (_centerLandblock == center
|
||||
&& _resident.Contains(center)
|
||||
&& _runtime.EntityObjects.Physics.Engine
|
||||
.IsLandblockTerrainResident(center))
|
||||
{
|
||||
_runtime.EntityObjects.Physics.Engine
|
||||
.UpdatePlayerCurrCell(fullCellId);
|
||||
return;
|
||||
}
|
||||
|
||||
RetireAll();
|
||||
int centerX = (int)((center >> 24) & 0xFFu);
|
||||
int centerY = (int)((center >> 16) & 0xFFu);
|
||||
try
|
||||
{
|
||||
PublishOne(
|
||||
center,
|
||||
Vector3.Zero,
|
||||
fullCellId,
|
||||
required: true);
|
||||
for (int dx = -1; dx <= 1; dx++)
|
||||
{
|
||||
for (int dy = -1; dy <= 1; dy++)
|
||||
{
|
||||
if (dx == 0 && dy == 0)
|
||||
continue;
|
||||
int landblockX = centerX + dx;
|
||||
int landblockY = centerY + dy;
|
||||
if ((uint)landblockX > byte.MaxValue
|
||||
|| (uint)landblockY > byte.MaxValue)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
uint landblockId =
|
||||
((uint)landblockX << 24)
|
||||
| ((uint)landblockY << 16)
|
||||
| 0xFFFFu;
|
||||
PublishOne(
|
||||
landblockId,
|
||||
new Vector3(dx * 192f, dy * 192f, 0f),
|
||||
fullCellId,
|
||||
required: false);
|
||||
}
|
||||
}
|
||||
_centerLandblock = center;
|
||||
_runtime.EntityObjects.Physics.Engine
|
||||
.UpdatePlayerCurrCell(fullCellId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
RetireAll();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsReady(uint fullCellId)
|
||||
{
|
||||
uint center = CanonicalLandblock(fullCellId);
|
||||
if (_centerLandblock != center
|
||||
|| !_resident.Contains(center)
|
||||
|| !_runtime.EntityObjects.Physics.Engine
|
||||
.IsLandblockTerrainResident(center))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return (fullCellId & 0xFFFFu) < 0x0100u
|
||||
|| _runtime.EntityObjects.Physics.DataCache
|
||||
.GetCellStruct(fullCellId) is not null;
|
||||
}
|
||||
|
||||
private void PublishOne(
|
||||
uint landblockId,
|
||||
Vector3 origin,
|
||||
uint currentCellId,
|
||||
bool required)
|
||||
{
|
||||
LoadedLandblock? source =
|
||||
LandblockLoader.Load(_content.Dats, landblockId);
|
||||
if (source is null)
|
||||
{
|
||||
if (required)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Required headless landblock 0x{landblockId:X8} is missing.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
IReadOnlyList<WorldEntity> staticEntities =
|
||||
LandblockPhysicsContentBuilder.HydrateStaticEntities(
|
||||
_content.Dats,
|
||||
source,
|
||||
origin,
|
||||
includeVisualBounds: false);
|
||||
IReadOnlyList<WorldEntity> scenery =
|
||||
LandblockPhysicsContentBuilder.HydrateProceduralScenery(
|
||||
_content.Dats,
|
||||
source,
|
||||
origin,
|
||||
_content.HeightTable.AsSpan(),
|
||||
includeVisualBounds: false);
|
||||
var entities = new List<WorldEntity>(
|
||||
staticEntities.Count + scenery.Count);
|
||||
entities.AddRange(staticEntities);
|
||||
entities.AddRange(scenery);
|
||||
PhysicsDatBundle dats =
|
||||
LandblockPhysicsContentBuilder.BuildDatBundle(
|
||||
_content.Dats,
|
||||
landblockId,
|
||||
entities);
|
||||
var landblock = new LoadedLandblock(
|
||||
source.LandblockId,
|
||||
source.Heightmap,
|
||||
entities,
|
||||
dats);
|
||||
LandblockCollisionBuild collisions =
|
||||
LandblockPhysicsContentBuilder
|
||||
.BuildPreparedCollisionClosure(
|
||||
_content.PreparedCollision,
|
||||
landblock);
|
||||
|
||||
RuntimePhysicsState physics =
|
||||
_runtime.EntityObjects.Physics;
|
||||
PhysicsDataCache cache = physics.DataCache;
|
||||
TerrainSurface terrain =
|
||||
LandblockPhysicsContentBuilder.BuildTerrainSurface(
|
||||
landblock,
|
||||
_content.HeightTable.AsSpan());
|
||||
var cellSurfaces = new List<CellSurface>();
|
||||
var portalPlanes = new List<PortalPlane>();
|
||||
LandblockPhysicsContentBuilder.PublishPreparedCells(
|
||||
cache,
|
||||
landblock,
|
||||
collisions,
|
||||
origin,
|
||||
cellSurfaces,
|
||||
portalPlanes);
|
||||
LandblockPhysicsContentBuilder.CacheBuildings(
|
||||
cache,
|
||||
landblock,
|
||||
terrain,
|
||||
origin);
|
||||
LandblockPhysicsContentBuilder.CachePreparedObjects(
|
||||
cache,
|
||||
collisions);
|
||||
|
||||
RuntimeCollisionAdmission admission =
|
||||
physics.BeginCollisionAdmission(landblockId);
|
||||
try
|
||||
{
|
||||
physics.AdmitCollisionAssets(
|
||||
admission,
|
||||
new RuntimeLandblockCollisionAssets(
|
||||
landblockId,
|
||||
terrain,
|
||||
cellSurfaces,
|
||||
portalPlanes,
|
||||
origin.X,
|
||||
origin.Y,
|
||||
currentCellId));
|
||||
_ = LandblockPhysicsContentBuilder
|
||||
.PublishStaticCollision(
|
||||
physics.Engine,
|
||||
cache,
|
||||
landblock,
|
||||
collisions,
|
||||
origin);
|
||||
_ = physics.CompleteCollisionAdmission(admission);
|
||||
_resident.Add(CanonicalLandblock(landblockId));
|
||||
}
|
||||
catch
|
||||
{
|
||||
_ = physics.WithdrawCollision(landblockId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private void RetireAll()
|
||||
{
|
||||
if (_resident.Count == 0)
|
||||
{
|
||||
_centerLandblock = 0u;
|
||||
return;
|
||||
}
|
||||
|
||||
uint[] retiring = [.. _resident];
|
||||
_resident.Clear();
|
||||
_centerLandblock = 0u;
|
||||
foreach (uint landblock in retiring)
|
||||
_ = _runtime.EntityObjects.Physics.WithdrawCollision(landblock);
|
||||
}
|
||||
|
||||
private static uint CanonicalLandblock(uint fullCellId) =>
|
||||
(fullCellId & 0xFFFF0000u) | 0xFFFFu;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// No-window projection of canonical Runtime entity and transit state into
|
||||
/// one local movement controller plus its per-session collision world.
|
||||
/// </summary>
|
||||
internal sealed class HeadlessSessionWorldProjection
|
||||
: IRuntimeDirectWorldProjection
|
||||
{
|
||||
private const float DefaultRadius = 0.48f;
|
||||
private const float DefaultHeight = 1.835f;
|
||||
|
||||
private readonly GameRuntime _runtime;
|
||||
private readonly IHeadlessCollisionNeighborhood _collision;
|
||||
private readonly IPreparedCollisionSource? _preparedCollision;
|
||||
|
||||
internal HeadlessSessionWorldProjection(
|
||||
GameRuntime runtime,
|
||||
HeadlessProcessContentOwner.HeadlessProcessContentLease content)
|
||||
: this(
|
||||
runtime,
|
||||
new HeadlessCollisionNeighborhood(runtime, content),
|
||||
content.PreparedCollision)
|
||||
{
|
||||
}
|
||||
|
||||
internal HeadlessSessionWorldProjection(
|
||||
GameRuntime runtime,
|
||||
IHeadlessCollisionNeighborhood collision,
|
||||
IPreparedCollisionSource? preparedCollision = null)
|
||||
{
|
||||
_runtime = runtime
|
||||
?? throw new ArgumentNullException(nameof(runtime));
|
||||
_collision = collision
|
||||
?? throw new ArgumentNullException(nameof(collision));
|
||||
_preparedCollision = preparedCollision;
|
||||
}
|
||||
|
||||
public void ProjectSpawn(
|
||||
RuntimeEntityRecord record,
|
||||
bool isLocalPlayer)
|
||||
{
|
||||
if (isLocalPlayer)
|
||||
SynchronizeLocalPlayer(record);
|
||||
}
|
||||
|
||||
public void ProjectPosition(
|
||||
RuntimeEntityRecord record,
|
||||
bool isLocalPlayer)
|
||||
{
|
||||
if (isLocalPlayer)
|
||||
SynchronizeLocalPlayer(record);
|
||||
}
|
||||
|
||||
public void BeginTeleport()
|
||||
{
|
||||
if (_runtime.MovementOwner.Controller is { } controller)
|
||||
controller.State = PlayerState.PortalSpace;
|
||||
}
|
||||
|
||||
public RuntimeDestinationReadiness PrepareDestination(
|
||||
long revealGeneration,
|
||||
RuntimeTeleportDestination destination)
|
||||
{
|
||||
_collision.CenterOn(destination.CellId);
|
||||
if (_runtime.EntityObjects.Entities.TryGetActive(
|
||||
destination.EntityGuid,
|
||||
out RuntimeEntityRecord record))
|
||||
{
|
||||
SynchronizeLocalPlayer(record);
|
||||
}
|
||||
if (_runtime.MovementOwner.Controller is { } controller)
|
||||
controller.State = PlayerState.InWorld;
|
||||
|
||||
bool ready = _collision.IsReady(destination.CellId);
|
||||
bool indoor = (destination.CellId & 0xFFFFu) >= 0x0100u;
|
||||
return new RuntimeDestinationReadiness(
|
||||
revealGeneration,
|
||||
destination.CellId,
|
||||
indoor,
|
||||
IsUnhydratable: !ready,
|
||||
RequiredRenderRadius: indoor ? 0 : 1,
|
||||
IsRenderNeighborhoodReady: true,
|
||||
AreCompositeTexturesReady: true,
|
||||
IsCollisionReady: ready);
|
||||
}
|
||||
|
||||
private void SynchronizeLocalPlayer(RuntimeEntityRecord record)
|
||||
{
|
||||
if (record.ServerGuid
|
||||
!= _runtime.PlayerIdentity.ServerGuid
|
||||
|| record.Snapshot.Position is not { } position)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_collision.CenterOn(position.LandblockId);
|
||||
PlayerMovementController controller =
|
||||
_runtime.MovementOwner.Controller
|
||||
?? CreateController(record);
|
||||
Vector3 wirePosition = new(
|
||||
position.PositionX,
|
||||
position.PositionY,
|
||||
position.PositionZ);
|
||||
Quaternion orientation = new(
|
||||
position.RotationX,
|
||||
position.RotationY,
|
||||
position.RotationZ,
|
||||
position.RotationW);
|
||||
|
||||
ResolveResult resolved =
|
||||
_runtime.EntityObjects.Physics.Engine.Resolve(
|
||||
wirePosition,
|
||||
position.LandblockId,
|
||||
Vector3.Zero,
|
||||
100f);
|
||||
ResolveResult placement =
|
||||
_runtime.EntityObjects.Physics.Engine.ResolvePlacement(
|
||||
resolved.Position,
|
||||
resolved.CellId,
|
||||
DefaultRadius,
|
||||
DefaultHeight,
|
||||
controller.StepUpHeight,
|
||||
controller.StepDownHeight,
|
||||
ObjectInfoState.IsPlayer
|
||||
| ObjectInfoState.EdgeSlide,
|
||||
record.LocalEntityId ?? 0u);
|
||||
if (placement.Ok)
|
||||
resolved = placement;
|
||||
|
||||
controller.LocalEntityId = record.LocalEntityId ?? 0u;
|
||||
controller.SetPosition(
|
||||
resolved.Position,
|
||||
resolved.CellId,
|
||||
wirePosition);
|
||||
controller.SetBodyOrientation(orientation);
|
||||
}
|
||||
|
||||
private PlayerMovementController CreateController(
|
||||
RuntimeEntityRecord record)
|
||||
{
|
||||
var controller = new PlayerMovementController(
|
||||
_runtime.EntityObjects.Physics.Engine,
|
||||
record.ObjectClock,
|
||||
PlayerMovementConstructionOptions.From(
|
||||
_runtime.CharacterOwner.MovementSkills.Snapshot));
|
||||
controller.ApplyPhysicsState(record.FinalPhysicsState);
|
||||
controller.LocalEntityId = record.LocalEntityId ?? 0u;
|
||||
ApplySetupStepHeights(record, controller);
|
||||
RuntimeMovementSkillProjection.ApplyTo(
|
||||
_runtime.CharacterOwner.MovementSkills,
|
||||
controller);
|
||||
_runtime.MovementOwner.Controller = controller;
|
||||
return controller;
|
||||
}
|
||||
|
||||
private void ApplySetupStepHeights(
|
||||
RuntimeEntityRecord record,
|
||||
PlayerMovementController controller)
|
||||
{
|
||||
if (record.Snapshot.SetupTableId is not { } setupId
|
||||
|| (setupId & 0xFF000000u) != 0x02000000u
|
||||
|| _preparedCollision is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PreparedCollisionReadResult<FlatSetupCollision> read =
|
||||
_preparedCollision.ReadSetupCollision(setupId);
|
||||
if (read.Status != PreparedAssetReadStatus.Loaded
|
||||
|| read.Data is not { } setup)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Player Setup collision 0x{setupId:X8} is {read.Status}.");
|
||||
}
|
||||
_runtime.EntityObjects.Physics.DataCache.CacheSetup(
|
||||
setupId,
|
||||
setup);
|
||||
controller.StepUpHeight = setup.StepUpHeight > 0f
|
||||
? setup.StepUpHeight
|
||||
: 0.4f;
|
||||
controller.StepDownHeight = setup.StepDownHeight > 0f
|
||||
? setup.StepDownHeight
|
||||
: 0.4f;
|
||||
}
|
||||
}
|
||||
49
src/AcDream.Headless/Hosting/HeadlessStaticStateAudit.cs
Normal file
49
src/AcDream.Headless/Hosting/HeadlessStaticStateAudit.cs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
using System.Reflection;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Headless.Configuration;
|
||||
|
||||
namespace AcDream.Headless.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Rejects process-global physics probes whose mutable capture cursors and
|
||||
/// last-hit fields cannot be attributed safely when several Runtime roots
|
||||
/// share one process. Ordinary diagnostics remain session-labelled through
|
||||
/// <see cref="Diagnostics.HeadlessDiagnosticWriter"/>.
|
||||
/// </summary>
|
||||
internal static class HeadlessStaticStateAudit
|
||||
{
|
||||
internal static void ValidateProcessIsolation()
|
||||
{
|
||||
var enabled = new List<string>();
|
||||
foreach (PropertyInfo property in typeof(PhysicsDiagnostics)
|
||||
.GetProperties(BindingFlags.Public | BindingFlags.Static)
|
||||
.OrderBy(static property => property.Name, StringComparer.Ordinal))
|
||||
{
|
||||
if (property.PropertyType != typeof(bool)
|
||||
|| property.GetMethod is null
|
||||
|| (!property.Name.StartsWith(
|
||||
"Probe",
|
||||
StringComparison.Ordinal)
|
||||
&& !property.Name.StartsWith(
|
||||
"Dump",
|
||||
StringComparison.Ordinal)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (property.GetValue(null) is true)
|
||||
enabled.Add(property.Name);
|
||||
}
|
||||
if (PhysicsDiagnostics.CollisionShadowSampleEvery > 0)
|
||||
enabled.Add(nameof(PhysicsDiagnostics.CollisionShadowSampleEvery));
|
||||
if (PhysicsResolveCapture.IsEnabled)
|
||||
enabled.Add(nameof(PhysicsResolveCapture));
|
||||
|
||||
if (enabled.Count != 0)
|
||||
{
|
||||
throw new HeadlessConfigurationException(
|
||||
"Multi-session headless mode cannot use process-global "
|
||||
+ "physics probes. Disable: "
|
||||
+ string.Join(", ", enabled));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -972,7 +972,7 @@ public sealed class PlayerMovementController
|
|||
/// Install a complete authoritative frame rotation (spawn, teleport, or
|
||||
/// server correction) without collapsing it through the yaw projection.
|
||||
/// </summary>
|
||||
internal void SetBodyOrientation(Quaternion orientation)
|
||||
public void SetBodyOrientation(Quaternion orientation)
|
||||
{
|
||||
_body.Orientation = AcDream.Core.Physics.Motion.FrameOps.SetRotate(
|
||||
_body.Position,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
namespace AcDream.Runtime.Gameplay;
|
||||
|
||||
/// <summary>
|
||||
/// Applies the exact server-owned run/jump snapshot to either host's one local
|
||||
/// movement controller. This lives beside the canonical skill owner so
|
||||
/// graphical and no-window construction cannot drift.
|
||||
/// </summary>
|
||||
public static class RuntimeMovementSkillProjection
|
||||
{
|
||||
public static bool ApplyTo(
|
||||
RuntimeMovementSkillState skills,
|
||||
PlayerMovementController? controller)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(skills);
|
||||
RuntimeMovementSkillSnapshot snapshot = skills.Snapshot;
|
||||
if (controller is null || !snapshot.IsComplete)
|
||||
return false;
|
||||
|
||||
controller.SetCharacterSkills(
|
||||
snapshot.RunSkill,
|
||||
snapshot.JumpSkill);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,23 @@ using AcDream.Runtime.World;
|
|||
|
||||
namespace AcDream.Runtime.Session;
|
||||
|
||||
public interface IRuntimeDirectWorldProjection
|
||||
{
|
||||
void ProjectSpawn(
|
||||
RuntimeEntityRecord record,
|
||||
bool isLocalPlayer);
|
||||
|
||||
void ProjectPosition(
|
||||
RuntimeEntityRecord record,
|
||||
bool isLocalPlayer);
|
||||
|
||||
void BeginTeleport();
|
||||
|
||||
RuntimeDestinationReadiness PrepareDestination(
|
||||
long revealGeneration,
|
||||
RuntimeTeleportDestination destination);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Presentation-free inbound entity route for a direct Runtime host. It
|
||||
/// applies the same canonical identity, timestamp, object-table, and transit
|
||||
|
|
@ -18,15 +35,18 @@ public sealed class RuntimeLiveEntitySessionController
|
|||
private readonly GameRuntime _runtime;
|
||||
private readonly WorldSession _session;
|
||||
private readonly Action<string> _log;
|
||||
private readonly IRuntimeDirectWorldProjection? _worldProjection;
|
||||
|
||||
public RuntimeLiveEntitySessionController(
|
||||
GameRuntime runtime,
|
||||
WorldSession session,
|
||||
Action<string>? log = null)
|
||||
Action<string>? log = null,
|
||||
IRuntimeDirectWorldProjection? worldProjection = null)
|
||||
{
|
||||
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
_log = log ?? (_ => { });
|
||||
_worldProjection = worldProjection;
|
||||
}
|
||||
|
||||
public LiveEntitySessionSink CreateSink() => new(
|
||||
|
|
@ -54,13 +74,20 @@ public sealed class RuntimeLiveEntitySessionController
|
|||
return;
|
||||
|
||||
ulong integrationVersion = canonical.CreateIntegrationVersion;
|
||||
_ = Entities.ApplyAcceptedSpawn(
|
||||
bool applied = Entities.ApplyAcceptedSpawn(
|
||||
canonical,
|
||||
integrationVersion,
|
||||
canonical.Snapshot,
|
||||
replaceGeneration:
|
||||
registration.Inbound.Disposition
|
||||
is CreateObjectTimestampDisposition.NewGeneration);
|
||||
if (applied)
|
||||
{
|
||||
_worldProjection?.ProjectSpawn(
|
||||
canonical,
|
||||
canonical.ServerGuid
|
||||
== _runtime.PlayerIdentity.ServerGuid);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDeleted(DeleteObject.Parsed delete)
|
||||
|
|
@ -146,6 +173,14 @@ public sealed class RuntimeLiveEntitySessionController
|
|||
_runtime.TransitOwner.OfferTeleportDestination(
|
||||
destination,
|
||||
timestamps.TeleportAdvanced);
|
||||
if (Entities.Entities.TryGetActive(
|
||||
update.Guid,
|
||||
out RuntimeEntityRecord record))
|
||||
{
|
||||
_worldProjection?.ProjectPosition(
|
||||
record,
|
||||
isLocalPlayer: true);
|
||||
}
|
||||
TryCompletePortal();
|
||||
}
|
||||
|
||||
|
|
@ -174,6 +209,7 @@ public sealed class RuntimeLiveEntitySessionController
|
|||
RuntimeWorldTransitState transit = _runtime.TransitOwner;
|
||||
if (!transit.TryQueueTeleportStart(sequence))
|
||||
return;
|
||||
_worldProjection?.BeginTeleport();
|
||||
if (!transit.ActivateQueuedTeleport())
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
|
|
@ -216,8 +252,11 @@ public sealed class RuntimeLiveEntitySessionController
|
|||
RuntimeWorldHostAcknowledgementStage.ProjectionRegistered);
|
||||
|
||||
bool indoor = (destination.CellId & 0xFFFFu) >= 0x0100u;
|
||||
if (!transit.AcknowledgeDestinationReadiness(
|
||||
new RuntimeDestinationReadiness(
|
||||
RuntimeDestinationReadiness readiness =
|
||||
_worldProjection?.PrepareDestination(
|
||||
generation,
|
||||
destination)
|
||||
?? new RuntimeDestinationReadiness(
|
||||
generation,
|
||||
destination.CellId,
|
||||
indoor,
|
||||
|
|
@ -225,7 +264,9 @@ public sealed class RuntimeLiveEntitySessionController
|
|||
RequiredRenderRadius: indoor ? 0 : 1,
|
||||
IsRenderNeighborhoodReady: true,
|
||||
AreCompositeTexturesReady: true,
|
||||
IsCollisionReady: true)))
|
||||
IsCollisionReady: true);
|
||||
if (!transit.AcknowledgeDestinationReadiness(
|
||||
readiness))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Runtime rejected headless destination readiness.");
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
using System.Reflection;
|
||||
using System.Collections.Immutable;
|
||||
using System.Runtime.InteropServices;
|
||||
using AcDream.Content;
|
||||
using AcDream.Headless.Configuration;
|
||||
using AcDream.Headless.Hosting;
|
||||
|
|
@ -114,6 +116,9 @@ public sealed class HeadlessProcessContentOwnerTests
|
|||
IPreparedCollisionSource sharedCollision =
|
||||
Assert.IsAssignableFrom<IPreparedCollisionSource>(
|
||||
host.Sessions[0].Content?.PreparedCollision);
|
||||
float[] sharedHeightTable = Assert.IsType<float[]>(
|
||||
ImmutableCollectionsMarshal.AsArray(
|
||||
host.Sessions[0].Content!.HeightTable));
|
||||
Assert.All(
|
||||
host.Sessions,
|
||||
session =>
|
||||
|
|
@ -130,6 +135,10 @@ public sealed class HeadlessProcessContentOwnerTests
|
|||
Assert.Same(
|
||||
sharedCollision,
|
||||
session.Content?.PreparedCollision);
|
||||
Assert.Same(
|
||||
sharedHeightTable,
|
||||
ImmutableCollectionsMarshal.AsArray(
|
||||
session.Content!.HeightTable));
|
||||
});
|
||||
Assert.Equal(
|
||||
sessionCount,
|
||||
|
|
@ -151,6 +160,13 @@ public sealed class HeadlessProcessContentOwnerTests
|
|||
session.Runtime.EntityObjects.Physics.DataCache)
|
||||
.Distinct(ReferenceEqualityComparer.Instance)
|
||||
.Count());
|
||||
Assert.Equal(
|
||||
sessionCount,
|
||||
host.Sessions
|
||||
.Select(static session =>
|
||||
session.Runtime.MovementOwner)
|
||||
.Distinct(ReferenceEqualityComparer.Instance)
|
||||
.Count());
|
||||
|
||||
HeadlessSessionHost[] sessions = host.Sessions.ToArray();
|
||||
host.Dispose();
|
||||
|
|
@ -223,7 +239,8 @@ public sealed class HeadlessProcessContentOwnerTests
|
|||
return new HeadlessOpenedProcessContent(
|
||||
DatsResource,
|
||||
PreparedResource,
|
||||
MagicCatalog.Empty);
|
||||
MagicCatalog.Empty,
|
||||
ImmutableArray.CreateRange(new float[256]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ using AcDream.Headless.Credentials;
|
|||
using AcDream.Headless.Diagnostics;
|
||||
using AcDream.Headless.Hosting;
|
||||
using AcDream.Headless.Platform;
|
||||
using AcDream.Headless.Policies;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Session;
|
||||
|
||||
|
|
@ -186,11 +187,60 @@ public sealed class HeadlessProcessSchedulerTests
|
|||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThrowingPolicyQuarantinesOnlyItsOwnSession()
|
||||
{
|
||||
var time = new ManualTimeProvider();
|
||||
var operations = new FixtureSessionOperations();
|
||||
var throwing = new ThrowingPolicy();
|
||||
var healthy = new CountingPolicy();
|
||||
using HeadlessSessionHost first =
|
||||
CreateSession(
|
||||
"fault",
|
||||
time,
|
||||
operations,
|
||||
policyOverride: throwing);
|
||||
using HeadlessSessionHost second =
|
||||
CreateSession(
|
||||
"healthy",
|
||||
time,
|
||||
operations,
|
||||
policyOverride: healthy);
|
||||
Assert.Equal(
|
||||
RuntimeSessionStartStatus.Connected,
|
||||
first.Start().Status);
|
||||
Assert.Equal(
|
||||
RuntimeSessionStartStatus.Connected,
|
||||
second.Start().Status);
|
||||
var scheduler = new HeadlessProcessScheduler(
|
||||
[first, second],
|
||||
time);
|
||||
|
||||
time.Advance(TimeSpan.FromMilliseconds(15));
|
||||
Assert.True(scheduler.DispatchDue(time.GetTimestamp()));
|
||||
|
||||
Assert.True(first.IsFaulted);
|
||||
Assert.IsType<InvalidOperationException>(first.Fault);
|
||||
Assert.False(second.IsFaulted);
|
||||
Assert.Equal(1, healthy.TickCount);
|
||||
HeadlessSchedulerSnapshot snapshot =
|
||||
scheduler.CaptureSnapshot();
|
||||
Assert.Equal(1, snapshot.FaultedSessionCount);
|
||||
Assert.Equal(1, snapshot.ActiveSessionCount);
|
||||
|
||||
time.Advance(TimeSpan.FromMilliseconds(15));
|
||||
Assert.True(scheduler.DispatchDue(time.GetTimestamp()));
|
||||
Assert.Equal(2, healthy.TickCount);
|
||||
Assert.Equal(1UL, first.Runtime.Clock.FrameNumber);
|
||||
Assert.Equal(2UL, second.Runtime.Clock.FrameNumber);
|
||||
}
|
||||
|
||||
private static HeadlessSessionHost CreateSession(
|
||||
string id,
|
||||
TimeProvider time,
|
||||
ILiveSessionOperations operations,
|
||||
TimeSpan? reconnectQuiescence = null)
|
||||
TimeSpan? reconnectQuiescence = null,
|
||||
IHeadlessBotPolicy? policyOverride = null)
|
||||
{
|
||||
var credential = new HeadlessCredentialSecret(
|
||||
$"{id}-credential",
|
||||
|
|
@ -203,7 +253,8 @@ public sealed class HeadlessProcessSchedulerTests
|
|||
new HeadlessDiagnosticWriter(TextWriter.Null),
|
||||
operations,
|
||||
time,
|
||||
reconnectQuiescence);
|
||||
reconnectQuiescence,
|
||||
policyOverride: policyOverride);
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
|
@ -304,4 +355,67 @@ public sealed class HeadlessProcessSchedulerTests
|
|||
session.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private abstract class FixturePolicy : IHeadlessBotPolicy
|
||||
{
|
||||
public virtual bool IsComplete => false;
|
||||
|
||||
public abstract void Tick(
|
||||
IGameRuntimeView view,
|
||||
IGameRuntimeCommands commands);
|
||||
|
||||
public void OnLifecycle(in RuntimeLifecycleDelta delta)
|
||||
{
|
||||
}
|
||||
|
||||
public void OnCommand(in RuntimeCommandDelta delta)
|
||||
{
|
||||
}
|
||||
|
||||
public void OnEntity(in RuntimeEntityDelta delta)
|
||||
{
|
||||
}
|
||||
|
||||
public void OnInventory(in RuntimeInventoryDelta delta)
|
||||
{
|
||||
}
|
||||
|
||||
public void OnChat(in RuntimeChatDelta delta)
|
||||
{
|
||||
}
|
||||
|
||||
public void OnMovement(in RuntimeMovementDelta delta)
|
||||
{
|
||||
}
|
||||
|
||||
public void OnPortal(in RuntimePortalDelta delta)
|
||||
{
|
||||
}
|
||||
|
||||
public void OnCombat(in RuntimeCombatDelta delta)
|
||||
{
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ThrowingPolicy : FixturePolicy
|
||||
{
|
||||
public override void Tick(
|
||||
IGameRuntimeView view,
|
||||
IGameRuntimeCommands commands) =>
|
||||
throw new InvalidOperationException("injected policy fault");
|
||||
}
|
||||
|
||||
private sealed class CountingPolicy : FixturePolicy
|
||||
{
|
||||
public int TickCount { get; private set; }
|
||||
|
||||
public override void Tick(
|
||||
IGameRuntimeView view,
|
||||
IGameRuntimeCommands commands) =>
|
||||
TickCount++;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ using AcDream.Runtime;
|
|||
using AcDream.Runtime.Entities;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.Runtime.Session;
|
||||
using AcDream.Runtime.World;
|
||||
|
||||
namespace AcDream.Headless.Tests;
|
||||
|
||||
|
|
@ -186,6 +187,71 @@ public sealed class HeadlessSessionHostTests
|
|||
Assert.True(host.Runtime.CaptureOwnership().IsConverged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WorldProjectionHydratesCanonicalMovementAndTeleportState()
|
||||
{
|
||||
var operations = new FixtureSessionOperations();
|
||||
using var credential = new HeadlessCredentialSecret(
|
||||
"fixture",
|
||||
"password");
|
||||
using var host = new HeadlessSessionHost(
|
||||
Descriptor(),
|
||||
credential,
|
||||
new HeadlessDiagnosticWriter(TextWriter.Null),
|
||||
operations);
|
||||
GameRuntime runtime = host.Runtime;
|
||||
const uint player = 0x50000002u;
|
||||
runtime.PlayerIdentity.ServerGuid = player;
|
||||
AddFlatLandblock(runtime.EntityObjects.Physics.Engine);
|
||||
RuntimeEntityRecord record = runtime.EntityObjects
|
||||
.RegisterEntity(Spawn(player))
|
||||
.Canonical!;
|
||||
Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn(
|
||||
record,
|
||||
record.CreateIntegrationVersion,
|
||||
record.Snapshot,
|
||||
replaceGeneration: false));
|
||||
var collision = new FixtureCollisionNeighborhood();
|
||||
var projection = new HeadlessSessionWorldProjection(
|
||||
runtime,
|
||||
collision);
|
||||
|
||||
projection.ProjectSpawn(record, isLocalPlayer: true);
|
||||
PlayerMovementController controller =
|
||||
Assert.IsType<PlayerMovementController>(
|
||||
runtime.MovementOwner.Controller);
|
||||
projection.ProjectPosition(record, isLocalPlayer: true);
|
||||
|
||||
Assert.Same(controller, runtime.MovementOwner.Controller);
|
||||
Assert.Equal(record.LocalEntityId, controller.LocalEntityId);
|
||||
Assert.Equal(
|
||||
0xA9B40000u,
|
||||
controller.CellId & 0xFFFF0000u);
|
||||
Assert.True((controller.CellId & 0xFFFFu) < 0x0100u);
|
||||
projection.BeginTeleport();
|
||||
Assert.Equal(PlayerState.PortalSpace, controller.State);
|
||||
|
||||
RuntimeDestinationReadiness readiness =
|
||||
projection.PrepareDestination(
|
||||
revealGeneration: 7,
|
||||
new RuntimeTeleportDestination(
|
||||
player,
|
||||
InstanceSequence: 1,
|
||||
PositionSequence: 2,
|
||||
TeleportSequence: 1,
|
||||
ForcePositionSequence: 0,
|
||||
new Position(
|
||||
0xA9B40001u,
|
||||
new Vector3(96f, 97f, 50f),
|
||||
Quaternion.Identity)));
|
||||
|
||||
Assert.True(readiness.IsCollisionReady);
|
||||
Assert.False(readiness.IsUnhydratable);
|
||||
Assert.Equal(PlayerState.InWorld, controller.State);
|
||||
Assert.Equal(4, collision.CenterCount);
|
||||
Assert.Equal(0xA9B40001u, collision.LastCell);
|
||||
}
|
||||
|
||||
private static HeadlessSessionDescriptor Descriptor(
|
||||
HeadlessCredentialProviderKind provider =
|
||||
HeadlessCredentialProviderKind.Environment,
|
||||
|
|
@ -217,18 +283,7 @@ public sealed class HeadlessSessionHostTests
|
|||
{
|
||||
const uint player = 0x50000002u;
|
||||
PhysicsEngine engine = runtime.EntityObjects.Physics.Engine;
|
||||
var heights = new byte[81];
|
||||
Array.Fill(heights, (byte)50);
|
||||
var heightTable = new float[256];
|
||||
for (int index = 0; index < heightTable.Length; index++)
|
||||
heightTable[index] = index;
|
||||
engine.AddLandblock(
|
||||
0xA9B4FFFFu,
|
||||
new TerrainSurface(heights, heightTable),
|
||||
[],
|
||||
[],
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f);
|
||||
AddFlatLandblock(engine);
|
||||
|
||||
RuntimeEntityRecord record = runtime.EntityObjects
|
||||
.RegisterEntity(Spawn(player))
|
||||
|
|
@ -246,6 +301,22 @@ public sealed class HeadlessSessionHostTests
|
|||
runtime.MovementOwner.Controller = controller;
|
||||
}
|
||||
|
||||
private static void AddFlatLandblock(PhysicsEngine engine)
|
||||
{
|
||||
var heights = new byte[81];
|
||||
Array.Fill(heights, (byte)50);
|
||||
var heightTable = new float[256];
|
||||
for (int index = 0; index < heightTable.Length; index++)
|
||||
heightTable[index] = index;
|
||||
engine.AddLandblock(
|
||||
0xA9B4FFFFu,
|
||||
new TerrainSurface(heights, heightTable),
|
||||
[],
|
||||
[],
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f);
|
||||
}
|
||||
|
||||
private static WorldSession.EntitySpawn Spawn(uint guid)
|
||||
{
|
||||
var position = new CreateObject.ServerPosition(
|
||||
|
|
@ -389,4 +460,20 @@ public sealed class HeadlessSessionHostTests
|
|||
}
|
||||
}
|
||||
|
||||
private sealed class FixtureCollisionNeighborhood
|
||||
: IHeadlessCollisionNeighborhood
|
||||
{
|
||||
public int CenterCount { get; private set; }
|
||||
public uint LastCell { get; private set; }
|
||||
|
||||
public void CenterOn(uint fullCellId)
|
||||
{
|
||||
CenterCount++;
|
||||
LastCell = fullCellId;
|
||||
}
|
||||
|
||||
public bool IsReady(uint fullCellId) =>
|
||||
fullCellId == LastCell;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
376
tests/AcDream.Headless.Tests/HeadlessSessionIsolationTests.cs
Normal file
376
tests/AcDream.Headless.Tests/HeadlessSessionIsolationTests.cs
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
using System.Net;
|
||||
using System.Reflection;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Headless.Configuration;
|
||||
using AcDream.Headless.Credentials;
|
||||
using AcDream.Headless.Diagnostics;
|
||||
using AcDream.Headless.Hosting;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Entities;
|
||||
using AcDream.Runtime.Session;
|
||||
|
||||
namespace AcDream.Headless.Tests;
|
||||
|
||||
public sealed class HeadlessSessionIsolationTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(5)]
|
||||
[InlineData(10)]
|
||||
public void SameServerIdentityStaysIsolatedAcrossActivityPortalAndReconnect(
|
||||
int sessionCount)
|
||||
{
|
||||
const uint sharedPlayerGuid = 0x50000001u;
|
||||
var operations = new FixtureOperations(sharedPlayerGuid);
|
||||
HeadlessSessionHost[] hosts = Enumerable.Range(0, sessionCount)
|
||||
.Select(index => CreateHost(index, operations))
|
||||
.ToArray();
|
||||
try
|
||||
{
|
||||
foreach (HeadlessSessionHost host in hosts)
|
||||
{
|
||||
Assert.Equal(
|
||||
RuntimeSessionStartStatus.Connected,
|
||||
host.Start().Status);
|
||||
}
|
||||
|
||||
var firstRecords = new RuntimeEntityRecord[sessionCount];
|
||||
for (int index = 0; index < hosts.Length; index++)
|
||||
{
|
||||
HeadlessSessionHost host = hosts[index];
|
||||
WorldSession session =
|
||||
operations.ActiveSession(host.SessionId);
|
||||
session.GameActionCapture = _ => { };
|
||||
SpawnInto(session, sharedPlayerGuid, index + 1f);
|
||||
RuntimeCommandResult selected =
|
||||
host.Commands.Selection.SelectObject(
|
||||
host.Runtime.Generation,
|
||||
sharedPlayerGuid);
|
||||
Assert.True(selected.Accepted);
|
||||
Assert.True(
|
||||
host.Runtime.EntityObjects.Entities.TryGetActive(
|
||||
sharedPlayerGuid,
|
||||
out RuntimeEntityRecord record));
|
||||
firstRecords[index] = record;
|
||||
Assert.Equal(
|
||||
index + 1f,
|
||||
record.Snapshot.Position!.Value.PositionX);
|
||||
|
||||
Portal(
|
||||
session,
|
||||
sharedPlayerGuid,
|
||||
index + 20f);
|
||||
Assert.True(host.Runtime.Portal.Snapshot.Completed);
|
||||
Assert.True(
|
||||
host.Runtime.TransitOwner
|
||||
.CaptureOwnership()
|
||||
.IsSessionIdle);
|
||||
}
|
||||
|
||||
Assert.Equal(
|
||||
sessionCount,
|
||||
firstRecords
|
||||
.Distinct(ReferenceEqualityComparer.Instance)
|
||||
.Count());
|
||||
Assert.Equal(
|
||||
sessionCount,
|
||||
hosts
|
||||
.Select(static host => host.Runtime.Entities)
|
||||
.Distinct(ReferenceEqualityComparer.Instance)
|
||||
.Count());
|
||||
|
||||
for (int index = 0; index < hosts.Length; index++)
|
||||
{
|
||||
HeadlessSessionHost host = hosts[index];
|
||||
ulong priorGeneration = host.Runtime.Generation.Value;
|
||||
Assert.Equal(
|
||||
RuntimeSessionStartStatus.Connected,
|
||||
host.Reconnect().Status);
|
||||
Assert.True(
|
||||
host.Runtime.Generation.Value > priorGeneration);
|
||||
Assert.Equal(0, host.Runtime.Entities.Count);
|
||||
|
||||
WorldSession replacement =
|
||||
operations.ActiveSession(host.SessionId);
|
||||
replacement.GameActionCapture = _ => { };
|
||||
SpawnInto(
|
||||
replacement,
|
||||
sharedPlayerGuid,
|
||||
index + 101f);
|
||||
Assert.True(
|
||||
host.Runtime.EntityObjects.Entities.TryGetActive(
|
||||
sharedPlayerGuid,
|
||||
out RuntimeEntityRecord replacementRecord));
|
||||
Assert.NotSame(firstRecords[index], replacementRecord);
|
||||
Assert.Equal(
|
||||
index + 101f,
|
||||
replacementRecord.Snapshot.Position!.Value.PositionX);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
for (int index = hosts.Length - 1; index >= 0; index--)
|
||||
hosts[index].Dispose();
|
||||
}
|
||||
|
||||
Assert.Equal(sessionCount * 2, operations.CreatedSessionCount);
|
||||
Assert.Equal(sessionCount * 2, operations.DisposedSessionCount);
|
||||
Assert.All(
|
||||
hosts,
|
||||
static host =>
|
||||
Assert.True(host.Runtime.CaptureOwnership().IsConverged));
|
||||
}
|
||||
|
||||
private static HeadlessSessionHost CreateHost(
|
||||
int index,
|
||||
FixtureOperations operations)
|
||||
{
|
||||
string id = $"bot-{index}";
|
||||
var credential = new HeadlessCredentialSecret(
|
||||
$"{id}-credential",
|
||||
"fixture-password");
|
||||
try
|
||||
{
|
||||
return new HeadlessSessionHost(
|
||||
new HeadlessSessionDescriptor
|
||||
{
|
||||
Id = id,
|
||||
Endpoint = new HeadlessEndpointDescriptor
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 9000,
|
||||
},
|
||||
Account = id,
|
||||
Character = new HeadlessCharacterSelector
|
||||
{
|
||||
Index = 0,
|
||||
},
|
||||
Policy = new HeadlessBotPolicyDescriptor
|
||||
{
|
||||
Id = "idle",
|
||||
},
|
||||
Credential = new HeadlessCredentialReference
|
||||
{
|
||||
Provider =
|
||||
HeadlessCredentialProviderKind.StandardInput,
|
||||
Reference = $"{id}-credential",
|
||||
},
|
||||
},
|
||||
credential,
|
||||
new HeadlessDiagnosticWriter(TextWriter.Null),
|
||||
operations);
|
||||
}
|
||||
catch
|
||||
{
|
||||
credential.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static void SpawnInto(
|
||||
WorldSession session,
|
||||
uint guid,
|
||||
float positionX) =>
|
||||
EventDelegate<Action<WorldSession.EntitySpawn>>(
|
||||
session,
|
||||
nameof(session.EntitySpawned))(
|
||||
Spawn(guid, positionX));
|
||||
|
||||
private static void Portal(
|
||||
WorldSession session,
|
||||
uint guid,
|
||||
float positionX)
|
||||
{
|
||||
EventDelegate<Action<uint>>(
|
||||
session,
|
||||
nameof(session.TeleportStarted))(1u);
|
||||
EventDelegate<Action<WorldSession.EntityPositionUpdate>>(
|
||||
session,
|
||||
nameof(session.PositionUpdated))(
|
||||
new WorldSession.EntityPositionUpdate(
|
||||
guid,
|
||||
Position(positionX) with
|
||||
{
|
||||
LandblockId = 0x01020001u,
|
||||
},
|
||||
Velocity: null,
|
||||
PlacementId: null,
|
||||
IsGrounded: true,
|
||||
InstanceSequence: 1,
|
||||
PositionSequence: 2,
|
||||
TeleportSequence: 1,
|
||||
ForcePositionSequence: 0));
|
||||
}
|
||||
|
||||
private static WorldSession.EntitySpawn Spawn(
|
||||
uint guid,
|
||||
float positionX)
|
||||
{
|
||||
CreateObject.ServerPosition position = Position(positionX);
|
||||
var timestamps = new PhysicsTimestamps(
|
||||
Position: 1,
|
||||
Movement: 1,
|
||||
State: 1,
|
||||
Vector: 1,
|
||||
Teleport: 0,
|
||||
ServerControlledMove: 1,
|
||||
ForcePosition: 0,
|
||||
ObjDesc: 1,
|
||||
Instance: 1);
|
||||
var physics = new PhysicsSpawnData(
|
||||
RawState: (uint)PhysicsStateFlags.ReportCollisions,
|
||||
Position: position,
|
||||
Movement: null,
|
||||
AnimationFrame: null,
|
||||
SetupTableId: 0x02000001u,
|
||||
MotionTableId: null,
|
||||
SoundTableId: null,
|
||||
PhysicsScriptTableId: null,
|
||||
Parent: null,
|
||||
Children: null,
|
||||
Scale: null,
|
||||
Friction: null,
|
||||
Elasticity: null,
|
||||
Translucency: null,
|
||||
Velocity: null,
|
||||
Acceleration: null,
|
||||
AngularVelocity: null,
|
||||
DefaultScriptType: null,
|
||||
DefaultScriptIntensity: null,
|
||||
Timestamps: timestamps);
|
||||
return new WorldSession.EntitySpawn(
|
||||
guid,
|
||||
position,
|
||||
0x02000001u,
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
null,
|
||||
null,
|
||||
"Headless",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
PhysicsState: physics.RawState,
|
||||
InstanceSequence: 1,
|
||||
MovementSequence: 1,
|
||||
ServerControlSequence: 1,
|
||||
PositionSequence: 1,
|
||||
Physics: physics);
|
||||
}
|
||||
|
||||
private static CreateObject.ServerPosition Position(float positionX) =>
|
||||
new(
|
||||
0x01010001u,
|
||||
positionX,
|
||||
10f,
|
||||
5f,
|
||||
1f,
|
||||
0f,
|
||||
0f,
|
||||
0f);
|
||||
|
||||
private static TDelegate EventDelegate<TDelegate>(
|
||||
WorldSession session,
|
||||
string eventName)
|
||||
where TDelegate : Delegate =>
|
||||
Assert.IsType<TDelegate>(
|
||||
typeof(WorldSession).GetField(
|
||||
eventName,
|
||||
BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
?.GetValue(session));
|
||||
|
||||
private sealed class FixtureOperations(uint sharedPlayerGuid)
|
||||
: ILiveSessionOperations
|
||||
{
|
||||
private readonly Dictionary<string, WorldSession> _active =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
public int CreatedSessionCount { get; private set; }
|
||||
public int DisposedSessionCount { get; private set; }
|
||||
|
||||
public WorldSession ActiveSession(string account) =>
|
||||
_active[account];
|
||||
|
||||
public IPEndPoint ResolveEndpoint(string host, int port) =>
|
||||
new(IPAddress.Loopback, port);
|
||||
|
||||
public WorldSession CreateSession(IPEndPoint endpoint)
|
||||
{
|
||||
CreatedSessionCount++;
|
||||
return new WorldSession(endpoint, new FixtureTransport());
|
||||
}
|
||||
|
||||
public void Connect(
|
||||
WorldSession session,
|
||||
string user,
|
||||
string password) =>
|
||||
_active[user] = session;
|
||||
|
||||
public CharacterList.Parsed GetCharacters(
|
||||
WorldSession session) =>
|
||||
new(
|
||||
0u,
|
||||
[
|
||||
new CharacterList.Character(
|
||||
sharedPlayerGuid,
|
||||
"Headless",
|
||||
0u),
|
||||
],
|
||||
[],
|
||||
11,
|
||||
"account",
|
||||
true,
|
||||
true);
|
||||
|
||||
public void EnterWorld(
|
||||
WorldSession session,
|
||||
int activeCharacterIndex)
|
||||
{
|
||||
}
|
||||
|
||||
public void Tick(WorldSession session)
|
||||
{
|
||||
}
|
||||
|
||||
public void DisposeSession(WorldSession session)
|
||||
{
|
||||
DisposedSessionCount++;
|
||||
session.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixtureTransport : IWorldSessionTransport
|
||||
{
|
||||
public void Send(ReadOnlySpan<byte> datagram)
|
||||
{
|
||||
}
|
||||
|
||||
public void Send(
|
||||
IPEndPoint remote,
|
||||
ReadOnlySpan<byte> datagram)
|
||||
{
|
||||
}
|
||||
|
||||
public int Receive(
|
||||
Span<byte> destination,
|
||||
TimeSpan timeout,
|
||||
out IPEndPoint? from)
|
||||
{
|
||||
from = null;
|
||||
return -1;
|
||||
}
|
||||
|
||||
public ValueTask<NetReceiveResult> ReceiveAsync(
|
||||
Memory<byte> destination,
|
||||
CancellationToken cancellationToken) =>
|
||||
ValueTask.FromException<NetReceiveResult>(
|
||||
new OperationCanceledException(cancellationToken));
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ using AcDream.Core.Spells;
|
|||
using AcDream.Runtime.Entities;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.Runtime.Session;
|
||||
using AcDream.Runtime.World;
|
||||
|
||||
namespace AcDream.Runtime.Tests.Session;
|
||||
|
||||
|
|
@ -102,6 +103,54 @@ public sealed class RuntimeLiveEntitySessionControllerTests
|
|||
Assert.Equal(0x01020001u, runtime.Portal.Snapshot.DestinationCell);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectSinkProjectsAcceptedLocalWorldStateThroughOneHostSeam()
|
||||
{
|
||||
using GameRuntime runtime = CreateRuntime();
|
||||
const uint playerGuid = 0x50000002u;
|
||||
runtime.PlayerIdentity.ServerGuid = playerGuid;
|
||||
using var session = new WorldSession(
|
||||
new IPEndPoint(IPAddress.Loopback, 9000),
|
||||
new FixtureTransport());
|
||||
session.GameActionCapture = _ => { };
|
||||
var projection = new FixtureWorldProjection();
|
||||
var controller = new RuntimeLiveEntitySessionController(
|
||||
runtime,
|
||||
session,
|
||||
worldProjection: projection);
|
||||
LiveEntitySessionSink sink = controller.CreateSink();
|
||||
WorldSession.EntitySpawn spawn =
|
||||
Spawn(playerGuid, incarnation: 1);
|
||||
|
||||
sink.Spawned(spawn);
|
||||
sink.TeleportStarted(1u);
|
||||
sink.PositionUpdated(new WorldSession.EntityPositionUpdate(
|
||||
playerGuid,
|
||||
spawn.Position!.Value with
|
||||
{
|
||||
LandblockId = 0x01020001u,
|
||||
PositionX = 30f,
|
||||
},
|
||||
Velocity: null,
|
||||
PlacementId: null,
|
||||
IsGrounded: true,
|
||||
InstanceSequence: 1,
|
||||
PositionSequence: 2,
|
||||
TeleportSequence: 1,
|
||||
ForcePositionSequence: 0));
|
||||
|
||||
Assert.Equal(1, projection.SpawnCount);
|
||||
Assert.Equal(1, projection.PositionCount);
|
||||
Assert.Equal(1, projection.TeleportStartCount);
|
||||
Assert.Equal(1, projection.PrepareCount);
|
||||
Assert.True(projection.LastSpawnWasLocal);
|
||||
Assert.True(projection.LastPositionWasLocal);
|
||||
Assert.Equal(playerGuid, projection.LastRecord?.ServerGuid);
|
||||
Assert.Equal(0x01020001u, projection.LastDestination.CellId);
|
||||
Assert.True(runtime.TransitOwner.CaptureOwnership().IsSessionIdle);
|
||||
Assert.True(runtime.Portal.Snapshot.Completed);
|
||||
}
|
||||
|
||||
private static GameRuntime CreateRuntime()
|
||||
{
|
||||
var operations = new FixtureGameplayOperations();
|
||||
|
|
@ -211,6 +260,57 @@ public sealed class RuntimeLiveEntitySessionControllerTests
|
|||
}
|
||||
}
|
||||
|
||||
private sealed class FixtureWorldProjection
|
||||
: IRuntimeDirectWorldProjection
|
||||
{
|
||||
public int SpawnCount { get; private set; }
|
||||
public int PositionCount { get; private set; }
|
||||
public int TeleportStartCount { get; private set; }
|
||||
public int PrepareCount { get; private set; }
|
||||
public bool LastSpawnWasLocal { get; private set; }
|
||||
public bool LastPositionWasLocal { get; private set; }
|
||||
public RuntimeEntityRecord? LastRecord { get; private set; }
|
||||
public RuntimeTeleportDestination LastDestination { get; private set; }
|
||||
|
||||
public void ProjectSpawn(
|
||||
RuntimeEntityRecord record,
|
||||
bool isLocalPlayer)
|
||||
{
|
||||
SpawnCount++;
|
||||
LastRecord = record;
|
||||
LastSpawnWasLocal = isLocalPlayer;
|
||||
}
|
||||
|
||||
public void ProjectPosition(
|
||||
RuntimeEntityRecord record,
|
||||
bool isLocalPlayer)
|
||||
{
|
||||
PositionCount++;
|
||||
LastRecord = record;
|
||||
LastPositionWasLocal = isLocalPlayer;
|
||||
}
|
||||
|
||||
public void BeginTeleport() => TeleportStartCount++;
|
||||
|
||||
public RuntimeDestinationReadiness PrepareDestination(
|
||||
long revealGeneration,
|
||||
RuntimeTeleportDestination destination)
|
||||
{
|
||||
PrepareCount++;
|
||||
LastDestination = destination;
|
||||
bool indoor = (destination.CellId & 0xFFFFu) >= 0x0100u;
|
||||
return new RuntimeDestinationReadiness(
|
||||
revealGeneration,
|
||||
destination.CellId,
|
||||
indoor,
|
||||
IsUnhydratable: false,
|
||||
RequiredRenderRadius: indoor ? 0 : 1,
|
||||
IsRenderNeighborhoodReady: true,
|
||||
AreCompositeTexturesReady: true,
|
||||
IsCollisionReady: true);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixtureGameplayOperations
|
||||
: IRuntimeCombatAttackOperations,
|
||||
IRuntimeCombatTargetOperations,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue