Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public sealed class BodyProfileDto
public string archetype_id;
public string visual_prefab_key;
public EquipmentLoadoutDto equipment;
public CharacterStatsDto stats;
public CharacterTraitsDto characteristics;
public BodyTimeDto time;
public CultivationDto cultivation;
Expand Down Expand Up @@ -51,6 +52,21 @@ public sealed class CharacterTraitsDto
public int sociability = 5;
}

[Serializable]
public sealed class CharacterStatsDto
{
public int level = 1;
public int vitality = 10;
public int force = 8;
public int agility = 8;
public int focus = 8;
public int resilience = 8;
public int max_health = 100;
public int max_energy = 50;
public int attack_power = 10;
public int defense_power = 5;
}

[Serializable]
public sealed class BodyTimeDto
{
Expand Down
93 changes: 77 additions & 16 deletions Unity/Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public sealed class CharacterMemorySync : MonoBehaviour
[SerializeField] private bool _syncOnStart = true;
[SerializeField] private bool _preferNakama = true;
[SerializeField] private bool _seedPrototypeMemory = true;
[SerializeField] private bool _applyProfileStatsToLocalPlayer = true;
[SerializeField] private bool _applyProfileEquipmentToLocalPlayer = true;
[SerializeField, TextArea] private string _prototypeMemory =
"JOY wants overnight prototype progress without client-side LLM secrets.";
Expand Down Expand Up @@ -59,7 +60,7 @@ public IEnumerator Refresh()
var soulName = ctx?.body?.soul?.name ?? "unknown";
Debug.Log($"[CharacterMemorySync] Loaded Nakama soul '{soulName}'.");
}, Debug.LogWarning);
yield return ApplyProfileEquipmentWhenAvailable();
yield return ApplyProfileToLocalPlayerWhenAvailable();
yield break;
}
}
Expand All @@ -70,7 +71,7 @@ public IEnumerator Refresh()
var soulName = ctx?.body?.soul?.name ?? "unknown";
Debug.Log($"[CharacterMemorySync] Loaded gateway prototype soul '{soulName}'.");
}, Debug.LogWarning);
yield return ApplyProfileEquipmentWhenAvailable();
yield return ApplyProfileToLocalPlayerWhenAvailable();
}

private IEnumerator WaitForAuthAttempt()
Expand All @@ -95,36 +96,37 @@ private IEnumerator AddMemory(MemoryRecordDto memory)
yield return _gateway.AddMemory(memory, ctx => _context = ctx, Debug.LogWarning);
}

private IEnumerator ApplyProfileEquipmentWhenAvailable()
private IEnumerator ApplyProfileToLocalPlayerWhenAvailable()
{
if (!_applyProfileEquipmentToLocalPlayer)
if (!_applyProfileEquipmentToLocalPlayer && !_applyProfileStatsToLocalPlayer)
{
yield break;
}

var equipmentVisualId = _context?.body?.equipment?.equipment_visual_id ?? EquipmentVisualCatalog.None;
if (equipmentVisualId == EquipmentVisualCatalog.None)
var body = _context?.body;
if (body == null)
{
yield break;
}

const float maxWaitSeconds = 10f;
const float retryIntervalSeconds = 0.25f;
var elapsed = 0f;
while (elapsed < maxWaitSeconds)
{
if (TryApplyProfileEquipment(equipmentVisualId))
if (TryApplyProfileBody(body))
{
yield break;
}

elapsed += Time.deltaTime;
yield return null;
elapsed += retryIntervalSeconds;
yield return new WaitForSeconds(retryIntervalSeconds);
}

Debug.LogWarning($"[CharacterMemorySync] No local state-authority player was ready for equipment visual {equipmentVisualId}.");
Debug.LogWarning("[CharacterMemorySync] No local state-authority player was ready for profile body sync.");
}

private static bool TryApplyProfileEquipment(int equipmentVisualId)
private bool TryApplyProfileBody(BodyProfileDto body)
{
var players = Object.FindObjectsByType<NetworkPlayer>(FindObjectsInactive.Exclude);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This method is called within a while loop in ApplyProfileToLocalPlayerWhenAvailable, meaning Object.FindObjectsByType is executed every frame for up to 10 seconds until a player is found. This is inefficient. Consider caching the result or using a more direct way to reference the local authoritative player.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using Object.FindObjectsByType<NetworkPlayer> inside a polling loop (called every 0.25 seconds via ApplyProfileToLocalPlayerWhenAvailable) is an expensive operation as it scans the entire scene hierarchy. While acceptable for a prototype, this can cause performance degradation in scenes with many objects. Consider using a more efficient approach, such as having NetworkPlayer register itself with a manager or using a static reference for the local player character.

foreach (var player in players)
Expand All @@ -134,20 +136,69 @@ private static bool TryApplyProfileEquipment(int equipmentVisualId)
continue;
}

