257 lines
8.5 KiB
C#
257 lines
8.5 KiB
C#
namespace NeonSprawl.Server.Game.Npc;
|
|
|
|
/// <summary>Deterministic prototype NPC behavior state machine + lazy tick advance (NEO-93).</summary>
|
|
public static class NpcRuntimeOperations
|
|
{
|
|
/// <summary>Default per-advance simulation cap (seconds) for lazy poll ticks.</summary>
|
|
public const double DefaultMaxDeltaSeconds = 5.0;
|
|
|
|
/// <summary>
|
|
/// Advances all prototype NPC runtime rows by up to <paramref name="maxDeltaSeconds"/> since the last call.
|
|
/// </summary>
|
|
public static void AdvanceAll(
|
|
DateTimeOffset nowUtc,
|
|
INpcRuntimeStateStore runtimeStore,
|
|
IThreatStateStore threatStore,
|
|
INpcBehaviorDefinitionRegistry behaviorRegistry,
|
|
double maxDeltaSeconds = DefaultMaxDeltaSeconds)
|
|
{
|
|
if (maxDeltaSeconds <= 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var lastAdvanced = runtimeStore.LastAdvancedUtc;
|
|
if (lastAdvanced == DateTimeOffset.MinValue)
|
|
{
|
|
foreach (var npcInstanceId in PrototypeNpcRegistry.GetInstanceIdsInOrder())
|
|
{
|
|
AdvanceOne(
|
|
npcInstanceId,
|
|
nowUtc,
|
|
nowUtc,
|
|
runtimeStore,
|
|
threatStore,
|
|
behaviorRegistry);
|
|
}
|
|
|
|
runtimeStore.LastAdvancedUtc = nowUtc;
|
|
return;
|
|
}
|
|
|
|
var rawDeltaSeconds = (nowUtc - lastAdvanced).TotalSeconds;
|
|
if (rawDeltaSeconds <= 0)
|
|
{
|
|
foreach (var npcInstanceId in PrototypeNpcRegistry.GetInstanceIdsInOrder())
|
|
{
|
|
AdvanceOne(
|
|
npcInstanceId,
|
|
nowUtc,
|
|
nowUtc,
|
|
runtimeStore,
|
|
threatStore,
|
|
behaviorRegistry);
|
|
}
|
|
|
|
runtimeStore.LastAdvancedUtc = nowUtc;
|
|
return;
|
|
}
|
|
|
|
var deltaSeconds = Math.Min(rawDeltaSeconds, maxDeltaSeconds);
|
|
var windowStart = lastAdvanced;
|
|
var windowEnd = windowStart.AddSeconds(deltaSeconds);
|
|
|
|
foreach (var npcInstanceId in PrototypeNpcRegistry.GetInstanceIdsInOrder())
|
|
{
|
|
AdvanceOne(
|
|
npcInstanceId,
|
|
windowStart,
|
|
windowEnd,
|
|
runtimeStore,
|
|
threatStore,
|
|
behaviorRegistry);
|
|
}
|
|
|
|
runtimeStore.LastAdvancedUtc = nowUtc;
|
|
}
|
|
|
|
/// <summary>Resets all prototype NPC runtime rows to idle (dev fixture).</summary>
|
|
public static void ResetAllPrototypeRows(INpcRuntimeStateStore runtimeStore) =>
|
|
runtimeStore.ResetAllPrototypeRows();
|
|
|
|
private static void AdvanceOne(
|
|
string npcInstanceId,
|
|
DateTimeOffset windowStart,
|
|
DateTimeOffset windowEnd,
|
|
INpcRuntimeStateStore runtimeStore,
|
|
IThreatStateStore threatStore,
|
|
INpcBehaviorDefinitionRegistry behaviorRegistry)
|
|
{
|
|
if (!PrototypeNpcRegistry.TryGet(npcInstanceId, out var entry) ||
|
|
!behaviorRegistry.TryGetDefinition(entry.BehaviorDefId, out var behavior))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!runtimeStore.TryGet(npcInstanceId, out var snapshot))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!TryHasAggroHolder(npcInstanceId, threatStore, out _))
|
|
{
|
|
if (snapshot.BehaviorState != NpcBehaviorState.Idle || snapshot.ActiveTelegraph is not null)
|
|
{
|
|
WriteIdle(runtimeStore, npcInstanceId);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (snapshot.BehaviorState == NpcBehaviorState.Idle)
|
|
{
|
|
WriteState(
|
|
runtimeStore,
|
|
npcInstanceId,
|
|
NpcBehaviorState.Aggro,
|
|
windowStart,
|
|
activeTelegraph: null);
|
|
// telemetry: npc_state_transition (NEO-96)
|
|
if (!runtimeStore.TryGet(npcInstanceId, out snapshot))
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
|
|
var cursor = windowStart;
|
|
while (cursor < windowEnd)
|
|
{
|
|
if (!TryHasAggroHolder(npcInstanceId, threatStore, out _))
|
|
{
|
|
WriteIdle(runtimeStore, npcInstanceId);
|
|
return;
|
|
}
|
|
|
|
if (!runtimeStore.TryGet(npcInstanceId, out snapshot))
|
|
{
|
|
return;
|
|
}
|
|
|
|
switch (snapshot.BehaviorState)
|
|
{
|
|
case NpcBehaviorState.Idle:
|
|
return;
|
|
|
|
case NpcBehaviorState.Aggro:
|
|
{
|
|
var phaseEnd = snapshot.PhaseStartedUtc.AddSeconds(behavior.AttackCooldownSeconds);
|
|
if (windowEnd < phaseEnd)
|
|
{
|
|
return;
|
|
}
|
|
|
|
cursor = phaseEnd;
|
|
var telegraph = CreateTelegraph(npcInstanceId, cursor);
|
|
WriteState(
|
|
runtimeStore,
|
|
npcInstanceId,
|
|
NpcBehaviorState.TelegraphWindup,
|
|
cursor,
|
|
telegraph);
|
|
// telemetry: npc_state_transition (NEO-96)
|
|
continue;
|
|
}
|
|
|
|
case NpcBehaviorState.TelegraphWindup:
|
|
{
|
|
var phaseEnd = snapshot.PhaseStartedUtc.AddSeconds(behavior.TelegraphWindupSeconds);
|
|
if (windowEnd < phaseEnd)
|
|
{
|
|
return;
|
|
}
|
|
|
|
cursor = phaseEnd;
|
|
WriteState(
|
|
runtimeStore,
|
|
npcInstanceId,
|
|
NpcBehaviorState.Recover,
|
|
cursor,
|
|
activeTelegraph: null);
|
|
// telemetry: telegraph_fired / npc_state_transition (NEO-96); logical attack_execute → recover (NEO-95 applies attackDamage)
|
|
continue;
|
|
}
|
|
|
|
case NpcBehaviorState.AttackExecute:
|
|
WriteState(
|
|
runtimeStore,
|
|
npcInstanceId,
|
|
NpcBehaviorState.Recover,
|
|
cursor,
|
|
activeTelegraph: null);
|
|
// telemetry: npc_state_transition (NEO-96); NEO-95 applies attackDamage here
|
|
continue;
|
|
|
|
case NpcBehaviorState.Recover:
|
|
{
|
|
var phaseEnd = snapshot.PhaseStartedUtc.AddSeconds(behavior.AttackCooldownSeconds);
|
|
if (windowEnd < phaseEnd)
|
|
{
|
|
return;
|
|
}
|
|
|
|
cursor = phaseEnd;
|
|
var telegraph = CreateTelegraph(npcInstanceId, cursor);
|
|
WriteState(
|
|
runtimeStore,
|
|
npcInstanceId,
|
|
NpcBehaviorState.TelegraphWindup,
|
|
cursor,
|
|
telegraph);
|
|
// telemetry: npc_state_transition (NEO-96)
|
|
continue;
|
|
}
|
|
|
|
default:
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static bool TryHasAggroHolder(
|
|
string npcInstanceId,
|
|
IThreatStateStore threatStore,
|
|
out string holderPlayerId)
|
|
{
|
|
holderPlayerId = string.Empty;
|
|
if (!threatStore.TryGet(npcInstanceId, out var threat) ||
|
|
string.IsNullOrEmpty(threat.AggroHolderPlayerId))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
holderPlayerId = threat.AggroHolderPlayerId;
|
|
return true;
|
|
}
|
|
|
|
private static ActiveTelegraphSnapshot CreateTelegraph(string npcInstanceId, DateTimeOffset windupStartedUtc)
|
|
{
|
|
var telegraphId = $"{npcInstanceId}:{windupStartedUtc.UtcTicks}";
|
|
return new ActiveTelegraphSnapshot(telegraphId, windupStartedUtc);
|
|
}
|
|
|
|
private static void WriteIdle(INpcRuntimeStateStore runtimeStore, string npcInstanceId) =>
|
|
WriteState(runtimeStore, npcInstanceId, NpcBehaviorState.Idle, DateTimeOffset.MinValue, activeTelegraph: null);
|
|
|
|
private static void WriteState(
|
|
INpcRuntimeStateStore runtimeStore,
|
|
string npcInstanceId,
|
|
NpcBehaviorState behaviorState,
|
|
DateTimeOffset phaseStartedUtc,
|
|
ActiveTelegraphSnapshot? activeTelegraph)
|
|
{
|
|
_ = runtimeStore.TryWrite(
|
|
npcInstanceId,
|
|
new NpcRuntimeStateSnapshot(npcInstanceId, behaviorState, phaseStartedUtc, activeTelegraph));
|
|
}
|
|
}
|