feat(vfx #C.1.5b): GpuWorldState fires activator for dat-hydrated entities

Four new foreach blocks in GpuWorldState wire EntityScriptActivator
into the dat-hydration spawn/despawn paths:
- AddLandblock: fires OnCreate for each entity with ServerGuid==0
  (live entities filtered out — they got OnCreate at AppendLiveEntity
  and would double-fire on pending-bucket merges).
- AddEntitiesToExistingLandblock: fires OnCreate for each entity in
  the promoted batch (all dat-hydrated by construction).
- RemoveLandblock: fires OnRemove(entity.Id) for each ServerGuid==0
  entity before the loaded record is dropped.
- RemoveEntitiesFromLandblock: fires OnRemove for the demote-tier
  entities about to be cleared (Near→Far demotion).

5 new integration tests cover the four fire-sites + the no-double-fire
invariant on pending-bucket merges. Pattern matches existing
GpuWorldStateTests (stub LandBlock heightmap + WorldEntity factory).

Closes #56 end-to-end. Slice A (per-part transforms in Tasks 1-3) +
Slice B (dat-hydrated entity DefaultScript firing, this task) both
ready for visual verification at Holtburg portal + Inn fireplace +
cottage chimney + spell cast.

Note: 8 pre-existing failures in Physics/Input/MotionInterpreter test
families are unrelated to this work (verified by re-running with this
task's changes stashed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-12 00:07:38 +02:00
parent 5ca5827abe
commit 8735c39a40
2 changed files with 220 additions and 0 deletions

View file

@ -180,6 +180,21 @@ public sealed class GpuWorldState
_loaded[landblock.LandblockId] = landblock;
if (_wbSpawnAdapter is not null)
_wbSpawnAdapter.OnLandblockLoaded(_loaded[landblock.LandblockId]);
// C.1.5b: fire DefaultScript for dat-hydrated entities (ServerGuid==0).
// Live entities (ServerGuid!=0) already had OnCreate fired at
// AppendLiveEntity; the filter avoids double-firing pending-bucket merges.
if (_entityScriptActivator is not null)
{
var loadedEntities = _loaded[landblock.LandblockId].Entities;
for (int i = 0; i < loadedEntities.Count; i++)
{
var e = loadedEntities[i];
if (e.ServerGuid == 0)
_entityScriptActivator.OnCreate(e);
}
}
RebuildFlatView();
}
@ -245,6 +260,19 @@ public sealed class GpuWorldState
_persistentRescued.Add(entity);
}
}
// C.1.5b: stop DefaultScript for each dat-hydrated entity in
// the landblock. Server-spawned entities are either being
// rescued (script continues at the new LB) or were OnRemove'd
// via RemoveEntityByServerGuid earlier; leave them alone here.
if (_entityScriptActivator is not null)
{
foreach (var entity in lb.Entities)
{
if (entity.ServerGuid == 0)
_entityScriptActivator.OnRemove(entity.Id);
}
}
}
_pendingByLandblock.Remove(landblockId);
@ -408,6 +436,18 @@ public sealed class GpuWorldState
// canonicalized). Null when the cache isn't wired (tests). Per spec §5.3 W3b.
_onLandblockUnloaded?.Invoke(canonical);
// C.1.5b: stop DefaultScript for each dat-hydrated entity about to
// be dropped. Demote-tier entities are always atlas-tier (ServerGuid==0
// per this method's class doc-comment); the filter is belt-and-suspenders.
if (_entityScriptActivator is not null)
{
foreach (var entity in lb.Entities)
{
if (entity.ServerGuid == 0)
_entityScriptActivator.OnRemove(entity.Id);
}
}
_loaded[canonical] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, System.Array.Empty<WorldEntity>());
_pendingByLandblock.Remove(canonical);
RebuildFlatView();
@ -447,6 +487,17 @@ public sealed class GpuWorldState
_loaded[canonical] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, merged);
if (_wbSpawnAdapter is not null)
_wbSpawnAdapter.OnLandblockLoaded(_loaded[canonical]);
// C.1.5b: fire DefaultScript for each promoted dat-hydrated entity.
// All entities arriving via this path are atlas-tier by construction
// (the promotion path streams in dat-static scenery + EnvCell statics
// + stabs per the method's class doc-comment).
if (_entityScriptActivator is not null)
{
for (int i = 0; i < entities.Count; i++)
_entityScriptActivator.OnCreate(entities[i]);
}
RebuildFlatView();
}