player.EquipmentVisualId = equipmentVisualId;
var loaders = player.GetComponentsInChildren<LocalVisualPrefabLoader>(includeInactive: true);
foreach (var loader in loaders)
if (_applyProfileStatsToLocalPlayer)
{
ApplyStats(player, body);
}

if (_applyProfileEquipmentToLocalPlayer)
{
loader.ApplyEquipmentVisual(equipmentVisualId);
ApplyEquipment(player, body);
}
Comment on lines +139 to 147
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This logic attempts to mutate networked state from what appears to be a client-side sync script. In a server-authoritative model (Pillar 4), clients cannot set [Networked] properties directly. If CharacterMemorySync runs on the client, player.ApplyProfileStats will fail the HasStateAuthority check and the stats will not replicate. Consider moving this logic to the server or using a secure RPC if the client must initiate the sync.


Debug.Log($"[CharacterMemorySync] Applied profile equipment visual {equipmentVisualId} to local player.");
Debug.Log($"[CharacterMemorySync] Applied profile body stats and visuals to local player '{player.name}'.");
return true;
}

return false;
}

private static void ApplyStats(NetworkPlayer player, BodyProfileDto body)
{
var stats = body.stats ?? new CharacterStatsDto();
var time = body.time ?? new BodyTimeDto();
player.ApplyProfileStats(
stats.level,
stats.vitality,
stats.force,
stats.agility,
stats.focus,
stats.resilience,
stats.max_health,
stats.max_energy,
stats.attack_power,
stats.defense_power,
ToNetworkSeconds(time.remaining_seconds),
ToNetworkSeconds(time.max_seconds),
ToNetworkSeconds(time.danger_drain_rate));
}

private static void ApplyEquipment(NetworkPlayer player, BodyProfileDto body)
{
var equipmentVisualId = body.equipment?.equipment_visual_id ?? EquipmentVisualCatalog.None;
if (equipmentVisualId == EquipmentVisualCatalog.None)
{
return;
}

player.ApplyProfileEquipment(equipmentVisualId);
var loaders = player.GetComponentsInChildren<LocalVisualPrefabLoader>(includeInactive: true);
foreach (var loader in loaders)
{
loader.ApplyEquipmentVisual(equipmentVisualId);
}
}

private static int ToNetworkSeconds(long seconds)
{
if (seconds <= 0)
{
return 0;
}

return seconds >= int.MaxValue ? int.MaxValue : (int)seconds;
}

private static bool IsLocalAuthoritativePlayer(NetworkPlayer player)
{
if (player == null || !player.HasStateAuthority)
Expand All @@ -160,6 +211,16 @@ private static bool IsLocalAuthoritativePlayer(NetworkPlayer player)
return true;
}

if (player.Runner.IsServer)
{
return true;
}

if (!player.Runner.IsSharedModeMasterClient)
{
return false;
}

return player.Object.InputAuthority == PlayerRef.None ||
player.Object.InputAuthority == player.Runner.LocalPlayer;
}
Expand Down
28 changes: 28 additions & 0 deletions Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeAgentBrain.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ private enum BrainPhase
private Coroutine _brainLoop;
private Vector3 _homePosition;
private Vector3 _moveTarget;
private float _baseMoveSpeed;
private bool _hasMoveTarget;
private float _nextTalkAt;
private int _pendingFootAlignFrames;
Expand All @@ -67,6 +68,7 @@ private enum BrainPhase
private void Awake()
{
_homePosition = transform.position;
_baseMoveSpeed = _moveSpeed;
_speechBubble = GetOrAdd<PrototypeSpeechBubble>();
_voiceCue = GetOrAdd<PrototypeVoiceCue>();
_gateway = FindAnyObjectByType<SecondSpawnGatewayClient>();
Expand Down Expand Up @@ -185,6 +187,7 @@ private IEnumerator BootstrapContext()
LogPhase(BrainPhase.Bootstrap, _context == null
? "context unavailable after bootstrap"
: "context loaded");
ApplyContextToPrototypeBody();
}

