From bb7fd184c7a6132fa97b5db6638e738d6a9f2ce6 Mon Sep 17 00:00:00 2001 From: JOY Date: Sun, 17 May 2026 00:30:47 +0700 Subject: [PATCH 1/4] feat(character): apply profile stats to bodies --- .../Scripts/AI/AgentContextDto.cs | 16 +++ .../Scripts/AI/CharacterMemorySync.cs | 78 +++++++++-- .../Scripts/AI/PrototypeAgentBrain.cs | 21 +++ .../Scripts/AI/PrototypeLLMAgentDriver.cs | 60 ++++++++- .../Scripts/AI/SecondSpawnGatewayClient.cs | 2 + .../Scripts/Networking/NetworkPlayer.cs | 123 +++++++++++++++++- .../_SecondSpawn/Scripts/UI/HUDController.cs | 83 +++++++++++- .../Scripts/UI/SecondSpawn.UI.asmdef | 4 +- backend/gateway/internal/character/profile.go | 12 ++ .../internal/character/profile_test.go | 17 ++- .../tests/supabase_custom_auth.test.mjs | 5 + .../10-character-profile-agent-memory.md | 35 ++++- 12 files changed, 426 insertions(+), 30 deletions(-) diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs index 7680c15..b12dedf 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs @@ -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; @@ -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 { diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs index 3d03685..0403d04 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs @@ -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."; @@ -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; } } @@ -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() @@ -95,15 +96,15 @@ 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; } @@ -112,7 +113,7 @@ private IEnumerator ApplyProfileEquipmentWhenAvailable() var elapsed = 0f; while (elapsed < maxWaitSeconds) { - if (TryApplyProfileEquipment(equipmentVisualId)) + if (TryApplyProfileBody(body)) { yield break; } @@ -121,10 +122,10 @@ private IEnumerator ApplyProfileEquipmentWhenAvailable() yield return null; } - 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(FindObjectsInactive.Exclude); foreach (var player in players) @@ -134,20 +135,69 @@ private static bool TryApplyProfileEquipment(int equipmentVisualId) continue; } - player.EquipmentVisualId = equipmentVisualId; - var loaders = player.GetComponentsInChildren(includeInactive: true); - foreach (var loader in loaders) + if (_applyProfileStatsToLocalPlayer) + { + ApplyStats(player, body); + } + + if (_applyProfileEquipmentToLocalPlayer) { - loader.ApplyEquipmentVisual(equipmentVisualId); + ApplyEquipment(player, body); } - 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(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) diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeAgentBrain.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeAgentBrain.cs index 964b975..a7fa261 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeAgentBrain.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeAgentBrain.cs @@ -185,6 +185,7 @@ private IEnumerator BootstrapContext() LogPhase(BrainPhase.Bootstrap, _context == null ? "context unavailable after bootstrap" : "context loaded"); + ApplyContextToPrototypeBody(); } private UpdateSoulRequestDto BuildSoulSeed() @@ -353,6 +354,26 @@ 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.Clamp(_moveSpeed * Mathf.Clamp(stats.agility / 8f, 0.75f, 1.4f), 0.5f, 6f); + } + } + private void LogPhase(BrainPhase phase, string detail) { if (!_logPhaseTransitions) diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeLLMAgentDriver.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeLLMAgentDriver.cs index f755b49..0e0d916 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeLLMAgentDriver.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeLLMAgentDriver.cs @@ -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; @@ -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() }, - allowed = _allowPrototypeInteract - ? new[] { "move", "interact", "say", "stop" } - : new[] { "move", "say", "stop" } + allowed = BuildAllowedActions() }; } @@ -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(); @@ -197,11 +210,46 @@ private IEnumerator ClearMoveAfterDelay() private void PlayVisualIntent(VisualAnimationIntent intent) { - var driver = GetComponentInChildren(); - 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(); + } + + return new[] + { + new WorldTargetDto + { + id = "training-dummy", + kind = "prototype_dummy", + distance = 2.5f, + threat = 1 + } + }; } } } diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs index ac47c6b..613af77 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs @@ -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; @@ -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, diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkPlayer.cs b/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkPlayer.cs index e612e64..760c296 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkPlayer.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkPlayer.cs @@ -24,6 +24,19 @@ public sealed class NetworkPlayer : NetworkBehaviour [Networked] public int CultivationTier { get; set; } [Networked] public float Hp { get; set; } [Networked] public float Stamina { get; set; } + [Networked] public int Level { get; set; } + [Networked] public int Vitality { get; set; } + [Networked] public int Force { get; set; } + [Networked] public int Agility { get; set; } + [Networked] public int Focus { get; set; } + [Networked] public int Resilience { get; set; } + [Networked] public int MaxHealth { get; set; } + [Networked] public int MaxEnergy { get; set; } + [Networked] public int AttackPower { get; set; } + [Networked] public int DefensePower { get; set; } + [Networked] public int BodyTimeRemainingSeconds { get; set; } + [Networked] public int BodyTimeMaxSeconds { get; set; } + [Networked] public int BodyTimeDangerDrainRate { get; set; } [Networked] public int VisualVariant { get; set; } [Networked] public int EquipmentVisualId { get; set; } @@ -42,6 +55,7 @@ public sealed class NetworkPlayer : NetworkBehaviour private SimpleKCC _kcc; private NetworkInputData _prototypeAgentInput; private bool _hasPrototypeAgentInput; + private VisualAnimationIntentDriver _visualIntentDriver; private void Awake() { @@ -55,8 +69,7 @@ public override void Spawned() if (HasStateAuthority) { CultivationTier = 1; // Awakening - starting tier per docs/design/04-cultivation-system.md - Hp = 100f; - Stamina = 100f; + ApplyDefaultStats(); if (EquipmentVisualId == EquipmentVisualCatalog.None) { EquipmentVisualId = EquipmentVisualCatalog.GetDefaultForVisualVariant(VisualVariant); @@ -87,7 +100,7 @@ public override void FixedUpdateNetwork() if (move.sqrMagnitude > 0.0001f) { _kcc.SetLookRotation(Quaternion.LookRotation(move), preservePitch: false, preserveYaw: false); - var speed = input.Run ? _moveSpeed : _walkSpeed; + var speed = input.Run ? GetRunSpeed() : GetWalkSpeed(); moveVelocity = move * speed; } @@ -125,6 +138,12 @@ public void ClearPrototypeAgentInput() IsAgentControlled = false; } + public bool TryPlayVisualIntent(VisualAnimationIntent intent) + { + _visualIntentDriver ??= GetComponentInChildren(includeInactive: true); + return _visualIntentDriver != null && _visualIntentDriver.TryPlay(intent); + } + private bool TryGetAuthoritativeInput(out NetworkInputData input) { if (_hasPrototypeAgentInput && IsAgentControlled) @@ -135,5 +154,103 @@ private bool TryGetAuthoritativeInput(out NetworkInputData input) return GetInput(out input); } + + public void ApplyProfileEquipment(int equipmentVisualId) + { + if (!HasStateAuthority) + { + Debug.LogWarning("[NetworkPlayer] Ignored profile equipment on a non-authoritative player."); + return; + } + + EquipmentVisualId = Mathf.Max(EquipmentVisualCatalog.None, equipmentVisualId); + } + + public void ApplyProfileStats( + int level, + int vitality, + int force, + int agility, + int focus, + int resilience, + int maxHealth, + int maxEnergy, + int attackPower, + int defensePower, + int bodyTimeRemainingSeconds, + int bodyTimeMaxSeconds, + int bodyTimeDangerDrainRate) + { + if (!HasStateAuthority) + { + Debug.LogWarning("[NetworkPlayer] Ignored profile stats on a non-authoritative player."); + return; + } + + var previousMaxHealth = MaxHealth; + var previousMaxEnergy = MaxEnergy; + var healthRatio = previousMaxHealth > 0 ? Hp / previousMaxHealth : 1f; + var energyRatio = previousMaxEnergy > 0 ? Stamina / previousMaxEnergy : 1f; + + Level = Mathf.Max(1, level); + Vitality = Mathf.Clamp(vitality, 1, 999); + Force = Mathf.Clamp(force, 1, 999); + Agility = Mathf.Clamp(agility, 1, 999); + Focus = Mathf.Clamp(focus, 1, 999); + Resilience = Mathf.Clamp(resilience, 1, 999); + MaxHealth = Mathf.Max(1, maxHealth); + MaxEnergy = Mathf.Max(1, maxEnergy); + AttackPower = Mathf.Max(0, attackPower); + DefensePower = Mathf.Max(0, defensePower); + BodyTimeRemainingSeconds = Mathf.Max(0, bodyTimeRemainingSeconds); + BodyTimeMaxSeconds = Mathf.Max(0, bodyTimeMaxSeconds); + BodyTimeDangerDrainRate = Mathf.Max(0, bodyTimeDangerDrainRate); + + Hp = previousMaxHealth > 0 + ? Mathf.Clamp(Mathf.Round(MaxHealth * healthRatio), 1f, MaxHealth) + : MaxHealth; + Stamina = previousMaxEnergy > 0 + ? Mathf.Clamp(Mathf.Round(MaxEnergy * energyRatio), 1f, MaxEnergy) + : MaxEnergy; + } + + private void ApplyDefaultStats() + { + Level = 1; + Vitality = 10; + Force = 8; + Agility = 8; + Focus = 8; + Resilience = 8; + MaxHealth = 100; + MaxEnergy = 50; + AttackPower = 10; + DefensePower = 5; + BodyTimeRemainingSeconds = 24 * 60 * 60; + BodyTimeMaxSeconds = 24 * 60 * 60; + BodyTimeDangerDrainRate = 1; + Hp = MaxHealth; + Stamina = MaxEnergy; + } + + private float GetRunSpeed() + { + return _moveSpeed * GetAgilitySpeedMultiplier(); + } + + private float GetWalkSpeed() + { + return _walkSpeed * GetAgilitySpeedMultiplier(); + } + + private float GetAgilitySpeedMultiplier() + { + if (Agility <= 0) + { + return 1f; + } + + return Mathf.Clamp(Agility / 8f, 0.75f, 1.4f); + } } } diff --git a/Unity/Assets/_SecondSpawn/Scripts/UI/HUDController.cs b/Unity/Assets/_SecondSpawn/Scripts/UI/HUDController.cs index c8a1920..6a01c33 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/UI/HUDController.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/UI/HUDController.cs @@ -1,14 +1,15 @@ +using SecondSpawn.Networking; using UnityEngine; namespace SecondSpawn.UI { /// - /// HUD controller stub - combat, cultivation tier, currency, - /// reincarnation prompt, AI agent activity log entry point. + /// Prototype HUD for combat stats, BodyTime, and future activity surfaces. + /// The data is read from networked player state that was seeded by the + /// backend profile. It does not own gameplay authority. /// /// TODO (slice throughout phases 2-7): - /// - Bind cultivation tier display to persisted state. - /// - Wire combat HUD (HP, stamina, abilities) to PlayerController. + /// - Replace IMGUI with the production HUD stack. /// - Reincarnation flow UI (death -> SECOND token cost -> respawn). /// - AI agent activity log overlay (visible on player return). /// - See deferred templates .claude/templates/_deferred/hud-design.md @@ -16,5 +17,79 @@ namespace SecondSpawn.UI /// public sealed class HUDController : MonoBehaviour { + [SerializeField] private bool _showPrototypeStats = true; + [SerializeField] private Vector2 _panelPosition = new Vector2(16f, 16f); + [SerializeField] private Vector2 _panelSize = new Vector2(280f, 132f); + + private NetworkPlayer _cachedPlayer; + private GUIStyle _labelStyle; + + private void OnGUI() + { + if (!_showPrototypeStats) + { + return; + } + + var player = ResolvePlayer(); + if (player == null) + { + return; + } + + _labelStyle ??= new GUIStyle(GUI.skin.label) + { + fontSize = 14, + normal = { textColor = Color.white } + }; + + var rect = new Rect(_panelPosition.x, _panelPosition.y, _panelSize.x, _panelSize.y); + GUI.Box(rect, "SECOND SPAWN"); + GUILayout.BeginArea(new Rect(rect.x + 12f, rect.y + 24f, rect.width - 24f, rect.height - 32f)); + GUILayout.Label($"Level {player.Level} | Tier {player.CultivationTier}", _labelStyle); + GUILayout.Label($"HP {player.Hp:0}/{player.MaxHealth} | Energy {player.Stamina:0}/{player.MaxEnergy}", _labelStyle); + GUILayout.Label($"ATK {player.AttackPower} | DEF {player.DefensePower} | AGI {player.Agility}", _labelStyle); + GUILayout.Label($"BodyTime {FormatSeconds(player.BodyTimeRemainingSeconds)} / {FormatSeconds(player.BodyTimeMaxSeconds)}", _labelStyle); + GUILayout.EndArea(); + } + + private NetworkPlayer ResolvePlayer() + { + if (_cachedPlayer != null && _cachedPlayer.isActiveAndEnabled) + { + return _cachedPlayer; + } + + var players = FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None); + foreach (var player in players) + { + if (player.HasInputAuthority) + { + _cachedPlayer = player; + return _cachedPlayer; + } + } + + _cachedPlayer = players.Length > 0 ? players[0] : null; + return _cachedPlayer; + } + + private static string FormatSeconds(int seconds) + { + if (seconds <= 0) + { + return "0s"; + } + + var days = seconds / 86400; + var hours = seconds % 86400 / 3600; + var minutes = seconds % 3600 / 60; + if (days > 0) + { + return $"{days}d {hours}h"; + } + + return hours > 0 ? $"{hours}h {minutes}m" : $"{minutes}m"; + } } } diff --git a/Unity/Assets/_SecondSpawn/Scripts/UI/SecondSpawn.UI.asmdef b/Unity/Assets/_SecondSpawn/Scripts/UI/SecondSpawn.UI.asmdef index b4cab48..0bc66d5 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/UI/SecondSpawn.UI.asmdef +++ b/Unity/Assets/_SecondSpawn/Scripts/UI/SecondSpawn.UI.asmdef @@ -2,7 +2,9 @@ "name": "SecondSpawn.UI", "rootNamespace": "SecondSpawn.UI", "references": [ - "SecondSpawn.Gameplay" + "SecondSpawn.Gameplay", + "SecondSpawn.Networking", + "Fusion.Runtime" ], "includePlatforms": [], "excludePlatforms": [], diff --git a/backend/gateway/internal/character/profile.go b/backend/gateway/internal/character/profile.go index 4bd5ada..c256fba 100644 --- a/backend/gateway/internal/character/profile.go +++ b/backend/gateway/internal/character/profile.go @@ -194,6 +194,18 @@ func BuildAgentContextPrompt(ctx AgentContext, maxMemories int) string { writeKV(&b, "archetype_id", ctx.Body.ArchetypeID) writeKV(&b, "visual_prefab_key", ctx.Body.VisualPrefabKey) writeKV(&b, "primary_weapon", ctx.Body.Equipment.PrimaryWeapon) + writeKV(&b, "stats", fmt.Sprintf("level=%d vitality=%d force=%d agility=%d focus=%d resilience=%d max_health=%d max_energy=%d attack_power=%d defense_power=%d", + ctx.Body.Stats.Level, + ctx.Body.Stats.Vitality, + ctx.Body.Stats.Force, + ctx.Body.Stats.Agility, + ctx.Body.Stats.Focus, + ctx.Body.Stats.Resilience, + ctx.Body.Stats.MaxHealth, + ctx.Body.Stats.MaxEnergy, + ctx.Body.Stats.AttackPower, + ctx.Body.Stats.DefensePower, + )) writeKV(&b, "body_lifecycle", string(ctx.Body.Lifecycle)) writeKV(&b, "cultivation_tier", ctx.Body.Cultivation.Tier) writeKV(&b, "body_time_seconds", fmt.Sprintf("%d/%d", ctx.Body.Time.RemainingSeconds, ctx.Body.Time.MaxSeconds)) diff --git a/backend/gateway/internal/character/profile_test.go b/backend/gateway/internal/character/profile_test.go index a340e2f..d3d52fd 100644 --- a/backend/gateway/internal/character/profile_test.go +++ b/backend/gateway/internal/character/profile_test.go @@ -21,7 +21,19 @@ func TestBuildAgentContextPromptSortsAndBoundsMemories(t *testing.T) { PrimaryWeapon: "one_hand_sword", EquipmentVisualID: 2, }, - Lifecycle: BodyLifecycleAlive, + Stats: CharacterStats{ + Level: 2, + Vitality: 12, + Force: 9, + Agility: 11, + Focus: 8, + Resilience: 10, + MaxHealth: 140, + MaxEnergy: 60, + AttackPower: 15, + DefensePower: 7, + }, + Lifecycle: BodyLifecycleAlive, Time: BodyTimeState{ RemainingSeconds: 3600, MaxSeconds: 7200, @@ -61,6 +73,9 @@ func TestBuildAgentContextPromptSortsAndBoundsMemories(t *testing.T) { if !strings.Contains(prompt, "primary_weapon: one_hand_sword") { t.Fatalf("expected equipment in prompt, got %s", prompt) } + if !strings.Contains(prompt, "stats: level=2 vitality=12 force=9 agility=11 focus=8 resilience=10 max_health=140 max_energy=60 attack_power=15 defense_power=7") { + t.Fatalf("expected body stats in prompt, got %s", prompt) + } if !strings.Contains(prompt, "memory_02_summary: Recent preference memory") { t.Fatalf("expected second bounded memory, got %s", prompt) } diff --git a/backend/nakama/tests/supabase_custom_auth.test.mjs b/backend/nakama/tests/supabase_custom_auth.test.mjs index 13386ce..2b2ec09 100644 --- a/backend/nakama/tests/supabase_custom_auth.test.mjs +++ b/backend/nakama/tests/supabase_custom_auth.test.mjs @@ -163,6 +163,11 @@ assert.equal(profile.body.soul.name, "user-1"); assert.equal(profile.body.memory.length, 1); assert.equal(profile.body.equipment.primary_weapon, "none"); assert.equal(profile.body.equipment.equipment_visual_id, 0); +assert.equal(profile.body.stats.level, 1); +assert.equal(profile.body.stats.vitality, 10); +assert.equal(profile.body.stats.agility, 8); +assert.equal(profile.body.stats.max_health, 100); +assert.equal(profile.body.stats.attack_power, 10); assert.equal(profile.body.agent_runtime.decision_count, 0); assert.equal(profile.body.agent_runtime.fallback_decision_count, 0); assert.equal(profile.body.agent_activity.length, 1); diff --git a/docs/design/10-character-profile-agent-memory.md b/docs/design/10-character-profile-agent-memory.md index 25faac9..4d51482 100644 --- a/docs/design/10-character-profile-agent-memory.md +++ b/docs/design/10-character-profile-agent-memory.md @@ -19,8 +19,15 @@ This document defines the first durable character data model for SECOND SPAWN: - soul/personality profile for the LLM agent - compact memory records for agent context - player-owned offline-agent policy +- NPC-like body profiles that can later receive a player consciousness -The goal is to make the AI agent feel like the player's character, without giving the LLM authority over game state. +The goal is to make every active character body feel like a real world actor, without giving the LLM authority over game state. + +Important spawn rule: a player does not spawn as an empty account shell. The +player enters a current body, which may be implemented as an NPC-like synthetic +body with its own stats, characteristics, soul profile, memory, BodyTime, and +activity history. Player identity survives across bodies. Body-specific state +can be replaced on reincarnation or consciousness transfer. --- @@ -51,6 +58,12 @@ This preserves: | `AgentRuntime` | Yes, across bodies until reset policy exists | Backend | Counters for profile bootstrap, activity, decisions, fallback decisions, and offline time | | `AgentActivity` | Yes, bounded recent history | Backend | Compact audit trail for offline-agent sessions and Unity/Nakama bootstrap events | +NPCs and player-controlled bodies use the same body profile shape. The +difference is authority and ownership: a player-controlled body receives human +input or offline-agent intent for that player, while an NPC body receives NPC +brain intent. Both still pass through server validation before gameplay state +changes. + --- ## Player Profile @@ -145,6 +158,26 @@ Fields: The LLM may use these fields to choose between valid intents, not to invent new abilities. +### NPC and Body Profile Rule + +Every NPC-like actor that can think, speak, fight, or receive a player +consciousness needs its own profile bundle: + +- `BodyProfile` +- `CharacterStats` +- `CharacterTraits` +- `SoulProfile` +- `MemoryRecord` +- `AgentPolicy` or NPC policy equivalent +- `AgentRuntime` +- `AgentActivity` + +The vertical slice can store prototype NPC profiles using the same agent context +shape as player bodies. Later production work may split durable account data, +body templates, NPC definitions, and live body instances into separate storage +records, but the runtime contract should stay consistent: the agent always sees +the specific body it is currently controlling. + --- ## Agent Policy From 13e890c22be21c2dd218de4ced19d069436de0b7 Mon Sep 17 00:00:00 2001 From: JOY Date: Sun, 17 May 2026 00:53:05 +0700 Subject: [PATCH 2/4] fix(unity): address profile stats review --- .../Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs | 5 +++-- .../Assets/_SecondSpawn/Scripts/AI/PrototypeAgentBrain.cs | 4 +++- .../_SecondSpawn/Scripts/Networking/NetworkPlayer.cs | 7 +++++-- Unity/Assets/_SecondSpawn/Scripts/UI/HUDController.cs | 7 +++++++ 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs index 0403d04..747d3e5 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs @@ -110,6 +110,7 @@ private IEnumerator ApplyProfileToLocalPlayerWhenAvailable() } const float maxWaitSeconds = 10f; + const float retryIntervalSeconds = 0.25f; var elapsed = 0f; while (elapsed < maxWaitSeconds) { @@ -118,8 +119,8 @@ private IEnumerator ApplyProfileToLocalPlayerWhenAvailable() 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 profile body sync."); diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeAgentBrain.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeAgentBrain.cs index a7fa261..82dba95 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeAgentBrain.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeAgentBrain.cs @@ -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; @@ -67,6 +68,7 @@ private enum BrainPhase private void Awake() { _homePosition = transform.position; + _baseMoveSpeed = _moveSpeed; _speechBubble = GetOrAdd(); _voiceCue = GetOrAdd(); _gateway = FindAnyObjectByType(); @@ -370,7 +372,7 @@ private void ApplyContextToPrototypeBody() var stats = body.stats; if (stats != null) { - _moveSpeed = Mathf.Clamp(_moveSpeed * Mathf.Clamp(stats.agility / 8f, 0.75f, 1.4f), 0.5f, 6f); + _moveSpeed = Mathf.Clamp(_baseMoveSpeed * Mathf.Clamp(stats.agility / 8f, 0.75f, 1.4f), 0.5f, 6f); } } diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkPlayer.cs b/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkPlayer.cs index 760c296..d4a1ab5 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkPlayer.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkPlayer.cs @@ -189,6 +189,7 @@ public void ApplyProfileStats( var previousMaxHealth = MaxHealth; var previousMaxEnergy = MaxEnergy; + var wasDead = Hp <= 0f; var healthRatio = previousMaxHealth > 0 ? Hp / previousMaxHealth : 1f; var energyRatio = previousMaxEnergy > 0 ? Stamina / previousMaxEnergy : 1f; @@ -206,11 +207,13 @@ public void ApplyProfileStats( BodyTimeMaxSeconds = Mathf.Max(0, bodyTimeMaxSeconds); BodyTimeDangerDrainRate = Mathf.Max(0, bodyTimeDangerDrainRate); - Hp = previousMaxHealth > 0 + Hp = wasDead + ? 0f + : previousMaxHealth > 0 ? Mathf.Clamp(Mathf.Round(MaxHealth * healthRatio), 1f, MaxHealth) : MaxHealth; Stamina = previousMaxEnergy > 0 - ? Mathf.Clamp(Mathf.Round(MaxEnergy * energyRatio), 1f, MaxEnergy) + ? Mathf.Clamp(Mathf.Round(MaxEnergy * energyRatio), 0f, MaxEnergy) : MaxEnergy; } diff --git a/Unity/Assets/_SecondSpawn/Scripts/UI/HUDController.cs b/Unity/Assets/_SecondSpawn/Scripts/UI/HUDController.cs index 6a01c33..d160a06 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/UI/HUDController.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/UI/HUDController.cs @@ -23,6 +23,7 @@ public sealed class HUDController : MonoBehaviour private NetworkPlayer _cachedPlayer; private GUIStyle _labelStyle; + private float _nextPlayerRefreshAt; private void OnGUI() { @@ -60,6 +61,12 @@ private NetworkPlayer ResolvePlayer() return _cachedPlayer; } + if (Time.unscaledTime < _nextPlayerRefreshAt) + { + return null; + } + + _nextPlayerRefreshAt = Time.unscaledTime + 0.5f; var players = FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None); foreach (var player in players) { From 6cad84114b13d1f176f62863e72d299a0855e8b0 Mon Sep 17 00:00:00 2001 From: JOY Date: Sun, 17 May 2026 01:02:37 +0700 Subject: [PATCH 3/4] fix(unity): align prototype stat display --- .../_SecondSpawn/Scripts/AI/PrototypeAgentBrain.cs | 12 +++++++++++- .../Assets/_SecondSpawn/Scripts/UI/HUDController.cs | 5 +++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeAgentBrain.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeAgentBrain.cs index 82dba95..ea36bab 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeAgentBrain.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeAgentBrain.cs @@ -372,10 +372,20 @@ private void ApplyContextToPrototypeBody() var stats = body.stats; if (stats != null) { - _moveSpeed = Mathf.Clamp(_baseMoveSpeed * Mathf.Clamp(stats.agility / 8f, 0.75f, 1.4f), 0.5f, 6f); + _moveSpeed = Mathf.Max(0.1f, _baseMoveSpeed * CalculateAgilitySpeedMultiplier(stats.agility)); } } + private static float CalculateAgilitySpeedMultiplier(int agility) + { + if (agility <= 0) + { + return 1f; + } + + return Mathf.Clamp(agility / 8f, 0.75f, 1.4f); + } + private void LogPhase(BrainPhase phase, string detail) { if (!_logPhaseTransitions) diff --git a/Unity/Assets/_SecondSpawn/Scripts/UI/HUDController.cs b/Unity/Assets/_SecondSpawn/Scripts/UI/HUDController.cs index d160a06..ac2d6a9 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/UI/HUDController.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/UI/HUDController.cs @@ -96,6 +96,11 @@ private static string FormatSeconds(int seconds) return $"{days}d {hours}h"; } + if (hours == 0 && minutes == 0) + { + return $"{seconds}s"; + } + return hours > 0 ? $"{hours}h {minutes}m" : $"{minutes}m"; } } From c7d00a8349c3d81de3e94a3c6a6bc2ef073ec64e Mon Sep 17 00:00:00 2001 From: JOY Date: Sun, 17 May 2026 06:51:25 +0700 Subject: [PATCH 4/4] fix(unity): tighten profile stat authority --- .../_SecondSpawn/Scripts/AI/CharacterMemorySync.cs | 10 ++++++++++ .../_SecondSpawn/Scripts/AI/PrototypeAgentBrain.cs | 5 ----- .../_SecondSpawn/Scripts/Networking/NetworkPlayer.cs | 5 ----- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs index 747d3e5..d67c827 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs @@ -211,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; } diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeAgentBrain.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeAgentBrain.cs index ea36bab..86f4d23 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeAgentBrain.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeAgentBrain.cs @@ -378,11 +378,6 @@ private void ApplyContextToPrototypeBody() private static float CalculateAgilitySpeedMultiplier(int agility) { - if (agility <= 0) - { - return 1f; - } - return Mathf.Clamp(agility / 8f, 0.75f, 1.4f); } diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkPlayer.cs b/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkPlayer.cs index d4a1ab5..92d0b32 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkPlayer.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkPlayer.cs @@ -248,11 +248,6 @@ private float GetWalkSpeed() private float GetAgilitySpeedMultiplier() { - if (Agility <= 0) - { - return 1f; - } - return Mathf.Clamp(Agility / 8f, 0.75f, 1.4f); } }