bbf418e779
Phase 1.5 long-term #8, the LAST 1.5 item (design review wf_e14dd739-069: both HIGHs + the distinct MEDs verified before the session limit ate the duplicate verifiers; unverified leftovers reasoned-closed in the spec). Cover = a BlightClutter ghost (Variant 4) with a baked Environment-layer BoxCollider: the runtime ghost's collider joins BuildPhysicsWorld -> blocks the player CC and the enemy sweep while alive, and the gap opens the instant the entity dies. Both hit paths already break it (the clutter chassis); 8 hits to carve. Review folds: - HIGH: the 07-07 anti-stuck nudge would phase enemies THROUGH cover after ~1.5s (it exists to prevent room soft-locks). The nudge is now COVER-AWARE: a SphereCast identifies the blocker; a live BlightClutter ghost suppresses the phase (no soft-lock possible - the blocker is destructible); static-geometry wedges still nudge. - HIGH: yield==durability made tanky cover an economy faucet. Deposit is DECOUPLED from the decrement at both hit sites: ScrapPerHit=0 = breaks in Remaining hits, yields NOTHING; fractional POSITIVE yields still credit >=1 (the B1 immortal-sink test pinned this - zero means zero, positive floors to 1). - MEDs: HitRadius 1.2 (visual core, not a shot-eating disc), NO cover in Boss rooms (no boss depenetration backstop), scatter keep-out >=7u from the room origin (player landing + portal), Variant=4 explicit (the clutter block's explosive reroll cannot leak). - Old static layer removed: 8 WorldColliders_RoomCover colliders (subscene) + the RoomCover_Visuals root (Game.unity). Verified live: 3 cover ghosts spawned with SOLID baked colliders; BLOCK - NudgeUntilTick stayed 0 across 3.5s of forced zero-progress (old code arms at 1.5s); DEPENETRATE - an enemy shoved inside a live rock exited to 1.7u (backstop holds against ghost colliders); CARVE - the rock died to the real sweep and the spot probed OPEN. 470/470 EditMode; console clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
241 lines
13 KiB
C#
241 lines
13 KiB
C#
using ProjectM.Simulation;
|
|
using Unity.Burst;
|
|
using Unity.Collections;
|
|
using Unity.Entities;
|
|
using Unity.Mathematics;
|
|
using Unity.NetCode;
|
|
using Unity.Transforms;
|
|
|
|
namespace ProjectM.Server
|
|
{
|
|
/// <summary>
|
|
/// Server-only resource harvest + Blight-clutter clearing: sweeps each surviving projectile's this-tick travel
|
|
/// segment against a UNIFIED target set of resource-node ghosts AND Blight-clutter ghosts, deposits the hit
|
|
/// target's yield into the GLOBAL resource ledger (the CycleDirector's <see cref="StorageEntry"/> buffer,
|
|
/// resolved via <see cref="ResourceLedger"/> — NEVER GetSingleton<StorageEntry>, which would collide with
|
|
/// the base storage container) and decrements its Remaining; the target despawns at <= 0. Nodes deposit
|
|
/// <see cref="ResourceNode.ResourceId"/> @ HarvestPerHit; clutter deposits <see cref="BlightClutter.ScrapResourceId"/>
|
|
/// @ ScrapPerHit (a small "minor scrap" trickle — carving through the frontier). UNIFYING the two into one sweep
|
|
/// is a CORRECTNESS requirement: two separate sweeps would each DestroyEntity a projectile that overlaps a node
|
|
/// AND a clutter piece — a double DestroyEntity throws at ECB playback. Runs in the plain server
|
|
/// SimulationSystemGroup <c>[UpdateAfter(PredictedSimulationSystemGroup)]</c> — after ProjectileDamageSystem has
|
|
/// consumed Health-target hits and range-expired projectiles, so this only sees true survivors. The swept
|
|
/// segment is reconstructed from <see cref="Projectile.LastStep"/> (written by ProjectileMoveSystem in the
|
|
/// fixed-step group), so it is tunnelling-safe WITHOUT depending on this plain group's variable-frame DeltaTime.
|
|
/// A target hit by two projectiles in one tick deposits twice but is destroyed exactly once. Relies on the
|
|
/// asserted ~1000-unit base/expedition coordinate gap so a base projectile can never reach an expedition target.
|
|
/// </summary>
|
|
[BurstCompile]
|
|
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
|
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
|
[UpdateAfter(typeof(PredictedSimulationSystemGroup))]
|
|
public partial struct ResourceHarvestSystem : ISystem
|
|
{
|
|
const float k_ProjectileRadius = Tuning.HarvestProjectileRadius;
|
|
|
|
ComponentLookup<GhostOwner> m_GhostOwnerLookup;
|
|
BufferLookup<InventorySlot> m_InvLookup;
|
|
ComponentLookup<RegionTag> m_RegionLookup;
|
|
|
|
[BurstCompile]
|
|
public void OnCreate(ref SystemState state)
|
|
{
|
|
state.RequireForUpdate<Projectile>();
|
|
state.RequireForUpdate<ResourceLedger>();
|
|
m_GhostOwnerLookup = state.GetComponentLookup<GhostOwner>(true);
|
|
m_InvLookup = state.GetBufferLookup<InventorySlot>(false);
|
|
m_RegionLookup = state.GetComponentLookup<RegionTag>(true);
|
|
}
|
|
|
|
[BurstCompile]
|
|
public void OnUpdate(ref SystemState state)
|
|
{
|
|
var ledgerEntity = SystemAPI.GetSingletonEntity<ResourceLedger>();
|
|
// Fuse scheduling needs the server tick; absent/invalid (plain test worlds) -> explosive pops fall
|
|
// back to plain destroy (old behaviour).
|
|
bool haveTick = SystemAPI.TryGetSingleton<Unity.NetCode.NetworkTime>(out var hvNetTime)
|
|
&& hvNetTime.ServerTick.IsValid;
|
|
uint nowTick = haveTick ? hvNetTime.ServerTick.TickIndexForValidTick : 0u;
|
|
var ledger = SystemAPI.GetBuffer<StorageEntry>(ledgerEntity);
|
|
|
|
// Resolve the harvesting player from the projectile's GhostOwner so yield lands in their PERSONAL
|
|
// inventory. Owner read via a cached lookup (optional); the owner->player map + item catalog are
|
|
// hoisted out of the per-hit sweep (invariant for the tick).
|
|
m_GhostOwnerLookup.Update(ref state);
|
|
m_InvLookup.Update(ref state);
|
|
m_RegionLookup.Update(ref state);
|
|
bool haveDb = SystemAPI.TryGetSingleton<ItemDatabase>(out var itemDb);
|
|
|
|
var playerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
|
|
foreach (var (owner, playerEntity) in
|
|
SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag, InventorySlot>().WithEntityAccess())
|
|
playerByConn[owner.ValueRO.NetworkId] = playerEntity;
|
|
|
|
// Snapshot all harvest/clear targets (nodes + clutter) once this tick into a UNIFIED set.
|
|
var tgtEntity = new NativeList<Entity>(Allocator.Temp);
|
|
var tgtPos = new NativeList<float2>(Allocator.Temp);
|
|
var tgtRadius = new NativeList<float>(Allocator.Temp);
|
|
var tgtRemaining = new NativeList<int>(Allocator.Temp);
|
|
var tgtYieldId = new NativeList<byte>(Allocator.Temp);
|
|
var tgtYieldPerHit = new NativeList<float>(Allocator.Temp);
|
|
var tgtVariant = new NativeList<byte>(Allocator.Temp);
|
|
var tgtIsClutter = new NativeList<bool>(Allocator.Temp);
|
|
var tgtToLedger = new NativeList<bool>(Allocator.Temp);
|
|
|
|
foreach (var (xform, hr, node, e) in
|
|
SystemAPI.Query<RefRO<LocalTransform>, RefRO<HitRadius>, RefRO<ResourceNode>>().WithEntityAccess())
|
|
{
|
|
if (node.ValueRO.Remaining <= 0) continue; // spent (a lit fuse) is not a target
|
|
|
|
tgtEntity.Add(e);
|
|
tgtPos.Add(xform.ValueRO.Position.xz);
|
|
tgtRadius.Add(hr.ValueRO.Value);
|
|
tgtRemaining.Add(node.ValueRO.Remaining);
|
|
tgtYieldId.Add(node.ValueRO.ResourceId);
|
|
tgtYieldPerHit.Add(node.ValueRO.HarvestPerHit);
|
|
tgtVariant.Add(0);
|
|
tgtIsClutter.Add(false);
|
|
tgtToLedger.Add(m_RegionLookup.HasComponent(e) && m_RegionLookup[e].Region == RegionId.Base);
|
|
}
|
|
|
|
foreach (var (xform, hr, clutter, e) in
|
|
SystemAPI.Query<RefRO<LocalTransform>, RefRO<HitRadius>, RefRO<BlightClutter>>().WithEntityAccess())
|
|
{
|
|
if (clutter.ValueRO.Remaining <= 0) continue; // lit-fuse barrel: unhittable, detonation owns it
|
|
|
|
tgtEntity.Add(e);
|
|
tgtPos.Add(xform.ValueRO.Position.xz);
|
|
tgtRadius.Add(hr.ValueRO.Value);
|
|
tgtRemaining.Add(clutter.ValueRO.Remaining);
|
|
tgtYieldId.Add(clutter.ValueRO.ScrapResourceId);
|
|
tgtYieldPerHit.Add(clutter.ValueRO.ScrapPerHit);
|
|
tgtVariant.Add(clutter.ValueRO.Variant);
|
|
tgtIsClutter.Add(true);
|
|
tgtToLedger.Add(m_RegionLookup.HasComponent(e) && m_RegionLookup[e].Region == RegionId.Base);
|
|
}
|
|
|
|
var destroyed = new NativeArray<bool>(tgtEntity.Length, Allocator.Temp);
|
|
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
|
|
|
foreach (var (xform, proj, projEntity) in
|
|
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Projectile>>().WithEntityAccess())
|
|
{
|
|
float3 cur = xform.ValueRO.Position;
|
|
float2 segEnd = cur.xz;
|
|
float2 segStart = segEnd - proj.ValueRO.Direction * proj.ValueRO.LastStep;
|
|
float2 seg = segEnd - segStart;
|
|
float segLenSq = math.lengthsq(seg);
|
|
|
|
int bestIdx = -1;
|
|
float bestT = float.MaxValue;
|
|
bool overlappedCleared = false; // struck a target a sibling projectile already cleared THIS tick
|
|
for (int i = 0; i < tgtEntity.Length; i++)
|
|
{
|
|
float2 tp = tgtPos[i];
|
|
float t = segLenSq > 1e-8f ? math.saturate(math.dot(tp - segStart, seg) / segLenSq) : 0f;
|
|
float2 closest = segStart + t * seg;
|
|
float hitDist = tgtRadius[i] + k_ProjectileRadius;
|
|
if (math.distancesq(tp, closest) > hitDist * hitDist)
|
|
continue;
|
|
if (destroyed[i]) { overlappedCleared = true; continue; }
|
|
if (t < bestT) { bestT = t; bestIdx = i; }
|
|
}
|
|
|
|
if (bestIdx < 0)
|
|
{
|
|
// No LIVE target on the segment. If the shot still overlapped a target a sibling projectile
|
|
// cleared this same tick, consume it anyway (a hit always spends the shot); else it's a miss.
|
|
if (overlappedCleared)
|
|
ecb.DestroyEntity(projEntity);
|
|
continue;
|
|
}
|
|
|
|
// A positive baked yield must always make progress: a raw (int) truncation of a sub-1.0 per-hit
|
|
// value would deposit 0 AND never decrement Remaining -> an immortal target that silently eats shots.
|
|
int amount = math.max(1, (int)tgtYieldPerHit[bestIdx]);
|
|
// DECOUPLED deposit (cover review wf_e14dd739-069): DESTRUCTIBLE COVER has ScrapPerHit=0 — it
|
|
// breaks in Remaining hits but yields NOTHING (durability must not be an economy faucet).
|
|
// Nodes/clutter (PerHit >= 1) keep deposit == decrement exactly as before.
|
|
// zero means ZERO (cover); any POSITIVE yield still credits >= 1 (the fractional-yield guard).
|
|
int deposit = tgtYieldPerHit[bestIdx] > 0f ? amount : 0;
|
|
byte yieldId = tgtYieldId[bestIdx];
|
|
|
|
// Route the yield into the HARVESTING player's PERSONAL inventory. The projectile carries the
|
|
// firing player's GhostOwner (AbilityFireSystem); the owner is read OPTIONALLY (cached lookup) so
|
|
// an un-owned projectile (or a test projectile with no GhostOwner) falls through to the ledger.
|
|
Entity harvester = Entity.Null;
|
|
if (m_GhostOwnerLookup.HasComponent(projEntity)
|
|
&& playerByConn.TryGetValue(m_GhostOwnerLookup[projEntity].NetworkId, out var ownedPlayer))
|
|
harvester = ownedPlayer;
|
|
if (deposit > 0)
|
|
HarvestMath.DepositYield(yieldId, deposit, tgtToLedger[bestIdx], harvester,
|
|
m_InvLookup, ledger, true, haveDb, itemDb);
|
|
int rem = tgtRemaining[bestIdx] - amount;
|
|
tgtRemaining[bestIdx] = rem;
|
|
ecb.DestroyEntity(projEntity);
|
|
|
|
if (rem <= 0)
|
|
{
|
|
if (!destroyed[bestIdx])
|
|
{
|
|
destroyed[bestIdx] = true;
|
|
if (tgtIsClutter[bestIdx] && tgtVariant[bestIdx] == 3 && haveTick)
|
|
{
|
|
// EXPLOSIVE pop (Variant 3): light the fuse instead of destroying — the replicated
|
|
// Remaining=0 on a still-alive barrel is the client's unambiguous fuse cue;
|
|
// HazardExplosionSystem detonates + destroys (Exploding_Barrels_Build_Spec).
|
|
SystemAPI.SetComponent(tgtEntity[bestIdx], new BlightClutter
|
|
{
|
|
Remaining = 0,
|
|
Variant = tgtVariant[bestIdx],
|
|
ScrapResourceId = tgtYieldId[bestIdx],
|
|
ScrapPerHit = tgtYieldPerHit[bestIdx],
|
|
});
|
|
ecb.AddComponent(tgtEntity[bestIdx], new BarrelFuse
|
|
{
|
|
ExplodeTick = TickUtil.NonZero(nowTick + Tuning.BarrelFuseTicks),
|
|
});
|
|
}
|
|
else
|
|
ecb.DestroyEntity(tgtEntity[bestIdx]);
|
|
}
|
|
}
|
|
else if (tgtIsClutter[bestIdx])
|
|
{
|
|
// Persist the decremented Remaining (replicated GhostField) so depletion carries across ticks.
|
|
SystemAPI.SetComponent(tgtEntity[bestIdx], new BlightClutter
|
|
{
|
|
Remaining = rem,
|
|
Variant = tgtVariant[bestIdx],
|
|
ScrapResourceId = tgtYieldId[bestIdx],
|
|
ScrapPerHit = tgtYieldPerHit[bestIdx],
|
|
});
|
|
}
|
|
else
|
|
{
|
|
SystemAPI.SetComponent(tgtEntity[bestIdx], new ResourceNode
|
|
{
|
|
ResourceId = tgtYieldId[bestIdx],
|
|
Remaining = rem,
|
|
HarvestPerHit = tgtYieldPerHit[bestIdx],
|
|
});
|
|
}
|
|
}
|
|
|
|
ecb.Playback(state.EntityManager);
|
|
ecb.Dispose();
|
|
playerByConn.Dispose();
|
|
destroyed.Dispose();
|
|
tgtEntity.Dispose();
|
|
tgtPos.Dispose();
|
|
tgtRadius.Dispose();
|
|
tgtRemaining.Dispose();
|
|
tgtYieldId.Dispose();
|
|
tgtYieldPerHit.Dispose();
|
|
tgtVariant.Dispose();
|
|
tgtIsClutter.Dispose();
|
|
tgtToLedger.Dispose();
|
|
}
|
|
}
|
|
}
|