private UpdateSoulRequestDto BuildSoulSeed()
Expand Down Expand Up @@ -353,6 +356,31 @@ private void ApplyDecision(AgentDecisionDto decision)
ApplyLocomotion(0f);
}

private void ApplyContextToPrototypeBody()
{
var body = _context?.body;
if (body == null)
{
return;
}

if (!string.IsNullOrWhiteSpace(body.soul?.name))
{
_displayName = body.soul.name.Trim();
}

var stats = body.stats;
if (stats != null)
{
_moveSpeed = Mathf.Max(0.1f, _baseMoveSpeed * CalculateAgilitySpeedMultiplier(stats.agility));
}
}

private static float CalculateAgilitySpeedMultiplier(int agility)
{
return Mathf.Clamp(agility / 8f, 0.75f, 1.4f);
}

private void LogPhase(BrainPhase phase, string detail)
{
if (!_logPhaseTransitions)
Expand Down
60 changes: 54 additions & 6 deletions Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeLLMAgentDriver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public sealed class PrototypeLLMAgentDriver : MonoBehaviour
[SerializeField] private float _moveHoldSeconds = 0.9f;
[SerializeField] private string _zoneId = "prototype-hub";
[SerializeField] private bool _allowPrototypeInteract;
[SerializeField] private bool _allowPrototypeAttack;

private SecondSpawnGatewayClient _gateway;
private CharacterMemorySync _memorySync;
Expand Down Expand Up @@ -139,11 +140,10 @@ private AgentDecisionRequestDto BuildDecisionRequest()
position = new Vector2Dto { x = position.x, z = position.z },
safe_radius = 8f,
body_time_seconds = bodyTime,
nearby_targets = BuildPrototypeTargets(position),
nearby_objects = System.Array.Empty<WorldObjectDto>()
},
allowed = _allowPrototypeInteract
? new[] { "move", "interact", "say", "stop" }
: new[] { "move", "say", "stop" }
allowed = BuildAllowedActions()
};
}

Expand Down Expand Up @@ -183,6 +183,19 @@ private void ApplyDecision(AgentDecisionDto decision)
Debug.Log("[PrototypeLLMAgentDriver] Ignored prototype interact decision. Interact is disabled for patrol mode.");
}
}
else if (decision.action == "attack")
{
if (_allowPrototypeAttack)
{
_networkPlayer.ClearPrototypeAgentInput();
PlayVisualIntent(VisualAnimationIntent.Attack);
}
else
{
_networkPlayer.ClearPrototypeAgentInput();
Debug.Log("[PrototypeLLMAgentDriver] Ignored prototype attack decision. Attack is disabled for patrol mode.");
}
}
else
{
_networkPlayer.ClearPrototypeAgentInput();
Expand All @@ -197,11 +210,46 @@ private IEnumerator ClearMoveAfterDelay()

private void PlayVisualIntent(VisualAnimationIntent intent)
{
var driver = GetComponentInChildren<VisualAnimationIntentDriver>();
if (driver != null)
if (_networkPlayer != null && _networkPlayer.TryPlayVisualIntent(intent))
{
return;
}
}

private string[] BuildAllowedActions()
{
if (_allowPrototypeAttack && _allowPrototypeInteract)
{
driver.TryPlay(intent);
return new[] { "move", "attack", "interact", "say", "stop" };
}

if (_allowPrototypeAttack)
{
return new[] { "move", "attack", "say", "stop" };
}

return _allowPrototypeInteract
? new[] { "move", "interact", "say", "stop" }
: new[] { "move", "say", "stop" };
}

private WorldTargetDto[] BuildPrototypeTargets(Vector3 position)
{
if (!_allowPrototypeAttack)
{
return System.Array.Empty<WorldTargetDto>();
}

return new[]
{
new WorldTargetDto
{
id = "training-dummy",
kind = "prototype_dummy",
distance = 2.5f,
threat = 1
}
};
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,7 @@ private sealed class GatewayBodyProfileDto
public string archetype_id;
public string visual_prefab_key;
public EquipmentLoadoutDto equipment;
public CharacterStatsDto stats;
public CharacterTraitsDto characteristics;
public BodyTimeDto time;
public CultivationDto cultivation;
Expand All @@ -564,6 +565,7 @@ public static GatewayBodyProfileDto From(BodyProfileDto body)
archetype_id = body.archetype_id,
visual_prefab_key = body.visual_prefab_key,
equipment = body.equipment,
stats = body.stats,
characteristics = body.characteristics,
time = body.time,
cultivation = body.cultivation,
Expand Down
Loading
Loading