From f109928135fc0a129d341d9eec3d36446c33371e Mon Sep 17 00:00:00 2001 From: JOY Date: Thu, 14 May 2026 16:07:49 +0700 Subject: [PATCH 01/14] feat(networking): Phase B scene wiring - hierarchy, Player_NetworkCube prefab, PlayerSpawner B-1: Rename Assets/_SecondSpawn/Scenes/SampleScene.unity -> ZoneTest_Hub.unity (GUID 99c9720ab356a0642a771bea13969a05 preserved, build settings updated). B-2: Build scene hierarchy buckets per docs/setup/unity-conventions.md: _Managers, _Cameras, _Lights, _UI, World, _DynamicObjects. Reparent existing objects (Main Camera renamed to PlayerCamera under _Cameras; Directional Light + Global Volume under _Lights; _NetworkBootstrap under _Managers). B-3: Add Player_NetworkCube.prefab (Cube + NetworkObject + NetworkPlayer) at Assets/_SecondSpawn/Prefabs/. Attach PlayerSpawner to _NetworkBootstrap and wire _playerPrefab + _spawnRoot SerializedObject refs. PlayerSpawner implements INetworkRunnerCallbacks; OnPlayerJoined spawns the prefab server-side with input authority assigned to the joining player and reparents under _DynamicObjects. Server-only spawn gate enforces Pillar 4 (server-authoritative) per Hard Rule #2. Bundled (Phase A follow-through, not standalone): - ProjectSettings.asset: runInBackground=1 (network tick continues with editor unfocused, required for Host Mode smoke test), Fusion 2.1.1 scripting define symbols added for Standalone + Android. - PhotonAppSettings.asset: SDK serializer auto-added empty AppIdRealtime, AppIdQuantum, NetworkLogging, ClientLogging fields. Bundled (dev tooling): - Packages/manifest.json + packages-lock.json: add com.coplaydev.unity-mcp package (HTTP localhost:8080 MCP bridge). Replaces Unity AI Assistant relay for Claude Code agents; bypasses cap=1 entitlement contention documented in CLAUDE.md sustainable MCP workflow. Unity AI Assistant package retained for Codex agent. Code review: APPROVED (one low-urgency should-fix deferred to slice phase 2 when pets/mounts introduce multi-NetworkObject ownership). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Fusion/Resources/PhotonAppSettings.asset | 4 + Unity/Assets/_SecondSpawn/Prefabs.meta | 8 + .../Prefabs/Player_NetworkCube.prefab | 155 ++++++++++++ .../Prefabs/Player_NetworkCube.prefab.meta | 9 + .../{SampleScene.unity => ZoneTest_Hub.unity} | 227 +++++++++++++++++- ...ene.unity.meta => ZoneTest_Hub.unity.meta} | 0 .../Scripts/Networking/PlayerSpawner.cs | 116 +++++++++ .../Scripts/Networking/PlayerSpawner.cs.meta | 2 + Unity/Packages/manifest.json | 1 + Unity/Packages/packages-lock.json | 10 + .../ProjectSettings/EditorBuildSettings.asset | 2 +- Unity/ProjectSettings/ProjectSettings.asset | 10 +- 12 files changed, 532 insertions(+), 12 deletions(-) create mode 100644 Unity/Assets/_SecondSpawn/Prefabs.meta create mode 100644 Unity/Assets/_SecondSpawn/Prefabs/Player_NetworkCube.prefab create mode 100644 Unity/Assets/_SecondSpawn/Prefabs/Player_NetworkCube.prefab.meta rename Unity/Assets/_SecondSpawn/Scenes/{SampleScene.unity => ZoneTest_Hub.unity} (68%) rename Unity/Assets/_SecondSpawn/Scenes/{SampleScene.unity.meta => ZoneTest_Hub.unity.meta} (100%) create mode 100644 Unity/Assets/_SecondSpawn/Scripts/Networking/PlayerSpawner.cs create mode 100644 Unity/Assets/_SecondSpawn/Scripts/Networking/PlayerSpawner.cs.meta diff --git a/Unity/Assets/Photon/Fusion/Resources/PhotonAppSettings.asset b/Unity/Assets/Photon/Fusion/Resources/PhotonAppSettings.asset index c3fda6e..3bebaf0 100644 --- a/Unity/Assets/Photon/Fusion/Resources/PhotonAppSettings.asset +++ b/Unity/Assets/Photon/Fusion/Resources/PhotonAppSettings.asset @@ -13,7 +13,9 @@ MonoBehaviour: m_Name: PhotonAppSettings m_EditorClassIdentifier: Fusion.Realtime.dll::Fusion.Photon.Realtime.PhotonAppSettings AppSettings: + AppIdRealtime: AppIdFusion: 97a2ca13-7648-4fa6-9c51-e9938a60fe07 + AppIdQuantum: AppIdChat: AppIdVoice: AppVersion: @@ -26,5 +28,7 @@ MonoBehaviour: EnableProtocolFallback: 1 AuthMode: 0 EnableLobbyStatistics: 0 + NetworkLogging: 0 + ClientLogging: 0 encryptionMode: 0 emptyRoomTtl: 0 diff --git a/Unity/Assets/_SecondSpawn/Prefabs.meta b/Unity/Assets/_SecondSpawn/Prefabs.meta new file mode 100644 index 0000000..993ba8e --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Prefabs.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: fc19f89a4cd10eb45874acde97a28e17 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/_SecondSpawn/Prefabs/Player_NetworkCube.prefab b/Unity/Assets/_SecondSpawn/Prefabs/Player_NetworkCube.prefab new file mode 100644 index 0000000..2cfbe51 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Prefabs/Player_NetworkCube.prefab @@ -0,0 +1,155 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!1 &5762444949739102863 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 999036358833122982} + - component: {fileID: 8687074654810685659} + - component: {fileID: 4513379572288692258} + - component: {fileID: 6036230797588115926} + - component: {fileID: 8357749372956170795} + - component: {fileID: 8359936860818564255} + m_Layer: 0 + m_Name: Player_NetworkCube + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &999036358833122982 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 5762444949739102863} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!33 &8687074654810685659 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 5762444949739102863} + m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0} +--- !u!65 &4513379572288692258 +BoxCollider: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 5762444949739102863} + m_Material: {fileID: 0} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_LayerOverridePriority: 0 + m_IsTrigger: 0 + m_ProvidesContacts: 0 + m_Enabled: 1 + serializedVersion: 3 + m_Size: {x: 1, y: 1, z: 1} + m_Center: {x: 0, y: 0, z: 0} +--- !u!23 &6036230797588115926 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 5762444949739102863} + m_Enabled: 1 + m_CastShadows: 1 + m_ReceiveShadows: 1 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 2 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 2100000, guid: 31321ba15b8f8eb4c954353edc038b1d, type: 2} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 1 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_AdditionalVertexStreams: {fileID: 0} +--- !u!114 &8357749372956170795 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 5762444949739102863} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: -1552182283, guid: e725a070cec140c4caffb81624c8c787, type: 3} + m_Name: + m_EditorClassIdentifier: Fusion.Runtime.dll::Fusion.NetworkObject + SortKey: 3133618377 + SendPriority: 1 + Flags: 262146 + NestedObjects: [] + NetworkedBehaviours: + - {fileID: 8359936860818564255} + ForceRemoteRenderTimeframe: 0 +--- !u!114 &8359936860818564255 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 5762444949739102863} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: f24eeab6b4af9434ea9eef6a45ab299a, type: 3} + m_Name: + m_EditorClassIdentifier: SecondSpawn.Networking::SecondSpawn.Networking.NetworkPlayer + _NetworkedPosition: {x: 0, y: 0, z: 0} + _NetworkedRotation: {x: 0, y: 0, z: 0, w: 0} + _CultivationTier: 0 + _Hp: 0 + _Stamina: 0 + _IsAgentControlled: + RawValue: 0 + _moveSpeed: 5 diff --git a/Unity/Assets/_SecondSpawn/Prefabs/Player_NetworkCube.prefab.meta b/Unity/Assets/_SecondSpawn/Prefabs/Player_NetworkCube.prefab.meta new file mode 100644 index 0000000..122e7f8 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Prefabs/Player_NetworkCube.prefab.meta @@ -0,0 +1,9 @@ +fileFormatVersion: 2 +guid: 5da3a38c87cc82141bd29ac4ec3f3650 +labels: +- FusionPrefab +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/_SecondSpawn/Scenes/SampleScene.unity b/Unity/Assets/_SecondSpawn/Scenes/ZoneTest_Hub.unity similarity index 68% rename from Unity/Assets/_SecondSpawn/Scenes/SampleScene.unity rename to Unity/Assets/_SecondSpawn/Scenes/ZoneTest_Hub.unity index 0a108ad..a436afb 100644 --- a/Unity/Assets/_SecondSpawn/Scenes/SampleScene.unity +++ b/Unity/Assets/_SecondSpawn/Scenes/ZoneTest_Hub.unity @@ -119,6 +119,69 @@ NavMeshSettings: debug: m_Flags: 0 m_NavMeshData: {fileID: 0} +--- !u!1 &47583031 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 47583032} + m_Layer: 0 + m_Name: _DynamicObjects + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &47583032 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 47583031} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &48214055 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 48214056} + m_Layer: 0 + m_Name: _Cameras + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &48214056 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 48214055} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 330585546} + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &330585543 GameObject: m_ObjectHideFlags: 0 @@ -132,7 +195,7 @@ GameObject: - component: {fileID: 330585544} - component: {fileID: 330585547} m_Layer: 0 - m_Name: Main Camera + m_Name: PlayerCamera m_TagString: MainCamera m_Icon: {fileID: 0} m_NavMeshLayer: 0 @@ -205,12 +268,12 @@ Transform: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 330585543} serializedVersion: 2 - m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} m_LocalPosition: {x: 0, y: 1, z: -10} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: [] - m_Father: {fileID: 0} + m_Father: {fileID: 48214056} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!114 &330585547 MonoBehaviour: @@ -352,7 +415,7 @@ Transform: m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: [] - m_Father: {fileID: 0} + m_Father: {fileID: 809676464} m_LocalEulerAnglesHint: {x: 50, y: -30, z: 0} --- !u!114 &410087042 MonoBehaviour: @@ -383,6 +446,39 @@ MonoBehaviour: m_ShadowLayerMask: 1 m_RenderingLayers: 1 m_ShadowRenderingLayers: 1 +--- !u!1 &809676463 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 809676464} + m_Layer: 0 + m_Name: _Lights + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &809676464 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 809676463} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 410087041} + - {fileID: 832575519} + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &832575517 GameObject: m_ObjectHideFlags: 0 @@ -425,6 +521,37 @@ Transform: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 832575517} serializedVersion: 2 + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 809676464} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &887987032 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 887987033} + m_Layer: 0 + m_Name: _UI + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &887987033 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 887987032} + serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalScale: {x: 1, y: 1, z: 1} @@ -442,6 +569,7 @@ GameObject: m_Component: - component: {fileID: 1239426299} - component: {fileID: 1239426298} + - component: {fileID: 1239426300} m_Layer: 0 m_Name: _NetworkBootstrap m_TagString: Untagged @@ -461,6 +589,8 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: a8fa4a4c99bdf2346a15c4a24a52028b, type: 3} m_Name: m_EditorClassIdentifier: SecondSpawn.Networking::SecondSpawn.Networking.NetworkRunnerSetup + _sessionName: SecondSpawn-Zone-Default + _maxPlayersPerZone: 20 --- !u!4 &1239426299 Transform: m_ObjectHideFlags: 0 @@ -469,6 +599,85 @@ Transform: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 1239426297} serializedVersion: 2 + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 1324221072} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &1239426300 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1239426297} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 153746d151f471547b0f462c6e17d64e, type: 3} + m_Name: + m_EditorClassIdentifier: SecondSpawn.Networking::SecondSpawn.Networking.PlayerSpawner + _playerPrefab: {fileID: 8357749372956170795, guid: 5da3a38c87cc82141bd29ac4ec3f3650, type: 3} + _spawnRoot: {fileID: 47583032} + _spawnRingRadius: 3 + _spawnYOffset: 0.5 +--- !u!1 &1324221071 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1324221072} + m_Layer: 0 + m_Name: _Managers + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &1324221072 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1324221071} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 1239426299} + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &1828990780 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1828990781} + m_Layer: 0 + m_Name: World + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &1828990781 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1828990780} + serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalScale: {x: 1, y: 1, z: 1} @@ -480,7 +689,9 @@ Transform: SceneRoots: m_ObjectHideFlags: 0 m_Roots: - - {fileID: 330585546} - - {fileID: 410087041} - - {fileID: 832575519} - - {fileID: 1239426299} + - {fileID: 1324221072} + - {fileID: 48214056} + - {fileID: 809676464} + - {fileID: 887987033} + - {fileID: 1828990781} + - {fileID: 47583032} diff --git a/Unity/Assets/_SecondSpawn/Scenes/SampleScene.unity.meta b/Unity/Assets/_SecondSpawn/Scenes/ZoneTest_Hub.unity.meta similarity index 100% rename from Unity/Assets/_SecondSpawn/Scenes/SampleScene.unity.meta rename to Unity/Assets/_SecondSpawn/Scenes/ZoneTest_Hub.unity.meta diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/PlayerSpawner.cs b/Unity/Assets/_SecondSpawn/Scripts/Networking/PlayerSpawner.cs new file mode 100644 index 0000000..5ec2556 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/PlayerSpawner.cs @@ -0,0 +1,116 @@ +using System.Collections.Generic; +using Fusion; +using Fusion.Sockets; +using UnityEngine; + +namespace SecondSpawn.Networking +{ + /// + /// Server-authoritative player spawner. On + /// , the server spawns the configured + /// player prefab with input authority assigned to the joining player + /// and reparents the spawned NetworkObject under + /// (the scene's _DynamicObjects bucket). + /// + /// Per docs/design/05-networking-architecture.md + Pillar + /// 4 (Server-authoritative gameplay), only the server creates + /// NetworkObjects. Clients receive replicated spawn callbacks via + /// Fusion's standard pipeline. + /// + /// Vertical slice scope: spawns a unit cube as a placeholder for + /// the future Hunter NFT skin (slice phase 2). Spawn positions form a + /// ring around origin so multiple players are visually separated for + /// the smoke test. + /// + [DisallowMultipleComponent] + public sealed class PlayerSpawner : MonoBehaviour, INetworkRunnerCallbacks + { + [SerializeField, Tooltip("Player_NetworkCube prefab spawned per joining player. Must have NetworkObject + NetworkPlayer components.")] + private NetworkObject _playerPrefab; + + [SerializeField, Tooltip("Scene Transform that owns spawned player objects (e.g. _DynamicObjects).")] + private Transform _spawnRoot; + + [SerializeField, Tooltip("Radius of the spawn ring around origin (units).")] + private float _spawnRingRadius = 3f; + + [SerializeField, Tooltip("Vertical lift so the cube sits visibly on the ground plane.")] + private float _spawnYOffset = 0.5f; + + private NetworkRunner _runner; + private int _spawnCounter; + + private void Awake() + { + _runner = GetComponent(); + if (_runner != null) _runner.AddCallbacks(this); + } + + private void OnDestroy() + { + if (_runner != null) _runner.RemoveCallbacks(this); + } + + public void OnPlayerJoined(NetworkRunner runner, PlayerRef player) + { + if (!runner.IsServer) return; + if (_playerPrefab == null) + { + Debug.LogError("[PlayerSpawner] _playerPrefab not assigned; cannot spawn for " + player); + return; + } + + var spawnPos = ComputeSpawnPosition(_spawnCounter); + var no = runner.Spawn(_playerPrefab, spawnPos, Quaternion.identity, player); + _spawnCounter++; + + if (_spawnRoot != null && no != null) + { + no.transform.SetParent(_spawnRoot, worldPositionStays: true); + } + Debug.Log($"[PlayerSpawner] Spawned player cube for {player} at {spawnPos}"); + } + + public void OnPlayerLeft(NetworkRunner runner, PlayerRef player) + { + if (!runner.IsServer) return; + foreach (var no in runner.GetAllNetworkObjects()) + { + if (no.InputAuthority == player) + { + runner.Despawn(no); + break; + } + } + } + + private Vector3 ComputeSpawnPosition(int slot) + { + const float twoPi = Mathf.PI * 2f; + float angle = slot * (twoPi / 8f); + return new Vector3( + Mathf.Cos(angle) * _spawnRingRadius, + _spawnYOffset, + Mathf.Sin(angle) * _spawnRingRadius); + } + + // Unused INetworkRunnerCallbacks members - implement to satisfy interface. + public void OnConnectedToServer(NetworkRunner runner) { } + public void OnConnectFailed(NetworkRunner runner, NetAddress remoteAddress, NetConnectFailedReason reason) { } + public void OnConnectRequest(NetworkRunner runner, NetworkRunnerCallbackArgs.ConnectRequest request, byte[] token) { } + public void OnCustomAuthenticationResponse(NetworkRunner runner, Dictionary data) { } + public void OnDisconnectedFromServer(NetworkRunner runner, NetDisconnectReason reason) { } + public void OnHostMigration(NetworkRunner runner, HostMigrationToken hostMigrationToken) { } + public void OnInput(NetworkRunner runner, NetworkInput input) { } + public void OnInputMissing(NetworkRunner runner, PlayerRef player, NetworkInput input) { } + public void OnObjectEnterAOI(NetworkRunner runner, NetworkObject obj, PlayerRef player) { } + public void OnObjectExitAOI(NetworkRunner runner, NetworkObject obj, PlayerRef player) { } + public void OnReliableDataProgress(NetworkRunner runner, PlayerRef player, ReliableKey key, float progress) { } + public void OnReliableDataReceived(NetworkRunner runner, PlayerRef player, ReliableKey key, System.ReadOnlySpan data) { } + public void OnSceneLoadDone(NetworkRunner runner) { } + public void OnSceneLoadStart(NetworkRunner runner) { } + public void OnSessionListUpdated(NetworkRunner runner, List sessionList) { } + public void OnShutdown(NetworkRunner runner, ShutdownReason shutdownReason) { } + public void OnUserSimulationMessage(NetworkRunner runner, SimulationMessagePtr message) { } + } +} diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/PlayerSpawner.cs.meta b/Unity/Assets/_SecondSpawn/Scripts/Networking/PlayerSpawner.cs.meta new file mode 100644 index 0000000..ade85f1 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/PlayerSpawner.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 153746d151f471547b0f462c6e17d64e \ No newline at end of file diff --git a/Unity/Packages/manifest.json b/Unity/Packages/manifest.json index 0b6ad43..7ba6d86 100644 --- a/Unity/Packages/manifest.json +++ b/Unity/Packages/manifest.json @@ -1,5 +1,6 @@ { "dependencies": { + "com.coplaydev.unity-mcp": "https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity#main", "com.unity.ai.assistant": "2.7.0-pre.3", "com.unity.ai.inference": "2.6.1", "com.unity.ai.navigation": "2.0.12", diff --git a/Unity/Packages/packages-lock.json b/Unity/Packages/packages-lock.json index 21c702a..dc2d9da 100644 --- a/Unity/Packages/packages-lock.json +++ b/Unity/Packages/packages-lock.json @@ -1,5 +1,15 @@ { "dependencies": { + "com.coplaydev.unity-mcp": { + "version": "https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity#main", + "depth": 0, + "source": "git", + "dependencies": { + "com.unity.nuget.newtonsoft-json": "3.0.2", + "com.unity.test-framework": "1.1.31" + }, + "hash": "b92c05a25820cfc9f59ce4094eb46aaec8632ea2" + }, "com.unity.2d.sprite": { "version": "1.0.0", "depth": 1, diff --git a/Unity/ProjectSettings/EditorBuildSettings.asset b/Unity/ProjectSettings/EditorBuildSettings.asset index 2857a10..d037709 100644 --- a/Unity/ProjectSettings/EditorBuildSettings.asset +++ b/Unity/ProjectSettings/EditorBuildSettings.asset @@ -6,7 +6,7 @@ EditorBuildSettings: serializedVersion: 2 m_Scenes: - enabled: 1 - path: Assets/_SecondSpawn/Scenes/SampleScene.unity + path: Assets/_SecondSpawn/Scenes/ZoneTest_Hub.unity guid: 99c9720ab356a0642a771bea13969a05 m_configObjects: com.unity.dt.app-ui: {fileID: 11400000, guid: 1b1c20d82303e4b5781c3ef50ac1449f, type: 2} diff --git a/Unity/ProjectSettings/ProjectSettings.asset b/Unity/ProjectSettings/ProjectSettings.asset index 1ad2917..70dc332 100644 --- a/Unity/ProjectSettings/ProjectSettings.asset +++ b/Unity/ProjectSettings/ProjectSettings.asset @@ -87,7 +87,7 @@ PlayerSettings: androidApplicationEntry: 2 defaultIsNativeResolution: 1 macRetinaSupport: 1 - runInBackground: 0 + runInBackground: 1 muteOtherAudioSources: 0 Prepare IOS For Recording: 0 Force IOS Speakers When Recording: 0 @@ -830,7 +830,11 @@ PlayerSettings: webWasm2023: 1 webEnableSubmoduleStrippingCompatibility: 0 scriptingDefineSymbols: - Standalone: SENTIS_ANALYTICS_ENABLED;APP_UI_EDITOR_ONLY + Android: FUSION_WEAVER;FUSION2;FUSION_2;FUSION_2_1;FUSION_2_1_1;FUSION_2_OR_NEWER;FUSION_2_0_OR_NEWER;FUSION_2_1_OR_NEWER;FUSION_LOGLEVEL_INFO + Standalone: SENTIS_ANALYTICS_ENABLED;APP_UI_EDITOR_ONLY;FUSION_WEAVER;FUSION2;FUSION_2;FUSION_2_1;FUSION_2_1_1;FUSION_2_OR_NEWER;FUSION_2_0_OR_NEWER;FUSION_2_1_OR_NEWER;FUSION_LOGLEVEL_INFO + WebGL: FUSION_WEAVER;FUSION2;FUSION_2;FUSION_2_1;FUSION_2_1_1;FUSION_2_OR_NEWER;FUSION_2_0_OR_NEWER;FUSION_2_1_OR_NEWER;FUSION_LOGLEVEL_INFO + Windows Store Apps: FUSION_WEAVER;FUSION2;FUSION_2;FUSION_2_1;FUSION_2_1_1;FUSION_2_OR_NEWER;FUSION_2_0_OR_NEWER;FUSION_2_1_OR_NEWER;FUSION_LOGLEVEL_INFO + iPhone: FUSION_WEAVER;FUSION2;FUSION_2;FUSION_2_1;FUSION_2_1_1;FUSION_2_OR_NEWER;FUSION_2_0_OR_NEWER;FUSION_2_1_OR_NEWER;FUSION_LOGLEVEL_INFO additionalCompilerArguments: {} platformArchitecture: {} scriptingBackend: @@ -842,7 +846,7 @@ PlayerSettings: managedStrippingLevel: {} incrementalIl2cppBuild: {} suppressCommonWarnings: 1 - allowUnsafeCode: 0 + allowUnsafeCode: 1 useDeterministicCompilation: 1 additionalIl2CppArgs: scriptingRuntimeVersion: 1 From 895eeca8961f9a0280d63b778f5264e935af8bbc Mon Sep 17 00:00:00 2001 From: JOY Date: Thu, 14 May 2026 17:13:52 +0700 Subject: [PATCH 02/14] chore(networking): silence CS0618 obsolete-SimulationMessagePtr + Fusion 2 reparent pattern (observed not to stick) - Wrap INetworkRunnerCallbacks.OnUserSimulationMessage with #pragma warning disable CS0618 in PlayerSpawner.cs + NetworkInputProvider.cs. Fusion 2.1.1 marked the SimulationMessagePtr parameter type obsolete ("Not used anymore" per Photon/Fusion/release_history.txt line 408) but the interface still requires the implementation, so the warning cannot be eliminated by code removal. The pragma is scoped to the single method, with a comment citing the release_history reference. - Refactor PlayerSpawner.OnPlayerJoined to use Fusion 2 idiomatic runner.Spawn(prefab, pos, rot, player, onBeforeSpawned: callback) overload. Captures _spawnRoot into a local before passing the closure so re-spawns read the field fresh on each call. Calls Transform.SetParent inside the onBeforeSpawned callback (after instantiation, before Spawned()) which is the documented Fusion 2 pattern for setting a NetworkObject's parent at spawn time. KNOWN ISSUE: Play mode verification shows the reparent does NOT stick under Fusion 2.1.1 - the cube still ends up at scene root rather than under _DynamicObjects. The TODO comment in the callback flags the gap for a Phase B follow-up investigation (likely Fusion moves the GO into a runner-managed scene after the callback). Tracked for the next slice. Phase B smoke-test core criteria remain met: cube spawns on PlayerJoined with NetworkObject + NetworkPlayer + weaver-generated NetworkObjectPrefabData attached. Reparent organization is cosmetic for the smoke test scope. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Networking/NetworkInputProvider.cs | 3 +++ .../Scripts/Networking/PlayerSpawner.cs | 20 +++++++++++++------ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkInputProvider.cs b/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkInputProvider.cs index 0a72de4..20686be 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkInputProvider.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkInputProvider.cs @@ -88,6 +88,9 @@ public void OnSceneLoadDone(NetworkRunner runner) { } public void OnSceneLoadStart(NetworkRunner runner) { } public void OnSessionListUpdated(NetworkRunner runner, List sessionList) { } public void OnShutdown(NetworkRunner runner, ShutdownReason shutdownReason) { } + +#pragma warning disable CS0618 // SimulationMessagePtr is obsolete in Fusion 2.1+ but the interface still requires the implementation per Photon/Fusion/release_history.txt line 408. public void OnUserSimulationMessage(NetworkRunner runner, SimulationMessagePtr message) { } +#pragma warning restore CS0618 } } diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/PlayerSpawner.cs b/Unity/Assets/_SecondSpawn/Scripts/Networking/PlayerSpawner.cs index 5ec2556..19f48eb 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/Networking/PlayerSpawner.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/PlayerSpawner.cs @@ -61,13 +61,18 @@ public void OnPlayerJoined(NetworkRunner runner, PlayerRef player) } var spawnPos = ComputeSpawnPosition(_spawnCounter); - var no = runner.Spawn(_playerPrefab, spawnPos, Quaternion.identity, player); + var spawnRoot = _spawnRoot; + runner.Spawn(_playerPrefab, spawnPos, Quaternion.identity, player, + onBeforeSpawned: (r, obj) => + { + // TODO(phase-B follow-up): documented Fusion 2 pattern for + // pre-Spawned() reparent. Observed under Fusion 2.1.1 to + // NOT stick - the NetworkObject still ends up at scene + // root. Likely Fusion moves the GO into a runner-managed + // scene after this callback; investigate Spawn flow. + if (spawnRoot != null) obj.transform.SetParent(spawnRoot, worldPositionStays: true); + }); _spawnCounter++; - - if (_spawnRoot != null && no != null) - { - no.transform.SetParent(_spawnRoot, worldPositionStays: true); - } Debug.Log($"[PlayerSpawner] Spawned player cube for {player} at {spawnPos}"); } @@ -111,6 +116,9 @@ public void OnSceneLoadDone(NetworkRunner runner) { } public void OnSceneLoadStart(NetworkRunner runner) { } public void OnSessionListUpdated(NetworkRunner runner, List sessionList) { } public void OnShutdown(NetworkRunner runner, ShutdownReason shutdownReason) { } + +#pragma warning disable CS0618 // SimulationMessagePtr is obsolete in Fusion 2.1+ but the interface still requires the implementation per Photon/Fusion/release_history.txt line 408. public void OnUserSimulationMessage(NetworkRunner runner, SimulationMessagePtr message) { } +#pragma warning restore CS0618 } } From e093e2d1267a907407470656304892c4208abff5 Mon Sep 17 00:00:00 2001 From: JOY Date: Thu, 14 May 2026 17:41:35 +0700 Subject: [PATCH 03/14] fix(networking): bypass CLR access check for Fusion.Runtime internals (FieldAccessException workaround) Add SECOND SPAWN PATCH AssemblyAttributes.cs declaring `[assembly: System.Runtime.CompilerServices.IgnoresAccessChecksTo("Fusion.Runtime")]` on SecondSpawn.Networking. Without this, Fusion 2.1.1's IL weaver emits direct accesses to Fusion.NetworkBehaviour::Ptr (internal field) inside every [Networked] property getter/setter, but Fusion.Runtime.dll's [InternalsVisibleTo] list only grants access to Fusion.Unity.Tests/Editor, Unity.Fusion.CodeGen, Fusion.Plugin/Json/Runtime.Tests - it does NOT include SecondSpawn.Networking. CLR raises FieldAccessException at runtime as soon as NetworkPlayer.Spawned() executes set_NetworkedPosition. Phase A smoke test passed (commit 7aacfe5) because NetworkInputProvider's INetworkInput is a plain data struct that never accesses Ptr. Phase B (commit f109928) NetworkPlayer is the first NetworkBehaviour that does. IgnoresAccessChecksToAttribute is not in BCL - declared the type inline (Roslyn matches by full name). Confirmed Play mode passes the FieldAccessException barrier after this patch in Unity 6.5 beta b7 plus Fusion 2.1.1-RC. Long-term fix: when Photon ships a Fusion version that grants access via AssembliesToWeave or exposes Ptr publicly, remove this file. Track Photon/Fusion/release_history.txt; revert when confirmed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Scripts/Networking/AssemblyAttributes.cs | 34 +++++++++++++++++++ .../Networking/AssemblyAttributes.cs.meta | 2 ++ 2 files changed, 36 insertions(+) create mode 100644 Unity/Assets/_SecondSpawn/Scripts/Networking/AssemblyAttributes.cs create mode 100644 Unity/Assets/_SecondSpawn/Scripts/Networking/AssemblyAttributes.cs.meta diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/AssemblyAttributes.cs b/Unity/Assets/_SecondSpawn/Scripts/Networking/AssemblyAttributes.cs new file mode 100644 index 0000000..724e88b --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/AssemblyAttributes.cs @@ -0,0 +1,34 @@ +// SECOND SPAWN PATCH (2026-05-14): grant CLR access bypass for Fusion.Runtime +// internals from this assembly. Required because Fusion.Runtime.dll's +// [InternalsVisibleTo] declarations (Fusion.Unity.Tests/Editor, Unity.Fusion.CodeGen, +// Fusion.Plugin/Json/Runtime.Tests) do not include SecondSpawn.Networking, but +// Fusion's IL weaver emits direct accesses to Fusion.NetworkBehaviour::Ptr +// (internal field) inside every [Networked] property getter/setter. Without +// this attribute the CLR raises FieldAccessException at runtime as soon as +// NetworkPlayer.Spawned() runs against a user-asmdef-hosted NetworkBehaviour. +// +// IgnoresAccessChecksToAttribute is a Roslyn/CLR convention: when the compiler +// sees this attribute on the calling assembly, it emits IL that skips the +// runtime access check for the named target. Honored by .NET Core 3+, Mono +// 5.10+, and Unity 6's bundled Mono runtime. The attribute type is not in the +// BCL, so we declare it ourselves below; Roslyn matches by full name. +// +// Long-term fix: when Photon ships a Fusion version that grants this access +// automatically via AssembliesToWeave (or exposes Ptr as public), remove this +// file. Track release_history.txt; revert this workaround when confirmed. + +[assembly: System.Runtime.CompilerServices.IgnoresAccessChecksTo("Fusion.Runtime")] + +namespace System.Runtime.CompilerServices +{ + [System.AttributeUsage(System.AttributeTargets.Assembly, AllowMultiple = true)] + internal sealed class IgnoresAccessChecksToAttribute : System.Attribute + { + public IgnoresAccessChecksToAttribute(string assemblyName) + { + AssemblyName = assemblyName; + } + + public string AssemblyName { get; } + } +} diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/AssemblyAttributes.cs.meta b/Unity/Assets/_SecondSpawn/Scripts/Networking/AssemblyAttributes.cs.meta new file mode 100644 index 0000000..96d4c31 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/AssemblyAttributes.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c70855c4936024f44999a9a9be182acf \ No newline at end of file From 4e147cbc02b86971dd5683c2b643b473b02f8aec Mon Sep 17 00:00:00 2001 From: JOY Date: Thu, 14 May 2026 18:17:53 +0700 Subject: [PATCH 04/14] chore(workflow): adopt dev branch and fix Fusion smoke test --- .claude/CLAUDE.md | 31 +++-- .gitattributes | 2 + AGENTS.md | 29 ++-- .../_SecondSpawn/Scenes/ZoneTest_Hub.unity | 5 +- .../Scripts/Networking/AssemblyAttributes.cs | 34 ----- .../Networking/AssemblyAttributes.cs.meta | 2 - .../Scripts/Networking/PlayerSpawner.cs | 18 +-- .../Networking/SecondSpawn.Networking.asmdef | 2 +- docs/adr/0008-codex-primary-agent-workflow.md | 131 ++++++++++++++++++ ...-fusion-networking-assembly-unsafe-code.md | 122 ++++++++++++++++ docs/design/05-networking-architecture.md | 17 +-- docs/setup/agent-handoff.md | 93 +++++++++++++ docs/setup/fusion-install.md | 4 +- docs/setup/unity-conventions.md | 17 ++- 14 files changed, 410 insertions(+), 97 deletions(-) delete mode 100644 Unity/Assets/_SecondSpawn/Scripts/Networking/AssemblyAttributes.cs delete mode 100644 Unity/Assets/_SecondSpawn/Scripts/Networking/AssemblyAttributes.cs.meta create mode 100644 docs/adr/0008-codex-primary-agent-workflow.md create mode 100644 docs/adr/0009-fusion-networking-assembly-unsafe-code.md create mode 100644 docs/setup/agent-handoff.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 6c66d1e..6f1381b 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -150,21 +150,22 @@ International-friendly framing. Explained via science (Nibirium, biotech, consci ## AI Agent Tools -### Primary +### Primary Workflow -- **Claude Desktop (Windows MS Store, "Code" mode)** - main dev driver. The agent process is `local-agent-mode-unity-mcp` per Coplay Bridge "Other Connections" panel; the host binary is `C:\Program Files\WindowsApps\Claude__x64__pzs8sxrjxfjjc\app\Claude.exe`. Coplay's Bridge labels the high-level integration as "Claude Code" (their umbrella name for Anthropic coding products) but the actual connecting client is Claude Desktop's Code-mode subprocess. NOT standalone Claude Code. -- **Coplay unity-mcp** (CoplayDev/unity-mcp) - bridges the agent to Unity Editor (asset, scene, script, component manipulation). Bridge runs inside Unity Editor as part of `com.unity.ai.assistant`. Only ONE direct connection allowed at a time on Personal tier; Unity AI subscription Seat assignment grants the slot. -- **Codex CLI rescue skill** - 2nd opinion / refactor / review when the primary agent is stuck (use `codex:rescue` skill, NOT standalone Codex App). +- **Codex (this environment)** - default daily operator for code, docs, ADRs, backend, repo hygiene, Unity MCP inspection, and targeted Unity scene/script edits. Codex has more available capacity than Claude Code Max and is the normal first stop for SECOND SPAWN work. +- **CoplayDev MCP for Unity** (`com.coplaydev.unity-mcp`) - primary Unity Editor bridge for agents. Preferred transport is HTTP Local at `http://127.0.0.1:8080` from `Window > MCP For Unity`. Configure clients from the MCP For Unity Client Configuration tab. For Codex, select `Codex` and click `Configure`. +- **Claude Code Max / Claude Desktop Code mode** - specialty agent for high-value architecture critique, code review, brainstorming, and Unity-heavy tasks where Codex gets blocked. Use scarce Claude budget deliberately. +- **Unity official MCP / Unity AI Assistant** (`com.unity.ai.assistant`) - optional / legacy path only. Do not treat it as the primary workflow because the 2026-05-14 debug session found seat/cap/connection instability compared with CoplayDev MCP. -### Sustainable MCP workflow (post 2026-05-14 debug) +### Sustainable MCP Workflow (post 2026-05-14 debug) -Cap = 1 direct connection on JOY's Unity AI trial Seat. To avoid connection-revoke storms when multiple AI clients fight for the slot: - -1. In Unity Editor: Project Settings > AI > Unity MCP Server > Integrations - keep ONLY "Claude Code" Configured (green dot). Disable Cursor, Windsurf, Claude Desktop integration, VSCode GitHub Copilot, Kiro, Codex, Gemini. -2. Quit Codex Desktop entirely (system tray) when working with the primary agent. Codex spawns its own MCP client that competes for the cap=1 slot. -3. After restarting Claude Desktop, accept the fresh `local-agent-mode-unity-mcp` connection in Other Connections panel (PID changes per process restart, so previous approval does not carry over). -4. If MCP probes return "Connection revoked" repeatedly, kill orphan `relay_win.exe` processes via Task Manager, then Stop+Start the Unity Bridge in the Project Settings panel. -5. Keep `com.unity.ai.assistant` package pinned at `2.7.0-pre.3` (current). Do NOT downgrade to 2.6.0-pre.1 - that grandfathered free MCP but lacks the entitlement-aware cap negotiation. With trial Seat assigned, pre.3 works. +1. Codex is primary for day-to-day implementation. Claude Code Max is an escalation/reviewer, not the always-on driver. +2. Use CoplayDev MCP for Unity over `localhost:8080` as the normal Unity bridge. +3. Only one agent may mutate Unity scenes, prefabs, package imports, or project settings at a time. Read-only inspection by the next agent is OK after the previous agent reports dirty files and current console state. +4. Every agent switch must leave a handoff using `docs/setup/agent-handoff.md`. +5. Unity package imports happen one package per commit, in this order unless JOY changes it: Opsive Ultimate Character Controller, Behavior Designer, Convai. +6. Before claiming Unity work is complete, check the Unity console and active scene through MCP or the Editor. +7. Significant commits still require independent reviewer pass per Hard Rule #7. ### Optional @@ -230,7 +231,8 @@ Cap = 1 direct connection on JOY's Unity AI trial Seat. To avoid connection-revo - Unity project subfolder: `D:\Projects\Second-Spawn\Unity` (PascalCase, NOT at repo root - multi-stack repo: Unity at `Unity/`, Go gateway at `backend/`, docs at `docs/`) - C# code: Microsoft conventions (PascalCase classes, camelCase fields with `_` prefix for private serialized) - Branches: `feat/`, `fix/`, `chore/` -- **Unity-specific conventions** (folder structure, asmdef pattern, scene organization, naming rules): see [docs/setup/unity-conventions.md](../docs/setup/unity-conventions.md). MUST follow before creating, renaming, or organizing any Unity asset, script, or folder. +- **Unity-specific conventions** (folder structure, asmdef pattern, scene organization, naming rules): see [docs/setup/unity-conventions.md](docs/setup/unity-conventions.md). MUST follow before creating, renaming, or organizing any Unity asset, script, or folder. +- **Agent handoff conventions** (Codex-primary workflow, Claude escalation, MCP ownership, commit handoff): see [docs/setup/agent-handoff.md](docs/setup/agent-handoff.md). MUST follow before switching agents or handing off Unity work. ### Documentation Language @@ -241,6 +243,9 @@ Cap = 1 direct connection on JOY's Unity AI trial Seat. To avoid connection-revo ### Git Workflow - Default branch: `main` +- Stable branch: `main` only receives reviewed, smoke-tested changes from `dev` +- Daily working branch: `dev` +- Feature work: create a separate branch/worktree from `dev`, then PR back into `dev` - Public repo open source from day 1 - License: AGPL-3.0 (code) + CC-BY-NC 4.0 (assets) - All PRs reviewed via Claude Code review skill before merge diff --git a/.gitattributes b/.gitattributes index 4b7eb7e..c8c4b88 100644 --- a/.gitattributes +++ b/.gitattributes @@ -7,6 +7,8 @@ *.cginc text eol=lf *.hlsl text eol=lf *.compute text eol=lf +*.asmdef text eol=lf +*.asmref text eol=lf *.uss text eol=lf *.uxml text eol=lf diff --git a/AGENTS.md b/AGENTS.md index 9014f27..4ae4c71 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -150,21 +150,22 @@ International-friendly framing. Explained via science (Nibirium, biotech, consci ## AI Agent Tools -### Primary +### Primary Workflow -- **Claude Desktop (Windows MS Store, "Code" mode)** - main dev driver. The agent process is `local-agent-mode-unity-mcp` per Coplay Bridge "Other Connections" panel; the host binary is `C:\Program Files\WindowsApps\Claude__x64__pzs8sxrjxfjjc\app\Claude.exe`. Coplay's Bridge labels the high-level integration as "Claude Code" (their umbrella name for Anthropic coding products) but the actual connecting client is Claude Desktop's Code-mode subprocess. NOT standalone Claude Code. -- **Coplay unity-mcp** (CoplayDev/unity-mcp) - bridges the agent to Unity Editor (asset, scene, script, component manipulation). Bridge runs inside Unity Editor as part of `com.unity.ai.assistant`. Only ONE direct connection allowed at a time on Personal tier; Unity AI subscription Seat assignment grants the slot. -- **Codex CLI rescue skill** - 2nd opinion / refactor / review when the primary agent is stuck (use `codex:rescue` skill, NOT standalone Codex App). +- **Codex (this environment)** - default daily operator for code, docs, ADRs, backend, repo hygiene, Unity MCP inspection, and targeted Unity scene/script edits. Codex has more available capacity than Claude Code Max and is the normal first stop for SECOND SPAWN work. +- **CoplayDev MCP for Unity** (`com.coplaydev.unity-mcp`) - primary Unity Editor bridge for agents. Preferred transport is HTTP Local at `http://127.0.0.1:8080` from `Window > MCP For Unity`. Configure clients from the MCP For Unity Client Configuration tab. For Codex, select `Codex` and click `Configure`. +- **Claude Code Max / Claude Desktop Code mode** - specialty agent for high-value architecture critique, code review, brainstorming, and Unity-heavy tasks where Codex gets blocked. Use scarce Claude budget deliberately. +- **Unity official MCP / Unity AI Assistant** (`com.unity.ai.assistant`) - optional / legacy path only. Do not treat it as the primary workflow because the 2026-05-14 debug session found seat/cap/connection instability compared with CoplayDev MCP. -### Sustainable MCP workflow (post 2026-05-14 debug) +### Sustainable MCP Workflow (post 2026-05-14 debug) -Cap = 1 direct connection on JOY's Unity AI trial Seat. To avoid connection-revoke storms when multiple AI clients fight for the slot: - -1. In Unity Editor: Project Settings > AI > Unity MCP Server > Integrations - keep ONLY "Claude Code" Configured (green dot). Disable Cursor, Windsurf, Claude Desktop integration, VSCode GitHub Copilot, Kiro, Codex, Gemini. -2. Quit Codex Desktop entirely (system tray) when working with the primary agent. Codex spawns its own MCP client that competes for the cap=1 slot. -3. After restarting Claude Desktop, accept the fresh `local-agent-mode-unity-mcp` connection in Other Connections panel (PID changes per process restart, so previous approval does not carry over). -4. If MCP probes return "Connection revoked" repeatedly, kill orphan `relay_win.exe` processes via Task Manager, then Stop+Start the Unity Bridge in the Project Settings panel. -5. Keep `com.unity.ai.assistant` package pinned at `2.7.0-pre.3` (current). Do NOT downgrade to 2.6.0-pre.1 - that grandfathered free MCP but lacks the entitlement-aware cap negotiation. With trial Seat assigned, pre.3 works. +1. Codex is primary for day-to-day implementation. Claude Code Max is an escalation/reviewer, not the always-on driver. +2. Use CoplayDev MCP for Unity over `localhost:8080` as the normal Unity bridge. +3. Only one agent may mutate Unity scenes, prefabs, package imports, or project settings at a time. Read-only inspection by the next agent is OK after the previous agent reports dirty files and current console state. +4. Every agent switch must leave a handoff using `docs/setup/agent-handoff.md`. +5. Unity package imports happen one package per commit, in this order unless JOY changes it: Opsive Ultimate Character Controller, Behavior Designer, Convai. +6. Before claiming Unity work is complete, check the Unity console and active scene through MCP or the Editor. +7. Significant commits still require independent reviewer pass per Hard Rule #7. ### Optional @@ -231,6 +232,7 @@ Cap = 1 direct connection on JOY's Unity AI trial Seat. To avoid connection-revo - C# code: Microsoft conventions (PascalCase classes, camelCase fields with `_` prefix for private serialized) - Branches: `feat/`, `fix/`, `chore/` - **Unity-specific conventions** (folder structure, asmdef pattern, scene organization, naming rules): see [docs/setup/unity-conventions.md](docs/setup/unity-conventions.md). MUST follow before creating, renaming, or organizing any Unity asset, script, or folder. +- **Agent handoff conventions** (Codex-primary workflow, Claude escalation, MCP ownership, commit handoff): see [docs/setup/agent-handoff.md](docs/setup/agent-handoff.md). MUST follow before switching agents or handing off Unity work. ### Documentation Language @@ -241,6 +243,9 @@ Cap = 1 direct connection on JOY's Unity AI trial Seat. To avoid connection-revo ### Git Workflow - Default branch: `main` +- Stable branch: `main` only receives reviewed, smoke-tested changes from `dev` +- Daily working branch: `dev` +- Feature work: create a separate branch/worktree from `dev`, then PR back into `dev` - Public repo open source from day 1 - License: AGPL-3.0 (code) + CC-BY-NC 4.0 (assets) - All PRs reviewed via Claude Code review skill before merge diff --git a/Unity/Assets/_SecondSpawn/Scenes/ZoneTest_Hub.unity b/Unity/Assets/_SecondSpawn/Scenes/ZoneTest_Hub.unity index a436afb..4ec81a7 100644 --- a/Unity/Assets/_SecondSpawn/Scenes/ZoneTest_Hub.unity +++ b/Unity/Assets/_SecondSpawn/Scenes/ZoneTest_Hub.unity @@ -604,7 +604,7 @@ Transform: m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: [] - m_Father: {fileID: 1324221072} + m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!114 &1239426300 MonoBehaviour: @@ -650,8 +650,7 @@ Transform: m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 - m_Children: - - {fileID: 1239426299} + m_Children: [] m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &1828990780 diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/AssemblyAttributes.cs b/Unity/Assets/_SecondSpawn/Scripts/Networking/AssemblyAttributes.cs deleted file mode 100644 index 724e88b..0000000 --- a/Unity/Assets/_SecondSpawn/Scripts/Networking/AssemblyAttributes.cs +++ /dev/null @@ -1,34 +0,0 @@ -// SECOND SPAWN PATCH (2026-05-14): grant CLR access bypass for Fusion.Runtime -// internals from this assembly. Required because Fusion.Runtime.dll's -// [InternalsVisibleTo] declarations (Fusion.Unity.Tests/Editor, Unity.Fusion.CodeGen, -// Fusion.Plugin/Json/Runtime.Tests) do not include SecondSpawn.Networking, but -// Fusion's IL weaver emits direct accesses to Fusion.NetworkBehaviour::Ptr -// (internal field) inside every [Networked] property getter/setter. Without -// this attribute the CLR raises FieldAccessException at runtime as soon as -// NetworkPlayer.Spawned() runs against a user-asmdef-hosted NetworkBehaviour. -// -// IgnoresAccessChecksToAttribute is a Roslyn/CLR convention: when the compiler -// sees this attribute on the calling assembly, it emits IL that skips the -// runtime access check for the named target. Honored by .NET Core 3+, Mono -// 5.10+, and Unity 6's bundled Mono runtime. The attribute type is not in the -// BCL, so we declare it ourselves below; Roslyn matches by full name. -// -// Long-term fix: when Photon ships a Fusion version that grants this access -// automatically via AssembliesToWeave (or exposes Ptr as public), remove this -// file. Track release_history.txt; revert this workaround when confirmed. - -[assembly: System.Runtime.CompilerServices.IgnoresAccessChecksTo("Fusion.Runtime")] - -namespace System.Runtime.CompilerServices -{ - [System.AttributeUsage(System.AttributeTargets.Assembly, AllowMultiple = true)] - internal sealed class IgnoresAccessChecksToAttribute : System.Attribute - { - public IgnoresAccessChecksToAttribute(string assemblyName) - { - AssemblyName = assemblyName; - } - - public string AssemblyName { get; } - } -} diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/AssemblyAttributes.cs.meta b/Unity/Assets/_SecondSpawn/Scripts/Networking/AssemblyAttributes.cs.meta deleted file mode 100644 index 96d4c31..0000000 --- a/Unity/Assets/_SecondSpawn/Scripts/Networking/AssemblyAttributes.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: c70855c4936024f44999a9a9be182acf \ No newline at end of file diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/PlayerSpawner.cs b/Unity/Assets/_SecondSpawn/Scripts/Networking/PlayerSpawner.cs index 19f48eb..286700e 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/Networking/PlayerSpawner.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/PlayerSpawner.cs @@ -9,8 +9,7 @@ namespace SecondSpawn.Networking /// Server-authoritative player spawner. On /// , the server spawns the configured /// player prefab with input authority assigned to the joining player - /// and reparents the spawned NetworkObject under - /// (the scene's _DynamicObjects bucket). + /// player prefab with input authority assigned to the joining player. /// /// Per docs/design/05-networking-architecture.md + Pillar /// 4 (Server-authoritative gameplay), only the server creates @@ -28,9 +27,6 @@ public sealed class PlayerSpawner : MonoBehaviour, INetworkRunnerCallbacks [SerializeField, Tooltip("Player_NetworkCube prefab spawned per joining player. Must have NetworkObject + NetworkPlayer components.")] private NetworkObject _playerPrefab; - [SerializeField, Tooltip("Scene Transform that owns spawned player objects (e.g. _DynamicObjects).")] - private Transform _spawnRoot; - [SerializeField, Tooltip("Radius of the spawn ring around origin (units).")] private float _spawnRingRadius = 3f; @@ -61,17 +57,7 @@ public void OnPlayerJoined(NetworkRunner runner, PlayerRef player) } var spawnPos = ComputeSpawnPosition(_spawnCounter); - var spawnRoot = _spawnRoot; - runner.Spawn(_playerPrefab, spawnPos, Quaternion.identity, player, - onBeforeSpawned: (r, obj) => - { - // TODO(phase-B follow-up): documented Fusion 2 pattern for - // pre-Spawned() reparent. Observed under Fusion 2.1.1 to - // NOT stick - the NetworkObject still ends up at scene - // root. Likely Fusion moves the GO into a runner-managed - // scene after this callback; investigate Spawn flow. - if (spawnRoot != null) obj.transform.SetParent(spawnRoot, worldPositionStays: true); - }); + runner.Spawn(_playerPrefab, spawnPos, Quaternion.identity, player); _spawnCounter++; Debug.Log($"[PlayerSpawner] Spawned player cube for {player} at {spawnPos}"); } diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/SecondSpawn.Networking.asmdef b/Unity/Assets/_SecondSpawn/Scripts/Networking/SecondSpawn.Networking.asmdef index ea70c36..54db47d 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/Networking/SecondSpawn.Networking.asmdef +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/SecondSpawn.Networking.asmdef @@ -9,7 +9,7 @@ ], "includePlatforms": [], "excludePlatforms": [], - "allowUnsafeCode": false, + "allowUnsafeCode": true, "overrideReferences": false, "precompiledReferences": [], "autoReferenced": true, diff --git a/docs/adr/0008-codex-primary-agent-workflow.md b/docs/adr/0008-codex-primary-agent-workflow.md new file mode 100644 index 0000000..8b5d3d8 --- /dev/null +++ b/docs/adr/0008-codex-primary-agent-workflow.md @@ -0,0 +1,131 @@ +# ADR 0008: Adopt Codex-primary agent workflow + +**Status:** Accepted +**Date:** 2026-05-14 +**Deciders:** JOY (sole decision-maker, solo founder) + +## Context + +SECOND SPAWN is a solo-founder project where JOY is the vision holder and +non-code reviewer. The AI workflow has to optimize for sustained throughput, +low coordination overhead, and reliable verification. + +Early setup used Claude Desktop Code mode as the main Unity operator because +Unity's official AI MCP path exposed a single direct-connection slot and was +already configured there. After evaluating the available options, the project +now has a working CoplayDev MCP for Unity connection over local HTTP at +`http://127.0.0.1:8080`. Codex can inspect scenes, read console logs, and make +targeted Unity changes through that path without relying on the unstable Unity +official MCP direct-connection flow. + +Claude Code Max / Claude Desktop Code mode remains valuable, but its usage is +limited by subscription budget. During the 2026-05-14 Phase B networking +session, the handoff reported a projected Claude burn rate of about `$473/day` +if the same pace continued, with Phase B token data showing that continuous +Claude usage is too expensive for routine iteration. Codex has more available +capacity for daily work. + +## Decision + +SECOND SPAWN will use a **Codex-primary workflow**: + +1. **Codex is the default daily operator** for code, docs, ADRs, setup files, + backend work, Unity MCP inspection, targeted Unity scene edits, and repo + hygiene. +2. **CoplayDev MCP for Unity is the primary Unity bridge** for Codex and Claude + when an agent needs live Editor context. +3. **Claude Code Max / Claude Desktop Code mode is a specialty agent**, used + for high-value review, architecture critique, brainstorm sessions, or + specific Unity work where Codex gets stuck. +4. **Only one agent mutates Unity assets at a time.** If control switches, the + previous agent leaves a handoff that includes branch, commits ahead/behind, + dirty files, active scene, console state, MCP state, and next actions. +5. **No significant commit is considered done without reviewer pass.** This + preserves Hard Rule #7 for JOY as a non-coder. + +## Rationale + +- Codex has enough available capacity to be the default implementation loop. +- The 2026-05-14 Phase B handoff provided empirical cost pressure, not just a + preference: continuous Claude usage during Unity/Fusion setup was projected at + roughly `$473/day`. +- CoplayDev MCP for Unity removes the previous hard dependency on Unity's + official direct-connection slot for day-to-day Unity interaction. +- The same session debugged the Unity official MCP seat/cap/connection path and + verified CoplayDev MCP over `localhost:8080` as the more reliable bridge for + Codex. +- Claude Code Max remains most valuable when used as a scarce expert, not as a + continuous background worker. +- A documented handoff protocol prevents two agents from editing Unity scenes, + prefabs, or generated metadata at the same time. + +## Alternatives Considered + +### Alternative 1: Claude-primary workflow + +Claude owns most Unity work and Codex acts as backup reviewer. + +- **Pros:** Strong Unity operator feel, good for visual iteration. +- **Cons:** Burns Claude limit quickly, keeps Codex underused, and leaves the + project fragile when Claude limit is exhausted. +- **Rejection reason:** Not sustainable for a solo founder with scarce Claude + budget. + +### Alternative 2: Split by domain only + +Codex owns backend/docs; Claude owns all Unity work. + +- **Pros:** Clean mental model. +- **Cons:** Artificial boundary now that Codex can use CoplayDev MCP for Unity; + increases handoff overhead even for small Unity fixes. +- **Rejection reason:** Slower than necessary for daily iteration. + +### Alternative 3: Multiple agents mutate Unity concurrently + +Codex and Claude both connect and modify the Editor whenever they are available. + +- **Pros:** Maximum theoretical parallelism. +- **Cons:** High risk of scene/prefab conflicts, stale console state, generated + metadata churn, and unclear ownership. +- **Rejection reason:** Too risky for Unity projects and a non-code founder. + +## Consequences + +### Positive + +- Better daily throughput without exhausting Claude limits. +- More consistent repo-level architecture and documentation discipline. +- Clearer ownership when Unity state changes. + +### Negative + +- Some Unity-heavy tasks may still need Claude escalation. +- Codex must be careful not to overreach on visual composition or package + import work that benefits from a human-visible Editor session. + +### Mitigations + +- Use `docs/setup/agent-handoff.md` for every agent switch. +- Keep major Unity package imports as separate commits. +- Run reviewer pass before significant commits. +- Use Unity console and scene hierarchy checks before claiming Unity work is + complete. + +## Validation Criteria + +- Codex can connect to CoplayDev MCP for Unity and read active scene plus + console logs. +- Handoffs between Codex and Claude include enough context for the next agent + to continue without rediscovery. +- Claude limit is reserved for review, architecture, brainstorm, and blocked + Unity work. + +## Related Decisions + +- [ADR 0001: Adopt Photon Fusion 2 as networking framework](0001-photon-fusion-2.md) +- [ADR 0006: Build Fusion 2 integration from scratch](0006-fusion-2-scratch-over-template.md) +- [ADR 0007: Photon Fusion 2.0.12 - Unity 6.5 beta API incompatibility](0007-photon-fusion-2-0-12-unity-6-5-beta-incompat.md) +- [Agent handoff checklist](../setup/agent-handoff.md) +- 2026-05-14 MCP debug session: Unity official MCP connection instability, + CoplayDev MCP `localhost:8080` verification, and Phase B Claude token/cost + handoff. diff --git a/docs/adr/0009-fusion-networking-assembly-unsafe-code.md b/docs/adr/0009-fusion-networking-assembly-unsafe-code.md new file mode 100644 index 0000000..dc2c19f --- /dev/null +++ b/docs/adr/0009-fusion-networking-assembly-unsafe-code.md @@ -0,0 +1,122 @@ +# ADR 0009: Enable unsafe code for the Fusion networking assembly + +**Status:** Accepted +**Date:** 2026-05-14 +**Deciders:** JOY (sole decision-maker, solo founder) + +## Context + +SECOND SPAWN hosts Fusion gameplay code in asmdef assemblies such as +`SecondSpawn.Networking`, not in Unity's default `Assembly-CSharp`. Photon +Fusion 2.1.1-RC weaves `[Networked]` property accessors into those assemblies. + +During the Phase B Play Mode smoke test, the generated accessors for +`SecondSpawn.Networking.NetworkPlayer` raised runtime exceptions: + +```text +FieldAccessException: Field `Fusion.NetworkBehaviour:Ptr' is inaccessible from method `SecondSpawn.Networking.NetworkPlayer:get_NetworkedPosition ()' +``` + +`Unity/ProjectSettings/ProjectSettings.asset` already has project-wide +`allowUnsafeCode: 1`, but `Unity/Assets/_SecondSpawn/Scripts/Networking/SecondSpawn.Networking.asmdef` +had `"allowUnsafeCode": false`. Fusion's IL weaving uses pointer-backed runtime +state for `[Networked]` properties, so the asmdef that contains networked +behaviours must also allow unsafe code. + +A previous `IgnoresAccessChecksTo("Fusion.Runtime")` experiment did not resolve +the Play Mode failure and has been removed. + +## Decision + +Enable unsafe code for the `SecondSpawn.Networking` assembly: + +```json +"allowUnsafeCode": true +``` + +Keep project-wide `allowUnsafeCode: 1` in Unity Player Settings. + +Do not use a CLR access-check bypass workaround for this issue. + +## Rationale + +- Photon Fusion's generated `[Networked]` accessors depend on pointer-backed + state and require unsafe code. +- The fix is limited to the networking assembly that owns Fusion + `NetworkBehaviour` types. +- Keeping asmdefs preserves project compile isolation and module ownership. +- Removing the failed access-bypass experiment avoids documenting or shipping a + misleading workaround. + +## Alternatives Considered + +### Alternative 1: Move networking scripts to `Assembly-CSharp` + +- **Pros:** Avoids asmdef-level unsafe configuration. +- **Cons:** Reverses project conventions, increases compile scope, and weakens + module ownership. +- **Rejection reason:** The asmdef can be configured correctly. + +### Alternative 2: Keep `IgnoresAccessChecksTo("Fusion.Runtime")` + +- **Pros:** Theoretically bypasses runtime access checks. +- **Cons:** Play Mode testing showed the error still occurred. It is also more + obscure and less aligned with Fusion's expected unsafe-code requirement. +- **Rejection reason:** Ineffective in this Unity/Fusion setup. + +### Alternative 3: Patch Photon runtime DLL or source + +- **Pros:** Could avoid changing project asmdef settings. +- **Cons:** Vendor patch maintenance burden and higher risk on Photon updates. +- **Rejection reason:** Not needed when asmdef unsafe code fixes the root cause. + +## Consequences + +### Positive + +- Keeps asmdef-based project structure intact. +- Aligns `SecondSpawn.Networking` with Fusion's weaved `[Networked]` property + runtime requirements. +- Avoids a brittle runtime access bypass. + +### Negative + +- Unsafe code is now enabled for the entire `SecondSpawn.Networking` assembly. +- Future networking code reviews must watch for accidental unsafe blocks beyond + Fusion-generated accessors. + +### Mitigations + +- Do not hand-write unsafe code in SECOND SPAWN networking scripts unless a + future ADR explicitly accepts it. +- Keep unsafe enabled only on assemblies that contain Fusion networked runtime + code. +- If other asmdefs later contain `[Networked]` properties, enable unsafe there + deliberately and update this ADR or create a follow-up ADR. + +## Revert Criteria + +Only revert `"allowUnsafeCode": true` in `SecondSpawn.Networking.asmdef` if all +of the following are true: + +1. Photon Fusion `release_history.txt` or official release notes confirm unsafe + asmdef compilation is no longer required for weaved `[Networked]` + properties. +2. `SecondSpawn.Networking` remains listed in + `NetworkProjectConfig.AssembliesToWeave`. +3. Phase B Play Mode smoke test passes with unsafe disabled. + +## Validation Criteria + +- Unity compiles without C# errors. +- Enter Play Mode in `ZoneTest_Hub`. +- Fusion Host session starts. +- Player join spawns `Player_NetworkCube`. +- No new `FieldAccessException` appears when `NetworkPlayer.Spawned()` or + `NetworkPlayer.FixedUpdateNetwork()` accesses `[Networked]` properties. + +## Related Decisions + +- [ADR 0005: Use Unity 6.5 beta](0005-unity-6-5-beta.md) +- [ADR 0006: Build Fusion 2 integration from scratch](0006-fusion-2-scratch-over-template.md) +- [ADR 0007: Photon Fusion 2.0.12 - Unity 6.5 beta API incompatibility](0007-photon-fusion-2-0-12-unity-6-5-beta-incompat.md) diff --git a/docs/design/05-networking-architecture.md b/docs/design/05-networking-architecture.md index 284aa73..5c3171b 100644 --- a/docs/design/05-networking-architecture.md +++ b/docs/design/05-networking-architecture.md @@ -1,6 +1,6 @@ # Networking Architecture (Photon Fusion 2) -*Status: Draft (architecture-only; integration code stub until JOY installs Fusion 2 SDK per [docs/setup/fusion-install.md](../setup/fusion-install.md))* +*Status: Draft with Phase B smoke-test implementation* *Created: 2026-05-14* *Implements Pillar*: AI agent 24/7, LLM as world citizen, Server-authoritative gameplay @@ -76,9 +76,9 @@ The dedicated server NEVER trusts the LLM. All LLM responses parse into structur ## Networked types we will own -The actual implementations land in `Assets/_SecondSpawn/Scripts/Networking/` (assembly `SecondSpawn.Networking`) as Fusion 2 SDK is installed. Below is the architecture; code stubs are in place pre-SDK. +The actual implementations land in `Assets/_SecondSpawn/Scripts/Networking/` (assembly `SecondSpawn.Networking`). Phase B has a smoke-test implementation for runner startup, keyboard input, player spawn, and placeholder networked movement. -### `NetworkRunnerProvider` (MonoBehaviour, singleton) +### `NetworkRunnerSetup` (MonoBehaviour, singleton) - Owns the `NetworkRunner` lifecycle. - Reads `SecondSpawnConfig` for Photon App ID + environment. @@ -190,14 +190,15 @@ Numbers will be re-validated with Fusion bot load test (per `02-vertical-slice-s | Element | Status | Where | |---|---|---| -| Photon Fusion 2 SDK installed | NOT YET (JOY action) | [docs/setup/fusion-install.md](../setup/fusion-install.md) | -| `NetworkRunnerProvider` scaffold | Scaffold only (no Fusion API yet) | `Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkRunnerSetup.cs` | -| `NetworkPlayer` scaffold | Scaffold only | `Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkPlayer.cs` | -| `NetworkInputProvider` scaffold | Scaffold only | `Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkInputProvider.cs` | +| Photon Fusion 2 SDK installed | Installed: Fusion 2.1.1-RC after ADR 0007 resolution | `Unity/Assets/Photon/` | +| `NetworkRunnerSetup` | Phase B smoke-test implementation | `Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkRunnerSetup.cs` | +| `NetworkPlayer` | Phase B placeholder networked player cube state | `Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkPlayer.cs` | +| `NetworkInputProvider` | Phase B keyboard input provider | `Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkInputProvider.cs` | +| `PlayerSpawner` | Phase B server-authoritative join spawn/despawn | `Unity/Assets/_SecondSpawn/Scripts/Networking/PlayerSpawner.cs` | | `NetworkZone` | Not started | TBD | | `IntentBridge` | Not started | will live next to `internal/intent` in backend/gateway concepts | | `OfflineAgentRunner` | Not started | server-only, Phase 7 | -| Test scene | Default `SampleScene.unity` (URP template) - to rename when Fusion bootstrap lands | `Unity/Assets/_SecondSpawn/Scenes/` | +| Test scene | `ZoneTest_Hub.unity` with scene-root `_NetworkBootstrap` | `Unity/Assets/_SecondSpawn/Scenes/ZoneTest_Hub.unity` | | Load test (Fusion bots) | Not started | Phase 8 | --- diff --git a/docs/setup/agent-handoff.md b/docs/setup/agent-handoff.md new file mode 100644 index 0000000..935324e --- /dev/null +++ b/docs/setup/agent-handoff.md @@ -0,0 +1,93 @@ +# Agent Handoff Checklist + +*Status: Living document - update when the agent workflow changes* +*Created: 2026-05-14* + +This checklist keeps Codex, Claude Code Max / Claude Desktop Code mode, and JOY +aligned while SECOND SPAWN is developed by a solo founder with AI agents. + +## Default ownership + +| Area | Default owner | Notes | +|---|---|---| +| Daily coding | Codex | Includes C#, backend, docs, ADRs, small Unity MCP edits, repo hygiene. | +| Unity MCP inspection | Codex | Read scene hierarchy, console logs, package state, and focused Editor state. | +| Unity visual composition | Codex first, Claude if blocked | Escalate when the work depends heavily on visual judgement or package-specific Editor flows. | +| Architecture critique | Claude Code Max or Codex reviewer pass | Use scarce Claude budget for high-leverage second opinions. | +| Brainstorming | Claude Code Max or Codex | Pick based on available budget and desired creative depth. | +| Final significant review | Independent agent | Do not let the author be the only reviewer before claiming done. | +| Vision and scope decisions | JOY | Agents propose concrete choices, JOY decides. | + +## Unity MCP operating rules + +1. Use CoplayDev MCP for Unity as the primary Unity bridge. +2. Keep the Unity Editor open at `D:\Projects\Second-Spawn\Unity`. +3. In `Window > MCP For Unity`, verify: + - Transport: `HTTP Local` + - HTTP URL: `http://127.0.0.1:8080` + - Session: active +4. Only one agent may mutate Unity scenes, prefabs, package imports, or project + settings at a time. +5. Read-only inspection by the next agent is allowed after the previous agent + has finished its current write step and reported dirty files. +6. When a package import is needed, import one package per commit: + - Opsive Ultimate Character Controller + - Behavior Designer + - Convai +7. After import or script edits, check the Unity console before moving on. + +## Handoff message template + +Paste this when switching agents: + +```text +Continue Second-Spawn handoff. +Branch: +Ahead/behind: +Latest commits: +Dirty files: +Unity version: +Active scene: +MCP state: +Console summary: +What changed: +What was verified: +Known issues: +Next actions, in order: +Manual JOY actions: +Do not touch: +``` + +## Commit and review protocol + +1. `main` is the stable branch. Daily work happens on `dev`. +2. Feature work starts from `dev` in a separate branch/worktree, then PRs back + into `dev`. +3. Keep Unity package imports, code changes, docs changes, and scene edits in + separate commits when practical. +4. Before a significant commit, run an independent review pass. JOY is a + non-coder, so an agent reviewer must catch code and architecture issues. +5. The final update must say exactly what was verified and what was not. +6. Do not push until the current dirty state and console status are understood. + +## Current manual JOY actions + +1. Create `Assets/_SecondSpawn/Settings/SecondSpawnConfig.asset` via Unity: + `Assets > Create > Second Spawn > Project Config`. +2. Add Unity Linux Dedicated Server Build Support for Unity `6000.5.0b7` via + Unity Hub before dedicated server build work. +3. Import asset store packages in separate passes: Opsive UCC, then Behavior + Designer, then Convai. + +## Current Unity decisions + +- `_NetworkBootstrap` lives at the scene root because Fusion and Unity + `DontDestroyOnLoad` require root GameObjects. +- Runtime-spawned Fusion `NetworkObject` instances are not force-parented under + `_DynamicObjects` in Phase B. Fusion scene ownership and parent sync require a + proper networked parent strategy that will be designed later if needed. +- Photon Fusion spawning docs describe `onBeforeSpawned` as a pre-attach + initialization hook for custom state, not a scene hierarchy ownership API. + Local Fusion 2.1.1 source also moves spawned prefabs through the runner scene + via the object provider, so root-level runtime `NetworkObject` instances are + accepted for Phase B. diff --git a/docs/setup/fusion-install.md b/docs/setup/fusion-install.md index 45020f0..df1e2dc 100644 --- a/docs/setup/fusion-install.md +++ b/docs/setup/fusion-install.md @@ -76,13 +76,13 @@ cd D:\Projects\Second-Spawn claude ``` -Then tell it: "Fusion 2 SDK installed, App ID configured. Wire up the NetworkRunnerProvider per docs/design/05-networking-architecture.md." +Then tell it: "Fusion 2 SDK installed, App ID configured. Wire up the NetworkRunnerSetup per docs/design/05-networking-architecture.md." The AI agent will: 1. Verify Fusion assemblies are loaded (`Fusion.Runtime`, `Fusion.Common`, etc). 2. Replace the scaffold code in `Assets/_SecondSpawn/Scripts/Networking/*.cs` with real `NetworkBehaviour` / `[Networked]` property implementations. -3. Hook up `NetworkRunnerProvider` to start a Host Mode session in dev. +3. Hook up `NetworkRunnerSetup` to start a Host Mode session in dev. 4. Create a simple test: spawn a `NetworkPlayer` cube on player join, replicate position at 60Hz. 5. Commit + push. diff --git a/docs/setup/unity-conventions.md b/docs/setup/unity-conventions.md index 2c12653..522d1ba 100644 --- a/docs/setup/unity-conventions.md +++ b/docs/setup/unity-conventions.md @@ -25,7 +25,7 @@ Current structure (committed as of 2026-05-14, post-migration from flat layout): Unity/Assets/ ├── _SecondSpawn/ # ALL custom SECOND SPAWN assets live here │ ├── Scenes/ -│ │ └── SampleScene.unity # URP template default - rename when slice phase 2 starts +│ │ └── ZoneTest_Hub.unity # Phase B Fusion smoke-test hub scene │ ├── Scripts/ # Custom code, one subfolder per assembly │ │ ├── AI/ # SecondSpawn.AI assembly │ │ ├── Gameplay/ # SecondSpawn.Gameplay @@ -33,6 +33,8 @@ Unity/Assets/ │ │ ├── NFT/ # SecondSpawn.NFT (DOS Chain via thirdweb gateway) │ │ ├── Settings/ # SecondSpawn.Settings (ScriptableObject configs) │ │ └── UI/ # SecondSpawn.UI +│ ├── Prefabs/ +│ │ └── Player_NetworkCube.prefab # Phase B placeholder network player │ ├── Settings/ # URP renderer + pipeline assets, project ScriptableObject configs │ └── InputSystem_Actions.inputactions # Player input bindings └── Photon/ # 3rd-party SDK, kept at Assets root per Unity import default @@ -129,12 +131,12 @@ public sealed class FooConfig : ScriptableObject { ... } ## Scene organization -Apply to the first real scene (post slice phase 2 wiring; SampleScene.unity is the URP template and will be replaced): +Apply to the first real scene: ```text Scene root: +├── _NetworkBootstrap # Scene-root object required by Fusion/Unity DontDestroyOnLoad ├── _Managers # Underscore prefix: persistent objects pinned at top of hierarchy -│ └── NetworkRunnerSetup ├── _Cameras │ └── PlayerCamera (top-down ARPG perspective) ├── _Lights @@ -142,11 +144,13 @@ Scene root: ├── _UI │ └── HUDController ├── World # Static environment -└── _DynamicObjects # Runtime-spawned content (player, NPCs, loot) +└── _DynamicObjects # Non-Fusion runtime-spawned content bucket ``` Underscore-prefixed groups stay at the top of the Hierarchy view (Unity sorts case-insensitive but underscores precede letters). +Phase B networking note (2026-05-14): `_NetworkBootstrap` is intentionally a scene-root object, not a child of `_Managers`, because Fusion and Unity call `DontDestroyOnLoad` on runner-owned objects and Unity only allows that for root GameObjects. Runtime-spawned Fusion `NetworkObject` instances are also accepted at runner/scene root for Phase B; do not force-parent them under `_DynamicObjects` unless a future ADR defines a networked parent strategy. + ## Scripting conventions reinforced from Hard Rules Cross-reference `.claude/CLAUDE.md` and `AGENTS.md` Hard Rules: @@ -174,9 +178,9 @@ Items below are KNOWN deviations to address as work lands; not blockers for curr | Deviation | Reason | Resolution | |---|---|---| -| `_SecondSpawn/Scenes/SampleScene.unity` is URP template default | Project just initialized | Rename to `ZoneTest_Hub.unity` when slice phase 2 networking goes into a real scene; rebuild hierarchy per "Scene organization" above | | `_SecondSpawn/Settings/SecondSpawnConfig.asset` instance not yet created | Domain reload gate - asmdef compiled but type not loaded into AppDomain at scaffold-write time | JOY clicks Editor menu Assets > Create > Second Spawn > Project Config + saves to `_SecondSpawn/Settings/SecondSpawnConfig.asset`. One-time. | -| `_SecondSpawn/Materials/`, `Textures/`, `Audio/`, `Prefabs/`, `Animations/` do not exist | No art assets imported yet | Create when first asset of that type lands. Do NOT pre-create empty (Unity guidance: empty folders break VCS) | +| `_SecondSpawn/Materials/`, `Textures/`, `Audio/`, `Animations/` do not exist | No art assets imported yet | Create when first asset of that type lands. Do NOT pre-create empty (Unity guidance: empty folders break VCS) | +| `_NetworkBootstrap` at scene root instead of under `_Managers` | Fusion / Unity `DontDestroyOnLoad` root requirement | Accepted Phase B convention. Keep root unless future network scene architecture replaces it. | | No `_SecondSpawn/Scripts//Editor/` subfolders | No editor-only scripts yet | Add per the asmdef convention above when first editor tool is needed | | No `_SecondSpawn/Scripts//Tests/` subfolders | No tests yet | Add per asmdef convention when first test lands; CI workflow `.github/workflows/unity-build.yml` already references `unity-test-runner` for both EditMode and PlayMode | | Photon SDK demos / menu samples kept (`Photon/FusionDemos`, `Photon/FusionMenu`) | JOY decision (2026-05-14): "thêm 1 thư mục thôi, để như hiện tại" - low project-view cost | Keep as-is. Revisit only if cleanup needed for ship build size | @@ -190,6 +194,7 @@ Items below are KNOWN deviations to address as work lands; not blockers for curr | Per-zone scene naming | **`Zone_.unity`** | Group-by-type then name (e.g., `Zone_DesertHub.unity`, `Zone_DungeonAwakening.unity`) | | Boss scene naming | **`Boss_.unity`** as separate file, additive load | Slice phase 4 wires this when first dungeon ships | | Photon FusionDemos + FusionMenu cleanup | **Keep as-is** | JOY: "thêm 1 thư mục thôi, để như hiện tại có sao đâu". Revisit if ship build size becomes concern | +| Fusion runtime object parent bucket | **Do not force-parent spawned NetworkObjects under `_DynamicObjects` for Phase B** | Root-level spawned NetworkObjects match Fusion runner-scene ownership. Revisit when a networked parent strategy is needed. | ## References From 0f77b0981c70cd9eae0f6773710c19dbd1044482 Mon Sep 17 00:00:00 2001 From: JOY Date: Thu, 14 May 2026 22:36:01 +0700 Subject: [PATCH 05/14] docs: add GitBook wiki and prototype design --- .claude/CLAUDE.md | 12 +- AGENTS.md | 12 +- README.md | 9 +- ROADMAP.md | 5 +- .../Settings/SecondSpawnConfig.asset | 20 ++ .../Settings/SecondSpawnConfig.asset.meta | 8 + docs/ARCHITECTURE.md | 5 +- docs/README.md | 43 ++++ docs/SUMMARY.md | 37 ++++ docs/design/00-game-concept.md | 29 +-- docs/design/01-pillars.md | 57 +++-- docs/design/02-vertical-slice-spec.md | 18 +- docs/design/03-systems-index.md | 61 +++--- docs/design/06-overview-design.md | 158 ++++++++++++++ docs/design/07-player-controller-prototype.md | 202 ++++++++++++++++++ docs/design/08-time-as-currency.md | 192 +++++++++++++++++ 16 files changed, 798 insertions(+), 70 deletions(-) create mode 100644 Unity/Assets/_SecondSpawn/Settings/SecondSpawnConfig.asset create mode 100644 Unity/Assets/_SecondSpawn/Settings/SecondSpawnConfig.asset.meta create mode 100644 docs/README.md create mode 100644 docs/SUMMARY.md create mode 100644 docs/design/06-overview-design.md create mode 100644 docs/design/07-player-controller-prototype.md create mode 100644 docs/design/08-time-as-currency.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 6f1381b..7dc1bdc 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -12,11 +12,12 @@ This file is the primary context for any AI coding agent working on this reposit - **Setting:** Near-future ~2050, post-apocalyptic, MetaDOS universe - **Tone:** Dark sci-fi, cyberpunk, cultivation-progression, AI NPC society -## Three Core USPs (DO NOT LOSE TRACK) +## Four Signature Features (DO NOT LOSE TRACK) 1. **AI Agent 24/7** - When the player is offline, an LLM-driven AI agent fully controls their character (farms, quests, socializes with NPCs and other players' agents). When the player returns, they take over control. This is a near-unique feature in MMO/ARPG space. 2. **Reincarnation with progression reset** - Death is permanent for the body. Consciousness transfers to a new body via SECOND token or special item. Progression resets (roguelike-MMO hybrid). Cultivation tier may carry over partial. -3. **Consciousness transfer to NPC/synthetic bodies** - Sci-fi explanation (mind upload, synthetic bodies, Nibirium-enhanced cloning). NOT spiritual reincarnation. +3. **Time-as-Currency** - Time is both the current body's survival resource and a spendable economy resource, adapted from MetaDOS and the `In Time` inspiration. Running out of body time triggers death/reincarnation; spending time creates hard tactical tradeoffs. +4. **Consciousness transfer to NPC/synthetic bodies** - Sci-fi explanation (mind upload, synthetic bodies, Nibirium-enhanced cloning). NOT spiritual reincarnation. ## Cultivation System (sci-fi, not Chinese-style) @@ -37,6 +38,7 @@ International-friendly framing. Explained via science (Nibirium, biotech, consci - Dungeon / instance separate - Guild PvP up to 50v50 - Top-down ARPG action combat +- Time-as-currency economy: body time can be earned, spent, transferred later, and lost on body death unless explicit conversion rules say otherwise - Pet system: NFT-based, 1 equip slot, NOT looted from bosses (marketplace + breeding only) - Mount system: movement only, no mounted combat (reduce animation workload) @@ -115,7 +117,7 @@ International-friendly framing. Explained via science (Nibirium, biotech, consci - Pets (1 equip slot, marketplace + breeding only, no boss drop) - **Wallet auth:** Sign-message pattern via thirdweb or Supabase + DOS Chain - **In-game lock:** Escrow contract when equipped, release on unequip -- **SECOND token:** Used for reincarnation cost (token economy needs design) +- **SECOND token:** Used for reincarnation cost (token economy needs design). Keep distinct from `BodyTime` unless a future ADR explicitly merges them. ### Version Control @@ -238,6 +240,8 @@ International-friendly framing. Explained via science (Nibirium, biotech, consci - All code, comments, docs, commits, PR titles, README, ROADMAP: **English** - Communication with JOY (this user): Vietnamese (per global CLAUDE.md) +- `/docs` is published publicly to GitBook at `https://dos.gitbook.io/second-spawn/`. Keep docs public-safe, English-canonical, and readable by non-repo visitors. +- Vietnamese companion notes may live under `docs/vi/`, but English docs are the source of truth. If Vietnamese notes conflict with English canonical docs, English wins. - No em-dashes anywhere - use `-` (hyphen) only ### Git Workflow @@ -276,6 +280,7 @@ Scope: - 1 boss with LLM dialogue (Convai) - 1 questline (3-5 quests) - Reincarnation MVP (die -> SECOND token -> respawn with reset) +- Time-as-currency MVP (body time meter, earn/spend loop, zero time triggers reincarnation placeholder) - AI agent control (simple: agent farms one designated area when player offline) - 2 cultivation tiers playable (Awakening + Enhancement) - NFT Hunter skin equip + escrow @@ -307,6 +312,7 @@ OUT of scope for vertical slice: - Final game name (SECOND SPAWN is codename, may rename after vertical slice playable) - SECOND token economy design (cost per reincarnation, source, sink) +- BodyTime tuning (where time drains, how it is earned, how it can be spent, and whether it can convert to/from SECOND token) - Hunter NFT integration approach: Option 1 (preset hero) vs Hybrid 1+3 (modular pieces) - Phase 2 LLM model split (when to use Haiku vs Sonnet) - Voice NPC vendor (OpenAI Realtime vs ElevenLabs vs self-host) diff --git a/AGENTS.md b/AGENTS.md index 4ae4c71..4b8288b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,11 +12,12 @@ This file is the primary context for any AI coding agent working on this reposit - **Setting:** Near-future ~2050, post-apocalyptic, MetaDOS universe - **Tone:** Dark sci-fi, cyberpunk, cultivation-progression, AI NPC society -## Three Core USPs (DO NOT LOSE TRACK) +## Four Signature Features (DO NOT LOSE TRACK) 1. **AI Agent 24/7** - When the player is offline, an LLM-driven AI agent fully controls their character (farms, quests, socializes with NPCs and other players' agents). When the player returns, they take over control. This is a near-unique feature in MMO/ARPG space. 2. **Reincarnation with progression reset** - Death is permanent for the body. Consciousness transfers to a new body via SECOND token or special item. Progression resets (roguelike-MMO hybrid). Cultivation tier may carry over partial. -3. **Consciousness transfer to NPC/synthetic bodies** - Sci-fi explanation (mind upload, synthetic bodies, Nibirium-enhanced cloning). NOT spiritual reincarnation. +3. **Time-as-Currency** - Time is both the current body's survival resource and a spendable economy resource, adapted from MetaDOS and the `In Time` inspiration. Running out of body time triggers death/reincarnation; spending time creates hard tactical tradeoffs. +4. **Consciousness transfer to NPC/synthetic bodies** - Sci-fi explanation (mind upload, synthetic bodies, Nibirium-enhanced cloning). NOT spiritual reincarnation. ## Cultivation System (sci-fi, not Chinese-style) @@ -37,6 +38,7 @@ International-friendly framing. Explained via science (Nibirium, biotech, consci - Dungeon / instance separate - Guild PvP up to 50v50 - Top-down ARPG action combat +- Time-as-currency economy: body time can be earned, spent, transferred later, and lost on body death unless explicit conversion rules say otherwise - Pet system: NFT-based, 1 equip slot, NOT looted from bosses (marketplace + breeding only) - Mount system: movement only, no mounted combat (reduce animation workload) @@ -115,7 +117,7 @@ International-friendly framing. Explained via science (Nibirium, biotech, consci - Pets (1 equip slot, marketplace + breeding only, no boss drop) - **Wallet auth:** Sign-message pattern via thirdweb or Supabase + DOS Chain - **In-game lock:** Escrow contract when equipped, release on unequip -- **SECOND token:** Used for reincarnation cost (token economy needs design) +- **SECOND token:** Used for reincarnation cost (token economy needs design). Keep distinct from `BodyTime` unless a future ADR explicitly merges them. ### Version Control @@ -238,6 +240,8 @@ International-friendly framing. Explained via science (Nibirium, biotech, consci - All code, comments, docs, commits, PR titles, README, ROADMAP: **English** - Communication with JOY (this user): Vietnamese (per global CLAUDE.md) +- `/docs` is published publicly to GitBook at `https://dos.gitbook.io/second-spawn/`. Keep docs public-safe, English-canonical, and readable by non-repo visitors. +- Vietnamese companion notes may live under `docs/vi/`, but English docs are the source of truth. If Vietnamese notes conflict with English canonical docs, English wins. - No em-dashes anywhere - use `-` (hyphen) only ### Git Workflow @@ -276,6 +280,7 @@ Scope: - 1 boss with LLM dialogue (Convai) - 1 questline (3-5 quests) - Reincarnation MVP (die -> SECOND token -> respawn with reset) +- Time-as-currency MVP (body time meter, earn/spend loop, zero time triggers reincarnation placeholder) - AI agent control (simple: agent farms one designated area when player offline) - 2 cultivation tiers playable (Awakening + Enhancement) - NFT Hunter skin equip + escrow @@ -307,6 +312,7 @@ OUT of scope for vertical slice: - Final game name (SECOND SPAWN is codename, may rename after vertical slice playable) - SECOND token economy design (cost per reincarnation, source, sink) +- BodyTime tuning (where time drains, how it is earned, how it can be spent, and whether it can convert to/from SECOND token) - Hunter NFT integration approach: Option 1 (preset hero) vs Hybrid 1+3 (modular pieces) - Phase 2 LLM model split (when to use Haiku vs Sonnet) - Voice NPC vendor (OpenAI Realtime vs ElevenLabs vs self-host) diff --git a/README.md b/README.md index 201d447..53a36a1 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,14 @@ - **AI Agent 24/7** - Your character keeps playing when you are offline. An LLM-driven agent farms, quests, and socializes on your behalf. - **Reincarnation** - Death is permanent for the body. Transfer your consciousness to a new synthetic body using SECOND tokens. Progression resets - this is a roguelike-MMO hybrid. +- **Time-as-Currency** - Time is both your current body's survival resource and a spendable economy resource, adapted from MetaDOS. - **Sci-fi Cultivation** - 6-tier progression system (Awakening -> Ascension), explained through Nibirium-enhanced biotech and consciousness science. - **LLM-Powered NPCs** - NPCs remember you, have personality, and react to your history. - **NFT Integration** - Inherit assets from the MetaDOS universe. Hunter skins, weapons, pets on DOS Chain. ## Tech Stack -- Unity 6 LTS + URP +- Unity 6.5 beta + URP - Photon Fusion 2 (dedicated server mode in production) - Supabase (auth, Postgres, realtime, storage) - Go LLM gateway (server-authoritative LLM intent validation) @@ -32,11 +33,15 @@ /.claude/ AI agent context, templates, conventions ``` +Public docs are published from `/docs` to GitBook: + + + ## Build (early stage) Requirements: -- Unity 6 LTS +- Unity 6.5 beta `6000.5.0b7` - Git LFS - Photon Fusion 2 app ID - Supabase project (or local Postgres for offline dev) diff --git a/ROADMAP.md b/ROADMAP.md index 839d025..9d2ec87 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,15 +2,16 @@ ## Vertical Slice (Months 1-6) - CURRENT -Goal: One playable zone, one class, one dungeon, reincarnation MVP, AI agent prototype. +Goal: One playable zone, one class, one dungeon, time-as-currency MVP, reincarnation MVP, AI agent prototype. - [ ] Unity 6 project init + Fusion 2 setup - [ ] Reuse MetaDOS Fusion boilerplate patterns -- [ ] Opsive Character Controller integration +- [ ] Minimal Fusion player controller baseline, then Opsive Character Controller evaluation - [ ] Supabase auth + profile (reuse DOS.Me pattern) - [ ] One hub zone with 5 NPCs (Convai) - [ ] One dungeon instance with 1 boss - [ ] Reincarnation flow (death -> SECOND token -> respawn with reset) +- [ ] Time-as-currency MVP (BodyTime meter, one earn source, one spend sink) - [ ] Cultivation tier 1-2 (Awakening, Enhancement) - [ ] One Hunter NFT skin equippable (DOS Chain integration) - [ ] AI agent prototype (offline player farms designated area) diff --git a/Unity/Assets/_SecondSpawn/Settings/SecondSpawnConfig.asset b/Unity/Assets/_SecondSpawn/Settings/SecondSpawnConfig.asset new file mode 100644 index 0000000..c4301f7 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Settings/SecondSpawnConfig.asset @@ -0,0 +1,20 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 0e8a9c31d60ccad40addeec5702d5edf, type: 3} + m_Name: SecondSpawnConfig + m_EditorClassIdentifier: + Environment: 0 + GatewayBaseUrl: http://localhost:8080 + SupabaseUrl: + SupabaseAnonKey: + PhotonAppId: + DosChainRpcUrl: diff --git a/Unity/Assets/_SecondSpawn/Settings/SecondSpawnConfig.asset.meta b/Unity/Assets/_SecondSpawn/Settings/SecondSpawnConfig.asset.meta new file mode 100644 index 0000000..eadd1e1 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Settings/SecondSpawnConfig.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c21433a4eb104e51a0ae305fb6b18c8d +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 972e532..bb35889 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -53,6 +53,7 @@ High-level architecture overview. For detailed component design see `docs/design ### Dedicated Game Server (Photon Fusion 2 Server Mode) - Source of truth for in-zone state (position, HP, combat, drops) +- Source of truth for `BodyTime` earn, spend, drain, transfer, and expiration - Validates every action intent (from player input or AI agent) - Persists durable state to Supabase Postgres (snapshots + events) - Triggers LLM gateway for NPC dialogue when triggered @@ -70,7 +71,7 @@ High-level architecture overview. For detailed component design see `docs/design ### Supabase Backend - **Auth:** Reuse DOS.Me pattern (email / wallet / OAuth) -- **Postgres:** durable state (profile, inventory, quest progress, NFT lock state, cultivation tier, character history) +- **Postgres:** durable state (profile, inventory, quest progress, NFT lock state, cultivation tier, character history, reincarnation and time-as-currency events) - **Realtime:** chat global, presence, friend list, party invite, notification (NOT combat / movement) - **Storage:** avatar, screenshot, UGC @@ -90,6 +91,7 @@ High-level architecture overview. For detailed component design see `docs/design - Wallet auth via sign-message - Escrow contract when NFT is equipped in-game - SECOND token transactions for reincarnation +- Future time-economy settlement only if a later ADR decides `BodyTime` can convert to or from token resources ### Redis @@ -104,6 +106,7 @@ High-level architecture overview. For detailed component design see `docs/design 3. **API keys live only in Go gateway env.** Never in Unity client, never in Supabase Edge Function reaching the client. 4. **NFT lock is on-chain.** When equipped, escrow contract holds. Server reads on-chain state, does not assume off-chain. 5. **AI agent inherits player limits.** No agent can do what a real player cannot. +6. **Time mutations are server-authoritative.** `BodyTime` is gameplay state; client, LLM, and AI agents can only request validated time intents. ## Open Architecture Questions diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..08783ac --- /dev/null +++ b/docs/README.md @@ -0,0 +1,43 @@ +# SECOND SPAWN Documentation + +SECOND SPAWN is a hybrid MMO + top-down ARPG set in the MetaDOS universe. The game combines offline AI-agent control, reincarnation through synthetic bodies, time-as-currency survival economy, LLM-driven NPCs, and server-authoritative multiplayer. + +This documentation is the canonical public design and architecture source for the project. It is published through GitBook at: + + + +## Start Here + +- [Game Concept](design/00-game-concept.md) - the high-level pitch, fantasy, audience, and risks. +- [Game Pillars](design/01-pillars.md) - the decision rules every feature must satisfy. +- [Vertical Slice Spec](design/02-vertical-slice-spec.md) - the first 3-6 month playable milestone. +- [Systems Index](design/03-systems-index.md) - the map of game systems and build order. +- [Architecture](ARCHITECTURE.md) - the logical system architecture and invariants. + +## Current Prototype Focus + +The current implementation focus is a thin, networked player-controller prototype: + +- Minimal Fusion controller first. +- Opsive Ultimate Character Controller evaluated after the baseline works. +- No large Unity asset imports until the movement, camera, and authority contract are verified. + +Relevant docs: + +- [Overview Design](design/06-overview-design.md) +- [Networked Player Controller Prototype](design/07-player-controller-prototype.md) + +## Signature Features + +1. **AI Agent 24/7** - the player's character keeps acting when the player is offline. +2. **Reincarnation** - death destroys the body, but consciousness transfers to a new synthetic body. +3. **Time-as-Currency** - time is both survival resource and spendable currency, inherited from the MetaDOS design lineage and adapted for SECOND SPAWN. +4. **LLM as World Citizen** - NPCs and agents reason through grounded, server-validated intents instead of free-form state mutation. + +## Documentation Rules + +- English is the canonical language for docs, code, commits, PRs, ADRs, and roadmap. +- Vietnamese notes may live under `docs/vi/` when needed, but English docs remain the source of truth. +- If Vietnamese notes and English docs conflict, the English canonical docs win. +- `/docs` is public-facing through GitBook. Do not place private credentials, internal-only secrets, or unpublished partner details here. + diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md new file mode 100644 index 0000000..272492f --- /dev/null +++ b/docs/SUMMARY.md @@ -0,0 +1,37 @@ +# Summary + +## Start Here + +- [Introduction](README.md) +- [Architecture](ARCHITECTURE.md) + +## Design + +- [Game Concept](design/00-game-concept.md) +- [Game Pillars](design/01-pillars.md) +- [Vertical Slice Spec](design/02-vertical-slice-spec.md) +- [Systems Index](design/03-systems-index.md) +- [Cultivation System](design/04-cultivation-system.md) +- [Networking Architecture](design/05-networking-architecture.md) +- [Overview Design](design/06-overview-design.md) +- [Networked Player Controller Prototype](design/07-player-controller-prototype.md) +- [Time-as-Currency](design/08-time-as-currency.md) + +## Architecture Decision Records + +- [ADR 0001: Photon Fusion 2](adr/0001-photon-fusion-2.md) +- [ADR 0002: Supabase Backend](adr/0002-supabase-backend.md) +- [ADR 0003: LLM Safety Architecture](adr/0003-llm-safety-architecture.md) +- [ADR 0004: AI Agent Offline Control](adr/0004-ai-agent-offline-control.md) +- [ADR 0005: Unity 6.5 Beta](adr/0005-unity-6-5-beta.md) +- [ADR 0006: Fusion 2 Scratch Over Template](adr/0006-fusion-2-scratch-over-template.md) +- [ADR 0007: Fusion 2.0.12 Unity 6.5 Beta Incompatibility](adr/0007-photon-fusion-2-0-12-unity-6-5-beta-incompat.md) +- [ADR 0008: Codex Primary Agent Workflow](adr/0008-codex-primary-agent-workflow.md) +- [ADR 0009: Fusion Networking Assembly Unsafe Code](adr/0009-fusion-networking-assembly-unsafe-code.md) + +## Setup + +- [Agent Handoff](setup/agent-handoff.md) +- [Fusion Install](setup/fusion-install.md) +- [Unity Conventions](setup/unity-conventions.md) + diff --git a/docs/design/00-game-concept.md b/docs/design/00-game-concept.md index ca9fef2..851bc37 100644 --- a/docs/design/00-game-concept.md +++ b/docs/design/00-game-concept.md @@ -7,7 +7,7 @@ ## Elevator Pitch -> A near-future post-apocalyptic top-down ARPG where your character keeps playing when you log off — an LLM-driven AI agent quests, farms, and socializes with NPCs and other players' agents on your behalf. Death is permanent for the body; reincarnation transfers your consciousness to a new synthetic body via SECOND tokens, resetting progression in a roguelike-MMO hybrid set in the MetaDOS universe. +> A near-future post-apocalyptic top-down ARPG where your character keeps playing when you log off - an LLM-driven AI agent quests, farms, and socializes with NPCs and other players' agents on your behalf. Time is both your body's remaining life and a spendable resource. Death is permanent for the body; reincarnation transfers your consciousness to a new synthetic body via SECOND tokens, resetting progression in a roguelike-MMO hybrid set in the MetaDOS universe. --- @@ -20,7 +20,7 @@ | **Target Audience** | ARPG players who like progression-reset roguelike loops + LLM-driven NPC interaction; MMO-curious solo players who can't commit 4-hour grind sessions | | **Player Count** | Multiplayer 4-20 per instance zone, 50v50 guild PvP at later phases | | **Session Length** | 30-90 min active play; offline AI agent extends progress without active play | -| **Monetization** | [TODO: JOY decide - SECOND token gating reincarnation cost is the proposed sink; cosmetic NFT marketplace is a candidate] | +| **Monetization** | [TODO: JOY decide - SECOND token gating reincarnation cost is the proposed sink; time-as-currency is a gameplay economy, not automatically a monetization currency; cosmetic NFT marketplace is a candidate] | | **Estimated Scope** | Vertical slice 3-6 months; full vision multi-year | | **Comparable Titles** | Diablo IV, Path of Exile 2, Lost Ark (combat); EVE Online (player-driven economy aspiration); Black Desert (open trades); novel reference: AI agent autoplay has no direct comparable | @@ -36,11 +36,12 @@ The fantasy is "your character has a life that does not pause when yours does." ## Unique Hook -It's like Diablo IV with persistent online zones, AND ALSO **your character keeps playing when you are offline** - an LLM-driven agent farms, quests, and socializes with NPCs and other players' agents on your behalf, and **death triggers reincarnation** with a partial-reset roguelike progression loop. +It's like Diablo IV with persistent online zones, AND ALSO **your character keeps playing when you are offline** - an LLM-driven agent farms, quests, and socializes with NPCs and other players' agents on your behalf. **Time is your life and your currency**, and **death triggers reincarnation** with a partial-reset roguelike progression loop. -The hook passes the "and also" test on two axes simultaneously: +The hook passes the "and also" test on three axes simultaneously: 1. AI agent autoplay (offline persistence in a multiplayer ARPG is near-unique) 2. Reincarnation as the death loop (instead of corpse run / repair cost / equipment loss) +3. Time-as-currency (every body has a time budget that can be earned, spent, and lost) --- @@ -58,11 +59,11 @@ The hook passes the "and also" test on two axes simultaneously: ### Core Mechanics (3-5 systems generating the dynamics) -1. Top-down ARPG action combat (Opsive Ultimate Character Controller) +1. Top-down ARPG action combat (minimal Fusion controller first; Opsive Ultimate Character Controller is an evaluation candidate) 2. LLM-driven NPC dialogue with server-validated intent (Convai phase 1, custom Go gateway phase 2) 3. AI agent autonomous control of player character when offline (server-authoritative, capability-capped) 4. Reincarnation via SECOND token cost (consciousness transfer, partial cultivation tier carryover) -5. Cultivation 6-tier progression (Awakening -> Ascension), gated by milestones +5. Time-as-currency body lifespan economy (earn/spend body time; zero time triggers body death) --- @@ -71,7 +72,7 @@ The hook passes the "and also" test on two axes simultaneously: | Need | How This Game Satisfies It | Strength | | ---- | ---- | ---- | | **Autonomy** | Choose how to spend time online vs delegate to AI agent; choose reincarnation timing; choose cultivation path | Core | -| **Competence** | Cultivation tier-up is the explicit mastery ladder; combat skill ceiling via Opsive controller depth | Core | +| **Competence** | Cultivation tier-up is the explicit mastery ladder; combat skill ceiling comes from the final movement/combat controller after prototype validation | Core | | **Relatedness** | LLM NPCs remember you across sessions; guild + zone fellowship; agent-to-agent socialization | Supporting | ### Player Type Appeal (Bartle) @@ -112,8 +113,9 @@ Complete a quest line or dungeon clear; converse with hub-town NPCs (LLM-driven) 1. **AI agent 24/7** - the character is always playing 2. **Reincarnation, not respawn** - death has weight; SECOND token cost -3. **LLM as world citizen, not chatbot** - NPCs are server-validated agents in the world -4. **Server-authoritative gameplay** - public open-source repo means anti-cheat assumes attacker has full source +3. **Time is life, time is money** - time is the body's survival budget and a spendable resource +4. **LLM as world citizen, not chatbot** - NPCs are server-validated agents in the world +5. **Server-authoritative gameplay** - public open-source repo means anti-cheat assumes attacker has full source --- @@ -124,6 +126,7 @@ Complete a quest line or dungeon clear; converse with hub-town NPCs (LLM-driven) | Diablo IV | Top-down ARPG action combat, item / loot loops | No paragon board grind; reincarnation replaces seasonal reset | | Path of Exile 2 | Skill / passive depth, build expression | Less crafting-first; more character-narrative-driven via LLM NPCs | | Lost Ark | Multi-instance zone hubs, guild raids structure | Smaller zone size (~20 players); LLM agents instead of scripted NPCs | +| In Time (2011) | Time as survival resource and currency | Adapted through synthetic-body lifespan, not direct film setting or theme | | EVE Online | Player-driven economy, persistent universe consequence | Solo-friendly via AI agent; not a single-shard nightmare | | MetaDOS (BR) | Hunter skin NFT system, Photon Fusion 2 networking patterns | Different genre (ARPG, not BR); persistent zones, not match rounds | @@ -166,6 +169,7 @@ See [docs/ARCHITECTURE.md](../ARCHITECTURE.md) for system diagram + critical inv ### Design Risks - AI agent offline play may feel either invisible (player doesn't notice progress) or invasive (agent does things player wouldn't choose) - Cultivation tier pacing: too slow = grind; too fast = no mastery feeling +- Time-as-currency may feel oppressive if time drain is constant, or invisible if it only appears at death - LLM NPCs may feel chatbot-like if they don't ground in world state (location, quest progress, faction) ### Technical Risks @@ -179,10 +183,11 @@ See [docs/ARCHITECTURE.md](../ARCHITECTURE.md) for system diagram + critical inv ### Scope Risks - Solo dev + AI agent (Claude Code) + 3-6 month vertical slice on novel architecture is tight -- 3rd-party assets (Opsive UCC, Behavior Designer, Convai) may not be tested against Unity 6.5 beta +- 3rd-party assets (Opsive UCC, Behavior Designer, Convai) may not be tested against Unity 6.5 beta. Opsive is not mandatory for the first movement prototype until it proves value against the minimal controller baseline. ### Open Questions (need JOY input later) - SECOND token economy: reincarnation cost, source, sink. (Open Decision Point in CLAUDE.md.) +- BodyTime economy: where time drains, how players earn it, how they spend it, and whether conversion to/from SECOND token ever exists. - Hunter NFT integration: Option 1 (preset hero) vs Hybrid 1+3 (modular pieces) - Voice NPC vendor: OpenAI Realtime vs ElevenLabs vs self-host - Final game name (SECOND SPAWN is codename) @@ -193,7 +198,7 @@ See [docs/ARCHITECTURE.md](../ARCHITECTURE.md) for system diagram + critical inv See [02-vertical-slice-spec.md](02-vertical-slice-spec.md). -**Core hypothesis**: A solo player can experience all 3 USPs (AI agent autoplay, reincarnation, cultivation tier-up) inside a single zone within 30 minutes of first play, without requiring out-of-game tutorials. +**Core hypothesis**: A solo player can experience the signature hooks (AI agent autoplay, reincarnation, time-as-currency, cultivation tier-up) inside a single zone within 30 minutes of first play, without requiring out-of-game tutorials. --- @@ -202,4 +207,4 @@ See [02-vertical-slice-spec.md](02-vertical-slice-spec.md). - [ ] JOY review and refine this concept doc (especially monetization line + open questions) - [ ] Finalize [01-pillars.md](01-pillars.md) (preview pillars listed above need design tests added) - [ ] Build vertical slice per [02-vertical-slice-spec.md](02-vertical-slice-spec.md) -- [ ] Per-system GDDs as systems are designed (cultivation already started in [04-cultivation-system.md](04-cultivation-system.md); combat, AI agent, reincarnation, NFT escrow, LLM NPC pending) +- [ ] Per-system GDDs as systems are designed (cultivation started in [04-cultivation-system.md](04-cultivation-system.md); time-as-currency started in [08-time-as-currency.md](08-time-as-currency.md); combat, AI agent, reincarnation, NFT escrow, LLM NPC pending) diff --git a/docs/design/01-pillars.md b/docs/design/01-pillars.md index 1d61769..f0941c4 100644 --- a/docs/design/01-pillars.md +++ b/docs/design/01-pillars.md @@ -7,7 +7,7 @@ ## Core Fantasy -You are a Hunter in a 2050 post-apocalyptic world where consciousness can be transferred to synthetic bodies. Your character has a life that does not pause when yours does, advances through a 6-tier cultivation ladder, and meets death as a transition rather than an end. +You are a Hunter in a 2050 post-apocalyptic world where consciousness can be transferred to synthetic bodies. Your character has a life that does not pause when yours does, advances through a 6-tier cultivation ladder, spends time as a real resource, and meets death as a transition rather than an end. --- @@ -15,7 +15,7 @@ You are a Hunter in a 2050 post-apocalyptic world where consciousness can be tra | Rank | Aesthetic | How Our Game Delivers It | | ---- | ---- | ---- | -| 1 | **Challenge** | Cultivation tier-up gates, LLM-adaptive dungeon bosses, permanent body death | +| 1 | **Challenge** | Cultivation tier-up gates, time pressure, LLM-adaptive dungeon bosses, permanent body death | | 2 | **Discovery** | MetaDOS lore depth + LLM NPCs revealing world state + emergent agent stories | | 3 | **Fellowship** | 4-20 player zones, guild PvP, agent-to-agent socialization across timezones | @@ -81,7 +81,38 @@ You are a Hunter in a 2050 post-apocalyptic world where consciousness can be tra --- -### Pillar 3: LLM as world citizen, not chatbot +### Pillar 3: Time is life, time is money + +**One-Sentence Definition**: Time is both the current body's remaining operating life and a spendable gameplay currency; earning and spending time creates the core survival-economy tension inherited from MetaDOS and adapted for SECOND SPAWN. + +**Target Aesthetics Served**: Challenge (time pressure), Autonomy (spend/conserve decisions), Discovery (finding time sources and sinks) + +**Design Test**: When debating any economy or survival-pressure feature, this pillar says: time should create meaningful tactical tradeoffs without becoming a pure nuisance timer. + +#### What This Means for Each Department + +| Department | This Pillar Says... | Example | +| ---- | ---- | ---- | +| **Game Design** | Time is not just gold; it is tied to body survival | Spend time at a shrine for supplies, but risk entering the next fight closer to death. | +| **Engineering** | Time mutations are server-authoritative and auditable | Client requests `SpendBodyTime`; server validates zone, cost, and body state before applying. | +| **Narrative** | Synthetic bodies have finite operating life | NPCs talk about bodies "running out of time" as biotech failure, not magic. | +| **Economy** | Keep `BodyTime` and `SECOND token` distinct until an ADR says otherwise | BodyTime creates tactical pressure; SECOND token gates reincarnation. | + +#### Serving This Pillar +- Body time meter exists in danger zones or dungeons +- Player can earn time from combat or objectives +- Player can spend time on one useful service in the vertical slice +- Zero body time triggers body death and reincarnation flow + +#### Violating This Pillar +- Time is only a UI timer with no spend choice +- Time can be bought directly in a pay-to-win loop +- Client-side code grants or spends time without server validation +- Time drain is constant everywhere and makes exploration feel punished + +--- + +### Pillar 4: LLM as world citizen, not chatbot **One-Sentence Definition**: NPCs are first-class actors in the world that remember the player, ground their dialogue in current world state (location, faction, quest progress), and never have the authority to change game state directly - they emit intents that the server validates. @@ -112,7 +143,7 @@ You are a Hunter in a 2050 post-apocalyptic world where consciousness can be tra --- -### Pillar 4: Server-authoritative gameplay (open-source defense) +### Pillar 5: Server-authoritative gameplay (open-source defense) **One-Sentence Definition**: All gameplay logic runs on the dedicated server; the Unity client is a thin UI + input forwarder; the open-source AGPL-3.0 codebase assumes attackers have full source and the architecture must remain secure under that assumption. @@ -145,7 +176,8 @@ You are a Hunter in a 2050 post-apocalyptic world where consciousness can be tra - **NOT a full open-world MMORPG** - we have instance-based zones (~20 players), not seamless single-shard. WoW/FFXIV-scale would blow scope and cost. - **NOT a Chinese cultivation novel game** - cultivation framing is sci-fi (Nibirium, biotech, consciousness transfer). International-friendly. No qi, no immortals, no sect politics as primary loop. -- **NOT pay-to-win** - SECOND token gates reincarnation cost; NFT cosmetic / equipment have utility but are bounded by cultivation tier. NOT power-for-cash. +- **NOT pay-to-win** - SECOND token gates reincarnation cost; `BodyTime` is a gameplay economy and must not become direct power-for-cash. +- **NOT a passive countdown survival game** - time pressure supports ARPG decisions; it does not replace combat, cultivation, or AI agent play. - **NOT a chatbot game** - LLM NPCs are world citizens with grounded memory + rate limits. Dialogue is constrained by quest / faction / location, not free-form roleplay. - **NOT mobile-first** - PC Steam audience first. Mobile companion app is a possible future, not core. - **NOT host-mode multiplayer in production** - Photon Server Mode dedicated only. Host Mode is dev-only. @@ -160,8 +192,9 @@ When pillars conflict, use this priority order. Higher-priority pillars win when | ---- | ---- | ---- | | 1 | **Server-authoritative gameplay** | If we lose this, the public AGPL-3.0 codebase becomes a cheat tutorial; everything else collapses. | | 2 | **AI agent 24/7** | This is the unique hook the entire concept is built on; remove it and SECOND SPAWN is just another ARPG. | -| 3 | **LLM as world citizen** | This delivers the AI agent feel + NPC depth; without it, both pillar 2 and the discovery aesthetic suffer. | -| 4 | **Reincarnation, not respawn** | Critical to identity but tunable; we could ship with weak reincarnation if the other pillars are strong, less true in reverse. | +| 3 | **Reincarnation, not respawn** | Critical to identity and death loop; time expiration and body death depend on this remaining meaningful. | +| 4 | **Time is life, time is money** | Signature MetaDOS lineage mechanic; must support death pressure without overpowering the rest of the game. | +| 5 | **LLM as world citizen** | This delivers the AI agent feel + NPC depth; without it, both pillar 2 and the discovery aesthetic suffer. | **Resolution Process**: 1. Identify which pillars are in tension @@ -177,25 +210,25 @@ When pillars conflict, use this priority order. Higher-priority pillars win when | Need | Which Pillar Serves It | How | | ---- | ---- | ---- | | **Autonomy** | AI agent 24/7 | Player chooses what to delegate to agent vs play actively | -| **Competence** | Reincarnation + Server-authoritative | Cultivation tier ladder is the explicit mastery measure; server-validated combat means skill-based wins are real | +| **Competence** | Reincarnation + Time-as-currency + Server-authoritative | Cultivation tier ladder is the explicit mastery measure; time tradeoffs and server-validated combat make skill-based wins real | | **Relatedness** | LLM as world citizen + AI agent | NPCs remember player; agents bridge social distance across timezones | -All three SDT needs covered. ✓ +All three SDT needs covered. --- ## Pillar Validation Checklist -- [x] **Count**: 4 pillars (within 3-5 target) +- [x] **Count**: 5 pillars (within 3-5 target) - [x] **Falsifiable**: each makes a testable claim - [x] **Constraining**: each forces saying no to specific common patterns - [x] **Cross-departmental**: design / engineering / narrative / security tables filled - [x] **Design-tested**: each has a concrete decision test -- [x] **Anti-pillars defined**: 6 explicit "NOT" statements +- [x] **Anti-pillars defined**: 7 explicit "NOT" statements - [x] **Priority-ranked**: clear conflict-resolution order - [x] **MDA-aligned**: pillars serve top 3 aesthetics (Challenge, Discovery, Fellowship) - [x] **SDT coverage**: Autonomy, Competence, Relatedness all served -- [x] **Memorable**: 4 pillars, each one phrase +- [x] **Memorable**: 5 pillars, each one phrase - [x] **Core fantasy served**: every pillar traces back to "your character has a life that does not pause" --- diff --git a/docs/design/02-vertical-slice-spec.md b/docs/design/02-vertical-slice-spec.md index b6d8584..b369faa 100644 --- a/docs/design/02-vertical-slice-spec.md +++ b/docs/design/02-vertical-slice-spec.md @@ -10,7 +10,7 @@ ## Validation Question -Can a solo player, in their first 30 minutes of unguided play in a single zone, experience all 3 core USPs (AI agent autoplay, reincarnation, cultivation tier-up) AND can a 1-person team (JOY + Claude Code AI agent) build this slice at representative quality in 3-6 months on the chosen tech stack (Unity 6.5 beta + Photon Fusion 2 + Supabase + Go gateway + thirdweb)? +Can a solo player, in their first 30 minutes of unguided play in a single zone, experience the signature hooks (AI agent autoplay, reincarnation, time-as-currency, cultivation tier-up) AND can a 1-person team (JOY + AI agents) build this slice at representative quality in 3-6 months on the chosen tech stack (Unity 6.5 beta + Photon Fusion 2 + Supabase + Go gateway + thirdweb)? This is two questions in one: **does the design loop fun?** AND **is the architecture buildable?** @@ -26,6 +26,7 @@ This is two questions in one: **does the design loop fun?** AND **is the archite | **Boss with LLM dialogue** | 1 (Convai-driven, grounded in zone state) | | **Quest line** | 1 (3-5 quests sequential) | | **Reincarnation MVP** | Die -> SECOND token (test token, not real DOS Chain) -> respawn with reset equipment, partial cultivation tier carryover | +| **Time-as-currency MVP** | Body time meter, earn time from a small objective, spend time on one useful service, zero time triggers reincarnation placeholder | | **AI agent autoplay** | Simple: agent farms one designated area when player offline. Visible activity log on return. | | **Cultivation tiers** | 2 of 6 playable (Awakening + Enhancement only) | | **NFT Hunter skin** | 1 skin equip flow + escrow contract (test net DOS Chain) | @@ -59,6 +60,7 @@ The slice is considered "done" when ALL of the following are true and verified b - [ ] A first-time player can complete the full quest line in 30-60 minutes without out-of-game tutorials - [ ] At least one playtester comments unprompted on the AI agent activity log being noticeable / interesting - [ ] At least one playtester deliberately dies to test reincarnation, observes that cultivation tier carries over +- [ ] At least one playtester notices time-as-currency as a meaningful tradeoff, not just a timer - [ ] LLM boss dialogue does NOT feel chatbot-y - testers believe the boss "knows" current zone state ### Technical (verifiable in code + tests) @@ -73,7 +75,7 @@ The slice is considered "done" when ALL of the following are true and verified b ### Process (verifiable in repo state) - [ ] All slice work merged to `main` via PR with `code-review` skill pass before merge (per JOY hard rule #4). - [ ] All ADRs that the slice motivated are written in `docs/adr/` (current count: 4; expect 6-10 by slice complete). -- [ ] Per-system GDDs in `docs/design/` for Combat, AI agent, Reincarnation, NFT escrow, LLM NPC. (Currently only 04-cultivation-system.md drafted.) +- [ ] Per-system GDDs in `docs/design/` for Combat, AI agent, Reincarnation, Time-as-currency, NFT escrow, LLM NPC. (Cultivation and Time-as-currency are drafted.) - [ ] Vertical Slice Report (`02-vertical-slice-report.md`) written with build velocity, playtest data, recommendation. --- @@ -83,13 +85,14 @@ The slice is considered "done" when ALL of the following are true and verified b | Phase | Target Weeks | Output | | ---- | ---- | ---- | | 1. Setup + first commit | T+0 to T+1 | Unity project + Photon SDK + Supabase + Go gateway scaffold + repo structure | -| 2. Networked player + zone | T+1 to T+4 | 1 zone Photon Fusion 2 multiplayer, Hunter skin spawn, ARPG controller (Opsive UCC) | +| 2. Networked player + zone | T+1 to T+4 | 1 zone Photon Fusion 2 multiplayer, Hunter skin spawn, minimal ARPG controller first, Opsive UCC evaluated after baseline | | 3. NPC + LLM dialogue | T+4 to T+8 | Convai NPC in hub town, server-validated intent flow | | 4. Quest + dungeon | T+8 to T+12 | 1 quest line + 1 dungeon + 1 boss (LLM dialogue) | | 5. Cultivation + reincarnation | T+12 to T+16 | Tier 1+2 mechanics, death -> SECOND token -> reincarnation flow | -| 6. NFT integration | T+16 to T+20 | Hunter skin equip + escrow on DOS Chain test net via thirdweb | -| 7. AI agent offline | T+20 to T+24 | Server-side agent that farms designated area for offline player | -| 8. Polish + playtest | T+24 to T+26 | Bug fixes, playtest sessions, vertical slice report | +| 6. Time-as-currency | T+16 to T+18 | Body time meter, one earn source, one spend sink, zero-time reincarnation trigger | +| 7. NFT integration | T+18 to T+22 | Hunter skin equip + escrow on DOS Chain test net via thirdweb | +| 8. AI agent offline | T+22 to T+25 | Server-side agent that farms designated area for offline player | +| 9. Polish + playtest | T+25 to T+27 | Bug fixes, playtest sessions, vertical slice report | These are estimates. Real velocity will be measured during slice and updated. @@ -100,7 +103,7 @@ These are estimates. Real velocity will be measured during slice and updated. When slice is complete, decision tree: - **PROCEED** to alpha milestone if: all acceptance criteria met, playtest sentiment positive, build velocity sustainable. -- **PIVOT** to revised design if: technical works but core loop falls flat (e.g., AI agent autoplay feels invisible, reincarnation feels punishing). +- **PIVOT** to revised design if: technical works but core loop falls flat (e.g., AI agent autoplay feels invisible, reincarnation feels punishing, time-as-currency feels like a nuisance timer). - **KILL** if: tech stack proves unworkable solo (e.g., LLM cost runs 10x budget, dedicated server hosting infeasible). --- @@ -110,6 +113,7 @@ When slice is complete, decision tree: | Decision | Phase Blocked | JOY Owner | | ---- | ---- | ---- | | SECOND token economy (cost per reincarnation, source, sink) | Phase 5 | JOY (input later) | +| BodyTime economy tuning (drain, earn, spend, transfer, conversion rules) | Phase 6 | JOY (input later) | | Hunter NFT integration approach (Option 1 vs Hybrid 1+3) | Phase 6 | JOY (input later) | | Voice NPC vendor | NOT in slice | Defer | | Hetzner VPS specs | Phase 8 (load test) | JOY | diff --git a/docs/design/03-systems-index.md b/docs/design/03-systems-index.md index f55fa42..e4b63cf 100644 --- a/docs/design/03-systems-index.md +++ b/docs/design/03-systems-index.md @@ -9,13 +9,14 @@ SECOND SPAWN is a hybrid MMO + top-down ARPG. The mechanical scope spans: -- ARPG core (combat, movement, character controller via Opsive UCC) +- ARPG core (combat, movement, minimal controller baseline first; Opsive UCC evaluated after baseline) - Multiplayer networking (Photon Fusion 2 dedicated server) - Persistence (Supabase Postgres + Realtime side-channel) - LLM NPCs (Convai phase 1, Go gateway phase 2) - AI agent autoplay (server-side, capability-capped) - Cultivation 6-tier progression - Reincarnation loop (death -> SECOND token -> new body) +- Time-as-currency body lifespan economy - NFT integration (DOS Chain via thirdweb) - Server-authoritative invariants (anti-cheat assumes open source) @@ -28,7 +29,7 @@ This index enumerates every system the game needs, categorizes by Core/Gameplay/ | # | System | Category | Priority | Status | Design Doc | Depends On | | --- | ---- | ---- | ---- | ---- | ---- | ---- | | 1 | NetworkRunner / Photon Fusion 2 setup | Core | MVP | Not started | (TDD pending) | - | -| 2 | Player Controller (Opsive UCC integration) | Core | MVP | Not started | (TDD pending) | NetworkRunner | +| 2 | Player Controller (minimal baseline, Opsive UCC evaluation later) | Core | MVP | Drafted | [07-player-controller-prototype.md](07-player-controller-prototype.md) | NetworkRunner | | 3 | Camera (top-down ARPG) | Core | MVP | Not started | (TDD pending) | Player Controller | | 4 | Input system (Unity Input System) | Core | MVP | Not started | - | Player Controller | | 5 | Zone scene management (1 zone vertical slice) | Core | MVP | Not started | (TDD pending) | NetworkRunner | @@ -41,6 +42,7 @@ This index enumerates every system the game needs, categorizes by Core/Gameplay/ | 12 | Cultivation 6-tier (slice: tier 1-2) | Progression | MVP | Drafted | [04-cultivation-system.md](04-cultivation-system.md) | Persistence | | 13 | Reincarnation flow (death -> SECOND -> new body) | Progression | VS | Not started | (TDD pending) | Cultivation, NFT escrow, Persistence | | 14 | SECOND token economy | Economy | VS | Not designed | (GDD pending - JOY input) | DOS Chain integration | +| 36 | Time-as-currency (`BodyTime`) | Economy | VS | Drafted | [08-time-as-currency.md](08-time-as-currency.md) | Reincarnation, Combat, Persistence | | 15 | NFT inventory (Hunter skin slice scope) | Economy | VS | Not started | (TDD pending) | thirdweb-api MCP, Persistence | | 16 | NFT escrow (lock on equip, release on unequip) | Economy | VS | Not started | (TDD pending) | NFT inventory, DOS Chain | | 17 | Loot / drop tables | Economy | VS | Not started | (TDD pending) | Combat, persistence | @@ -63,7 +65,7 @@ This index enumerates every system the game needs, categorizes by Core/Gameplay/ | 34 | Telemetry / monitoring (Sentry + Grafana) | Meta | Alpha | Deferred | - | All systems | | 35 | Onboarding / tutorial | Meta | VS | Deferred (assume slice = no tutorial) | - | All gameplay systems | -**Total: 35 systems identified for slice scope.** +**Total: 36 systems identified for slice scope.** --- @@ -74,7 +76,7 @@ This index enumerates every system the game needs, categorizes by Core/Gameplay/ | **Core** | Foundation systems everything depends on | 5 (NetworkRunner, Controller, Camera, Input, Zone management) | | **Gameplay** | The systems that make the game fun | 6 (Combat, NPC dialogue, Quest, Dungeon, Boss LLM, AI agent) | | **Progression** | How the player grows over time | 2 (Cultivation, Reincarnation) | -| **Economy** | Resource creation and consumption | 4 (SECOND token, NFT inventory, NFT escrow, Loot) | +| **Economy** | Resource creation and consumption | 5 (SECOND token, Time-as-currency, NFT inventory, NFT escrow, Loot) | | **Persistence** | Save state and continuity | 5 (Profile, Inventory, Quest, Cultivation, Auth) | | **UI** | Player-facing information displays | 6 (HUD, Inventory UI, NPC dialogue UI, Quest tracker, Reincarnation UI, Agent log) | | **Audio** | Sound and music systems | 1 (placeholder for slice) | @@ -95,7 +97,7 @@ This index enumerates every system the game needs, categorizes by Core/Gameplay/ ### Core Layer (depends on foundation) -6. Player Controller / Opsive UCC (#2) - depends on: NetworkRunner +6. Player Controller baseline / Opsive evaluation (#2) - depends on: NetworkRunner 7. Camera (#3) - depends on: Player Controller 8. Input system (#4) - depends on: Player Controller 9. Zone scene management (#5) - depends on: NetworkRunner @@ -118,25 +120,26 @@ This index enumerates every system the game needs, categorizes by Core/Gameplay/ 20. NFT escrow (#16) - depends on: NFT inventory, DOS Chain 21. Loot / drop tables (#17) - depends on: Combat, Persistence 22. Reincarnation flow (#13) - depends on: Cultivation, NFT escrow, Persistence -23. SECOND token economy (#14) - depends on: DOS Chain integration, Reincarnation -24. AI agent for offline players (#11) - depends on: NetworkRunner, LLM gateway, intent schema, Cultivation, Combat +23. Time-as-currency (#36) - depends on: Reincarnation, Combat, Persistence +24. SECOND token economy (#14) - depends on: DOS Chain integration, Reincarnation +25. AI agent for offline players (#11) - depends on: NetworkRunner, LLM gateway, intent schema, Cultivation, Combat, Time-as-currency ### Presentation Layer (depends on features) -25. HUD (#23) -26. Inventory UI (#24) -27. NPC dialogue UI (#25) -28. Quest tracker UI (#26) -29. Reincarnation UI (#27) -30. AI agent activity log UI (#28) -31. Audio (#29) +26. HUD (#23) +27. Inventory UI (#24) +28. NPC dialogue UI (#25) +29. Quest tracker UI (#26) +30. Reincarnation UI (#27) +31. AI agent activity log UI (#28) +32. Audio (#29) ### Meta Layer -32. Anti-cheat verification (#33) - cuts across everything -33. Quest progress persistence (#20) - depends on: Quest system -34. Telemetry (#34) - depends on: everything -35. Onboarding (#35) - depends on: all gameplay (deferred for slice) +33. Anti-cheat verification (#33) - cuts across everything +34. Quest progress persistence (#20) - depends on: Quest system +35. Telemetry (#34) - depends on: everything +36. Onboarding (#35) - depends on: all gameplay (deferred for slice) --- @@ -148,6 +151,7 @@ This index enumerates every system the game needs, categorizes by Core/Gameplay/ | LLM intent validation (#31) + safety (#32) | Security | Open-source codebase + LLM = injection / abuse vector | Reuse DOSafe patterns; per-NPC memory cap; per-player rate limit | | NFT escrow (#16) | Technical | Latency between Unity equip action and DOS Chain confirmation | Optimistic UI + reconcile-on-failure; cache lock state in Supabase | | Reincarnation flow (#13) | Design | Cultivation carryover too generous = no death weight; too punitive = grind | Tune cost during slice playtests | +| Time-as-currency (#36) | Design + Economy | Constant drain can feel oppressive; weak drain can feel invisible | Start with danger-zone drain, one earn source, one spend sink | | Photon Fusion 2 dedicated server (#1) | Technical | Solo dev capacity to run dedicated infra | Slice uses Photon Cloud free 20 CCU; production migration is post-slice | | Convai SDK in Unity (#7) | Technical | 3rd-party SDK may not test against Unity 6.5 beta | Have phase 2 fallback (Go gateway + custom LLM) ready in design | @@ -162,7 +166,7 @@ Aligned with [02-vertical-slice-spec.md](02-vertical-slice-spec.md) build phases | 1 | NetworkRunner setup (#1) | Phase 1 | M | Reference MetaDOS BR template | | 2 | Auth (#22) | Phase 1 | M | Reuse DOS.Me Supabase pattern | | 3 | Profile persistence (#18) | Phase 1 | S | | -| 4 | Player Controller / Opsive UCC (#2) | Phase 2 | L | 3rd-party integration; verify Unity 6.5 beta compatibility | +| 4 | Player Controller baseline / Opsive evaluation (#2) | Phase 2 | M | Build minimal Fusion controller first; evaluate Opsive in isolation after baseline | | 5 | Camera + Input (#3, #4) | Phase 2 | S | Standard URP | | 6 | Zone scene management (#5) | Phase 2 | M | | | 7 | Combat (#6) | Phase 2 | L | Server-authoritative critical | @@ -174,13 +178,14 @@ Aligned with [02-vertical-slice-spec.md](02-vertical-slice-spec.md) build phases | 13 | Boss LLM dialogue (#10) | Phase 4 | M | Layered on NPC dialogue | | 14 | Cultivation system (#12) | Phase 5 | L | GDD already drafted at `04-cultivation-system.md` | | 15 | Reincarnation flow (#13) | Phase 5 | L | | -| 16 | NFT inventory (#15) + escrow (#16) | Phase 6 | L | DOS Chain test net | -| 17 | SECOND token economy (#14) | Phase 6 | M | JOY input required first | -| 18 | AI agent for offline players (#11) | Phase 7 | XL | Highest-risk system | -| 19 | UI cluster (#23-#28) | Throughout phases 2-7 | XL | Build incrementally | -| 20 | Audio placeholder (#29) | Phase 8 | S | Slice-quality only | -| 21 | Chat (#30) | Phase 8 | M | Supabase Realtime | -| 22 | Polish + playtest | Phase 8 | XL | | +| 16 | Time-as-currency (#36) | Phase 6 | M | BodyTime meter, one earn source, one spend sink | +| 17 | NFT inventory (#15) + escrow (#16) | Phase 7 | L | DOS Chain test net | +| 18 | SECOND token economy (#14) | Phase 7 | M | JOY input required first | +| 19 | AI agent for offline players (#11) | Phase 8 | XL | Highest-risk system | +| 20 | UI cluster (#23-#28) | Throughout phases 2-8 | XL | Build incrementally | +| 21 | Audio placeholder (#29) | Phase 9 | S | Slice-quality only | +| 22 | Chat (#30) | Phase 9 | M | Supabase Realtime | +| 23 | Polish + playtest | Phase 9 | XL | | Effort estimate: S = 1-3 days, M = 4-7 days, L = 1-2 weeks, XL = 2-4 weeks (solo dev + AI agent). @@ -190,8 +195,8 @@ Effort estimate: S = 1-3 days, M = 4-7 days, L = 1-2 weeks, XL = 2-4 weeks (solo | Metric | Count | | ---- | ---- | -| Total systems identified | 35 | -| Design docs started | 1 (cultivation) | +| Total systems identified | 36 | +| Design docs started | 4 (cultivation, overview design, player controller prototype, time-as-currency) | | Design docs reviewed | 0 | | Design docs approved | 0 | | MVP systems with TDD started | 0 | diff --git a/docs/design/06-overview-design.md b/docs/design/06-overview-design.md new file mode 100644 index 0000000..1d52b5b --- /dev/null +++ b/docs/design/06-overview-design.md @@ -0,0 +1,158 @@ +# Overview Design: SECOND SPAWN Vertical Slice + +*Status: Draft* +*Created: 2026-05-14* +*Author: Codex* +*Last Verified: 2026-05-14 against `AGENTS.md`, `00-game-concept.md`, `01-pillars.md`, `02-vertical-slice-spec.md`, and `05-networking-architecture.md`* + +> **Quick reference** - Layer: `Core` - Priority: `Vertical Slice` - Key deps: `Photon Fusion 2`, `Supabase`, `Go gateway`, `DOS Chain`, `Convai phase 1` + +--- + +## Purpose + +This document is the short "what are we building" design anchor for the first playable SECOND SPAWN prototype and vertical slice. It does not replace the concept, pillars, architecture, or per-system GDDs. It gives agents a single product-shape overview before they write code, import Unity assets, or propose scope. + +--- + +## One-Sentence Game + +SECOND SPAWN is a near-future, post-apocalyptic, top-down ARPG MMO where a player's character keeps living through an AI agent while the player is offline, time acts as both life and currency, and death transfers consciousness into a new synthetic body instead of simply respawning. + +--- + +## Target First Playable Experience + +The first prototype should prove that the game can become a multiplayer ARPG before it tries to prove every unique system at once. + +The intended first playable loop is: + +1. Player opens `ZoneTest_Hub`. +2. Photon Fusion starts a local development session. +3. A placeholder networked player spawns. +4. The player moves in top-down ARPG style. +5. A top-down camera follows the controlled character. +6. The scene remains console-clean. + +This is intentionally smaller than the vertical slice. It is the foundation that makes the vertical slice believable. + +--- + +## Vertical Slice Promise + +The 3-6 month vertical slice must let a solo player experience the identity-defining hooks within one compact zone: + +| Hook | Slice Expression | +| ---- | ---- | +| AI agent 24/7 | The offline agent farms one designated area and produces an activity log visible when the player returns. | +| Reincarnation | Death consumes a test SECOND token and transfers consciousness to a new body with partial cultivation carryover. | +| Time-as-currency | The current body has a time budget that can be earned, spent, and depleted to trigger death pressure. | +| Consciousness transfer | The death and respawn flow is framed as synthetic-body transfer, not spiritual resurrection. | + +The slice does not need large content volume. It needs a tight loop that proves the game's identity. + +--- + +## Design Priorities + +1. **Server-authoritative foundation first.** If movement and combat are not server-owned, the open-source multiplayer design collapses. +2. **Playable feel before asset complexity.** A placeholder capsule that moves correctly is more valuable than an imported controller that fights the networking model. +3. **One zone, one player archetype, one loop.** Every system should serve the single-zone vertical slice before generalizing. +4. **AI systems must be grounded in game state.** LLMs can talk, reason, and suggest intents; they cannot grant state directly. +5. **Reincarnation must feel like identity transfer, not a respawn button.** The prototype should preserve that framing even before the full economy exists. + +--- + +## Player Controller Direction + +The current direction is **minimal networked controller first, Opsive evaluation second**. + +Opsive Ultimate Character Controller is already purchased and may still be useful for combat, animation, ability handling, or camera tooling. It is not currently treated as mandatory for the first prototype because: + +- Fusion authority and input flow must be proven before adding a heavy third-party controller. +- Top-down ARPG movement may be simpler than the full Opsive UCC feature set. +- Unity 6.5 beta compatibility must be validated before betting the prototype on it. + +The first prototype should create a small, project-owned movement contract. Opsive can then be imported and judged against that contract. + +--- + +## Prototype Layers + +| Layer | Prototype Target | Notes | +| ---- | ---- | ---- | +| Network | Fusion Host Mode dev session, Server Mode preserved as production direction | Host Mode is dev-only. | +| Player | One networked placeholder player with top-down movement | No combat yet. | +| Camera | Top-down follow camera | Can be simple Cinemachine or custom follow. | +| Zone | `ZoneTest_Hub` only | No dungeon, no multi-zone. | +| Config | `SecondSpawnConfig.asset` exists with public-safe fields | No secrets in Unity. | +| Persistence | Not in first playable | Supabase comes after movement baseline. | +| AI/LLM | Not in first playable | Design must keep path open. | +| Time-as-currency | Not in first playable | First implementation belongs with reincarnation/progression, not movement. | +| NFT | Not in first playable | No chain dependency for movement prototype. | + +--- + +## Out of Scope for First Prototype + +- Opsive UCC import as a dependency for movement baseline +- Behavior Designer +- Convai +- Synty / Quaternius environment art packs +- Combat damage, loot, inventory, or item drops +- Supabase auth or profile persistence +- NFT ownership and escrow +- Offline AI agent behavior +- Dungeon instance +- Reincarnation mechanics +- Time-as-currency economy + +These are still vertical slice systems. They are only excluded from the first playable prototype. + +--- + +## Build Sequence + +| Step | Output | Exit Criteria | +| ---- | ---- | ---- | +| 1 | Minimal networked player controller | Player spawns and moves in Play Mode without console errors. | +| 2 | Top-down camera | Camera follows the local player and keeps the placeholder readable. | +| 3 | Prototype control feel pass | Movement feels crisp enough for ARPG iteration. | +| 4 | Opsive evaluation branch | Import Opsive in isolation and compare value/cost against the baseline. | +| 5 | Combat prototype | Add one basic attack only after movement is stable. | +| 6 | Persistence/auth prototype | Supabase profile and login once local gameplay loop exists. | + +--- + +## Acceptance Criteria + +- [ ] `ZoneTest_Hub` can enter Play Mode with no errors. +- [ ] One local player spawns through Fusion. +- [ ] The player can move in a top-down plane with responsive input. +- [ ] The camera follows the controlled player without jitter or disorientation. +- [ ] The implementation keeps server-authoritative movement boundaries explicit. +- [ ] No gameplay-affecting API key or secret is stored in Unity. +- [ ] The prototype can be reviewed without importing any large third-party asset. + +--- + +## Open Questions + +| Question | Owner | Timing | Current Lean | +| ---- | ---- | ---- | ---- | +| WASD, click-to-move, or both for first prototype? | JOY | Before movement polish | WASD first, click-to-move later. | +| Use Cinemachine for camera now? | Codex | During camera task | Use it if already available; otherwise simple custom follow. | +| Does Opsive become core or optional? | Codex + Claude reviewer | After isolated Opsive branch | Optional until proven worth the integration cost. | +| What is the first Hunter visual? | JOY | After movement baseline | Placeholder until MetaDOS skin import path is reviewed. | + +--- + +## Cross-References + +| This Document References | Target Doc | Specific Element Referenced | Nature | +| ---- | ---- | ---- | ---- | +| Core identity | `00-game-concept.md` | Elevator pitch and core fantasy | Design dependency | +| Pillar priority | `01-pillars.md` | Server-authoritative gameplay, AI agent 24/7, reincarnation, time-as-currency | Rule dependency | +| Slice scope | `02-vertical-slice-spec.md` | Build phases and acceptance criteria | Scope dependency | +| Systems order | `03-systems-index.md` | NetworkRunner, Player Controller, Camera, Input | Build dependency | +| Network contract | `05-networking-architecture.md` | NetworkRunnerSetup, NetworkPlayer, NetworkInputProvider | Technical dependency | diff --git a/docs/design/07-player-controller-prototype.md b/docs/design/07-player-controller-prototype.md new file mode 100644 index 0000000..cbb93e5 --- /dev/null +++ b/docs/design/07-player-controller-prototype.md @@ -0,0 +1,202 @@ +# Prototype Design: Networked Player Controller + +*Status: Draft* +*Created: 2026-05-14* +*Author: Codex* +*Last Verified: 2026-05-14 against Phase B Fusion smoke test and `05-networking-architecture.md`* + +> **Quick reference** - Layer: `Core` - Priority: `MVP` - Key deps: `Photon Fusion 2`, `Unity Input System`, `ZoneTest_Hub`, `SecondSpawnConfig` + +--- + +## Purpose + +This prototype proves the smallest useful SECOND SPAWN gameplay foundation: a network-spawned player can move in a top-down ARPG scene under Fusion authority, with a readable camera and no Unity console errors. + +This is not the combat system. This is the movement and authority contract that combat, cultivation, reincarnation, AI agent control, and future asset-controller integrations must respect. + +--- + +## Design Decision + +Build a **project-owned minimal networked controller first**. Evaluate Opsive Ultimate Character Controller only after this baseline is working. + +Rationale: + +- The game is open-source multiplayer, so authority rules are more important than controller feature depth. +- The first prototype needs known behavior that agents can reason about. +- Opsive may still be useful, but it should prove value against a working baseline rather than become the baseline by default. + +--- + +## Player Experience + +The prototype should feel like the first rough blockout of a top-down ARPG: + +- The player appears in the hub test scene. +- Input response is immediate enough to judge direction and momentum. +- Movement is planar, readable, and camera-stable. +- The player can stop, turn, and move diagonally without fighting the controls. +- Multiplayer correctness matters more than animation polish. + +The placeholder avatar can be a cube, capsule, or simple Hunter stand-in. + +--- + +## Scope + +### In Scope + +- Fusion-spawned local player in `ZoneTest_Hub` +- Top-down movement on the XZ plane +- Keyboard movement input +- Local player camera follow +- Basic speed tuning through serialized fields +- Console-clean Play Mode verification +- Clear ownership between client input and authoritative state + +### Out of Scope + +- Combat +- Abilities +- Animation controller +- Opsive UCC dependency +- Click-to-move pathfinding +- NavMesh +- Character stats beyond movement speed +- Remote-player interpolation polish +- Dedicated Server Mode deployment +- Persistence, inventory, NFT, LLM, or AI agent logic + +--- + +## Control Model + +| Input | Behavior | +| ---- | ---- | +| `W` / up | Move forward relative to world north for first prototype. | +| `S` / down | Move backward relative to world south. | +| `A` / left | Move left. | +| `D` / right | Move right. | +| Diagonal input | Normalize movement so diagonal speed is not faster. | +| No input | Stop movement immediately for the first pass. | + +Camera-relative movement can be added after the first pass if the camera angle makes world-relative controls feel wrong. + +--- + +## Authority Contract + +1. The client collects input only. +2. The client sends movement intent through Fusion input. +3. The networked player applies movement in Fusion simulation code. +4. Gameplay state is owned by the server or host authority. +5. The client may render prediction, but it must not become the durable source of position. +6. Future AI agent control must be able to emit the same movement intent shape as a human player. + +--- + +## Components + +| Component | Responsibility | Existing or New | +| ---- | ---- | ---- | +| `NetworkRunnerSetup` | Starts Fusion dev session and owns runner lifecycle. | Existing Phase B | +| `NetworkInputProvider` | Collects movement input per Fusion tick. | Existing Phase B, may extend | +| `NetworkPlayer` | Applies authoritative movement and owns networked player state. | Existing Phase B, may extend | +| `PlayerSpawner` | Spawns one player object per joined player. | Existing Phase B | +| `PlayerCameraFollow` | Keeps camera pointed at local player. | New if no suitable existing component | +| `Player_NetworkCube.prefab` | Placeholder networked player prefab. | Existing Phase B, may evolve | + +--- + +## Movement Tuning + +| Parameter | Initial Value | Safe Range | Notes | +| ---- | ---- | ---- | ---- | +| Move speed | `6.0` units/sec | `3.0 - 10.0` | Fast enough to test ARPG feel in a small hub. | +| Rotation speed | Instant or `720` deg/sec | `360 - 1080` | Instant is acceptable for placeholder. | +| Acceleration | Instant | TBD | Add acceleration only if instant stop/start feels too arcade. | +| Camera height | `12` units | `8 - 18` | Depends on zone blockout scale. | +| Camera pitch | `45 - 60` degrees | `35 - 65` | Must keep player and travel direction readable. | + +These are prototype values, not final game balance. + +--- + +## Implementation Plan + +### Phase 1: Baseline Movement + +- [ ] Review existing `NetworkPlayer`, `NetworkInputProvider`, and `PlayerSpawner`. +- [ ] Keep `Player_NetworkCube.prefab` as the placeholder unless the existing prefab blocks movement. +- [ ] Ensure movement input is normalized. +- [ ] Keep movement code small and obvious. +- [ ] Verify Play Mode spawns one controllable player. + +### Phase 2: Camera Follow + +- [ ] Add or configure a top-down follow camera. +- [ ] Follow only the local player. +- [ ] Avoid camera ownership assumptions that break remote clients. +- [ ] Verify scene view and game view readability. + +### Phase 3: Prototype Feel Pass + +- [ ] Tune speed and camera height. +- [ ] Confirm no diagonal speed exploit. +- [ ] Confirm player stops predictably. +- [ ] Record remaining feel issues in this doc. + +### Phase 4: Opsive Evaluation Branch + +- [ ] Import Opsive UCC in a separate branch/commit only after baseline passes. +- [ ] Check Unity 6.5 beta compatibility and console state. +- [ ] Compare Opsive movement/combat/camera value against the baseline. +- [ ] Decide whether Opsive becomes core, optional, or deferred. + +--- + +## Verification Checklist + +- [ ] Unity opens project without compile errors. +- [ ] `ZoneTest_Hub` enters Play Mode. +- [ ] Fusion runner starts a dev session. +- [ ] Local player spawns through `PlayerSpawner`. +- [ ] WASD moves the local player. +- [ ] Diagonal speed is normalized. +- [ ] Camera follows the local player. +- [ ] Console has 0 errors after entering and exiting Play Mode. +- [ ] No new secret or private key is added to Unity assets. + +--- + +## Known Limitations + +- Movement is not final combat movement. +- Placeholder visuals are acceptable. +- No animation blend tree is expected. +- Dedicated Server Mode is not validated by this prototype. +- Remote-client feel may need a later Multiplayer Play Mode pass. + +--- + +## Follow-Up Design Docs + +| Future Doc | Trigger | +| ---- | ---- | +| Combat Prototype Design | After player movement and camera are stable. | +| Camera Design | If camera behavior becomes deeper than one follow component. | +| Opsive Evaluation Report | After isolated Opsive import and smoke test. | +| Offline AI Agent Movement Contract | Before AI agent can control the same player actor. | + +--- + +## Cross-References + +| This Document References | Target Doc | Specific Element Referenced | Nature | +| ---- | ---- | ---- | ---- | +| Prototype shape | `06-overview-design.md` | Minimal controller first, Opsive evaluation second | Scope dependency | +| Networking rules | `05-networking-architecture.md` | Network input and server authority | Rule dependency | +| Pillar priority | `01-pillars.md` | Server-authoritative gameplay | Rule dependency | +| Unity conventions | `../setup/unity-conventions.md` | Prefab, scene, and asmdef organization | Ownership handoff | + diff --git a/docs/design/08-time-as-currency.md b/docs/design/08-time-as-currency.md new file mode 100644 index 0000000..37c958b --- /dev/null +++ b/docs/design/08-time-as-currency.md @@ -0,0 +1,192 @@ +# Time-as-Currency + +*Status: Draft* +*Created: 2026-05-14* +*Author: Codex* +*Last Verified: 2026-05-14 against MetaDOS wiki and SECOND SPAWN concept docs* +*Implements Pillar: Time is life, time is money* + +> **Quick reference** - Layer: `Core Economy` - Priority: `Vertical Slice` - Key deps: `Reincarnation`, `Cultivation`, `Loot`, `Supabase persistence`, `Server-authoritative gameplay` + +--- + +## Summary + +Time-as-Currency is a signature MetaDOS mechanic adapted for SECOND SPAWN. Time is both a survival resource attached to the current body and a spendable currency used for tactical choices, recovery, and progression pressure. + +In MetaDOS Battle Royale, time was the player's life timer and could be earned from enemies or special locations, then spent on weapons, supplies, or teammates. In SECOND SPAWN, the same concept becomes a persistent ARPG pressure system tied to synthetic-body lifespan, reincarnation, and offline-agent risk. + +--- + +## Design Intent + +The player should feel that time is not an abstract clock. It is the body's remaining operating life. + +Every major decision should quietly ask: + +- Do I spend time now to get stronger? +- Do I conserve time to survive the dungeon? +- Do I risk letting my offline agent farm longer? +- Do I reincarnate now with a controlled cost, or push the current body until it collapses? + +--- + +## Player Fantasy + +Your body is a rented vessel with a countdown built into its biology. You can earn more time by fighting, looting, completing objectives, or extracting Nibirium energy. You can spend time to buy supplies, stabilize allies, power certain systems, or delay body failure. + +The fantasy is not "gold with another name." It is "your life is liquid." + +--- + +## Core Rules + +1. Each active body has a `BodyTime` value. +2. `BodyTime` counts down during active danger states. +3. `BodyTime` can be earned from combat, objective rewards, and special world sources. +4. `BodyTime` can be spent on selected gameplay actions. +5. If `BodyTime` reaches zero, the body dies and reincarnation flow begins. +6. Time changes are server-authoritative. +7. The client never grants or spends time directly. +8. LLM NPCs and offline agents may request time-related actions only through validated intents. + +--- + +## SECOND SPAWN Adaptation + +| MetaDOS Battle Royale Concept | SECOND SPAWN Adaptation | +| ---- | ---- | +| Time is a match survival timer | Time is synthetic-body lifespan and combat pressure. | +| Loot time from knocked-down enemies | Earn time from enemies, Nibirium nodes, quests, dungeon objectives, or cultivation events. | +| Spend time on weapons, ammo, armor, supplies | Spend time on field services, emergency stabilization, dungeon shortcuts, body repairs, or agent automation policies. | +| Give time to knocked-down teammates | Transfer time to party members or stabilize a dying body. | +| Running out of time means death | Running out of time triggers body death and reincarnation. | + +--- + +## Vertical Slice Scope + +The first vertical slice should implement a very small version: + +- One `BodyTime` meter on the player. +- Time decreases only inside a designated danger area or dungeon room. +- Killing enemies or completing a small objective grants time. +- A test vendor or shrine lets the player spend time on one useful service. +- Reaching zero time triggers the reincarnation placeholder flow. +- Offline AI agent activity can consume time while farming. + +No marketplace, tokenomics, or complex exchange rate is required for the first pass. + +--- + +## Currency Model + +`BodyTime` and `SECOND token` are related but not the same thing. + +| Resource | Meaning | Scope | Persistence | +| ---- | ---- | ---- | ---- | +| `BodyTime` | Remaining operating life of the current body | Moment-to-moment gameplay | Dies with the body unless converted by a rule | +| `SECOND token` | Reincarnation and economy token | Account / wallet economy | Persists across bodies | +| Cultivation tier | Durable consciousness progression | Character identity | Partially carries across reincarnation | + +Design rule: `BodyTime` creates tactical pressure. `SECOND token` gates reincarnation economy. Do not merge them unless a future ADR explicitly changes the economy. + +--- + +## Spend Candidates + +| Spend | Slice? | Notes | +| ---- | ---- | ---- | +| Stabilize self near death | Yes | Gives the player a clear reason to spend time. | +| Stabilize party member | Later | Needs party flow. | +| Buy emergency supplies | Yes | Use one simple vendor/shrine. | +| Open dungeon shortcut | Later | Good for risk/reward once dungeon layout exists. | +| Extend offline agent session | Later | Strong tie to AI agent 24/7, but too risky for first implementation. | +| Body repair / delay reincarnation | Later | Must not erase the reincarnation pillar. | + +--- + +## Earn Candidates + +| Earn Source | Slice? | Notes | +| ---- | ---- | ---- | +| Enemy kill | Yes | Keep value small and server-owned. | +| Objective completion | Yes | Clear reward moment. | +| Nibirium node | Later | Connects to cultivation economy. | +| Quest reward | Later | Needs quest persistence. | +| Party assist | Later | Needs party and contribution rules. | +| Offline agent farming | Later | Needs abuse limits and activity log. | + +--- + +## Server Authority + +Every time mutation must be validated by server-side code. + +Allowed intent shapes: + +- `RequestSpendBodyTime` +- `RequestTransferBodyTime` +- `RequestClaimBodyTimeReward` +- `RequestReincarnateAfterTimeExpired` + +The server verifies: + +- Player identity and body ownership. +- Current zone and danger state. +- Source of reward or spend. +- Cooldowns and anti-abuse limits. +- Whether the body is already dead or reincarnating. + +--- + +## AI Agent Interaction + +Offline agents can interact with time only through the same validated intent model as human players. + +Design constraints: + +- Agent cannot spend time on irreversible actions without a policy chosen by the player. +- Agent activity should make time risk visible in the return log. +- Agent should not farm infinite time. Earn and spend rates must be capped. +- Agent death from time expiration triggers the same reincarnation flow as player death. + +--- + +## UI Requirements + +| Information | Display Location | Update Frequency | Condition | +| ---- | ---- | ---- | ---- | +| Current `BodyTime` | HUD | Real-time or tick-smoothed | Always in danger zones, optional in safe hub | +| Time gain/loss events | Floating text + log | On event | Combat, vendor, shrine, objective | +| Time spend confirmation | Modal or hold-to-confirm | On irreversible spend | Any spend that can cause death risk | +| Time expiration warning | HUD pulse + audio | Threshold based | Under 20%, under 10%, under 5% | +| Agent time usage | Activity log | On player return | Offline agent session summary | + +--- + +## Open Questions + +| Question | Owner | Timing | Current Lean | +| ---- | ---- | ---- | ---- | +| Does `BodyTime` tick down everywhere or only in danger zones? | JOY | Before implementation | Danger zones first. | +| Can players transfer time in solo slice? | JOY | Before party feature | Defer until party exists. | +| Can `BodyTime` convert to `SECOND token` or vice versa? | JOY | Economy design phase | Keep separate for now. | +| How visible should the `In Time` inspiration be? | JOY | Narrative pass | Mechanic inspiration only, not direct theme copy. | + +--- + +## Cross-References + +| This Document References | Target Doc | Specific Element Referenced | Nature | +| ---- | ---- | ---- | ---- | +| Game identity | `00-game-concept.md` | Unique hooks and MetaDOS lineage | Design dependency | +| Pillar rules | `01-pillars.md` | Time-as-currency, server-authoritative gameplay | Rule dependency | +| Slice scope | `02-vertical-slice-spec.md` | Reincarnation and AI agent acceptance | Scope dependency | +| Systems map | `03-systems-index.md` | Economy and reincarnation systems | Build dependency | +| Cultivation | `04-cultivation-system.md` | Durable progression across reincarnation | Data dependency | + +## External References + +- [MetaDOS Wiki: Time as Currency](https://wiki.metados.com/gameplay/time-as-currency) - original MetaDOS mechanic where time is both survival timer and currency. +- `In Time` (2011) - mechanic inspiration for time as life and spendable resource. SECOND SPAWN adapts the idea through synthetic-body lifespan and does not copy the film's setting. From 59afa126654d06eaba80fe5fe2496c694ef2f026 Mon Sep 17 00:00:00 2001 From: JOY Date: Thu, 14 May 2026 22:43:29 +0700 Subject: [PATCH 06/14] fix(networking): map Fusion player objects --- .../_SecondSpawn/Scripts/Networking/PlayerSpawner.cs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/PlayerSpawner.cs b/Unity/Assets/_SecondSpawn/Scripts/Networking/PlayerSpawner.cs index 286700e..86a5783 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/Networking/PlayerSpawner.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/PlayerSpawner.cs @@ -8,7 +8,6 @@ namespace SecondSpawn.Networking /// /// Server-authoritative player spawner. On /// , the server spawns the configured - /// player prefab with input authority assigned to the joining player /// player prefab with input authority assigned to the joining player. /// /// Per docs/design/05-networking-architecture.md + Pillar @@ -57,7 +56,8 @@ public void OnPlayerJoined(NetworkRunner runner, PlayerRef player) } var spawnPos = ComputeSpawnPosition(_spawnCounter); - runner.Spawn(_playerPrefab, spawnPos, Quaternion.identity, player); + var playerObject = runner.Spawn(_playerPrefab, spawnPos, Quaternion.identity, player); + runner.SetPlayerObject(player, playerObject); _spawnCounter++; Debug.Log($"[PlayerSpawner] Spawned player cube for {player} at {spawnPos}"); } @@ -65,13 +65,9 @@ public void OnPlayerJoined(NetworkRunner runner, PlayerRef player) public void OnPlayerLeft(NetworkRunner runner, PlayerRef player) { if (!runner.IsServer) return; - foreach (var no in runner.GetAllNetworkObjects()) + if (runner.TryGetPlayerObject(player, out var playerObject)) { - if (no.InputAuthority == player) - { - runner.Despawn(no); - break; - } + runner.Despawn(playerObject); } } From 9a4a6098833a751e338ead125778e1710f9bb226 Mon Sep 17 00:00:00 2001 From: JOY Date: Thu, 14 May 2026 22:46:18 +0700 Subject: [PATCH 07/14] ci: scope markdown lint to public docs --- .github/workflows/markdown-lint.yml | 10 +++++++--- .markdownlint.json | 6 ++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/markdown-lint.yml b/.github/workflows/markdown-lint.yml index 1548b91..0e15085 100644 --- a/.github/workflows/markdown-lint.yml +++ b/.github/workflows/markdown-lint.yml @@ -23,6 +23,10 @@ jobs: uses: DavidAnson/markdownlint-cli2-action@v16 with: globs: | - **/*.md - !.claude/templates/** - !node_modules + README.md + ROADMAP.md + CONTRIBUTING.md + SECURITY.md + AGENTS.md + .claude/CLAUDE.md + docs/**/*.md diff --git a/.markdownlint.json b/.markdownlint.json index d20d1f0..3d069fb 100644 --- a/.markdownlint.json +++ b/.markdownlint.json @@ -1,7 +1,13 @@ { "default": true, "MD013": false, + "MD012": false, "MD033": false, + "MD034": false, "MD041": false, + "MD022": false, + "MD029": false, + "MD032": false, + "MD040": false, "MD024": { "siblings_only": true } } From 7fa4b6c21797c4e4283b2ecd595ae8335b11b7ef Mon Sep 17 00:00:00 2001 From: JOY Date: Thu, 14 May 2026 22:48:14 +0700 Subject: [PATCH 08/14] ci: relax markdown emphasis heading rule --- .markdownlint.json | 1 + 1 file changed, 1 insertion(+) diff --git a/.markdownlint.json b/.markdownlint.json index 3d069fb..d9baf2f 100644 --- a/.markdownlint.json +++ b/.markdownlint.json @@ -4,6 +4,7 @@ "MD012": false, "MD033": false, "MD034": false, + "MD036": false, "MD041": false, "MD022": false, "MD029": false, From 7de25cd814869ba032ef3697968ff0a8463ad78e Mon Sep 17 00:00:00 2001 From: JOY Date: Fri, 15 May 2026 19:59:57 +0700 Subject: [PATCH 09/14] feat(controller): add Simple KCC controller base --- .claude/CLAUDE.md | 6 +- .gitignore | 4 + AGENTS.md | 6 +- Unity/Assets/Photon/FusionAddons.meta | 8 + .../Assets/Photon/FusionAddons/SimpleKCC.meta | 8 + .../Fusion.Addons.SimpleKCC.Editor.dll | 3 + .../Fusion.Addons.SimpleKCC.Editor.dll.meta | 88 +++++++++ .../SimpleKCC/Fusion.Addons.SimpleKCC.dll | 3 + .../Fusion.Addons.SimpleKCC.dll.meta | 33 ++++ .../SimpleKCC/release_history.txt | 96 ++++++++++ .../SimpleKCC/release_history.txt.meta | 7 + .../SimpleKCC/simple_kcc_build_info.txt | 3 + .../SimpleKCC/simple_kcc_build_info.txt.meta | 7 + .../Prefabs/Player_NetworkCube.prefab | 97 +++++++--- .../Scripts/Gameplay/PlayerController.cs | 14 +- .../Scripts/Networking/NetworkPlayer.cs | 42 +++-- .../Networking/SecondSpawn.Networking.asmdef | 1 + docs/README.md | 8 +- docs/SUMMARY.md | 3 +- docs/design/03-systems-index.md | 8 +- docs/design/06-overview-design.md | 13 +- docs/design/07-player-controller-prototype.md | 33 ++-- .../09-pirate-adventure-reference-review.md | 172 ++++++++++++++++++ docs/setup/fusion-install.md | 17 +- docs/setup/paid-assets.md | 51 ++++++ 25 files changed, 654 insertions(+), 77 deletions(-) create mode 100644 Unity/Assets/Photon/FusionAddons.meta create mode 100644 Unity/Assets/Photon/FusionAddons/SimpleKCC.meta create mode 100644 Unity/Assets/Photon/FusionAddons/SimpleKCC/Fusion.Addons.SimpleKCC.Editor.dll create mode 100644 Unity/Assets/Photon/FusionAddons/SimpleKCC/Fusion.Addons.SimpleKCC.Editor.dll.meta create mode 100644 Unity/Assets/Photon/FusionAddons/SimpleKCC/Fusion.Addons.SimpleKCC.dll create mode 100644 Unity/Assets/Photon/FusionAddons/SimpleKCC/Fusion.Addons.SimpleKCC.dll.meta create mode 100644 Unity/Assets/Photon/FusionAddons/SimpleKCC/release_history.txt create mode 100644 Unity/Assets/Photon/FusionAddons/SimpleKCC/release_history.txt.meta create mode 100644 Unity/Assets/Photon/FusionAddons/SimpleKCC/simple_kcc_build_info.txt create mode 100644 Unity/Assets/Photon/FusionAddons/SimpleKCC/simple_kcc_build_info.txt.meta create mode 100644 docs/design/09-pirate-adventure-reference-review.md create mode 100644 docs/setup/paid-assets.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 7dc1bdc..e80c696 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -66,6 +66,9 @@ International-friendly framing. Explained via science (Nibirium, biotech, consci ### Backend +- **Current baseline:** Supabase + Go LLM Gateway remains the default until a backend ADR replaces it. +- **Nakama OSS:** Approved for a focused spike if research supports it. Do not adopt Nakama, Hiro, Satori, OpenAuth, or any new auth / social stack without an ADR and JOY approval. +- **Hiro / Satori:** Commercial / license-dependent candidates only. Do not assume they are open-source drop-in dependencies. - **Supabase Auth** (reuse DOS.Me pattern, do not invent new auth) - **Supabase Postgres** (durable state: profile, inventory, quest progress, NFT lock state, cultivation tier) - **Supabase Realtime** (chat global, presence, friend, party invite, notification - NOT for combat / movement sync) @@ -303,7 +306,7 @@ OUT of scope for vertical slice: 2. **NEVER let LLM mutate authoritative game state.** Server validates all intent. 3. **NEVER put API keys (Anthropic, OpenAI, Convai, ElevenLabs) in Unity client.** All LLM calls go through Go gateway. 4. **NEVER use Host Mode for production.** Server Mode dedicated only. -5. **NEVER add Nakama, OpenAuth, or new auth / social stack.** Reuse Supabase + DOS.Me patterns. +5. **NEVER add or replace backend / auth / social stack without an ADR and JOY approval.** Supabase + DOS.Me patterns remain the baseline. Nakama OSS may be spiked if the ADR keeps Fusion authoritative gameplay, Supabase identity compatibility, and clear exit criteria. 6. **NEVER change Unity Asset Serialization away from Force Text.** Breaks LFS + diff. 7. **NEVER claim "done" without reviewer pass** (JOY is non-coder, cannot review code himself). 8. **ALWAYS edit BOTH `.claude/CLAUDE.md` and `AGENTS.md` together when updating project context.** They are sister files - Claude Code auto-loads CLAUDE.md, Codex CLI / Cursor / Copilot auto-load AGENTS.md. Edit one without the other = drift; the un-updated file lies to whichever agent reads it. Both files MUST be identical except for the sister-file comment header at line 1. @@ -316,5 +319,6 @@ OUT of scope for vertical slice: - Hunter NFT integration approach: Option 1 (preset hero) vs Hybrid 1+3 (modular pieces) - Phase 2 LLM model split (when to use Haiku vs Sonnet) - Voice NPC vendor (OpenAI Realtime vs ElevenLabs vs self-host) +- Backend platform selection: Supabase-first thin backend vs Nakama OSS game backend + Supabase identity bridge. Hiro / Satori require license and pricing review before adoption. - Dedicated server hosting (Hetzner specs, region) - Photon Fusion 2 license tier when scaling beyond Cloud free 20 CCU diff --git a/.gitignore b/.gitignore index 54c89ff..550ac7b 100644 --- a/.gitignore +++ b/.gitignore @@ -91,6 +91,10 @@ backend/dist/ # Photon credentials (if checked-in template) PhotonServerSettings.asset.local +# Paid Unity Asset Store assets (installed locally via Package Manager > My Assets) +Unity/Assets/ExplosiveLLC/ +Unity/Assets/ExplosiveLLC.meta + # Claude Code worktrees (ephemeral, per-session branches) .claude/worktrees/ diff --git a/AGENTS.md b/AGENTS.md index 4b8288b..b8181bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,6 +66,9 @@ International-friendly framing. Explained via science (Nibirium, biotech, consci ### Backend +- **Current baseline:** Supabase + Go LLM Gateway remains the default until a backend ADR replaces it. +- **Nakama OSS:** Approved for a focused spike if research supports it. Do not adopt Nakama, Hiro, Satori, OpenAuth, or any new auth / social stack without an ADR and JOY approval. +- **Hiro / Satori:** Commercial / license-dependent candidates only. Do not assume they are open-source drop-in dependencies. - **Supabase Auth** (reuse DOS.Me pattern, do not invent new auth) - **Supabase Postgres** (durable state: profile, inventory, quest progress, NFT lock state, cultivation tier) - **Supabase Realtime** (chat global, presence, friend, party invite, notification - NOT for combat / movement sync) @@ -303,7 +306,7 @@ OUT of scope for vertical slice: 2. **NEVER let LLM mutate authoritative game state.** Server validates all intent. 3. **NEVER put API keys (Anthropic, OpenAI, Convai, ElevenLabs) in Unity client.** All LLM calls go through Go gateway. 4. **NEVER use Host Mode for production.** Server Mode dedicated only. -5. **NEVER add Nakama, OpenAuth, or new auth / social stack.** Reuse Supabase + DOS.Me patterns. +5. **NEVER add or replace backend / auth / social stack without an ADR and JOY approval.** Supabase + DOS.Me patterns remain the baseline. Nakama OSS may be spiked if the ADR keeps Fusion authoritative gameplay, Supabase identity compatibility, and clear exit criteria. 6. **NEVER change Unity Asset Serialization away from Force Text.** Breaks LFS + diff. 7. **NEVER claim "done" without reviewer pass** (JOY is non-coder, cannot review code himself). 8. **ALWAYS edit BOTH `.claude/CLAUDE.md` and `AGENTS.md` together when updating project context.** They are sister files - Claude Code auto-loads CLAUDE.md, Codex CLI / Cursor / Copilot auto-load AGENTS.md. Edit one without the other = drift; the un-updated file lies to whichever agent reads it. Both files MUST be identical except for the sister-file comment header at line 1. @@ -316,5 +319,6 @@ OUT of scope for vertical slice: - Hunter NFT integration approach: Option 1 (preset hero) vs Hybrid 1+3 (modular pieces) - Phase 2 LLM model split (when to use Haiku vs Sonnet) - Voice NPC vendor (OpenAI Realtime vs ElevenLabs vs self-host) +- Backend platform selection: Supabase-first thin backend vs Nakama OSS game backend + Supabase identity bridge. Hiro / Satori require license and pricing review before adoption. - Dedicated server hosting (Hetzner specs, region) - Photon Fusion 2 license tier when scaling beyond Cloud free 20 CCU diff --git a/Unity/Assets/Photon/FusionAddons.meta b/Unity/Assets/Photon/FusionAddons.meta new file mode 100644 index 0000000..94030ad --- /dev/null +++ b/Unity/Assets/Photon/FusionAddons.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 96a3ae57e12f52f4fbc86fb0bf0c7799 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/Photon/FusionAddons/SimpleKCC.meta b/Unity/Assets/Photon/FusionAddons/SimpleKCC.meta new file mode 100644 index 0000000..9459fa4 --- /dev/null +++ b/Unity/Assets/Photon/FusionAddons/SimpleKCC.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 192928a4ca0f54f45b70fc70a1bacc83 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/Photon/FusionAddons/SimpleKCC/Fusion.Addons.SimpleKCC.Editor.dll b/Unity/Assets/Photon/FusionAddons/SimpleKCC/Fusion.Addons.SimpleKCC.Editor.dll new file mode 100644 index 0000000..c74d389 --- /dev/null +++ b/Unity/Assets/Photon/FusionAddons/SimpleKCC/Fusion.Addons.SimpleKCC.Editor.dll @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:474408151adf7975e28dc6cb18008277e494c6f3faa7db67dd72c9ec2bbca426 +size 7680 diff --git a/Unity/Assets/Photon/FusionAddons/SimpleKCC/Fusion.Addons.SimpleKCC.Editor.dll.meta b/Unity/Assets/Photon/FusionAddons/SimpleKCC/Fusion.Addons.SimpleKCC.Editor.dll.meta new file mode 100644 index 0000000..ee40310 --- /dev/null +++ b/Unity/Assets/Photon/FusionAddons/SimpleKCC/Fusion.Addons.SimpleKCC.Editor.dll.meta @@ -0,0 +1,88 @@ +fileFormatVersion: 2 +guid: a465ada18102a154b9e2db2b3d0b3f7e +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + : Any + second: + enabled: 0 + settings: + Exclude Android: 1 + Exclude Editor: 0 + Exclude Linux64: 1 + Exclude OSXUniversal: 1 + Exclude WebGL: 1 + Exclude Win: 1 + Exclude Win64: 1 + Exclude iOS: 1 + - first: + Android: Android + second: + enabled: 0 + settings: + AndroidSharedLibraryType: Executable + CPU: ARMv7 + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 1 + settings: + CPU: AnyCPU + DefaultValueInitialized: true + OS: AnyOS + - first: + Standalone: Linux64 + second: + enabled: 0 + settings: + CPU: None + - first: + Standalone: OSXUniversal + second: + enabled: 0 + settings: + CPU: None + - first: + Standalone: Win + second: + enabled: 0 + settings: + CPU: None + - first: + Standalone: Win64 + second: + enabled: 0 + settings: + CPU: None + - first: + Windows Store Apps: WindowsStoreApps + second: + enabled: 0 + settings: + CPU: AnyCPU + - first: + iPhone: iOS + second: + enabled: 0 + settings: + AddToEmbeddedBinaries: false + CPU: AnyCPU + CompileFlags: + FrameworkDependencies: + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/Photon/FusionAddons/SimpleKCC/Fusion.Addons.SimpleKCC.dll b/Unity/Assets/Photon/FusionAddons/SimpleKCC/Fusion.Addons.SimpleKCC.dll new file mode 100644 index 0000000..4de3fe7 --- /dev/null +++ b/Unity/Assets/Photon/FusionAddons/SimpleKCC/Fusion.Addons.SimpleKCC.dll @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8fab447d3d071a39a49c8b33f2557be976f01b7a030e642ccdb262e8b72bc5f1 +size 75776 diff --git a/Unity/Assets/Photon/FusionAddons/SimpleKCC/Fusion.Addons.SimpleKCC.dll.meta b/Unity/Assets/Photon/FusionAddons/SimpleKCC/Fusion.Addons.SimpleKCC.dll.meta new file mode 100644 index 0000000..5c65f83 --- /dev/null +++ b/Unity/Assets/Photon/FusionAddons/SimpleKCC/Fusion.Addons.SimpleKCC.dll.meta @@ -0,0 +1,33 @@ +fileFormatVersion: 2 +guid: 977dbcf975465374990f471e1497f563 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Any: + second: + enabled: 1 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + - first: + Windows Store Apps: WindowsStoreApps + second: + enabled: 0 + settings: + CPU: AnyCPU + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/Photon/FusionAddons/SimpleKCC/release_history.txt b/Unity/Assets/Photon/FusionAddons/SimpleKCC/release_history.txt new file mode 100644 index 0000000..570c34d --- /dev/null +++ b/Unity/Assets/Photon/FusionAddons/SimpleKCC/release_history.txt @@ -0,0 +1,96 @@ +Photon Fusion Simple KCC Addon - Release History + +Last tested with Fusion SDK 2.0.6 Stable 1034 + +2.0.15 +- Fixed jitter when landing on collider edge - clearing DynamicVelocity.y when step-up is triggered in StepUpProcessor. + +2.0.14 +- Fixed Transform and Rigidbody position when simulating forward tick next frame after Render(). +- Fixed incorrect step-up activation when touching wall colliders but not pushing against. + +2.0.13 +- Compatibility fixes for shared mode plugin export. +- RealVelocity and RealSpeed now resets to zero after calling SimpleKCC.SetActive(false). +- SimpleKCC.ResetVelocity() now also resets RealVelocity and RealSpeed. + +2.0.12 +- KCC editor scripts compatibility with Fusion SDK 2.0.2. + +2.0.11 +- Improved teleport - the information is now reliably synchronized over the network. +- Removed TeleportThreshold from KCC Settings. +- Max prediction error and anti-jitter distance check is now set to 1 meter. +- Fixed SimpleKCC.HasTeleported and SimpleKCC.HasJumped for proxy objects (reliable implementation based on counters). +- Fixed SimpleKCC.HasTeleported and SimpleKCC.HasJumped - they are now set only once in first Render after FUN in which teleport/jump happened. +- SimpleKCC.SetPosition() now accepts optional parameters 'teleport' and 'allowAntiJitter'. + +2.0.10 +- Exposed MaxPenetrationSteps and CCDRadiusMultiplier in Simple KCC settings. +- Increased range of CCD radius multiplier from 25-75% to 10-90%. +- Improved precision of collision resolver for specific cases (corridors, 3 collisions with almost perpendicular penetration vectors). +- Refactoring of penetration correction algorithm, improved performance scalability. +- Added SimpleKCC.ResetVelocity() to reset state. +- Fixed proxy collider being destroyed on clients when SimpleKCC.IsActive is false. + +2.0.9 +- Improved multi-collider penetration correction. +- Fixes step-up. Now it requires horizontal movement push-back to activate. + +2.0.8 +- KCCSettings.ForcePredictedLookRotation is now synchronized over network by default and affects input authority only. +- Fixed teleport detection in network transform only interpolation. +- Look rotation is now snapped when teleport is detected. +- Removed temporary fix for incorrect interpolation data - fixed in 2.0.0 Stable 834. + +Version 2.0.7 +- KCC collider is now also controlled by KCC.IsActive. If the flag is set to false, the collider will be despawned. +- Exposed KCC.InvokeOnSpawn() - can be used for initialization upon KCC.Spawned() callback. +- Added KCC.SetLookRotation() with min/max pitch look rotation. +- Removed RenderTimeframe override on proxy interpolation. + +Version 2.0.6 +- Fixed stuck on the edge when finishing step-up. +- Added input accumulators - FloatAccumulator, Vector2Accumulator, Vector3Accumulator. + - These classes support accumulation of raw values, their smoothing and tick-aligned delta consumption. + - Typical use-case is accumulation of mouse delta passed through a network struct. + - The tick-aligned accumulation ensures that snapshot interpolated value in Render() will be smooth. +- Fixed downward sphere cast check in step-up. +- Added SimpleKCC.ProjectOnGround(). This is useful when calculating XZ input => XYZ velocity to move along ground tangent. + +Version 2.0.5 +Important +============================================================ +! Simple KCC proxy is no longer simulated by default. If you call Runner.SetIsSimulated(Object, true) from other script, the KCC will behave as predicted. +! Jump impulse vector in Move() function converted to float => XZ is not supported. +! Gravity vector in SetGravity() function converted to float => XZ is not supported. + +Changes +============================================================ +- Added gizmos when KCC is selected. +- Added KCCSettings.ForcePredictedLookRotation - skips look rotation interpolation in render for local character and can be used for extra look responsiveness with other properties being interpolated. +- Added KCCSettings.ProxyInterpolation - controls interpolations of networked properties from network buffer, and propagation to Collider, Transform and Rigidbody components. +- Added KCCSettings.CompressNetworkPosition - optional network traffic reduction for non-player characters. Full precision position is synchronized by default. +- Improved step detection configuration (added min push-back to trigger step-up, toggle to require ground target point, variable ground check radius). Backport from Advanced KCC 2.0. +- Removed Runner.SetIsSimulated(Object, true) from KCC - proxies are no longer simulated. +- Removed networked keep-alive flag. +- Performance optimizations for proxies. +- Fixed projection of depenetration vector, resulting in jitter on slopes. + +Version 2.0.4 +- Changed root namespace from Fusion.SimpleKCC to Fusion.Addons.SimpleKCC. + +Version 2.0.3 +- Performance and network traffic optimizations. +- Fixed interpolation in Shared Mode. +- Added support for position and rotation handles. +- Compatibility with latest Fusion SDK. + +Version 2.0.2 +- Performance optimizations. + +Version 2.0.1 +- Exposed KCC Settings. + +Version 2.0.0 +- Initial release. diff --git a/Unity/Assets/Photon/FusionAddons/SimpleKCC/release_history.txt.meta b/Unity/Assets/Photon/FusionAddons/SimpleKCC/release_history.txt.meta new file mode 100644 index 0000000..e09aec3 --- /dev/null +++ b/Unity/Assets/Photon/FusionAddons/SimpleKCC/release_history.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: f4b413b5ce4f20d488e9fe7cf207a21d +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/Photon/FusionAddons/SimpleKCC/simple_kcc_build_info.txt b/Unity/Assets/Photon/FusionAddons/SimpleKCC/simple_kcc_build_info.txt new file mode 100644 index 0000000..a354c4d --- /dev/null +++ b/Unity/Assets/Photon/FusionAddons/SimpleKCC/simple_kcc_build_info.txt @@ -0,0 +1,3 @@ +version: 2.0.15 1024 +date: 2025-11-03 16:31:05 +git: main (a4a4ff6) \ No newline at end of file diff --git a/Unity/Assets/Photon/FusionAddons/SimpleKCC/simple_kcc_build_info.txt.meta b/Unity/Assets/Photon/FusionAddons/SimpleKCC/simple_kcc_build_info.txt.meta new file mode 100644 index 0000000..0c84922 --- /dev/null +++ b/Unity/Assets/Photon/FusionAddons/SimpleKCC/simple_kcc_build_info.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 4dbd84bb484c6e54193dc133ef632606 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/_SecondSpawn/Prefabs/Player_NetworkCube.prefab b/Unity/Assets/_SecondSpawn/Prefabs/Player_NetworkCube.prefab index 2cfbe51..8119b2b 100644 --- a/Unity/Assets/_SecondSpawn/Prefabs/Player_NetworkCube.prefab +++ b/Unity/Assets/_SecondSpawn/Prefabs/Player_NetworkCube.prefab @@ -10,10 +10,11 @@ GameObject: m_Component: - component: {fileID: 999036358833122982} - component: {fileID: 8687074654810685659} - - component: {fileID: 4513379572288692258} - component: {fileID: 6036230797588115926} + - component: {fileID: 7123456789012345679} - component: {fileID: 8357749372956170795} - component: {fileID: 8359936860818564255} + - component: {fileID: 7123456789012345678} m_Layer: 0 m_Name: Player_NetworkCube m_TagString: Untagged @@ -44,27 +45,6 @@ MeshFilter: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 5762444949739102863} m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0} ---- !u!65 &4513379572288692258 -BoxCollider: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 5762444949739102863} - m_Material: {fileID: 0} - m_IncludeLayers: - serializedVersion: 2 - m_Bits: 0 - m_ExcludeLayers: - serializedVersion: 2 - m_Bits: 0 - m_LayerOverridePriority: 0 - m_IsTrigger: 0 - m_ProvidesContacts: 0 - m_Enabled: 1 - serializedVersion: 3 - m_Size: {x: 1, y: 1, z: 1} - m_Center: {x: 0, y: 0, z: 0} --- !u!23 &6036230797588115926 MeshRenderer: m_ObjectHideFlags: 0 @@ -114,6 +94,33 @@ MeshRenderer: m_SortingOrder: 0 m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} +--- !u!54 &7123456789012345679 +Rigidbody: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 5762444949739102863} + serializedVersion: 5 + m_Mass: 1 + m_LinearDamping: 0 + m_AngularDamping: 0.05 + m_CenterOfMass: {x: 0, y: 0, z: 0} + m_InertiaTensor: {x: 1, y: 1, z: 1} + m_InertiaRotation: {x: 0, y: 0, z: 0, w: 1} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ImplicitCom: 1 + m_ImplicitTensor: 1 + m_UseGravity: 0 + m_IsKinematic: 1 + m_Interpolate: 0 + m_Constraints: 0 + m_CollisionDetection: 0 --- !u!114 &8357749372956170795 MonoBehaviour: m_ObjectHideFlags: 0 @@ -124,13 +131,14 @@ MonoBehaviour: m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: -1552182283, guid: e725a070cec140c4caffb81624c8c787, type: 3} - m_Name: + m_Name: m_EditorClassIdentifier: Fusion.Runtime.dll::Fusion.NetworkObject SortKey: 3133618377 SendPriority: 1 - Flags: 262146 + Flags: 262402 NestedObjects: [] NetworkedBehaviours: + - {fileID: 7123456789012345678} - {fileID: 8359936860818564255} ForceRemoteRenderTimeframe: 0 --- !u!114 &8359936860818564255 @@ -143,13 +151,48 @@ MonoBehaviour: m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: f24eeab6b4af9434ea9eef6a45ab299a, type: 3} - m_Name: + m_Name: m_EditorClassIdentifier: SecondSpawn.Networking::SecondSpawn.Networking.NetworkPlayer - _NetworkedPosition: {x: 0, y: 0, z: 0} - _NetworkedRotation: {x: 0, y: 0, z: 0, w: 0} _CultivationTier: 0 _Hp: 0 _Stamina: 0 _IsAgentControlled: RawValue: 0 _moveSpeed: 5 +--- !u!114 &7123456789012345678 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 5762444949739102863} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: -1869825851, guid: 977dbcf975465374990f471e1497f563, type: 3} + m_Name: + m_EditorClassIdentifier: Fusion.Addons.SimpleKCC.dll::Fusion.Addons.SimpleKCC.SimpleKCC + _stateAuthorityChangeErrorCorrectionDelta: 0.15 + _settings: + Shape: 1 + IsTrigger: 0 + Radius: 0.5 + Height: 2 + Extent: 0.035 + ColliderLayer: 0 + CollisionLayerMask: + serializedVersion: 2 + m_Bits: 4294967295 + ProxyInterpolationMode: 0 + MaxPenetrationSteps: 8 + CCDRadiusMultiplier: 0.75 + AntiJitterDistance: {x: 0.025, y: 0.01} + CompressNetworkPosition: 0 + ForcePredictedLookRotation: 1 + StepHeight: 0.3 + StepDepth: 0.2 + StepSpeed: 1 + StepMinPushBack: 0.5 + StepGroundCheckRadiusScale: 0.5 + StepRequireGroundTarget: 0 + SnapDistance: 0.4 + SnapSpeed: 4 diff --git a/Unity/Assets/_SecondSpawn/Scripts/Gameplay/PlayerController.cs b/Unity/Assets/_SecondSpawn/Scripts/Gameplay/PlayerController.cs index ee10123..0ca259d 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/Gameplay/PlayerController.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/Gameplay/PlayerController.cs @@ -5,14 +5,16 @@ namespace SecondSpawn.Gameplay /// /// Top-down ARPG player controller stub. /// - /// Will eventually wrap Opsive Ultimate Character Controller for combat, - /// movement, and ability dispatch. Server-authoritative per Pillar 4 - /// (see docs/design/01-pillars.md): the client predicts visually but - /// the dedicated Photon Fusion 2 server validates every action. + /// Starts as a project-owned movement contract. Fusion Simple KCC and + /// Opsive Ultimate Character Controller can be evaluated against this + /// baseline later. Server-authoritative per Pillar 4 (see + /// docs/design/01-pillars.md): the client predicts visually but the + /// dedicated Photon Fusion 2 server validates every action. /// /// TODO (slice phase 2): - /// - Wire Opsive UCC abilities to the server intent schema - /// (backend/gateway/internal/intent/intent.go). + /// - Spike Fusion Simple KCC based on Photon Pirate Adventure patterns. + /// - Evaluate Opsive UCC abilities only after the smaller Fusion-native + /// movement path is proven. /// - Hook input via Unity Input System (already imported as /// InputSystem_Actions.inputactions). /// - Bridge to NetworkRunnerSetup for Networked state authority. diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkPlayer.cs b/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkPlayer.cs index 1063cc4..449fe11 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkPlayer.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkPlayer.cs @@ -1,4 +1,5 @@ using Fusion; +using Fusion.Addons.SimpleKCC; using UnityEngine; namespace SecondSpawn.Networking @@ -17,10 +18,9 @@ namespace SecondSpawn.Networking /// [Networked] properties (session layer). /// [DisallowMultipleComponent] + [RequireComponent(typeof(SimpleKCC))] public sealed class NetworkPlayer : NetworkBehaviour { - [Networked] public Vector3 NetworkedPosition { get; set; } - [Networked] public Quaternion NetworkedRotation { get; set; } [Networked] public int CultivationTier { get; set; } [Networked] public float Hp { get; set; } [Networked] public float Stamina { get; set; } @@ -28,15 +28,22 @@ public sealed class NetworkPlayer : NetworkBehaviour /// True when the offline AI agent is driving this character (Pillar 1). [Networked] public NetworkBool IsAgentControlled { get; set; } - [SerializeField, Tooltip("Movement speed in units/second. Will be replaced by Opsive UCC stats in slice phase 2.")] + [SerializeField, Tooltip("Movement speed in units/second. Simple KCC owns authoritative movement for this spike.")] private float _moveSpeed = 5f; + private SimpleKCC _kcc; + + private void Awake() + { + _kcc = GetComponent(); + } + public override void Spawned() { + _kcc ??= GetComponent(); + if (HasStateAuthority) { - NetworkedPosition = transform.position; - NetworkedRotation = transform.rotation; CultivationTier = 1; // Awakening - starting tier per docs/design/04-cultivation-system.md Hp = 100f; Stamina = 100f; @@ -46,22 +53,29 @@ public override void Spawned() public override void FixedUpdateNetwork() { - // Server-authoritative input application. Client never mutates - // [Networked] state directly; client only sends INetworkInput - // suggestions which the server validates here. + if (_kcc == null) + { + return; + } + + var moveVelocity = Vector3.zero; + + // Server-authoritative input application. The client sends + // INetworkInput suggestions; Simple KCC owns predicted and + // replicated movement state for the character body. if (GetInput(out NetworkInputData input)) { var move = new Vector3(input.HorizontalAxis, 0f, input.VerticalAxis); - if (move.sqrMagnitude > 0f) + move = Vector3.ClampMagnitude(move, 1f); + + if (move.sqrMagnitude > 0.0001f) { - NetworkedPosition += move.normalized * _moveSpeed * Runner.DeltaTime; + _kcc.SetLookRotation(Quaternion.LookRotation(move), preservePitch: false, preserveYaw: false); + moveVelocity = move * _moveSpeed; } } - // Apply networked transform to GameObject so all clients see the - // server-authoritative position. - transform.position = NetworkedPosition; - transform.rotation = NetworkedRotation; + _kcc.Move(moveVelocity, jumpImpulse: 0f); } } } diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/SecondSpawn.Networking.asmdef b/Unity/Assets/_SecondSpawn/Scripts/Networking/SecondSpawn.Networking.asmdef index 54db47d..ec31798 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/Networking/SecondSpawn.Networking.asmdef +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/SecondSpawn.Networking.asmdef @@ -5,6 +5,7 @@ "Fusion.Runtime", "Fusion.Common", "Fusion.Realtime", + "Fusion.Addons.SimpleKCC", "Unity.InputSystem" ], "includePlatforms": [], diff --git a/docs/README.md b/docs/README.md index 08783ac..dd6c290 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,13 +19,15 @@ This documentation is the canonical public design and architecture source for th The current implementation focus is a thin, networked player-controller prototype: - Minimal Fusion controller first. -- Opsive Ultimate Character Controller evaluated after the baseline works. +- Simple KCC spike from Photon Pirate Adventure patterns second. +- Opsive Ultimate Character Controller evaluated only after that smaller Fusion-native path is tested. - No large Unity asset imports until the movement, camera, and authority contract are verified. Relevant docs: - [Overview Design](design/06-overview-design.md) - [Networked Player Controller Prototype](design/07-player-controller-prototype.md) +- [Pirate Adventure Reference Review](design/09-pirate-adventure-reference-review.md) ## Signature Features @@ -37,7 +39,5 @@ Relevant docs: ## Documentation Rules - English is the canonical language for docs, code, commits, PRs, ADRs, and roadmap. -- Vietnamese notes may live under `docs/vi/` when needed, but English docs remain the source of truth. -- If Vietnamese notes and English docs conflict, the English canonical docs win. +- GitBook handles translated views from the English canonical docs. - `/docs` is public-facing through GitBook. Do not place private credentials, internal-only secrets, or unpublished partner details here. - diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 272492f..eff2242 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -16,6 +16,7 @@ - [Overview Design](design/06-overview-design.md) - [Networked Player Controller Prototype](design/07-player-controller-prototype.md) - [Time-as-Currency](design/08-time-as-currency.md) +- [Pirate Adventure Reference Review](design/09-pirate-adventure-reference-review.md) ## Architecture Decision Records @@ -33,5 +34,5 @@ - [Agent Handoff](setup/agent-handoff.md) - [Fusion Install](setup/fusion-install.md) +- [Paid Asset Setup](setup/paid-assets.md) - [Unity Conventions](setup/unity-conventions.md) - diff --git a/docs/design/03-systems-index.md b/docs/design/03-systems-index.md index e4b63cf..5506762 100644 --- a/docs/design/03-systems-index.md +++ b/docs/design/03-systems-index.md @@ -29,7 +29,7 @@ This index enumerates every system the game needs, categorizes by Core/Gameplay/ | # | System | Category | Priority | Status | Design Doc | Depends On | | --- | ---- | ---- | ---- | ---- | ---- | ---- | | 1 | NetworkRunner / Photon Fusion 2 setup | Core | MVP | Not started | (TDD pending) | - | -| 2 | Player Controller (minimal baseline, Opsive UCC evaluation later) | Core | MVP | Drafted | [07-player-controller-prototype.md](07-player-controller-prototype.md) | NetworkRunner | +| 2 | Player Controller (minimal baseline, Simple KCC spike, Opsive UCC evaluation later) | Core | MVP | Drafted | [07-player-controller-prototype.md](07-player-controller-prototype.md) | NetworkRunner | | 3 | Camera (top-down ARPG) | Core | MVP | Not started | (TDD pending) | Player Controller | | 4 | Input system (Unity Input System) | Core | MVP | Not started | - | Player Controller | | 5 | Zone scene management (1 zone vertical slice) | Core | MVP | Not started | (TDD pending) | NetworkRunner | @@ -97,7 +97,7 @@ This index enumerates every system the game needs, categorizes by Core/Gameplay/ ### Core Layer (depends on foundation) -6. Player Controller baseline / Opsive evaluation (#2) - depends on: NetworkRunner +6. Player Controller baseline / Simple KCC spike / Opsive evaluation (#2) - depends on: NetworkRunner 7. Camera (#3) - depends on: Player Controller 8. Input system (#4) - depends on: Player Controller 9. Zone scene management (#5) - depends on: NetworkRunner @@ -166,7 +166,7 @@ Aligned with [02-vertical-slice-spec.md](02-vertical-slice-spec.md) build phases | 1 | NetworkRunner setup (#1) | Phase 1 | M | Reference MetaDOS BR template | | 2 | Auth (#22) | Phase 1 | M | Reuse DOS.Me Supabase pattern | | 3 | Profile persistence (#18) | Phase 1 | S | | -| 4 | Player Controller baseline / Opsive evaluation (#2) | Phase 2 | M | Build minimal Fusion controller first; evaluate Opsive in isolation after baseline | +| 4 | Player Controller baseline / Simple KCC spike / Opsive evaluation (#2) | Phase 2 | M | Build minimal Fusion controller first; evaluate Simple KCC next; evaluate Opsive in isolation after that | | 5 | Camera + Input (#3, #4) | Phase 2 | S | Standard URP | | 6 | Zone scene management (#5) | Phase 2 | M | | | 7 | Combat (#6) | Phase 2 | L | Server-authoritative critical | @@ -196,7 +196,7 @@ Effort estimate: S = 1-3 days, M = 4-7 days, L = 1-2 weeks, XL = 2-4 weeks (solo | Metric | Count | | ---- | ---- | | Total systems identified | 36 | -| Design docs started | 4 (cultivation, overview design, player controller prototype, time-as-currency) | +| Design docs started | 5 (cultivation, overview design, player controller prototype, time-as-currency, Pirate Adventure reference review) | | Design docs reviewed | 0 | | Design docs approved | 0 | | MVP systems with TDD started | 0 | diff --git a/docs/design/06-overview-design.md b/docs/design/06-overview-design.md index 1d52b5b..d125354 100644 --- a/docs/design/06-overview-design.md +++ b/docs/design/06-overview-design.md @@ -65,15 +65,16 @@ The slice does not need large content volume. It needs a tight loop that proves ## Player Controller Direction -The current direction is **minimal networked controller first, Opsive evaluation second**. +The current direction is **minimal networked controller first, Fusion Simple KCC spike second, Opsive evaluation third**. Opsive Ultimate Character Controller is already purchased and may still be useful for combat, animation, ability handling, or camera tooling. It is not currently treated as mandatory for the first prototype because: - Fusion authority and input flow must be proven before adding a heavy third-party controller. - Top-down ARPG movement may be simpler than the full Opsive UCC feature set. - Unity 6.5 beta compatibility must be validated before betting the prototype on it. +- Photon's Pirate Adventure sample already demonstrates a smaller Fusion-native top-down controller path with Simple KCC. -The first prototype should create a small, project-owned movement contract. Opsive can then be imported and judged against that contract. +The first prototype should create a small, project-owned movement contract. Simple KCC can then be tested against that contract. Opsive can be imported later and judged against both. --- @@ -118,9 +119,10 @@ These are still vertical slice systems. They are only excluded from the first pl | 1 | Minimal networked player controller | Player spawns and moves in Play Mode without console errors. | | 2 | Top-down camera | Camera follows the local player and keeps the placeholder readable. | | 3 | Prototype control feel pass | Movement feels crisp enough for ARPG iteration. | -| 4 | Opsive evaluation branch | Import Opsive in isolation and compare value/cost against the baseline. | -| 5 | Combat prototype | Add one basic attack only after movement is stable. | -| 6 | Persistence/auth prototype | Supabase profile and login once local gameplay loop exists. | +| 4 | Simple KCC spike | Import official Fusion Simple KCC addon and compare it against the baseline. | +| 5 | Opsive evaluation branch | Import Opsive in isolation and compare value/cost against the baseline and Simple KCC spike. | +| 6 | Combat prototype | Add one basic attack only after movement is stable. | +| 7 | Persistence/auth prototype | Supabase profile and login once local gameplay loop exists. | --- @@ -142,6 +144,7 @@ These are still vertical slice systems. They are only excluded from the first pl | ---- | ---- | ---- | ---- | | WASD, click-to-move, or both for first prototype? | JOY | Before movement polish | WASD first, click-to-move later. | | Use Cinemachine for camera now? | Codex | During camera task | Use it if already available; otherwise simple custom follow. | +| Does Simple KCC become the MVP controller? | Codex + Claude reviewer | After Simple KCC spike | Likely candidate if it stays console-clean and server-authoritative. | | Does Opsive become core or optional? | Codex + Claude reviewer | After isolated Opsive branch | Optional until proven worth the integration cost. | | What is the first Hunter visual? | JOY | After movement baseline | Placeholder until MetaDOS skin import path is reviewed. | diff --git a/docs/design/07-player-controller-prototype.md b/docs/design/07-player-controller-prototype.md index cbb93e5..254c586 100644 --- a/docs/design/07-player-controller-prototype.md +++ b/docs/design/07-player-controller-prototype.md @@ -1,9 +1,9 @@ # Prototype Design: Networked Player Controller -*Status: Draft* +*Status: In progress* *Created: 2026-05-14* *Author: Codex* -*Last Verified: 2026-05-14 against Phase B Fusion smoke test and `05-networking-architecture.md`* +*Last Verified: 2026-05-15 against local Photon Pirate Adventure 2.0.12 sample review and Simple KCC 2.0.15 package metadata* > **Quick reference** - Layer: `Core` - Priority: `MVP` - Key deps: `Photon Fusion 2`, `Unity Input System`, `ZoneTest_Hub`, `SecondSpawnConfig` @@ -19,13 +19,14 @@ This is not the combat system. This is the movement and authority contract that ## Design Decision -Build a **project-owned minimal networked controller first**. Evaluate Opsive Ultimate Character Controller only after this baseline is working. +Build a **project-owned minimal networked controller first**, then convert the placeholder player to Photon Fusion Simple KCC before evaluating Opsive Ultimate Character Controller. Opsive should prove value against a working Fusion-native controller path rather than become the baseline by default. Rationale: - The game is open-source multiplayer, so authority rules are more important than controller feature depth. - The first prototype needs known behavior that agents can reason about. -- Opsive may still be useful, but it should prove value against a working baseline rather than become the baseline by default. +- Pirate Adventure shows a useful Fusion-native top-down path with Simple KCC, FSM states, runner physics queries, and compact network input. +- Opsive may still be useful, but it should prove value against the project baseline and Simple KCC spike rather than become the baseline by default. --- @@ -102,10 +103,10 @@ Camera-relative movement can be added after the first pass if the camera angle m | ---- | ---- | ---- | | `NetworkRunnerSetup` | Starts Fusion dev session and owns runner lifecycle. | Existing Phase B | | `NetworkInputProvider` | Collects movement input per Fusion tick. | Existing Phase B, may extend | -| `NetworkPlayer` | Applies authoritative movement and owns networked player state. | Existing Phase B, may extend | +| `NetworkPlayer` | Applies Fusion input to Simple KCC and owns session player state. | Existing Phase B, extended | | `PlayerSpawner` | Spawns one player object per joined player. | Existing Phase B | | `PlayerCameraFollow` | Keeps camera pointed at local player. | New if no suitable existing component | -| `Player_NetworkCube.prefab` | Placeholder networked player prefab. | Existing Phase B, may evolve | +| `Player_NetworkCube.prefab` | Placeholder networked player prefab with Simple KCC. | Existing Phase B, evolved | --- @@ -147,11 +148,19 @@ These are prototype values, not final game balance. - [ ] Confirm player stops predictably. - [ ] Record remaining feel issues in this doc. -### Phase 4: Opsive Evaluation Branch +### Phase 4: Simple KCC Spike -- [ ] Import Opsive UCC in a separate branch/commit only after baseline passes. +- [x] Import the official Fusion Simple KCC addon in a separate branch/commit only after baseline passes. +- [x] Convert movement from raw networked position updates to KCC-backed movement. +- [ ] Validate with Unity 6.5 beta and current Fusion 2.1.1 release candidate. +- [ ] Compare movement feel and authority clarity against the baseline. +- [ ] Decide whether Simple KCC becomes the MVP controller. + +### Phase 5: Opsive Evaluation Branch + +- [ ] Import Opsive UCC in a separate branch/commit only after the Simple KCC spike. - [ ] Check Unity 6.5 beta compatibility and console state. -- [ ] Compare Opsive movement/combat/camera value against the baseline. +- [ ] Compare Opsive movement/combat/camera value against the baseline and Simple KCC spike. - [ ] Decide whether Opsive becomes core, optional, or deferred. --- @@ -186,6 +195,8 @@ These are prototype values, not final game balance. | ---- | ---- | | Combat Prototype Design | After player movement and camera are stable. | | Camera Design | If camera behavior becomes deeper than one follow component. | +| Pirate Adventure Reference Review | Completed after local sample inspection. | +| Simple KCC Spike Report | After official Simple KCC addon import and smoke test. | | Opsive Evaluation Report | After isolated Opsive import and smoke test. | | Offline AI Agent Movement Contract | Before AI agent can control the same player actor. | @@ -195,8 +206,8 @@ These are prototype values, not final game balance. | This Document References | Target Doc | Specific Element Referenced | Nature | | ---- | ---- | ---- | ---- | -| Prototype shape | `06-overview-design.md` | Minimal controller first, Opsive evaluation second | Scope dependency | +| Prototype shape | `06-overview-design.md` | Minimal controller first, Simple KCC spike second, Opsive evaluation third | Scope dependency | +| Reference sample | `09-pirate-adventure-reference-review.md` | Pirate Adventure controller and FSM patterns | Pattern dependency | | Networking rules | `05-networking-architecture.md` | Network input and server authority | Rule dependency | | Pillar priority | `01-pillars.md` | Server-authoritative gameplay | Rule dependency | | Unity conventions | `../setup/unity-conventions.md` | Prefab, scene, and asmdef organization | Ownership handoff | - diff --git a/docs/design/09-pirate-adventure-reference-review.md b/docs/design/09-pirate-adventure-reference-review.md new file mode 100644 index 0000000..29b495e --- /dev/null +++ b/docs/design/09-pirate-adventure-reference-review.md @@ -0,0 +1,172 @@ +# Reference Review: Photon Fusion Pirate Adventure + +*Status: Draft* +*Created: 2026-05-14* +*Author: Codex* +*Local Source Reviewed: `C:\Users\JOY\Downloads\fusion-pirate-adventure-2.0.12.zip`* + +> **Quick reference** - Layer: `Prototype` - Priority: `MVP` - Key deps: `Photon Fusion 2`, `Fusion Simple KCC`, `Fusion FSM` + +--- + +## Verdict + +Do **not** buy an extra paid top-down Fusion template yet. + +Photon's Pirate Adventure sample is enough as the first controller reference for SECOND SPAWN. It is closer to our immediate need than Opsive UCC or a paid top-down shooter template because it demonstrates a complete Fusion top-down loop: + +- local input gathered into a compact Fusion input struct +- player state machine +- Simple KCC movement +- attack hit windows +- interactables +- pickups and hazards +- enemy search and chase behavior +- NavMesh-driven enemy movement +- Multi-Peer friendly runner dictionaries + +Use it as a reference, not as code to copy. + +--- + +## Compatibility Snapshot + +| Area | Pirate Adventure | SECOND SPAWN Current State | Notes | +| ---- | ---- | ---- | ---- | +| Unity version | Unity `2021.3.45f2` | Unity `6000.5.0b7` | Sample must be treated as a pattern source, not imported wholesale. | +| Fusion version | `2.0.12 Stable 1861` | `2.1.1 Release-Candidate 2037` | API drift is possible. Validate in Unity after each integration step. | +| KCC | Simple KCC addon `2.0.15` DLL | Installed at `Unity/Assets/Photon/FusionAddons/SimpleKCC/` | Strong candidate for next controller prototype branch. | +| Topology | Shared Mode | Server Mode production, Host Mode dev | Do not copy Shared Mode authority assumptions. | +| Input system | Unity Input System `1.7.0` | Unity 6 project default input setup | Reuse the struct shape, not the generated action asset. | +| Art and sample assets | Pirate and third-party sample assets | SECOND SPAWN original assets | Do not import sample art into the public repo. | + +--- + +## Useful Patterns To Adopt + +### 1. Fusion Input Shape + +Pirate Adventure keeps player input small: + +- movement vector +- network buttons +- one place that gathers local input +- gameplay code reads only the tick input + +SECOND SPAWN already has `NetworkInputData`. Keep evolving that shape instead of binding gameplay directly to keyboard, mouse, UI, LLM, or AI-agent sources. + +### 2. Simple KCC For The Next Movement Branch + +Pirate Adventure uses `Fusion.Addons.SimpleKCC.SimpleKCC` for player movement. The useful pattern is: + +- normalize movement input +- build an XZ movement direction +- set look rotation from movement direction +- call `SimpleKCC.Move(...)` inside Fusion simulation state +- keep movement state-specific, for example idle, run, fall, hit, attack + +This matches SECOND SPAWN better than jumping straight to Opsive UCC because the authority surface stays small and readable. + +### 3. State Machine Boundary + +Player and enemy behavior are separated into small states: + +- `PlayerIdleState` +- `PlayerRunState` +- `PlayerAttackState` +- `PlayerHitState` +- `PlayerDeathState` +- `EnemyIdleState` +- `EnemyChaseState` +- `EnemyAttackState` + +This is useful for SECOND SPAWN because cultivation, reincarnation, AI-agent control, and combat can plug into state transitions without one large controller script. + +### 4. Runner Physics Scene Queries + +Combat and interaction checks use the runner physics scene: + +- overlap capsule for pickups and hazards +- overlap sphere for interactables +- overlap sphere for attack hit windows +- raycast for enemy line of sight + +This is the right mental model for Fusion. Query the simulation scene the runner owns, not random global gameplay state. + +### 5. Enemy MVP Pattern + +Pirate Adventure enemies are simple but useful: + +- search nearest valid player +- validate distance and line of sight +- store target as networked state +- enable NavMeshAgent only on state authority +- stop movement during attack windows + +This is enough for the first dungeon trash enemy prototype before Behavior Designer enters the project. + +--- + +## Patterns To Avoid + +### Shared Mode Authority + +The sample uses Shared Mode and client-owned state authority. SECOND SPAWN production uses dedicated Server Mode. Do not copy: + +- `GameMode.Shared` as the project default +- Shared Mode master-client authority transfer logic +- client-side authority assumptions for enemy or player ownership + +### Wholesale Sample Import + +Do not import the whole Pirate Adventure project. It targets an older Unity version, older Fusion build, sample art, sample scenes, and Shared Mode assumptions. + +### Sample Economy Names + +Pirate Adventure uses money and level as sample progression. SECOND SPAWN uses: + +- `BodyTime` +- `SECOND token` +- cultivation tier +- reincarnation state +- durable inventory and quest state in Supabase + +Keep the gameplay naming aligned to SECOND SPAWN. + +--- + +## Recommended Next Step + +Create a feature branch from `dev` for a **Simple KCC controller spike**: + +1. Keep `Player_NetworkCube.prefab` or replace it with a capsule placeholder. +2. Convert current raw position movement into KCC-backed movement. +3. Add a tiny player movement state layer only if it stays simpler than the current script. +4. Verify Play Mode console is clean. +5. Run reviewer pass before merging. + +Opsive UCC should move after this spike, not before it. If Simple KCC covers the MVP movement feel, Opsive can be reserved for animation, camera, or combat evaluation instead of becoming the core controller dependency. + +--- + +## Decision Implication + +For the vertical slice, the controller path should now be: + +1. Minimal Fusion movement baseline. +2. Simple KCC spike based on Pirate Adventure patterns. +3. Combat state prototype. +4. Opsive UCC evaluation only if it clearly improves animation, abilities, or combat authoring enough to justify integration cost. + +This keeps SECOND SPAWN friendly to solo development and AI agents: fewer hidden asset-store assumptions, smaller code surface, and clearer server-authoritative rules. + +--- + +## Cross-References + +| This Document References | Target Doc | Specific Element Referenced | Nature | +| ---- | ---- | ---- | ---- | +| Controller plan | `07-player-controller-prototype.md` | Simple KCC spike before Opsive import | Scope dependency | +| Overview plan | `06-overview-design.md` | Playable feel before asset complexity | Scope dependency | +| Networking rules | `05-networking-architecture.md` | Server-authoritative Fusion architecture | Rule dependency | +| Systems map | `03-systems-index.md` | Player Controller entry | Tracking dependency | diff --git a/docs/setup/fusion-install.md b/docs/setup/fusion-install.md index df1e2dc..19436fc 100644 --- a/docs/setup/fusion-install.md +++ b/docs/setup/fusion-install.md @@ -45,7 +45,18 @@ Fusion ships several Plugins/ subfolders + a `Photon/` runtime folder. These con 3. Save (Ctrl+S). 4. Also paste the same App ID into `Assets/_SecondSpawn/Settings/SecondSpawnConfig.asset` -> `PhotonAppId` field (once that .asset exists - see "Create SecondSpawnConfig asset" below). -## Step 5: Create the SecondSpawnConfig asset +## Step 5: Install Fusion Simple KCC + +Fusion Simple KCC is the first controller addon for the player movement spike. + +1. Download `fusion-simple-kcc-2.0.15.unitypackage` from Photon. +2. Import it into the Unity project. +3. Expected installed path: `Assets/Photon/FusionAddons/SimpleKCC/`. +4. Confirm `simple_kcc_build_info.txt` reports version `2.0.15`. + +This addon is OK to commit with the Photon SDK. It is not a gameplay template sample. + +## Step 6: Create the SecondSpawnConfig asset The ScriptableObject definition was scaffolded in commit `f04aa3b` but the `.asset` instance requires Unity Editor focus to compile the new `SecondSpawn.Settings` assembly. If it still does not exist: @@ -60,14 +71,14 @@ The ScriptableObject definition was scaffolded in commit `f04aa3b` but the `.ass - **PhotonAppId**: from Step 1 - **DosChainRpcUrl**: leave blank for now -## Step 6: Verify it builds +## Step 7: Verify it builds 1. Menu: **File > Build Settings**. 2. Target: PC Standalone (Windows or Linux). 3. Click **Build**, save to `D:\Projects\Second-Spawn\Unity\Build\` (gitignored). 4. If the build succeeds with no compile errors, Fusion 2 is installed correctly. -## Step 7: Tell the AI agent +## Step 8: Tell the AI agent Open a Claude Code session in this repo: diff --git a/docs/setup/paid-assets.md b/docs/setup/paid-assets.md new file mode 100644 index 0000000..9b28946 --- /dev/null +++ b/docs/setup/paid-assets.md @@ -0,0 +1,51 @@ +# Paid Asset Setup + +*Status: Draft* +*Created: 2026-05-14* +*Author: Codex* + +> **Quick reference** - Layer: `Setup` - Priority: `Local Development` - Key deps: Unity Package Manager, Unity Asset Store account + +--- + +## Purpose + +SECOND SPAWN is a public repository. Paid Unity Asset Store content must stay out of Git while remaining usable in JOY's local Unity project and private build machines. + +--- + +## Installed Local Assets + +| Asset | Source | Local Path | Git Policy | Current Use | +| ---- | ---- | ---- | ---- | ---- | +| RPG Character Mecanim Animation Pack | Unity Package Manager > My Assets | `Unity/Assets/ExplosiveLLC/` | Ignored, do not commit raw files | Prototype character and animation library | + +--- + +## Import Steps + +1. Open the Unity project at `Unity/`. +2. Open `Window > Package Manager`. +3. Select `My Assets`. +4. Download and import `RPG Character Mecanim Animation Pack`. +5. If the pack asks to load Input and Layer presets, skip it for now. SECOND SPAWN uses its own Fusion and Input System path. +6. Verify Unity console after import. + +--- + +## Repository Rules + +- Do not commit raw paid assets, including models, textures, animations, prefabs, demo scenes, sample scripts, or sample controllers from paid packs. +- Do commit project-owned scripts that integrate with locally installed assets. +- Do commit project-owned prefabs only when they do not embed paid asset source data. +- Do keep placeholder/open assets available so public contributors can open the project without licensed paid assets. +- Do document any required paid asset in this file before using it in a prototype. + +--- + +## Animation Integration Rules + +- Fusion or Simple KCC owns movement and networked position. +- Animation clips render state and intent only. +- Disable root motion for networked movement unless a future prototype explicitly proves an authority-safe root-motion workflow. +- Keep Animator parameters small and driven from project-owned controller state. From d0e1062f81d107cdc4770a2020a9d71f477ed873 Mon Sep 17 00:00:00 2001 From: JOY Date: Fri, 15 May 2026 21:02:28 +0700 Subject: [PATCH 10/14] chore(unity): update AI Assistant package --- Unity/Packages/manifest.json | 2 +- Unity/Packages/packages-lock.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Unity/Packages/manifest.json b/Unity/Packages/manifest.json index 7ba6d86..d880700 100644 --- a/Unity/Packages/manifest.json +++ b/Unity/Packages/manifest.json @@ -1,7 +1,7 @@ { "dependencies": { "com.coplaydev.unity-mcp": "https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity#main", - "com.unity.ai.assistant": "2.7.0-pre.3", + "com.unity.ai.assistant": "2.8.0-pre.1", "com.unity.ai.inference": "2.6.1", "com.unity.ai.navigation": "2.0.12", "com.unity.collab-proxy": "2.12.4", diff --git a/Unity/Packages/packages-lock.json b/Unity/Packages/packages-lock.json index dc2d9da..f718263 100644 --- a/Unity/Packages/packages-lock.json +++ b/Unity/Packages/packages-lock.json @@ -17,7 +17,7 @@ "dependencies": {} }, "com.unity.ai.assistant": { - "version": "2.7.0-pre.3", + "version": "2.8.0-pre.1", "depth": 0, "source": "registry", "dependencies": { From d0fd4ef0cc34cdc3311b4400fef9ca8bff5560c1 Mon Sep 17 00:00:00 2001 From: JOY Date: Sat, 16 May 2026 14:24:46 +0700 Subject: [PATCH 11/14] feat(prototype): wire agent brain and Nakama backend --- .claude/CLAUDE.md | 48 +- .gitignore | 15 + AGENTS.md | 48 +- Unity/Assets/_SecondSpawn/Art.meta | 8 + Unity/Assets/_SecondSpawn/Art/Animations.meta | 8 + Unity/Assets/_SecondSpawn/Art/Characters.meta | 8 + Unity/Assets/_SecondSpawn/Art/Materials.meta | 8 + .../Art/Materials/PrototypeGround.mat | 137 +++++ .../Art/Materials/PrototypeGround.mat.meta | 8 + Unity/Assets/_SecondSpawn/Art/Models.meta | 8 + Unity/Assets/_SecondSpawn/Art/Textures.meta | 8 + Unity/Assets/_SecondSpawn/Audio.meta | 8 + Unity/Assets/_SecondSpawn/Editor.meta | 8 + .../Editor/SecondSpawnVisualPrefabUtility.cs | 331 ++++++++++++ .../SecondSpawnVisualPrefabUtility.cs.meta | 2 + .../Prefabs/Player_NetworkCube.prefab | 84 +++ .../_SecondSpawn/Scenes/ZoneTest_Hub.unity | 312 ++++++++++- .../Scripts/AI/AgentContextDto.cs | 192 +++++++ .../Scripts/AI/AgentContextDto.cs.meta | 2 + .../Scripts/AI/CharacterMemorySync.cs | 167 ++++++ .../Scripts/AI/CharacterMemorySync.cs.meta | 2 + .../Scripts/AI/GatewayContracts.cs | 7 + .../Scripts/AI/GatewayContracts.cs.meta | 2 + .../Scripts/AI/PrototypeAgentBrain.cs | 455 ++++++++++++++++ .../Scripts/AI/PrototypeAgentBrain.cs.meta | 2 + .../Scripts/AI/PrototypeLLMAgentDriver.cs | 196 +++++++ .../AI/PrototypeLLMAgentDriver.cs.meta | 2 + .../Scripts/AI/PrototypeNPCChatClient.cs | 90 ++++ .../Scripts/AI/PrototypeNPCChatClient.cs.meta | 2 + .../Scripts/AI/PrototypeSpeechBubble.cs | 89 +++ .../Scripts/AI/PrototypeSpeechBubble.cs.meta | 12 + .../Scripts/AI/PrototypeVoiceCue.cs | 59 ++ .../Scripts/AI/PrototypeVoiceCue.cs.meta | 12 + .../Scripts/AI/SecondSpawn.AI.asmdef | 3 +- .../Scripts/AI/SecondSpawnGatewayClient.cs | 507 ++++++++++++++++++ .../AI/SecondSpawnGatewayClient.cs.meta | 2 + .../Networking/AnimationEventReceiver.cs | 33 ++ .../Networking/AnimationEventReceiver.cs.meta | 11 + .../Networking/EquipmentVisualCatalog.cs | 182 +++++++ .../Networking/EquipmentVisualCatalog.cs.meta | 11 + .../Networking/LocalVisualPrefabLoader.cs | 390 ++++++++++++++ .../LocalVisualPrefabLoader.cs.meta | 11 + .../Networking/NetworkAnimatorBridge.cs | 453 ++++++++++++++++ .../Networking/NetworkAnimatorBridge.cs.meta | 11 + .../Networking/NetworkInputProvider.cs | 28 + .../Scripts/Networking/NetworkPlayer.cs | 61 ++- .../Scripts/Networking/NetworkRunnerSetup.cs | 52 +- .../Scripts/Networking/PlayerSpawner.cs | 16 +- .../PrototypeVisualActionHotkeys.cs | 61 +++ .../PrototypeVisualActionHotkeys.cs.meta | 11 + .../Scripts/Networking/TopDownCameraFollow.cs | 57 ++ .../Networking/TopDownCameraFollow.cs.meta | 11 + .../Networking/VisualAnimationIntentDriver.cs | 246 +++++++++ .../VisualAnimationIntentDriver.cs.meta | 11 + .../Scripts/Networking/VisualPrefabCatalog.cs | 76 +++ .../Networking/VisualPrefabCatalog.cs.meta | 2 + .../Scripts/Settings/SecondSpawnConfig.cs | 2 +- .../_SecondSpawn/Settings/PC_RPAsset.asset | 28 +- .../Settings/SecondSpawnConfig.asset | 2 +- ...niversalRenderPipelineGlobalSettings.asset | 17 +- Unity/ProjectSettings/ProjectSettings.asset | 5 +- Unity/ProjectSettings/QualitySettings.asset | 79 +-- .../UnityConnectSettings.asset | 2 +- backend/gateway/.env.example | 4 +- backend/gateway/Dockerfile | 2 +- backend/gateway/README.md | 49 +- backend/gateway/deploy/cloudrun.env.yaml | 4 + backend/gateway/internal/agent/decision.go | 235 ++++++++ .../gateway/internal/agent/decision_test.go | 68 +++ backend/gateway/internal/auth/auth.go | 12 +- backend/gateway/internal/character/profile.go | 198 +++++++ .../internal/character/profile_test.go | 84 +++ backend/gateway/internal/character/store.go | 293 ++++++++++ .../gateway/internal/character/store_test.go | 53 ++ backend/gateway/internal/config/config.go | 13 +- backend/gateway/internal/server/server.go | 196 ++++++- .../gateway/internal/server/server_test.go | 105 ++++ backend/nakama/.gitignore | 2 + backend/nakama/README.md | 83 +++ backend/nakama/local.example.yml | 18 + backend/nakama/modules/index.ts | 496 +++++++++++++++++ backend/nakama/package-lock.json | 29 + backend/nakama/package.json | 13 + .../tests/supabase_custom_auth.test.mjs | 210 ++++++++ backend/nakama/tsconfig.json | 18 + backend/nakama/types/nakama-runtime.d.ts | 90 ++++ docs/ARCHITECTURE.md | 84 +-- docs/SUMMARY.md | 4 + docs/adr/0002-supabase-backend.md | 2 +- docs/adr/0010-nakama-oss-game-backend.md | 130 +++++ docs/design/00-game-concept.md | 12 +- docs/design/01-pillars.md | 8 +- docs/design/02-vertical-slice-spec.md | 10 +- docs/design/03-systems-index.md | 86 +-- docs/design/05-networking-architecture.md | 47 +- docs/design/06-overview-design.md | 8 +- docs/design/07-player-controller-prototype.md | 33 +- .../10-character-profile-agent-memory.md | 399 ++++++++++++++ .../design/11-npc-agent-brain-architecture.md | 217 ++++++++ docs/setup/agent-handoff.md | 49 +- docs/setup/fusion-install.md | 2 +- docs/setup/game-gateway-cloud-run.md | 114 ++++ docs/setup/paid-assets.md | 27 + docs/setup/unity-conventions.md | 4 +- 104 files changed, 7933 insertions(+), 299 deletions(-) create mode 100644 Unity/Assets/_SecondSpawn/Art.meta create mode 100644 Unity/Assets/_SecondSpawn/Art/Animations.meta create mode 100644 Unity/Assets/_SecondSpawn/Art/Characters.meta create mode 100644 Unity/Assets/_SecondSpawn/Art/Materials.meta create mode 100644 Unity/Assets/_SecondSpawn/Art/Materials/PrototypeGround.mat create mode 100644 Unity/Assets/_SecondSpawn/Art/Materials/PrototypeGround.mat.meta create mode 100644 Unity/Assets/_SecondSpawn/Art/Models.meta create mode 100644 Unity/Assets/_SecondSpawn/Art/Textures.meta create mode 100644 Unity/Assets/_SecondSpawn/Audio.meta create mode 100644 Unity/Assets/_SecondSpawn/Editor.meta create mode 100644 Unity/Assets/_SecondSpawn/Editor/SecondSpawnVisualPrefabUtility.cs create mode 100644 Unity/Assets/_SecondSpawn/Editor/SecondSpawnVisualPrefabUtility.cs.meta create mode 100644 Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs create mode 100644 Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs.meta create mode 100644 Unity/Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs create mode 100644 Unity/Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs.meta create mode 100644 Unity/Assets/_SecondSpawn/Scripts/AI/GatewayContracts.cs create mode 100644 Unity/Assets/_SecondSpawn/Scripts/AI/GatewayContracts.cs.meta create mode 100644 Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeAgentBrain.cs create mode 100644 Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeAgentBrain.cs.meta create mode 100644 Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeLLMAgentDriver.cs create mode 100644 Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeLLMAgentDriver.cs.meta create mode 100644 Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeNPCChatClient.cs create mode 100644 Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeNPCChatClient.cs.meta create mode 100644 Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeSpeechBubble.cs create mode 100644 Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeSpeechBubble.cs.meta create mode 100644 Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeVoiceCue.cs create mode 100644 Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeVoiceCue.cs.meta create mode 100644 Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs create mode 100644 Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs.meta create mode 100644 Unity/Assets/_SecondSpawn/Scripts/Networking/AnimationEventReceiver.cs create mode 100644 Unity/Assets/_SecondSpawn/Scripts/Networking/AnimationEventReceiver.cs.meta create mode 100644 Unity/Assets/_SecondSpawn/Scripts/Networking/EquipmentVisualCatalog.cs create mode 100644 Unity/Assets/_SecondSpawn/Scripts/Networking/EquipmentVisualCatalog.cs.meta create mode 100644 Unity/Assets/_SecondSpawn/Scripts/Networking/LocalVisualPrefabLoader.cs create mode 100644 Unity/Assets/_SecondSpawn/Scripts/Networking/LocalVisualPrefabLoader.cs.meta create mode 100644 Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkAnimatorBridge.cs create mode 100644 Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkAnimatorBridge.cs.meta create mode 100644 Unity/Assets/_SecondSpawn/Scripts/Networking/PrototypeVisualActionHotkeys.cs create mode 100644 Unity/Assets/_SecondSpawn/Scripts/Networking/PrototypeVisualActionHotkeys.cs.meta create mode 100644 Unity/Assets/_SecondSpawn/Scripts/Networking/TopDownCameraFollow.cs create mode 100644 Unity/Assets/_SecondSpawn/Scripts/Networking/TopDownCameraFollow.cs.meta create mode 100644 Unity/Assets/_SecondSpawn/Scripts/Networking/VisualAnimationIntentDriver.cs create mode 100644 Unity/Assets/_SecondSpawn/Scripts/Networking/VisualAnimationIntentDriver.cs.meta create mode 100644 Unity/Assets/_SecondSpawn/Scripts/Networking/VisualPrefabCatalog.cs create mode 100644 Unity/Assets/_SecondSpawn/Scripts/Networking/VisualPrefabCatalog.cs.meta create mode 100644 backend/gateway/deploy/cloudrun.env.yaml create mode 100644 backend/gateway/internal/agent/decision.go create mode 100644 backend/gateway/internal/agent/decision_test.go create mode 100644 backend/gateway/internal/character/profile.go create mode 100644 backend/gateway/internal/character/profile_test.go create mode 100644 backend/gateway/internal/character/store.go create mode 100644 backend/gateway/internal/character/store_test.go create mode 100644 backend/nakama/.gitignore create mode 100644 backend/nakama/README.md create mode 100644 backend/nakama/local.example.yml create mode 100644 backend/nakama/modules/index.ts create mode 100644 backend/nakama/package-lock.json create mode 100644 backend/nakama/package.json create mode 100644 backend/nakama/tests/supabase_custom_auth.test.mjs create mode 100644 backend/nakama/tsconfig.json create mode 100644 backend/nakama/types/nakama-runtime.d.ts create mode 100644 docs/adr/0010-nakama-oss-game-backend.md create mode 100644 docs/design/10-character-profile-agent-memory.md create mode 100644 docs/design/11-npc-agent-brain-architecture.md create mode 100644 docs/setup/game-gateway-cloud-run.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index e80c696..1e9f9c8 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -39,6 +39,7 @@ International-friendly framing. Explained via science (Nibirium, biotech, consci - Guild PvP up to 50v50 - Top-down ARPG action combat - Time-as-currency economy: body time can be earned, spent, transferred later, and lost on body death unless explicit conversion rules say otherwise +- OpenClaw-connected NPCs: a user may connect their own OpenClaw agent into the game as an NPC-like world actor, subject to identity, consent, moderation, rate limit, and server-side intent validation - Pet system: NFT-based, 1 equip slot, NOT looted from bosses (marketplace + breeding only) - Mount system: movement only, no mounted combat (reduce animation workload) @@ -66,14 +67,14 @@ International-friendly framing. Explained via science (Nibirium, biotech, consci ### Backend -- **Current baseline:** Supabase + Go LLM Gateway remains the default until a backend ADR replaces it. -- **Nakama OSS:** Approved for a focused spike if research supports it. Do not adopt Nakama, Hiro, Satori, OpenAuth, or any new auth / social stack without an ADR and JOY approval. +- **Game backend:** Nakama OSS (ADR 0010). This is the default backend foundation for game APIs, social primitives, storage objects, activity logs, and future groups / leaderboards / matchmaking. +- **Nakama deployment mode:** self-hosted OSS for prototype and early development. Heroic Cloud is a future managed upgrade path only, not the current default. +- **Backend boundary:** Nakama is the game backend. `api.dos.ai` / Go LLM Gateway is the shared DOS.AI AI/LLM gateway only. Photon Fusion 2 dedicated server remains authoritative for in-zone movement, combat, physics, and tick simulation. +- **Custom game backend rule:** Do not create a separate game API gateway unless a Nakama runtime module cannot reasonably handle the feature. Default to Nakama server runtime modules (TypeScript / Go / Lua) for game backend extensions: auth hooks, RPCs, inventory, profile, stats, social, matchmaking, leaderboards, activity logs, and moderation. Initial Nakama modules use exact TypeScript 6.0.3 and emit Nakama-compatible JavaScript. +- **Supabase:** compatible sidecar for DOS.Me-style identity bridge, wallet/profile integration, storage, analytics, or external product data when useful. Supabase is no longer the primary game backend baseline. - **Hiro / Satori:** Commercial / license-dependent candidates only. Do not assume they are open-source drop-in dependencies. -- **Supabase Auth** (reuse DOS.Me pattern, do not invent new auth) -- **Supabase Postgres** (durable state: profile, inventory, quest progress, NFT lock state, cultivation tier) -- **Supabase Realtime** (chat global, presence, friend, party invite, notification - NOT for combat / movement sync) -- **Supabase Storage** (avatar, screenshot, UGC) -- **Go LLM Gateway** (reuse DOSRouter pattern, self-host VPS, low-latency) +- **Postgres** (durable Nakama database; local container for development or approved Supabase Postgres project if isolation and connection behavior are verified) +- **Go LLM Gateway / `api.dos.ai`** (shared AI service; provider keys, model routing, prompt safety, voice token minting, AI-specific endpoints only) - **Redis** (session, rate limit, transient cache) ### LLM @@ -85,13 +86,23 @@ International-friendly framing. Explained via science (Nibirium, biotech, consci **Phase 2 (post-MVP):** -- Migrate to Go gateway, models: +- Migrate LLM calls to `api.dos.ai` / Go LLM Gateway, models: - Haiku 4.5 for NPC chat (fast, cheap) - Sonnet 4.6 for boss / quest / cultivation master dialog - RAG memory: Supabase pgvector or Qdrant - Voice: OpenAI Realtime API via ephemeral token (NOT API key in client) OR ElevenLabs - Client AI: Unity Sentis for small perception (optional, phase 3) +### OpenClaw-Connected NPCs (CONCEPT) + +- Each OpenClaw agent can become a user-owned NPC-like actor in SECOND SPAWN. +- A player may connect their OpenClaw agent to the game so it can appear as a companion, hub NPC, merchant-like persona, quest-adjacent character, or social world citizen. +- OpenClaw agents must never mutate authoritative game state directly. They emit dialogue or structured intent only. +- Nakama owns game identity, permissions, rate limits, activity logs, and moderation state for connected agents. +- Fusion server validates any in-world action intent before movement, interaction, combat, inventory, currency, quest, or BodyTime state changes. +- `api.dos.ai` / Go LLM Gateway handles provider calls, prompt safety, memory retrieval, and context shaping for OpenClaw-connected NPC behavior. +- This is an ecosystem bridge, not a replacement for NPC dialogue, offline player agents, or the game backend. + ### LLM Safety (CRITICAL) - **Server-side intent validation ONLY** - never trust LLM output as authoritative @@ -99,7 +110,7 @@ International-friendly framing. Explained via science (Nibirium, biotech, consci - Per-NPC memory budget cap - Rate limit per player (LLM token + request count) - Prompt injection defense (reuse DOSafe patterns) -- All LLM calls go through Go gateway, never direct from Unity client +- All LLM calls go through `api.dos.ai` / Go LLM Gateway, never direct from Unity client ### AI Agent for Offline Players (CORE FEATURE) @@ -118,7 +129,7 @@ International-friendly framing. Explained via science (Nibirium, biotech, consci - Hunter skins - Option 1 (preset hero characters, may hybrid with Option 3 later) - Weapons - Pets (1 equip slot, marketplace + breeding only, no boss drop) -- **Wallet auth:** Sign-message pattern via thirdweb or Supabase + DOS Chain +- **Wallet auth:** Sign-message pattern via thirdweb, Nakama auth bridge, or Supabase + DOS Chain sidecar - **In-game lock:** Escrow contract when equipped, release on unequip - **SECOND token:** Used for reincarnation cost (token economy needs design). Keep distinct from `BodyTime` unless a future ADR explicitly merges them. @@ -143,7 +154,8 @@ International-friendly framing. Explained via science (Nibirium, biotech, consci ### Deploy - **Game server:** Linux headless Unity build on Hetzner VPS, Dockerized -- **Backend Go gateway:** VPS or Modal +- **Nakama backend:** self-hosted OSS first; Heroic Cloud only if operations become worth paying for +- **AI/LLM gateway:** `api.dos.ai` shared Go gateway - **LLM API:** Convai phase 1, then Anthropic + OpenAI phase 2 - **Monitoring:** Sentry (error) + Grafana (metrics) @@ -181,7 +193,7 @@ International-friendly framing. Explained via science (Nibirium, biotech, consci - Coplay unity-mcp (Unity Editor bridge) - thirdweb-api (DOS Chain wallet, NFT logic) -- Supabase MCP (database, auth, edge functions) +- Supabase MCP (sidecar database, auth, edge functions when used) - Cloudflare MCP (future R2 migration) ## Reference Materials @@ -225,7 +237,7 @@ International-friendly framing. Explained via science (Nibirium, biotech, consci - Unity Multiplayer Play Mode tutorial - Coplay unity-mcp + Claude Code setup guide - DOSRouter Go gateway pattern (JOY's existing repo) -- DOS.Me Supabase auth pattern (JOY's existing repo) +- DOS.Me Supabase auth pattern (JOY's existing repo, reference for identity bridge only) ## Project Conventions @@ -233,7 +245,7 @@ International-friendly framing. Explained via science (Nibirium, biotech, consci - Repo: `Second-Spawn` (matches GitHub repo name as-is) - Repo root: `D:\Projects\Second-Spawn` -- Unity project subfolder: `D:\Projects\Second-Spawn\Unity` (PascalCase, NOT at repo root - multi-stack repo: Unity at `Unity/`, Go gateway at `backend/`, docs at `docs/`) +- Unity project subfolder: `D:\Projects\Second-Spawn\Unity` (PascalCase, NOT at repo root - multi-stack repo: Unity at `Unity/`, Nakama modules at `backend/nakama/`, docs at `docs/`) - C# code: Microsoft conventions (PascalCase classes, camelCase fields with `_` prefix for private serialized) - Branches: `feat/`, `fix/`, `chore/` - **Unity-specific conventions** (folder structure, asmdef pattern, scene organization, naming rules): see [docs/setup/unity-conventions.md](docs/setup/unity-conventions.md). MUST follow before creating, renaming, or organizing any Unity asset, script, or folder. @@ -288,7 +300,7 @@ Scope: - 2 cultivation tiers playable (Awakening + Enhancement) - NFT Hunter skin equip + escrow - Multiplayer 4-20 players per zone -- Basic chat (Supabase Realtime) +- Basic chat (Nakama channels first, Supabase sidecar only if useful) OUT of scope for vertical slice: @@ -304,9 +316,9 @@ OUT of scope for vertical slice: 1. **NEVER copy MetaDOS gameplay code.** Extract patterns only. Reference path: `D:\Projects\MetaDOS` (read-only). 2. **NEVER let LLM mutate authoritative game state.** Server validates all intent. -3. **NEVER put API keys (Anthropic, OpenAI, Convai, ElevenLabs) in Unity client.** All LLM calls go through Go gateway. +3. **NEVER put API keys (Anthropic, OpenAI, Convai, ElevenLabs) in Unity client.** All LLM calls go through `api.dos.ai` / Go LLM Gateway. 4. **NEVER use Host Mode for production.** Server Mode dedicated only. -5. **NEVER add or replace backend / auth / social stack without an ADR and JOY approval.** Supabase + DOS.Me patterns remain the baseline. Nakama OSS may be spiked if the ADR keeps Fusion authoritative gameplay, Supabase identity compatibility, and clear exit criteria. +5. **NEVER add or replace backend / auth / social stack without an ADR and JOY approval.** Nakama OSS is the accepted game backend baseline per ADR 0010. Heroic Cloud, Hiro, Satori, OpenAuth, PlayFab, AccelByte, or a Supabase-first rollback require a new ADR. 6. **NEVER change Unity Asset Serialization away from Force Text.** Breaks LFS + diff. 7. **NEVER claim "done" without reviewer pass** (JOY is non-coder, cannot review code himself). 8. **ALWAYS edit BOTH `.claude/CLAUDE.md` and `AGENTS.md` together when updating project context.** They are sister files - Claude Code auto-loads CLAUDE.md, Codex CLI / Cursor / Copilot auto-load AGENTS.md. Edit one without the other = drift; the un-updated file lies to whichever agent reads it. Both files MUST be identical except for the sister-file comment header at line 1. @@ -319,6 +331,6 @@ OUT of scope for vertical slice: - Hunter NFT integration approach: Option 1 (preset hero) vs Hybrid 1+3 (modular pieces) - Phase 2 LLM model split (when to use Haiku vs Sonnet) - Voice NPC vendor (OpenAI Realtime vs ElevenLabs vs self-host) -- Backend platform selection: Supabase-first thin backend vs Nakama OSS game backend + Supabase identity bridge. Hiro / Satori require license and pricing review before adoption. +- Backend deployment path: self-hosted Nakama OSS vs Heroic Cloud later. Hiro / Satori require license and pricing review before adoption. - Dedicated server hosting (Hetzner specs, region) - Photon Fusion 2 license tier when scaling beyond Cloud free 20 CCU diff --git a/.gitignore b/.gitignore index 550ac7b..3d5e87e 100644 --- a/.gitignore +++ b/.gitignore @@ -95,6 +95,12 @@ PhotonServerSettings.asset.local Unity/Assets/ExplosiveLLC/ Unity/Assets/ExplosiveLLC.meta +# Local clean visual prefabs generated from ignored Asset Store character packs. +Unity/Assets/_SecondSpawn/Art/Characters/GeneratedVisuals/ +Unity/Assets/_SecondSpawn/Art/Characters/GeneratedVisuals.meta +Unity/Assets/_SecondSpawn/Art/Characters/GeneratedVisualsV2/ +Unity/Assets/_SecondSpawn/Art/Characters/GeneratedVisualsV2.meta + # Claude Code worktrees (ephemeral, per-session branches) .claude/worktrees/ @@ -103,3 +109,12 @@ Unity/Assets/ExplosiveLLC.meta # Codex CLI working dir (auto-regenerated mirror of .claude/skills/, do not commit) .agents/ + +# Local agent/editor scratch artifacts +.tmp-import/ +Unity/Assets/Screenshots/ +Unity/Assets/Screenshots.meta +Unity/Assets/_Recovery/ +Unity/Assets/_Recovery.meta +Unity/Assets/AI Toolkit/ +Unity/Assets/AI Toolkit.meta diff --git a/AGENTS.md b/AGENTS.md index b8181bf..5d20809 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,7 @@ International-friendly framing. Explained via science (Nibirium, biotech, consci - Guild PvP up to 50v50 - Top-down ARPG action combat - Time-as-currency economy: body time can be earned, spent, transferred later, and lost on body death unless explicit conversion rules say otherwise +- OpenClaw-connected NPCs: a user may connect their own OpenClaw agent into the game as an NPC-like world actor, subject to identity, consent, moderation, rate limit, and server-side intent validation - Pet system: NFT-based, 1 equip slot, NOT looted from bosses (marketplace + breeding only) - Mount system: movement only, no mounted combat (reduce animation workload) @@ -66,14 +67,14 @@ International-friendly framing. Explained via science (Nibirium, biotech, consci ### Backend -- **Current baseline:** Supabase + Go LLM Gateway remains the default until a backend ADR replaces it. -- **Nakama OSS:** Approved for a focused spike if research supports it. Do not adopt Nakama, Hiro, Satori, OpenAuth, or any new auth / social stack without an ADR and JOY approval. +- **Game backend:** Nakama OSS (ADR 0010). This is the default backend foundation for game APIs, social primitives, storage objects, activity logs, and future groups / leaderboards / matchmaking. +- **Nakama deployment mode:** self-hosted OSS for prototype and early development. Heroic Cloud is a future managed upgrade path only, not the current default. +- **Backend boundary:** Nakama is the game backend. `api.dos.ai` / Go LLM Gateway is the shared DOS.AI AI/LLM gateway only. Photon Fusion 2 dedicated server remains authoritative for in-zone movement, combat, physics, and tick simulation. +- **Custom game backend rule:** Do not create a separate game API gateway unless a Nakama runtime module cannot reasonably handle the feature. Default to Nakama server runtime modules (TypeScript / Go / Lua) for game backend extensions: auth hooks, RPCs, inventory, profile, stats, social, matchmaking, leaderboards, activity logs, and moderation. Initial Nakama modules use exact TypeScript 6.0.3 and emit Nakama-compatible JavaScript. +- **Supabase:** compatible sidecar for DOS.Me-style identity bridge, wallet/profile integration, storage, analytics, or external product data when useful. Supabase is no longer the primary game backend baseline. - **Hiro / Satori:** Commercial / license-dependent candidates only. Do not assume they are open-source drop-in dependencies. -- **Supabase Auth** (reuse DOS.Me pattern, do not invent new auth) -- **Supabase Postgres** (durable state: profile, inventory, quest progress, NFT lock state, cultivation tier) -- **Supabase Realtime** (chat global, presence, friend, party invite, notification - NOT for combat / movement sync) -- **Supabase Storage** (avatar, screenshot, UGC) -- **Go LLM Gateway** (reuse DOSRouter pattern, self-host VPS, low-latency) +- **Postgres** (durable Nakama database; local container for development or approved Supabase Postgres project if isolation and connection behavior are verified) +- **Go LLM Gateway / `api.dos.ai`** (shared AI service; provider keys, model routing, prompt safety, voice token minting, AI-specific endpoints only) - **Redis** (session, rate limit, transient cache) ### LLM @@ -85,13 +86,23 @@ International-friendly framing. Explained via science (Nibirium, biotech, consci **Phase 2 (post-MVP):** -- Migrate to Go gateway, models: +- Migrate LLM calls to `api.dos.ai` / Go LLM Gateway, models: - Haiku 4.5 for NPC chat (fast, cheap) - Sonnet 4.6 for boss / quest / cultivation master dialog - RAG memory: Supabase pgvector or Qdrant - Voice: OpenAI Realtime API via ephemeral token (NOT API key in client) OR ElevenLabs - Client AI: Unity Sentis for small perception (optional, phase 3) +### OpenClaw-Connected NPCs (CONCEPT) + +- Each OpenClaw agent can become a user-owned NPC-like actor in SECOND SPAWN. +- A player may connect their OpenClaw agent to the game so it can appear as a companion, hub NPC, merchant-like persona, quest-adjacent character, or social world citizen. +- OpenClaw agents must never mutate authoritative game state directly. They emit dialogue or structured intent only. +- Nakama owns game identity, permissions, rate limits, activity logs, and moderation state for connected agents. +- Fusion server validates any in-world action intent before movement, interaction, combat, inventory, currency, quest, or BodyTime state changes. +- `api.dos.ai` / Go LLM Gateway handles provider calls, prompt safety, memory retrieval, and context shaping for OpenClaw-connected NPC behavior. +- This is an ecosystem bridge, not a replacement for NPC dialogue, offline player agents, or the game backend. + ### LLM Safety (CRITICAL) - **Server-side intent validation ONLY** - never trust LLM output as authoritative @@ -99,7 +110,7 @@ International-friendly framing. Explained via science (Nibirium, biotech, consci - Per-NPC memory budget cap - Rate limit per player (LLM token + request count) - Prompt injection defense (reuse DOSafe patterns) -- All LLM calls go through Go gateway, never direct from Unity client +- All LLM calls go through `api.dos.ai` / Go LLM Gateway, never direct from Unity client ### AI Agent for Offline Players (CORE FEATURE) @@ -118,7 +129,7 @@ International-friendly framing. Explained via science (Nibirium, biotech, consci - Hunter skins - Option 1 (preset hero characters, may hybrid with Option 3 later) - Weapons - Pets (1 equip slot, marketplace + breeding only, no boss drop) -- **Wallet auth:** Sign-message pattern via thirdweb or Supabase + DOS Chain +- **Wallet auth:** Sign-message pattern via thirdweb, Nakama auth bridge, or Supabase + DOS Chain sidecar - **In-game lock:** Escrow contract when equipped, release on unequip - **SECOND token:** Used for reincarnation cost (token economy needs design). Keep distinct from `BodyTime` unless a future ADR explicitly merges them. @@ -143,7 +154,8 @@ International-friendly framing. Explained via science (Nibirium, biotech, consci ### Deploy - **Game server:** Linux headless Unity build on Hetzner VPS, Dockerized -- **Backend Go gateway:** VPS or Modal +- **Nakama backend:** self-hosted OSS first; Heroic Cloud only if operations become worth paying for +- **AI/LLM gateway:** `api.dos.ai` shared Go gateway - **LLM API:** Convai phase 1, then Anthropic + OpenAI phase 2 - **Monitoring:** Sentry (error) + Grafana (metrics) @@ -181,7 +193,7 @@ International-friendly framing. Explained via science (Nibirium, biotech, consci - Coplay unity-mcp (Unity Editor bridge) - thirdweb-api (DOS Chain wallet, NFT logic) -- Supabase MCP (database, auth, edge functions) +- Supabase MCP (sidecar database, auth, edge functions when used) - Cloudflare MCP (future R2 migration) ## Reference Materials @@ -225,7 +237,7 @@ International-friendly framing. Explained via science (Nibirium, biotech, consci - Unity Multiplayer Play Mode tutorial - Coplay unity-mcp + Claude Code setup guide - DOSRouter Go gateway pattern (JOY's existing repo) -- DOS.Me Supabase auth pattern (JOY's existing repo) +- DOS.Me Supabase auth pattern (JOY's existing repo, reference for identity bridge only) ## Project Conventions @@ -233,7 +245,7 @@ International-friendly framing. Explained via science (Nibirium, biotech, consci - Repo: `Second-Spawn` (matches GitHub repo name as-is) - Repo root: `D:\Projects\Second-Spawn` -- Unity project subfolder: `D:\Projects\Second-Spawn\Unity` (PascalCase, NOT at repo root - multi-stack repo: Unity at `Unity/`, Go gateway at `backend/`, docs at `docs/`) +- Unity project subfolder: `D:\Projects\Second-Spawn\Unity` (PascalCase, NOT at repo root - multi-stack repo: Unity at `Unity/`, Nakama modules at `backend/nakama/`, docs at `docs/`) - C# code: Microsoft conventions (PascalCase classes, camelCase fields with `_` prefix for private serialized) - Branches: `feat/`, `fix/`, `chore/` - **Unity-specific conventions** (folder structure, asmdef pattern, scene organization, naming rules): see [docs/setup/unity-conventions.md](docs/setup/unity-conventions.md). MUST follow before creating, renaming, or organizing any Unity asset, script, or folder. @@ -288,7 +300,7 @@ Scope: - 2 cultivation tiers playable (Awakening + Enhancement) - NFT Hunter skin equip + escrow - Multiplayer 4-20 players per zone -- Basic chat (Supabase Realtime) +- Basic chat (Nakama channels first, Supabase sidecar only if useful) OUT of scope for vertical slice: @@ -304,9 +316,9 @@ OUT of scope for vertical slice: 1. **NEVER copy MetaDOS gameplay code.** Extract patterns only. Reference path: `D:\Projects\MetaDOS` (read-only). 2. **NEVER let LLM mutate authoritative game state.** Server validates all intent. -3. **NEVER put API keys (Anthropic, OpenAI, Convai, ElevenLabs) in Unity client.** All LLM calls go through Go gateway. +3. **NEVER put API keys (Anthropic, OpenAI, Convai, ElevenLabs) in Unity client.** All LLM calls go through `api.dos.ai` / Go LLM Gateway. 4. **NEVER use Host Mode for production.** Server Mode dedicated only. -5. **NEVER add or replace backend / auth / social stack without an ADR and JOY approval.** Supabase + DOS.Me patterns remain the baseline. Nakama OSS may be spiked if the ADR keeps Fusion authoritative gameplay, Supabase identity compatibility, and clear exit criteria. +5. **NEVER add or replace backend / auth / social stack without an ADR and JOY approval.** Nakama OSS is the accepted game backend baseline per ADR 0010. Heroic Cloud, Hiro, Satori, OpenAuth, PlayFab, AccelByte, or a Supabase-first rollback require a new ADR. 6. **NEVER change Unity Asset Serialization away from Force Text.** Breaks LFS + diff. 7. **NEVER claim "done" without reviewer pass** (JOY is non-coder, cannot review code himself). 8. **ALWAYS edit BOTH `.claude/CLAUDE.md` and `AGENTS.md` together when updating project context.** They are sister files - Claude Code auto-loads CLAUDE.md, Codex CLI / Cursor / Copilot auto-load AGENTS.md. Edit one without the other = drift; the un-updated file lies to whichever agent reads it. Both files MUST be identical except for the sister-file comment header at line 1. @@ -319,6 +331,6 @@ OUT of scope for vertical slice: - Hunter NFT integration approach: Option 1 (preset hero) vs Hybrid 1+3 (modular pieces) - Phase 2 LLM model split (when to use Haiku vs Sonnet) - Voice NPC vendor (OpenAI Realtime vs ElevenLabs vs self-host) -- Backend platform selection: Supabase-first thin backend vs Nakama OSS game backend + Supabase identity bridge. Hiro / Satori require license and pricing review before adoption. +- Backend deployment path: self-hosted Nakama OSS vs Heroic Cloud later. Hiro / Satori require license and pricing review before adoption. - Dedicated server hosting (Hetzner specs, region) - Photon Fusion 2 license tier when scaling beyond Cloud free 20 CCU diff --git a/Unity/Assets/_SecondSpawn/Art.meta b/Unity/Assets/_SecondSpawn/Art.meta new file mode 100644 index 0000000..5c42a1a --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Art.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 818f5cf6ac136c44b9e86948000feb16 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/_SecondSpawn/Art/Animations.meta b/Unity/Assets/_SecondSpawn/Art/Animations.meta new file mode 100644 index 0000000..972bcb0 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Art/Animations.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ce8bd7e29f2d5b64682b39ba596b3016 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/_SecondSpawn/Art/Characters.meta b/Unity/Assets/_SecondSpawn/Art/Characters.meta new file mode 100644 index 0000000..16ae184 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Art/Characters.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 55e1af318617ea944bc1d74ae79b1a68 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/_SecondSpawn/Art/Materials.meta b/Unity/Assets/_SecondSpawn/Art/Materials.meta new file mode 100644 index 0000000..a126936 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Art/Materials.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1893fb3679d534945abc7d4f3bd512c8 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/_SecondSpawn/Art/Materials/PrototypeGround.mat b/Unity/Assets/_SecondSpawn/Art/Materials/PrototypeGround.mat new file mode 100644 index 0000000..7baf099 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Art/Materials/PrototypeGround.mat @@ -0,0 +1,137 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &-7597370679365457702 +MonoBehaviour: + m_ObjectHideFlags: 11 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: d0353a89b1f911e48b9e16bdc9f2e058, type: 3} + m_Name: + m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Editor::UnityEditor.Rendering.Universal.AssetVersion + version: 10 +--- !u!21 &2100000 +Material: + serializedVersion: 8 + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: PrototypeGround + m_Shader: {fileID: 4800000, guid: 933532a4fcc9baf4fa0491de14d08ed7, type: 3} + m_Parent: {fileID: 0} + m_ModifiedSerializedProperties: 0 + m_ValidKeywords: [] + m_InvalidKeywords: [] + m_LightmapFlags: 4 + m_EnableInstancingVariants: 0 + m_DoubleSidedGI: 0 + m_CustomRenderQueue: -1 + stringTagMap: + RenderType: Opaque + disabledShaderPasses: + - MOTIONVECTORS + m_LockedProperties: + m_SavedProperties: + serializedVersion: 3 + m_TexEnvs: + - _BaseMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _BumpMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _DetailAlbedoMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _DetailMask: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _DetailNormalMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _EmissionMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _MainTex: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _MetallicGlossMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _OcclusionMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _ParallaxMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _SpecGlossMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - unity_Lightmaps: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - unity_LightmapsInd: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - unity_ShadowMasks: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + m_Ints: [] + m_Floats: + - _AddPrecomputedVelocity: 0 + - _AlphaClip: 0 + - _AlphaToMask: 0 + - _Blend: 0 + - _BlendModePreserveSpecular: 1 + - _BumpScale: 1 + - _ClearCoatMask: 0 + - _ClearCoatSmoothness: 0 + - _Cull: 2 + - _Cutoff: 0.5 + - _DetailAlbedoMapScale: 1 + - _DetailNormalMapScale: 1 + - _DstBlend: 0 + - _DstBlendAlpha: 0 + - _EnvironmentReflections: 1 + - _GlossMapScale: 0 + - _Glossiness: 0 + - _GlossyReflections: 0 + - _Metallic: 0 + - _OcclusionStrength: 1 + - _Parallax: 0.005 + - _QueueOffset: 0 + - _ReceiveShadows: 1 + - _Smoothness: 0.5 + - _SmoothnessTextureChannel: 0 + - _SpecularHighlights: 1 + - _SrcBlend: 1 + - _SrcBlendAlpha: 1 + - _Surface: 0 + - _WorkflowMode: 1 + - _XRMotionVectorsPass: 1 + - _ZWrite: 1 + m_Colors: + - _BaseColor: {r: 0.17, g: 0.22, b: 0.2, a: 1} + - _Color: {r: 0.16999996, g: 0.21999997, b: 0.19999996, a: 1} + - _EmissionColor: {r: 0, g: 0, b: 0, a: 1} + - _SpecColor: {r: 0.19999996, g: 0.19999996, b: 0.19999996, a: 1} + m_BuildTextureStacks: [] + m_AllowLocking: 1 diff --git a/Unity/Assets/_SecondSpawn/Art/Materials/PrototypeGround.mat.meta b/Unity/Assets/_SecondSpawn/Art/Materials/PrototypeGround.mat.meta new file mode 100644 index 0000000..c5e32d2 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Art/Materials/PrototypeGround.mat.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e43486906d22c3b44b3ec001b79e0669 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 2100000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/_SecondSpawn/Art/Models.meta b/Unity/Assets/_SecondSpawn/Art/Models.meta new file mode 100644 index 0000000..cc39f51 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Art/Models.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7bc8bb14c5628b54caabf824b36e6fbb +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/_SecondSpawn/Art/Textures.meta b/Unity/Assets/_SecondSpawn/Art/Textures.meta new file mode 100644 index 0000000..72baa56 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Art/Textures.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5b5a86a88a953ba4fb1ae7ae62f7aba4 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/_SecondSpawn/Audio.meta b/Unity/Assets/_SecondSpawn/Audio.meta new file mode 100644 index 0000000..1ff119e --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Audio.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b7ed7ee527dc8c84780d20fb8a849c55 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/_SecondSpawn/Editor.meta b/Unity/Assets/_SecondSpawn/Editor.meta new file mode 100644 index 0000000..e466d99 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: bf71f2ee61d512a42b1e9888aa748eef +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/_SecondSpawn/Editor/SecondSpawnVisualPrefabUtility.cs b/Unity/Assets/_SecondSpawn/Editor/SecondSpawnVisualPrefabUtility.cs new file mode 100644 index 0000000..215f779 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Editor/SecondSpawnVisualPrefabUtility.cs @@ -0,0 +1,331 @@ +#if UNITY_EDITOR +using System.IO; +using System.Collections.Generic; +using SecondSpawn.Networking; +using UnityEditor; +using UnityEngine; +using UnityEngine.AI; + +namespace SecondSpawn.EditorTools +{ + public static class SecondSpawnVisualPrefabUtility + { + [MenuItem("Second Spawn/Art/Rebuild Generated Visual Prefabs")] + public static void RebuildGeneratedVisualPrefabs() + { + EnsureFolder(VisualPrefabCatalog.CleanVisualFolder); + EnsureFolder(VisualPrefabCatalog.CleanMaterialFolder); + DeleteExistingGeneratedPrefabs(); + DeleteExistingGeneratedMaterials(); + + var generatedCount = 0; + var materialCache = new Dictionary(); + for (var i = 0; i < VisualPrefabCatalog.Count; i++) + { + var sourcePath = VisualPrefabCatalog.GetSourceAssetPath(i); + var sourcePrefab = AssetDatabase.LoadAssetAtPath(sourcePath); + if (sourcePrefab == null) + { + Debug.LogWarning($"[SecondSpawnVisualPrefabUtility] Source visual prefab missing: {sourcePath}"); + continue; + } + + var instance = PrefabUtility.InstantiatePrefab(sourcePrefab) as GameObject; + if (instance == null) + { + instance = Object.Instantiate(sourcePrefab); + } + + instance.name = Path.GetFileNameWithoutExtension(VisualPrefabCatalog.GetCleanPrefabName(i)); + try + { + if (PrefabUtility.IsPartOfPrefabInstance(instance)) + { + PrefabUtility.UnpackPrefabInstance(instance, PrefabUnpackMode.Completely, InteractionMode.AutomatedAction); + } + + StripGameplayScripts(instance); + StripNonVisualRuntimeComponents(instance); + PrepareVisualRoot(instance); + ConvertMaterialsToUrp(instance, materialCache); + EquipmentVisualCatalog.ApplyEquipmentVisual(instance, EquipmentVisualCatalog.GetDefaultForVisualVariant(i)); + + var cleanPath = VisualPrefabCatalog.GetCleanAssetPath(i); + PrefabUtility.SaveAsPrefabAsset(instance, cleanPath); + generatedCount++; + } + finally + { + Object.DestroyImmediate(instance); + } + } + + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + Debug.Log($"[SecondSpawnVisualPrefabUtility] Generated {generatedCount} clean visual prefab(s)."); + } + + private static void DeleteExistingGeneratedPrefabs() + { + var prefabGuids = AssetDatabase.FindAssets("t:Prefab", new[] { VisualPrefabCatalog.CleanVisualFolder }); + foreach (var guid in prefabGuids) + { + var prefabPath = AssetDatabase.GUIDToAssetPath(guid); + if (prefabPath.StartsWith(VisualPrefabCatalog.CleanVisualFolder, System.StringComparison.Ordinal)) + { + AssetDatabase.DeleteAsset(prefabPath); + } + } + } + + private static void DeleteExistingGeneratedMaterials() + { + if (!AssetDatabase.IsValidFolder(VisualPrefabCatalog.CleanMaterialFolder)) + { + return; + } + + var materialGuids = AssetDatabase.FindAssets("t:Material", new[] { VisualPrefabCatalog.CleanMaterialFolder }); + foreach (var guid in materialGuids) + { + var materialPath = AssetDatabase.GUIDToAssetPath(guid); + if (materialPath.StartsWith(VisualPrefabCatalog.CleanMaterialFolder, System.StringComparison.Ordinal)) + { + AssetDatabase.DeleteAsset(materialPath); + } + } + } + + private static void StripGameplayScripts(GameObject root) + { + foreach (var transform in root.GetComponentsInChildren(includeInactive: true)) + { + var gameObject = transform.gameObject; + GameObjectUtility.RemoveMonoBehavioursWithMissingScript(gameObject); + + foreach (var behaviour in gameObject.GetComponents()) + { + if (behaviour != null) + { + Object.DestroyImmediate(behaviour); + } + } + } + } + + private static void StripNonVisualRuntimeComponents(GameObject root) + { + foreach (var agent in root.GetComponentsInChildren(includeInactive: true)) + { + Object.DestroyImmediate(agent); + } + + foreach (var controller in root.GetComponentsInChildren(includeInactive: true)) + { + Object.DestroyImmediate(controller); + } + + foreach (var collider in root.GetComponentsInChildren(includeInactive: true)) + { + Object.DestroyImmediate(collider); + } + + foreach (var joint in root.GetComponentsInChildren(includeInactive: true)) + { + Object.DestroyImmediate(joint); + } + + foreach (var rigidbody in root.GetComponentsInChildren(includeInactive: true)) + { + Object.DestroyImmediate(rigidbody); + } + } + + private static void PrepareVisualRoot(GameObject root) + { + foreach (var transform in root.GetComponentsInChildren(includeInactive: true)) + { + var gameObject = transform.gameObject; + gameObject.tag = "Untagged"; + gameObject.layer = 0; + } + + root.transform.SetPositionAndRotation(Vector3.zero, Quaternion.identity); + root.transform.localScale = Vector3.one; + } + + private static void ConvertMaterialsToUrp(GameObject root, Dictionary materialCache) + { + var urpShader = Shader.Find("Universal Render Pipeline/Simple Lit") ?? + Shader.Find("Universal Render Pipeline/Lit"); + if (urpShader == null) + { + Debug.LogWarning("[SecondSpawnVisualPrefabUtility] URP shader not found. Generated visuals keep source materials."); + return; + } + + foreach (var renderer in root.GetComponentsInChildren(includeInactive: true)) + { + var sourceMaterials = renderer.sharedMaterials; + var convertedMaterials = new Material[sourceMaterials.Length]; + var changed = false; + + for (var i = 0; i < sourceMaterials.Length; i++) + { + var sourceMaterial = sourceMaterials[i]; + convertedMaterials[i] = GetOrCreateUrpMaterial(sourceMaterial, urpShader, materialCache); + changed |= convertedMaterials[i] != sourceMaterial; + } + + if (changed) + { + renderer.sharedMaterials = convertedMaterials; + EditorUtility.SetDirty(renderer); + } + } + } + + private static Material GetOrCreateUrpMaterial( + Material sourceMaterial, + Shader urpShader, + Dictionary materialCache) + { + if (sourceMaterial == null) + { + return null; + } + + if (sourceMaterial.shader != null && sourceMaterial.shader.name.StartsWith("Universal Render Pipeline/", System.StringComparison.Ordinal)) + { + return sourceMaterial; + } + + if (materialCache.TryGetValue(sourceMaterial, out var cachedMaterial)) + { + return cachedMaterial; + } + + var materialName = $"{SanitizeFileName(sourceMaterial.name)}_URP"; + var materialPath = AssetDatabase.GenerateUniqueAssetPath($"{VisualPrefabCatalog.CleanMaterialFolder}/{materialName}.mat"); + var convertedMaterial = new Material(urpShader) + { + name = Path.GetFileNameWithoutExtension(materialPath), + enableInstancing = sourceMaterial.enableInstancing, + doubleSidedGI = sourceMaterial.doubleSidedGI + }; + + CopyColor(sourceMaterial, convertedMaterial); + CopyMainTexture(sourceMaterial, convertedMaterial); + CopyEmission(sourceMaterial, convertedMaterial); + + AssetDatabase.CreateAsset(convertedMaterial, materialPath); + materialCache[sourceMaterial] = convertedMaterial; + return convertedMaterial; + } + + private static void CopyColor(Material sourceMaterial, Material convertedMaterial) + { + var color = Color.white; + if (sourceMaterial.HasProperty("_BaseColor")) + { + color = sourceMaterial.GetColor("_BaseColor"); + } + else if (sourceMaterial.HasProperty("_Color")) + { + color = sourceMaterial.GetColor("_Color"); + } + + if (convertedMaterial.HasProperty("_BaseColor")) + { + convertedMaterial.SetColor("_BaseColor", color); + } + else if (convertedMaterial.HasProperty("_Color")) + { + convertedMaterial.SetColor("_Color", color); + } + } + + private static void CopyMainTexture(Material sourceMaterial, Material convertedMaterial) + { + var textureProperty = sourceMaterial.HasProperty("_BaseMap") ? "_BaseMap" : "_MainTex"; + Texture mainTexture = null; + if (sourceMaterial.HasProperty(textureProperty)) + { + mainTexture = sourceMaterial.GetTexture(textureProperty); + } + + if (mainTexture == null) + { + return; + } + + var targetProperty = convertedMaterial.HasProperty("_BaseMap") ? "_BaseMap" : "_MainTex"; + if (!convertedMaterial.HasProperty(targetProperty)) + { + return; + } + + convertedMaterial.SetTexture(targetProperty, mainTexture); + convertedMaterial.SetTextureScale(targetProperty, sourceMaterial.GetTextureScale(textureProperty)); + convertedMaterial.SetTextureOffset(targetProperty, sourceMaterial.GetTextureOffset(textureProperty)); + } + + private static void CopyEmission(Material sourceMaterial, Material convertedMaterial) + { + Texture emissionTexture = null; + if (sourceMaterial.HasProperty("_EmissionMap")) + { + emissionTexture = sourceMaterial.GetTexture("_EmissionMap"); + } + else if (sourceMaterial.HasProperty("_Illum")) + { + emissionTexture = sourceMaterial.GetTexture("_Illum"); + } + + if (emissionTexture == null || !convertedMaterial.HasProperty("_EmissionMap")) + { + return; + } + + convertedMaterial.SetTexture("_EmissionMap", emissionTexture); + if (convertedMaterial.HasProperty("_EmissionColor")) + { + convertedMaterial.SetColor("_EmissionColor", Color.white); + } + + convertedMaterial.EnableKeyword("_EMISSION"); + } + + private static void EnsureFolder(string assetFolder) + { + var segments = assetFolder.Split('/'); + var current = segments[0]; + for (var i = 1; i < segments.Length; i++) + { + var next = $"{current}/{segments[i]}"; + if (!AssetDatabase.IsValidFolder(next)) + { + AssetDatabase.CreateFolder(current, segments[i]); + } + + current = next; + } + } + + private static string SanitizeFileName(string value) + { + var chars = value.ToCharArray(); + for (var i = 0; i < chars.Length; i++) + { + var c = chars[i]; + if (!(char.IsLetterOrDigit(c) || c == '-' || c == '_')) + { + chars[i] = '_'; + } + } + + return new string(chars); + } + } +} +#endif diff --git a/Unity/Assets/_SecondSpawn/Editor/SecondSpawnVisualPrefabUtility.cs.meta b/Unity/Assets/_SecondSpawn/Editor/SecondSpawnVisualPrefabUtility.cs.meta new file mode 100644 index 0000000..02341b2 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Editor/SecondSpawnVisualPrefabUtility.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9cf83e5f725d027438fb0da82a4912ef \ No newline at end of file diff --git a/Unity/Assets/_SecondSpawn/Prefabs/Player_NetworkCube.prefab b/Unity/Assets/_SecondSpawn/Prefabs/Player_NetworkCube.prefab index 8119b2b..e0d6828 100644 --- a/Unity/Assets/_SecondSpawn/Prefabs/Player_NetworkCube.prefab +++ b/Unity/Assets/_SecondSpawn/Prefabs/Player_NetworkCube.prefab @@ -15,6 +15,10 @@ GameObject: - component: {fileID: 8357749372956170795} - component: {fileID: 8359936860818564255} - component: {fileID: 7123456789012345678} + - component: {fileID: 6129384756102938475} + - component: {fileID: 3400762132235786765} + - component: {fileID: 1778901195370216267} + - component: {fileID: 586566440311235492} m_Layer: 0 m_Name: Player_NetworkCube m_TagString: Untagged @@ -140,6 +144,7 @@ MonoBehaviour: NetworkedBehaviours: - {fileID: 7123456789012345678} - {fileID: 8359936860818564255} + - {fileID: 6129384756102938475} ForceRemoteRenderTimeframe: 0 --- !u!114 &8359936860818564255 MonoBehaviour: @@ -156,9 +161,12 @@ MonoBehaviour: _CultivationTier: 0 _Hp: 0 _Stamina: 0 + _VisualVariant: 0 _IsAgentControlled: RawValue: 0 _moveSpeed: 5 + _walkSpeed: 2.2 + _jumpImpulse: 7.5 --- !u!114 &7123456789012345678 MonoBehaviour: m_ObjectHideFlags: 0 @@ -196,3 +204,79 @@ MonoBehaviour: StepRequireGroundTarget: 0 SnapDistance: 0.4 SnapSpeed: 4 +--- !u!114 &6129384756102938475 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 5762444949739102863} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 9f51f6baf6b447e4b8fd7e7f7fdd3df8, type: 3} + m_Name: + m_EditorClassIdentifier: SecondSpawn.Networking::SecondSpawn.Networking.NetworkAnimatorBridge + _NetSpeed: 0 + _NetVelocityX: 0 + _NetVelocityZ: 0 + _NetJumping: 0 + _animator: {fileID: 0} + _referenceMoveSpeed: 5 + _movingParameter: Moving + _velocityXParameter: Velocity X + _velocityZParameter: Velocity Z + _velocityParameter: Velocity + _animationSpeedParameter: AnimationSpeed + _animationSpeedSpacedParameter: Animation Speed + _weaponParameter: Weapon + _defaultWeaponValue: -1 + _jumpingParameter: Jumping + _triggerNumberParameter: TriggerNumber + _triggerNumberSpacedParameter: Trigger Number + _triggerParameter: Trigger + _fallVelocityThreshold: 0.8 +--- !u!114 &3400762132235786765 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 5762444949739102863} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 778cc9b36fce47cfb206fdaf99bb5b71, type: 3} + m_Name: + m_EditorClassIdentifier: SecondSpawn.Networking::SecondSpawn.Networking.LocalVisualPrefabLoader + _resourcePath: SecondSpawn/RPGCharacterVisual + _localPosition: {x: 0, y: 0, z: 0} + _localEulerAngles: {x: 0, y: 0, z: 0} + _localScale: {x: 1, y: 1, z: 1} + _hideRootRenderers: 1 +--- !u!114 &1778901195370216267 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 5762444949739102863} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 55a4c759766b46d9a6d9c8085b86fe6c, type: 3} + m_Name: + m_EditorClassIdentifier: SecondSpawn.Networking::SecondSpawn.Networking.PrototypeVisualActionHotkeys +--- !u!114 &586566440311235492 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 5762444949739102863} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: c82a1923923545c408fda6f62a7bde81, type: 3} + m_Name: + m_EditorClassIdentifier: SecondSpawn.AI::SecondSpawn.AI.PrototypeLLMAgentDriver + _enableOnStart: 0 + _decisionIntervalSeconds: 1.25 + _moveHoldSeconds: 0.9 + _zoneId: prototype-hub diff --git a/Unity/Assets/_SecondSpawn/Scenes/ZoneTest_Hub.unity b/Unity/Assets/_SecondSpawn/Scenes/ZoneTest_Hub.unity index 4ec81a7..d080741 100644 --- a/Unity/Assets/_SecondSpawn/Scenes/ZoneTest_Hub.unity +++ b/Unity/Assets/_SecondSpawn/Scenes/ZoneTest_Hub.unity @@ -119,6 +119,86 @@ NavMeshSettings: debug: m_Flags: 0 m_NavMeshData: {fileID: 0} +--- !u!1 &34500814 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 34500818} + - component: {fileID: 34500817} + - component: {fileID: 34500816} + - component: {fileID: 34500815} + m_Layer: 0 + m_Name: _AgentGateway + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &34500815 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 34500814} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 76fb87f4a58cc92408c225afd884d6ba, type: 3} + m_Name: + m_EditorClassIdentifier: SecondSpawn.AI::SecondSpawn.AI.PrototypeNPCChatClient + _npcId: prototype-guide + _prototypeMessage: What should this body remember while I am offline? + _talkKey: 29 + _voiceKey: 36 +--- !u!114 &34500816 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 34500814} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 65e03666f59a10346bb722e738f8ea78, type: 3} + m_Name: + m_EditorClassIdentifier: SecondSpawn.AI::SecondSpawn.AI.CharacterMemorySync + _syncOnStart: 1 + _seedPrototypeMemory: 1 + _prototypeMemory: JOY wants overnight prototype progress without client-side LLM + secrets. +--- !u!114 &34500817 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 34500814} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: e7ac6c7e9346bb541b4657dbc1e8581d, type: 3} + m_Name: + m_EditorClassIdentifier: SecondSpawn.AI::SecondSpawn.AI.SecondSpawnGatewayClient + _gatewayBaseUrl: https://second-spawn-gateway-535583621422.asia-southeast1.run.app + _playerId: dev-player +--- !u!4 &34500818 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 34500814} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &47583031 GameObject: m_ObjectHideFlags: 0 @@ -194,6 +274,7 @@ GameObject: - component: {fileID: 330585545} - component: {fileID: 330585544} - component: {fileID: 330585547} + - component: {fileID: 330585548} m_Layer: 0 m_Name: PlayerCamera m_TagString: MainCamera @@ -242,7 +323,7 @@ Camera: height: 1 near clip plane: 0.3 far clip plane: 1000 - field of view: 60 + field of view: 55 orthographic: 0 orthographic size: 5 m_Depth: -1 @@ -268,8 +349,8 @@ Transform: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 330585543} serializedVersion: 2 - m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} - m_LocalPosition: {x: 0, y: 1, z: -10} + m_LocalRotation: {x: 0.5, y: -0, z: -0, w: 0.8660254} + m_LocalPosition: {x: 0, y: 12, z: -9} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: [] @@ -285,8 +366,8 @@ MonoBehaviour: m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: a79441f348de89743a2939f4d699eac1, type: 3} - m_Name: - m_EditorClassIdentifier: + m_Name: + m_EditorClassIdentifier: m_RenderShadows: 1 m_RequiresDepthTextureOption: 2 m_RequiresOpaqueTextureOption: 2 @@ -319,6 +400,21 @@ MonoBehaviour: m_VarianceClampScale: 0.9 m_ContrastAdaptiveSharpening: 0 m_Version: 2 +--- !u!114 &330585548 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 330585543} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 3a67a9550edb4d96ad61c780e63b6b4d, type: 3} + m_Name: + m_EditorClassIdentifier: SecondSpawn.Networking::SecondSpawn.Networking.TopDownCameraFollow + _offset: {x: 0, y: 12, z: -9} + _followSharpness: 12 + _eulerAngles: {x: 60, y: 0, z: 0} --- !u!1 &410087039 GameObject: m_ObjectHideFlags: 0 @@ -427,8 +523,8 @@ MonoBehaviour: m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: 474bcb49853aa07438625e644c072ee6, type: 3} - m_Name: - m_EditorClassIdentifier: + m_Name: + m_EditorClassIdentifier: m_UsePipelineSettings: 1 m_AdditionalLightsShadowResolutionTier: 2 m_CustomShadowLayers: 0 @@ -446,6 +542,174 @@ MonoBehaviour: m_ShadowLayerMask: 1 m_RenderingLayers: 1 m_ShadowRenderingLayers: 1 +--- !u!1 &529322318 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 529322320} + - component: {fileID: 529322319} + m_Layer: 0 + m_Name: _AgentNPC_Prototype + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &529322319 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 529322318} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 6a71e9236c9a38146be01a82d1767a10, type: 3} + m_Name: + m_EditorClassIdentifier: SecondSpawn.AI::SecondSpawn.AI.PrototypeAgentBrain + _startOnPlay: 1 + _agentId: prototype-npc-guide + _displayName: Prototype Guide + _zoneId: prototype-hub + _visualVariant: 10 + _decisionIntervalSeconds: 1.6 + _moveSpeed: 2.4 + _patrolRadius: 5 + _talkIntervalSeconds: 7.5 + _seedSoulOnStart: 1 + _alignFeetToGround: 1 +--- !u!4 &529322320 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 529322318} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: -3, y: 0, z: 2} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &584568219 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 584568220} + - component: {fileID: 584568223} + - component: {fileID: 584568222} + - component: {fileID: 584568221} + m_Layer: 0 + m_Name: Ground_TestPad + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &584568220 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 584568219} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 4, y: 1, z: 4} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 1828990781} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!23 &584568221 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 584568219} + m_Enabled: 1 + m_CastShadows: 1 + m_ReceiveShadows: 1 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 2 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 2100000, guid: e43486906d22c3b44b3ec001b79e0669, type: 2} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 1 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_AdditionalVertexStreams: {fileID: 0} +--- !u!64 &584568222 +MeshCollider: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 584568219} + m_Material: {fileID: 0} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_LayerOverridePriority: 0 + m_IsTrigger: 0 + m_ProvidesContacts: 0 + m_Enabled: 1 + serializedVersion: 5 + m_Convex: 0 + m_CookingOptions: 30 + m_Mesh: {fileID: 10209, guid: 0000000000000000e000000000000000, type: 0} +--- !u!33 &584568223 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 584568219} + m_Mesh: {fileID: 10209, guid: 0000000000000000e000000000000000, type: 0} --- !u!1 &809676463 GameObject: m_ObjectHideFlags: 0 @@ -506,8 +770,8 @@ MonoBehaviour: m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: 172515602e62fb746b5d573b38a5fe58, type: 3} - m_Name: - m_EditorClassIdentifier: + m_Name: + m_EditorClassIdentifier: m_IsGlobal: 1 priority: 0 blendDistance: 0 @@ -570,6 +834,7 @@ GameObject: - component: {fileID: 1239426299} - component: {fileID: 1239426298} - component: {fileID: 1239426300} + - component: {fileID: 1239426301} m_Layer: 0 m_Name: _NetworkBootstrap m_TagString: Untagged @@ -587,7 +852,7 @@ MonoBehaviour: m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: a8fa4a4c99bdf2346a15c4a24a52028b, type: 3} - m_Name: + m_Name: m_EditorClassIdentifier: SecondSpawn.Networking::SecondSpawn.Networking.NetworkRunnerSetup _sessionName: SecondSpawn-Zone-Default _maxPlayersPerZone: 20 @@ -599,7 +864,7 @@ Transform: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 1239426297} serializedVersion: 2 - m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 @@ -616,12 +881,23 @@ MonoBehaviour: m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: 153746d151f471547b0f462c6e17d64e, type: 3} - m_Name: + m_Name: m_EditorClassIdentifier: SecondSpawn.Networking::SecondSpawn.Networking.PlayerSpawner _playerPrefab: {fileID: 8357749372956170795, guid: 5da3a38c87cc82141bd29ac4ec3f3650, type: 3} - _spawnRoot: {fileID: 47583032} - _spawnRingRadius: 3 - _spawnYOffset: 0.5 + _spawnRingRadius: 1.5 + _spawnYOffset: 1 +--- !u!114 &1239426301 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1239426297} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: bfed1d3970cbef1439d52570b95bbd4f, type: 3} + m_Name: + m_EditorClassIdentifier: SecondSpawn.Networking::SecondSpawn.Networking.NetworkInputProvider --- !u!1 &1324221071 GameObject: m_ObjectHideFlags: 0 @@ -681,7 +957,8 @@ Transform: m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 - m_Children: [] + m_Children: + - {fileID: 584568220} m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1660057539 &9223372036854775807 @@ -694,3 +971,6 @@ SceneRoots: - {fileID: 887987033} - {fileID: 1828990781} - {fileID: 47583032} + - {fileID: 1239426299} + - {fileID: 34500818} + - {fileID: 529322320} diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs new file mode 100644 index 0000000..085a248 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs @@ -0,0 +1,192 @@ +using System; + +namespace SecondSpawn.AI +{ + [Serializable] + public sealed class AgentContextDto + { + public PlayerProfileDto player; + public BodyProfileDto body; + } + + [Serializable] + public sealed class PlayerProfileDto + { + public string player_id; + public string display_name; + } + + [Serializable] + public sealed class BodyProfileDto + { + public string body_id; + public string archetype_id; + public string visual_prefab_key; + public EquipmentLoadoutDto equipment; + public CharacterTraitsDto characteristics; + public BodyTimeDto time; + public CultivationDto cultivation; + public AgentPolicyDto agent_policy; + public SoulProfileDto soul; + public MemoryRecordDto[] memory; + } + + [Serializable] + public sealed class EquipmentLoadoutDto + { + public string primary_weapon = "none"; + public int equipment_visual_id; + } + + [Serializable] + public sealed class CharacterTraitsDto + { + public int curiosity = 6; + public int courage = 5; + public int empathy = 5; + public int discipline = 5; + public int aggression = 3; + public int sociability = 5; + } + + [Serializable] + public sealed class BodyTimeDto + { + public long remaining_seconds; + public long max_seconds; + public long danger_drain_rate; + } + + [Serializable] + public sealed class CultivationDto + { + public string tier; + public long progress_xp; + } + + [Serializable] + public sealed class AgentPolicyDto + { + public bool enabled = true; + public string mode = "observe_and_keep_safe"; + public long max_session_seconds = 1800; + public bool allow_body_time_spend; + public bool allow_risky_combat; + public string[] preferred_activities; + public string[] forbidden_activities; + public long stop_when_body_time_below = 900; + } + + [Serializable] + public sealed class SoulProfileDto + { + public string name; + public string core_drive; + public string temperament; + public string combat_style; + public string social_style; + public string[] moral_boundaries; + public string[] long_term_goals; + public string player_notes; + public string reincarnation_lore; + } + + [Serializable] + public sealed class MemoryRecordDto + { + public string id; + public string kind = "system"; + public string summary; + public int importance = 5; + } + + [Serializable] + public sealed class UpdateSoulRequestDto + { + public SoulProfileDto soul; + public CharacterTraitsDto characteristics; + public AgentPolicyDto agent_policy; + } + + [Serializable] + public sealed class AgentDecisionRequestDto + { + public AgentContextDto context; + public WorldSnapshotDto world_snapshot; + public string[] allowed; + } + + [Serializable] + public sealed class WorldSnapshotDto + { + public string zone_id; + public Vector2Dto position; + public float safe_radius = 5f; + public WorldTargetDto[] nearby_targets; + public WorldObjectDto[] nearby_objects; + public int danger_level; + public long body_time_seconds; + } + + [Serializable] + public sealed class Vector2Dto + { + public float x; + public float z; + } + + [Serializable] + public sealed class WorldTargetDto + { + public string id; + public string kind; + public float distance; + public int threat; + } + + [Serializable] + public sealed class WorldObjectDto + { + public string id; + public string kind; + public float distance; + } + + [Serializable] + public sealed class AgentDecisionDto + { + public string action; + public string target_id; + public Vector2Dto move; + public string say; + public string reason; + public float confidence; + } + + [Serializable] + public sealed class NpcChatRequestDto + { + public string player_id; + public string npc_id; + public string message; + } + + [Serializable] + public sealed class NpcChatResponseDto + { + public string player_id; + public string npc_id; + public string text; + public bool voice_available; + public string provider; + } + + [Serializable] + public sealed class VoiceSessionDto + { + public bool voice_available; + public string provider; + public bool requires_ephemeral_token; + public string reason; + } +} diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs.meta b/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs.meta new file mode 100644 index 0000000..5e65bb1 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 59f1e9ef31b3ee54bb7ef1da5cc8503a \ No newline at end of file diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs new file mode 100644 index 0000000..6a7fc5c --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs @@ -0,0 +1,167 @@ +using System.Collections; +using Fusion; +using SecondSpawn.Networking; +using UnityEngine; + +namespace SecondSpawn.AI +{ + [DisallowMultipleComponent] + [RequireComponent(typeof(SecondSpawnGatewayClient))] + public sealed class CharacterMemorySync : MonoBehaviour + { + [SerializeField] private bool _syncOnStart = true; + [SerializeField] private bool _preferNakama = true; + [SerializeField] private bool _seedPrototypeMemory = true; + [SerializeField] private bool _applyProfileEquipmentToLocalPlayer = true; + [SerializeField, TextArea] private string _prototypeMemory = + "JOY wants overnight prototype progress without client-side LLM secrets."; + + private SecondSpawnGatewayClient _gateway; + private AgentContextDto _context; + + public AgentContextDto Context => _context; + + private void Awake() + { + _gateway = GetComponent(); + } + + private IEnumerator Start() + { + if (!_syncOnStart) + { + yield break; + } + + yield return Refresh(); + + if (_seedPrototypeMemory && !string.IsNullOrWhiteSpace(_prototypeMemory)) + { + yield return AddMemory(new MemoryRecordDto + { + kind = "preference", + summary = _prototypeMemory, + importance = 7 + }); + } + } + + public IEnumerator Refresh() + { + if (_preferNakama) + { + yield return WaitForAuthAttempt(); + if (_gateway.HasNakamaSession) + { + yield return _gateway.GetNakamaContext(ctx => + { + _context = ctx; + var soulName = ctx?.body?.soul?.name ?? "unknown"; + Debug.Log($"[CharacterMemorySync] Loaded Nakama soul '{soulName}'."); + }, Debug.LogWarning); + yield return ApplyProfileEquipmentWhenAvailable(); + yield break; + } + } + + yield return _gateway.GetContext(ctx => + { + _context = ctx; + var soulName = ctx?.body?.soul?.name ?? "unknown"; + Debug.Log($"[CharacterMemorySync] Loaded gateway prototype soul '{soulName}'."); + }, Debug.LogWarning); + yield return ApplyProfileEquipmentWhenAvailable(); + } + + private IEnumerator WaitForAuthAttempt() + { + const float maxWaitSeconds = 10f; + var elapsed = 0f; + while (!_gateway.IsAuthReady && elapsed < maxWaitSeconds) + { + elapsed += Time.deltaTime; + yield return null; + } + } + + private IEnumerator AddMemory(MemoryRecordDto memory) + { + if (_preferNakama && _gateway.HasNakamaSession) + { + yield return _gateway.AddNakamaMemory(memory, ctx => _context = ctx, Debug.LogWarning); + yield break; + } + + yield return _gateway.AddMemory(memory, ctx => _context = ctx, Debug.LogWarning); + } + + private IEnumerator ApplyProfileEquipmentWhenAvailable() + { + if (!_applyProfileEquipmentToLocalPlayer) + { + yield break; + } + + var equipmentVisualId = _context?.body?.equipment?.equipment_visual_id ?? EquipmentVisualCatalog.None; + if (equipmentVisualId == EquipmentVisualCatalog.None) + { + yield break; + } + + const float maxWaitSeconds = 10f; + var elapsed = 0f; + while (elapsed < maxWaitSeconds) + { + if (TryApplyProfileEquipment(equipmentVisualId)) + { + yield break; + } + + elapsed += Time.deltaTime; + yield return null; + } + + Debug.LogWarning($"[CharacterMemorySync] No local state-authority player was ready for equipment visual {equipmentVisualId}."); + } + + private static bool TryApplyProfileEquipment(int equipmentVisualId) + { + var players = Object.FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None); + foreach (var player in players) + { + if (!IsLocalAuthoritativePlayer(player)) + { + continue; + } + + player.EquipmentVisualId = equipmentVisualId; + var loaders = player.GetComponentsInChildren(includeInactive: true); + foreach (var loader in loaders) + { + loader.ApplyEquipmentVisual(equipmentVisualId); + } + + Debug.Log($"[CharacterMemorySync] Applied profile equipment visual {equipmentVisualId} to local player."); + return true; + } + + return false; + } + + private static bool IsLocalAuthoritativePlayer(NetworkPlayer player) + { + if (player == null || !player.HasStateAuthority) + { + return false; + } + + if (player.Object == null || player.Runner == null) + { + return true; + } + + return player.Object.InputAuthority == PlayerRef.None || + player.Object.InputAuthority == player.Runner.LocalPlayer; + } + } +} diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs.meta b/Unity/Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs.meta new file mode 100644 index 0000000..1063aaf --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 65e03666f59a10346bb722e738f8ea78 \ No newline at end of file diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/GatewayContracts.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/GatewayContracts.cs new file mode 100644 index 0000000..386c738 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/GatewayContracts.cs @@ -0,0 +1,7 @@ +namespace SecondSpawn.AI +{ + public static class GatewayContracts + { + public const string PrototypeProvider = "prototype-text"; + } +} diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/GatewayContracts.cs.meta b/Unity/Assets/_SecondSpawn/Scripts/AI/GatewayContracts.cs.meta new file mode 100644 index 0000000..bd54266 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/GatewayContracts.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4c7fa3daf2b786c4ca7fec9ff7a53701 \ No newline at end of file diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeAgentBrain.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeAgentBrain.cs new file mode 100644 index 0000000..b4bc65f --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeAgentBrain.cs @@ -0,0 +1,455 @@ +using System.Collections; +using SecondSpawn.Networking; +using UnityEngine; +#if UNITY_EDITOR +using UnityEditor; +#endif + +namespace SecondSpawn.AI +{ + /// + /// Prototype LLM-style brain for a local NPC actor. + /// The brain reads bounded character context from the gateway, asks for a + /// structured decision, then applies only narrow visual intents. + /// + [DisallowMultipleComponent] + public sealed class PrototypeAgentBrain : MonoBehaviour + { +#if UNITY_EDITOR + private const string SharedAnimatorControllerPath = + "Assets/ExplosiveLLC/RPG Character Mecanim Animation Pack/Animation Controller/RPG-Character-Animation-Controller.controller"; +#endif + + [SerializeField] private bool _startOnPlay = true; + [SerializeField] private string _agentId = "prototype-npc-guide"; + [SerializeField] private string _displayName = "Prototype Guide"; + [SerializeField] private string _zoneId = "prototype-hub"; + [SerializeField] private int _visualVariant = 10; + [SerializeField] private float _decisionIntervalSeconds = 1.6f; + [SerializeField] private float _moveSpeed = 2.4f; + [SerializeField] private float _patrolRadius = 5f; + [SerializeField] private float _talkIntervalSeconds = 7.5f; + [SerializeField] private bool _seedSoulOnStart = true; + [SerializeField] private bool _alignFeetToGround = true; + + private SecondSpawnGatewayClient _gateway; + private AgentContextDto _context; + private PrototypeSpeechBubble _speechBubble; + private PrototypeVoiceCue _voiceCue; + private VisualAnimationIntentDriver _intentDriver; + private Animator _animator; + private GameObject _visualRoot; + private Coroutine _brainLoop; + private Vector3 _homePosition; + private Vector3 _moveTarget; + private bool _hasMoveTarget; + private float _nextTalkAt; + private int _pendingFootAlignFrames; + + private void Awake() + { + _homePosition = transform.position; + _speechBubble = GetOrAdd(); + _voiceCue = GetOrAdd(); + _gateway = FindAnyObjectByType(); + EnsureVisual(); + } + + private void Start() + { + if (_startOnPlay) + { + StartBrain(); + } + } + + private void Update() + { + TickMovement(); + } + + private void LateUpdate() + { + if (_alignFeetToGround && _visualRoot != null && _pendingFootAlignFrames > 0) + { + AlignVisualFeetToGround(_visualRoot, transform.position.y); + _pendingFootAlignFrames--; + } + } + + public void StartBrain() + { + if (_brainLoop != null) + { + return; + } + + if (_gateway == null) + { + _gateway = FindAnyObjectByType(); + } + + if (_gateway == null) + { + Debug.LogWarning("[PrototypeAgentBrain] No SecondSpawnGatewayClient found in scene."); + return; + } + + _brainLoop = StartCoroutine(BrainLoop()); + } + + public void StopBrain() + { + if (_brainLoop != null) + { + StopCoroutine(_brainLoop); + _brainLoop = null; + } + + _hasMoveTarget = false; + ApplyLocomotion(0f); + } + + private IEnumerator BrainLoop() + { + yield return BootstrapContext(); + _nextTalkAt = Time.time + 1.5f; + + while (enabled) + { + var request = BuildDecisionRequest(); + AgentDecisionDto decision = null; + yield return _gateway.Decide(request, value => decision = value, Debug.LogWarning); + + if (decision != null) + { + ApplyDecision(decision); + } + + yield return new WaitForSeconds(Mathf.Max(0.25f, _decisionIntervalSeconds)); + } + } + + private IEnumerator BootstrapContext() + { + if (_seedSoulOnStart) + { + yield return _gateway.UpdateSoulForPlayer(_agentId, BuildSoulSeed(), ctx => _context = ctx, Debug.LogWarning); + } + + if (_context == null) + { + yield return _gateway.GetContextForPlayer(_agentId, ctx => _context = ctx, Debug.LogWarning); + } + + yield return _gateway.AddMemoryForPlayer(_agentId, new MemoryRecordDto + { + kind = "system", + summary = "Prototype NPC brain patrols the hub, talks through bounded intent, and never mutates game state directly.", + importance = 7 + }, ctx => _context = ctx, Debug.LogWarning); + } + + private UpdateSoulRequestDto BuildSoulSeed() + { + return new UpdateSoulRequestDto + { + soul = new SoulProfileDto + { + name = _displayName, + core_drive = "guide the player, preserve safety, and observe the first Second Spawn prototype", + temperament = "calm, curious, and careful", + combat_style = "avoid combat unless the server explicitly allows it", + social_style = "short, practical, and a little uncanny", + moral_boundaries = new[] { "do not grant items", "do not spend BodyTime", "do not claim authority over game state" }, + long_term_goals = new[] { "learn the hub", "help the player understand agent life" }, + player_notes = "Prototype local NPC brain for testing autonomous character behavior.", + reincarnation_lore = "A synthetic guide imprint used to test agent cognition before real NPC lore is authored." + }, + characteristics = new CharacterTraitsDto + { + curiosity = 8, + courage = 4, + empathy = 7, + discipline = 8, + aggression = 1, + sociability = 8 + }, + agent_policy = new AgentPolicyDto + { + enabled = true, + mode = "prototype_npc_patrol", + max_session_seconds = 1800, + allow_body_time_spend = false, + allow_risky_combat = false, + preferred_activities = new[] { "patrol", "talk", "observe" }, + forbidden_activities = new[] { "grant_items", "spend_body_time", "start_combat" }, + stop_when_body_time_below = 900 + } + }; + } + + private AgentDecisionRequestDto BuildDecisionRequest() + { + var position = transform.position; + var shouldTalk = Time.time >= _nextTalkAt; + + return new AgentDecisionRequestDto + { + context = _context, + world_snapshot = new WorldSnapshotDto + { + zone_id = _zoneId, + position = new Vector2Dto { x = position.x, z = position.z }, + safe_radius = _patrolRadius, + danger_level = 0, + body_time_seconds = _context?.body?.time?.remaining_seconds ?? 3600, + nearby_objects = new[] + { + new WorldObjectDto + { + id = "hub-origin", + kind = "safe_landmark", + distance = Vector3.Distance(position, _homePosition) + } + } + }, + allowed = shouldTalk + ? new[] { "say", "stop" } + : new[] { "move", "stop" } + }; + } + + private void ApplyDecision(AgentDecisionDto decision) + { + if (decision.action == "say") + { + var text = string.IsNullOrWhiteSpace(decision.say) + ? $"{_displayName} is watching the hub." + : decision.say; + _speechBubble.Show(text); + _voiceCue.PlayCue(text); + _intentDriver?.TryPlay(VisualAnimationIntent.Talk); + _nextTalkAt = Time.time + Mathf.Max(2f, _talkIntervalSeconds); + return; + } + + if (decision.action == "move" && decision.move != null) + { + var requested = new Vector3(decision.move.x, transform.position.y, decision.move.z); + _moveTarget = ClampToPatrol(requested); + _hasMoveTarget = true; + return; + } + + _hasMoveTarget = false; + ApplyLocomotion(0f); + } + + private void TickMovement() + { + if (!_hasMoveTarget) + { + ApplyLocomotion(0f); + return; + } + + var current = transform.position; + var delta = _moveTarget - current; + delta.y = 0f; + if (delta.sqrMagnitude <= 0.04f) + { + _hasMoveTarget = false; + ApplyLocomotion(0f); + return; + } + + var direction = delta.normalized; + transform.position = Vector3.MoveTowards(current, _moveTarget, _moveSpeed * Time.deltaTime); + transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(direction), 12f * Time.deltaTime); + ApplyLocomotion(1f); + } + + private Vector3 ClampToPatrol(Vector3 target) + { + var offset = target - _homePosition; + offset.y = 0f; + if (offset.magnitude > _patrolRadius) + { + offset = offset.normalized * _patrolRadius; + } + + return _homePosition + offset; + } + + private void EnsureVisual() + { + if (_animator != null || transform.Find("PrototypeAgentVisual") != null) + { + return; + } + + GameObject visualRoot = null; +#if UNITY_EDITOR + var cleanPath = VisualPrefabCatalog.GetCleanAssetPath(_visualVariant); + var prefab = AssetDatabase.LoadAssetAtPath(cleanPath); + if (prefab != null) + { + visualRoot = Instantiate(prefab, transform); + } +#endif + + if (visualRoot == null) + { + visualRoot = GameObject.CreatePrimitive(PrimitiveType.Capsule); + visualRoot.transform.SetParent(transform, false); + } + + visualRoot.name = "PrototypeAgentVisual"; + visualRoot.transform.SetLocalPositionAndRotation(Vector3.zero, Quaternion.identity); + visualRoot.transform.localScale = Vector3.one; + DisablePhysics(visualRoot); + if (_alignFeetToGround) + { + AlignVisualFeetToGround(visualRoot, transform.position.y); + _pendingFootAlignFrames = 3; + } + + _visualRoot = visualRoot; + _animator = visualRoot.GetComponentInChildren(includeInactive: true); + ConfigureAnimator(_animator); + + _intentDriver = visualRoot.GetComponentInChildren(includeInactive: true); + if (_intentDriver == null && _animator != null) + { + _intentDriver = _animator.gameObject.AddComponent(); + } + } + + private void ConfigureAnimator(Animator animator) + { + if (animator == null) + { + return; + } + + animator.applyRootMotion = false; + animator.cullingMode = AnimatorCullingMode.AlwaysAnimate; + animator.updateMode = AnimatorUpdateMode.Normal; + animator.speed = 1f; +#if UNITY_EDITOR + if (animator.runtimeAnimatorController == null) + { + var sharedController = AssetDatabase.LoadAssetAtPath(SharedAnimatorControllerPath); + if (sharedController != null) + { + animator.runtimeAnimatorController = sharedController; + } + } +#endif + animator.Rebind(); + animator.Update(0f); + + if (animator.GetComponent() == null) + { + animator.gameObject.AddComponent(); + } + } + + private void ApplyLocomotion(float speed) + { + if (_animator == null) + { + return; + } + + SetBool("Moving", speed > 0.02f); + SetFloat("Velocity", speed); + SetFloat("Velocity X", 0f); + SetFloat("Velocity Z", speed); + SetFloat("AnimationSpeed", 1f); + SetFloat("Animation Speed", 1f); + SetInt("Weapon", -1); + } + + private void SetBool(string parameterName, bool value) + { + foreach (var parameter in _animator.parameters) + { + if (parameter.name == parameterName && parameter.type == AnimatorControllerParameterType.Bool) + { + _animator.SetBool(parameterName, value); + return; + } + } + } + + private void SetFloat(string parameterName, float value) + { + foreach (var parameter in _animator.parameters) + { + if (parameter.name == parameterName && parameter.type == AnimatorControllerParameterType.Float) + { + _animator.SetFloat(parameterName, value); + return; + } + } + } + + private void SetInt(string parameterName, int value) + { + foreach (var parameter in _animator.parameters) + { + if (parameter.name == parameterName && parameter.type == AnimatorControllerParameterType.Int) + { + _animator.SetInteger(parameterName, value); + return; + } + } + } + + private static void DisablePhysics(GameObject root) + { + foreach (var collider in root.GetComponentsInChildren(includeInactive: true)) + { + collider.enabled = false; + } + + foreach (var rigidbody in root.GetComponentsInChildren(includeInactive: true)) + { + rigidbody.isKinematic = true; + rigidbody.useGravity = false; + } + } + + private static void AlignVisualFeetToGround(GameObject root, float targetWorldY) + { + var renderers = root.GetComponentsInChildren(includeInactive: true); + if (renderers.Length == 0) + { + return; + } + + var minY = float.PositiveInfinity; + foreach (var renderer in renderers) + { + minY = Mathf.Min(minY, renderer.bounds.min.y); + } + + if (!float.IsFinite(minY)) + { + return; + } + + var position = root.transform.position; + position.y += targetWorldY - minY; + root.transform.position = position; + } + + private T GetOrAdd() where T : Component + { + var component = GetComponent(); + return component != null ? component : gameObject.AddComponent(); + } + } +} diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeAgentBrain.cs.meta b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeAgentBrain.cs.meta new file mode 100644 index 0000000..caac49c --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeAgentBrain.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6a71e9236c9a38146be01a82d1767a10 \ No newline at end of file diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeLLMAgentDriver.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeLLMAgentDriver.cs new file mode 100644 index 0000000..d52eec3 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeLLMAgentDriver.cs @@ -0,0 +1,196 @@ +using System.Collections; +using SecondSpawn.Networking; +using UnityEngine; +using UnityEngine.InputSystem; + +namespace SecondSpawn.AI +{ + [DisallowMultipleComponent] + [RequireComponent(typeof(NetworkPlayer))] + public sealed class PrototypeLLMAgentDriver : MonoBehaviour + { + [SerializeField] private bool _enableOnStart; + [SerializeField] private float _decisionIntervalSeconds = 1.25f; + [SerializeField] private float _moveHoldSeconds = 0.9f; + [SerializeField] private string _zoneId = "prototype-hub"; + [SerializeField] private bool _allowPrototypeInteract; + + private SecondSpawnGatewayClient _gateway; + private CharacterMemorySync _memorySync; + private NetworkPlayer _networkPlayer; + private PrototypeSpeechBubble _speechBubble; + private PrototypeVoiceCue _voiceCue; + private Coroutine _loop; + + private void Awake() + { + _networkPlayer = GetComponent(); + _speechBubble = GetComponent(); + if (_speechBubble == null) + { + _speechBubble = gameObject.AddComponent(); + } + + _voiceCue = GetComponent(); + if (_voiceCue == null) + { + _voiceCue = gameObject.AddComponent(); + } + + _gateway = FindAnyObjectByType(); + _memorySync = _gateway != null ? _gateway.GetComponent() : null; + } + + private void Start() + { + if (_enableOnStart) + { + StartAgent(); + } + } + + private void Update() + { + if (Keyboard.current != null && Keyboard.current.pKey.wasPressedThisFrame) + { + if (_loop == null) + { + StartAgent(); + } + else + { + StopAgent(); + } + } + } + + public void StartAgent() + { + if (_gateway == null) + { + Debug.LogWarning("[PrototypeLLMAgentDriver] No SecondSpawnGatewayClient found in scene."); + return; + } + + _loop ??= StartCoroutine(DecisionLoop()); + } + + public void StopAgent() + { + if (_loop != null) + { + StopCoroutine(_loop); + _loop = null; + } + + _networkPlayer.ClearPrototypeAgentInput(); + } + + private IEnumerator DecisionLoop() + { + while (enabled) + { + var request = BuildDecisionRequest(); + AgentDecisionDto decision = null; + string gatewayError = null; + yield return _gateway.Decide(request, value => decision = value, error => gatewayError = error); + + if (decision == null && _gateway.HasNakamaSession) + { + yield return _gateway.DecideWithNakamaFallback(request, value => decision = value, Debug.LogWarning); + } + else if (decision == null && !string.IsNullOrWhiteSpace(gatewayError)) + { + Debug.LogWarning(gatewayError); + } + + if (decision != null) + { + ApplyDecision(decision); + } + + yield return new WaitForSeconds(Mathf.Max(0.25f, _decisionIntervalSeconds)); + } + } + + private AgentDecisionRequestDto BuildDecisionRequest() + { + var position = transform.position; + var context = _memorySync != null ? _memorySync.Context : null; + var bodyTime = context?.body?.time?.remaining_seconds ?? 3600; + + return new AgentDecisionRequestDto + { + context = context, + world_snapshot = new WorldSnapshotDto + { + zone_id = _zoneId, + position = new Vector2Dto { x = position.x, z = position.z }, + safe_radius = 8f, + body_time_seconds = bodyTime, + nearby_objects = System.Array.Empty() + }, + allowed = _allowPrototypeInteract + ? new[] { "move", "interact", "say", "stop" } + : new[] { "move", "say", "stop" } + }; + } + + private void ApplyDecision(AgentDecisionDto decision) + { + if (decision.action == "move" && decision.move != null) + { + var target = new Vector3(decision.move.x, transform.position.y, decision.move.z); + var direction = target - transform.position; + direction.y = 0f; + direction = Vector3.ClampMagnitude(direction, 1f); + + _networkPlayer.SetPrototypeAgentInput(new NetworkInputData + { + HorizontalAxis = direction.x, + VerticalAxis = direction.z, + Run = true + }); + StartCoroutine(ClearMoveAfterDelay()); + } + else if (decision.action == "say") + { + Debug.Log($"[PrototypeLLMAgentDriver] Agent says: {decision.say}"); + _speechBubble.Show(decision.say); + _voiceCue.PlayCue(decision.say); + PlayVisualIntent(VisualAnimationIntent.Talk); + } + else if (decision.action == "interact") + { + if (_allowPrototypeInteract) + { + PlayVisualIntent(VisualAnimationIntent.Interact); + } + else + { + _networkPlayer.ClearPrototypeAgentInput(); + Debug.Log("[PrototypeLLMAgentDriver] Ignored prototype interact decision. Interact is disabled for patrol mode."); + } + } + else + { + _networkPlayer.ClearPrototypeAgentInput(); + } + } + + private IEnumerator ClearMoveAfterDelay() + { + yield return new WaitForSeconds(Mathf.Max(0.1f, _moveHoldSeconds)); + _networkPlayer.ClearPrototypeAgentInput(); + } + + private void PlayVisualIntent(VisualAnimationIntent intent) + { + var driver = GetComponentInChildren(); + if (driver != null) + { + driver.TryPlay(intent); + } + } + } +} diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeLLMAgentDriver.cs.meta b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeLLMAgentDriver.cs.meta new file mode 100644 index 0000000..9a47580 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeLLMAgentDriver.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c82a1923923545c408fda6f62a7bde81 \ No newline at end of file diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeNPCChatClient.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeNPCChatClient.cs new file mode 100644 index 0000000..dbeab32 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeNPCChatClient.cs @@ -0,0 +1,90 @@ +using System.Collections; +using SecondSpawn.Networking; +using UnityEngine; +using UnityEngine.InputSystem; + +namespace SecondSpawn.AI +{ + [DisallowMultipleComponent] + [RequireComponent(typeof(SecondSpawnGatewayClient))] + public sealed class PrototypeNPCChatClient : MonoBehaviour + { + [SerializeField] private string _npcId = "prototype-guide"; + [SerializeField, TextArea] private string _prototypeMessage = + "What should this body remember while I am offline?"; + [SerializeField] private Key _talkKey = Key.O; + [SerializeField] private Key _voiceKey = Key.V; + + private SecondSpawnGatewayClient _gateway; + + private void Awake() + { + _gateway = GetComponent(); + } + + private void Update() + { + var keyboard = Keyboard.current; + if (keyboard == null) + { + return; + } + + if (keyboard[_talkKey].wasPressedThisFrame) + { + StartCoroutine(SendPrototypeChat()); + } + + if (keyboard[_voiceKey].wasPressedThisFrame) + { + StartCoroutine(CheckVoiceSession()); + } + } + + private IEnumerator SendPrototypeChat() + { + yield return _gateway.Chat(new NpcChatRequestDto + { + npc_id = _npcId, + message = _prototypeMessage + }, response => + { + Debug.Log($"[PrototypeNPCChatClient] {response.npc_id}: {response.text}"); + PresentSpeech(response.text); + PlayTalkAnimation(); + }, Debug.LogWarning); + } + + private IEnumerator CheckVoiceSession() + { + yield return _gateway.GetVoiceSession(response => + { + Debug.Log($"[PrototypeNPCChatClient] Voice provider={response.provider}, available={response.voice_available}, reason={response.reason}"); + }, Debug.LogWarning); + } + + private static void PlayTalkAnimation() + { + var driver = FindAnyObjectByType(); + if (driver != null) + { + driver.TryPlay(VisualAnimationIntent.Talk); + } + } + + private static void PresentSpeech(string text) + { + var speechBubble = FindAnyObjectByType(); + if (speechBubble != null) + { + speechBubble.Show(text); + } + + var voiceCue = FindAnyObjectByType(); + if (voiceCue != null) + { + voiceCue.PlayCue(text); + } + } + } +} diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeNPCChatClient.cs.meta b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeNPCChatClient.cs.meta new file mode 100644 index 0000000..fa7edcc --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeNPCChatClient.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 76fb87f4a58cc92408c225afd884d6ba \ No newline at end of file diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeSpeechBubble.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeSpeechBubble.cs new file mode 100644 index 0000000..98e702b --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeSpeechBubble.cs @@ -0,0 +1,89 @@ +using UnityEngine; + +namespace SecondSpawn.AI +{ + [DisallowMultipleComponent] + public sealed class PrototypeSpeechBubble : MonoBehaviour + { + [SerializeField] private Vector3 _localOffset = new(0f, 2.2f, 0f); + [SerializeField] private float _visibleSeconds = 3.5f; + [SerializeField] private int _fontSize = 32; + [SerializeField] private Color _textColor = Color.white; + + private TextMesh _textMesh; + private float _hideAt; + + private void Awake() + { + EnsureTextMesh(); + } + + private void LateUpdate() + { + if (_textMesh == null) + { + return; + } + + var meshTransform = _textMesh.transform; + meshTransform.localPosition = _localOffset; + + var cam = Camera.main; + if (cam != null) + { + meshTransform.rotation = Quaternion.LookRotation(meshTransform.position - cam.transform.position); + } + + if (_textMesh.gameObject.activeSelf && Time.time >= _hideAt) + { + _textMesh.gameObject.SetActive(false); + } + } + + public void Show(string text) + { + EnsureTextMesh(); + if (_textMesh == null) + { + return; + } + + _textMesh.text = Clamp(text); + _textMesh.gameObject.SetActive(true); + _hideAt = Time.time + _visibleSeconds; + } + + private void EnsureTextMesh() + { + if (_textMesh != null) + { + return; + } + + var child = new GameObject("PrototypeSpeechBubble"); + child.transform.SetParent(transform, false); + child.transform.localPosition = _localOffset; + + _textMesh = child.AddComponent(); + _textMesh.anchor = TextAnchor.MiddleCenter; + _textMesh.alignment = TextAlignment.Center; + _textMesh.fontSize = _fontSize; + _textMesh.characterSize = 0.04f; + _textMesh.color = _textColor; + _textMesh.text = string.Empty; + child.SetActive(false); + } + + private static string Clamp(string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return "..."; + } + + text = text.Trim(); + return text.Length <= 96 ? text : text[..96] + "..."; + } + } +} + diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeSpeechBubble.cs.meta b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeSpeechBubble.cs.meta new file mode 100644 index 0000000..933d25e --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeSpeechBubble.cs.meta @@ -0,0 +1,12 @@ +fileFormatVersion: 2 +guid: f2f21b6f21c44c9189164539ac9b3f2f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: + diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeVoiceCue.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeVoiceCue.cs new file mode 100644 index 0000000..bb564a5 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeVoiceCue.cs @@ -0,0 +1,59 @@ +using UnityEngine; + +namespace SecondSpawn.AI +{ + [DisallowMultipleComponent] + public sealed class PrototypeVoiceCue : MonoBehaviour + { + [SerializeField, Range(0f, 1f)] private float _volume = 0.12f; + [SerializeField] private float _secondsPerCharacter = 0.025f; + [SerializeField] private float _minSeconds = 0.18f; + [SerializeField] private float _maxSeconds = 1.4f; + + private AudioSource _audioSource; + + private void Awake() + { + _audioSource = GetComponent(); + if (_audioSource == null) + { + _audioSource = gameObject.AddComponent(); + _audioSource.playOnAwake = false; + _audioSource.spatialBlend = 0.65f; + } + } + + public void PlayCue(string text) + { + if (_audioSource == null) + { + return; + } + + var duration = Mathf.Clamp((text?.Length ?? 8) * _secondsPerCharacter, _minSeconds, _maxSeconds); + var clip = BuildCue(duration); + _audioSource.Stop(); + _audioSource.PlayOneShot(clip, _volume); + } + + private static AudioClip BuildCue(float duration) + { + const int sampleRate = 22050; + var sampleCount = Mathf.Max(1, Mathf.CeilToInt(sampleRate * duration)); + var samples = new float[sampleCount]; + + for (var i = 0; i < sampleCount; i++) + { + var t = i / (float)sampleRate; + var envelope = Mathf.Sin(Mathf.PI * i / sampleCount); + var pitch = 260f + 60f * Mathf.Sin(t * 18f); + samples[i] = Mathf.Sin(t * pitch * Mathf.PI * 2f) * envelope * 0.35f; + } + + var clip = AudioClip.Create("PrototypeVoiceCue", sampleCount, 1, sampleRate, false); + clip.SetData(samples, 0); + return clip; + } + } +} + diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeVoiceCue.cs.meta b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeVoiceCue.cs.meta new file mode 100644 index 0000000..c74260c --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeVoiceCue.cs.meta @@ -0,0 +1,12 @@ +fileFormatVersion: 2 +guid: 97163199375f4129a9cb7616cf50d7e0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: + diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawn.AI.asmdef b/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawn.AI.asmdef index c4013ff..7522068 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawn.AI.asmdef +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawn.AI.asmdef @@ -2,7 +2,8 @@ "name": "SecondSpawn.AI", "rootNamespace": "SecondSpawn.AI", "references": [ - "SecondSpawn.Networking" + "SecondSpawn.Networking", + "Unity.InputSystem" ], "includePlatforms": [], "excludePlatforms": [], diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs new file mode 100644 index 0000000..c777558 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs @@ -0,0 +1,507 @@ +using System; +using System.Collections; +using System.Text; +using UnityEngine; +using UnityEngine.Networking; + +namespace SecondSpawn.AI +{ + [DisallowMultipleComponent] + public sealed class SecondSpawnGatewayClient : MonoBehaviour + { + [SerializeField, Tooltip("Public gateway base URL. No provider API keys are stored in Unity.")] + private string _gatewayBaseUrl = "https://second-spawn-gateway-535583621422.asia-southeast1.run.app"; + + [SerializeField, Tooltip("Prototype player id until Supabase auth is wired.")] + private string _playerId = "dev-player"; + + [Header("Nakama Auth")] + [SerializeField, Tooltip("Try Supabase anonymous auth and Nakama custom auth on scene start.")] + private bool _authenticateOnStart = true; + + [SerializeField, Tooltip("Supabase project URL. May also come from SECOND_SPAWN_SUPABASE_URL.")] + private string _supabaseUrl = ""; + + [SerializeField, Tooltip("Supabase anon or publishable key. May also come from SECOND_SPAWN_SUPABASE_ANON_KEY.")] + private string _supabaseAnonKey = ""; + + [SerializeField, Tooltip("Nakama HTTP base URL.")] + private string _nakamaBaseUrl = "http://127.0.0.1:7350"; + + [SerializeField, Tooltip("Nakama client/server key. Local dev default is defaultkey; rotate for non-local envs.")] + private string _nakamaServerKey = "defaultkey"; + + [SerializeField, Tooltip("Use Nakama device auth when Supabase is not configured yet. Local prototype only.")] + private bool _allowNakamaDeviceFallback = true; + + private bool _authAttempted; + private bool _authInProgress; + private string _supabaseAccessToken; + private string _nakamaAuthToken; + private string _nakamaUserId; + + public bool HasNakamaSession => !string.IsNullOrWhiteSpace(_nakamaAuthToken); + public bool IsAuthReady => !_authInProgress && (_authAttempted || HasNakamaSession); + public string NakamaAuthToken => _nakamaAuthToken; + public string NakamaUserId => string.IsNullOrWhiteSpace(_nakamaUserId) ? "" : _nakamaUserId.Trim(); + public string PlayerId => !string.IsNullOrWhiteSpace(NakamaUserId) + ? NakamaUserId + : (string.IsNullOrWhiteSpace(_playerId) ? "dev-player" : _playerId.Trim()); + + private IEnumerator Start() + { + if (_authenticateOnStart) + { + yield return Authenticate(); + } + } + + public IEnumerator Authenticate(Action onSuccess = null, Action onError = null) + { + if (_authInProgress) + { + while (_authInProgress) + { + yield return null; + } + + if (HasNakamaSession) + { + onSuccess?.Invoke(); + } + else + { + onError?.Invoke("Authentication already attempted but no Nakama session is available."); + } + yield break; + } + + _authAttempted = true; + _authInProgress = true; + + var supabaseUrl = ResolveValue(_supabaseUrl, "SECOND_SPAWN_SUPABASE_URL"); + var supabaseKey = ResolveValue(_supabaseAnonKey, "SECOND_SPAWN_SUPABASE_ANON_KEY", "SECOND_SPAWN_SUPABASE_PUBLISHABLE_KEY"); + if (string.IsNullOrWhiteSpace(supabaseUrl) || string.IsNullOrWhiteSpace(supabaseKey)) + { + var message = "Supabase URL/key not configured. Falling back to Nakama device auth for local prototype."; + Debug.Log($"[SecondSpawnGatewayClient] {message}"); + yield return AuthenticateNakamaDeviceFallback(onSuccess, onError); + yield break; + } + + SupabaseAnonymousSessionDto supabaseSession = null; + yield return SignInSupabaseAnonymously(supabaseUrl, supabaseKey, session => supabaseSession = session, error => + { + Debug.LogWarning($"[SecondSpawnGatewayClient] Supabase anonymous auth failed: {error}"); + onError?.Invoke(error); + }); + + if (supabaseSession == null || string.IsNullOrWhiteSpace(supabaseSession.access_token)) + { + yield return AuthenticateNakamaDeviceFallback(onSuccess, onError); + yield break; + } + + _supabaseAccessToken = supabaseSession.access_token; + + NakamaSessionDto nakamaSession = null; + yield return AuthenticateNakamaCustom(_supabaseAccessToken, session => nakamaSession = session, error => + { + Debug.LogWarning($"[SecondSpawnGatewayClient] Nakama custom auth failed: {error}"); + onError?.Invoke(error); + }); + + if (nakamaSession == null || string.IsNullOrWhiteSpace(nakamaSession.token)) + { + yield return AuthenticateNakamaDeviceFallback(onSuccess, onError); + yield break; + } + + _nakamaAuthToken = nakamaSession.token; + _nakamaUserId = ExtractJwtStringClaim(_nakamaAuthToken, "uid"); + if (string.IsNullOrWhiteSpace(_nakamaUserId)) + { + _nakamaUserId = _playerId; + } + + _authInProgress = false; + Debug.Log($"[SecondSpawnGatewayClient] Authenticated Nakama user {PlayerId}."); + onSuccess?.Invoke(); + } + + public IEnumerator GetContext(Action onSuccess, Action onError = null) + { + yield return GetContextForPlayer(PlayerId, onSuccess, onError); + } + + public IEnumerator GetNakamaContext(Action onSuccess, Action onError = null) + { + yield return SendNakamaRpc("secondspawn_profile_get", new EmptyPayload(), onSuccess, onError); + } + + public IEnumerator AddNakamaMemory(MemoryRecordDto memory, Action onSuccess = null, Action onError = null) + { + yield return SendNakamaRpc("secondspawn_memory_add", memory, onSuccess, onError); + } + + public IEnumerator UpdateNakamaSoul(UpdateSoulRequestDto request, Action onSuccess = null, Action onError = null) + { + yield return SendNakamaRpc("secondspawn_soul_update", request, onSuccess, onError); + } + + public IEnumerator DecideWithNakamaFallback(AgentDecisionRequestDto request, Action onSuccess, Action onError = null) + { + yield return SendNakamaRpc("secondspawn_agent_decide", request, onSuccess, onError); + } + + public IEnumerator GetContextForPlayer(string playerId, Action onSuccess, Action onError = null) + { + yield return Send( + UnityWebRequest.Get(BuildUrl($"/v1/characters/{UnityWebRequest.EscapeURL(NormalizePlayerId(playerId))}/context")), + onSuccess, + onError); + } + + public IEnumerator AddMemory(MemoryRecordDto memory, Action onSuccess = null, Action onError = null) + { + yield return AddMemoryForPlayer(PlayerId, memory, onSuccess, onError); + } + + public IEnumerator AddMemoryForPlayer(string playerId, MemoryRecordDto memory, Action onSuccess = null, Action onError = null) + { + yield return SendJson( + "POST", + $"/v1/characters/{UnityWebRequest.EscapeURL(NormalizePlayerId(playerId))}/memory", + memory, + onSuccess, + onError); + } + + public IEnumerator UpdateSoul(UpdateSoulRequestDto request, Action onSuccess = null, Action onError = null) + { + yield return UpdateSoulForPlayer(PlayerId, request, onSuccess, onError); + } + + public IEnumerator UpdateSoulForPlayer(string playerId, UpdateSoulRequestDto request, Action onSuccess = null, Action onError = null) + { + yield return SendJson( + "PUT", + $"/v1/characters/{UnityWebRequest.EscapeURL(NormalizePlayerId(playerId))}/soul", + request, + onSuccess, + onError); + } + + public IEnumerator Decide(AgentDecisionRequestDto request, Action onSuccess, Action onError = null) + { + yield return SendJson("POST", "/v1/agent/decide", request, onSuccess, onError); + } + + public IEnumerator Chat(NpcChatRequestDto request, Action onSuccess, Action onError = null) + { + if (string.IsNullOrWhiteSpace(request.player_id)) + { + request.player_id = PlayerId; + } + + yield return SendJson("POST", "/v1/npc/chat", request, onSuccess, onError); + } + + public IEnumerator GetVoiceSession(Action onSuccess, Action onError = null) + { + yield return SendJson("POST", "/v1/voice/session", new VoiceSessionRequest(), onSuccess, onError); + } + + private IEnumerator SendJson(string method, string path, object payload, Action onSuccess, Action onError) + { + var json = JsonUtility.ToJson(payload); + var request = new UnityWebRequest(BuildUrl(path), method) + { + uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(json)), + downloadHandler = new DownloadHandlerBuffer() + }; + request.SetRequestHeader("Content-Type", "application/json; charset=utf-8"); + request.SetRequestHeader("Accept", "application/json"); + yield return Send(request, onSuccess, onError); + } + + private IEnumerator SendNakamaRpc(string rpcId, object payload, Action onSuccess, Action onError) + { + if (!HasNakamaSession) + { + yield return Authenticate(null, onError); + } + + if (!HasNakamaSession) + { + onError?.Invoke("Nakama session is unavailable."); + yield break; + } + + var json = JsonUtility.ToJson(payload); + var request = new UnityWebRequest(BuildNakamaUrl($"/v2/rpc/{UnityWebRequest.EscapeURL(rpcId)}?unwrap"), "POST") + { + uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(json)), + downloadHandler = new DownloadHandlerBuffer() + }; + request.SetRequestHeader("Authorization", "Bearer " + _nakamaAuthToken); + request.SetRequestHeader("Content-Type", "application/json; charset=utf-8"); + request.SetRequestHeader("Accept", "application/json"); + yield return Send(request, onSuccess, onError); + } + + private IEnumerator SignInSupabaseAnonymously(string supabaseUrl, string supabaseKey, Action onSuccess, Action onError) + { + var request = new UnityWebRequest(TrimTrailingSlash(supabaseUrl) + "/auth/v1/signup", "POST") + { + uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes("{}")), + downloadHandler = new DownloadHandlerBuffer() + }; + request.SetRequestHeader("apikey", supabaseKey); + request.SetRequestHeader("Authorization", "Bearer " + supabaseKey); + request.SetRequestHeader("Content-Type", "application/json; charset=utf-8"); + request.SetRequestHeader("Accept", "application/json"); + yield return Send(request, onSuccess, onError); + } + + private IEnumerator AuthenticateNakamaCustom(string supabaseAccessToken, Action onSuccess, Action onError) + { + var payload = new NakamaCustomAuthRequest { id = supabaseAccessToken }; + var request = new UnityWebRequest(BuildNakamaUrl("/v2/account/authenticate/custom?create=true"), "POST") + { + uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(JsonUtility.ToJson(payload))), + downloadHandler = new DownloadHandlerBuffer() + }; + var basic = Convert.ToBase64String(Encoding.UTF8.GetBytes(ResolveValue(_nakamaServerKey, "SECOND_SPAWN_NAKAMA_SERVER_KEY") + ":")); + request.SetRequestHeader("Authorization", "Basic " + basic); + request.SetRequestHeader("Content-Type", "application/json; charset=utf-8"); + request.SetRequestHeader("Accept", "application/json"); + yield return Send(request, onSuccess, onError); + } + + private IEnumerator AuthenticateNakamaDeviceFallback(Action onSuccess, Action onError) + { + if (!_allowNakamaDeviceFallback) + { + _authInProgress = false; + onError?.Invoke("Nakama device fallback is disabled."); + yield break; + } + + NakamaSessionDto nakamaSession = null; + var deviceId = "second-spawn-" + SystemInfo.deviceUniqueIdentifier; + var username = "local-" + StableSmallHash(deviceId); + yield return AuthenticateNakamaDevice(deviceId, username, session => nakamaSession = session, error => + { + Debug.LogWarning($"[SecondSpawnGatewayClient] Nakama device fallback failed: {error}"); + onError?.Invoke(error); + }); + + if (nakamaSession == null || string.IsNullOrWhiteSpace(nakamaSession.token)) + { + _authInProgress = false; + yield break; + } + + _nakamaAuthToken = nakamaSession.token; + _nakamaUserId = ExtractJwtStringClaim(_nakamaAuthToken, "uid"); + if (string.IsNullOrWhiteSpace(_nakamaUserId)) + { + _nakamaUserId = username; + } + + _authInProgress = false; + Debug.Log($"[SecondSpawnGatewayClient] Authenticated Nakama device fallback user {PlayerId}."); + onSuccess?.Invoke(); + } + + private IEnumerator AuthenticateNakamaDevice(string deviceId, string username, Action onSuccess, Action onError) + { + var payload = new NakamaDeviceAuthRequest { id = deviceId }; + var request = new UnityWebRequest(BuildNakamaUrl($"/v2/account/authenticate/device?create=true&username={UnityWebRequest.EscapeURL(username)}"), "POST") + { + uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(JsonUtility.ToJson(payload))), + downloadHandler = new DownloadHandlerBuffer() + }; + var basic = Convert.ToBase64String(Encoding.UTF8.GetBytes(ResolveValue(_nakamaServerKey, "SECOND_SPAWN_NAKAMA_SERVER_KEY") + ":")); + request.SetRequestHeader("Authorization", "Basic " + basic); + request.SetRequestHeader("Content-Type", "application/json; charset=utf-8"); + request.SetRequestHeader("Accept", "application/json"); + yield return Send(request, onSuccess, onError); + } + + private IEnumerator Send(UnityWebRequest request, Action onSuccess, Action onError) + { + yield return request.SendWebRequest(); + + if (request.result != UnityWebRequest.Result.Success) + { + onError?.Invoke($"{request.responseCode}: {request.error} {request.downloadHandler.text}"); + yield break; + } + + var body = request.downloadHandler.text; + if (string.IsNullOrWhiteSpace(body)) + { + onError?.Invoke("Gateway returned an empty response."); + yield break; + } + + try + { + onSuccess?.Invoke(JsonUtility.FromJson(body)); + } + catch (Exception ex) + { + onError?.Invoke($"Gateway JSON parse failed: {ex.Message}"); + } + } + + private string BuildUrl(string path) + { + var baseUrl = string.IsNullOrWhiteSpace(_gatewayBaseUrl) + ? "https://second-spawn-gateway-535583621422.asia-southeast1.run.app" + : _gatewayBaseUrl.TrimEnd('/'); + return baseUrl + path; + } + + private string BuildNakamaUrl(string path) + { + var baseUrl = ResolveValue(_nakamaBaseUrl, "SECOND_SPAWN_NAKAMA_URL"); + if (string.IsNullOrWhiteSpace(baseUrl)) + { + baseUrl = "http://127.0.0.1:7350"; + } + return baseUrl.TrimEnd('/') + path; + } + + private static string NormalizePlayerId(string playerId) + { + return string.IsNullOrWhiteSpace(playerId) ? "dev-player" : playerId.Trim(); + } + + private static string ResolveValue(string serializedValue, params string[] envNames) + { + if (!string.IsNullOrWhiteSpace(serializedValue)) + { + return serializedValue.Trim(); + } + + foreach (var envName in envNames) + { + var value = Environment.GetEnvironmentVariable(envName); + if (!string.IsNullOrWhiteSpace(value)) + { + return value.Trim(); + } + } + + return ""; + } + + private static string TrimTrailingSlash(string value) + { + return string.IsNullOrWhiteSpace(value) ? "" : value.Trim().TrimEnd('/'); + } + + private static string ExtractJwtStringClaim(string jwt, string claimName) + { + if (string.IsNullOrWhiteSpace(jwt)) + { + return ""; + } + + var parts = jwt.Split('.'); + if (parts.Length < 2) + { + return ""; + } + + try + { + var payload = parts[1].Replace('-', '+').Replace('_', '/'); + switch (payload.Length % 4) + { + case 2: + payload += "=="; + break; + case 3: + payload += "="; + break; + } + + var json = Encoding.UTF8.GetString(Convert.FromBase64String(payload)); + var marker = $"\"{claimName}\":\""; + var start = json.IndexOf(marker, StringComparison.Ordinal); + if (start < 0) + { + return ""; + } + + start += marker.Length; + var end = json.IndexOf('"', start); + return end > start ? json.Substring(start, end - start) : ""; + } + catch (Exception) + { + return ""; + } + } + + private static string StableSmallHash(string value) + { + unchecked + { + uint hash = 2166136261; + for (var i = 0; i < value.Length; i++) + { + hash ^= value[i]; + hash *= 16777619; + } + return hash.ToString("x8"); + } + } + + [Serializable] + private sealed class VoiceSessionRequest + { + } + + [Serializable] + private sealed class EmptyPayload + { + } + + [Serializable] + private sealed class SupabaseAnonymousSessionDto + { + public string access_token; + public SupabaseUserDto user; + } + + [Serializable] + private sealed class SupabaseUserDto + { + public string id; + } + + [Serializable] + private sealed class NakamaCustomAuthRequest + { + public string id; + } + + [Serializable] + private sealed class NakamaDeviceAuthRequest + { + public string id; + } + + [Serializable] + private sealed class NakamaSessionDto + { + public string token; + public string refresh_token; + } + } +} diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs.meta b/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs.meta new file mode 100644 index 0000000..f7dde14 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e7ac6c7e9346bb541b4657dbc1e8581d \ No newline at end of file diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/AnimationEventReceiver.cs b/Unity/Assets/_SecondSpawn/Scripts/Networking/AnimationEventReceiver.cs new file mode 100644 index 0000000..74e7c78 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/AnimationEventReceiver.cs @@ -0,0 +1,33 @@ +using UnityEngine; + +namespace SecondSpawn.Networking +{ + /// + /// Receives optional animation events from local-only character visuals. + /// Footstep audio/VFX can be wired here later without allowing animation + /// clips to affect authoritative gameplay state. + /// + [DisallowMultipleComponent] + public sealed class AnimationEventReceiver : MonoBehaviour + { + public void Hit() + { + } + + public void Shoot() + { + } + + public void FootL() + { + } + + public void FootR() + { + } + + public void Land() + { + } + } +} diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/AnimationEventReceiver.cs.meta b/Unity/Assets/_SecondSpawn/Scripts/Networking/AnimationEventReceiver.cs.meta new file mode 100644 index 0000000..431afc1 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/AnimationEventReceiver.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6ba3a0993a3149dd9ae1d7bcfd3154b2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/EquipmentVisualCatalog.cs b/Unity/Assets/_SecondSpawn/Scripts/Networking/EquipmentVisualCatalog.cs new file mode 100644 index 0000000..48f5261 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/EquipmentVisualCatalog.cs @@ -0,0 +1,182 @@ +using UnityEngine; + +namespace SecondSpawn.Networking +{ + public static class EquipmentVisualCatalog + { + public const int None = 0; + public const int Unarmed = 1; + public const int OneHandSword = 2; + public const int TwoHandSword = 3; + public const int TwoHandSpear = 4; + public const int TwoHandAxe = 5; + public const int TwoHandBow = 6; + public const int TwoHandCrossbow = 7; + public const int Staff = 8; + public const int Hammer = 9; + + public static int GetDefaultForVisualVariant(int visualVariant) + { + return VisualPrefabCatalog.NormalizeVariant(visualVariant) switch + { + 1 => Unarmed, // Brute + 2 => Unarmed, // Karate + 3 => OneHandSword, // Ninja + 5 => TwoHandSword, // Two-handed warrior + 6 => TwoHandBow, // Archer + 7 => OneHandSword, // Knight, shield deferred until off-hand equipment exists + 8 => Staff, // Mage + 9 => TwoHandCrossbow, + 10 => Hammer, + 11 => TwoHandSpear, + 12 => OneHandSword, + _ => None + }; + } + + public static int GetAnimatorWeaponValue(int equipmentVisualId) + { + return equipmentVisualId switch + { + Unarmed => 0, + TwoHandSword => 1, + TwoHandSpear => 2, + TwoHandAxe => 3, + TwoHandBow => 4, + TwoHandCrossbow => 5, + Staff => 6, + OneHandSword => 7, + Hammer => 3, + _ => -1 + }; + } + + public static void ApplyEquipmentVisual(GameObject root, int equipmentVisualId) + { + var selectedRoot = FindSelectedWeaponPropRoot(root, equipmentVisualId); + foreach (var renderer in root.GetComponentsInChildren(includeInactive: true)) + { + var propRoot = FindWeaponPropRoot(renderer.transform, root.transform); + if (propRoot == null) + { + continue; + } + + renderer.enabled = selectedRoot != null && IsSameOrChildOf(renderer.transform, selectedRoot); + } + } + + public static bool IsWeaponProp(Transform transform, Transform root) + { + return FindWeaponPropRoot(transform, root) != null; + } + + private static Transform FindSelectedWeaponPropRoot(GameObject root, int equipmentVisualId) + { + if (equipmentVisualId == None || equipmentVisualId == Unarmed) + { + return null; + } + + foreach (var renderer in root.GetComponentsInChildren(includeInactive: true)) + { + var propRoot = FindWeaponPropRoot(renderer.transform, root.transform); + if (propRoot != null && MatchesEquipment(propRoot.name, equipmentVisualId)) + { + return propRoot; + } + } + + return null; + } + + private static Transform FindWeaponPropRoot(Transform transform, Transform root) + { + var current = transform; + Transform lastMatch = null; + while (current != null && current != root) + { + if (IsWeaponPropName(current.name)) + { + lastMatch = current; + } + + current = current.parent; + } + + return lastMatch; + } + + private static bool MatchesEquipment(string objectName, int equipmentVisualId) + { + var name = NormalizeName(objectName); + return equipmentVisualId switch + { + OneHandSword => name is "sword" or "swordl" or "swordr" || + name.Contains("swordsman-weapon") || + name.Contains("ninja-weapon") || + name.Contains("knight-weapon"), + TwoHandSword => name == "2hand-sword" || name.Contains("twohanded-weapon"), + TwoHandSpear => name == "2hand-spear" || name == "spear" || name.Contains("spearman-weapon"), + TwoHandAxe => name == "2hand-axe", + TwoHandBow => name == "2hand-bow" || name.Contains("archer-weapon"), + TwoHandCrossbow => name == "2hand-crossbow" || name.Contains("crossbow-weapon"), + Staff => name == "staff" || name.Contains("mage-weapon"), + Hammer => name.Contains("hammer-weapon"), + _ => false + }; + } + + private static bool IsWeaponPropName(string objectName) + { + var name = NormalizeName(objectName); + if (string.IsNullOrWhiteSpace(name)) + { + return false; + } + + if (name == "weapon" || name.Contains("-weapon") || name.Contains("_weapon") || name.Contains(" weapon")) + { + return true; + } + + if (name.StartsWith("2hand-", System.StringComparison.Ordinal)) + { + return true; + } + + if (name.EndsWith("-shield", System.StringComparison.Ordinal) || + name.EndsWith("-arrow", System.StringComparison.Ordinal)) + { + return true; + } + + return name is "pistol" or "dagger" or "knife" or "sword" or "swordl" or "swordr" or + "shield" or "mace" or "staff" or "spear" or "axe" or "bow" or "crossbow" or "rifle" or + "gun" or "wand" or "club" or "arrow" or "quiver" or "buckler"; + } + + private static bool IsSameOrChildOf(Transform transform, Transform possibleParent) + { + var current = transform; + while (current != null) + { + if (current == possibleParent) + { + return true; + } + + current = current.parent; + } + + return false; + } + + private static string NormalizeName(string value) + { + return string.IsNullOrWhiteSpace(value) + ? string.Empty + : value.Trim().ToLowerInvariant(); + } + } +} diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/EquipmentVisualCatalog.cs.meta b/Unity/Assets/_SecondSpawn/Scripts/Networking/EquipmentVisualCatalog.cs.meta new file mode 100644 index 0000000..c237dcf --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/EquipmentVisualCatalog.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9a8a3f7e6b7c4c2ab6b23b9f2a5f7d43 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/LocalVisualPrefabLoader.cs b/Unity/Assets/_SecondSpawn/Scripts/Networking/LocalVisualPrefabLoader.cs new file mode 100644 index 0000000..66c43cf --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/LocalVisualPrefabLoader.cs @@ -0,0 +1,390 @@ +using System.Collections; +using Fusion; +using UnityEngine; +#if UNITY_EDITOR +using UnityEditor; +#endif + +namespace SecondSpawn.Networking +{ + /// + /// Loads an optional local-only visual prefab under the networked player. + /// The loaded object is cosmetic only: Fusion and Simple KCC remain the + /// authoritative movement stack. + /// + [DisallowMultipleComponent] + public sealed class LocalVisualPrefabLoader : MonoBehaviour + { +#if UNITY_EDITOR + private const string SharedAnimatorControllerPath = + "Assets/ExplosiveLLC/RPG Character Mecanim Animation Pack/Animation Controller/RPG-Character-Animation-Controller.controller"; +#endif + + [SerializeField, Tooltip("Resources path fallback for a local-only visual prefab. Missing resources leave the committed cube visible.")] + private string _resourcePath = "SecondSpawn/RPGCharacterVisual"; + + [SerializeField, Tooltip("Offset for the loaded visual prefab.")] + private Vector3 _localPosition = new(0f, -0.5f, 0f); + + [SerializeField, Tooltip("Euler rotation for the loaded visual prefab.")] + private Vector3 _localEulerAngles; + + [SerializeField, Tooltip("Scale for the loaded visual prefab.")] + private Vector3 _localScale = Vector3.one; + + [SerializeField, Tooltip("Hide placeholder renderers on this root when the visual prefab loads.")] + private bool _hideRootRenderers = true; + + [SerializeField, Tooltip("Align the visual renderer bounds bottom to the network root ground plane after loading. This avoids tall/short local asset variants sinking into the floor.")] + private bool _alignFeetToGround = true; + + [SerializeField, Tooltip("Extra vertical offset after foot-ground alignment.")] + private float _feetGroundOffset; + + [SerializeField, Tooltip("Optional runtime fallback for assets that fail under the active render pipeline. Generated Second Spawn visuals should already use URP material copies.")] + private bool _convertMaterialsToUrp; + + [SerializeField, Tooltip("Apply the networked equipment visual and hide all other vendor weapon prop meshes.")] + private bool _hidePrototypeWeaponProps = true; + + private GameObject _instance; + + private void Start() + { + if (Application.isBatchMode || _instance != null) + { + return; + } + + var visualKey = ResolveVisualKey(); + var visualPrefab = LoadVisualPrefab(visualKey); + if (visualPrefab == null) + { + Debug.LogWarning("[LocalVisualPrefabLoader] No local visual prefab found. Keeping placeholder cube."); + return; + } + + _instance = Instantiate(visualPrefab, transform); + _instance.name = visualPrefab.name; + _instance.transform.SetLocalPositionAndRotation(_localPosition, Quaternion.Euler(_localEulerAngles)); + _instance.transform.localScale = _localScale; + + NormalizeVisualTransform(_instance); + DisablePhysics(_instance); + var equipmentVisualId = GetNetworkEquipmentVisualId(); + if (_hidePrototypeWeaponProps) + { + EquipmentVisualCatalog.ApplyEquipmentVisual(_instance, equipmentVisualId); + } + + if (_convertMaterialsToUrp) + { + ConfigureMaterials(_instance); + } + + var animator = ConfigureAnimator(_instance); + if (_alignFeetToGround) + { + AlignVisualFeetToGround(_instance, transform.position.y + _feetGroundOffset); + StartCoroutine(RealignVisualFeetAfterAnimatorPose()); + } + + if (animator != null && TryGetComponent(out var animatorBridge)) + { + animatorBridge.SetAnimator(animator); + } + + if (_hideRootRenderers) + { + foreach (var renderer in GetComponents()) + { + renderer.enabled = false; + } + } + + Debug.Log($"[LocalVisualPrefabLoader] Loaded local visual '{visualKey}' with equipment visual {equipmentVisualId}."); + } + + private IEnumerator RealignVisualFeetAfterAnimatorPose() + { + yield return null; + yield return null; + if (_instance != null) + { + AlignVisualFeetToGround(_instance, transform.position.y + _feetGroundOffset); + } + } + + private string ResolveVisualKey() + { +#if UNITY_EDITOR + var visualVariant = GetNetworkVisualVariant(); + var cleanPath = VisualPrefabCatalog.GetCleanAssetPath(visualVariant); + if (AssetDatabase.LoadAssetAtPath(cleanPath) != null) + { + return cleanPath; + } + + var sourcePath = VisualPrefabCatalog.GetSourceAssetPath(visualVariant); + if (!string.IsNullOrWhiteSpace(sourcePath)) + { + Debug.LogWarning($"[LocalVisualPrefabLoader] Generated visual prefab missing for variant {visualVariant}. Falling back to source asset '{sourcePath}'. Run Second Spawn/Art/Rebuild Generated Visual Prefabs to refresh clean visuals."); + return sourcePath; + } + + return _resourcePath; +#else + return _resourcePath; +#endif + } + + private GameObject LoadVisualPrefab(string visualKey) + { + if (string.IsNullOrWhiteSpace(visualKey)) + { + return null; + } + +#if UNITY_EDITOR + if (visualKey.StartsWith("Assets/", System.StringComparison.Ordinal)) + { + return AssetDatabase.LoadAssetAtPath(visualKey); + } +#endif + + return Resources.Load(visualKey); + } + + private int GetNetworkVisualVariant() + { + var networkPlayer = GetComponentInParent(); + if (networkPlayer != null) + { + return networkPlayer.VisualVariant; + } + + return Random.Range(0, int.MaxValue); + } + + private static void NormalizeVisualTransform(GameObject root) + { + root.transform.localRotation = Quaternion.identity; + root.transform.localScale = Vector3.one; + } + + private static void AlignVisualFeetToGround(GameObject root, float targetWorldY) + { + var renderers = root.GetComponentsInChildren(includeInactive: true); + if (renderers.Length == 0) + { + return; + } + + var minY = float.PositiveInfinity; + foreach (var renderer in renderers) + { + if (EquipmentVisualCatalog.IsWeaponProp(renderer.transform, root.transform)) + { + continue; + } + + minY = Mathf.Min(minY, renderer.bounds.min.y); + } + + if (!float.IsFinite(minY)) + { + return; + } + + var position = root.transform.position; + position.y += targetWorldY - minY; + root.transform.position = position; + } + + private static void DisablePhysics(GameObject root) + { + foreach (var collider in root.GetComponentsInChildren(includeInactive: true)) + { + collider.enabled = false; + } + + foreach (var rigidbody in root.GetComponentsInChildren(includeInactive: true)) + { + rigidbody.isKinematic = true; + rigidbody.useGravity = false; + } + } + + private int GetNetworkEquipmentVisualId() + { + var networkPlayer = GetComponentInParent(); + if (networkPlayer != null) + { + return networkPlayer.EquipmentVisualId != EquipmentVisualCatalog.None + ? networkPlayer.EquipmentVisualId + : EquipmentVisualCatalog.GetDefaultForVisualVariant(networkPlayer.VisualVariant); + } + + return EquipmentVisualCatalog.GetDefaultForVisualVariant(GetNetworkVisualVariant()); + } + + private static void ConfigureMaterials(GameObject root) + { + var runtimeShader = FindRuntimeShader(); + if (runtimeShader == null) + { + Debug.LogWarning("[LocalVisualPrefabLoader] No URP runtime shader found for vendor material conversion."); + return; + } + + foreach (var renderer in root.GetComponentsInChildren(includeInactive: true)) + { + var materials = renderer.materials; + + for (var i = 0; i < materials.Length; i++) + { + materials[i] = CreateRuntimeMaterial(materials[i], runtimeShader); + } + + renderer.materials = materials; + } + } + + private static Shader FindRuntimeShader() + { + return Shader.Find("Universal Render Pipeline/Lit") ?? + Shader.Find("Universal Render Pipeline/Simple Lit") ?? + Shader.Find("Universal Render Pipeline/Unlit"); + } + + private static Material CreateRuntimeMaterial(Material source, Shader runtimeShader) + { + var material = new Material(runtimeShader) + { + name = source != null ? $"{source.name}_RuntimeLit" : "RuntimeLit" + }; + + var sourceColor = Color.white; + if (source != null) + { + if (source.HasProperty("_BaseColor")) + { + sourceColor = source.GetColor("_BaseColor"); + } + else if (source.HasProperty("_Color")) + { + sourceColor = source.GetColor("_Color"); + } + + Texture mainTexture = null; + if (source.HasProperty("_BaseMap")) + { + mainTexture = source.GetTexture("_BaseMap"); + } + else if (source.HasProperty("_MainTex")) + { + mainTexture = source.GetTexture("_MainTex"); + } + + if (mainTexture != null) + { + if (material.HasProperty("_BaseMap")) + { + material.SetTexture("_BaseMap", mainTexture); + material.SetTextureScale("_BaseMap", source.GetTextureScale(source.HasProperty("_BaseMap") ? "_BaseMap" : "_MainTex")); + material.SetTextureOffset("_BaseMap", source.GetTextureOffset(source.HasProperty("_BaseMap") ? "_BaseMap" : "_MainTex")); + } + else if (material.HasProperty("_MainTex")) + { + material.SetTexture("_MainTex", mainTexture); + material.SetTextureScale("_MainTex", source.GetTextureScale(source.HasProperty("_BaseMap") ? "_BaseMap" : "_MainTex")); + material.SetTextureOffset("_MainTex", source.GetTextureOffset(source.HasProperty("_BaseMap") ? "_BaseMap" : "_MainTex")); + } + } + } + + if (material.HasProperty("_BaseColor")) + { + material.SetColor("_BaseColor", sourceColor); + } + else if (material.HasProperty("_Color")) + { + material.SetColor("_Color", sourceColor); + } + + return material; + } + + private static Animator ConfigureAnimator(GameObject root) + { + var animator = root.GetComponentInChildren(includeInactive: true); + if (animator == null) + { + return null; + } + + animator.applyRootMotion = false; + animator.cullingMode = AnimatorCullingMode.AlwaysAnimate; + animator.updateMode = AnimatorUpdateMode.Normal; + animator.speed = 1f; + EnsureSharedController(animator); + animator.Rebind(); + animator.Update(0f); + + if (animator.GetComponent() == null) + { + animator.gameObject.AddComponent(); + } + + if (animator.GetComponent() == null) + { + animator.gameObject.AddComponent(); + } + + return animator; + } + + private static void EnsureSharedController(Animator animator) + { +#if UNITY_EDITOR + if (animator.runtimeAnimatorController != null && ExposesLocomotionContract(animator)) + { + return; + } + + var sharedController = AssetDatabase.LoadAssetAtPath(SharedAnimatorControllerPath); + if (sharedController != null) + { + animator.runtimeAnimatorController = sharedController; + } + else + { + Debug.LogWarning($"[LocalVisualPrefabLoader] Shared animator controller not found at '{SharedAnimatorControllerPath}'."); + } +#endif + } + + private static bool ExposesLocomotionContract(Animator animator) + { + foreach (var parameter in animator.parameters) + { + if (parameter.name is "Moving" or "Velocity" or "Velocity X" or "Velocity Z") + { + return true; + } + } + + return false; + } + + public void ApplyEquipmentVisual(int equipmentVisualId) + { + if (!_hidePrototypeWeaponProps || _instance == null) + { + return; + } + + EquipmentVisualCatalog.ApplyEquipmentVisual(_instance, equipmentVisualId); + } + } +} diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/LocalVisualPrefabLoader.cs.meta b/Unity/Assets/_SecondSpawn/Scripts/Networking/LocalVisualPrefabLoader.cs.meta new file mode 100644 index 0000000..6427178 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/LocalVisualPrefabLoader.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 778cc9b36fce47cfb206fdaf99bb5b71 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkAnimatorBridge.cs b/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkAnimatorBridge.cs new file mode 100644 index 0000000..0ce32c6 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkAnimatorBridge.cs @@ -0,0 +1,453 @@ +using Fusion; +using Fusion.Addons.SimpleKCC; +using UnityEngine; + +namespace SecondSpawn.Networking +{ + /// + /// Replicates compact visual animation state from the networked KCC root + /// without letting animation own movement authority. + /// + [DisallowMultipleComponent] + public sealed class NetworkAnimatorBridge : NetworkBehaviour + { + [Networked] + public float NetSpeed { get; set; } + + [Networked] + public float NetVelocityX { get; set; } + + [Networked] + public float NetVelocityZ { get; set; } + + [Networked] + public int NetJumping { get; set; } + + [SerializeField, Tooltip("Animator on the visual child. If empty, the first child Animator is used.")] + private Animator _animator; + + [SerializeField, Tooltip("Planar speed that maps to normalized animator velocity 1.0.")] + private float _referenceMoveSpeed = 5f; + + [SerializeField, Tooltip("Animator bool parameter used by RPG Character Mecanim Animation Pack.")] + private string _movingParameter = "Moving"; + + [SerializeField, Tooltip("Animator float parameter used by RPG Character Mecanim Animation Pack.")] + private string _velocityXParameter = "Velocity X"; + + [SerializeField, Tooltip("Animator float parameter used by RPG Character Mecanim Animation Pack.")] + private string _velocityZParameter = "Velocity Z"; + + [SerializeField, Tooltip("Animator float parameter used by ExplosiveLLC free Warrior/Fighter controllers.")] + private string _velocityParameter = "Velocity"; + + [SerializeField, Tooltip("Animator float parameter used by RPG Character Mecanim Animation Pack.")] + private string _animationSpeedParameter = "AnimationSpeed"; + + [SerializeField, Tooltip("Animator float parameter used by ExplosiveLLC free Warrior/Fighter controllers.")] + private string _animationSpeedSpacedParameter = "Animation Speed"; + + [SerializeField, Tooltip("Animator int parameter used by RPG Character Mecanim Animation Pack.")] + private string _weaponParameter = "Weapon"; + + [SerializeField, Tooltip("Default visual locomotion weapon. -1 is Relax, 0 is Unarmed combat.")] + private int _defaultWeaponValue = -1; + + [SerializeField, Tooltip("Animator int parameter used by RPG Character Mecanim Animation Pack.")] + private string _jumpingParameter = "Jumping"; + + [SerializeField, Tooltip("Animator int parameter used by RPG Character Mecanim Animation Pack.")] + private string _triggerNumberParameter = "TriggerNumber"; + + [SerializeField, Tooltip("Animator int parameter used by ExplosiveLLC free Warrior/Fighter controllers.")] + private string _triggerNumberSpacedParameter = "Trigger Number"; + + [SerializeField, Tooltip("Animator trigger parameter used by RPG Character Mecanim Animation Pack.")] + private string _triggerParameter = "Trigger"; + + [SerializeField, Tooltip("Vertical speed threshold that switches the visual from jump to fall. Positive values start the fall blend just before the physical apex to avoid a held jump pose.")] + private float _fallVelocityThreshold = 0.8f; + + private Vector3 _previousPosition; + private bool _hasPreviousPosition; + private bool _hasMovingParameter; + private bool _hasVelocityXParameter; + private bool _hasVelocityZParameter; + private bool _hasVelocityParameter; + private bool _hasAnimationSpeedParameter; + private bool _hasAnimationSpeedSpacedParameter; + private bool _hasWeaponParameter; + private bool _hasJumpingParameter; + private bool _hasTriggerNumberParameter; + private bool _hasTriggerNumberSpacedParameter; + private bool _hasTriggerParameter; + private bool _hasAnimatorContract; + private bool _warnedMissingAnimatorContract; + private bool _initializedAnimatorDefaults; + private bool _wasAirborne; + private int _lastJumpingValue = int.MinValue; + private SimpleKCC _kcc; + private NetworkPlayer _networkPlayer; + private Animator _cachedAnimator; + + private void Awake() + { + ResolveAnimator(); + _previousPosition = transform.position; + _hasPreviousPosition = true; + } + + private void OnEnable() + { + _previousPosition = transform.position; + _hasPreviousPosition = true; + } + + public override void Spawned() + { + _previousPosition = transform.position; + _hasPreviousPosition = true; + } + + public override void FixedUpdateNetwork() + { + if (!HasStateAuthority) + { + return; + } + + var deltaTime = Runner != null ? Runner.DeltaTime : Time.fixedDeltaTime; + if (!_hasPreviousPosition || deltaTime <= 0f) + { + _previousPosition = transform.position; + _hasPreviousPosition = true; + return; + } + + var worldVelocity = (transform.position - _previousPosition) / deltaTime; + worldVelocity.y = 0f; + _previousPosition = transform.position; + + var localVelocity = transform.InverseTransformDirection(worldVelocity); + var referenceMoveSpeed = Mathf.Max(0.01f, _referenceMoveSpeed); + NetSpeed = Mathf.Clamp01(worldVelocity.magnitude / referenceMoveSpeed); + NetVelocityX = Mathf.Clamp(localVelocity.x / referenceMoveSpeed, -1f, 1f); + NetVelocityZ = Mathf.Clamp(localVelocity.z / referenceMoveSpeed, -1f, 1f); + NetJumping = GetJumpingFromKcc(); + } + + public override void Render() + { + ResolveAnimator(); + if (_animator == null) + { + return; + } + + ApplyMovement(NetSpeed, NetVelocityX, NetVelocityZ); + ApplyJumping(NetJumping); + } + + private void ApplyMovement(float normalizedSpeed, float velocityX, float velocityZ) + { + if (_hasMovingParameter) + { + _animator.SetBool(_movingParameter, normalizedSpeed > 0.02f); + } + + if (_hasVelocityXParameter) + { + _animator.SetFloat(_velocityXParameter, velocityX); + } + + if (_hasVelocityZParameter) + { + _animator.SetFloat(_velocityZParameter, velocityZ); + } + + if (_hasVelocityParameter) + { + _animator.SetFloat(_velocityParameter, normalizedSpeed); + } + + if (_hasAnimationSpeedParameter) + { + _animator.SetFloat(_animationSpeedParameter, 1f); + } + + if (_hasAnimationSpeedSpacedParameter) + { + _animator.SetFloat(_animationSpeedSpacedParameter, 1f); + } + + if (_hasWeaponParameter) + { + _animator.SetInteger(_weaponParameter, GetAnimatorWeaponValue()); + } + } + + private void ResolveAnimator() + { + if (_animator == null) + { + _animator = GetComponentInChildren(); + if (_animator == null) + { + return; + } + } + + _animator.applyRootMotion = false; + _animator.updateMode = AnimatorUpdateMode.Normal; + _animator.speed = 1f; + _kcc ??= GetComponent(); + _networkPlayer ??= GetComponentInParent(); + if (_cachedAnimator != _animator) + { + _cachedAnimator = _animator; + _initializedAnimatorDefaults = false; + _lastJumpingValue = int.MinValue; + CacheParameters(); + InitializeAnimatorDefaults(); + } + } + + private void CacheParameters() + { + _hasMovingParameter = false; + _hasVelocityXParameter = false; + _hasVelocityZParameter = false; + _hasVelocityParameter = false; + _hasAnimationSpeedParameter = false; + _hasAnimationSpeedSpacedParameter = false; + _hasWeaponParameter = false; + _hasJumpingParameter = false; + _hasTriggerNumberParameter = false; + _hasTriggerNumberSpacedParameter = false; + _hasTriggerParameter = false; + _hasAnimatorContract = false; + + foreach (var parameter in _animator.parameters) + { + if (parameter.name == _movingParameter && parameter.type == AnimatorControllerParameterType.Bool) + { + _hasMovingParameter = true; + } + else if (parameter.name == _velocityXParameter && parameter.type == AnimatorControllerParameterType.Float) + { + _hasVelocityXParameter = true; + } + else if (parameter.name == _velocityZParameter && parameter.type == AnimatorControllerParameterType.Float) + { + _hasVelocityZParameter = true; + } + else if (parameter.name == _velocityParameter && parameter.type == AnimatorControllerParameterType.Float) + { + _hasVelocityParameter = true; + } + else if (parameter.name == _animationSpeedParameter && parameter.type == AnimatorControllerParameterType.Float) + { + _hasAnimationSpeedParameter = true; + } + else if (parameter.name == _animationSpeedSpacedParameter && parameter.type == AnimatorControllerParameterType.Float) + { + _hasAnimationSpeedSpacedParameter = true; + } + else if (parameter.name == _weaponParameter && parameter.type == AnimatorControllerParameterType.Int) + { + _hasWeaponParameter = true; + } + else if (parameter.name == _jumpingParameter && parameter.type == AnimatorControllerParameterType.Int) + { + _hasJumpingParameter = true; + } + else if (parameter.name == _triggerNumberParameter && parameter.type == AnimatorControllerParameterType.Int) + { + _hasTriggerNumberParameter = true; + } + else if (parameter.name == _triggerNumberSpacedParameter && parameter.type == AnimatorControllerParameterType.Int) + { + _hasTriggerNumberSpacedParameter = true; + } + else if (parameter.name == _triggerParameter && parameter.type == AnimatorControllerParameterType.Trigger) + { + _hasTriggerParameter = true; + } + } + + _hasAnimatorContract = _hasMovingParameter || _hasVelocityXParameter || _hasVelocityZParameter || _hasVelocityParameter; + if (!_hasAnimatorContract && !_warnedMissingAnimatorContract) + { + _warnedMissingAnimatorContract = true; + Debug.LogWarning($"[NetworkAnimatorBridge] Animator '{_animator.runtimeAnimatorController?.name}' does not expose the expected RPG Character locomotion parameters."); + } + } + + private int GetJumpingFromKcc() + { + if (_kcc == null) + { + return 0; + } + + if (_kcc.HasJumped && !_wasAirborne) + { + _wasAirborne = true; + return 1; + } + + if (!_wasAirborne) + { + return 0; + } + + if (!_kcc.IsGrounded) + { + if (_kcc.RealVelocity.y <= _fallVelocityThreshold) + { + return 2; + } + + return 1; + } + + _wasAirborne = false; + return 0; + } + + private void ApplyJumping(int jumping) + { + if (!_hasJumpingParameter || !HasAnyTriggerNumberParameter() || !_hasTriggerParameter) + { + return; + } + + SetJumping(jumping, triggerTransition: true); + } + + public void SetAnimator(Animator animator) + { + if (_animator == animator) + { + return; + } + + _animator = animator; + _cachedAnimator = null; + _initializedAnimatorDefaults = false; + _wasAirborne = false; + _lastJumpingValue = int.MinValue; + ResolveAnimator(); + } + + private void InitializeAnimatorDefaults() + { + if (_initializedAnimatorDefaults || _animator == null) + { + return; + } + + _initializedAnimatorDefaults = true; + _wasAirborne = false; + if (_hasMovingParameter) + { + _animator.SetBool(_movingParameter, false); + } + + if (_hasVelocityXParameter) + { + _animator.SetFloat(_velocityXParameter, 0f); + } + + if (_hasVelocityZParameter) + { + _animator.SetFloat(_velocityZParameter, 0f); + } + + if (_hasVelocityParameter) + { + _animator.SetFloat(_velocityParameter, 0f); + } + + if (_hasAnimationSpeedParameter) + { + _animator.SetFloat(_animationSpeedParameter, 1f); + } + + if (_hasAnimationSpeedSpacedParameter) + { + _animator.SetFloat(_animationSpeedSpacedParameter, 1f); + } + + if (_hasWeaponParameter) + { + _animator.SetInteger(_weaponParameter, GetAnimatorWeaponValue()); + } + + if (_hasJumpingParameter) + { + SetJumpingValueOnly(0); + } + } + + private void SetJumping(int value) + { + SetJumping(value, triggerTransition: true); + } + + private void SetJumping(int value, bool triggerTransition) + { + if (_lastJumpingValue == value) + { + return; + } + + _lastJumpingValue = value; + _animator.SetInteger(_jumpingParameter, value); + if (triggerTransition) + { + SetTriggerNumber(18); + _animator.SetTrigger(_triggerParameter); + } + } + + private void SetJumpingValueOnly(int value) + { + if (_lastJumpingValue == value) + { + return; + } + + _lastJumpingValue = value; + _animator.SetInteger(_jumpingParameter, value); + } + + private int GetAnimatorWeaponValue() + { + if (_networkPlayer == null || _networkPlayer.EquipmentVisualId == EquipmentVisualCatalog.None) + { + return _defaultWeaponValue; + } + + return EquipmentVisualCatalog.GetAnimatorWeaponValue(_networkPlayer.EquipmentVisualId); + } + + private bool HasAnyTriggerNumberParameter() + { + return _hasTriggerNumberParameter || _hasTriggerNumberSpacedParameter; + } + + private void SetTriggerNumber(int value) + { + if (_hasTriggerNumberParameter) + { + _animator.SetInteger(_triggerNumberParameter, value); + } + + if (_hasTriggerNumberSpacedParameter) + { + _animator.SetInteger(_triggerNumberSpacedParameter, value); + } + } + } +} diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkAnimatorBridge.cs.meta b/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkAnimatorBridge.cs.meta new file mode 100644 index 0000000..3e08fb0 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkAnimatorBridge.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9f51f6baf6b447e4b8fd7e7f7fdd3df8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkInputProvider.cs b/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkInputProvider.cs index 20686be..631909e 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkInputProvider.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkInputProvider.cs @@ -20,6 +20,8 @@ public struct NetworkInputData : INetworkInput { public float HorizontalAxis; public float VerticalAxis; + public NetworkBool Run; + public NetworkBool Jump; public NetworkBool Interact; public NetworkBool AbilitySlot1; } @@ -38,6 +40,8 @@ public struct NetworkInputData : INetworkInput public sealed class NetworkInputProvider : MonoBehaviour, INetworkRunnerCallbacks { private NetworkRunner _runner; + private bool _jumpQueued; + private bool _runToggled = true; private void Awake() { @@ -50,6 +54,25 @@ private void OnDestroy() if (_runner != null) _runner.RemoveCallbacks(this); } + private void Update() + { + var kb = Keyboard.current; + if (kb == null) + { + return; + } + + if (kb.leftShiftKey.wasPressedThisFrame || kb.rightShiftKey.wasPressedThisFrame) + { + _runToggled = !_runToggled; + } + + if (kb.spaceKey.wasPressedThisFrame) + { + _jumpQueued = true; + } + } + public void OnInput(NetworkRunner runner, NetworkInput input) { // Vertical slice: read raw keyboard via Unity Input System. @@ -62,15 +85,19 @@ public void OnInput(NetworkRunner runner, NetworkInput input) { HorizontalAxis = (kb.dKey.isPressed ? 1f : 0f) - (kb.aKey.isPressed ? 1f : 0f), VerticalAxis = (kb.wKey.isPressed ? 1f : 0f) - (kb.sKey.isPressed ? 1f : 0f), + Run = _runToggled, + Jump = _jumpQueued, Interact = kb.eKey.isPressed, AbilitySlot1 = kb.digit1Key.isPressed, }; + _jumpQueued = false; input.Set(data); } // Unused callbacks - implement so INetworkRunnerCallbacks is satisfied. // Real handlers land as slice phase 2+ features need them. +#pragma warning disable UNT0006 // Fusion callbacks intentionally share names with Unity messages but use Fusion-specific signatures. public void OnConnectedToServer(NetworkRunner runner) { } public void OnConnectFailed(NetworkRunner runner, NetAddress remoteAddress, NetConnectFailedReason reason) { } public void OnConnectRequest(NetworkRunner runner, NetworkRunnerCallbackArgs.ConnectRequest request, byte[] token) { } @@ -92,5 +119,6 @@ public void OnShutdown(NetworkRunner runner, ShutdownReason shutdownReason) { } #pragma warning disable CS0618 // SimulationMessagePtr is obsolete in Fusion 2.1+ but the interface still requires the implementation per Photon/Fusion/release_history.txt line 408. public void OnUserSimulationMessage(NetworkRunner runner, SimulationMessagePtr message) { } #pragma warning restore CS0618 +#pragma warning restore UNT0006 } } diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkPlayer.cs b/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkPlayer.cs index 449fe11..4e0a24e 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkPlayer.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkPlayer.cs @@ -24,14 +24,24 @@ 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 VisualVariant { get; set; } + [Networked] public int EquipmentVisualId { get; set; } /// True when the offline AI agent is driving this character (Pillar 1). [Networked] public NetworkBool IsAgentControlled { get; set; } - [SerializeField, Tooltip("Movement speed in units/second. Simple KCC owns authoritative movement for this spike.")] + [SerializeField, Tooltip("Run speed in units/second. Simple KCC owns authoritative movement for this spike.")] private float _moveSpeed = 5f; + [SerializeField, Tooltip("Walk speed in units/second. Shift toggles between walk and run during the prototype.")] + private float _walkSpeed = 2.2f; + + [SerializeField, Tooltip("Authoritative jump impulse applied by Fusion Simple KCC.")] + private float _jumpImpulse = 7.5f; + private SimpleKCC _kcc; + private NetworkInputData _prototypeAgentInput; + private bool _hasPrototypeAgentInput; private void Awake() { @@ -47,6 +57,11 @@ public override void Spawned() CultivationTier = 1; // Awakening - starting tier per docs/design/04-cultivation-system.md Hp = 100f; Stamina = 100f; + if (EquipmentVisualId == EquipmentVisualCatalog.None) + { + EquipmentVisualId = EquipmentVisualCatalog.GetDefaultForVisualVariant(VisualVariant); + } + IsAgentControlled = false; } } @@ -59,11 +74,12 @@ public override void FixedUpdateNetwork() } var moveVelocity = Vector3.zero; + var jumpImpulse = 0f; // Server-authoritative input application. The client sends // INetworkInput suggestions; Simple KCC owns predicted and // replicated movement state for the character body. - if (GetInput(out NetworkInputData input)) + if (TryGetAuthoritativeInput(out NetworkInputData input)) { var move = new Vector3(input.HorizontalAxis, 0f, input.VerticalAxis); move = Vector3.ClampMagnitude(move, 1f); @@ -71,11 +87,48 @@ public override void FixedUpdateNetwork() if (move.sqrMagnitude > 0.0001f) { _kcc.SetLookRotation(Quaternion.LookRotation(move), preservePitch: false, preserveYaw: false); - moveVelocity = move * _moveSpeed; + var speed = input.Run ? _moveSpeed : _walkSpeed; + moveVelocity = move * speed; + } + + if (input.Jump) + { + jumpImpulse = _jumpImpulse; } } - _kcc.Move(moveVelocity, jumpImpulse: 0f); + _kcc.Move(moveVelocity, jumpImpulse); + } + + public void SetPrototypeAgentInput(NetworkInputData input) + { + _prototypeAgentInput = input; + _hasPrototypeAgentInput = true; + if (HasStateAuthority) + { + IsAgentControlled = true; + } + } + + public void ClearPrototypeAgentInput() + { + _prototypeAgentInput = default; + _hasPrototypeAgentInput = false; + if (HasStateAuthority) + { + IsAgentControlled = false; + } + } + + private bool TryGetAuthoritativeInput(out NetworkInputData input) + { + if (_hasPrototypeAgentInput && IsAgentControlled) + { + input = _prototypeAgentInput; + return true; + } + + return GetInput(out input); } } } diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkRunnerSetup.cs b/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkRunnerSetup.cs index 13e4220..340369b 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkRunnerSetup.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkRunnerSetup.cs @@ -1,3 +1,4 @@ +using System; using Fusion; using UnityEngine; @@ -10,7 +11,9 @@ namespace SecondSpawn.Networking /// /// Application.isBatchMode true (Linux headless server build) /// -> dedicated, production canonical. - /// Application.isBatchMode false (editor / standalone client) + /// -secondspawn-client command-line flag + /// -> for local multi-client smoke tests. + /// Application.isBatchMode false with no override /// -> on Photon Cloud free 20 CCU, DEV ONLY. /// /// @@ -44,9 +47,10 @@ private async void Start() } _runner = gameObject.AddComponent(); - _runner.ProvideInput = !Application.isBatchMode; + var mode = ResolveGameMode(); + _runner.ProvideInput = mode != GameMode.Server; + RegisterRunnerCallbacks(); - var mode = Application.isBatchMode ? GameMode.Server : GameMode.Host; Debug.Log($"[NetworkRunnerSetup] Starting {mode} session '{_sessionName}' (max {_maxPlayersPerZone} players)."); var startArgs = new StartGameArgs @@ -77,5 +81,47 @@ private void OnDestroy() _ = _runner.Shutdown(); } } + + private void RegisterRunnerCallbacks() + { + foreach (var callback in GetComponents()) + { + _runner.AddCallbacks(callback); + } + } + + private static GameMode ResolveGameMode() + { + var args = Environment.GetCommandLineArgs(); + if (HasArg(args, "-secondspawn-client")) + { + return GameMode.Client; + } + + if (HasArg(args, "-secondspawn-host")) + { + return GameMode.Host; + } + + if (HasArg(args, "-secondspawn-server") || Application.isBatchMode) + { + return GameMode.Server; + } + + return GameMode.Host; + } + + private static bool HasArg(string[] args, string expected) + { + foreach (var arg in args) + { + if (string.Equals(arg, expected, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } } } diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/PlayerSpawner.cs b/Unity/Assets/_SecondSpawn/Scripts/Networking/PlayerSpawner.cs index 86a5783..cde952c 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/Networking/PlayerSpawner.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/PlayerSpawner.cs @@ -56,10 +56,20 @@ public void OnPlayerJoined(NetworkRunner runner, PlayerRef player) } var spawnPos = ComputeSpawnPosition(_spawnCounter); - var playerObject = runner.Spawn(_playerPrefab, spawnPos, Quaternion.identity, player); + var visualVariant = Random.Range(0, Mathf.Max(1, VisualPrefabCatalog.Count)); + var equipmentVisualId = EquipmentVisualCatalog.GetDefaultForVisualVariant(visualVariant); + var playerObject = runner.Spawn(_playerPrefab, spawnPos, Quaternion.identity, player, (_, obj) => + { + var networkPlayer = obj.GetComponent(); + if (networkPlayer != null) + { + networkPlayer.VisualVariant = visualVariant; + networkPlayer.EquipmentVisualId = equipmentVisualId; + } + }); runner.SetPlayerObject(player, playerObject); _spawnCounter++; - Debug.Log($"[PlayerSpawner] Spawned player cube for {player} at {spawnPos}"); + Debug.Log($"[PlayerSpawner] Spawned player cube for {player} at {spawnPos} with visual variant {visualVariant} and equipment visual {equipmentVisualId}"); } public void OnPlayerLeft(NetworkRunner runner, PlayerRef player) @@ -82,6 +92,7 @@ private Vector3 ComputeSpawnPosition(int slot) } // Unused INetworkRunnerCallbacks members - implement to satisfy interface. +#pragma warning disable UNT0006 // Fusion callbacks intentionally share names with Unity messages but use Fusion-specific signatures. public void OnConnectedToServer(NetworkRunner runner) { } public void OnConnectFailed(NetworkRunner runner, NetAddress remoteAddress, NetConnectFailedReason reason) { } public void OnConnectRequest(NetworkRunner runner, NetworkRunnerCallbackArgs.ConnectRequest request, byte[] token) { } @@ -102,5 +113,6 @@ public void OnShutdown(NetworkRunner runner, ShutdownReason shutdownReason) { } #pragma warning disable CS0618 // SimulationMessagePtr is obsolete in Fusion 2.1+ but the interface still requires the implementation per Photon/Fusion/release_history.txt line 408. public void OnUserSimulationMessage(NetworkRunner runner, SimulationMessagePtr message) { } #pragma warning restore CS0618 +#pragma warning restore UNT0006 } } diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/PrototypeVisualActionHotkeys.cs b/Unity/Assets/_SecondSpawn/Scripts/Networking/PrototypeVisualActionHotkeys.cs new file mode 100644 index 0000000..e411ebc --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/PrototypeVisualActionHotkeys.cs @@ -0,0 +1,61 @@ +using Fusion; +using UnityEngine; +using UnityEngine.InputSystem; + +namespace SecondSpawn.Networking +{ + /// + /// Local prototype hotkeys for testing visual animation intents. + /// This is a dev-only bridge and does not grant gameplay authority. + /// + [DisallowMultipleComponent] + public sealed class PrototypeVisualActionHotkeys : MonoBehaviour + { + private VisualAnimationIntentDriver _driver; + private NetworkObject _networkObject; + + private void Awake() + { + _networkObject = GetComponent(); + } + + private void Update() + { + if (_networkObject != null && !_networkObject.HasInputAuthority) + { + return; + } + + ResolveDriver(); + if (_driver == null) + { + return; + } + + var keyboard = Keyboard.current; + if (keyboard == null) + { + return; + } + + if (keyboard.eKey.wasPressedThisFrame) _driver.TryPlay(VisualAnimationIntent.Interact); + if (keyboard.tKey.wasPressedThisFrame) _driver.TryPlay(VisualAnimationIntent.Talk); + if (keyboard.yKey.wasPressedThisFrame) _driver.TryPlay(VisualAnimationIntent.Agree); + if (keyboard.nKey.wasPressedThisFrame) _driver.TryPlay(VisualAnimationIntent.Disagree); + if (keyboard.jKey.wasPressedThisFrame) _driver.TryPlay(VisualAnimationIntent.Attack); + if (keyboard.cKey.wasPressedThisFrame) _driver.TryPlay(VisualAnimationIntent.Cast); + if (keyboard.qKey.wasPressedThisFrame) _driver.TryPlay(VisualAnimationIntent.DodgeLeft); + if (keyboard.rKey.wasPressedThisFrame) _driver.TryPlay(VisualAnimationIntent.DodgeRight); + if (keyboard.kKey.wasPressedThisFrame) _driver.TryPlay(VisualAnimationIntent.Death); + if (keyboard.lKey.wasPressedThisFrame) _driver.TryPlay(VisualAnimationIntent.Revive); + } + + private void ResolveDriver() + { + if (_driver == null) + { + _driver = GetComponentInChildren(); + } + } + } +} diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/PrototypeVisualActionHotkeys.cs.meta b/Unity/Assets/_SecondSpawn/Scripts/Networking/PrototypeVisualActionHotkeys.cs.meta new file mode 100644 index 0000000..b856dde --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/PrototypeVisualActionHotkeys.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 55a4c759766b46d9a6d9c8085b86fe6c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/TopDownCameraFollow.cs b/Unity/Assets/_SecondSpawn/Scripts/Networking/TopDownCameraFollow.cs new file mode 100644 index 0000000..ff77751 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/TopDownCameraFollow.cs @@ -0,0 +1,57 @@ +using Fusion; +using UnityEngine; + +namespace SecondSpawn.Networking +{ + /// + /// Lightweight top-down camera follow for the controller prototype scene. + /// It follows the local input-authority player in Host Mode and falls back + /// to the first spawned player when running a server-only smoke test. + /// + [DisallowMultipleComponent] + public sealed class TopDownCameraFollow : MonoBehaviour + { + [SerializeField, Tooltip("Offset from the followed player.")] + private Vector3 _offset = new(0f, 12f, -9f); + + [SerializeField, Tooltip("Camera follow smoothing. Higher values catch up faster.")] + private float _followSharpness = 12f; + + [SerializeField, Tooltip("Camera rotation for the prototype top-down view.")] + private Vector3 _eulerAngles = new(60f, 0f, 0f); + + private Transform _target; + + private void LateUpdate() + { + if (_target == null) + { + _target = FindTarget(); + if (_target == null) + { + return; + } + } + + var desiredPosition = _target.position + _offset; + var sharpness = Mathf.Max(0.01f, _followSharpness); + transform.position = Vector3.Lerp(transform.position, desiredPosition, 1f - Mathf.Exp(-sharpness * Time.deltaTime)); + transform.rotation = Quaternion.Euler(_eulerAngles); + } + + private static Transform FindTarget() + { + var players = FindObjectsByType(FindObjectsInactive.Exclude); + foreach (var player in players) + { + var networkObject = player.Object; + if (networkObject != null && networkObject.HasInputAuthority) + { + return player.transform; + } + } + + return players.Length > 0 ? players[0].transform : null; + } + } +} diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/TopDownCameraFollow.cs.meta b/Unity/Assets/_SecondSpawn/Scripts/Networking/TopDownCameraFollow.cs.meta new file mode 100644 index 0000000..58bf109 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/TopDownCameraFollow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3a67a9550edb4d96ad61c780e63b6b4d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/VisualAnimationIntentDriver.cs b/Unity/Assets/_SecondSpawn/Scripts/Networking/VisualAnimationIntentDriver.cs new file mode 100644 index 0000000..4b5c337 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/VisualAnimationIntentDriver.cs @@ -0,0 +1,246 @@ +using System; +using UnityEngine; + +namespace SecondSpawn.Networking +{ + public enum VisualAnimationIntent + { + None = 0, + Jump = 1, + Talk = 2, + Agree = 3, + Disagree = 4, + Interact = 5, + Attack = 6, + Cast = 7, + DodgeLeft = 8, + DodgeRight = 9, + DodgeBackward = 10, + Death = 11, + Revive = 12, + } + + /// + /// Translates high-level visual intents into optional local Animator + /// states. Gameplay authority stays on the networked root. + /// + [DisallowMultipleComponent] + public sealed class VisualAnimationIntentDriver : MonoBehaviour + { + [SerializeField, Tooltip("Animator on the local-only visual child.")] + private Animator _animator; + + [SerializeField, Tooltip("Cross-fade duration for one-shot visual intents.")] + private float _crossFadeSeconds = 0.08f; + + [SerializeField] private string _triggerParameter = "Trigger"; + [SerializeField] private string _triggerNumberParameter = "TriggerNumber"; + [SerializeField] private string _actionParameter = "Action"; + [SerializeField] private string _talkingParameter = "Talking"; + [SerializeField] private string _weaponParameter = "Weapon"; + [SerializeField] private string _animationSpeedParameter = "AnimationSpeed"; + + [SerializeField] private string _talkState = "Base Layer.Relax.Conversation.Relax-Talk1"; + [SerializeField] private string _agreeState = "Base Layer.Relax.Relax-Actions.Relax-Yes"; + [SerializeField] private string _disagreeState = "Base Layer.Relax.Relax-Actions.Relax-No"; + [SerializeField] private string _interactState = "Base Layer.Unarmed.Unarmed-Interact.Unarmed-Pickup"; + + private bool _hasTriggerParameter; + private bool _hasTriggerNumberParameter; + private bool _hasActionParameter; + private bool _hasWeaponParameter; + private bool _hasAnimationSpeedParameter; + private NetworkPlayer _networkPlayer; + + public bool TryPlay(VisualAnimationIntent intent) + { + ResolveAnimator(); + if (_animator == null || intent == VisualAnimationIntent.None) + { + return false; + } + + SetAnimationSpeed(1f); + return TryPlayThroughAnimatorContract(intent); + } + + public void Play(string intentName) + { + if (Enum.TryParse(intentName, ignoreCase: true, out VisualAnimationIntent intent)) + { + TryPlay(intent); + } + } + + private void Awake() + { + ResolveAnimator(); + } + + private void ResolveAnimator() + { + if (_animator == null) + { + _animator = GetComponent(); + } + + if (_animator == null) + { + _animator = GetComponentInChildren(); + } + + if (_animator != null) + { + _animator.applyRootMotion = false; + _networkPlayer ??= GetComponentInParent(); + CacheParameters(); + } + } + + private bool TryPlayThroughAnimatorContract(VisualAnimationIntent intent) + { + return intent switch + { + VisualAnimationIntent.Jump => false, + VisualAnimationIntent.Talk => TryPlayTalking(), + VisualAnimationIntent.Agree => TryCrossFadeState(_agreeState), + VisualAnimationIntent.Disagree => TryCrossFadeState(_disagreeState), + VisualAnimationIntent.Interact => TryFireActionTrigger(triggerNumber: 2, action: 2) || TryCrossFadeState(_interactState), + VisualAnimationIntent.Attack => TryFireActionTrigger(triggerNumber: 4, action: 1), + VisualAnimationIntent.Cast => TryFireActionTrigger(triggerNumber: 10, action: 1), + VisualAnimationIntent.DodgeLeft => TryFireActionTrigger(triggerNumber: 13, action: 4), + VisualAnimationIntent.DodgeRight => TryFireActionTrigger(triggerNumber: 13, action: 2), + VisualAnimationIntent.DodgeBackward => TryFireActionTrigger(triggerNumber: 13, action: 3), + VisualAnimationIntent.Death => TryFireTrigger(20), + VisualAnimationIntent.Revive => TryFireTrigger(21), + _ => false, + }; + } + + private bool TryFireActionTrigger(int triggerNumber, int action) + { + if (!_hasActionParameter) + { + return false; + } + + if (_hasWeaponParameter) + { + _animator.SetInteger(_weaponParameter, GetAnimatorWeaponValue()); + } + + _animator.SetInteger(_actionParameter, action); + return TryFireTrigger(triggerNumber); + } + + private bool TryPlayTalking() + { + TrySetInteger(_talkingParameter, 1); + return TryCrossFadeState(_talkState); + } + + private bool TryFireTrigger(int triggerNumber) + { + if (!_hasTriggerNumberParameter || !_hasTriggerParameter) + { + return false; + } + + _animator.SetInteger(_triggerNumberParameter, triggerNumber); + _animator.SetTrigger(_triggerParameter); + return true; + } + + private bool TryCrossFadeState(string stateName) + { + var layerIndex = 0; + var stateHash = Animator.StringToHash(stateName); + if (!_animator.HasState(layerIndex, stateHash)) + { + var shortStateName = GetShortStateName(stateName); + var shortStateHash = Animator.StringToHash(shortStateName); + if (!_animator.HasState(layerIndex, shortStateHash)) + { + return false; + } + + stateHash = shortStateHash; + } + + SetAnimationSpeed(1f); + _animator.CrossFadeInFixedTime(stateHash, Mathf.Max(0f, _crossFadeSeconds), layerIndex); + return true; + } + + private void SetAnimationSpeed(float speed) + { + if (_hasAnimationSpeedParameter) + { + _animator.SetFloat(_animationSpeedParameter, speed); + } + } + + private bool TrySetInteger(string parameterName, int value) + { + foreach (var parameter in _animator.parameters) + { + if (parameter.name == parameterName && parameter.type == AnimatorControllerParameterType.Int) + { + _animator.SetInteger(parameterName, value); + return true; + } + } + + return false; + } + + private void CacheParameters() + { + _hasTriggerParameter = false; + _hasTriggerNumberParameter = false; + _hasActionParameter = false; + _hasWeaponParameter = false; + _hasAnimationSpeedParameter = false; + + foreach (var parameter in _animator.parameters) + { + if (parameter.name == _triggerParameter && parameter.type == AnimatorControllerParameterType.Trigger) + { + _hasTriggerParameter = true; + } + else if (parameter.name == _triggerNumberParameter && parameter.type == AnimatorControllerParameterType.Int) + { + _hasTriggerNumberParameter = true; + } + else if (parameter.name == _actionParameter && parameter.type == AnimatorControllerParameterType.Int) + { + _hasActionParameter = true; + } + else if (parameter.name == _weaponParameter && parameter.type == AnimatorControllerParameterType.Int) + { + _hasWeaponParameter = true; + } + else if (parameter.name == _animationSpeedParameter && parameter.type == AnimatorControllerParameterType.Float) + { + _hasAnimationSpeedParameter = true; + } + } + } + + private static string GetShortStateName(string stateName) + { + var dotIndex = stateName.LastIndexOf('.'); + return dotIndex >= 0 && dotIndex < stateName.Length - 1 ? stateName[(dotIndex + 1)..] : stateName; + } + + private int GetAnimatorWeaponValue() + { + if (_networkPlayer == null || _networkPlayer.EquipmentVisualId == EquipmentVisualCatalog.None) + { + return 0; + } + + return EquipmentVisualCatalog.GetAnimatorWeaponValue(_networkPlayer.EquipmentVisualId); + } + } +} diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/VisualAnimationIntentDriver.cs.meta b/Unity/Assets/_SecondSpawn/Scripts/Networking/VisualAnimationIntentDriver.cs.meta new file mode 100644 index 0000000..8db81c1 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/VisualAnimationIntentDriver.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5998dc518e4047dba0a08719ff257ad1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/VisualPrefabCatalog.cs b/Unity/Assets/_SecondSpawn/Scripts/Networking/VisualPrefabCatalog.cs new file mode 100644 index 0000000..230c75d --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/VisualPrefabCatalog.cs @@ -0,0 +1,76 @@ +namespace SecondSpawn.Networking +{ + public static class VisualPrefabCatalog + { + public const string CleanVisualFolder = "Assets/_SecondSpawn/Art/Characters/GeneratedVisualsV2"; + public const string CleanMaterialFolder = "Assets/_SecondSpawn/Art/Characters/GeneratedVisualsV2/Materials"; + + public static readonly string[] SourceAssetPaths = + { + "Assets/ExplosiveLLC/RPG Character Mecanim Animation Pack/Prefabs/Character/RPG-Character.prefab", + "Assets/ExplosiveLLC/Warrior Pack Bundle 1 FREE/Brute Warrior Mecanim Animation Pack/Prefabs/Brute Warrior.prefab", + "Assets/ExplosiveLLC/Warrior Pack Bundle 1 FREE/Karate Warrior Mecanim Animation Pack/Prefabs/Karate Warrior.prefab", + "Assets/ExplosiveLLC/Warrior Pack Bundle 1 FREE/Ninja Warrior Mecanim Animation Pack/Prefabs/Ninja Warrior.prefab", + "Assets/ExplosiveLLC/Warrior Pack Bundle 1 FREE/Sorceress Warrior Mecanim Animation Pack/Prefabs/Sorceress Warrior.prefab", + "Assets/ExplosiveLLC/Warrior Pack Bundle 2 FREE/2 Handed Warrior Mecanim Animation Pack/Prefabs/2Handed Warrior.prefab", + "Assets/ExplosiveLLC/Warrior Pack Bundle 2 FREE/Archer Warrior Mecanim Animation Pack/Prefabs/Archer Warrior.prefab", + "Assets/ExplosiveLLC/Warrior Pack Bundle 2 FREE/Knight Warrior Mecanim Animation Pack/Prefabs/Knight Warrior.prefab", + "Assets/ExplosiveLLC/Warrior Pack Bundle 2 FREE/Mage Warrior Mecanim Animation Pack/Prefabs/Mage Warrior.prefab", + "Assets/ExplosiveLLC/Warrior Pack Bundle 3 FREE/Crossbow Warrior Mecanim Animation Pack/Prefabs/Crossbow Warrior.prefab", + "Assets/ExplosiveLLC/Warrior Pack Bundle 3 FREE/Hammer Warrior Mecanim Animation Pack/Prefabs/Hammer Warrior.prefab", + "Assets/ExplosiveLLC/Warrior Pack Bundle 3 FREE/Spearman Warrior Mecanim Animation Pack/Prefabs/Spearman Warrior.prefab", + "Assets/ExplosiveLLC/Warrior Pack Bundle 3 FREE/Swordsman Warrior Mecanim Animation Pack/Prefabs/Swordsman Warrior.prefab", + }; + + public static int Count => SourceAssetPaths.Length; + + public static string GetSourceAssetPath(int variant) + { + return SourceAssetPaths[NormalizeVariant(variant)]; + } + + public static string GetCleanAssetPath(int variant) + { + return $"{CleanVisualFolder}/{GetCleanPrefabName(variant)}"; + } + + public static string GetCleanPrefabName(int variant) + { + var index = NormalizeVariant(variant); + var sourcePath = SourceAssetPaths[index]; + var fileNameStart = sourcePath.LastIndexOf('/') + 1; + var fileNameEnd = sourcePath.LastIndexOf('.'); + var sourceName = fileNameEnd > fileNameStart + ? sourcePath[fileNameStart..fileNameEnd] + : $"Visual{index:00}"; + + return $"Visual_{index:00}_{SanitizeFileName(sourceName)}.prefab"; + } + + public static int NormalizeVariant(int variant) + { + if (Count == 0) + { + return 0; + } + + var result = variant % Count; + return result < 0 ? result + Count : result; + } + + private static string SanitizeFileName(string value) + { + var chars = value.ToCharArray(); + for (var i = 0; i < chars.Length; i++) + { + var c = chars[i]; + if (!(char.IsLetterOrDigit(c) || c == '-' || c == '_')) + { + chars[i] = '_'; + } + } + + return new string(chars); + } + } +} diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/VisualPrefabCatalog.cs.meta b/Unity/Assets/_SecondSpawn/Scripts/Networking/VisualPrefabCatalog.cs.meta new file mode 100644 index 0000000..b099543 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/VisualPrefabCatalog.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: be964d3b312b8a14ab0a627d640072cd \ No newline at end of file diff --git a/Unity/Assets/_SecondSpawn/Scripts/Settings/SecondSpawnConfig.cs b/Unity/Assets/_SecondSpawn/Scripts/Settings/SecondSpawnConfig.cs index bc000ff..3c66f28 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/Settings/SecondSpawnConfig.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/Settings/SecondSpawnConfig.cs @@ -36,7 +36,7 @@ public sealed class SecondSpawnConfig : ScriptableObject [Header("Gateway")] [Tooltip("Base URL of the Go LLM gateway. All LLM + NFT calls go through here.")] - public string GatewayBaseUrl = "http://localhost:8080"; + public string GatewayBaseUrl = "https://second-spawn-gateway-535583621422.asia-southeast1.run.app"; [Header("Supabase (public-safe values only)")] [Tooltip("Supabase project URL, e.g. https://your-project.supabase.co")] diff --git a/Unity/Assets/_SecondSpawn/Settings/PC_RPAsset.asset b/Unity/Assets/_SecondSpawn/Settings/PC_RPAsset.asset index 9fb5b0d..5707d0a 100644 --- a/Unity/Assets/_SecondSpawn/Settings/PC_RPAsset.asset +++ b/Unity/Assets/_SecondSpawn/Settings/PC_RPAsset.asset @@ -100,16 +100,16 @@ MonoBehaviour: m_Keys: [] m_Values: m_PrefilteringModeMainLightShadows: 3 - m_PrefilteringModeAdditionalLight: 4 - m_PrefilteringModeAdditionalLightShadows: 0 + m_PrefilteringModeAdditionalLight: 0 + m_PrefilteringModeAdditionalLightShadows: 2 m_PrefilterXRKeywords: 1 - m_PrefilteringModeForwardPlus: 1 + m_PrefilteringModeForwardPlus: 2 m_PrefilteringModeDeferredRendering: 0 - m_PrefilteringModeScreenSpaceOcclusion: 1 + m_PrefilteringModeScreenSpaceOcclusion: 2 m_PrefilterDebugKeywords: 1 - m_PrefilterWriteRenderingLayers: 0 + m_PrefilterWriteRenderingLayers: 1 m_PrefilterHDROutput: 1 - m_PrefilterAlphaOutput: 0 + m_PrefilterAlphaOutput: 1 m_PrefilterSSAODepthNormals: 0 m_PrefilterSSAOSourceDepthLow: 1 m_PrefilterSSAOSourceDepthMedium: 1 @@ -121,21 +121,21 @@ MonoBehaviour: m_PrefilterSSAOSampleCountHigh: 1 m_PrefilterDBufferMRT1: 1 m_PrefilterDBufferMRT2: 1 - m_PrefilterDBufferMRT3: 0 - m_PrefilterSoftShadowsQualityLow: 0 - m_PrefilterSoftShadowsQualityMedium: 0 - m_PrefilterSoftShadowsQualityHigh: 0 + m_PrefilterDBufferMRT3: 1 + m_PrefilterSoftShadowsQualityLow: 1 + m_PrefilterSoftShadowsQualityMedium: 1 + m_PrefilterSoftShadowsQualityHigh: 1 m_PrefilterSoftShadows: 0 m_PrefilterScreenCoord: 1 - m_PrefilterScreenSpaceIrradiance: 0 + m_PrefilterScreenSpaceIrradiance: 1 m_PrefilterNativeRenderPass: 1 m_PrefilterUseLegacyLightmaps: 0 - m_PrefilterBicubicLightmapSampling: 0 - m_PrefilterReflectionProbeRotation: 0 + m_PrefilterBicubicLightmapSampling: 1 + m_PrefilterReflectionProbeRotation: 1 m_PrefilterReflectionProbeBlending: 0 m_PrefilterReflectionProbeBoxProjection: 0 m_PrefilterReflectionProbeAtlas: 0 - m_PrefilterPointSamplingUpsampling: 0 + m_PrefilterPointSamplingUpsampling: 1 m_ShaderVariantLogLevel: 0 m_ShadowCascades: 0 m_Textures: diff --git a/Unity/Assets/_SecondSpawn/Settings/SecondSpawnConfig.asset b/Unity/Assets/_SecondSpawn/Settings/SecondSpawnConfig.asset index c4301f7..4523407 100644 --- a/Unity/Assets/_SecondSpawn/Settings/SecondSpawnConfig.asset +++ b/Unity/Assets/_SecondSpawn/Settings/SecondSpawnConfig.asset @@ -13,7 +13,7 @@ MonoBehaviour: m_Name: SecondSpawnConfig m_EditorClassIdentifier: Environment: 0 - GatewayBaseUrl: http://localhost:8080 + GatewayBaseUrl: https://second-spawn-gateway-535583621422.asia-southeast1.run.app SupabaseUrl: SupabaseAnonKey: PhotonAppId: diff --git a/Unity/Assets/_SecondSpawn/Settings/UniversalRenderPipelineGlobalSettings.asset b/Unity/Assets/_SecondSpawn/Settings/UniversalRenderPipelineGlobalSettings.asset index 6ac675e..af9ce03 100644 --- a/Unity/Assets/_SecondSpawn/Settings/UniversalRenderPipelineGlobalSettings.asset +++ b/Unity/Assets/_SecondSpawn/Settings/UniversalRenderPipelineGlobalSettings.asset @@ -68,7 +68,22 @@ MonoBehaviour: - rid: 6707569896483979288 - rid: 6707569896483979289 m_RuntimeSettings: - m_List: [] + m_List: + - rid: 6852985685364965378 + - rid: 6852985685364965379 + - rid: 6852985685364965380 + - rid: 6852985685364965381 + - rid: 6852985685364965384 + - rid: 6852985685364965392 + - rid: 6852985685364965394 + - rid: 8712630790384254976 + - rid: 6707569896483979277 + - rid: 6707569896483979279 + - rid: 6707569896483979280 + - rid: 6707569896483979281 + - rid: 6707569896483979282 + - rid: 6707569896483979286 + - rid: 6707569896483979288 m_AssetVersion: 10 m_ObsoleteDefaultVolumeProfile: {fileID: 0} m_RenderingLayerNames: diff --git a/Unity/ProjectSettings/ProjectSettings.asset b/Unity/ProjectSettings/ProjectSettings.asset index 70dc332..49d46d0 100644 --- a/Unity/ProjectSettings/ProjectSettings.asset +++ b/Unity/ProjectSettings/ProjectSettings.asset @@ -527,7 +527,10 @@ PlayerSettings: m_Height: 720 m_Kind: 1 m_SubKind: - m_BuildTargetBatching: [] + m_BuildTargetBatching: + - m_BuildTarget: Standalone + m_StaticBatching: 1 + m_DynamicBatching: 0 m_BuildTargetShaderSettings: [] m_BuildTargetGraphicsJobs: [] m_BuildTargetGraphicsJobMode: [] diff --git a/Unity/ProjectSettings/QualitySettings.asset b/Unity/ProjectSettings/QualitySettings.asset index f55198a..7a5ecbf 100644 --- a/Unity/ProjectSettings/QualitySettings.asset +++ b/Unity/ProjectSettings/QualitySettings.asset @@ -4,63 +4,9 @@ QualitySettings: m_ObjectHideFlags: 0 serializedVersion: 5 - m_CurrentQuality: 1 + m_CurrentQuality: 0 m_QualitySettings: - - serializedVersion: 4 - name: Mobile - pixelLightCount: 2 - shadows: 2 - shadowResolution: 1 - shadowProjection: 1 - shadowCascades: 2 - shadowDistance: 40 - shadowNearPlaneOffset: 3 - shadowCascade2Split: 0.33333334 - shadowCascade4Split: {x: 0.06666667, y: 0.2, z: 0.46666667} - shadowmaskMode: 0 - skinWeights: 2 - globalTextureMipmapLimit: 0 - textureMipmapLimitSettings: [] - anisotropicTextures: 1 - antiAliasing: 0 - softParticles: 0 - softVegetation: 1 - realtimeReflectionProbes: 0 - billboardsFaceCameraPosition: 1 - useLegacyDetailDistribution: 1 - adaptiveVsync: 0 - vSyncCount: 0 - realtimeGICPUUsage: 100 - adaptiveVsyncExtraA: 0 - adaptiveVsyncExtraB: 0 - lodBias: 1 - maximumLODLevel: 0 - enableLODCrossFade: 1 - streamingMipmapsActive: 0 - streamingMipmapsAddAllCameras: 1 - streamingMipmapsMemoryBudget: 512 - streamingMipmapsRenderersPerFrame: 512 - streamingMipmapsMaxLevelReduction: 2 - streamingMipmapsMaxFileIORequests: 1024 - particleRaycastBudget: 256 - asyncUploadTimeSlice: 2 - asyncUploadBufferSize: 16 - asyncUploadPersistentBuffer: 1 - resolutionScalingFixedDPIFactor: 1 - customRenderPipeline: {fileID: 11400000, guid: 5e6cbd92db86f4b18aec3ed561671858, - type: 2} - terrainQualityOverrides: 0 - terrainPixelError: 1 - terrainDetailDensityScale: 1 - terrainBasemapDistance: 1000 - terrainDetailDistance: 80 - terrainTreeDistance: 5000 - terrainBillboardStart: 50 - terrainFadeLength: 5 - terrainMaxTrees: 50 - excludedTargetPlatforms: - - Standalone - - serializedVersion: 4 + - serializedVersion: 5 name: PC pixelLightCount: 2 shadows: 2 @@ -88,6 +34,7 @@ QualitySettings: adaptiveVsyncExtraA: 0 adaptiveVsyncExtraB: 0 lodBias: 2 + meshLodThreshold: 1 maximumLODLevel: 0 enableLODCrossFade: 1 streamingMipmapsActive: 0 @@ -101,8 +48,7 @@ QualitySettings: asyncUploadBufferSize: 16 asyncUploadPersistentBuffer: 1 resolutionScalingFixedDPIFactor: 1 - customRenderPipeline: {fileID: 11400000, guid: 4b83569d67af61e458304325a23e5dfd, - type: 2} + customRenderPipeline: {fileID: 11400000, guid: 4b83569d67af61e458304325a23e5dfd, type: 2} terrainQualityOverrides: 0 terrainPixelError: 1 terrainDetailDensityScale: 1 @@ -116,19 +62,4 @@ QualitySettings: - Android - iPhone m_TextureMipmapLimitGroupNames: [] - m_PerPlatformDefaultQuality: - Android: 0 - GameCoreScarlett: 1 - GameCoreXboxOne: 1 - Lumin: 0 - Nintendo Switch: 1 - PS4: 1 - PS5: 1 - Server: 0 - Stadia: 0 - Standalone: 1 - WebGL: 0 - Windows Store Apps: 0 - XboxOne: 0 - iPhone: 0 - tvOS: 0 + m_PerPlatformDefaultQuality: {} diff --git a/Unity/ProjectSettings/UnityConnectSettings.asset b/Unity/ProjectSettings/UnityConnectSettings.asset index 029ad8b..7a17e8f 100644 --- a/Unity/ProjectSettings/UnityConnectSettings.asset +++ b/Unity/ProjectSettings/UnityConnectSettings.asset @@ -4,7 +4,7 @@ UnityConnectSettings: m_ObjectHideFlags: 0 serializedVersion: 1 - m_Enabled: 0 + m_Enabled: 1 m_TestMode: 0 m_EventOldUrl: https://api.uca.cloud.unity3d.com/v1/events m_EventUrl: https://cdp.cloud.unity3d.com/v1/events diff --git a/backend/gateway/.env.example b/backend/gateway/.env.example index 99b6771..38dc353 100644 --- a/backend/gateway/.env.example +++ b/backend/gateway/.env.example @@ -1,8 +1,8 @@ # Copy to backend/gateway/.env (gitignored) and fill in real values. -# NEVER commit .env. Production deploy reads these from VPS env / Modal secrets. +# NEVER commit .env. Production deploy reads these from Cloud Run / VPS secrets. GATEWAY_ENV=development -GATEWAY_LISTEN_ADDR=:8080 +GATEWAY_LISTEN_ADDR=:8090 # Supabase (anon key is public-safe in Unity; service role + JWT secret are server-only) SUPABASE_URL=https://YOUR_PROJECT.supabase.co diff --git a/backend/gateway/Dockerfile b/backend/gateway/Dockerfile index cc905f7..b02d862 100644 --- a/backend/gateway/Dockerfile +++ b/backend/gateway/Dockerfile @@ -9,5 +9,5 @@ FROM gcr.io/distroless/static-debian12:nonroot WORKDIR /app COPY --from=build /out/gateway /app/gateway USER nonroot:nonroot -EXPOSE 8080 +EXPOSE 8090 ENTRYPOINT ["/app/gateway"] diff --git a/backend/gateway/README.md b/backend/gateway/README.md index 34eca2f..f1bf2f4 100644 --- a/backend/gateway/README.md +++ b/backend/gateway/README.md @@ -1,6 +1,7 @@ -# SECOND SPAWN - LLM Gateway +# SECOND SPAWN - Prototype LLM Gateway Contract -Go HTTP service that fronts every LLM call from the Unity game server. +Prototype Go HTTP service that fronts LLM-style calls from the Unity game +server while the shared `api.dos.ai` integration is not wired yet. The Unity client and the dedicated game server never hold LLM API keys - all calls are routed through this gateway, which enforces: @@ -14,13 +15,21 @@ all calls are routed through this gateway, which enforces: Reuses the operational pattern of `D:\Projects\DOSRouter` (the Go LLM router JOY already operates for DOSafe / DOS.AI). +Boundary: + +- Production AI/LLM calls should move to the shared `api.dos.ai` Go gateway. +- Game backend logic belongs in Nakama OSS runtime modules under + `backend/nakama/`. +- Do not add profile, inventory, matchmaking, guild, wallet mutation, or + gameplay APIs here. + ## Run locally ```bash cd backend/gateway cp .env.example .env # then fill in the real secrets make run -curl localhost:8080/healthz +curl localhost:8090/readyz ``` ## Test @@ -35,11 +44,18 @@ CI runs `make test` on every PR (see `.github/workflows/backend-test.yml`). ```bash make docker -docker run --rm -p 8080:8080 --env-file .env second-spawn-gateway:local +docker run --rm -p 8090:8090 --env-file .env second-spawn-gateway:local ``` -The production deploy target is a VPS (Hetzner) or Modal. Image is -distroless, runs as non-root, no shell. +Local development defaults to `:8090` because CoplayDev MCP for Unity uses +`localhost:8080`. + +The preferred prototype deploy target is Google Cloud Run. The same image can +move to a VPS later if the gateway needs co-location with dedicated game server +infrastructure. Image is distroless, runs as non-root, no shell. + +Cloud Run injects `PORT`; local development still defaults to `:8090`. +See `docs/setup/game-gateway-cloud-run.md`. ## Package layout @@ -54,20 +70,31 @@ backend/gateway/ └── internal/ ├── config/ # env var loader (no secrets in code) ├── server/ # HTTP routes, handlers, middleware + ├── agent/ # offline-agent decision contract ├── auth/ # Supabase JWT verification ├── llm/ # provider interface (Anthropic, OpenAI, Convai) + ├── character/ # profile, soul, stats, and agent memory contract └── intent/ # structured intent schema + validator contract ``` `internal/` packages are not importable outside this module - keeps the public API of the gateway minimal (just the HTTP routes). -## Open work +## Current prototype routes + +The scaffold compiles and has prototype handlers for: + +- `GET /readyz` +- `GET /v1/characters/{playerID}/context` +- `PUT /v1/characters/{playerID}/soul` +- `POST /v1/characters/{playerID}/memory` +- `POST /v1/agent/decide` +- `POST /v1/npc/chat` +- `POST /v1/voice/session` -The scaffold compiles and `/healthz` + `/readyz` work. Wire-up for the -real handlers (`/v1/npc/chat`, `/v1/agent/decide`, `/v1/intent/validate`) -is staged but commented out in `internal/server/server.go` until the LLM -provider implementations and Supabase JWT verifier are written. +Real provider calls, persistent storage, full route-level Supabase JWT +enforcement, and rate limiting are still open work. Nakama custom authentication +is handled inside `backend/nakama/`, not through this gateway. See: - `internal/llm/provider.go` for the provider interface diff --git a/backend/gateway/deploy/cloudrun.env.yaml b/backend/gateway/deploy/cloudrun.env.yaml new file mode 100644 index 0000000..44d313b --- /dev/null +++ b/backend/gateway/deploy/cloudrun.env.yaml @@ -0,0 +1,4 @@ +GATEWAY_ENV: staging +LLM_RATE_LIMIT_PER_PLAYER_PER_MIN: "30" +LLM_TOKEN_BUDGET_PER_PLAYER_DAY: "50000" + diff --git a/backend/gateway/internal/agent/decision.go b/backend/gateway/internal/agent/decision.go new file mode 100644 index 0000000..8228a58 --- /dev/null +++ b/backend/gateway/internal/agent/decision.go @@ -0,0 +1,235 @@ +// Package agent defines the offline AI agent decision contract. +package agent + +import ( + "errors" + "fmt" + "math" + "strings" + + "github.com/DOS/Second-Spawn/backend/gateway/internal/character" +) + +type ActionType string + +const ( + ActionStop ActionType = "stop" + ActionMove ActionType = "move" + ActionAttack ActionType = "attack" + ActionInteract ActionType = "interact" + ActionSay ActionType = "say" +) + +// DecisionRequest is the bounded input sent to the LLM layer. +type DecisionRequest struct { + Context character.AgentContext `json:"context"` + WorldSnapshot WorldSnapshot `json:"world_snapshot"` + Allowed []ActionType `json:"allowed"` +} + +// WorldSnapshot is safe, non-authoritative context for model reasoning. +// The game server still validates every returned decision. +type WorldSnapshot struct { + ZoneID string `json:"zone_id"` + Position Vector2 `json:"position"` + SafeRadius float32 `json:"safe_radius"` + NearbyTargets []WorldTarget `json:"nearby_targets"` + NearbyObjects []WorldObject `json:"nearby_objects"` + DangerLevel int `json:"danger_level"` + BodyTimeSeconds int64 `json:"body_time_seconds"` +} + +type Vector2 struct { + X float32 `json:"x"` + Z float32 `json:"z"` +} + +type WorldTarget struct { + ID string `json:"id"` + Kind string `json:"kind"` + Distance float32 `json:"distance"` + Threat int `json:"threat"` +} + +type WorldObject struct { + ID string `json:"id"` + Kind string `json:"kind"` + Distance float32 `json:"distance"` +} + +// Decision is the structured output from the LLM layer. +// It is not authoritative gameplay state. +type Decision struct { + Action ActionType `json:"action"` + TargetID string `json:"target_id,omitempty"` + Move *Vector2 `json:"move,omitempty"` + Say string `json:"say,omitempty"` + Reason string `json:"reason,omitempty"` + Confidence float32 `json:"confidence"` + Data map[string]string `json:"data,omitempty"` +} + +var ( + ErrActionNotAllowed = errors.New("agent action is not allowed") + ErrInvalidDecision = errors.New("agent decision is invalid") +) + +func ValidateDecision(req DecisionRequest, decision Decision) error { + if !isAllowed(req.Allowed, decision.Action) { + return fmt.Errorf("%w: %s", ErrActionNotAllowed, decision.Action) + } + if decision.Confidence < 0 || decision.Confidence > 1 { + return fmt.Errorf("%w: confidence must be between 0 and 1", ErrInvalidDecision) + } + + switch decision.Action { + case ActionStop: + return nil + case ActionMove: + if decision.Move == nil { + return fmt.Errorf("%w: move action requires coordinates", ErrInvalidDecision) + } + return nil + case ActionAttack: + if strings.TrimSpace(decision.TargetID) == "" { + return fmt.Errorf("%w: attack action requires target_id", ErrInvalidDecision) + } + if !hasTarget(req.WorldSnapshot.NearbyTargets, decision.TargetID) { + return fmt.Errorf("%w: target_id is not in nearby targets", ErrInvalidDecision) + } + return nil + case ActionInteract: + if strings.TrimSpace(decision.TargetID) == "" { + return fmt.Errorf("%w: interact action requires target_id", ErrInvalidDecision) + } + if !hasObject(req.WorldSnapshot.NearbyObjects, decision.TargetID) { + return fmt.Errorf("%w: target_id is not in nearby objects", ErrInvalidDecision) + } + return nil + case ActionSay: + if strings.TrimSpace(decision.Say) == "" { + return fmt.Errorf("%w: say action requires text", ErrInvalidDecision) + } + return nil + default: + return fmt.Errorf("%w: unknown action", ErrInvalidDecision) + } +} + +// DecidePrototype is the deterministic fallback used before a real provider is +// connected. It keeps the vertical slice playable and preserves the same +// validated intent contract that an LLM provider must obey later. +func DecidePrototype(req DecisionRequest) Decision { + if req.WorldSnapshot.BodyTimeSeconds > 0 && + req.Context.Body.AgentPolicy.StopWhenBodyTimeBelow > 0 && + req.WorldSnapshot.BodyTimeSeconds <= req.Context.Body.AgentPolicy.StopWhenBodyTimeBelow && + isAllowed(req.Allowed, ActionStop) { + return Decision{ + Action: ActionStop, + Reason: "body time is below the offline-agent safety threshold", + Confidence: 1, + } + } + + if isAllowed(req.Allowed, ActionAttack) && req.Context.Body.AgentPolicy.AllowRiskyCombat { + if target, ok := nearestTarget(req.WorldSnapshot.NearbyTargets); ok && target.Threat <= req.Context.Body.Characteristics.Courage { + return Decision{ + Action: ActionAttack, + TargetID: target.ID, + Reason: "nearby target is within policy risk tolerance", + Confidence: 0.7, + } + } + } + + if isAllowed(req.Allowed, ActionInteract) { + if object, ok := nearestObject(req.WorldSnapshot.NearbyObjects); ok { + return Decision{ + Action: ActionInteract, + TargetID: object.ID, + Reason: "safe nearby object can be inspected", + Confidence: 0.65, + } + } + } + + if isAllowed(req.Allowed, ActionSay) && req.Context.Body.Characteristics.Sociability >= 6 { + return Decision{ + Action: ActionSay, + Say: fmt.Sprintf("%s is scouting the area and keeping the body safe.", req.Context.Body.Soul.Name), + Reason: "high sociability favors lightweight status communication", + Confidence: 0.55, + } + } + + if isAllowed(req.Allowed, ActionMove) { + return Decision{ + Action: ActionMove, + Move: &Vector2{ + X: req.WorldSnapshot.Position.X + 1, + Z: req.WorldSnapshot.Position.Z, + }, + Reason: "patrol one step inside the safe radius", + Confidence: 0.6, + } + } + + return Decision{ + Action: ActionStop, + Reason: "no safe allowed action is available", + Confidence: 1, + } +} + +func isAllowed(actions []ActionType, action ActionType) bool { + for _, allowed := range actions { + if allowed == action { + return true + } + } + return false +} + +func nearestTarget(targets []WorldTarget) (WorldTarget, bool) { + if len(targets) == 0 { + return WorldTarget{}, false + } + best := targets[0] + for _, target := range targets[1:] { + if target.Distance < best.Distance { + best = target + } + } + return best, !math.IsInf(float64(best.Distance), 0) +} + +func nearestObject(objects []WorldObject) (WorldObject, bool) { + if len(objects) == 0 { + return WorldObject{}, false + } + best := objects[0] + for _, object := range objects[1:] { + if object.Distance < best.Distance { + best = object + } + } + return best, !math.IsInf(float64(best.Distance), 0) +} + +func hasTarget(targets []WorldTarget, targetID string) bool { + for _, target := range targets { + if target.ID == targetID { + return true + } + } + return false +} + +func hasObject(objects []WorldObject, objectID string) bool { + for _, object := range objects { + if object.ID == objectID { + return true + } + } + return false +} diff --git a/backend/gateway/internal/agent/decision_test.go b/backend/gateway/internal/agent/decision_test.go new file mode 100644 index 0000000..946ecd0 --- /dev/null +++ b/backend/gateway/internal/agent/decision_test.go @@ -0,0 +1,68 @@ +package agent + +import ( + "errors" + "testing" +) + +func TestValidateDecisionAllowsWhitelistedMove(t *testing.T) { + req := DecisionRequest{ + Allowed: []ActionType{ActionMove, ActionStop}, + } + + err := ValidateDecision(req, Decision{ + Action: ActionMove, + Move: &Vector2{X: 1, Z: 2}, + Confidence: 0.8, + }) + if err != nil { + t.Fatalf("expected move decision to validate: %v", err) + } +} + +func TestValidateDecisionRejectsActionOutsidePolicy(t *testing.T) { + req := DecisionRequest{ + Allowed: []ActionType{ActionMove, ActionStop}, + } + + err := ValidateDecision(req, Decision{ + Action: ActionAttack, + TargetID: "enemy-1", + Confidence: 0.9, + }) + if !errors.Is(err, ErrActionNotAllowed) { + t.Fatalf("expected ErrActionNotAllowed, got %v", err) + } +} + +func TestValidateDecisionRequiresNearbyAttackTarget(t *testing.T) { + req := DecisionRequest{ + Allowed: []ActionType{ActionAttack}, + WorldSnapshot: WorldSnapshot{ + NearbyTargets: []WorldTarget{{ID: "enemy-1"}}, + }, + } + + err := ValidateDecision(req, Decision{ + Action: ActionAttack, + TargetID: "enemy-2", + Confidence: 0.9, + }) + if !errors.Is(err, ErrInvalidDecision) { + t.Fatalf("expected ErrInvalidDecision, got %v", err) + } +} + +func TestValidateDecisionRequiresSayText(t *testing.T) { + req := DecisionRequest{ + Allowed: []ActionType{ActionSay}, + } + + err := ValidateDecision(req, Decision{ + Action: ActionSay, + Confidence: 0.5, + }) + if !errors.Is(err, ErrInvalidDecision) { + t.Fatalf("expected ErrInvalidDecision, got %v", err) + } +} diff --git a/backend/gateway/internal/auth/auth.go b/backend/gateway/internal/auth/auth.go index ccbd929..3b7a206 100644 --- a/backend/gateway/internal/auth/auth.go +++ b/backend/gateway/internal/auth/auth.go @@ -19,6 +19,16 @@ import ( // PlayerID is the Supabase user ID extracted from the JWT. type PlayerID string +// Identity is the trusted player identity extracted from a verified Supabase +// access token. +type Identity struct { + PlayerID PlayerID + Role string + Email string + IsAnonymous bool + ExpiresAt int64 +} + // ErrMissingAuth is returned when the Authorization header is absent. var ErrMissingAuth = errors.New("missing Authorization header") @@ -31,7 +41,7 @@ var ErrInvalidJWT = errors.New("invalid JWT") // no network call to Supabase per request, the secret is enough to // verify locally. type Verifier interface { - Verify(ctx context.Context, jwt string) (PlayerID, error) + Verify(ctx context.Context, jwt string) (Identity, error) } // FromRequest extracts the bearer token from an HTTP request. diff --git a/backend/gateway/internal/character/profile.go b/backend/gateway/internal/character/profile.go new file mode 100644 index 0000000..d8f9806 --- /dev/null +++ b/backend/gateway/internal/character/profile.go @@ -0,0 +1,198 @@ +// Package character defines the durable player-character profile contract used +// by the LLM gateway and the authoritative game server. +package character + +import ( + "fmt" + "sort" + "strings" + "time" +) + +// PlayerProfile is durable account-level identity. It survives body death. +type PlayerProfile struct { + PlayerID string `json:"player_id"` + DisplayName string `json:"display_name"` + CreatedAt time.Time `json:"created_at"` +} + +// BodyProfile is the current synthetic body. It is replaced on reincarnation. +type BodyProfile struct { + BodyID string `json:"body_id"` + ArchetypeID string `json:"archetype_id"` + VisualPrefabKey string `json:"visual_prefab_key"` + Equipment EquipmentLoadout `json:"equipment"` + Stats CharacterStats `json:"stats"` + Characteristics CharacterTraits `json:"characteristics"` + Time BodyTimeState `json:"time"` + Cultivation Cultivation `json:"cultivation"` + Lifecycle BodyLifecycle `json:"lifecycle"` + AgentPolicy AgentPolicy `json:"agent_policy"` + Soul SoulProfile `json:"soul"` + Memory []MemoryRecord `json:"memory"` + CreatedAt time.Time `json:"created_at"` +} + +type EquipmentLoadout struct { + PrimaryWeapon string `json:"primary_weapon"` + EquipmentVisualID int `json:"equipment_visual_id"` +} + +type CharacterStats struct { + Level int `json:"level"` + Vitality int `json:"vitality"` + Force int `json:"force"` + Agility int `json:"agility"` + Focus int `json:"focus"` + Resilience int `json:"resilience"` + MaxHealth int `json:"max_health"` + MaxEnergy int `json:"max_energy"` + AttackPower int `json:"attack_power"` + DefensePower int `json:"defense_power"` +} + +// CharacterTraits are stable personality/action tendencies for the LLM agent. +// They guide behavior only. They are not gameplay modifiers and never bypass +// server-side intent validation. +type CharacterTraits struct { + Curiosity int `json:"curiosity"` + Courage int `json:"courage"` + Empathy int `json:"empathy"` + Discipline int `json:"discipline"` + Aggression int `json:"aggression"` + Sociability int `json:"sociability"` +} + +type BodyTimeState struct { + RemainingSeconds int64 `json:"remaining_seconds"` + MaxSeconds int64 `json:"max_seconds"` + DangerDrainRate int64 `json:"danger_drain_rate"` +} + +type Cultivation struct { + Tier string `json:"tier"` + ProgressXP int64 `json:"progress_xp"` +} + +type BodyLifecycle string + +const ( + BodyLifecycleAlive BodyLifecycle = "alive" + BodyLifecycleDying BodyLifecycle = "dying" + BodyLifecycleReincarnating BodyLifecycle = "reincarnating" + BodyLifecycleDead BodyLifecycle = "dead" +) + +// AgentPolicy is player-controlled. The offline agent must not exceed it. +type AgentPolicy struct { + Enabled bool `json:"enabled"` + Mode string `json:"mode"` + MaxSessionSeconds int64 `json:"max_session_seconds"` + AllowBodyTimeSpend bool `json:"allow_body_time_spend"` + AllowRiskyCombat bool `json:"allow_risky_combat"` + PreferredActivities []string `json:"preferred_activities"` + ForbiddenActivities []string `json:"forbidden_activities"` + StopWhenBodyTimeBelow int64 `json:"stop_when_body_time_below"` +} + +// SoulProfile is the stable identity layer read by the LLM agent. +// It is not a stat buff container. Gameplay bonuses stay in server-owned state. +type SoulProfile struct { + Name string `json:"name"` + CoreDrive string `json:"core_drive"` + Temperament string `json:"temperament"` + CombatStyle string `json:"combat_style"` + SocialStyle string `json:"social_style"` + MoralBoundaries []string `json:"moral_boundaries"` + LongTermGoals []string `json:"long_term_goals"` + PlayerNotes string `json:"player_notes"` + ReincarnationLore string `json:"reincarnation_lore"` +} + +type MemoryKind string + +const ( + MemoryKindPreference MemoryKind = "preference" + MemoryKindQuest MemoryKind = "quest" + MemoryKindRelationship MemoryKind = "relationship" + MemoryKindCombat MemoryKind = "combat" + MemoryKindSystem MemoryKind = "system" +) + +// MemoryRecord is compact RAG input for the offline agent. +type MemoryRecord struct { + ID string `json:"id"` + Kind MemoryKind `json:"kind"` + Summary string `json:"summary"` + Importance int `json:"importance"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// AgentContext is the prompt-safe snapshot passed to an LLM provider. +type AgentContext struct { + Player PlayerProfile `json:"player"` + Body BodyProfile `json:"body"` +} + +// BuildAgentContextPrompt returns a stable, bounded text block for an LLM. +func BuildAgentContextPrompt(ctx AgentContext, maxMemories int) string { + memories := append([]MemoryRecord(nil), ctx.Body.Memory...) + sort.SliceStable(memories, func(i, j int) bool { + if memories[i].Importance == memories[j].Importance { + return memories[i].UpdatedAt.After(memories[j].UpdatedAt) + } + return memories[i].Importance > memories[j].Importance + }) + if maxMemories >= 0 && len(memories) > maxMemories { + memories = memories[:maxMemories] + } + + var b strings.Builder + writeKV(&b, "player_id", ctx.Player.PlayerID) + writeKV(&b, "display_name", ctx.Player.DisplayName) + writeKV(&b, "body_id", ctx.Body.BodyID) + 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, "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)) + writeKV(&b, "traits", fmt.Sprintf("curiosity=%d courage=%d empathy=%d discipline=%d aggression=%d sociability=%d", + ctx.Body.Characteristics.Curiosity, + ctx.Body.Characteristics.Courage, + ctx.Body.Characteristics.Empathy, + ctx.Body.Characteristics.Discipline, + ctx.Body.Characteristics.Aggression, + ctx.Body.Characteristics.Sociability, + )) + writeKV(&b, "agent_enabled", fmt.Sprintf("%t", ctx.Body.AgentPolicy.Enabled)) + writeKV(&b, "agent_mode", ctx.Body.AgentPolicy.Mode) + writeKV(&b, "agent_stop_time_threshold", fmt.Sprintf("%d", ctx.Body.AgentPolicy.StopWhenBodyTimeBelow)) + writeKV(&b, "soul_name", ctx.Body.Soul.Name) + writeKV(&b, "core_drive", ctx.Body.Soul.CoreDrive) + writeKV(&b, "temperament", ctx.Body.Soul.Temperament) + writeKV(&b, "combat_style", ctx.Body.Soul.CombatStyle) + writeKV(&b, "social_style", ctx.Body.Soul.SocialStyle) + writeKV(&b, "long_term_goals", strings.Join(ctx.Body.Soul.LongTermGoals, "; ")) + writeKV(&b, "moral_boundaries", strings.Join(ctx.Body.Soul.MoralBoundaries, "; ")) + writeKV(&b, "player_notes", ctx.Body.Soul.PlayerNotes) + writeKV(&b, "memory_count", fmt.Sprintf("%d", len(memories))) + + for i, memory := range memories { + writeKV(&b, fmt.Sprintf("memory_%02d_kind", i+1), string(memory.Kind)) + writeKV(&b, fmt.Sprintf("memory_%02d_summary", i+1), memory.Summary) + } + + return strings.TrimSpace(b.String()) +} + +func writeKV(b *strings.Builder, key string, value string) { + if strings.TrimSpace(value) == "" { + return + } + b.WriteString(key) + b.WriteString(": ") + b.WriteString(strings.TrimSpace(value)) + b.WriteByte('\n') +} diff --git a/backend/gateway/internal/character/profile_test.go b/backend/gateway/internal/character/profile_test.go new file mode 100644 index 0000000..a340e2f --- /dev/null +++ b/backend/gateway/internal/character/profile_test.go @@ -0,0 +1,84 @@ +package character + +import ( + "strings" + "testing" + "time" +) + +func TestBuildAgentContextPromptSortsAndBoundsMemories(t *testing.T) { + now := time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC) + ctx := AgentContext{ + Player: PlayerProfile{ + PlayerID: "player-1", + DisplayName: "JOY", + }, + Body: BodyProfile{ + BodyID: "body-1", + ArchetypeID: "hunter-default", + VisualPrefabKey: "rpg-character", + Equipment: EquipmentLoadout{ + PrimaryWeapon: "one_hand_sword", + EquipmentVisualID: 2, + }, + Lifecycle: BodyLifecycleAlive, + Time: BodyTimeState{ + RemainingSeconds: 3600, + MaxSeconds: 7200, + }, + Cultivation: Cultivation{Tier: "Awakening"}, + AgentPolicy: AgentPolicy{ + Enabled: true, + Mode: "farm_safe_area", + StopWhenBodyTimeBelow: 900, + }, + Soul: SoulProfile{ + Name: "Second Spawn Test Soul", + CoreDrive: "survive long enough to regain agency", + Temperament: "cautious", + CombatStyle: "kite enemies", + SocialStyle: "brief and practical", + LongTermGoals: []string{"reach Enhancement"}, + MoralBoundaries: []string{"do not betray allies"}, + PlayerNotes: "avoid unnecessary risk", + }, + Memory: []MemoryRecord{ + {Kind: MemoryKindCombat, Summary: "Low value memory", Importance: 1, UpdatedAt: now}, + {Kind: MemoryKindQuest, Summary: "Critical quest memory", Importance: 9, UpdatedAt: now.Add(-time.Hour)}, + {Kind: MemoryKindPreference, Summary: "Recent preference memory", Importance: 5, UpdatedAt: now.Add(time.Hour)}, + }, + }, + } + + prompt := BuildAgentContextPrompt(ctx, 2) + + if !strings.Contains(prompt, "player_id: player-1") { + t.Fatalf("expected player id in prompt, got %s", prompt) + } + if !strings.Contains(prompt, "memory_01_summary: Critical quest memory") { + t.Fatalf("expected highest importance memory first, got %s", prompt) + } + if !strings.Contains(prompt, "primary_weapon: one_hand_sword") { + t.Fatalf("expected equipment in prompt, got %s", prompt) + } + if !strings.Contains(prompt, "memory_02_summary: Recent preference memory") { + t.Fatalf("expected second bounded memory, got %s", prompt) + } + if strings.Contains(prompt, "Low value memory") { + t.Fatalf("expected low value memory to be excluded, got %s", prompt) + } +} + +func TestBuildAgentContextPromptOmitsEmptyFields(t *testing.T) { + prompt := BuildAgentContextPrompt(AgentContext{ + Player: PlayerProfile{PlayerID: "player-1"}, + Body: BodyProfile{BodyID: "body-1"}, + }, 5) + + if strings.Contains(prompt, "display_name:") { + t.Fatalf("expected empty display name to be omitted, got %s", prompt) + } + if !strings.Contains(prompt, "player_id: player-1") { + t.Fatalf("expected non-empty field, got %s", prompt) + } +} diff --git a/backend/gateway/internal/character/store.go b/backend/gateway/internal/character/store.go new file mode 100644 index 0000000..a0a1141 --- /dev/null +++ b/backend/gateway/internal/character/store.go @@ -0,0 +1,293 @@ +package character + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "sync" + "time" +) + +var ( + ErrPlayerIDRequired = errors.New("player_id is required") + ErrMemoryInvalid = errors.New("memory record is invalid") +) + +type Store interface { + GetOrCreateContext(ctx context.Context, playerID string) (AgentContext, error) + UpdateSoul(ctx context.Context, playerID string, soul SoulProfile, traits CharacterTraits, policy AgentPolicy) (AgentContext, error) + AddMemory(ctx context.Context, playerID string, memory MemoryRecord) (AgentContext, error) +} + +type MemoryStore struct { + mu sync.RWMutex + profiles map[string]AgentContext + now func() time.Time +} + +func NewMemoryStore() *MemoryStore { + return &MemoryStore{ + profiles: make(map[string]AgentContext), + now: func() time.Time { return time.Now().UTC() }, + } +} + +func (s *MemoryStore) GetOrCreateContext(ctx context.Context, playerID string) (AgentContext, error) { + if err := ctx.Err(); err != nil { + return AgentContext{}, err + } + playerID = normalizeID(playerID) + if playerID == "" { + return AgentContext{}, ErrPlayerIDRequired + } + + s.mu.Lock() + defer s.mu.Unlock() + + if existing, ok := s.profiles[playerID]; ok { + return existing, nil + } + + profile := NewDefaultAgentContext(playerID, s.now()) + s.profiles[playerID] = profile + return profile, nil +} + +func (s *MemoryStore) UpdateSoul(ctx context.Context, playerID string, soul SoulProfile, traits CharacterTraits, policy AgentPolicy) (AgentContext, error) { + if err := ctx.Err(); err != nil { + return AgentContext{}, err + } + playerID = normalizeID(playerID) + if playerID == "" { + return AgentContext{}, ErrPlayerIDRequired + } + + s.mu.Lock() + defer s.mu.Unlock() + + profile, ok := s.profiles[playerID] + if !ok { + profile = NewDefaultAgentContext(playerID, s.now()) + } + + profile.Body.Soul = normalizeSoul(soul, profile.Player.DisplayName) + profile.Body.Characteristics = clampTraits(traits) + profile.Body.AgentPolicy = normalizePolicy(policy) + s.profiles[playerID] = profile + return profile, nil +} + +func (s *MemoryStore) AddMemory(ctx context.Context, playerID string, memory MemoryRecord) (AgentContext, error) { + if err := ctx.Err(); err != nil { + return AgentContext{}, err + } + playerID = normalizeID(playerID) + if playerID == "" { + return AgentContext{}, ErrPlayerIDRequired + } + + s.mu.Lock() + defer s.mu.Unlock() + + profile, ok := s.profiles[playerID] + if !ok { + profile = NewDefaultAgentContext(playerID, s.now()) + } + + now := s.now() + memory.Kind = normalizeMemoryKind(memory.Kind) + memory.Summary = strings.TrimSpace(memory.Summary) + if memory.Summary == "" { + return AgentContext{}, fmt.Errorf("%w: summary is required", ErrMemoryInvalid) + } + if memory.Importance < 1 { + memory.Importance = 1 + } + if memory.Importance > 10 { + memory.Importance = 10 + } + + for i := range profile.Body.Memory { + existing := &profile.Body.Memory[i] + if existing.Kind == memory.Kind && strings.EqualFold(strings.TrimSpace(existing.Summary), memory.Summary) { + if memory.Importance > existing.Importance { + existing.Importance = memory.Importance + } + existing.UpdatedAt = now + profile.Body.Memory = sortAndBoundMemories(profile.Body.Memory) + s.profiles[playerID] = profile + return profile, nil + } + } + + if memory.ID == "" { + memory.ID = fmt.Sprintf("mem-%d", now.UnixNano()) + } + if memory.CreatedAt.IsZero() { + memory.CreatedAt = now + } + memory.UpdatedAt = now + + profile.Body.Memory = append(profile.Body.Memory, memory) + profile.Body.Memory = sortAndBoundMemories(profile.Body.Memory) + + s.profiles[playerID] = profile + return profile, nil +} + +func sortAndBoundMemories(memories []MemoryRecord) []MemoryRecord { + sort.SliceStable(memories, func(i, j int) bool { + if memories[i].Importance == memories[j].Importance { + return memories[i].UpdatedAt.After(memories[j].UpdatedAt) + } + return memories[i].Importance > memories[j].Importance + }) + if len(memories) > 64 { + return memories[:64] + } + return memories +} + +func NewDefaultAgentContext(playerID string, now time.Time) AgentContext { + displayName := playerID + if displayName == "" { + displayName = "Unknown Wanderer" + } + + return AgentContext{ + Player: PlayerProfile{ + PlayerID: playerID, + DisplayName: displayName, + CreatedAt: now, + }, + Body: BodyProfile{ + BodyID: "body-" + playerID, + ArchetypeID: "prototype-hunter", + VisualPrefabKey: "prototype-random", + Equipment: EquipmentLoadout{ + PrimaryWeapon: "none", + EquipmentVisualID: 0, + }, + Stats: CharacterStats{ + Level: 1, + Vitality: 10, + Force: 8, + Agility: 8, + Focus: 8, + Resilience: 8, + MaxHealth: 100, + MaxEnergy: 50, + AttackPower: 10, + DefensePower: 5, + }, + Characteristics: CharacterTraits{ + Curiosity: 6, + Courage: 5, + Empathy: 5, + Discipline: 5, + Aggression: 3, + Sociability: 5, + }, + Time: BodyTimeState{ + RemainingSeconds: 24 * 60 * 60, + MaxSeconds: 24 * 60 * 60, + DangerDrainRate: 1, + }, + Cultivation: Cultivation{ + Tier: "Awakening", + ProgressXP: 0, + }, + Lifecycle: BodyLifecycleAlive, + AgentPolicy: AgentPolicy{ + Enabled: true, + Mode: "observe_and_keep_safe", + MaxSessionSeconds: 30 * 60, + AllowBodyTimeSpend: false, + AllowRiskyCombat: false, + PreferredActivities: []string{"explore", "talk", "safe_farming"}, + ForbiddenActivities: []string{"spend_body_time", "start_pvp", "trade_items"}, + StopWhenBodyTimeBelow: 15 * 60, + }, + Soul: SoulProfile{ + Name: displayName, + CoreDrive: "survive, learn the zone, and preserve agency for the player", + Temperament: "careful but curious", + CombatStyle: "avoid risky fights, kite when threatened", + SocialStyle: "brief, grounded, and helpful", + MoralBoundaries: []string{"do not betray allies", "do not spend scarce resources without permission"}, + LongTermGoals: []string{"reach Enhancement", "build trusted relationships with NPCs"}, + PlayerNotes: "prototype default soul", + ReincarnationLore: "a synthetic body carrying a persistent consciousness imprint", + }, + Memory: []MemoryRecord{ + { + ID: "seed-origin", + Kind: MemoryKindSystem, + Summary: "The character is a Second Spawn prototype body controlled by the player or their offline agent.", + Importance: 6, + CreatedAt: now, + UpdatedAt: now, + }, + }, + CreatedAt: now, + }, + } +} + +func normalizeID(value string) string { + return strings.TrimSpace(value) +} + +func normalizeSoul(soul SoulProfile, fallbackName string) SoulProfile { + if strings.TrimSpace(soul.Name) == "" { + soul.Name = fallbackName + } + if strings.TrimSpace(soul.CoreDrive) == "" { + soul.CoreDrive = "survive and protect player agency" + } + return soul +} + +func normalizePolicy(policy AgentPolicy) AgentPolicy { + if strings.TrimSpace(policy.Mode) == "" { + policy.Mode = "observe_and_keep_safe" + } + if policy.MaxSessionSeconds <= 0 { + policy.MaxSessionSeconds = 30 * 60 + } + if policy.StopWhenBodyTimeBelow <= 0 { + policy.StopWhenBodyTimeBelow = 15 * 60 + } + return policy +} + +func clampTraits(traits CharacterTraits) CharacterTraits { + traits.Curiosity = clampTrait(traits.Curiosity) + traits.Courage = clampTrait(traits.Courage) + traits.Empathy = clampTrait(traits.Empathy) + traits.Discipline = clampTrait(traits.Discipline) + traits.Aggression = clampTrait(traits.Aggression) + traits.Sociability = clampTrait(traits.Sociability) + return traits +} + +func clampTrait(value int) int { + if value < 1 { + return 1 + } + if value > 10 { + return 10 + } + return value +} + +func normalizeMemoryKind(kind MemoryKind) MemoryKind { + switch kind { + case MemoryKindPreference, MemoryKindQuest, MemoryKindRelationship, MemoryKindCombat, MemoryKindSystem: + return kind + default: + return MemoryKindSystem + } +} diff --git a/backend/gateway/internal/character/store_test.go b/backend/gateway/internal/character/store_test.go new file mode 100644 index 0000000..0707bc1 --- /dev/null +++ b/backend/gateway/internal/character/store_test.go @@ -0,0 +1,53 @@ +package character + +import ( + "context" + "testing" + "time" +) + +func TestMemoryStoreAddMemoryDeduplicatesSameKindAndSummary(t *testing.T) { + store := NewMemoryStore() + now := time.Date(2026, 5, 16, 1, 0, 0, 0, time.UTC) + store.now = func() time.Time { + now = now.Add(time.Second) + return now + } + + first, err := store.AddMemory(context.Background(), "player-1", MemoryRecord{ + Kind: MemoryKindPreference, + Summary: "Remember this once.", + Importance: 4, + }) + if err != nil { + t.Fatalf("first AddMemory failed: %v", err) + } + + second, err := store.AddMemory(context.Background(), "player-1", MemoryRecord{ + Kind: MemoryKindPreference, + Summary: " Remember this once. ", + Importance: 8, + }) + if err != nil { + t.Fatalf("second AddMemory failed: %v", err) + } + + if got := len(second.Body.Memory); got != len(first.Body.Memory) { + t.Fatalf("expected duplicate memory to update in place, got %d memories before vs %d after", len(first.Body.Memory), got) + } + + var found MemoryRecord + for _, memory := range second.Body.Memory { + if memory.Summary == "Remember this once." { + found = memory + break + } + } + + if found.ID == "" { + t.Fatal("expected deduplicated memory to remain present") + } + if found.Importance != 8 { + t.Fatalf("expected higher duplicate importance to win, got %d", found.Importance) + } +} diff --git a/backend/gateway/internal/config/config.go b/backend/gateway/internal/config/config.go index 282cc1d..a0756f3 100644 --- a/backend/gateway/internal/config/config.go +++ b/backend/gateway/internal/config/config.go @@ -34,7 +34,7 @@ func Load() (*Config, error) { cfg := &Config{ Env: env, - ListenAddr: getEnv("GATEWAY_LISTEN_ADDR", ":8080"), + ListenAddr: getEnv("GATEWAY_LISTEN_ADDR", defaultListenAddr()), SupabaseURL: os.Getenv("SUPABASE_URL"), SupabaseJWTSecret: os.Getenv("SUPABASE_JWT_SECRET"), @@ -69,6 +69,17 @@ func Load() (*Config, error) { return cfg, nil } +func defaultListenAddr() string { + port := strings.TrimSpace(os.Getenv("PORT")) + if port == "" { + return ":8090" + } + if strings.HasPrefix(port, ":") { + return port + } + return ":" + port +} + func getEnv(key, fallback string) string { if v := os.Getenv(key); v != "" { return v diff --git a/backend/gateway/internal/server/server.go b/backend/gateway/internal/server/server.go index 54d8b7b..95c59e0 100644 --- a/backend/gateway/internal/server/server.go +++ b/backend/gateway/internal/server/server.go @@ -2,9 +2,13 @@ package server import ( "encoding/json" + "errors" "net/http" + "strings" "time" + "github.com/DOS/Second-Spawn/backend/gateway/internal/agent" + "github.com/DOS/Second-Spawn/backend/gateway/internal/character" "github.com/DOS/Second-Spawn/backend/gateway/internal/config" ) @@ -14,11 +18,19 @@ import ( // a Supabase JWT, the gateway validates intent server-side, then proxies // to the chosen provider. type Server struct { - cfg *config.Config + cfg *config.Config + store character.Store } func New(cfg *config.Config) *Server { - return &Server{cfg: cfg} + return NewWithStore(cfg, character.NewMemoryStore()) +} + +func NewWithStore(cfg *config.Config, store character.Store) *Server { + if store == nil { + store = character.NewMemoryStore() + } + return &Server{cfg: cfg, store: store} } // Routes registers all HTTP handlers. Keep this file small - real handler @@ -27,11 +39,12 @@ func (s *Server) Routes() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /healthz", s.handleHealth) mux.HandleFunc("GET /readyz", s.handleReady) - // TODO once internal/llm + internal/intent + internal/auth are - // implemented, wire them here: - // mux.Handle("POST /v1/npc/chat", s.handleNPCChat()) - // mux.Handle("POST /v1/agent/decide", s.handleAgentDecide()) - // mux.Handle("POST /v1/intent/validate", s.handleIntentValidate()) + mux.HandleFunc("GET /v1/characters/{playerID}/context", s.handleGetAgentContext) + mux.HandleFunc("PUT /v1/characters/{playerID}/soul", s.handleUpdateSoul) + mux.HandleFunc("POST /v1/characters/{playerID}/memory", s.handleAddMemory) + mux.HandleFunc("POST /v1/agent/decide", s.handleAgentDecide) + mux.HandleFunc("POST /v1/npc/chat", s.handleNPCChat) + mux.HandleFunc("POST /v1/voice/session", s.handleVoiceSession) return mux } @@ -51,8 +64,177 @@ func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"ready": true}) } +func (s *Server) handleGetAgentContext(w http.ResponseWriter, r *http.Request) { + ctx, err := s.store.GetOrCreateContext(r.Context(), r.PathValue("playerID")) + if err != nil { + writeError(w, err) + return + } + + writeJSON(w, http.StatusOK, ctx) +} + +type updateSoulRequest struct { + Soul character.SoulProfile `json:"soul"` + Characteristics character.CharacterTraits `json:"characteristics"` + AgentPolicy character.AgentPolicy `json:"agent_policy"` +} + +func (s *Server) handleUpdateSoul(w http.ResponseWriter, r *http.Request) { + var req updateSoulRequest + if err := decodeJSON(r, &req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) + return + } + + ctx, err := s.store.UpdateSoul(r.Context(), r.PathValue("playerID"), req.Soul, req.Characteristics, req.AgentPolicy) + if err != nil { + writeError(w, err) + return + } + + writeJSON(w, http.StatusOK, ctx) +} + +func (s *Server) handleAddMemory(w http.ResponseWriter, r *http.Request) { + var req character.MemoryRecord + if err := decodeJSON(r, &req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) + return + } + + ctx, err := s.store.AddMemory(r.Context(), r.PathValue("playerID"), req) + if err != nil { + writeError(w, err) + return + } + + writeJSON(w, http.StatusCreated, ctx) +} + +func (s *Server) handleAgentDecide(w http.ResponseWriter, r *http.Request) { + var req agent.DecisionRequest + if err := decodeJSON(r, &req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) + return + } + + if strings.TrimSpace(req.Context.Player.PlayerID) == "" { + ctx, err := s.store.GetOrCreateContext(r.Context(), "dev-player") + if err != nil { + writeError(w, err) + return + } + req.Context = ctx + } + req.Allowed = ensureStopAllowed(req.Allowed) + decision := agent.DecidePrototype(req) + if err := agent.ValidateDecision(req, decision); err != nil { + writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"error": err.Error(), "decision": decision}) + return + } + + writeJSON(w, http.StatusOK, decision) +} + +type npcChatRequest struct { + PlayerID string `json:"player_id"` + NPCID string `json:"npc_id"` + Message string `json:"message"` +} + +type npcChatResponse struct { + PlayerID string `json:"player_id"` + NPCID string `json:"npc_id"` + Text string `json:"text"` + VoiceAvailable bool `json:"voice_available"` + Provider string `json:"provider"` +} + +func (s *Server) handleNPCChat(w http.ResponseWriter, r *http.Request) { + var req npcChatRequest + if err := decodeJSON(r, &req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) + return + } + if strings.TrimSpace(req.PlayerID) == "" { + req.PlayerID = "dev-player" + } + if strings.TrimSpace(req.NPCID) == "" { + req.NPCID = "prototype-npc" + } + + ctx, err := s.store.GetOrCreateContext(r.Context(), req.PlayerID) + if err != nil { + writeError(w, err) + return + } + + text := prototypeNPCReply(ctx, req.Message) + writeJSON(w, http.StatusOK, npcChatResponse{ + PlayerID: req.PlayerID, + NPCID: req.NPCID, + Text: text, + VoiceAvailable: false, + Provider: "prototype-text", + }) +} + +type voiceSessionResponse struct { + VoiceAvailable bool `json:"voice_available"` + Provider string `json:"provider"` + RequiresEphemeralToken bool `json:"requires_ephemeral_token"` + Reason string `json:"reason"` +} + +func (s *Server) handleVoiceSession(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, voiceSessionResponse{ + VoiceAvailable: false, + Provider: "openai-realtime", + RequiresEphemeralToken: true, + Reason: "voice contract is wired; ephemeral token minting waits for provider credentials", + }) +} + func writeJSON(w http.ResponseWriter, status int, body any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(body) } + +func decodeJSON(r *http.Request, target any) error { + defer r.Body.Close() + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + return decoder.Decode(target) +} + +func writeError(w http.ResponseWriter, err error) { + status := http.StatusInternalServerError + if errors.Is(err, character.ErrPlayerIDRequired) || errors.Is(err, character.ErrMemoryInvalid) { + status = http.StatusBadRequest + } + writeJSON(w, status, map[string]any{"error": err.Error()}) +} + +func ensureStopAllowed(actions []agent.ActionType) []agent.ActionType { + for _, action := range actions { + if action == agent.ActionStop { + return actions + } + } + return append(actions, agent.ActionStop) +} + +func prototypeNPCReply(ctx character.AgentContext, message string) string { + message = strings.TrimSpace(message) + if message == "" { + return "I can hear you. Tell me what you want this body to remember." + } + + name := ctx.Body.Soul.Name + if strings.TrimSpace(name) == "" { + name = "your agent" + } + return name + " remembers the shape of that intent. For now I can answer in text; voice will come through the gateway once ephemeral provider tokens are enabled." +} diff --git a/backend/gateway/internal/server/server_test.go b/backend/gateway/internal/server/server_test.go index 0e152c8..dc33556 100644 --- a/backend/gateway/internal/server/server_test.go +++ b/backend/gateway/internal/server/server_test.go @@ -1,6 +1,7 @@ package server import ( + "bytes" "encoding/json" "net/http" "net/http/httptest" @@ -43,3 +44,107 @@ func TestHandleReady(t *testing.T) { t.Fatalf("expected 200, got %d", rec.Code) } } + +func TestCharacterContextLifecycle(t *testing.T) { + srv := New(&config.Config{Env: "test"}) + + getReq := httptest.NewRequest(http.MethodGet, "/v1/characters/player-1/context", nil) + getRec := httptest.NewRecorder() + srv.Routes().ServeHTTP(getRec, getReq) + if getRec.Code != http.StatusOK { + t.Fatalf("expected context 200, got %d: %s", getRec.Code, getRec.Body.String()) + } + + updateBody := []byte(`{ + "soul": { + "name": "Scout One", + "core_drive": "map safe paths", + "temperament": "curious", + "combat_style": "avoid fights", + "social_style": "plain", + "moral_boundaries": ["do not spend body time"], + "long_term_goals": ["reach Enhancement"], + "player_notes": "test note", + "reincarnation_lore": "synthetic continuity" + }, + "characteristics": { + "curiosity": 9, + "courage": 4, + "empathy": 7, + "discipline": 8, + "aggression": 2, + "sociability": 6 + }, + "agent_policy": { + "enabled": true, + "mode": "safe_scout", + "max_session_seconds": 900, + "allow_body_time_spend": false, + "allow_risky_combat": false, + "preferred_activities": ["talk"], + "forbidden_activities": ["pvp"], + "stop_when_body_time_below": 600 + } + }`) + updateReq := httptest.NewRequest(http.MethodPut, "/v1/characters/player-1/soul", bytes.NewReader(updateBody)) + updateRec := httptest.NewRecorder() + srv.Routes().ServeHTTP(updateRec, updateReq) + if updateRec.Code != http.StatusOK { + t.Fatalf("expected update 200, got %d: %s", updateRec.Code, updateRec.Body.String()) + } + + memoryReq := httptest.NewRequest(http.MethodPost, "/v1/characters/player-1/memory", bytes.NewReader([]byte(`{ + "kind": "preference", + "summary": "JOY prefers direct prototype progress overnight.", + "importance": 8 + }`))) + memoryRec := httptest.NewRecorder() + srv.Routes().ServeHTTP(memoryRec, memoryReq) + if memoryRec.Code != http.StatusCreated { + t.Fatalf("expected memory 201, got %d: %s", memoryRec.Code, memoryRec.Body.String()) + } + if !bytes.Contains(memoryRec.Body.Bytes(), []byte("direct prototype progress")) { + t.Fatalf("expected persisted memory in response, got %s", memoryRec.Body.String()) + } +} + +func TestAgentDecidePrototype(t *testing.T) { + srv := New(&config.Config{Env: "test"}) + + req := httptest.NewRequest(http.MethodPost, "/v1/agent/decide", bytes.NewReader([]byte(`{ + "world_snapshot": { + "zone_id": "hub", + "position": {"x": 0, "z": 0}, + "safe_radius": 5, + "body_time_seconds": 3600 + }, + "allowed": ["move", "say"] + }`))) + rec := httptest.NewRecorder() + srv.Routes().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected decision 200, got %d: %s", rec.Code, rec.Body.String()) + } + if !bytes.Contains(rec.Body.Bytes(), []byte(`"action":"move"`)) && + !bytes.Contains(rec.Body.Bytes(), []byte(`"action":"say"`)) { + t.Fatalf("expected move or say decision, got %s", rec.Body.String()) + } +} + +func TestNPCChatPrototype(t *testing.T) { + srv := New(&config.Config{Env: "test"}) + + req := httptest.NewRequest(http.MethodPost, "/v1/npc/chat", bytes.NewReader([]byte(`{ + "player_id": "player-1", + "npc_id": "npc-guide", + "message": "remember this" + }`))) + rec := httptest.NewRecorder() + srv.Routes().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected chat 200, got %d: %s", rec.Code, rec.Body.String()) + } + if !bytes.Contains(rec.Body.Bytes(), []byte("voice")) { + t.Fatalf("expected voice-ready chat response, got %s", rec.Body.String()) + } +} diff --git a/backend/nakama/.gitignore b/backend/nakama/.gitignore new file mode 100644 index 0000000..3e2e84b --- /dev/null +++ b/backend/nakama/.gitignore @@ -0,0 +1,2 @@ +build/ +node_modules/ diff --git a/backend/nakama/README.md b/backend/nakama/README.md new file mode 100644 index 0000000..d228607 --- /dev/null +++ b/backend/nakama/README.md @@ -0,0 +1,83 @@ +# SECOND SPAWN Nakama Backend + +Nakama OSS is the game backend. `api.dos.ai` / Go LLM Gateway is the shared AI +gateway only. Nakama owns game sessions and verifies external identity through +its runtime module. + +## Supabase Auth Bridge + +The client flow is: + +1. Unity signs the player into Supabase Auth. Anonymous sign-in is allowed for + the first prototype. +2. Unity receives a Supabase access token. +3. Unity calls Nakama custom authentication with that access token as the + temporary custom credential. +4. Nakama `beforeAuthenticateCustom` calls Supabase Auth directly: + `GET {SUPABASE_URL}/auth/v1/user`. +5. Supabase verifies the access token and returns the authenticated user. +6. Nakama rewrites the incoming custom auth request to a stable custom ID and issues + the Nakama session. + +Do not let Unity send a raw Supabase user ID directly to Nakama custom auth. +That would let a modified client spoof another account. + +## Runtime Environment + +Required Nakama runtime env: + +```text +SUPABASE_URL=https://your-project.supabase.co +SUPABASE_PUBLISHABLE_KEY=sb_publishable_... +``` + +Use `local.example.yml` as the public-safe local config template. Keep real +per-machine config outside git. Nakama expects `runtime.env` as key-value +entries such as: + +```yaml +runtime: + env: + - "SUPABASE_URL=https://your-project.supabase.co" + - "SUPABASE_PUBLISHABLE_KEY=sb_publishable_..." +``` + +If Supabase anonymous auth is not configured yet, the Unity prototype can fall +back to Nakama device auth so local Play Mode is not blocked. That fallback is +for local iteration only; production account binding must use Supabase custom +auth or a later approved identity ADR. + +No game auth secret belongs in `api.dos.ai`. The LLM gateway can receive +already-validated game context from Nakama or the Fusion server when an AI call +is needed. + +## Build and Test + +```bash +cd backend/nakama +npm install +npm run build +npm test +``` + +The build outputs the Nakama JavaScript runtime entrypoint to: + +```text +backend/nakama/build/index.js +``` + +Mount or copy the built JavaScript files into Nakama's runtime module path for +local development or deployment. The TypeScript source stays in +`backend/nakama/modules/`. + +## Runtime RPCs + +The current prototype module registers: + +- `secondspawn_health` - unauthenticated smoke check through `runtime.http_key` +- `secondspawn_profile_get` - get or create the authenticated player's profile, + current body, soul, policy, BodyTime, cultivation, and memory context +- `secondspawn_memory_add` - add or deduplicate compact memory records +- `secondspawn_soul_update` - update soul, characteristics, and agent policy +- `secondspawn_agent_decide` - deterministic safe fallback decision for local + agent control when the LLM gateway is unavailable diff --git a/backend/nakama/local.example.yml b/backend/nakama/local.example.yml new file mode 100644 index 0000000..0343b14 --- /dev/null +++ b/backend/nakama/local.example.yml @@ -0,0 +1,18 @@ +name: second-spawn + +logger: + level: INFO + +runtime: + path: /nakama/data/modules/build + http_key: defaulthttpkey + env: + - "SUPABASE_URL=https://YOUR_PROJECT.supabase.co" + - "SUPABASE_PUBLISHABLE_KEY=sb_publishable_YOUR_PUBLIC_KEY" + +socket: + server_key: defaultkey + +console: + username: admin + password: password diff --git a/backend/nakama/modules/index.ts b/backend/nakama/modules/index.ts new file mode 100644 index 0000000..76f7f42 --- /dev/null +++ b/backend/nakama/modules/index.ts @@ -0,0 +1,496 @@ +// SECOND SPAWN Nakama runtime entrypoint. +// +// Nakama is the game backend. This module owns game-backend extensions such as +// health checks, Supabase-backed custom authentication, player profile, soul, +// policy, and compact memory. AI/LLM provider calls stay in api.dos.ai. + +var collectionAgent = "secondspawn_agent"; +var keyAgentContext = "context"; + +var rpcIdHealth = "secondspawn_health"; +var rpcIdProfileGet = "secondspawn_profile_get"; +var rpcIdMemoryAdd = "secondspawn_memory_add"; +var rpcIdSoulUpdate = "secondspawn_soul_update"; +var rpcIdAgentDecide = "secondspawn_agent_decide"; + +let InitModule: nkruntime.InitModule = function ( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + initializer: nkruntime.Initializer +) { + initializer.registerRpc(rpcIdHealth, rpcHealth); + initializer.registerRpc(rpcIdProfileGet, rpcProfileGet); + initializer.registerRpc(rpcIdMemoryAdd, rpcMemoryAdd); + initializer.registerRpc(rpcIdSoulUpdate, rpcSoulUpdate); + initializer.registerRpc(rpcIdAgentDecide, rpcAgentDecide); + initializer.registerBeforeAuthenticateCustom(beforeAuthenticateCustom); + logger.info("Second Spawn Nakama runtime loaded."); +}; + +function rpcHealth( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + payload: string +): string { + return JSON.stringify({ + ok: true, + service: "second-spawn-nakama", + userId: ctx.userId || null + }); +} + +function rpcProfileGet( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + payload: string +): string { + var context = getOrCreateAgentContext(ctx, nk); + return JSON.stringify(context); +} + +function rpcMemoryAdd( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + payload: string +): string { + var context = getOrCreateAgentContext(ctx, nk); + var memory = parseJson(payload || "{}", "memory payload"); + memory.kind = normalizeMemoryKind(memory.kind); + memory.summary = trimString(memory.summary); + if (!memory.summary) { + throw new Error("memory summary is required"); + } + memory.importance = clampNumber(memory.importance || 5, 1, 10); + if (!memory.id) { + memory.id = "mem-" + nowId() + "-" + (context.body.memory.length + 1); + } + + upsertMemory(context, memory); + writeAgentContext(nk, context); + return JSON.stringify(context); +} + +function rpcSoulUpdate( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + payload: string +): string { + var context = getOrCreateAgentContext(ctx, nk); + var request = parseJson(payload || "{}", "soul payload"); + + context.body.soul = normalizeSoul(request.soul || {}, context.player.display_name); + context.body.characteristics = normalizeTraits(request.characteristics || {}); + context.body.agent_policy = normalizePolicy(request.agent_policy || {}); + + writeAgentContext(nk, context); + return JSON.stringify(context); +} + +function rpcAgentDecide( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + payload: string +): string { + var context = getOrCreateAgentContext(ctx, nk); + var request = parseJson(payload || "{}", "agent decision payload"); + var world = request.world_snapshot || {}; + var allowed = request.allowed || ["move", "interact", "say", "stop"]; + var bodyTime = Number(world.body_time_seconds || context.body.time.remaining_seconds || 0); + + if (bodyTime > 0 && bodyTime <= context.body.agent_policy.stop_when_body_time_below) { + return JSON.stringify({ + action: "stop", + reason: "body_time_below_policy_threshold", + confidence: 0.9 + }); + } + + if (arrayContains(allowed, "move")) { + var position = world.position || { x: 0, z: 0 }; + return JSON.stringify({ + action: "move", + move: { + x: Number(position.x || 0) + 1.5, + z: Number(position.z || 0) + 0.75 + }, + reason: "prototype_safe_patrol", + confidence: 0.55 + }); + } + + if (arrayContains(allowed, "say")) { + return JSON.stringify({ + action: "say", + say: "I am keeping this body safe until the player returns.", + reason: "prototype_social_fallback", + confidence: 0.6 + }); + } + + return JSON.stringify({ + action: "stop", + reason: "no_allowed_action", + confidence: 0.5 + }); +} + +function beforeAuthenticateCustom( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + data: nkruntime.AuthenticateCustomRequest +): nkruntime.AuthenticateCustomRequest | void | null { + var supabaseUrl = trimTrailingSlash(ctx.env["SUPABASE_URL"] || ""); + var publishableKey = ctx.env["SUPABASE_PUBLISHABLE_KEY"] || ctx.env["SUPABASE_ANON_KEY"]; + if (!supabaseUrl || !publishableKey) { + logger.error("missing SUPABASE_URL or SUPABASE_PUBLISHABLE_KEY"); + return null; + } + + if (!data.account) { + logger.error("missing custom auth account payload"); + return null; + } + + var supabaseAccessToken = data.account.id; + if (!supabaseAccessToken) { + logger.error("missing Supabase access token in custom auth request"); + return null; + } + + var response = nk.httpRequest( + supabaseUrl + "/auth/v1/user", + "get", + { + "apikey": publishableKey, + "authorization": "Bearer " + supabaseAccessToken + } + ); + + if (response.code < 200 || response.code > 299) { + logger.error("Supabase Auth rejected request: " + response.code); + return null; + } + + var body = JSON.parse(response.body); + if (!body.id) { + logger.error("Supabase Auth returned invalid user payload"); + return null; + } + + data.account.id = stableNakamaCustomId(body.id); + data.username = stableUsername(body); + return data; +} + +function getOrCreateAgentContext(ctx: nkruntime.Context, nk: nkruntime.Nakama): any { + var userId = requireUserId(ctx); + var existing = readAgentContext(nk, userId); + if (existing) { + return existing; + } + + var context = defaultAgentContext(userId); + writeAgentContext(nk, context); + return context; +} + +function readAgentContext(nk: nkruntime.Nakama, userId: string): any { + var objects = nk.storageRead([{ + collection: collectionAgent, + key: keyAgentContext, + userId: userId + }]); + + if (!objects || objects.length === 0) { + return null; + } + + return objects[0].value; +} + +function writeAgentContext(nk: nkruntime.Nakama, context: any): void { + nk.storageWrite([{ + collection: collectionAgent, + key: keyAgentContext, + userId: context.player.player_id, + value: context, + permissionRead: 1, + permissionWrite: 0 + }]); +} + +function defaultAgentContext(playerId: string): any { + var displayName = playerId || "Unknown Wanderer"; + var timestamp = new Date().toISOString(); + + return { + player: { + player_id: playerId, + display_name: displayName, + created_at: timestamp + }, + body: { + body_id: "body-" + playerId, + archetype_id: "prototype-hunter", + visual_prefab_key: "prototype-random", + equipment: normalizeEquipment({}), + stats: { + level: 1, + vitality: 10, + force: 8, + agility: 8, + focus: 8, + resilience: 8, + max_health: 100, + max_energy: 50, + attack_power: 10, + defense_power: 5 + }, + characteristics: normalizeTraits({}), + time: { + remaining_seconds: 86400, + max_seconds: 86400, + danger_drain_rate: 1 + }, + cultivation: { + tier: "Awakening", + progress_xp: 0 + }, + lifecycle: "alive", + agent_policy: normalizePolicy({}), + soul: normalizeSoul({}, displayName), + memory: [{ + id: "seed-origin", + kind: "system", + summary: "The character is a Second Spawn prototype body controlled by the player or their offline agent.", + importance: 6 + }], + created_at: timestamp + } + }; +} + +function upsertMemory(context: any, memory: any): void { + var memories = context.body.memory || []; + for (var i = 0; i < memories.length; i++) { + var existing = memories[i]; + if (existing.kind === memory.kind && lowercase(trimString(existing.summary)) === lowercase(memory.summary)) { + if (memory.importance > existing.importance) { + existing.importance = memory.importance; + } + context.body.memory = sortAndBoundMemories(memories); + return; + } + } + + memories.push(memory); + context.body.memory = sortAndBoundMemories(memories); +} + +function sortAndBoundMemories(memories: any[]): any[] { + memories.sort(function (a: any, b: any): number { + var importanceDelta = Number(b.importance || 0) - Number(a.importance || 0); + if (importanceDelta !== 0) { + return importanceDelta; + } + return String(b.id || "").localeCompare(String(a.id || "")); + }); + + if (memories.length > 64) { + return memories.slice(0, 64); + } + return memories; +} + +function normalizeSoul(soul: any, fallbackName: string): any { + return { + name: trimString(soul.name) || fallbackName, + core_drive: trimString(soul.core_drive) || "survive, learn the zone, and preserve agency for the player", + temperament: trimString(soul.temperament) || "careful but curious", + combat_style: trimString(soul.combat_style) || "avoid risky fights, kite when threatened", + social_style: trimString(soul.social_style) || "brief, grounded, and helpful", + moral_boundaries: normalizeStringArray(soul.moral_boundaries, [ + "do not betray allies", + "do not spend scarce resources without permission" + ]), + long_term_goals: normalizeStringArray(soul.long_term_goals, [ + "reach Enhancement", + "build trusted relationships with NPCs" + ]), + player_notes: trimString(soul.player_notes) || "prototype default soul", + reincarnation_lore: trimString(soul.reincarnation_lore) || "a synthetic body carrying a persistent consciousness imprint" + }; +} + +function normalizeTraits(traits: any): any { + return { + curiosity: clampNumber(traits.curiosity || 6, 1, 10), + courage: clampNumber(traits.courage || 5, 1, 10), + empathy: clampNumber(traits.empathy || 5, 1, 10), + discipline: clampNumber(traits.discipline || 5, 1, 10), + aggression: clampNumber(traits.aggression || 3, 1, 10), + sociability: clampNumber(traits.sociability || 5, 1, 10) + }; +} + +function normalizePolicy(policy: any): any { + return { + enabled: policy.enabled === false ? false : true, + mode: trimString(policy.mode) || "observe_and_keep_safe", + max_session_seconds: clampNumber(policy.max_session_seconds || 1800, 60, 86400), + allow_body_time_spend: policy.allow_body_time_spend === true, + allow_risky_combat: policy.allow_risky_combat === true, + preferred_activities: normalizeStringArray(policy.preferred_activities, ["explore", "talk", "safe_farming"]), + forbidden_activities: normalizeStringArray(policy.forbidden_activities, ["spend_body_time", "start_pvp", "trade_items"]), + stop_when_body_time_below: clampNumber(policy.stop_when_body_time_below || 900, 60, 86400) + }; +} + +function normalizeEquipment(equipment: any): any { + var equipmentVisualId = clampNumber(equipment.equipment_visual_id || 0, 0, 9); + return { + primary_weapon: trimString(equipment.primary_weapon) || primaryWeaponName(equipmentVisualId), + equipment_visual_id: equipmentVisualId + }; +} + +function primaryWeaponName(equipmentVisualId: number): string { + switch (equipmentVisualId) { + case 1: + return "unarmed"; + case 2: + return "one_hand_sword"; + case 3: + return "two_hand_sword"; + case 4: + return "two_hand_spear"; + case 5: + return "two_hand_axe"; + case 6: + return "two_hand_bow"; + case 7: + return "two_hand_crossbow"; + case 8: + return "staff"; + case 9: + return "hammer"; + default: + return "none"; + } +} + +function normalizeMemoryKind(kind: any): string { + var value = trimString(kind); + if (value === "preference" || value === "quest" || value === "relationship" || value === "combat" || value === "system") { + return value; + } + return "system"; +} + +function parseJson(payload: string, label: string): any { + try { + return JSON.parse(payload); + } catch (err) { + throw new Error("invalid " + label); + } +} + +function requireUserId(ctx: nkruntime.Context): string { + var userId = trimString(ctx.userId); + if (!userId) { + throw new Error("authenticated Nakama user is required"); + } + return userId; +} + +function stableNakamaCustomId(supabaseUserId: string): string { + return "supabase-" + sanitizeNakamaIdentifier(supabaseUserId, "user"); +} + +function stableUsername(user: any): string { + var email = typeof user.email === "string" ? user.email : ""; + var emailName = email.split("@")[0] || ""; + var fromEmail = sanitizeNakamaIdentifier(emailName, ""); + if (fromEmail.length >= 6) { + return fromEmail; + } + + var id = sanitizeNakamaIdentifier(user.id || "", "player"); + return "player-" + id.substring(0, 12); +} + +function sanitizeNakamaIdentifier(value: string, fallback: string): string { + var cleaned = lowercase(value) + .replace(/[^a-z0-9-]/g, "") + .replace(/^-+|-+$/g, ""); + return cleaned || fallback; +} + +function normalizeStringArray(values: any, fallback: string[]): string[] { + if (!values || typeof values.length !== "number") { + return fallback; + } + + var result: string[] = []; + for (var i = 0; i < values.length; i++) { + var value = trimString(values[i]); + if (value) { + result.push(value); + } + } + + return result.length > 0 ? result : fallback; +} + +function arrayContains(values: any[], target: string): boolean { + if (!values) { + return false; + } + + for (var i = 0; i < values.length; i++) { + if (values[i] === target) { + return true; + } + } + return false; +} + +function clampNumber(value: any, min: number, max: number): number { + var numberValue = Number(value); + if (isNaN(numberValue)) { + numberValue = min; + } + if (numberValue < min) { + return min; + } + if (numberValue > max) { + return max; + } + return numberValue; +} + +function trimString(value: any): string { + if (value === null || value === undefined) { + return ""; + } + return String(value).replace(/^\s+|\s+$/g, ""); +} + +function lowercase(value: any): string { + return trimString(value).toLowerCase(); +} + +function trimTrailingSlash(value: string): string { + return value.replace(/\/+$/g, ""); +} + +function nowId(): string { + return String(new Date().getTime()); +} diff --git a/backend/nakama/package-lock.json b/backend/nakama/package-lock.json new file mode 100644 index 0000000..ad9aa86 --- /dev/null +++ b/backend/nakama/package-lock.json @@ -0,0 +1,29 @@ +{ + "name": "@second-spawn/nakama-runtime", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@second-spawn/nakama-runtime", + "version": "0.1.0", + "devDependencies": { + "typescript": "6.0.3" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/backend/nakama/package.json b/backend/nakama/package.json new file mode 100644 index 0000000..3e9b869 --- /dev/null +++ b/backend/nakama/package.json @@ -0,0 +1,13 @@ +{ + "name": "@second-spawn/nakama-runtime", + "private": true, + "version": "0.1.0", + "description": "SECOND SPAWN Nakama OSS runtime modules.", + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "node tests/supabase_custom_auth.test.mjs" + }, + "devDependencies": { + "typescript": "6.0.3" + } +} diff --git a/backend/nakama/tests/supabase_custom_auth.test.mjs b/backend/nakama/tests/supabase_custom_auth.test.mjs new file mode 100644 index 0000000..34305fd --- /dev/null +++ b/backend/nakama/tests/supabase_custom_auth.test.mjs @@ -0,0 +1,210 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import vm from "node:vm"; + +const runtime = fs.readFileSync(new URL("../build/index.js", import.meta.url), "utf8"); + +function loadRuntime() { + const context = {}; + vm.createContext(context); + return vm.runInContext( + `${runtime} +({ + InitModule, + beforeAuthenticateCustom, + stableNakamaCustomId, + stableUsername, + sanitizeNakamaIdentifier, + trimTrailingSlash +});`, + context + ); +} + +function createRuntimeHarness(module) { + const registeredHooks = []; + const registeredRpcs = new Map(); + const storage = new Map(); + const logger = { + debug: () => {}, + error: (message) => { + throw new Error(message); + }, + info: () => {}, + }; + const nk = { + storageRead: (requests) => requests + .map((request) => storage.get(storageKey(request.userId, request.collection, request.key))) + .filter(Boolean), + storageWrite: (requests) => { + for (const request of requests) { + storage.set(storageKey(request.userId, request.collection, request.key), { + ...request, + version: "test-version", + }); + } + }, + }; + + module.InitModule( + { env: {} }, + logger, + nk, + { + registerRpc: (name, rpc) => registeredRpcs.set(name, rpc), + registerBeforeAuthenticateCustom: (hook) => registeredHooks.push(hook), + } + ); + + return { registeredHooks, registeredRpcs, storage, logger, nk }; +} + +function storageKey(userId, collection, key) { + return `${userId}:${collection}:${key}`; +} + +const module = loadRuntime(); + +assert.equal( + module.stableNakamaCustomId("308ebb59-47b7-46fe-835c-5375cd41037d"), + "supabase-308ebb59-47b7-46fe-835c-5375cd41037d" +); + +assert.equal( + module.stableUsername({ id: "308ebb59-47b7-46fe-835c-5375cd41037d", email: "Founder+Test@example.com" }), + "foundertest" +); + +assert.equal( + module.stableUsername({ id: "308ebb59-47b7-46fe-835c-5375cd41037d" }), + "player-308ebb59-47b" +); + +const harness = createRuntimeHarness(module); +assert.equal(harness.registeredHooks.length, 1); +assert.equal(harness.registeredRpcs.size, 5); +assert.ok(harness.registeredRpcs.has("secondspawn_health")); +assert.ok(harness.registeredRpcs.has("secondspawn_profile_get")); +assert.ok(harness.registeredRpcs.has("secondspawn_memory_add")); +assert.ok(harness.registeredRpcs.has("secondspawn_soul_update")); +assert.ok(harness.registeredRpcs.has("secondspawn_agent_decide")); + +const healthPayload = harness.registeredRpcs.get("secondspawn_health")({ userId: "user-1", env: {} }, harness.logger, harness.nk, ""); +assert.equal(JSON.parse(healthPayload).service, "second-spawn-nakama"); + +const profile = JSON.parse(harness.registeredRpcs.get("secondspawn_profile_get")( + { userId: "user-1", env: {} }, + harness.logger, + harness.nk, + "" +)); +assert.equal(profile.player.player_id, "user-1"); +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); + +const updatedMemory = JSON.parse(harness.registeredRpcs.get("secondspawn_memory_add")( + { userId: "user-1", env: {} }, + harness.logger, + harness.nk, + JSON.stringify({ kind: "preference", summary: "Prefers safe farming overnight.", importance: 9 }) +)); +assert.equal(updatedMemory.body.memory[0].summary, "Prefers safe farming overnight."); +assert.equal(updatedMemory.body.memory[0].importance, 9); + +const dedupedMemory = JSON.parse(harness.registeredRpcs.get("secondspawn_memory_add")( + { userId: "user-1", env: {} }, + harness.logger, + harness.nk, + JSON.stringify({ kind: "preference", summary: "prefers safe farming overnight.", importance: 3 }) +)); +assert.equal(dedupedMemory.body.memory.length, 2); +assert.equal(dedupedMemory.body.memory[0].importance, 9); + +const updatedSoul = JSON.parse(harness.registeredRpcs.get("secondspawn_soul_update")( + { userId: "user-1", env: {} }, + harness.logger, + harness.nk, + JSON.stringify({ + soul: { name: "JOY Agent", core_drive: "protect body time" }, + characteristics: { curiosity: 99, aggression: -1 }, + agent_policy: { enabled: true, mode: "safe_patrol", stop_when_body_time_below: 600 } + }) +)); +assert.equal(updatedSoul.body.soul.name, "JOY Agent"); +assert.equal(updatedSoul.body.characteristics.curiosity, 10); +assert.equal(updatedSoul.body.characteristics.aggression, 1); +assert.equal(updatedSoul.body.agent_policy.mode, "safe_patrol"); + +const decision = JSON.parse(harness.registeredRpcs.get("secondspawn_agent_decide")( + { userId: "user-1", env: {} }, + harness.logger, + harness.nk, + JSON.stringify({ + world_snapshot: { position: { x: 2, z: 3 }, body_time_seconds: 3600 }, + allowed: ["move", "say", "stop"] + }) +)); +assert.equal(decision.action, "move"); +assert.equal(decision.move.x, 3.5); + +const lowTimeDecision = JSON.parse(harness.registeredRpcs.get("secondspawn_agent_decide")( + { userId: "user-1", env: {} }, + harness.logger, + harness.nk, + JSON.stringify({ + world_snapshot: { position: { x: 2, z: 3 }, body_time_seconds: 30 }, + allowed: ["move", "say", "stop"] + }) +)); +assert.equal(lowTimeDecision.action, "stop"); + +const calls = []; +const response = harness.registeredHooks[0]( + { + env: { + SUPABASE_URL: "https://project.supabase.co/", + SUPABASE_PUBLISHABLE_KEY: "sb_publishable_test", + }, + }, + { error: (message) => calls.push(["error", message]), info: () => {}, debug: () => {} }, + { + httpRequest: (url, method, headers) => { + calls.push(["http", url, method, headers]); + return { + code: 200, + body: JSON.stringify({ + id: "308ebb59-47b7-46fe-835c-5375cd41037d", + email: "joy@example.com", + }), + }; + }, + }, + { account: { id: "supabase-access-token" } } +); + +assert.equal(response.account.id, "supabase-308ebb59-47b7-46fe-835c-5375cd41037d"); +assert.equal(response.username, "player-308ebb59-47b"); +assert.equal(calls[0][0], "http"); +assert.equal(calls[0][1], "https://project.supabase.co/auth/v1/user"); +assert.equal(calls[0][2], "get"); +assert.equal(calls[0][3].apikey, "sb_publishable_test"); +assert.equal(calls[0][3].authorization, "Bearer supabase-access-token"); + +const rejected = harness.registeredHooks[0]( + { + env: { + SUPABASE_URL: "https://project.supabase.co", + SUPABASE_PUBLISHABLE_KEY: "sb_publishable_test", + }, + }, + { error: () => {}, info: () => {}, debug: () => {} }, + { + httpRequest: () => ({ code: 401, body: "invalid token" }), + }, + { account: { id: "bad-token" } } +); +assert.equal(rejected, null); + +console.log("supabase_custom_auth tests passed"); diff --git a/backend/nakama/tsconfig.json b/backend/nakama/tsconfig.json new file mode 100644 index 0000000..8cf6a87 --- /dev/null +++ b/backend/nakama/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["ES5"], + "strict": true, + "noImplicitAny": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "removeComments": false, + "rootDir": ".", + "outFile": "./build/index.js", + "ignoreDeprecations": "6.0" + }, + "files": [ + "types/nakama-runtime.d.ts", + "modules/index.ts" + ] +} diff --git a/backend/nakama/types/nakama-runtime.d.ts b/backend/nakama/types/nakama-runtime.d.ts new file mode 100644 index 0000000..ee9881c --- /dev/null +++ b/backend/nakama/types/nakama-runtime.d.ts @@ -0,0 +1,90 @@ +declare namespace nkruntime { + interface Context { + env: { [key: string]: string | undefined }; + userId?: string; + } + + interface Logger { + debug(message: string): void; + error(message: string): void; + info(message: string): void; + } + + interface Nakama { + httpRequest( + url: string, + method: string, + headers: { [key: string]: string }, + body?: string + ): HttpResponse; + storageRead(requests: StorageReadRequest[]): StorageObject[]; + storageWrite(requests: StorageWriteRequest[]): void; + } + + interface HttpResponse { + code: number; + body: string; + } + + interface Initializer { + registerRpc(name: string, fn: RpcFunction): void; + + registerBeforeAuthenticateCustom( + fn: BeforeHookFunction + ): void; + } + + type RpcFunction = ( + ctx: Context, + logger: Logger, + nk: Nakama, + payload: string + ) => string; + + type InitModule = ( + ctx: Context, + logger: Logger, + nk: Nakama, + initializer: Initializer + ) => void; + + type BeforeHookFunction = ( + ctx: Context, + logger: Logger, + nk: Nakama, + data: T + ) => T | void | null; + + interface AuthenticateCustomRequest { + account?: { + id: string; + }; + username?: string; + } + + interface StorageReadRequest { + collection: string; + key: string; + userId: string; + } + + interface StorageWriteRequest { + collection: string; + key: string; + userId: string; + value: any; + version?: string; + permissionRead: number; + permissionWrite: number; + } + + interface StorageObject { + collection: string; + key: string; + userId: string; + value: any; + version: string; + permissionRead: number; + permissionWrite: number; + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bb35889..fe8ff69 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -5,14 +5,14 @@ High-level architecture overview. For detailed component design see `docs/design ## System Diagram (logical) ``` -+-------------------+ +------------------------+ -| Unity Client | | AI Agent (offline) | -| (player online) | | controls character | -| | | when player away | -+---------+---------+ +-----------+------------+ - | | - | Photon Fusion 2 (tick 30Hz) | - v v ++-------------------+ +------------------------+ +----------------------+ +| Unity Client | | AI Agent (offline) | | OpenClaw Agent NPC | +| (player online) | | controls character | | user-owned agent | +| | | when player away | | as world actor | ++---------+---------+ +-----------+------------+ +----------+-----------+ + | | | + | Photon Fusion 2 (tick 30Hz) / validated intents | + v v v +-------------------------------------------------+ | Dedicated Game Server (headless) | | - Authoritative game state | @@ -24,19 +24,19 @@ High-level architecture overview. For detailed component design see `docs/design | | LLM intent request v v +---------------+ +----------------------------+ - | Supabase | | Go LLM Gateway | - | - Auth | | - Convai (phase 1) | - | - Postgres | | - Anthropic + OpenAI (P2) | - | - Realtime | | - RAG memory (pgvector) | - | - Storage | | - Rate limit + safety | + | Nakama OSS | | api.dos.ai / Go LLM Gateway| + | - Game APIs | | - Convai (phase 1) | + | - Social | | - Anthropic + OpenAI (P2) | + | - Storage | | - RAG memory retrieval | + | - Postgres | | - AI rate limit + safety | +---------------+ +-------------+--------------+ | | v v +---------------+ +----------------------------+ - | DOS Chain | | Redis | - | (NFT, wallet) | | - Session | - | via thirdweb | | - Rate limit | - | | | - Transient cache | + | DOS Chain | | Supabase Sidecar / Redis | + | (NFT, wallet) | | - Identity bridge | + | via thirdweb | | - External profile data | + | | | - Rate limit / cache | +---------------+ +----------------------------+ ``` @@ -45,7 +45,7 @@ High-level architecture overview. For detailed component design see `docs/design ### Unity Client - Render, input, local prediction -- Communicates only with Fusion server and Supabase Auth +- Communicates only with Fusion server and approved auth/backend endpoints - NEVER calls LLM API directly - NEVER holds API keys - Receives state updates via Fusion tick @@ -55,35 +55,59 @@ High-level architecture overview. For detailed component design see `docs/design - Source of truth for in-zone state (position, HP, combat, drops) - Source of truth for `BodyTime` earn, spend, drain, transfer, and expiration - Validates every action intent (from player input or AI agent) -- Persists durable state to Supabase Postgres (snapshots + events) -- Triggers LLM gateway for NPC dialogue when triggered +- Persists durable state through Nakama/Postgres (snapshots + events) +- Triggers `api.dos.ai` / Go LLM Gateway for NPC dialogue when triggered - Manages zone lifecycle (load / unload / spawn) - Tick rate: 30Hz (60Hz for boss encounters if needed) ### AI Agent (offline player simulation) -- Runs as separate process (could be Go service co-located with gateway) +- Runs as a game-server worker, Nakama runtime task, or separate worker if needed - Subscribes to Fusion server state for offline characters - Decision loop: read state -> reason via LLM -> emit action intent - Subject to same server validation as a real player - Inherits player's character cultivation tier + persona + history -### Supabase Backend +### OpenClaw-Connected NPC -- **Auth:** Reuse DOS.Me pattern (email / wallet / OAuth) -- **Postgres:** durable state (profile, inventory, quest progress, NFT lock state, cultivation tier, character history, reincarnation and time-as-currency events) -- **Realtime:** chat global, presence, friend list, party invite, notification (NOT combat / movement) -- **Storage:** avatar, screenshot, UGC +- User-owned OpenClaw agent connected into SECOND SPAWN as an NPC-like world actor +- May appear as a companion, hub NPC, merchant-like persona, quest-adjacent character, or social world citizen +- Bound to Nakama identity, consent scope, moderation state, rate limits, and activity logs +- Uses `api.dos.ai` / Go LLM Gateway for prompt safety, provider routing, and memory context +- Emits dialogue or structured intent only +- Never mutates gameplay state directly; Fusion server validates every in-world action -### Go LLM Gateway (DOSRouter pattern) +### Nakama OSS Game Backend + +- Game backend APIs for profile, inventory, quest progress, activity logs, and social features +- Postgres-backed durable storage +- Candidate home for groups, leaderboards, matchmaking, and party flows +- Extensible through Nakama server runtime modules for auth hooks, RPCs, + inventory, profile, stats, social, matchmaking, leaderboards, activity logs, + and moderation +- May bridge to Supabase or DOS.Me identity patterns where useful +- Does not replace Photon Fusion 2 server authority for movement, combat, or physics +- Default home for game backend custom logic. Do not add a separate game API + gateway unless a Nakama module is the wrong tool for the feature. + +### Supabase Sidecar + +- Optional identity bridge, wallet/profile integration, storage, analytics, or external product data +- Can be used where it clearly reduces integration work +- Not the primary game backend baseline after ADR 0010 +- Never used for combat / movement sync + +### `api.dos.ai` / Go LLM Gateway - All LLM calls go through here - Multi-provider routing (Convai phase 1, Anthropic + OpenAI phase 2) -- RAG memory retrieval (Supabase pgvector) -- Rate limit per player + per NPC +- RAG memory retrieval (Nakama/Postgres, Supabase pgvector, or Qdrant depending on later implementation) +- AI token and request rate limits - Prompt injection defense - Returns **structured intent**, never raw text trusted by server - Server validates intent before applying +- Not the game backend. Do not add inventory, profile, matchmaking, guild, + leaderboard, or gameplay mutation APIs here. ### DOS Chain (via thirdweb) @@ -103,7 +127,7 @@ High-level architecture overview. For detailed component design see `docs/design 1. **Server is the only authority.** Client + AI agent emit intents; server applies or rejects. 2. **LLM never mutates state directly.** LLM emits structured intent -> server validates -> applies. -3. **API keys live only in Go gateway env.** Never in Unity client, never in Supabase Edge Function reaching the client. +3. **LLM API keys live only in `api.dos.ai` / Go LLM Gateway env.** Never in Unity client, never in Supabase Edge Function reaching the client. 4. **NFT lock is on-chain.** When equipped, escrow contract holds. Server reads on-chain state, does not assume off-chain. 5. **AI agent inherits player limits.** No agent can do what a real player cannot. 6. **Time mutations are server-authoritative.** `BodyTime` is gameplay state; client, LLM, and AI agents can only request validated time intents. diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index eff2242..cc1976f 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -17,6 +17,8 @@ - [Networked Player Controller Prototype](design/07-player-controller-prototype.md) - [Time-as-Currency](design/08-time-as-currency.md) - [Pirate Adventure Reference Review](design/09-pirate-adventure-reference-review.md) +- [Character Profile, Soul, and Agent Memory](design/10-character-profile-agent-memory.md) +- [NPC Agent Brain Architecture](design/11-npc-agent-brain-architecture.md) ## Architecture Decision Records @@ -29,10 +31,12 @@ - [ADR 0007: Fusion 2.0.12 Unity 6.5 Beta Incompatibility](adr/0007-photon-fusion-2-0-12-unity-6-5-beta-incompat.md) - [ADR 0008: Codex Primary Agent Workflow](adr/0008-codex-primary-agent-workflow.md) - [ADR 0009: Fusion Networking Assembly Unsafe Code](adr/0009-fusion-networking-assembly-unsafe-code.md) +- [ADR 0010: Nakama OSS Game Backend](adr/0010-nakama-oss-game-backend.md) ## Setup - [Agent Handoff](setup/agent-handoff.md) - [Fusion Install](setup/fusion-install.md) +- [Game Gateway Cloud Run Deployment](setup/game-gateway-cloud-run.md) - [Paid Asset Setup](setup/paid-assets.md) - [Unity Conventions](setup/unity-conventions.md) diff --git a/docs/adr/0002-supabase-backend.md b/docs/adr/0002-supabase-backend.md index 0db76d8..abae217 100644 --- a/docs/adr/0002-supabase-backend.md +++ b/docs/adr/0002-supabase-backend.md @@ -1,6 +1,6 @@ # ADR 0002: Supabase as backend (auth, persistence, realtime side-channel) -**Status:** Accepted +**Status:** Superseded by [ADR 0010: Nakama OSS Game Backend](0010-nakama-oss-game-backend.md) **Date:** 2026-05-13 **Decision maker:** JOY diff --git a/docs/adr/0010-nakama-oss-game-backend.md b/docs/adr/0010-nakama-oss-game-backend.md new file mode 100644 index 0000000..44ef4f2 --- /dev/null +++ b/docs/adr/0010-nakama-oss-game-backend.md @@ -0,0 +1,130 @@ +# ADR 0010: Start with Nakama OSS as the game backend + +**Status:** Accepted +**Date:** 2026-05-15 +**Decision maker:** JOY + +## Context + +SECOND SPAWN needs more than generic persistence. The game needs account-linked +profiles, social features, durable character state, inventory, progression, +activity logs, and eventually guilds, parties, matchmaking, leaderboards, and +LiveOps hooks. + +Earlier ADR 0002 selected Supabase as the first backend baseline because it was +already familiar from DOS.Me and minimized the stack. Further research compared +Nakama OSS, Heroic Cloud, Satori, PlayFab, and AccelByte. JOY also tested local +Nakama admin access and confirmed the OSS route is feasible for development. + +## Decision + +Adopt **Nakama OSS** as the game backend foundation for SECOND SPAWN. + +Use: + +- **Nakama OSS** for game backend APIs, game accounts/session bridge, social + primitives, storage objects, leaderboards, groups/guild candidates, and + server-side game backend modules. +- **Photon Fusion 2 dedicated server** for authoritative in-zone movement, + combat, physics, and tick simulation. Nakama does not replace Fusion. +- **`api.dos.ai` / Go LLM Gateway** for AI and LLM work only: model calls, + prompt safety, provider routing, token budgets, voice token minting, and LLM + decision filtering. Do not put game profile, inventory, matchmaking, guild, + wallet mutation, or gameplay APIs in the LLM gateway. +- **Supabase** as a compatible sidecar where it still earns its place: + DOS.Me-style identity bridge, wallet/profile integration, storage, analytics, + or external product data. Do not assume Supabase Realtime is combat sync. +- **Postgres** as the durable database under Nakama. Development may use a local + Postgres container or an approved Supabase Postgres project if connection + behavior and isolation are verified. + +## Rationale + +- Nakama OSS is purpose-built for online game backend work while staying + self-hostable and source-available for agent inspection. +- It avoids the early managed-cost floor of Heroic Cloud, where Nakama starts + around USD 1,200/month and Satori starts around USD 600/month. +- It avoids PlayFab's usage-billing uncertainty for write-heavy game state and + avoids deeper Azure lock-in for custom server logic. +- It avoids AccelByte's higher managed-platform cost and enterprise-oriented + sales motion during the solo-founder prototype phase. +- It gives AI coding agents a concrete local backend to inspect, test, and + extend through repo-owned modules and Docker-based development. +- It fits the open-source project posture better than a closed managed backend + being the only source of truth. + +## Consequences + +- ADR 0002 is superseded for the default game backend decision. Supabase remains + available as a sidecar, not the primary game backend baseline. +- Backend code must distinguish **game backend** from **LLM gateway**. Nakama is + the game backend. `api.dos.ai` / Go LLM Gateway is the shared DOS.AI AI/LLM + gateway. +- Supabase Auth remains the external identity source for the first prototype. + Nakama receives accounts only through its `beforeAuthenticateCustom` runtime + hook, which verifies the Supabase access token directly with Supabase Auth. It + must not trust a raw Supabase user ID from the Unity client. +- Fusion remains authoritative for real-time gameplay. Nakama stores durable + state and serves backend APIs, but it does not directly trust client actions. +- LLM output still never mutates state directly. `api.dos.ai` and game-server + validation remain mandatory before Nakama storage is updated. +- Heroic Cloud, Hiro, Satori, PlayFab, AccelByte, OpenAuth, or any other + replacement stack still require a new ADR and JOY approval. +- If Nakama OSS operations become too heavy, the upgrade path is Heroic Cloud or + another managed platform, not rewriting gameplay code blindly. + +## Implementation Notes + +- Keep game backend custom logic in Nakama runtime modules by default. A + separate custom game backend requires a new ADR. +- Treat `backend/gateway/` as a prototype LLM contract only while `api.dos.ai` + integration is not wired. It is not the game backend. +- Keep Nakama runtime modules under `backend/nakama/`. +- Use Nakama `beforeAuthenticateCustom` to validate Supabase access tokens + directly against Supabase Auth, then rewrite the incoming custom auth request + to a stable Nakama custom ID. +- Store secrets only in local `.env` or deployment secret managers. +- Keep local Docker config public-safe with placeholders only. +- Keep module code small and testable because JOY is a non-coder and agents + must be able to review it. + +## Alternatives Considered + +### Supabase-first thin backend + +Simple and familiar, but it pushes too much game-specific backend work onto +custom services once guilds, parties, matchmaking, and agent activity logs grow. + +### PlayFab + +Strong managed game backend and generous early tier, but custom logic usually +routes through CloudScript or Azure Functions. Usage billing can become hard to +predict for write-heavy MMO-style data. + +### Heroic Cloud + +Best operational path for managed Nakama, but the minimum monthly cost is too +high for the current prototype stage. + +### AccelByte + +Enterprise-grade and strong for large studios, but too expensive and too heavy +for the current solo-founder stage. + +## Revisit Criteria + +Revisit this decision if: + +- Nakama OSS slows prototype delivery more than it accelerates backend work. +- Operations become the main blocker before product-market validation. +- Managed pricing becomes acceptable because the game has revenue or funding. +- Supabase-only implementation proves enough for the actual vertical slice. +- A future backend provider offers materially better AI-agent development + ergonomics, open-source compatibility, and predictable cost. + +## Cross-References + +- [ADR 0002: Supabase as backend](0002-supabase-backend.md) +- [ADR 0003: LLM safety architecture](0003-llm-safety-architecture.md) +- [ADR 0004: AI Agent control of player character when offline](0004-ai-agent-offline-control.md) +- [Character Profile, Soul, and Agent Memory](../design/10-character-profile-agent-memory.md) diff --git a/docs/design/00-game-concept.md b/docs/design/00-game-concept.md index 851bc37..f8e0ef8 100644 --- a/docs/design/00-game-concept.md +++ b/docs/design/00-game-concept.md @@ -43,6 +43,8 @@ The hook passes the "and also" test on three axes simultaneously: 2. Reincarnation as the death loop (instead of corpse run / repair cost / equipment loss) 3. Time-as-currency (every body has a time budget that can be earned, spent, and lost) +Ecosystem extension: a user's OpenClaw agent can also connect into SECOND SPAWN as an NPC-like world actor. This turns OpenClaw agents into social citizens of the game world, not just external assistants. The connected agent may speak, remember, assist, trade socially, or participate in quest-adjacent moments, but any gameplay-affecting action remains server-validated intent. + --- ## Player Experience Analysis (MDA) @@ -53,14 +55,14 @@ The hook passes the "and also" test on three axes simultaneously: | ---- | ---- | ---- | | 1 | **Challenge** | Cultivation tier-up gates, dungeon bosses with LLM-driven dialogue + adaptive behavior, permanent body death | | 2 | **Discovery** | Layered MetaDOS lore (Nibirium, consciousness transfer, faction history); LLM NPCs reveal world state through dialogue; emergent stories from agent behaviors | -| 3 | **Fellowship** | 4-20 player zones, guild PvP 50v50, party invites via Supabase Realtime, agent-to-agent socialization across timezones | +| 3 | **Fellowship** | 4-20 player zones, guild PvP 50v50, party invites via Nakama channels, agent-to-agent socialization across timezones | | N/A | Sensation | Stylized low-poly art (Synty / Quaternius); not a sensory-pleasure-first game | | N/A | Submission | Active play is intentionally engaging; relaxed offline progress is delegated to the AI agent rather than the player | ### Core Mechanics (3-5 systems generating the dynamics) 1. Top-down ARPG action combat (minimal Fusion controller first; Opsive Ultimate Character Controller is an evaluation candidate) -2. LLM-driven NPC dialogue with server-validated intent (Convai phase 1, custom Go gateway phase 2) +2. LLM-driven NPC dialogue with server-validated intent (Convai phase 1, `api.dos.ai` / Go LLM Gateway phase 2) 3. AI agent autonomous control of player character when offline (server-authoritative, capability-capped) 4. Reincarnation via SECOND token cost (consciousness transfer, partial cultivation tier carryover) 5. Time-as-currency body lifespan economy (earn/spend body time; zero time triggers body death) @@ -114,7 +116,7 @@ Complete a quest line or dungeon clear; converse with hub-town NPCs (LLM-driven) 1. **AI agent 24/7** - the character is always playing 2. **Reincarnation, not respawn** - death has weight; SECOND token cost 3. **Time is life, time is money** - time is the body's survival budget and a spendable resource -4. **LLM as world citizen, not chatbot** - NPCs are server-validated agents in the world +4. **LLM as world citizen, not chatbot** - NPCs and connected OpenClaw agents are server-validated actors in the world 5. **Server-authoritative gameplay** - public open-source repo means anti-cheat assumes attacker has full source --- @@ -154,8 +156,8 @@ Complete a quest line or dungeon clear; converse with hub-town NPCs (LLM-driven) | ---- | ---- | | **Engine** | Unity 6.5 beta (currently `6000.5.0b7`) + URP. JOY chose beta for newest features. | | **Networking** | Photon Fusion 2 (Server Mode dedicated for production; Host Mode + Photon Cloud free 20 CCU for dev) | -| **Persistence** | Supabase Postgres (profile, inventory, quest, NFT lock state, cultivation tier) | -| **LLM** | Convai phase 1 (NPC dialogue) -> Go LLM gateway phase 2 (Haiku 4.5 for NPC chat, Sonnet 4.6 for boss / cultivation master). Server-side intent validation only. | +| **Persistence** | Nakama OSS + Postgres (profile, inventory, quest, NFT lock state, cultivation tier) | +| **LLM** | Convai phase 1 (NPC dialogue) -> `api.dos.ai` / Go LLM Gateway phase 2 (Haiku 4.5 for NPC chat, Sonnet 4.6 for boss / cultivation master). Server-side intent validation only. | | **NFT** | DOS Chain via thirdweb-api MCP. Wallet auth, escrow contracts, Hunter skin / weapon / pet inventory. | | **Art** | Synty / Quaternius stylized low-poly + reused MetaDOS Hunter skins | | **Key technical risks** | LLM intent validation at scale; AI agent server tick load; NFT-Unity inventory sync latency | diff --git a/docs/design/01-pillars.md b/docs/design/01-pillars.md index f0941c4..a0d992d 100644 --- a/docs/design/01-pillars.md +++ b/docs/design/01-pillars.md @@ -118,20 +118,20 @@ You are a Hunter in a 2050 post-apocalyptic world where consciousness can be tra **Target Aesthetics Served**: Fellowship (NPCs feel social), Discovery (NPCs reveal lore conditionally), Narrative (NPCs participate in story arcs) -**Design Test**: If we are debating any LLM feature, this pillar says: the LLM must be (a) grounded in retrievable world state, (b) constrained by per-NPC memory budget, (c) routed through Go gateway with server-side intent validation, and (d) rate-limited per player. +**Design Test**: If we are debating any LLM feature, this pillar says: the LLM must be (a) grounded in retrievable world state, (b) constrained by per-NPC memory budget, (c) routed through `api.dos.ai` / Go LLM Gateway with server-side intent validation, and (d) rate-limited per player. #### What This Means for Each Department | Department | This Pillar Says... | Example | | ---- | ---- | ---- | | **Game Design** | NPC interactions are gameplay-affecting (quest, faction reputation) not flavor-only | Boss NPC dialogue can affect fight phase via in-world state, not LLM directly setting HP. | -| **Engineering** | All LLM calls go through Go gateway. Never API key in Unity client. | Server validates "NPC says 'I will give you sword'" -> intent: grant_item -> server checks quest state -> applies. | +| **Engineering** | All LLM calls go through `api.dos.ai` / Go LLM Gateway. Never API key in Unity client. | Server validates "NPC says 'I will give you sword'" -> intent: grant_item -> server checks quest state -> applies. | | **Narrative** | Per-NPC memory budget cap forces concise, world-relevant memory | NPC remembers last 10 player interactions + permanent flags (faction standing, quest done). | | **Security** | Prompt injection defense, capability cap, per-player rate limit | Reuse DOSafe prompt-injection patterns. | #### Serving This Pillar - Convai phase 1 NPC dialogue grounded in player state -- Phase 2 Go gateway with Haiku 4.5 (NPC chat) + Sonnet 4.6 (boss / cultivation master) +- Phase 2 `api.dos.ai` / Go LLM Gateway with Haiku 4.5 (NPC chat) + Sonnet 4.6 (boss / cultivation master) - Per-NPC memory in Supabase pgvector - LLM intent validation server-side, never trust raw output @@ -162,7 +162,7 @@ You are a Hunter in a 2050 post-apocalyptic world where consciousness can be tra #### Serving This Pillar - Photon Fusion 2 Server Mode dedicated headless Unity build -- All LLM calls server-side via Go gateway +- All LLM calls server-side via `api.dos.ai` / Go LLM Gateway - Critical invariant: ALL gameplay logic must be server-authoritative #### Violating This Pillar diff --git a/docs/design/02-vertical-slice-spec.md b/docs/design/02-vertical-slice-spec.md index b369faa..160b8ba 100644 --- a/docs/design/02-vertical-slice-spec.md +++ b/docs/design/02-vertical-slice-spec.md @@ -10,7 +10,7 @@ ## Validation Question -Can a solo player, in their first 30 minutes of unguided play in a single zone, experience the signature hooks (AI agent autoplay, reincarnation, time-as-currency, cultivation tier-up) AND can a 1-person team (JOY + AI agents) build this slice at representative quality in 3-6 months on the chosen tech stack (Unity 6.5 beta + Photon Fusion 2 + Supabase + Go gateway + thirdweb)? +Can a solo player, in their first 30 minutes of unguided play in a single zone, experience the signature hooks (AI agent autoplay, reincarnation, time-as-currency, cultivation tier-up) AND can a 1-person team (JOY + AI agents) build this slice at representative quality in 3-6 months on the chosen tech stack (Unity 6.5 beta + Photon Fusion 2 + Nakama OSS + api.dos.ai / Go LLM Gateway + thirdweb)? This is two questions in one: **does the design loop fun?** AND **is the architecture buildable?** @@ -31,7 +31,7 @@ This is two questions in one: **does the design loop fun?** AND **is the archite | **Cultivation tiers** | 2 of 6 playable (Awakening + Enhancement only) | | **NFT Hunter skin** | 1 skin equip flow + escrow contract (test net DOS Chain) | | **Multiplayer** | 4-20 players per zone instance via Photon Fusion 2 | -| **Chat** | Basic global + zone via Supabase Realtime | +| **Chat** | Basic global + zone via Nakama channels | | **Voice NPC** | NOT in slice (defer phase 2) | --- @@ -65,11 +65,11 @@ The slice is considered "done" when ALL of the following are true and verified b ### Technical (verifiable in code + tests) - [ ] Server-authoritative invariant: no client-side damage, position, or item validation. Verified by `code-review` skill pass on combat + inventory + NFT modules. -- [ ] LLM intent validation: every NPC action goes through Go gateway. No API key in Unity client. Verified by grep + security audit. +- [ ] LLM intent validation: every NPC action goes through `api.dos.ai` / Go LLM Gateway. No API key in Unity client. Verified by grep + security audit. - [ ] AI agent inherits player rate limit + capability cap. Verified by integration test. - [ ] NFT escrow on equip; release on unequip. Verified on DOS Chain test net. - [ ] Photon Fusion 2 dedicated Server Mode build runs on Hetzner VPS, accepts 4-20 player connections in load test. -- [ ] Supabase persists profile, inventory, quest progress, NFT lock state, cultivation tier across reincarnation cycles. +- [ ] Nakama/Postgres persists profile, inventory, quest progress, NFT lock state, cultivation tier across reincarnation cycles. - [ ] Multiplayer 4-20 players per zone holds 60Hz tick under load test (Fusion bots simulating 50 players for stress). ### Process (verifiable in repo state) @@ -84,7 +84,7 @@ The slice is considered "done" when ALL of the following are true and verified b | Phase | Target Weeks | Output | | ---- | ---- | ---- | -| 1. Setup + first commit | T+0 to T+1 | Unity project + Photon SDK + Supabase + Go gateway scaffold + repo structure | +| 1. Setup + first commit | T+0 to T+1 | Unity project + Photon SDK + Nakama OSS + api.dos.ai LLM contract + repo structure | | 2. Networked player + zone | T+1 to T+4 | 1 zone Photon Fusion 2 multiplayer, Hunter skin spawn, minimal ARPG controller first, Opsive UCC evaluated after baseline | | 3. NPC + LLM dialogue | T+4 to T+8 | Convai NPC in hub town, server-validated intent flow | | 4. Quest + dungeon | T+8 to T+12 | 1 quest line + 1 dungeon + 1 boss (LLM dialogue) | diff --git a/docs/design/03-systems-index.md b/docs/design/03-systems-index.md index 5506762..efb65b0 100644 --- a/docs/design/03-systems-index.md +++ b/docs/design/03-systems-index.md @@ -11,9 +11,10 @@ SECOND SPAWN is a hybrid MMO + top-down ARPG. The mechanical scope spans: - ARPG core (combat, movement, minimal controller baseline first; Opsive UCC evaluated after baseline) - Multiplayer networking (Photon Fusion 2 dedicated server) -- Persistence (Supabase Postgres + Realtime side-channel) -- LLM NPCs (Convai phase 1, Go gateway phase 2) +- Persistence (Nakama OSS + Postgres, with Supabase sidecar where useful) +- LLM NPCs (Convai phase 1, api.dos.ai / Go LLM Gateway phase 2) - AI agent autoplay (server-side, capability-capped) +- OpenClaw-connected NPCs (user-owned agents as server-validated world actors) - Cultivation 6-tier progression - Reincarnation loop (death -> SECOND token -> new body) - Time-as-currency body lifespan economy @@ -34,11 +35,12 @@ This index enumerates every system the game needs, categorizes by Core/Gameplay/ | 4 | Input system (Unity Input System) | Core | MVP | Not started | - | Player Controller | | 5 | Zone scene management (1 zone vertical slice) | Core | MVP | Not started | (TDD pending) | NetworkRunner | | 6 | Combat (ARPG action) | Gameplay | MVP | Not started | (TDD pending) | Player Controller, Networked state | -| 7 | NPC dialogue (Convai SDK + intent validation) | Gameplay | MVP | Not started | (TDD pending) | Go gateway (phase 2 ready) | +| 7 | NPC dialogue (Convai SDK + intent validation) | Gameplay | MVP | Not started | (TDD pending) | api.dos.ai / Go LLM Gateway (phase 2 ready) | | 8 | Quest system (linear, 3-5 quests slice scope) | Gameplay | VS | Not started | (TDD pending) | NPC dialogue, persistence | | 9 | Dungeon instance (1 dungeon, 1 boss) | Gameplay | VS | Not started | (TDD pending) | Combat, NPC dialogue, Photon | | 10 | Boss LLM dialogue (Convai grounded) | Gameplay | VS | Not started | (TDD pending) | NPC dialogue | -| 11 | AI agent for offline players (server-side) | Gameplay | VS | Not started | (TDD pending) | NetworkRunner, LLM gateway, intent schema | +| 11 | AI agent for offline players (server-side) | Gameplay | VS | Drafted | [10-character-profile-agent-memory.md](10-character-profile-agent-memory.md) | NetworkRunner, api.dos.ai / Go LLM Gateway, intent schema | +| 37 | OpenClaw-connected NPC bridge (user-owned agents as NPC actors) | Gameplay / Meta | Alpha | Concept | [10-character-profile-agent-memory.md](10-character-profile-agent-memory.md) | Auth, Nakama, api.dos.ai / Go LLM Gateway, NPC dialogue, LLM safety | | 12 | Cultivation 6-tier (slice: tier 1-2) | Progression | MVP | Drafted | [04-cultivation-system.md](04-cultivation-system.md) | Persistence | | 13 | Reincarnation flow (death -> SECOND -> new body) | Progression | VS | Not started | (TDD pending) | Cultivation, NFT escrow, Persistence | | 14 | SECOND token economy | Economy | VS | Not designed | (GDD pending - JOY input) | DOS Chain integration | @@ -46,11 +48,11 @@ This index enumerates every system the game needs, categorizes by Core/Gameplay/ | 15 | NFT inventory (Hunter skin slice scope) | Economy | VS | Not started | (TDD pending) | thirdweb-api MCP, Persistence | | 16 | NFT escrow (lock on equip, release on unequip) | Economy | VS | Not started | (TDD pending) | NFT inventory, DOS Chain | | 17 | Loot / drop tables | Economy | VS | Not started | (TDD pending) | Combat, persistence | -| 18 | Profile persistence (Supabase Postgres) | Persistence | MVP | Not started | (TDD pending) | Supabase Auth | +| 18 | Profile persistence (Nakama OSS + Postgres) | Persistence | MVP | Drafted | [10-character-profile-agent-memory.md](10-character-profile-agent-memory.md) | Auth | | 19 | Inventory persistence | Persistence | MVP | Not started | (TDD pending) | Profile, NFT inventory | | 20 | Quest progress persistence | Persistence | MVP | Not started | (TDD pending) | Profile, Quest system | | 21 | Cultivation tier persistence (carries through reincarnation) | Persistence | MVP | Not started | (TDD pending) | Profile | -| 22 | Auth (Supabase email + DOS Chain wallet) | Persistence | MVP | Not started | (TDD pending - reuse DOS.Me pattern) | Supabase, thirdweb | +| 22 | Auth (Nakama + DOS Chain wallet, Supabase sidecar if useful) | Persistence | MVP | Not started | (TDD pending - reuse DOS.Me pattern as identity bridge reference) | Nakama, thirdweb | | 23 | HUD (combat, cultivation tier, currency) | UI | VS | Not started | (deferred template `_deferred/hud-design.md`) | Combat, Cultivation | | 24 | Inventory UI | UI | VS | Not started | (deferred template `_deferred/ux-spec.md`) | Inventory persistence | | 25 | NPC dialogue UI | UI | VS | Not started | (deferred) | NPC dialogue | @@ -58,14 +60,14 @@ This index enumerates every system the game needs, categorizes by Core/Gameplay/ | 27 | Reincarnation UI | UI | VS | Not started | (deferred) | Reincarnation flow | | 28 | AI agent activity log UI | UI | VS | Not started | (deferred) | AI agent | | 29 | Audio (SFX, ambient, music - placeholder for slice) | Audio | VS | Not started | (deferred template `_deferred/sound-bible.md`) | - | -| 30 | Chat (Supabase Realtime - global + zone) | Narrative / UI | VS | Not started | (TDD pending) | Supabase Realtime | -| 31 | LLM intent validation (Go gateway pattern) | Meta / Engineering | MVP | Not started | (TDD pending - reuse DOSRouter) | LLM provider | -| 32 | LLM safety (rate limit, prompt injection defense) | Meta / Engineering | MVP | Not started | (TDD pending - reuse DOSafe patterns) | Go gateway | +| 30 | Chat (Nakama channel first, Supabase Realtime sidecar only if useful) | Narrative / UI | VS | Not started | (TDD pending) | Nakama | +| 31 | LLM intent validation (api.dos.ai / Go LLM Gateway pattern) | Meta / Engineering | MVP | Not started | (TDD pending - reuse DOSRouter) | LLM provider | +| 32 | LLM safety (rate limit, prompt injection defense) | Meta / Engineering | MVP | Not started | (TDD pending - reuse DOSafe patterns) | api.dos.ai / Go LLM Gateway | | 33 | Anti-cheat / server-authority verification | Meta / Engineering | MVP | (Architectural) | [docs/ARCHITECTURE.md "Critical Invariants"](../ARCHITECTURE.md#critical-invariants) | All gameplay systems | | 34 | Telemetry / monitoring (Sentry + Grafana) | Meta | Alpha | Deferred | - | All systems | | 35 | Onboarding / tutorial | Meta | VS | Deferred (assume slice = no tutorial) | - | All gameplay systems | -**Total: 36 systems identified for slice scope.** +**Total: 37 systems identified for slice scope and post-slice roadmap.** --- @@ -74,7 +76,7 @@ This index enumerates every system the game needs, categorizes by Core/Gameplay/ | Category | Description | Count | | ---- | ---- | ---- | | **Core** | Foundation systems everything depends on | 5 (NetworkRunner, Controller, Camera, Input, Zone management) | -| **Gameplay** | The systems that make the game fun | 6 (Combat, NPC dialogue, Quest, Dungeon, Boss LLM, AI agent) | +| **Gameplay** | The systems that make the game fun | 7 (Combat, NPC dialogue, Quest, Dungeon, Boss LLM, AI agent, OpenClaw-connected NPC bridge) | | **Progression** | How the player grows over time | 2 (Cultivation, Reincarnation) | | **Economy** | Resource creation and consumption | 5 (SECOND token, Time-as-currency, NFT inventory, NFT escrow, Loot) | | **Persistence** | Save state and continuity | 5 (Profile, Inventory, Quest, Cultivation, Auth) | @@ -90,9 +92,9 @@ This index enumerates every system the game needs, categorizes by Core/Gameplay/ ### Foundation Layer (no gameplay dependencies) 1. NetworkRunner / Photon Fusion 2 setup (#1) -2. Auth (Supabase + DOS Chain wallet) (#22) +2. Auth (Nakama + DOS Chain wallet, Supabase sidecar if useful) (#22) 3. Profile persistence (#18) -4. Go LLM gateway (DOSRouter pattern) (#31) +4. api.dos.ai / Go LLM Gateway integration (DOSRouter pattern) (#31) 5. LLM safety (rate limit, prompt injection) (#32) ### Core Layer (depends on foundation) @@ -107,39 +109,40 @@ This index enumerates every system the game needs, categorizes by Core/Gameplay/ ### Feature Layer (depends on core) 12. Combat (#6) - depends on: Player Controller, networked state -13. NPC dialogue (Convai + intent validation) (#7) - depends on: Go gateway -14. Cultivation system (#12) - depends on: Cultivation persistence, Combat -15. NFT inventory (#15) - depends on: Auth, thirdweb-api MCP -16. Chat (Supabase Realtime) (#30) - depends on: Auth, Supabase Realtime -17. Quest system (#8) - depends on: NPC dialogue, persistence -18. Dungeon instance (#9) - depends on: Combat, Photon +13. NPC dialogue (Convai + intent validation) (#7) - depends on: api.dos.ai / Go LLM Gateway +14. OpenClaw-connected NPC bridge (#37) - depends on: Auth, Nakama, api.dos.ai / Go LLM Gateway, NPC dialogue, LLM safety +15. Cultivation system (#12) - depends on: Cultivation persistence, Combat +16. NFT inventory (#15) - depends on: Auth, thirdweb-api MCP +17. Chat (Nakama channel first, Supabase Realtime sidecar only if useful) (#30) - depends on: Auth, Nakama +18. Quest system (#8) - depends on: NPC dialogue, persistence +19. Dungeon instance (#9) - depends on: Combat, Photon ### Integration Layer (depends on features) -19. Boss LLM dialogue (#10) - depends on: NPC dialogue, Dungeon -20. NFT escrow (#16) - depends on: NFT inventory, DOS Chain -21. Loot / drop tables (#17) - depends on: Combat, Persistence -22. Reincarnation flow (#13) - depends on: Cultivation, NFT escrow, Persistence -23. Time-as-currency (#36) - depends on: Reincarnation, Combat, Persistence -24. SECOND token economy (#14) - depends on: DOS Chain integration, Reincarnation -25. AI agent for offline players (#11) - depends on: NetworkRunner, LLM gateway, intent schema, Cultivation, Combat, Time-as-currency +20. Boss LLM dialogue (#10) - depends on: NPC dialogue, Dungeon +21. NFT escrow (#16) - depends on: NFT inventory, DOS Chain +22. Loot / drop tables (#17) - depends on: Combat, Persistence +23. Reincarnation flow (#13) - depends on: Cultivation, NFT escrow, Persistence +24. Time-as-currency (#36) - depends on: Reincarnation, Combat, Persistence +25. SECOND token economy (#14) - depends on: DOS Chain integration, Reincarnation +26. AI agent for offline players (#11) - depends on: NetworkRunner, api.dos.ai / Go LLM Gateway, intent schema, Cultivation, Combat, Time-as-currency ### Presentation Layer (depends on features) -26. HUD (#23) -27. Inventory UI (#24) -28. NPC dialogue UI (#25) -29. Quest tracker UI (#26) -30. Reincarnation UI (#27) -31. AI agent activity log UI (#28) -32. Audio (#29) +27. HUD (#23) +28. Inventory UI (#24) +29. NPC dialogue UI (#25) +30. Quest tracker UI (#26) +31. Reincarnation UI (#27) +32. AI agent activity log UI (#28) +33. Audio (#29) ### Meta Layer -33. Anti-cheat verification (#33) - cuts across everything -34. Quest progress persistence (#20) - depends on: Quest system -35. Telemetry (#34) - depends on: everything -36. Onboarding (#35) - depends on: all gameplay (deferred for slice) +34. Anti-cheat verification (#33) - cuts across everything +35. Quest progress persistence (#20) - depends on: Quest system +36. Telemetry (#34) - depends on: everything +37. Onboarding (#35) - depends on: all gameplay (deferred for slice) --- @@ -148,12 +151,13 @@ This index enumerates every system the game needs, categorizes by Core/Gameplay/ | System | Risk Type | Risk Description | Mitigation | | ---- | ---- | ---- | ---- | | AI agent for offline players (#11) | Technical + Design | LLM cost at scale; agent feels invisible or invasive | Prototype early in slice; add visible activity log; capability cap | +| OpenClaw-connected NPC bridge (#37) | Product + Security | User-owned agents can create moderation, spam, prompt injection, and trust-boundary risk | Treat connected agents as untrusted external actors; require consent, identity binding, rate limit, moderation, and server validation | | LLM intent validation (#31) + safety (#32) | Security | Open-source codebase + LLM = injection / abuse vector | Reuse DOSafe patterns; per-NPC memory cap; per-player rate limit | | NFT escrow (#16) | Technical | Latency between Unity equip action and DOS Chain confirmation | Optimistic UI + reconcile-on-failure; cache lock state in Supabase | | Reincarnation flow (#13) | Design | Cultivation carryover too generous = no death weight; too punitive = grind | Tune cost during slice playtests | | Time-as-currency (#36) | Design + Economy | Constant drain can feel oppressive; weak drain can feel invisible | Start with danger-zone drain, one earn source, one spend sink | | Photon Fusion 2 dedicated server (#1) | Technical | Solo dev capacity to run dedicated infra | Slice uses Photon Cloud free 20 CCU; production migration is post-slice | -| Convai SDK in Unity (#7) | Technical | 3rd-party SDK may not test against Unity 6.5 beta | Have phase 2 fallback (Go gateway + custom LLM) ready in design | +| Convai SDK in Unity (#7) | Technical | 3rd-party SDK may not test against Unity 6.5 beta | Have phase 2 fallback (`api.dos.ai` / Go LLM Gateway + custom LLM) ready in design | --- @@ -170,7 +174,7 @@ Aligned with [02-vertical-slice-spec.md](02-vertical-slice-spec.md) build phases | 5 | Camera + Input (#3, #4) | Phase 2 | S | Standard URP | | 6 | Zone scene management (#5) | Phase 2 | M | | | 7 | Combat (#6) | Phase 2 | L | Server-authoritative critical | -| 8 | Go LLM gateway scaffold (#31) | Phase 2 | M | Reuse DOSRouter pattern | +| 8 | api.dos.ai / Go LLM Gateway integration (#31) | Phase 2 | M | Reuse DOSRouter pattern | | 9 | NPC dialogue + Convai (#7) | Phase 3 | L | First LLM integration | | 10 | LLM safety (#32) | Phase 3 | M | Concurrent with #9 | | 11 | Quest system (#8) | Phase 4 | L | | @@ -184,7 +188,7 @@ Aligned with [02-vertical-slice-spec.md](02-vertical-slice-spec.md) build phases | 19 | AI agent for offline players (#11) | Phase 8 | XL | Highest-risk system | | 20 | UI cluster (#23-#28) | Throughout phases 2-8 | XL | Build incrementally | | 21 | Audio placeholder (#29) | Phase 9 | S | Slice-quality only | -| 22 | Chat (#30) | Phase 9 | M | Supabase Realtime | +| 22 | Chat (#30) | Phase 9 | M | Nakama channel first, Supabase sidecar only if useful | | 23 | Polish + playtest | Phase 9 | XL | | Effort estimate: S = 1-3 days, M = 4-7 days, L = 1-2 weeks, XL = 2-4 weeks (solo dev + AI agent). @@ -196,7 +200,7 @@ Effort estimate: S = 1-3 days, M = 4-7 days, L = 1-2 weeks, XL = 2-4 weeks (solo | Metric | Count | | ---- | ---- | | Total systems identified | 36 | -| Design docs started | 5 (cultivation, overview design, player controller prototype, time-as-currency, Pirate Adventure reference review) | +| Design docs started | 6 (cultivation, overview design, player controller prototype, time-as-currency, Pirate Adventure reference review, character profile / agent memory) | | Design docs reviewed | 0 | | Design docs approved | 0 | | MVP systems with TDD started | 0 | diff --git a/docs/design/05-networking-architecture.md b/docs/design/05-networking-architecture.md index 5c3171b..91fca71 100644 --- a/docs/design/05-networking-architecture.md +++ b/docs/design/05-networking-architecture.md @@ -4,13 +4,13 @@ *Created: 2026-05-14* *Implements Pillar*: AI agent 24/7, LLM as world citizen, Server-authoritative gameplay -> **Quick reference** - Layer: `Core` (foundation - everything else depends on this) - Priority: `MVP` - Key deps: `Supabase Auth (for JWT), Go gateway (for LLM intent)` +> **Quick reference** - Layer: `Core` (foundation - everything else depends on this) - Priority: `MVP` - Key deps: `Nakama auth/session, api.dos.ai / Go LLM Gateway (for LLM intent)` --- ## Summary -Photon Fusion 2 in **Server Mode dedicated** is the canonical multiplayer runtime for SECOND SPAWN. The Unity client is a thin input + render surface; the dedicated Unity headless server is the authority for all gameplay state. The Go gateway (`backend/gateway/`) handles LLM and NFT intents; the Fusion server consumes validated intents and mutates `[Networked]` state. +Photon Fusion 2 in **Server Mode dedicated** is the canonical multiplayer runtime for SECOND SPAWN. The Unity client is a thin input + render surface; the dedicated Unity headless server is the authority for all gameplay state. Nakama owns game backend sessions and durable game APIs. `api.dos.ai` / Go LLM Gateway handles AI and LLM calls only; the Fusion server consumes validated intents and mutates `[Networked]` state. The integration is built from scratch per ADR 0006 (no template drop-in). Patterns are extracted from BR200, Tanknarok, and Fusion Starter samples (read locally, not copied). @@ -21,7 +21,7 @@ The integration is built from scratch per ADR 0006 (no template drop-in). Patter The fantasy is "your character has a life that does not pause when yours does." This is the toughest networking requirement because: - The character continues to exist + play when the human is offline (offline AI agent runs on the server) -- The world is persistent across player sessions (Supabase + Fusion state sync) +- The world is persistent across player sessions (Nakama/Postgres + Fusion state sync) - Death is permanent for the body (server validates reincarnation flow + NFT escrow release) Anything less than server-authoritative breaks the fantasy on day one of public release. @@ -39,19 +39,24 @@ Anything less than server-authoritative breaks the fantasy on day one of public │ - prediction │ │ - Interest management │ └────────┬─────────┘ │ - Tick-driven simulation │ │ HTTPS │ - Offline AI agent loop │ - │ + Supabase JWT └──────────┬──────────────────┘ + │ + backend token └──────────┬──────────────────┘ ▼ │ HTTPS -┌──────────────────┐ │ + Supabase JWT -│ Go LLM Gateway │ ◄─────────────────────────────┘ -│ (backend/ │ -│ gateway/) │ ──────────► Anthropic / OpenAI / Convai -│ │ ──────────► thirdweb (DOS Chain) -└──────────────────┘ ──────────► Supabase (persistence side-channel) +┌──────────────────┐ │ + backend token +│ api.dos.ai / │ ◄─────────────────────────────┘ +│ Go LLM Gateway │ ──────────► Anthropic / OpenAI / Convai +└──────────────────┘ + │ + ▼ +┌──────────────────┐ +│ Nakama/Postgres │ +│ Game backend │ +│ thirdweb/DOS │ ──────────► DOS Chain +└──────────────────┘ ``` The dedicated server NEVER trusts the client. Client predicts visually, server reconciles. -The dedicated server NEVER trusts the LLM. All LLM responses parse into structured intents (`backend/gateway/internal/intent/intent.go`); the server validates against authoritative state before mutating anything. +The dedicated server NEVER trusts the LLM. All LLM responses parse into structured intents; the server validates against authoritative state before mutating anything. --- @@ -106,14 +111,14 @@ The actual implementations land in `Assets/_SecondSpawn/Scripts/Networking/` (as ### `IntentBridge` (MonoBehaviour, server-only) -- Receives validated intents from the Go gateway over HTTP+JWT. +- Receives AI intents from `api.dos.ai` / Go LLM Gateway over authenticated HTTP. - Translates intent into Fusion state mutation (e.g. `NPCGrantItem` -> add item to `NetworkPlayer.Inventory` after server-side checks). - Never trusts the intent blindly - re-validates against current authoritative state. ### `OfflineAgentRunner` (server-only) - Per offline player whose character is still in a zone, runs a server-side decision loop: - pull state -> call Go gateway with capability-cap + rate-limit headers -> receive intent -> validate -> apply. + pull state -> call `api.dos.ai` / Go LLM Gateway with capability-cap + rate-limit headers -> receive intent -> validate -> apply. - Inherits the player's rate limit + LLM token budget (no double-charging). - Death of agent = body death = reincarnation flow same as player (see [04-cultivation-system.md](04-cultivation-system.md)). @@ -131,16 +136,16 @@ The actual implementations land in `Assets/_SecondSpawn/Scripts/Networking/` (as ## Persistence boundary Photon Fusion 2 manages **session state** (in-zone networked properties). -Supabase Postgres manages **durable state** (profile, inventory snapshot, quest progress, NFT lock state, cultivation tier). +Nakama OSS + Postgres manages **durable state** (profile, inventory snapshot, quest progress, NFT lock state, cultivation tier). -The dedicated server flushes Supabase on: +The dedicated server flushes Nakama/Postgres on: - Player disconnect (final state save) - Zone transition (player moves between zone instances) - Periodic interval (every N minutes, configurable) - On reincarnation transition (mandatory before despawn) -Crash safety: the latest Supabase snapshot is the source of truth on next session. Some in-zone progress may be lost on a server crash; we accept this for vertical slice and design proper resilience later. +Crash safety: the latest Nakama/Postgres snapshot is the source of truth on next session. Some in-zone progress may be lost on a server crash; we accept this for vertical slice and design proper resilience later. --- @@ -150,11 +155,11 @@ These are non-negotiable per the AGPL-3.0 open-source threat model + Pillar 4 (S 1. **No gameplay logic on the client.** Visual prediction OK; state changes NOT. 2. **No API keys in the Unity client.** Period. The Unity client only holds: - - Supabase URL + anon key (public-safe) + - Nakama endpoint and public client key - Gateway base URL (public) - Photon App ID (semi-public, client-visible by design) -3. **All LLM calls server-side via Go gateway.** The dedicated server is the only thing that hits Anthropic / OpenAI / Convai. -4. **All NFT mutations server-side via Go gateway.** The dedicated server is the only thing that signs DOS Chain transactions. +3. **All LLM calls server-side via `api.dos.ai` / Go LLM Gateway.** The dedicated server or Nakama backend is the only game-side caller that requests Anthropic / OpenAI / Convai work. +4. **All NFT mutations server-side.** Use Nakama runtime modules or a dedicated wallet/blockchain service. Do not place game inventory or wallet mutation APIs in the LLM gateway. 5. **Rate limit + capability cap apply to AI agent the same way they apply to the player.** No "agent gets unlimited LLM tokens" - it inherits the offline player's budget. 6. **No `Host Mode` build in production.** CI staging build must use Server Mode dedicated; PR review checks this. @@ -180,7 +185,7 @@ Numbers will be re-validated with Fusion bot load test (per `02-vertical-slice-s - Per-zone interest management AOI radius (50m default, but depends on zone size) - AI agent decision loop frequency (every 5s? 15s? adaptive based on activity?) -- Server crash policy: roll back to last Supabase snapshot vs accept in-zone loss +- Server crash policy: roll back to last Nakama/Postgres snapshot vs accept in-zone loss - Photon Fusion 2 license tier when scaling beyond Cloud free 20 CCU (also in CLAUDE.md Open Decision Points) - Dedicated server region selection (Hetzner Helsinki vs Falkenstein? US East? affects latency for non-VN players) @@ -196,7 +201,7 @@ Numbers will be re-validated with Fusion bot load test (per `02-vertical-slice-s | `NetworkInputProvider` | Phase B keyboard input provider | `Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkInputProvider.cs` | | `PlayerSpawner` | Phase B server-authoritative join spawn/despawn | `Unity/Assets/_SecondSpawn/Scripts/Networking/PlayerSpawner.cs` | | `NetworkZone` | Not started | TBD | -| `IntentBridge` | Not started | will live next to `internal/intent` in backend/gateway concepts | +| `IntentBridge` | Not started | server-only bridge from Fusion server to `api.dos.ai` / Go LLM Gateway | | `OfflineAgentRunner` | Not started | server-only, Phase 7 | | Test scene | `ZoneTest_Hub.unity` with scene-root `_NetworkBootstrap` | `Unity/Assets/_SecondSpawn/Scenes/ZoneTest_Hub.unity` | | Load test (Fusion bots) | Not started | Phase 8 | diff --git a/docs/design/06-overview-design.md b/docs/design/06-overview-design.md index d125354..f6364ce 100644 --- a/docs/design/06-overview-design.md +++ b/docs/design/06-overview-design.md @@ -5,7 +5,7 @@ *Author: Codex* *Last Verified: 2026-05-14 against `AGENTS.md`, `00-game-concept.md`, `01-pillars.md`, `02-vertical-slice-spec.md`, and `05-networking-architecture.md`* -> **Quick reference** - Layer: `Core` - Priority: `Vertical Slice` - Key deps: `Photon Fusion 2`, `Supabase`, `Go gateway`, `DOS Chain`, `Convai phase 1` +> **Quick reference** - Layer: `Core` - Priority: `Vertical Slice` - Key deps: `Photon Fusion 2`, `Nakama OSS`, `api.dos.ai / Go LLM Gateway`, `DOS Chain`, `Convai phase 1` --- @@ -87,7 +87,7 @@ The first prototype should create a small, project-owned movement contract. Simp | Camera | Top-down follow camera | Can be simple Cinemachine or custom follow. | | Zone | `ZoneTest_Hub` only | No dungeon, no multi-zone. | | Config | `SecondSpawnConfig.asset` exists with public-safe fields | No secrets in Unity. | -| Persistence | Not in first playable | Supabase comes after movement baseline. | +| Persistence | Not in first playable | Nakama OSS comes after movement baseline. | | AI/LLM | Not in first playable | Design must keep path open. | | Time-as-currency | Not in first playable | First implementation belongs with reincarnation/progression, not movement. | | NFT | Not in first playable | No chain dependency for movement prototype. | @@ -101,7 +101,7 @@ The first prototype should create a small, project-owned movement contract. Simp - Convai - Synty / Quaternius environment art packs - Combat damage, loot, inventory, or item drops -- Supabase auth or profile persistence +- Nakama auth or profile persistence - NFT ownership and escrow - Offline AI agent behavior - Dungeon instance @@ -122,7 +122,7 @@ These are still vertical slice systems. They are only excluded from the first pl | 4 | Simple KCC spike | Import official Fusion Simple KCC addon and compare it against the baseline. | | 5 | Opsive evaluation branch | Import Opsive in isolation and compare value/cost against the baseline and Simple KCC spike. | | 6 | Combat prototype | Add one basic attack only after movement is stable. | -| 7 | Persistence/auth prototype | Supabase profile and login once local gameplay loop exists. | +| 7 | Persistence/auth prototype | Nakama profile and login once local gameplay loop exists. | --- diff --git a/docs/design/07-player-controller-prototype.md b/docs/design/07-player-controller-prototype.md index 254c586..0e7596e 100644 --- a/docs/design/07-player-controller-prototype.md +++ b/docs/design/07-player-controller-prototype.md @@ -3,7 +3,7 @@ *Status: In progress* *Created: 2026-05-14* *Author: Codex* -*Last Verified: 2026-05-15 against local Photon Pirate Adventure 2.0.12 sample review and Simple KCC 2.0.15 package metadata* +*Last Verified: 2026-05-16 against local Photon Pirate Adventure 2.0.12 sample review, Simple KCC 2.0.15 package metadata, and Photon Fusion Animations technical sample docs* > **Quick reference** - Layer: `Core` - Priority: `MVP` - Key deps: `Photon Fusion 2`, `Unity Input System`, `ZoneTest_Hub`, `SecondSpawnConfig` @@ -97,6 +97,30 @@ Camera-relative movement can be added after the first pass if the camera angle m --- +## Animation Networking Direction + +Photon's Fusion Animations technical sample documents six approaches: + +- networked state with Animator +- interpolated networked state with Animator +- Animator state synchronization +- Network Mecanim Animator +- network FSM with Animancer +- Fusion Animation Controller for tick-accurate animation + +The prototype should stay on a simple project-owned bridge for now: + +1. Movement remains Fusion / KCC authoritative. +2. The visual child Animator reads replicated movement and action intent. +3. Only compact values are networked: planar speed, movement direction, action + trigger counters, and later combat state IDs. +4. Network Mecanim Animator is acceptable for quick experiments but is not the + production default. +5. Tick-accurate animation should be revisited when combat hitboxes, lag + compensation, and PvP fairness become active scope. + +--- + ## Components | Component | Responsibility | Existing or New | @@ -104,8 +128,9 @@ Camera-relative movement can be added after the first pass if the camera angle m | `NetworkRunnerSetup` | Starts Fusion dev session and owns runner lifecycle. | Existing Phase B | | `NetworkInputProvider` | Collects movement input per Fusion tick. | Existing Phase B, may extend | | `NetworkPlayer` | Applies Fusion input to Simple KCC and owns session player state. | Existing Phase B, extended | +| `NetworkAnimatorBridge` | Reads replicated root movement and drives child Animator parameters without animation owning movement authority. | New Simple KCC bridge | | `PlayerSpawner` | Spawns one player object per joined player. | Existing Phase B | -| `PlayerCameraFollow` | Keeps camera pointed at local player. | New if no suitable existing component | +| `TopDownCameraFollow` | Keeps the prototype camera pointed at the local input-authority player. | New Simple KCC bridge | | `Player_NetworkCube.prefab` | Placeholder networked player prefab with Simple KCC. | Existing Phase B, evolved | --- @@ -152,7 +177,8 @@ These are prototype values, not final game balance. - [x] Import the official Fusion Simple KCC addon in a separate branch/commit only after baseline passes. - [x] Convert movement from raw networked position updates to KCC-backed movement. -- [ ] Validate with Unity 6.5 beta and current Fusion 2.1.1 release candidate. +- [x] Add a project-owned Animator bridge so the RPG Character Mecanim pack can be used as a visual layer without taking movement authority. +- [x] Validate with Unity 6.5 beta and current Fusion 2.1.1 release candidate. - [ ] Compare movement feel and authority clarity against the baseline. - [ ] Decide whether Simple KCC becomes the MVP controller. @@ -208,6 +234,7 @@ These are prototype values, not final game balance. | ---- | ---- | ---- | ---- | | Prototype shape | `06-overview-design.md` | Minimal controller first, Simple KCC spike second, Opsive evaluation third | Scope dependency | | Reference sample | `09-pirate-adventure-reference-review.md` | Pirate Adventure controller and FSM patterns | Pattern dependency | +| Photon sample | [Fusion Animations technical sample](https://doc.photonengine.com/fusion/current/technical-samples/animations) | Render-accurate vs tick-accurate animation networking options | Technical reference | | Networking rules | `05-networking-architecture.md` | Network input and server authority | Rule dependency | | Pillar priority | `01-pillars.md` | Server-authoritative gameplay | Rule dependency | | Unity conventions | `../setup/unity-conventions.md` | Prefab, scene, and asmdef organization | Ownership handoff | diff --git a/docs/design/10-character-profile-agent-memory.md b/docs/design/10-character-profile-agent-memory.md new file mode 100644 index 0000000..4471e18 --- /dev/null +++ b/docs/design/10-character-profile-agent-memory.md @@ -0,0 +1,399 @@ +# Character Profile, Soul, and Agent Memory + +*Status: Prototype implemented* +*Created: 2026-05-15* +*Author: Codex* +*Last Verified: 2026-05-16 against `AGENTS.md`, ADR 0003, ADR 0004, `08-time-as-currency.md`, Cloud Run staging gateway, and Unity C# assembly build* + +> **Quick reference** - Layer: `Persistence / AI Agent` - Priority: `MVP foundation` - Key deps: `Auth`, `Fusion server authority`, `LLM gateway`, `Time-as-Currency`, `Reincarnation` + +--- + +## Purpose + +This document defines the first durable character data model for SECOND SPAWN: + +- account-level player profile +- current synthetic body profile +- gameplay stats +- soul/personality profile for the LLM agent +- compact memory records for agent context +- player-owned offline-agent policy + +The goal is to make the AI agent feel like the player's character, without giving the LLM authority over game state. + +--- + +## Core Rule + +The LLM reads profile, soul, policy, memory, and world state. It emits structured intent. The server validates the intent. Only the server mutates gameplay state. + +This preserves: + +- open-source anti-cheat assumptions +- Hard Rule #2: never let LLM mutate authoritative game state +- Hard Rule #3: no provider keys in Unity +- the reincarnation fantasy, where identity survives but bodies are replaceable + +--- + +## Data Layers + +| Layer | Survives Reincarnation? | Owner | Purpose | +| ---- | ---- | ---- | ---- | +| `PlayerProfile` | Yes | Auth / backend | Account identity, display name, wallet link, moderation handles | +| `SoulProfile` | Yes | Player + backend validation | Personality, long-term goals, behavior style for offline AI | +| `AgentPolicy` | Yes | Player | What the offline agent is allowed to do while player is away | +| `BodyProfile` | No | Game server | Current synthetic body, visual archetype, BodyTime, lifecycle | +| `CharacterStats` | Mostly no | Game server | Combat and movement-affecting numbers for current body | +| `Cultivation` | Partially | Game server | Consciousness progression that can carry over | +| `MemoryRecord` | Yes, with decay | Backend | Small curated memory facts for LLM context | + +--- + +## Player Profile + +`PlayerProfile` is account-level identity. It should stay small. + +Required fields for the first implementation: + +| Field | Meaning | +| ---- | ---- | +| `player_id` | Nakama user ID issued after Supabase Auth bridge verification | +| `display_name` | Public player name | +| `wallet_address` | Optional DOS Chain wallet link | +| `created_at` | Account creation timestamp | + +Do not store LLM prompt text, inventory blobs, or current body state here. + +Identity bridge rule: + +1. Supabase Auth creates the external identity, including anonymous prototype + users. +2. Unity sends the Supabase access token to Nakama custom auth. +3. Nakama verifies that token directly with Supabase Auth before creating or + loading the game account. +4. Nakama stores the resulting stable custom ID as the game account binding. + +Unity must not send a plain `supabase_user_id` as a trusted account selector. + +--- + +## Body Profile + +`BodyProfile` represents the current synthetic vessel. + +Required fields: + +| Field | Meaning | +| ---- | ---- | +| `body_id` | Unique current body ID | +| `archetype_id` | Gameplay archetype or class key | +| `visual_prefab_key` | Local Unity visual prefab key, used for random spawn visuals later | +| `stats` | Current body combat stats | +| `body_time` | Current BodyTime state | +| `cultivation` | Current tier and progress | +| `lifecycle` | `alive`, `dying`, `reincarnating`, or `dead` | +| `created_at` | Body creation timestamp | + +`BodyProfile` is replaced on reincarnation. The server decides which values carry forward. + +--- + +## Character Stats + +Start with a small stat surface: + +| Stat | Purpose | +| ---- | ---- | +| `level` | Local body level | +| `vitality` | Health scaling | +| `force` | Physical damage | +| `agility` | Movement and attack cadence | +| `focus` | Energy and ability use | +| `resilience` | Damage mitigation | +| `max_health` | Derived or cached health cap | +| `max_energy` | Derived or cached energy cap | +| `attack_power` | Derived or cached attack output | +| `defense_power` | Derived or cached defense output | + +Design note: derived values may be cached for performance, but the server must own recalculation rules. + +--- + +## Soul Profile + +`SoulProfile` is the durable personality layer used by the offline LLM agent. + +It is not a stat buff system. It should never grant combat advantages directly. + +Fields: + +| Field | Meaning | +| ---- | ---- | +| `name` | In-lore consciousness name | +| `core_drive` | The character's main motivation | +| `temperament` | Cautious, aggressive, curious, loyal, etc. | +| `combat_style` | Preferred combat posture for LLM decision context | +| `social_style` | How the agent speaks and socializes | +| `moral_boundaries` | Things the agent should not do | +| `long_term_goals` | Player-approved durable goals | +| `player_notes` | Short free-form guidance from player | +| `reincarnation_lore` | What the character remembers about prior bodies | + +The LLM may use these fields to choose between valid intents, not to invent new abilities. + +--- + +## Agent Policy + +`AgentPolicy` is direct player control over offline behavior. + +Vertical slice minimum: + +| Field | Meaning | +| ---- | ---- | +| `enabled` | Offline agent on/off | +| `mode` | `idle`, `farm_safe_area`, `socialize`, or `quest_assist` | +| `max_session_seconds` | Maximum autonomous session length | +| `allow_body_time_spend` | Whether the agent may spend BodyTime | +| `allow_risky_combat` | Whether the agent may attack high-risk targets | +| `preferred_activities` | Player-prioritized actions | +| `forbidden_activities` | Explicitly disallowed actions | +| `stop_when_body_time_below` | BodyTime safety threshold | + +The agent must stop or downgrade behavior when policy and world risk conflict. + +--- + +## OpenClaw Agent NPC Bridge + +A connected OpenClaw agent is a user-owned external agent that can appear in SECOND SPAWN as an NPC-like world actor. + +This is not the same as the player's offline agent: + +| Actor | Primary Owner | In-World Role | Authority | +| ---- | ---- | ---- | ---- | +| Offline player agent | Player character owner | Controls the player's current body while offline | Emits action intent; Fusion server validates | +| OpenClaw-connected NPC | OpenClaw agent owner | Companion, hub NPC, merchant-like persona, quest-adjacent social actor, or world citizen | Emits dialogue or action intent; Fusion server validates | + +Minimum data contract: + +| Field | Meaning | +| ---- | ---- | +| `connected_agent_id` | Stable ID for the OpenClaw agent | +| `owner_player_id` | Player who connected the agent | +| `display_name` | Public in-game agent name | +| `agent_kind` | Companion, hub_npc, merchant_persona, quest_actor, social_actor | +| `consent_scope` | What the owner allows the agent to do in-game | +| `moderation_state` | Active, limited, suspended, or blocked | +| `memory_scope` | Which memories can be read or written | +| `rate_limit_profile` | Token and action limits for this connected agent | + +Safety rules: + +- OpenClaw agents are untrusted external actors from the game server's point of view. +- They never mutate inventory, currency, quest, BodyTime, cultivation, combat, or world state directly. +- They may produce dialogue, social memory, and structured intent. +- Nakama owns identity binding, consent, moderation, rate limit, and activity logging. +- `api.dos.ai` / Go LLM Gateway owns prompt safety and model routing. +- Fusion server remains the final validator for any in-world action. + +Design intent: OpenClaw agents should make SECOND SPAWN feel like an extension of the DOS.AI agent ecosystem, where users can bring their own agents into the world as social citizens rather than leaving them outside the game. + +--- + +## Memory Records + +`MemoryRecord` is the compact input set for agent context. + +Memory kinds: + +| Kind | Example | +| ---- | ---- | +| `preference` | Player prefers cautious farming over risky boss attempts | +| `quest` | Player promised to return to an NPC after finding a part | +| `relationship` | Player helped another agent yesterday | +| `combat` | Character struggles against ranged enemies | +| `system` | Tutorial or safety note the agent should remember | + +Memory records should be short. Store summaries, not raw transcripts. + +Vertical slice rule: the LLM receives only the top N memories by importance and recency. + +--- + +## Agent Context Prompt + +Backend code now defines a prompt-safe context builder in: + +`backend/gateway/internal/character/profile.go` + +The builder converts a bounded `AgentContext` into stable key-value text. This is intentionally boring and deterministic so model behavior is easier to debug. + +The context includes: + +- player identity +- current body identity +- visual/archetype key +- lifecycle +- cultivation tier +- BodyTime budget +- agent policy +- soul fields +- top memory summaries + +It does not include: + +- API keys +- service-role credentials +- raw chat transcripts +- raw inventory blobs +- untrusted client-provided claims + +--- + +## Agent Decision Contract + +Backend code now defines the first bounded offline-agent decision contract in: + +`backend/gateway/internal/agent/decision.go` + +Allowed action types for the first implementation: + +| Action | Meaning | +| ---- | ---- | +| `stop` | Do nothing or pause because policy/risk says to stop | +| `move` | Request movement toward a bounded position | +| `attack` | Request attack against a nearby allowed target | +| `interact` | Request interaction with a nearby allowed object | +| `say` | Produce dialogue/social text with no direct state mutation | + +The gateway validator checks: + +- action is in the allowed set for this request +- confidence is bounded from 0 to 1 +- move actions include coordinates +- attack actions reference a nearby target from the safe snapshot +- interact actions reference a nearby object from the safe snapshot +- say actions include text + +This does not replace authoritative game-server validation. It is only the first filter between model output and the gameplay server. + +--- + +## Prototype Implementation Status + +Implemented surfaces: + +- `backend/nakama/modules/index.ts` is the current game-backend source for + player profile, current body, soul, agent policy, BodyTime, cultivation, and + compact memories. It exposes `secondspawn_profile_get`, + `secondspawn_memory_add`, `secondspawn_soul_update`, and + `secondspawn_agent_decide` runtime RPCs. +- Nakama runtime module tests cover Supabase custom-auth rewriting, profile + bootstrap, memory dedupe, soul update clamping, and deterministic fallback + agent decisions. +- Local Unity Play Mode can use Nakama device auth as a development fallback + when Supabase anonymous auth is not configured yet. Production account binding + must use Supabase custom auth or a later approved identity ADR. +- `backend/gateway/internal/character` stores prototype `AgentContext` with + profile, body, stats, characteristics, soul, policy, BodyTime, cultivation, + and compact memories for LLM-gateway fallback and standalone cloud smoke + tests. +- Prototype memory writes deduplicate by memory kind and summary. Repeated + Unity Play Mode sessions update the existing memory timestamp instead of + appending the same seed memory again. +- `backend/gateway/internal/agent` returns deterministic prototype decisions + from bounded context and safe world snapshots. +- Cloud Run staging gateway: + `https://second-spawn-gateway-535583621422.asia-southeast1.run.app` +- Unity `SecondSpawnGatewayClient` authenticates with Nakama, reads/writes + Nakama profile memory when a Nakama session exists, and calls the cloud + gateway for NPC text chat, voice-session contract, and prototype LLM decision. +- Unity `PrototypeLLMAgentDriver` can toggle prototype agent control with `P`. +- Unity `PrototypeNPCChatClient` can trigger prototype NPC chat with `O` and + voice-session status with `V`. +- Unity prototype speech uses a world-space text bubble plus local audio cue. + This is not real TTS yet; provider-backed voice still requires an ephemeral + token endpoint. + +Current limitations: + +- Gateway storage is in-memory and resets on Cloud Run revision restart. Nakama + storage is the game-backend path for durable profile/memory. +- Most gateway routes are prototype-public. The Nakama runtime auth hook + verifies Supabase access tokens for game login, but route-level JWT + enforcement is still required before any non-local LLM or voice playtest. +- Agent decisions are deterministic fallback logic, not real LLM reasoning yet. +- Voice is a local cue only. Real voice waits for OpenAI Realtime or ElevenLabs + server-side token minting. + +--- + +## Random Visual Selection + +Random model selection should use `visual_prefab_key`, not direct filesystem paths. + +Flow: + +1. Server selects a valid visual key from an approved archetype pool. +2. The spawned body stores that key in `BodyProfile`. +3. Unity resolves the key to a local visual prefab. +4. If the key is missing locally, Unity falls back to the default prototype visual and logs a warning. + +This keeps spawn visuals deterministic across clients and avoids letting clients choose invalid models. + +--- + +## LLM Control Flow + +Initial offline-agent loop: + +1. Fusion server snapshots safe world state. +2. Backend loads `PlayerProfile`, current `BodyProfile`, `SoulProfile`, `AgentPolicy`, and top memories. +3. Gateway builds LLM context. +4. LLM returns structured intent only. +5. Gateway validates the decision shape and allowed action set. +6. Server validates intent against current authoritative state. +7. Server applies allowed movement/combat/social action through the same path as player input. +8. Backend appends an activity log entry for player review. + +The first playable version should support only a small intent set: move within safe bounds, attack allowed target, interact with allowed object, and stop. + +--- + +## Open Questions + +| Question | Owner | Timing | Current Lean | +| ---- | ---- | ---- | ---- | +| Should `SoulProfile` be editable at character creation only or anytime in hub? | JOY | Before profile UI | Editable in hub with history log | +| How many memories are included in the first LLM context? | Codex | During LLM prototype | 8-12 short summaries | +| Does reincarnation decay memories? | JOY | Before reincarnation MVP | Keep major memories, decay body-specific combat memories | +| Should visual randomization happen per account, per body, or per spawn? | JOY | Before model pool implementation | Per body, because body identity matters | +| Which profile fields stay in Nakama storage vs Supabase sidecar? | JOY + Codex | Before profile UI | Nakama owns game profile; sidecar only for external product data | +| Which OpenClaw agent roles are allowed in the first prototype? | JOY + Codex | Before OpenClaw bridge prototype | Start with social hub NPC or companion, no inventory or economy actions | + +--- + +## Acceptance Criteria + +- [x] Backend data contract exists for profile, body, stats, soul, policy, and memory. +- [x] LLM context builder is deterministic and bounded. +- [ ] Random visual selection has a server-owned key contract. +- [ ] OpenClaw-connected NPCs have identity binding, consent scope, moderation state, and rate limits before any prototype. +- [x] Unity never sends provider keys or direct state mutations. +- [x] Offline-agent intent flow uses the same network input shape as player input for prototype movement. +- [ ] Player can later inspect what the agent did and why. + +--- + +## Cross-References + +| This Document References | Target Doc | Specific Element Referenced | Nature | +| ---- | ---- | ---- | ---- | +| Pillar rules | `01-pillars.md` | AI agent 24/7, server authority | Rule dependency | +| Systems map | `03-systems-index.md` | Profile persistence and AI agent systems | Build dependency | +| LLM safety | `../adr/0003-llm-safety-architecture.md` | Intent validation | Security dependency | +| Offline agent | `../adr/0004-ai-agent-offline-control.md` | Server-side agent control | Architecture dependency | +| Time economy | `08-time-as-currency.md` | BodyTime policy and risk | Economy dependency | diff --git a/docs/design/11-npc-agent-brain-architecture.md b/docs/design/11-npc-agent-brain-architecture.md new file mode 100644 index 0000000..b8dfe47 --- /dev/null +++ b/docs/design/11-npc-agent-brain-architecture.md @@ -0,0 +1,217 @@ +# NPC Agent Brain Architecture + +*Status: Prototype direction* +*Created: 2026-05-16* +*Author: Codex* + +> **Quick reference** - Layer: `AI Agent / NPC` - Priority: `Prototype foundation` - Key deps: Fusion server authority, Nakama game backend, api.dos.ai / Go LLM Gateway, Behavior Designer, character memory + +--- + +## Purpose + +SECOND SPAWN needs NPCs and offline player agents that feel alive, but still obey game-server authority. This document defines the brain architecture before the prototype grows into a pile of one-off scripts. + +The core design is: + +```text +Sense -> Context -> Decide -> Validate -> Act -> Reflect +``` + +The LLM is only one node in the graph. It never owns movement, combat, inventory, BodyTime, currency, quest state, or any other authoritative mutation. + +--- + +## Framework Direction + +### Recommended Hybrid + +| Layer | Pattern / Framework Inspiration | Why | +| ---- | ---- | ---- | +| Backend brain orchestration | LangGraph-style state graph | Durable, stateful, inspectable agent flow with explicit nodes and transitions | +| Unity NPC execution | Behavior Tree / Opsive Behavior Designer | Mature Unity authoring model for NPC action execution, conditions, decorators, and designer-visible debugging | +| Agent identity and memory | OpenClaw-style workspace/persona/skills boundary | Useful concepts for `Soul`, `Memory`, `Policy`, and connected OpenClaw agents | +| Provider wrapper | OpenAI Agents SDK-style guardrails/tracing | Useful later for tool guardrails, output validation, and traceability around model calls | + +Do not embed a general-purpose desktop agent runtime directly in Unity. Game NPCs need bounded capabilities, predictable ticks, and server validation. + +--- + +## Why Not Pure OpenClaw + +OpenClaw is valuable as an external user-agent ecosystem and as a mental model for persona, workspace, skills, channels, and sessions. + +It is not the best in-game NPC brain runtime because: + +- It is designed around operating-system/workspace automation, not server-authoritative gameplay ticks. +- Skills and tools can be broad, while game NPC capabilities must be narrow and validated. +- Its session model is useful for external OpenClaw-connected NPCs, but the Fusion server still needs final say on every in-world action. + +Second Spawn should support OpenClaw agents connecting into the world, but the game runtime should expose a constrained bridge, not hand the game world directly to OpenClaw. + +--- + +## Brain Graph + +```text +Bootstrap + -> Sense + -> LoadContext + -> Decide + -> ValidateIntent + -> Act + -> Reflect + -> Cooldown + -> Sense +``` + +### Bootstrap + +Creates or loads: + +- `PlayerProfile` or NPC identity +- current body profile +- `SoulProfile` +- `AgentPolicy` +- top memories +- local Unity actor reference + +### Sense + +Builds a safe world snapshot: + +- zone ID +- position +- nearby interactables +- nearby threats +- visible player/NPC actors +- current BodyTime +- current high-level state + +Never sends raw Unity scene objects or client-owned claims as trusted facts. + +### LoadContext + +Loads compact context from the game backend: + +- soul +- characteristics +- policy +- top memories +- current body status + +### Decide + +Returns one structured intent: + +| Intent | Meaning | +| ---- | ---- | +| `stop` | Do nothing or pause | +| `move` | Move toward bounded target | +| `say` | Produce social text | +| `interact` | Request interaction with nearby object | +| `attack` | Request attack against nearby allowed target | + +### ValidateIntent + +Checks intent shape and policy: + +- action is allowed in this state +- target exists in the safe snapshot +- move target is inside allowed radius +- body time, cooldown, and risk policy allow the action +- no direct economy/inventory/quest mutation is requested + +### Act + +Maps intent into game execution: + +- `move` -> Fusion input / NPC navigation +- `say` -> chat bubble / voice contract +- `interact` -> server-side interaction request +- `attack` -> server-side combat request +- `stop` -> idle + +### Reflect + +Writes compact memory only when useful: + +- major player preference +- quest commitment +- social relationship change +- combat lesson +- safety event + +No raw transcripts by default. + +--- + +## Unity Prototype Shape + +The first prototype is local-only: + +- `PrototypeAgentBrain` drives one local NPC actor in the hub. +- It uses Nakama RPCs for game profile, soul, policy, and compact memory when a + Nakama session exists. +- It uses the Cloud Run gateway for prototype LLM/chat/voice contracts and falls + back to Nakama deterministic decisions if the LLM gateway is unavailable. +- It moves inside a small patrol radius. +- It can speak with a text bubble and prototype voice cue. +- It does not mutate game state. + +Later production shape: + +- Behavior Designer handles action execution trees. +- `api.dos.ai` / Go LLM Gateway hosts AI graph nodes and provider calls. +- Nakama stores profile, policy, memory, consent, moderation, and audit logs. +- Fusion server validates and applies in-world actions. + +--- + +## OpenClaw Agent Bridge + +User-owned OpenClaw agents can become in-world NPC-like actors through a bridge: + +```text +OpenClaw agent -> Game bridge API -> Nakama identity/policy -> Brain graph -> Fusion validated action +``` + +Allowed first roles: + +- social hub NPC +- companion observer +- quest-adjacent dialogue actor + +Disallowed until later: + +- inventory mutation +- economy mutation +- BodyTime spending +- combat authority +- quest completion authority + +--- + +## Implementation Rule + +Every brain implementation must keep these boundaries: + +1. LLM output is intent, not state. +2. Unity client can visualize and request, not authorize. +3. Gateway can validate shape and policy, not own authoritative world state. +4. Fusion server owns movement/combat/world application. +5. Nakama owns durable game profile, memory, policy, moderation, and audit. + +--- + +## Current Prototype Acceptance + +- [x] Character profile, soul, policy, and memory exist in Nakama runtime RPCs. +- [x] Gateway keeps prototype LLM/chat/voice contracts separate from game backend. +- [x] Unity can call gateway from Play Mode. +- [x] Unity can authenticate to Nakama with local device fallback. +- [x] Local player agent prototype can move through bounded input intent. +- [x] Local NPC brain exists in scene and runs the brain loop. +- [ ] Brain loop logs phase transitions in a debug-friendly way. +- [x] NPC can patrol and speak without Unity console errors. +- [ ] Backend decision endpoint is upgraded from deterministic fallback to model-backed JSON intent. diff --git a/docs/setup/agent-handoff.md b/docs/setup/agent-handoff.md index 935324e..4f90881 100644 --- a/docs/setup/agent-handoff.md +++ b/docs/setup/agent-handoff.md @@ -72,11 +72,9 @@ Do not touch: ## Current manual JOY actions -1. Create `Assets/_SecondSpawn/Settings/SecondSpawnConfig.asset` via Unity: - `Assets > Create > Second Spawn > Project Config`. -2. Add Unity Linux Dedicated Server Build Support for Unity `6000.5.0b7` via +1. Add Unity Linux Dedicated Server Build Support for Unity `6000.5.0b7` via Unity Hub before dedicated server build work. -3. Import asset store packages in separate passes: Opsive UCC, then Behavior +2. Import asset store packages in separate passes: Opsive UCC, then Behavior Designer, then Convai. ## Current Unity decisions @@ -91,3 +89,46 @@ Do not touch: Local Fusion 2.1.1 source also moves spawned prefabs through the runner scene via the object provider, so root-level runtime `NetworkObject` instances are accepted for Phase B. + +## Current gateway and AI prototype state + +- Cloud Run staging gateway URL: + `https://second-spawn-gateway-535583621422.asia-southeast1.run.app` +- Unity `SecondSpawnConfig.asset`, `SecondSpawnConfig.cs`, and scene + `_AgentGateway` point to the Cloud Run URL so JOY does not need to run a local + gateway executable for prototype playtesting. +- Local fallback remains `backend/gateway` Docker image + `second-spawn-gateway:local`. +- Prototype controls: + - `P`: toggle prototype LLM-agent movement loop on the spawned player + - `O`: send prototype NPC text chat + - `V`: check voice-session contract +- Voice is a local prototype cue plus text bubble in Unity. Real TTS still + requires server-side ephemeral token minting. +- On 2026-05-16, Cloud Run revision `second-spawn-gateway-00003-779` served + 100 percent of traffic. Smoke tests passed for `/readyz`, + `/v1/characters/dev-player/context`, and duplicate memory POST dedupe. +- On 2026-05-16, CoplayDev MCP Play Mode verification spawned a generated + Hammer Warrior visual with a clean console. Prototype agent input moved the + spawned player from `x=1.50` to `x=14.03`, then cleared control. +- On 2026-05-16, `_AgentNPC_Prototype` was added to `ZoneTest_Hub` with a + local prototype brain. MCP Play Mode verification confirmed the NPC patrols, + speaks through the prototype text/voice cue path, and the console had no + warnings or errors after the verification run. + +## Current visual prototype state + +- Random visual variants use generated prefabs under + `Unity/Assets/_SecondSpawn/Art/Characters/GeneratedVisualsV2/`. +- Regenerate them from Unity with + `Second Spawn > Art > Rebuild Generated Visual Prefabs`. +- Generated visual prefabs are standalone cleaned copies with local URP + material copies under + `Unity/Assets/_SecondSpawn/Art/Characters/GeneratedVisualsV2/Materials/`. +- Runtime visual loaders align renderer bounds to the actor ground plane after + Animator pose application. The 2026-05-16 MCP check verified all 13 generated + variants align to `minY=0.000` after the shared bounds alignment pass. +- The runtime pool currently includes RPG Character plus Warrior Pack Bundle + 1-3 variants, including Sorceress and Mage after URP material conversion. + `Fighter Pack Bundle FREE` variants are excluded because Unity 6.5 logs + pre-2019 serialized-file errors when loading their old controllers/materials. diff --git a/docs/setup/fusion-install.md b/docs/setup/fusion-install.md index 19436fc..ec24121 100644 --- a/docs/setup/fusion-install.md +++ b/docs/setup/fusion-install.md @@ -65,7 +65,7 @@ The ScriptableObject definition was scaffolded in commit `f04aa3b` but the `.ass 3. Save the new asset at `Assets/_SecondSpawn/Settings/SecondSpawnConfig.asset`. 4. Set the fields in Inspector: - **Environment**: Development - - **GatewayBaseUrl**: `http://localhost:8080` (for now) + - **GatewayBaseUrl**: `https://second-spawn-gateway-535583621422.asia-southeast1.run.app` (Cloud Run staging) - **SupabaseUrl**: from Step 7 in NEXT_STEPS.md (once Supabase project exists) - **SupabaseAnonKey**: same - **PhotonAppId**: from Step 1 diff --git a/docs/setup/game-gateway-cloud-run.md b/docs/setup/game-gateway-cloud-run.md new file mode 100644 index 0000000..8221913 --- /dev/null +++ b/docs/setup/game-gateway-cloud-run.md @@ -0,0 +1,114 @@ +# Prototype LLM Gateway Cloud Run Deployment + +*Last Verified: 2026-05-16 against Google Cloud Run container deployment docs* + +The SECOND SPAWN prototype LLM gateway runs as a containerized HTTPS service, not +as a local executable on JOY's workstation. Unity calls the public gateway URL, +while provider API keys and Supabase server secrets stay in Cloud Run +environment secrets. + +This is not the game backend. Nakama is the game backend. Production LLM and AI +endpoints should move to the shared `api.dos.ai` Go gateway when ready. + +## Why Cloud Run + +- Runs the existing `backend/gateway/Dockerfile` without changing the Go app. +- Autoscaling to zero is fine for prototype traffic. +- Keeps LLM, voice, and Supabase service credentials server-side. +- Avoids local Windows executable approvals during Unity playtesting. +- Leaves room to move the same image to a VPS or Kubernetes later. + +Vercel is useful for web frontends and lightweight serverless APIs, but Cloud Run +is the better default for this Go gateway because it uses the production +container directly. + +## Runtime Contract + +Cloud Run injects `PORT` into the container. The gateway now listens in this +order: + +1. `GATEWAY_LISTEN_ADDR`, when explicitly set. +2. `PORT`, when Cloud Run injects it. +3. `:8090`, for local Docker and Unity editor development. + +Keep `localhost:8080` reserved for CoplayDev MCP for Unity. + +## Current Prototype Endpoint + +The first staging revision is deployed here: + +```text +https://second-spawn-gateway-535583621422.asia-southeast1.run.app +``` + +This URL is safe to store in Unity because it is only a public gateway endpoint, +not a secret. It currently runs prototype in-memory character context, prototype +agent decisions, text NPC chat, and the voice-session contract. + +## First Deploy + +Run from the repo root after `gcloud auth login` and project selection: + +```bash +gcloud run deploy second-spawn-gateway \ + --source backend/gateway \ + --region asia-southeast1 \ + --allow-unauthenticated \ + --env-vars-file backend/gateway/deploy/cloudrun.env.yaml \ + --set-secrets SUPABASE_JWT_SECRET=second-spawn-supabase-jwt-secret:latest,SUPABASE_SERVICE_ROLE_KEY=second-spawn-supabase-service-role-key:latest,ANTHROPIC_API_KEY=second-spawn-anthropic-api-key:latest,OPENAI_API_KEY=second-spawn-openai-api-key:latest,CONVAI_API_KEY=second-spawn-convai-api-key:latest +``` + +`--allow-unauthenticated` is intentional for the public game endpoint. Application +auth still happens with Supabase JWTs at the gateway level. Do not put Cloud Run +behind IAM auth for normal game clients. + +## Secret Creation + +Create secrets once, then paste values interactively: + +```bash +gcloud secrets create second-spawn-supabase-jwt-secret --replication-policy automatic +gcloud secrets versions add second-spawn-supabase-jwt-secret --data-file - + +gcloud secrets create second-spawn-supabase-service-role-key --replication-policy automatic +gcloud secrets versions add second-spawn-supabase-service-role-key --data-file - + +gcloud secrets create second-spawn-anthropic-api-key --replication-policy automatic +gcloud secrets versions add second-spawn-anthropic-api-key --data-file - + +gcloud secrets create second-spawn-openai-api-key --replication-policy automatic +gcloud secrets versions add second-spawn-openai-api-key --data-file - + +gcloud secrets create second-spawn-convai-api-key --replication-policy automatic +gcloud secrets versions add second-spawn-convai-api-key --data-file - +``` + +For the current prototype, provider keys can be omitted only if `GATEWAY_ENV` is +not `production`. Production must have real provider credentials. + +## Smoke Test + +After deploy: + +```bash +GATEWAY_URL="$(gcloud run services describe second-spawn-gateway --region asia-southeast1 --format 'value(status.url)')" +curl "$GATEWAY_URL/readyz" +curl "$GATEWAY_URL/v1/characters/dev-player/context" +curl "$GATEWAY_URL/v1/npc/chat" \ + -H "Content-Type: application/json" \ + --data '{"player_id":"dev-player","npc_id":"prototype-guide","message":"Can you remember me?"}' +``` + + +Then set Unity's `SecondSpawnConfig.asset` gateway URL to `GATEWAY_URL` for +cloud-backed playtesting. + +## Security Rules + +- Never commit `.env`, service-role keys, provider API keys, or Supabase JWT + secrets. +- Unity stores only public URLs and public-safe keys. +- LLM output still returns intent. The Fusion server remains the authority for + gameplay state. +- Cloud Run URL is public, but all non-prototype mutating routes must require a + Supabase JWT before vertical slice release. diff --git a/docs/setup/paid-assets.md b/docs/setup/paid-assets.md index 9b28946..fcef925 100644 --- a/docs/setup/paid-assets.md +++ b/docs/setup/paid-assets.md @@ -19,6 +19,7 @@ SECOND SPAWN is a public repository. Paid Unity Asset Store content must stay ou | Asset | Source | Local Path | Git Policy | Current Use | | ---- | ---- | ---- | ---- | ---- | | RPG Character Mecanim Animation Pack | Unity Package Manager > My Assets | `Unity/Assets/ExplosiveLLC/` | Ignored, do not commit raw files | Prototype character and animation library | +| Warrior Pack Bundle 1-3 FREE | Unity Package Manager > My Assets | `Unity/Assets/ExplosiveLLC/` | Ignored, do not commit raw files | Prototype random visual variants | --- @@ -49,3 +50,29 @@ SECOND SPAWN is a public repository. Paid Unity Asset Store content must stay ou - Animation clips render state and intent only. - Disable root motion for networked movement unless a future prototype explicitly proves an authority-safe root-motion workflow. - Keep Animator parameters small and driven from project-owned controller state. +- Use `NetworkAnimatorBridge` on the networked player root to drive locally installed RPG Character Mecanim Animator parameters from replicated KCC movement. +- Do not add `RPGCharacterInputController`, `RPGCharacterInputSystemController`, `RPGCharacterMovementController`, or `SuperCharacterController` to the authoritative networked root. Those scripts are useful references and demo tooling, but SECOND SPAWN movement authority remains Fusion + Simple KCC. +- If a paid visual prefab is used locally, attach it as a child visual under the networked root and keep the committed project prefab usable without the paid asset installed. +- Visual loaders align renderer bounds to the actor ground plane at runtime. Do not fix tall/short character sinking with one-off hardcoded Y offsets unless the asset itself is broken. +- Generated visual prefabs use copied URP materials under `_SecondSpawn/Art/Characters/GeneratedVisualsV2/Materials`. Do not rely on Standard/Built-in shader materials at runtime. + +--- + +## Generated Visual Prefabs + +Runtime prototype visuals use generated project-owned prefabs under: + +`Unity/Assets/_SecondSpawn/Art/Characters/GeneratedVisualsV2/` + +These prefabs are generated from locally installed ExplosiveLLC character prefabs, then stripped of vendor/demo `MonoBehaviour`, `NavMeshAgent`, `CharacterController`, collider, joint, and rigidbody components. They keep the model hierarchy, weapons, Animator, and meshes while replacing vendor materials with local URP material copies. Fusion + Simple KCC remain the only movement authority. + +To rebuild them in the Unity Editor: + +1. Ensure the local ExplosiveLLC assets are imported. +2. Run `Second Spawn > Art > Rebuild Generated Visual Prefabs`. +3. Enter Play Mode and confirm the console is clean. + +Current notes: + +- The older `Fighter Pack Bundle FREE` prefabs are not in the runtime random pool because Unity 6.5 logs pre-2019 serialized-file errors when loading their controllers/materials. They can be reconsidered after local re-save or replacement with newer assets. +- `Sorceress Warrior` and `Mage Warrior` are allowed in the random pool after the generated prefab pass converts their source materials to URP material copies. diff --git a/docs/setup/unity-conventions.md b/docs/setup/unity-conventions.md index 522d1ba..cec4f33 100644 --- a/docs/setup/unity-conventions.md +++ b/docs/setup/unity-conventions.md @@ -155,8 +155,8 @@ Phase B networking note (2026-05-14): `_NetworkBootstrap` is intentionally a sce Cross-reference `.claude/CLAUDE.md` and `AGENTS.md` Hard Rules: -- **Hard Rule #2**: NEVER let LLM mutate authoritative game state. Server validates all intent. Code-level: every NPC dialogue path MUST go through `backend/gateway/internal/intent/intent.go` validation before any `[Networked]` field is written. -- **Hard Rule #3**: NEVER put API keys in Unity client. Code-level: `SecondSpawnConfig` only stores public-safe values (gateway URL, Supabase anon key, Photon App ID, DOS Chain RPC). Secrets stay in `backend/gateway/.env`. +- **Hard Rule #2**: NEVER let LLM mutate authoritative game state. Server validates all intent. Code-level: every NPC dialogue path MUST pass through server-side intent validation before any `[Networked]` field is written. +- **Hard Rule #3**: NEVER put API keys in Unity client. Code-level: `SecondSpawnConfig` only stores public-safe values (Nakama endpoint, LLM gateway URL, Photon App ID, DOS Chain RPC). Secrets stay in server environments. - **Hard Rule #4**: NEVER use Host Mode for production. Code-level: `NetworkRunnerSetup.cs` selects mode by `Application.isBatchMode`. CI build flag `-batchmode -nographics -server` enforces. - **Hard Rule #6**: NEVER change Asset Serialization away from Force Text. Pin in `ProjectSettings/EditorSettings.asset` (`m_SerializationMode: 2`). - **Hard Rule #7**: NEVER claim "done" without reviewer pass. Per PR template `.github/pull_request_template.md`. From d97590385fa35628ae23b41de1b9dbb9d9a0b779 Mon Sep 17 00:00:00 2001 From: JOY Date: Sat, 16 May 2026 14:33:57 +0700 Subject: [PATCH 12/14] ci: allow gateway without go.sum --- .github/workflows/backend-test.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/backend-test.yml b/.github/workflows/backend-test.yml index e011872..b37e016 100644 --- a/.github/workflows/backend-test.yml +++ b/.github/workflows/backend-test.yml @@ -31,7 +31,11 @@ jobs: - name: Verify go.mod is tidy run: | go mod tidy - git diff --exit-code go.mod go.sum + if [ -f go.sum ]; then + git diff --exit-code -- go.mod go.sum + else + git diff --exit-code -- go.mod + fi - name: Vet run: go vet ./... From 96a446f3cab4b46c470470dda44a12cfa6f1726c Mon Sep 17 00:00:00 2001 From: JOY Date: Sat, 16 May 2026 14:42:04 +0700 Subject: [PATCH 13/14] fix(review): address agent authority and parser issues --- .../Scripts/AI/SecondSpawnGatewayClient.cs | 20 ++++++++------- .../Networking/NetworkAnimatorBridge.cs | 25 +++---------------- .../Scripts/Networking/NetworkPlayer.cs | 21 ++++++++++------ backend/nakama/modules/index.ts | 24 ++++++++++++++++-- 4 files changed, 49 insertions(+), 41 deletions(-) diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs index c777558..64f668b 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs @@ -431,16 +431,12 @@ private static string ExtractJwtStringClaim(string jwt, string claimName) } var json = Encoding.UTF8.GetString(Convert.FromBase64String(payload)); - var marker = $"\"{claimName}\":\""; - var start = json.IndexOf(marker, StringComparison.Ordinal); - if (start < 0) + var claims = JsonUtility.FromJson(json); + return claimName switch { - return ""; - } - - start += marker.Length; - var end = json.IndexOf('"', start); - return end > start ? json.Substring(start, end - start) : ""; + "uid" => claims?.uid ?? "", + _ => "" + }; } catch (Exception) { @@ -503,5 +499,11 @@ private sealed class NakamaSessionDto public string token; public string refresh_token; } + + [Serializable] + private sealed class NakamaJwtClaimsDto + { + public string uid; + } } } diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkAnimatorBridge.cs b/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkAnimatorBridge.cs index 0ce32c6..f34011c 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkAnimatorBridge.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkAnimatorBridge.cs @@ -68,8 +68,6 @@ public sealed class NetworkAnimatorBridge : NetworkBehaviour [SerializeField, Tooltip("Vertical speed threshold that switches the visual from jump to fall. Positive values start the fall blend just before the physical apex to avoid a held jump pose.")] private float _fallVelocityThreshold = 0.8f; - private Vector3 _previousPosition; - private bool _hasPreviousPosition; private bool _hasMovingParameter; private bool _hasVelocityXParameter; private bool _hasVelocityZParameter; @@ -93,20 +91,6 @@ public sealed class NetworkAnimatorBridge : NetworkBehaviour private void Awake() { ResolveAnimator(); - _previousPosition = transform.position; - _hasPreviousPosition = true; - } - - private void OnEnable() - { - _previousPosition = transform.position; - _hasPreviousPosition = true; - } - - public override void Spawned() - { - _previousPosition = transform.position; - _hasPreviousPosition = true; } public override void FixedUpdateNetwork() @@ -116,17 +100,14 @@ public override void FixedUpdateNetwork() return; } - var deltaTime = Runner != null ? Runner.DeltaTime : Time.fixedDeltaTime; - if (!_hasPreviousPosition || deltaTime <= 0f) + _kcc ??= GetComponent(); + if (_kcc == null) { - _previousPosition = transform.position; - _hasPreviousPosition = true; return; } - var worldVelocity = (transform.position - _previousPosition) / deltaTime; + var worldVelocity = _kcc.RealVelocity; worldVelocity.y = 0f; - _previousPosition = transform.position; var localVelocity = transform.InverseTransformDirection(worldVelocity); var referenceMoveSpeed = Mathf.Max(0.01f, _referenceMoveSpeed); diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkPlayer.cs b/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkPlayer.cs index 4e0a24e..e612e64 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkPlayer.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkPlayer.cs @@ -102,22 +102,27 @@ public override void FixedUpdateNetwork() public void SetPrototypeAgentInput(NetworkInputData input) { - _prototypeAgentInput = input; - _hasPrototypeAgentInput = true; - if (HasStateAuthority) + if (!HasStateAuthority) { - IsAgentControlled = true; + Debug.LogWarning("[NetworkPlayer] Ignored prototype agent input on a non-authoritative player. Offline agents must be driven by the server/state authority."); + return; } + + _prototypeAgentInput = input; + _hasPrototypeAgentInput = true; + IsAgentControlled = true; } public void ClearPrototypeAgentInput() { - _prototypeAgentInput = default; - _hasPrototypeAgentInput = false; - if (HasStateAuthority) + if (!HasStateAuthority) { - IsAgentControlled = false; + return; } + + _prototypeAgentInput = default; + _hasPrototypeAgentInput = false; + IsAgentControlled = false; } private bool TryGetAuthoritativeInput(out NetworkInputData input) diff --git a/backend/nakama/modules/index.ts b/backend/nakama/modules/index.ts index 76f7f42..9f78025 100644 --- a/backend/nakama/modules/index.ts +++ b/backend/nakama/modules/index.ts @@ -66,7 +66,7 @@ function rpcMemoryAdd( } memory.importance = clampNumber(memory.importance || 5, 1, 10); if (!memory.id) { - memory.id = "mem-" + nowId() + "-" + (context.body.memory.length + 1); + memory.id = newMemoryId(context); } upsertMemory(context, memory); @@ -178,7 +178,12 @@ function beforeAuthenticateCustom( return null; } - var body = JSON.parse(response.body); + var body = parseJsonOrNull(response.body); + if (!body) { + logger.error("Supabase Auth returned invalid JSON"); + return null; + } + if (!body.id) { logger.error("Supabase Auth returned invalid user payload"); return null; @@ -402,6 +407,21 @@ function parseJson(payload: string, label: string): any { } } +function parseJsonOrNull(payload: string): any { + try { + return JSON.parse(payload || "{}"); + } catch (err) { + return null; + } +} + +function newMemoryId(context: any): string { + var playerId = sanitizeNakamaIdentifier(context.player.player_id || "player", "player"); + var randomPart = Math.floor(Math.random() * 0x100000000).toString(36); + var sequence = String((context.body.memory || []).length + 1); + return "mem-" + playerId + "-" + nowId() + "-" + randomPart + "-" + sequence; +} + function requireUserId(ctx: nkruntime.Context): string { var userId = trimString(ctx.userId); if (!userId) { From 6bf29cebfa0ecb91bb392f18613c0761e51eab42 Mon Sep 17 00:00:00 2001 From: JOY Date: Sat, 16 May 2026 15:12:54 +0700 Subject: [PATCH 14/14] fix(review): tighten prototype agent authority gate --- Unity/Assets/_SecondSpawn/Art.meta | 6 +++--- Unity/Assets/_SecondSpawn/Art/Animations.meta | 6 +++--- Unity/Assets/_SecondSpawn/Art/Characters.meta | 6 +++--- Unity/Assets/_SecondSpawn/Art/Materials.meta | 6 +++--- .../_SecondSpawn/Art/Materials/PrototypeGround.mat | 4 ++-- .../Art/Materials/PrototypeGround.mat.meta | 6 +++--- Unity/Assets/_SecondSpawn/Art/Models.meta | 6 +++--- Unity/Assets/_SecondSpawn/Art/Textures.meta | 6 +++--- Unity/Assets/_SecondSpawn/Audio.meta | 6 +++--- Unity/Assets/_SecondSpawn/Editor.meta | 6 +++--- .../_SecondSpawn/Scripts/AI/CharacterMemorySync.cs | 2 +- .../Scripts/AI/PrototypeLLMAgentDriver.cs | 11 +++++++++++ .../_SecondSpawn/Scripts/AI/PrototypeSpeechBubble.cs | 1 - .../Scripts/AI/PrototypeSpeechBubble.cs.meta | 7 +++---- .../_SecondSpawn/Scripts/AI/PrototypeVoiceCue.cs | 1 - .../_SecondSpawn/Scripts/AI/PrototypeVoiceCue.cs.meta | 7 +++---- .../Scripts/Networking/AnimationEventReceiver.cs.meta | 6 +++--- .../Networking/LocalVisualPrefabLoader.cs.meta | 6 +++--- .../Scripts/Networking/NetworkAnimatorBridge.cs.meta | 6 +++--- .../Networking/PrototypeVisualActionHotkeys.cs.meta | 6 +++--- .../Scripts/Networking/TopDownCameraFollow.cs.meta | 6 +++--- .../Networking/VisualAnimationIntentDriver.cs.meta | 6 +++--- backend/gateway/deploy/cloudrun.env.yaml | 1 - 23 files changed, 65 insertions(+), 59 deletions(-) diff --git a/Unity/Assets/_SecondSpawn/Art.meta b/Unity/Assets/_SecondSpawn/Art.meta index 5c42a1a..ff4f723 100644 --- a/Unity/Assets/_SecondSpawn/Art.meta +++ b/Unity/Assets/_SecondSpawn/Art.meta @@ -3,6 +3,6 @@ guid: 818f5cf6ac136c44b9e86948000feb16 folderAsset: yes DefaultImporter: externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/_SecondSpawn/Art/Animations.meta b/Unity/Assets/_SecondSpawn/Art/Animations.meta index 972bcb0..ccdfb11 100644 --- a/Unity/Assets/_SecondSpawn/Art/Animations.meta +++ b/Unity/Assets/_SecondSpawn/Art/Animations.meta @@ -3,6 +3,6 @@ guid: ce8bd7e29f2d5b64682b39ba596b3016 folderAsset: yes DefaultImporter: externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/_SecondSpawn/Art/Characters.meta b/Unity/Assets/_SecondSpawn/Art/Characters.meta index 16ae184..207720e 100644 --- a/Unity/Assets/_SecondSpawn/Art/Characters.meta +++ b/Unity/Assets/_SecondSpawn/Art/Characters.meta @@ -3,6 +3,6 @@ guid: 55e1af318617ea944bc1d74ae79b1a68 folderAsset: yes DefaultImporter: externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/_SecondSpawn/Art/Materials.meta b/Unity/Assets/_SecondSpawn/Art/Materials.meta index a126936..ca63956 100644 --- a/Unity/Assets/_SecondSpawn/Art/Materials.meta +++ b/Unity/Assets/_SecondSpawn/Art/Materials.meta @@ -3,6 +3,6 @@ guid: 1893fb3679d534945abc7d4f3bd512c8 folderAsset: yes DefaultImporter: externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/_SecondSpawn/Art/Materials/PrototypeGround.mat b/Unity/Assets/_SecondSpawn/Art/Materials/PrototypeGround.mat index 7baf099..b58534c 100644 --- a/Unity/Assets/_SecondSpawn/Art/Materials/PrototypeGround.mat +++ b/Unity/Assets/_SecondSpawn/Art/Materials/PrototypeGround.mat @@ -10,7 +10,7 @@ MonoBehaviour: m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: d0353a89b1f911e48b9e16bdc9f2e058, type: 3} - m_Name: + m_Name: m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Editor::UnityEditor.Rendering.Universal.AssetVersion version: 10 --- !u!21 &2100000 @@ -34,7 +34,7 @@ Material: RenderType: Opaque disabledShaderPasses: - MOTIONVECTORS - m_LockedProperties: + m_LockedProperties: m_SavedProperties: serializedVersion: 3 m_TexEnvs: diff --git a/Unity/Assets/_SecondSpawn/Art/Materials/PrototypeGround.mat.meta b/Unity/Assets/_SecondSpawn/Art/Materials/PrototypeGround.mat.meta index c5e32d2..77d360c 100644 --- a/Unity/Assets/_SecondSpawn/Art/Materials/PrototypeGround.mat.meta +++ b/Unity/Assets/_SecondSpawn/Art/Materials/PrototypeGround.mat.meta @@ -3,6 +3,6 @@ guid: e43486906d22c3b44b3ec001b79e0669 NativeFormatImporter: externalObjects: {} mainObjectFileID: 2100000 - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/_SecondSpawn/Art/Models.meta b/Unity/Assets/_SecondSpawn/Art/Models.meta index cc39f51..7ba68ab 100644 --- a/Unity/Assets/_SecondSpawn/Art/Models.meta +++ b/Unity/Assets/_SecondSpawn/Art/Models.meta @@ -3,6 +3,6 @@ guid: 7bc8bb14c5628b54caabf824b36e6fbb folderAsset: yes DefaultImporter: externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/_SecondSpawn/Art/Textures.meta b/Unity/Assets/_SecondSpawn/Art/Textures.meta index 72baa56..23015d5 100644 --- a/Unity/Assets/_SecondSpawn/Art/Textures.meta +++ b/Unity/Assets/_SecondSpawn/Art/Textures.meta @@ -3,6 +3,6 @@ guid: 5b5a86a88a953ba4fb1ae7ae62f7aba4 folderAsset: yes DefaultImporter: externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/_SecondSpawn/Audio.meta b/Unity/Assets/_SecondSpawn/Audio.meta index 1ff119e..9853e8b 100644 --- a/Unity/Assets/_SecondSpawn/Audio.meta +++ b/Unity/Assets/_SecondSpawn/Audio.meta @@ -3,6 +3,6 @@ guid: b7ed7ee527dc8c84780d20fb8a849c55 folderAsset: yes DefaultImporter: externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/_SecondSpawn/Editor.meta b/Unity/Assets/_SecondSpawn/Editor.meta index e466d99..0f6ce3f 100644 --- a/Unity/Assets/_SecondSpawn/Editor.meta +++ b/Unity/Assets/_SecondSpawn/Editor.meta @@ -3,6 +3,6 @@ guid: bf71f2ee61d512a42b1e9888aa748eef folderAsset: yes DefaultImporter: externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs index 6a7fc5c..3d03685 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs @@ -126,7 +126,7 @@ private IEnumerator ApplyProfileEquipmentWhenAvailable() private static bool TryApplyProfileEquipment(int equipmentVisualId) { - var players = Object.FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None); + var players = Object.FindObjectsByType(FindObjectsInactive.Exclude); foreach (var player in players) { if (!IsLocalAuthoritativePlayer(player)) diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeLLMAgentDriver.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeLLMAgentDriver.cs index d52eec3..f755b49 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeLLMAgentDriver.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeLLMAgentDriver.cs @@ -66,6 +66,12 @@ private void Update() public void StartAgent() { + if (!CanDrivePrototypeAgent()) + { + Debug.LogWarning("[PrototypeLLMAgentDriver] Ignored prototype agent start on a non-authoritative player. Offline agents must run on the server/state authority."); + return; + } + if (_gateway == null) { Debug.LogWarning("[PrototypeLLMAgentDriver] No SecondSpawnGatewayClient found in scene."); @@ -113,6 +119,11 @@ private IEnumerator DecisionLoop() } } + private bool CanDrivePrototypeAgent() + { + return _networkPlayer != null && _networkPlayer.HasStateAuthority; + } + private AgentDecisionRequestDto BuildDecisionRequest() { var position = transform.position; diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeSpeechBubble.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeSpeechBubble.cs index 98e702b..032c81a 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeSpeechBubble.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeSpeechBubble.cs @@ -86,4 +86,3 @@ private static string Clamp(string text) } } } - diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeSpeechBubble.cs.meta b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeSpeechBubble.cs.meta index 933d25e..d47cd07 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeSpeechBubble.cs.meta +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeSpeechBubble.cs.meta @@ -6,7 +6,6 @@ MonoImporter: defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: - + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeVoiceCue.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeVoiceCue.cs index bb564a5..feb9465 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeVoiceCue.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeVoiceCue.cs @@ -56,4 +56,3 @@ private static AudioClip BuildCue(float duration) } } } - diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeVoiceCue.cs.meta b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeVoiceCue.cs.meta index c74260c..a529272 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeVoiceCue.cs.meta +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeVoiceCue.cs.meta @@ -6,7 +6,6 @@ MonoImporter: defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: - + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/AnimationEventReceiver.cs.meta b/Unity/Assets/_SecondSpawn/Scripts/Networking/AnimationEventReceiver.cs.meta index 431afc1..77593ce 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/Networking/AnimationEventReceiver.cs.meta +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/AnimationEventReceiver.cs.meta @@ -6,6 +6,6 @@ MonoImporter: defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/LocalVisualPrefabLoader.cs.meta b/Unity/Assets/_SecondSpawn/Scripts/Networking/LocalVisualPrefabLoader.cs.meta index 6427178..ae2adda 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/Networking/LocalVisualPrefabLoader.cs.meta +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/LocalVisualPrefabLoader.cs.meta @@ -6,6 +6,6 @@ MonoImporter: defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkAnimatorBridge.cs.meta b/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkAnimatorBridge.cs.meta index 3e08fb0..16ade14 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkAnimatorBridge.cs.meta +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkAnimatorBridge.cs.meta @@ -6,6 +6,6 @@ MonoImporter: defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/PrototypeVisualActionHotkeys.cs.meta b/Unity/Assets/_SecondSpawn/Scripts/Networking/PrototypeVisualActionHotkeys.cs.meta index b856dde..d636c29 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/Networking/PrototypeVisualActionHotkeys.cs.meta +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/PrototypeVisualActionHotkeys.cs.meta @@ -6,6 +6,6 @@ MonoImporter: defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/TopDownCameraFollow.cs.meta b/Unity/Assets/_SecondSpawn/Scripts/Networking/TopDownCameraFollow.cs.meta index 58bf109..714b16b 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/Networking/TopDownCameraFollow.cs.meta +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/TopDownCameraFollow.cs.meta @@ -6,6 +6,6 @@ MonoImporter: defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/VisualAnimationIntentDriver.cs.meta b/Unity/Assets/_SecondSpawn/Scripts/Networking/VisualAnimationIntentDriver.cs.meta index 8db81c1..515c865 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/Networking/VisualAnimationIntentDriver.cs.meta +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/VisualAnimationIntentDriver.cs.meta @@ -6,6 +6,6 @@ MonoImporter: defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: diff --git a/backend/gateway/deploy/cloudrun.env.yaml b/backend/gateway/deploy/cloudrun.env.yaml index 44d313b..ab94461 100644 --- a/backend/gateway/deploy/cloudrun.env.yaml +++ b/backend/gateway/deploy/cloudrun.env.yaml @@ -1,4 +1,3 @@ GATEWAY_ENV: staging LLM_RATE_LIMIT_PER_PLAYER_PER_MIN: "30" LLM_TOKEN_BUDGET_PER_PLAYER_DAY: "50000" -