Restore daemon destruction and loot
This commit is contained in:
parent
861b82f2ff
commit
22dae9adbe
13 changed files with 333 additions and 56 deletions
|
|
@ -19,6 +19,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
|
|||
CanonicalCatalogPublishesRecoveredMineLauncher();
|
||||
EnergyFundedShieldInterceptsDamageBeforeHull();
|
||||
ArmorCellsResolveBeforeEquipmentAndHull();
|
||||
DestructionDropsLootExactlyOnce();
|
||||
DaemonSimulationTreatsYmirHitAsPresentationOnly();
|
||||
ProjectileContactCannotKill();
|
||||
MissingPhysicsOwnerCannotAdvanceProjectiles();
|
||||
|
|
@ -504,6 +505,79 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
|
|||
"Eve world projection must expose the exact daemon-owned schematic grid");
|
||||
}
|
||||
|
||||
private static void DestructionDropsLootExactlyOnce()
|
||||
{
|
||||
var source = Entity(0, -100, "player");
|
||||
var target = Entity(1, 0, "raider");
|
||||
target.HullItemKey = "drop-hull";
|
||||
target.StatGrids = [Grid("hull", 1), Grid("shield", 0), Grid("heat", 0)];
|
||||
target.Equipment = [new AetheriaRuntimeLoadoutItemSlotCommit
|
||||
{
|
||||
Item = new AetheriaRuntimeLoadoutItemCommit
|
||||
{ ItemKey = "drop-gear", Quality = 0.8, Durability = 0.6, Quantity = 1, Enabled = true }
|
||||
}];
|
||||
target.CargoContents = [Cargo(("drop-cargo", 4, 0, 0))];
|
||||
var zone = new AetheriaRuntimeZoneSnapshotCommit
|
||||
{
|
||||
ZoneIndex = 0, Entities = [source, target],
|
||||
PhysicalPayloads = [new AetheriaRuntimePhysicalPayloadCommit
|
||||
{
|
||||
PayloadId = "destruction-mine", PayloadKind = "mine", WeaponItemKey = "destruction-charge",
|
||||
SourceEntityIndex = 0, PositionX = 0, PositionZ = 0, BlastRadius = 200,
|
||||
PayloadMagnitude = 20, TriggeredAtSeconds = 0, DetonationDelaySeconds = 0,
|
||||
LifetimeSeconds = 30, Active = true, Stationary = true
|
||||
}]
|
||||
};
|
||||
var run = new AetheriaRuntimeRunCheckpointCommit
|
||||
{
|
||||
RunId = "destruction-smoke", GenerationSeed = 29, CurrentZoneIndex = 0,
|
||||
CurrentEntityKey = "zone.0.entity.0", Zones = [zone]
|
||||
};
|
||||
var settings = LootSettings(1);
|
||||
|
||||
AetheriaRuntimeDaemonSimulation.Step(run, new AetheriaRuntimeDaemonIntentState(), 0.1, settings,
|
||||
new AetheriaYmirPhysicalPayloadPhysics(), new AetheriaYmirWorldPhysics(), null, 1, 0.1);
|
||||
|
||||
Require(!target.IsActive && target.DestroyedFrameId == 1 &&
|
||||
!string.IsNullOrWhiteSpace(target.DestructionId),
|
||||
"lethal daemon damage must commit one durable destruction identity and deactivate the entity");
|
||||
RequireEqual(3, zone.DroppedPickups.Count,
|
||||
"guaranteed fossil loot roll must drop hull, equipment, and every cargo stack");
|
||||
RequireEqual(4, zone.DroppedPickups.Single(value => value.Item.ItemKey == "drop-cargo").Item.Quantity,
|
||||
"cargo drops must preserve exact stack quantity");
|
||||
Require(zone.DroppedPickups.All(value => Math.Abs(Math.Sqrt(
|
||||
value.VelocityX * value.VelocityX + value.VelocityY * value.VelocityY +
|
||||
value.VelocityZ * value.VelocityZ) - 25) < 0.000001 &&
|
||||
Math.Abs(value.LifetimeSeconds - 30) < 0.000001),
|
||||
"destruction pickups must retain fossil launch speed and lifetime");
|
||||
RequireEqual(3, run.GameEvents.Count(value => value.Kind == "pickup.dropped"),
|
||||
"each durable pickup must publish one drop event");
|
||||
RequireEqual(1, run.GameEvents.Count(value => value.Kind == "entity.destroyed"),
|
||||
"destruction chronology must publish exactly once");
|
||||
|
||||
var surface = AetheriaRuntimeDaemonGameSurfaceBuilder.Build(
|
||||
new AetheriaRuntimeDaemonFrameDocument { FrameId = 1, Run = run },
|
||||
new AetheriaRuntimeDaemonHealthDocument(),
|
||||
AetheriaRuntimeDaemonCommandBoundaryDocument.Create("daemon"));
|
||||
Require(!Flatten(surface.Surface.Root).Any(node => node.Id == "aetheria.daemon.game.world.entity.1"),
|
||||
"Eve must not project a destroyed tombstone as an active world entity");
|
||||
RequireEqual(3, Flatten(surface.Surface.Root).Count(node => node.Kind == "world.entity3d" &&
|
||||
node.Props.TryGetValue("entityKind", out var kind) && kind == "pickup"),
|
||||
"Eve must project every daemon-owned destruction pickup");
|
||||
var destructionAsset = AetheriaRuntimeAssets.ProjectManifest(null).Assets.Single(value =>
|
||||
value.Ref.Metadata.TryGetValue("presentationRole", out var role) &&
|
||||
role == "effect.feedback.entity.destroyed");
|
||||
RequireEqual("prefab.effect.entity.destroyed", destructionAsset.Ref.AssetKey,
|
||||
"provider must advertise the original destruction effect by semantic feedback role");
|
||||
|
||||
AetheriaRuntimeDaemonSimulation.Step(run, new AetheriaRuntimeDaemonIntentState(), 0.1, settings,
|
||||
new AetheriaYmirPhysicalPayloadPhysics(), new AetheriaYmirWorldPhysics(), null, 2, 0.2);
|
||||
RequireEqual(3, zone.DroppedPickups.Count,
|
||||
"later ticks must not duplicate an already committed destruction transaction");
|
||||
RequireEqual(1, run.GameEvents.Count(value => value.Kind == "entity.destroyed"),
|
||||
"later ticks must not duplicate destruction chronology");
|
||||
}
|
||||
|
||||
private static void ChargedWeaponHoldRiskMalfunctionsDeterministically()
|
||||
{
|
||||
var source = Entity(0, 0, "player");
|
||||
|
|
@ -911,6 +985,22 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
|
|||
0, 0, "", Array.Empty<AetheriaRuntimeAudioStat>(), Array.Empty<AetheriaRuntimeCurveKey>(), "", "");
|
||||
}
|
||||
|
||||
private static AetheriaRuntimeDaemonSimulationSettings LootSettings(double probability)
|
||||
{
|
||||
var value = AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault;
|
||||
return new AetheriaRuntimeDaemonSimulationSettings(
|
||||
value.PawnSpeed, value.RaiderSpeed, value.AttackRange, value.AttackHoldRatio,
|
||||
value.PawnProjectileDamage, value.RaiderProjectileDamage, value.WeaponCooldownSeconds,
|
||||
value.ProjectileSpeed, value.ProjectileRadius, value.ProjectileLifetimeSeconds,
|
||||
value.ProjectileSpawnOffset, value.ProjectileHeatScale, value.HeatDissipationPerSecond,
|
||||
value.StationSensorRange, value.EntitySensorRange, value.PlayerStationHull,
|
||||
value.HostileStationHull, value.PlayerEntityHull, value.RaiderEntityHull,
|
||||
value.StationShield, value.EntityShield, value.WeaponLockSpeed,
|
||||
value.WeaponLockSensorImpact, value.WeaponLockAngleDegrees,
|
||||
value.WeaponLockDirectionImpact, value.WeaponLockDecayPerSecond,
|
||||
probability, value.LootDropVelocity, value.PickupLifetimeSeconds);
|
||||
}
|
||||
|
||||
private static void AgentPatrolsHistoricalOrbitCircuitThroughMovementCommands()
|
||||
{
|
||||
var agent = Entity(0, 0, "workers");
|
||||
|
|
|
|||
|
|
@ -11281,8 +11281,7 @@ static void RequireUnityPublicRequestVocabulary(string root)
|
|||
var checkedRoots = new[]
|
||||
{
|
||||
Path.Combine(root, "Assets", "Scripts", "Gameplay"),
|
||||
Path.Combine(root, "Assets", "Scripts", "UI"),
|
||||
Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime")
|
||||
Path.Combine(root, "Assets", "Scripts", "UI")
|
||||
};
|
||||
|
||||
var commitPattern = new System.Text.RegularExpressions.Regex(
|
||||
|
|
@ -16732,6 +16731,31 @@ static void RequireUnityDoesNotCallSharedSimulationTicks(string root)
|
|||
string.Join(", ", missingArmorTransactionSymbols));
|
||||
}
|
||||
|
||||
var daemonOperations = File.ReadAllText(Path.Combine(
|
||||
root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimeDaemonOperations.cs"));
|
||||
var requiredDestructionTransactionSymbols = new[]
|
||||
{
|
||||
"CommitDestruction(run, zone, target",
|
||||
"target.DestructionId = destructionId",
|
||||
"settings.LootDropProbability",
|
||||
"settings.LootDropVelocity",
|
||||
"settings.PickupLifetimeSeconds",
|
||||
"Kind = \"pickup.dropped\"",
|
||||
"Kind = \"entity.destroyed\"",
|
||||
"zone.DroppedPickups = pickups"
|
||||
};
|
||||
var missingDestructionTransactionSymbols = requiredDestructionTransactionSymbols
|
||||
.Where(symbol => !daemonSimulation.Contains(symbol, StringComparison.Ordinal))
|
||||
.ToArray();
|
||||
if (missingDestructionTransactionSymbols.Length > 0 ||
|
||||
daemonOperations.Contains("ApplyDestroyEntity(", StringComparison.Ordinal) ||
|
||||
daemonOperations.Contains("case AetheriaRuntimeDaemonCommandKinds.DestroyEntity:", StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Lethal daemon damage must own exactly-once destruction, loot drops, and cleanup; clients may not command entity erasure: " +
|
||||
string.Join(", ", missingDestructionTransactionSymbols));
|
||||
}
|
||||
|
||||
if (daemonSimulation.Contains("AetheriaRuntimeRtsSimulation", StringComparison.Ordinal) ||
|
||||
daemonSimulation.Contains("daemon-rts", StringComparison.Ordinal) ||
|
||||
daemonSimulation.Contains("rts-commander", StringComparison.Ordinal) ||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,6 @@
|
|||
"org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-source-v1.0.3-r2",
|
||||
"org.gamecult.eve.plugin-fields": "https://github.com/GameCult/EvePlugins.git?path=/plugins/eve-plugin-fields/unity/org.gamecult.eve.plugin-fields#eve-plugin-fields-unity-v0.1.1",
|
||||
"org.gamecult.eve.surface": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.surface#eveunity-surface-v0.1.0",
|
||||
"org.gamecult.eve.unity-scene": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#0d97dcf56c8b813de50d319ebce10a93595ad5c4"
|
||||
"org.gamecult.eve.unity-scene": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#290b384703d19b4deee79fe015d80957ec5f8eb6"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@
|
|||
"hash": "e10821f1cc81f619479c6507438f6a097d1f0ce2"
|
||||
},
|
||||
"org.gamecult.eve.unity-scene": {
|
||||
"version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#0d97dcf56c8b813de50d319ebce10a93595ad5c4",
|
||||
"version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#290b384703d19b4deee79fe015d80957ec5f8eb6",
|
||||
"depth": 0,
|
||||
"source": "git",
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -4303,7 +4303,7 @@ public class DaemonRuntimeDocumentTests
|
|||
}
|
||||
|
||||
[Test]
|
||||
public void DaemonOperationsDestroysEntityInDaemonState()
|
||||
public void DaemonOperationsRejectsClientAuthoredEntityDestruction()
|
||||
{
|
||||
var run = RunWithTwoEntities();
|
||||
run.CurrentEntityKey = "zone.0.entity.1";
|
||||
|
|
@ -4338,18 +4338,19 @@ public class DaemonRuntimeDocumentTests
|
|||
|
||||
var result = AetheriaRuntimeDaemonOperations.Execute(run, new[] { command });
|
||||
|
||||
Assert.AreEqual(1, result.AppliedCommandIds.Count);
|
||||
Assert.AreEqual(2, run.Zones[0].Entities.Count);
|
||||
Assert.AreEqual(0, result.AppliedCommandIds.Count);
|
||||
Assert.AreEqual(1, result.RejectedCommandIds.Count);
|
||||
Assert.AreEqual(3, run.Zones[0].Entities.Count);
|
||||
Assert.AreEqual("Player", run.Zones[0].Entities[0].Name);
|
||||
Assert.AreEqual(0, run.Zones[0].Entities[0].EntityIndex);
|
||||
Assert.AreEqual("Reindexed", run.Zones[0].Entities[1].Name);
|
||||
Assert.AreEqual(1, run.Zones[0].Entities[1].EntityIndex);
|
||||
Assert.AreEqual(-1, run.Zones[0].Entities[0].TargetEntityIndex);
|
||||
Assert.AreEqual(0, run.Zones[0].Entities[0].ChildEntityIndices.Count);
|
||||
Assert.AreEqual(-1, run.Zones[0].Entities[0].DockingBayAssignments[0]);
|
||||
Assert.AreEqual("Target", run.Zones[0].Entities[1].Name);
|
||||
Assert.AreEqual("Reindexed", run.Zones[0].Entities[2].Name);
|
||||
Assert.AreEqual(1, run.Zones[0].Entities[0].TargetEntityIndex);
|
||||
Assert.AreEqual(1, run.Zones[0].Entities[0].ChildEntityIndices.Single());
|
||||
Assert.AreEqual(1, run.Zones[0].Entities[0].DockingBayAssignments[0]);
|
||||
Assert.AreEqual(-1, run.Zones[0].Entities[0].DockingBayAssignments[1]);
|
||||
Assert.AreEqual(0, run.Zones[0].Entities[0].Contacts.Count);
|
||||
Assert.AreEqual("", run.CurrentEntityKey);
|
||||
Assert.AreEqual(1, run.Zones[0].Entities[0].Contacts.Single().TargetEntityIndex);
|
||||
Assert.AreEqual("zone.0.entity.1", run.CurrentEntityKey);
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ namespace GameCult.Aetheria.State.Verse
|
|||
Add(entries, MapPrefab("prefab.entity.orbital", "Orbital", "Prefabs/Stations/Zenith", "orbital"));
|
||||
Add(entries, MapPrefab("prefab.effect.shot.bolt", "Bolt", "Prefabs/Lightning", "effect.shot.bolt"));
|
||||
Add(entries, MapPrefab("prefab.effect.impact.shield", "Shield impact", "Prefabs/Shield", "effect.impact.shield"));
|
||||
Add(entries, DestructionEffect());
|
||||
Add(entries, MapProjectPrefab("prefab.entity.pickup", "Pickup", "Assets/Prefabs/RPG/Pickups/Tetrahedron.prefab"));
|
||||
Add(entries, MinePrefab());
|
||||
foreach (var inventoryAsset in InventoryUiAssets())
|
||||
|
|
@ -219,6 +220,19 @@ namespace GameCult.Aetheria.State.Verse
|
|||
return entry;
|
||||
}
|
||||
|
||||
private static AetheriaRuntimeAssetManifestEntry DestructionEffect()
|
||||
{
|
||||
var entry = MapProjectPrefab(
|
||||
"prefab.effect.entity.destroyed",
|
||||
"Entity destruction",
|
||||
"Assets/Prefabs/Fire & Explosion Effects/Prefabs/BigExplosion.prefab");
|
||||
entry.Ref.Metadata = new Dictionary<string, string>(entry.Ref.Metadata, StringComparer.Ordinal)
|
||||
{
|
||||
["presentationRole"] = "effect.feedback.entity.destroyed"
|
||||
};
|
||||
return entry;
|
||||
}
|
||||
|
||||
private static AetheriaRuntimeAssetRef Sprite(string key, string resourcePath)
|
||||
{
|
||||
return CultMeshAssetRef(
|
||||
|
|
|
|||
|
|
@ -130,8 +130,6 @@ namespace GameCult.Aetheria.State.Verse
|
|||
case AetheriaRuntimeDaemonCommandKinds.SetEntityName:
|
||||
return ApplyTargetEntity(run, command.TargetEntityKey, entity =>
|
||||
entity.Name = command.TextValue ?? "");
|
||||
case AetheriaRuntimeDaemonCommandKinds.DestroyEntity:
|
||||
return ApplyDestroyEntity(run, command);
|
||||
case AetheriaRuntimeDaemonCommandKinds.SetDockedCurrentShip:
|
||||
return ApplySetDockedCurrentShip(run, command.TargetEntityKey);
|
||||
case AetheriaRuntimeDaemonCommandKinds.TransferCargoItem:
|
||||
|
|
@ -1664,30 +1662,6 @@ namespace GameCult.Aetheria.State.Verse
|
|||
return true;
|
||||
}
|
||||
|
||||
private static bool ApplyDestroyEntity(
|
||||
AetheriaRuntimeRunCheckpointCommit run,
|
||||
AetheriaRuntimeDaemonCommandDocument command)
|
||||
{
|
||||
if (!AetheriaRuntimeRunCheckpointCommit.TryParseEntityKey(command.TargetEntityKey, out var zoneIndex, out var entityIndex))
|
||||
return false;
|
||||
|
||||
var zones = (run.Zones ?? Array.Empty<AetheriaRuntimeZoneSnapshotCommit>()).ToList();
|
||||
var zone = zones.FirstOrDefault(candidate => candidate != null && candidate.ZoneIndex == zoneIndex);
|
||||
if (zone == null)
|
||||
return false;
|
||||
|
||||
var entities = (zone.Entities ?? Array.Empty<AetheriaRuntimeEntitySnapshotCommit>()).ToList();
|
||||
if (entityIndex < 0 || entityIndex >= entities.Count)
|
||||
return false;
|
||||
|
||||
entities.RemoveAt(entityIndex);
|
||||
zone.Entities = entities.ToArray();
|
||||
ReindexZoneAfterEntityRemoval(zone, entityIndex);
|
||||
ReindexCurrentEntityKeyAfterRemoval(run, zoneIndex, entityIndex);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void ReindexZoneAfterEntityRemoval(AetheriaRuntimeZoneSnapshotCommit zone, int removedEntityIndex)
|
||||
{
|
||||
var entities = zone.Entities ?? Array.Empty<AetheriaRuntimeEntitySnapshotCommit>();
|
||||
|
|
|
|||
|
|
@ -331,7 +331,7 @@ namespace GameCult.Aetheria.State.Verse
|
|||
weapon.State.BurstRemaining--;
|
||||
weapon.State.BurstTimer -= weapon.State.BurstInterval;
|
||||
var shotId = NextShotId(zone, attacker, weapon);
|
||||
CommitShotResolution(run, zone, attacker, target, weapon, shotId, frameId, catalog);
|
||||
CommitShotResolution(run, zone, attacker, target, weapon, shotId, frameId, catalog, settings);
|
||||
AppendShotCommittedEvent(run, zone, attacker, target, weapon, shotId, frameId);
|
||||
weapon.State.Firing = true;
|
||||
AetheriaRuntimeThermalSimulation.AddHeat(attacker, weapon.Heat);
|
||||
|
|
@ -362,7 +362,7 @@ namespace GameCult.Aetheria.State.Verse
|
|||
}
|
||||
}
|
||||
}
|
||||
ResolveDeployableDetonations(run, zone, entities, frameId, simulationTimeSeconds, catalog);
|
||||
ResolveDeployableDetonations(run, zone, entities, frameId, simulationTimeSeconds, catalog, settings);
|
||||
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
|
|
@ -445,7 +445,8 @@ namespace GameCult.Aetheria.State.Verse
|
|||
IReadOnlyList<AetheriaRuntimeEntitySnapshotCommit> entities,
|
||||
long frameId,
|
||||
double simulationTimeSeconds,
|
||||
AetheriaRuntimeCatalogSnapshot? catalog)
|
||||
AetheriaRuntimeCatalogSnapshot? catalog,
|
||||
AetheriaRuntimeDaemonSimulationSettings settings)
|
||||
{
|
||||
var survivors = new List<AetheriaRuntimePhysicalPayloadCommit>();
|
||||
foreach (var payload in zone.PhysicalPayloads ?? Array.Empty<AetheriaRuntimePhysicalPayloadCommit>())
|
||||
|
|
@ -464,6 +465,7 @@ namespace GameCult.Aetheria.State.Verse
|
|||
var dz = target.PositionZ - payload.PositionZ;
|
||||
if (dx * dx + dz * dz > payload.BlastRadius * payload.BlastRadius) continue;
|
||||
var source = entities.FirstOrDefault(value => value.EntityIndex == payload.SourceEntityIndex);
|
||||
var aliveBefore = IsAlive(target);
|
||||
var damage = ResolveDamage(target, payload.PayloadMagnitude, payload.DamageType,
|
||||
payload.Penetration, payload.DamageSpread, source,
|
||||
payload.PositionX, payload.PositionZ, true, catalog);
|
||||
|
|
@ -490,6 +492,9 @@ namespace GameCult.Aetheria.State.Verse
|
|||
Reason = payload.DamageType,
|
||||
PositionX = payload.PositionX, PositionZ = payload.PositionZ
|
||||
});
|
||||
if (aliveBefore && !IsAlive(target))
|
||||
CommitDestruction(run, zone, target, payload.SourceEntityIndex,
|
||||
payload.PayloadId, payload.WeaponItemKey, frameId, settings);
|
||||
}
|
||||
AetheriaRuntimeGameEvents.Append(run, new AetheriaRuntimeGameEventCommit { EventId = $"physical-payload:{payload.PayloadId}:detonated", Kind = "deployable.detonated", FrameId = frameId, ZoneIndex = zone.ZoneIndex, SourceEntityIndex = payload.SourceEntityIndex, SubjectKey = payload.PayloadId, ItemKey = payload.WeaponItemKey, Reason = payload.TriggerReason, ScalarValue = payload.PayloadMagnitude, PositionX = payload.PositionX, PositionZ = payload.PositionZ });
|
||||
}
|
||||
|
|
@ -618,7 +623,7 @@ namespace GameCult.Aetheria.State.Verse
|
|||
state.BurstRemaining--;
|
||||
state.BurstTimer -= state.BurstInterval;
|
||||
var shotId = NextShotId(zone, attacker, shot);
|
||||
CommitShotResolution(run, zone, attacker, solutionTarget, shot, shotId, frameId, catalog);
|
||||
CommitShotResolution(run, zone, attacker, solutionTarget, shot, shotId, frameId, catalog, settings);
|
||||
AppendShotCommittedEvent(run, zone, attacker, solutionTarget, shot, shotId, frameId);
|
||||
state.Firing = true;
|
||||
AetheriaRuntimeThermalSimulation.AddHeat(attacker, shot.Heat);
|
||||
|
|
@ -772,7 +777,7 @@ namespace GameCult.Aetheria.State.Verse
|
|||
AetheriaRuntimeThermalSimulation.AddHeat(attacker, weapon.Heat * deltaSeconds);
|
||||
var shot = weapon.ResolutionShot(deltaSeconds);
|
||||
var shotId = NextShotId(zone, attacker, shot);
|
||||
CommitShotResolution(run, zone, attacker, selectedTarget!, shot, shotId, frameId, catalog);
|
||||
CommitShotResolution(run, zone, attacker, selectedTarget!, shot, shotId, frameId, catalog, settings);
|
||||
AppendShotCommittedEvent(run, zone, attacker, selectedTarget!, shot, shotId, frameId);
|
||||
}
|
||||
}
|
||||
|
|
@ -1096,7 +1101,8 @@ namespace GameCult.Aetheria.State.Verse
|
|||
ResolvedWeapon weapon,
|
||||
string shotId,
|
||||
long frameId,
|
||||
AetheriaRuntimeCatalogSnapshot? catalog)
|
||||
AetheriaRuntimeCatalogSnapshot? catalog,
|
||||
AetheriaRuntimeDaemonSimulationSettings settings)
|
||||
{
|
||||
if ((run.ShotReceipts ?? Array.Empty<AetheriaRuntimeShotReceiptCommit>())
|
||||
.Any(value => value != null && string.Equals(value.ShotId, shotId, StringComparison.Ordinal)))
|
||||
|
|
@ -1196,13 +1202,7 @@ namespace GameCult.Aetheria.State.Verse
|
|||
PositionX = target.PositionX, PositionZ = target.PositionZ
|
||||
});
|
||||
if (aliveBefore && !IsAlive(target))
|
||||
AetheriaRuntimeGameEvents.Append(run, new AetheriaRuntimeGameEventCommit
|
||||
{
|
||||
EventId = $"shot:{shotId}:destroyed:{target.EntityIndex}", Kind = "entity.destroyed",
|
||||
FrameId = frameId, ZoneIndex = zone.ZoneIndex, SourceEntityIndex = attacker.EntityIndex,
|
||||
TargetEntityIndex = target.EntityIndex, SubjectKey = shotId,
|
||||
ItemKey = weapon.ItemKey, PositionX = target.PositionX, PositionZ = target.PositionZ
|
||||
});
|
||||
CommitDestruction(run, zone, target, attacker.EntityIndex, shotId, weapon.ItemKey, frameId, settings);
|
||||
}
|
||||
|
||||
private static string NextShotId(AetheriaRuntimeZoneSnapshotCommit zone,
|
||||
|
|
@ -1212,6 +1212,130 @@ namespace GameCult.Aetheria.State.Verse
|
|||
return $"shot:{zone.ZoneIndex}:{attacker.EntityIndex}:{weapon.State.OwnerIndex}:{weapon.State.BehaviorIndex}:{weapon.State.ShotSequence}";
|
||||
}
|
||||
|
||||
private static void CommitDestruction(
|
||||
AetheriaRuntimeRunCheckpointCommit run,
|
||||
AetheriaRuntimeZoneSnapshotCommit zone,
|
||||
AetheriaRuntimeEntitySnapshotCommit target,
|
||||
int sourceEntityIndex,
|
||||
string subjectKey,
|
||||
string weaponItemKey,
|
||||
long frameId,
|
||||
AetheriaRuntimeDaemonSimulationSettings settings)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(target.DestructionId)) return;
|
||||
|
||||
var destructionId = $"destruction:{run.RunId}:{zone.ZoneIndex}:{target.EntityIndex}:{frameId}";
|
||||
target.DestructionId = destructionId;
|
||||
target.DestroyedFrameId = frameId;
|
||||
target.IsActive = false;
|
||||
target.TargetEntityIndex = -1;
|
||||
target.TractorPower = 0;
|
||||
target.TractorTargetPower = 0;
|
||||
foreach (var state in target.WeaponStates ?? Array.Empty<AetheriaRuntimeWeaponStateCommit>())
|
||||
{
|
||||
state.Firing = false;
|
||||
state.Charging = false;
|
||||
state.Charged = false;
|
||||
state.LockTargetEntityIndex = -1;
|
||||
}
|
||||
|
||||
var pickups = (zone.DroppedPickups ?? Array.Empty<AetheriaRuntimeDroppedPickupCommit>()).ToList();
|
||||
var nextPickupIndex = pickups.Count == 0 ? 0 : pickups.Max(value => value.PickupIndex) + 1;
|
||||
void Drop(AetheriaRuntimeLoadoutItemCommit item, string sourceKind, int sourceIndex)
|
||||
{
|
||||
var velocity = DestructionVelocity(run.GenerationSeed, destructionId,
|
||||
$"{sourceKind}:{sourceIndex}", settings.LootDropVelocity);
|
||||
var pickup = new AetheriaRuntimeDroppedPickupCommit
|
||||
{
|
||||
PickupIndex = nextPickupIndex++, PositionX = target.PositionX, PositionY = target.PositionY,
|
||||
PositionZ = target.PositionZ, VelocityX = velocity.X, VelocityY = velocity.Y,
|
||||
VelocityZ = velocity.Z, Item = CloneItem(item), AgeSeconds = 0,
|
||||
LifetimeSeconds = settings.PickupLifetimeSeconds
|
||||
};
|
||||
pickups.Add(pickup);
|
||||
AetheriaRuntimeGameEvents.Append(run, new AetheriaRuntimeGameEventCommit
|
||||
{
|
||||
EventId = $"{destructionId}:pickup:{pickup.PickupIndex}", Kind = "pickup.dropped",
|
||||
FrameId = frameId, ZoneIndex = zone.ZoneIndex, SourceEntityIndex = sourceEntityIndex,
|
||||
TargetEntityIndex = target.EntityIndex, PickupIndex = pickup.PickupIndex,
|
||||
ItemKey = pickup.Item.ItemKey, ScalarValue = Math.Max(1, pickup.Item.Quantity),
|
||||
SubjectKey = destructionId, Reason = sourceKind,
|
||||
PositionX = target.PositionX, PositionZ = target.PositionZ
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(target.HullItemKey) &&
|
||||
ShotRoll(run.GenerationSeed, destructionId, "equipment:hull") < settings.LootDropProbability)
|
||||
Drop(new AetheriaRuntimeLoadoutItemCommit
|
||||
{
|
||||
ItemKey = target.HullItemKey, Quality = 1, Durability = 0,
|
||||
Quantity = 1, Enabled = false
|
||||
}, "equipment", -1);
|
||||
var equipment = target.Equipment ?? Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>();
|
||||
for (var index = 0; index < equipment.Count; index++)
|
||||
{
|
||||
var slot = equipment[index];
|
||||
if (slot?.Item == null ||
|
||||
ShotRoll(run.GenerationSeed, destructionId, $"equipment:{index}") >= settings.LootDropProbability)
|
||||
continue;
|
||||
Drop(slot.Item, "equipment", index);
|
||||
}
|
||||
var cargoIndex = 0;
|
||||
foreach (var slot in (target.CargoContents ?? Array.Empty<AetheriaRuntimeCargoBayLoadoutCommit>())
|
||||
.Where(bay => bay != null)
|
||||
.SelectMany(bay => bay.Items ?? Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>()))
|
||||
{
|
||||
if (slot?.Item != null) Drop(slot.Item, "cargo", cargoIndex);
|
||||
cargoIndex++;
|
||||
}
|
||||
zone.DroppedPickups = pickups;
|
||||
|
||||
target.Equipment = Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>();
|
||||
target.CargoContents = Array.Empty<AetheriaRuntimeCargoBayLoadoutCommit>();
|
||||
target.CargoBays = Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>();
|
||||
target.DockingBays = Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>();
|
||||
target.DockingBayContents = Array.Empty<AetheriaRuntimeCargoBayLoadoutCommit>();
|
||||
target.DockingBayAssignments = Array.Empty<int>();
|
||||
foreach (var entity in zone.Entities ?? Array.Empty<AetheriaRuntimeEntitySnapshotCommit>())
|
||||
{
|
||||
if (entity == null || entity.EntityIndex == target.EntityIndex) continue;
|
||||
if (entity.TargetEntityIndex == target.EntityIndex) entity.TargetEntityIndex = -1;
|
||||
entity.ChildEntityIndices = (entity.ChildEntityIndices ?? Array.Empty<int>())
|
||||
.Where(index => index != target.EntityIndex).ToArray();
|
||||
entity.DockingBayAssignments = (entity.DockingBayAssignments ?? Array.Empty<int>())
|
||||
.Select(index => index == target.EntityIndex ? -1 : index).ToArray();
|
||||
entity.Contacts = (entity.Contacts ?? Array.Empty<AetheriaRuntimeEntityContactCommit>())
|
||||
.Where(contact => contact != null && contact.TargetEntityIndex != target.EntityIndex).ToArray();
|
||||
}
|
||||
if (run.CurrentZoneIndex == zone.ZoneIndex && TryParseEntityIndex(run.CurrentEntityKey, out var currentIndex) &&
|
||||
currentIndex == target.EntityIndex)
|
||||
run.CurrentEntityKey = "";
|
||||
|
||||
AetheriaRuntimeGameEvents.Append(run, new AetheriaRuntimeGameEventCommit
|
||||
{
|
||||
EventId = destructionId, Kind = "entity.destroyed", FrameId = frameId,
|
||||
ZoneIndex = zone.ZoneIndex, SourceEntityIndex = sourceEntityIndex,
|
||||
TargetEntityIndex = target.EntityIndex, SubjectKey = subjectKey,
|
||||
ItemKey = weaponItemKey, PositionX = target.PositionX, PositionZ = target.PositionZ
|
||||
});
|
||||
}
|
||||
|
||||
private static AetheriaRuntimeLoadoutItemCommit CloneItem(AetheriaRuntimeLoadoutItemCommit item) => new()
|
||||
{
|
||||
ItemKey = item.ItemKey ?? "", Quality = item.Quality, Durability = item.Durability,
|
||||
Quantity = item.Quantity, Enabled = item.Enabled, OverrideShutdown = item.OverrideShutdown,
|
||||
Temperature = item.Temperature
|
||||
};
|
||||
|
||||
private static (double X, double Y, double Z) DestructionVelocity(
|
||||
uint seed, string destructionId, string itemKey, double speed)
|
||||
{
|
||||
var z = ShotRoll(seed, destructionId, $"{itemKey}:velocity-z") * 2 - 1;
|
||||
var angle = ShotRoll(seed, destructionId, $"{itemKey}:velocity-angle") * Math.PI * 2;
|
||||
var radius = Math.Sqrt(Math.Max(0, 1 - z * z));
|
||||
return (Math.Cos(angle) * radius * speed, z * speed, Math.Sin(angle) * radius * speed);
|
||||
}
|
||||
|
||||
private static void AppendShotCommittedEvent(AetheriaRuntimeRunCheckpointCommit run,
|
||||
AetheriaRuntimeZoneSnapshotCommit zone, AetheriaRuntimeEntitySnapshotCommit attacker,
|
||||
AetheriaRuntimeEntitySnapshotCommit target, ResolvedWeapon weapon, string shotId, long frameId)
|
||||
|
|
|
|||
|
|
@ -34,7 +34,10 @@ namespace GameCult.Aetheria.State.Verse
|
|||
weaponLockSensorImpact: 1.0,
|
||||
weaponLockAngleDegrees: 45.0,
|
||||
weaponLockDirectionImpact: 1.0,
|
||||
weaponLockDecayPerSecond: 1.0);
|
||||
weaponLockDecayPerSecond: 1.0,
|
||||
lootDropProbability: 0.25,
|
||||
lootDropVelocity: 25.0,
|
||||
pickupLifetimeSeconds: 30.0);
|
||||
|
||||
public AetheriaRuntimeDaemonSimulationSettings(
|
||||
double pawnSpeed,
|
||||
|
|
@ -62,7 +65,10 @@ namespace GameCult.Aetheria.State.Verse
|
|||
double weaponLockSensorImpact = AetheriaDefaultRaw.WeaponLockSensorImpact,
|
||||
double weaponLockAngleDegrees = AetheriaDefaultRaw.WeaponLockAngleDegrees,
|
||||
double weaponLockDirectionImpact = AetheriaDefaultRaw.WeaponLockDirectionImpact,
|
||||
double weaponLockDecayPerSecond = AetheriaDefaultRaw.WeaponLockDecayPerSecond)
|
||||
double weaponLockDecayPerSecond = AetheriaDefaultRaw.WeaponLockDecayPerSecond,
|
||||
double lootDropProbability = AetheriaDefaultRaw.LootDropProbability,
|
||||
double lootDropVelocity = AetheriaDefaultRaw.LootDropVelocity,
|
||||
double pickupLifetimeSeconds = AetheriaDefaultRaw.PickupLifetimeSeconds)
|
||||
{
|
||||
PawnSpeed = PositiveOr(pawnSpeed, AetheriaDefaultRaw.PawnSpeed);
|
||||
RaiderSpeed = PositiveOr(raiderSpeed, AetheriaDefaultRaw.RaiderSpeed);
|
||||
|
|
@ -90,6 +96,9 @@ namespace GameCult.Aetheria.State.Verse
|
|||
WeaponLockAngleDegrees = PositiveOr(weaponLockAngleDegrees, AetheriaDefaultRaw.WeaponLockAngleDegrees);
|
||||
WeaponLockDirectionImpact = NonNegativeOr(weaponLockDirectionImpact, AetheriaDefaultRaw.WeaponLockDirectionImpact);
|
||||
WeaponLockDecayPerSecond = NonNegativeOr(weaponLockDecayPerSecond, AetheriaDefaultRaw.WeaponLockDecayPerSecond);
|
||||
LootDropProbability = Clamp01Or(lootDropProbability, AetheriaDefaultRaw.LootDropProbability);
|
||||
LootDropVelocity = NonNegativeOr(lootDropVelocity, AetheriaDefaultRaw.LootDropVelocity);
|
||||
PickupLifetimeSeconds = PositiveOr(pickupLifetimeSeconds, AetheriaDefaultRaw.PickupLifetimeSeconds);
|
||||
}
|
||||
|
||||
public double PawnSpeed { get; }
|
||||
|
|
@ -118,6 +127,9 @@ namespace GameCult.Aetheria.State.Verse
|
|||
public double WeaponLockAngleDegrees { get; }
|
||||
public double WeaponLockDirectionImpact { get; }
|
||||
public double WeaponLockDecayPerSecond { get; }
|
||||
public double LootDropProbability { get; }
|
||||
public double LootDropVelocity { get; }
|
||||
public double PickupLifetimeSeconds { get; }
|
||||
|
||||
private static double PositiveOr(double value, double fallback)
|
||||
{
|
||||
|
|
@ -162,6 +174,9 @@ namespace GameCult.Aetheria.State.Verse
|
|||
public const double WeaponLockAngleDegrees = 45.0;
|
||||
public const double WeaponLockDirectionImpact = 1.0;
|
||||
public const double WeaponLockDecayPerSecond = 1.0;
|
||||
public const double LootDropProbability = 0.25;
|
||||
public const double LootDropVelocity = 25.0;
|
||||
public const double PickupLifetimeSeconds = 30.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1039,6 +1039,12 @@ namespace GameCult.Aetheria.State.Verse
|
|||
|
||||
[Key(44)]
|
||||
public AetheriaRuntimeLoadoutGenerationReceiptCommit? LoadoutGeneration { get; set; }
|
||||
|
||||
[Key(45)]
|
||||
public string DestructionId { get; set; } = "";
|
||||
|
||||
[Key(46)]
|
||||
public long DestroyedFrameId { get; set; } = -1;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
|
|
|
|||
|
|
@ -750,6 +750,24 @@ legacy catalog cache, and legacy UI paths should be migration-only or deleted.
|
|||
transaction mutates them thereafter. Penetration always advances by a
|
||||
half-cell step, eliminating the fossil's non-advancing infinite loop.
|
||||
|
||||
### Daemon Destruction Transaction
|
||||
|
||||
- Owner: lethal daemon damage owns one destruction identity, active-world
|
||||
removal, reference cleanup, loot rolls, pickup state, and chronology.
|
||||
- Inputs: canonical hull result, run seed, entity equipment/cargo, fossil loot
|
||||
settings (`0.25` equipment probability, `25` launch speed, `30` second life).
|
||||
- Outputs: an inactive identity-stable tombstone, deterministic typed pickups,
|
||||
`entity.destroyed` and `pickup.dropped` events, and Eve world removal/effects.
|
||||
- Derived state: the retained entity row exists only for stable indexing and
|
||||
replay protection. It is not rendered, targetable, salvageable, or alive.
|
||||
- Forbidden writers: Unity destruction callbacks, Eve feedback renderers, and
|
||||
the reserved `DestroyEntity` command cannot erase entities or create loot.
|
||||
- Shared paths: direct shots and deployable splash invoke the same destruction
|
||||
commit after the shared damage resolver crosses zero hull.
|
||||
- Cut line: client-commanded removal and presentation-triggered loot are dead;
|
||||
EveUnity resolves `effect.feedback.entity.destroyed` only after the provider
|
||||
has published authoritative destruction chronology.
|
||||
|
||||
- Owner: `Aetheria.State` owns the new typed state spine for durable state.
|
||||
The embedded `GameCult.Aetheria.State.Unity` package owns Unity's runtime
|
||||
typed state file path plus the boot-time state-file probe, and is the landing
|
||||
|
|
|
|||
|
|
@ -154,6 +154,15 @@ the shape. Splash payloads such as mines preserve the fossil's distinct rule:
|
|||
they select the source-facing half of the hull schematic, then enter the same
|
||||
layer transaction.
|
||||
|
||||
Lethal damage commits destruction in the same daemon transaction boundary.
|
||||
The target receives one durable destruction identity, leaves the active Eve
|
||||
world, clears combat/docking references, and emits deterministic pickups. The
|
||||
fossil rules are preserved: every cargo stack drops, each equipped item rolls
|
||||
independently against `0.25`, launch velocity has magnitude `25`, and pickup
|
||||
lifetime is `30` seconds. A retained entity row is a non-rendered tombstone for
|
||||
stable identity and exactly-once replay protection; it is not a wreck or a
|
||||
second gameplay owner. The reserved `DestroyEntity` command is rejected.
|
||||
|
||||
## Armor-Cell Authority Map
|
||||
|
||||
- Owner: the daemon's canonical damage transaction owns shield interception,
|
||||
|
|
@ -253,6 +262,8 @@ drop or simplify an effect. It may not promote a visual collision into damage.
|
|||
- a repair loop may not reconcile client-observed impacts into daemon health.
|
||||
- direct and deployable callers may not bypass the shared layer transaction
|
||||
with scalar hull damage.
|
||||
- Unity, Eve, and command clients may not remove an entity or manufacture its
|
||||
loot after observing a destruction effect.
|
||||
|
||||
## Cut Line
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ validation proves contract shape, not wiring, exclusivity, lowering, or parity.
|
|||
| Shield/armor/equipment/hull damage | energy-funded shield, schematic armor cells, equipment and scalar hull | daemon canonical damage transaction | aggregate/exact armor grid, entity status, and layer-aware shot/damage receipts | schematic, meters, impacts, damage VFX | owner-exists; wired; exclusive; projected: daemon initializes armor/maximum-armor grids from hull plus hardpoint catalog topology; direct and deployable damage share one `shield -> armor cell -> equipment -> scalar hull` transaction; direct impacts select a cell or complete hardpoint footprint before orthogonal spread and 0.5-cell penetration, while splash payloads select the source-facing hull half; the fossil penetration infinite-loop defect is fixed; damage type is typed/pass-through without resistances because the fossil never applied them; Eve exposes aggregate and exact armor state and receipts identify reached/applied layers; fake Ymir contacts and presentation cannot decide damage | fossil parity fixtures for topology, edge impacts, orthogonal spread, multi-cell penetration, equipment interception, and generic shield/equipment effect lifecycle |
|
||||
| Thermal/energy | cell conduction and capacitor/reactor/radiator network | daemon | ship/equipment status and warnings | schematic/post effects | cell conduction/radiation wired; energy network and medical thresholds remain reference | capacitor/reactor/radiator ordering, shared energy and medical timelines |
|
||||
| Weapons | ServerShared behaviors + Unity effects | daemon plus Ymir geometry/kinematics | action capability, weapon state, shot/damage receipts | effects/audio/HUD | instant and constant resource/timing lifecycles are daemon-owned; charged weapons accept one semantic request, spool without requiring a solution, hold at full charge, commit automatically when a solution appears, and perform deterministic once-per-second malfunction hazard checks after the authored safe hold interval with increasing persisted risk; generic Eve `weapon.state` exposes charge hold/risk and feedback exposes readiness, commit, refusal, and malfunction chronology; committed direct damage now enters the shared daemon armor-cell transaction | guided persistent payloads, visibility/wear, remaining impact-distribution parity, and Unity effect reconciliation |
|
||||
| Destruction/drops | entity death and pickup paths | daemon | death/drop events and world entities | effects and pickups | fragments wired; chronology and original drop transaction absent | exactly-once drops and cleanup |
|
||||
| Destruction/drops | hull death, 25% equipment rolls, unconditional cargo drops, 25-unit launch, 30-second pickups | daemon | destruction identity, death/drop events, typed pickups | provider-advertised destruction effect and pickups | owner-exists; wired; exclusive; projected: direct and deployable lethal damage commit one deterministic destruction transaction, clear live references, retain only a non-rendered identity tombstone, and emit exact pickups/events; the reserved client `DestroyEntity` command is rejected; Aetheria advertises its original big explosion as `effect.feedback.entity.destroyed`, lowered generically by EveUnity | equipment-destruction-before-hull parity and multiplayer reconnect chronology |
|
||||
| Tractor/scoop | analog tractor and physical timed pickups | daemon + Ymir | tractor power, pickup body and receipt | beam/interaction feedback | fossil 2/s power ramp and authored forward radius/traction/distance feed pickup bodies into the shared Ymir world; shield contact collects exactly once when capacity permits or applies the fossil 25-unit rejection kick; 30s daemon lifetime; instant cargo teleport deleted | prove generic runtime beam, contact, success and rejection feedback |
|
||||
| Dock/undock | wormhole-first interact; first eligible bay; component prerequisites | daemon + Ymir placement | dock commands/context | camera/UI transition | daemon path wired/projected; Ymir placement and parity unproven | priority, eligibility, placement and reconnect |
|
||||
| Inventory/equipment | inventory panels + ServerShared | daemon | inventory/equipment surfaces | generic UI | owner-exists; wired/projected in fragments; not proven | all transfer directions and rejection |
|
||||
|
|
|
|||
Loading…
Reference in a new issue