From ebbd27091a3e025cebd794d54853d509c4603bda Mon Sep 17 00:00:00 2001 From: JOY Date: Sun, 17 May 2026 08:09:52 +0700 Subject: [PATCH] feat(unity): add BodyTime reincarnation debug loop --- ROADMAP.md | 17 +- .../_SecondSpawn/Scenes/ZoneTest_Hub.unity | 57 ++++++ .../Scripts/AI/CharacterMemorySync.cs | 60 +++++- .../AI/PrototypeBodyLifecycleDebugPanel.cs | 174 ++++++++++++++++++ .../PrototypeBodyLifecycleDebugPanel.cs.meta | 2 + .../Scripts/Networking/NetworkPlayer.cs | 32 +++- .../_SecondSpawn/Scripts/UI/HUDController.cs | 6 +- backend/nakama/README.md | 12 ++ backend/nakama/local.example.yml | 1 + backend/nakama/modules/index.ts | 25 ++- .../tests/supabase_custom_auth.test.mjs | 40 ++++ docs/design/12-game-design-document.md | 7 + 12 files changed, 415 insertions(+), 18 deletions(-) create mode 100644 Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeBodyLifecycleDebugPanel.cs create mode 100644 Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeBodyLifecycleDebugPanel.cs.meta diff --git a/ROADMAP.md b/ROADMAP.md index 45855cd..feccf03 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -40,9 +40,13 @@ especially `docs/design/02-vertical-slice-spec.md` and ## Current Review Gate - [x] Merge PR #5: model-backed agent decisions and brain phase logging. -- [ ] Review the profile bootstrap and agent activity branch. -- [ ] Merge the profile bootstrap and agent activity branch into `dev` after +- [x] Review the profile bootstrap and agent activity branch. +- [x] Merge the profile bootstrap and agent activity branch into `dev` after backend tests, Unity compile, and reviewer verification. +- [x] Merge BodyTime event flow into `dev`. +- [x] Defer cultivation/Nibirium runtime progression from the current vertical + slice. +- [x] Merge reincarnation placeholder flow into `dev`. ## Vertical Slice - Current Milestone @@ -67,9 +71,14 @@ MVP, and a visible offline-agent prototype. - [ ] Persist gateway-side prototype context or remove in-memory fallback once Nakama is the only source of durable game profile truth. - [ ] Wire Convai phase 1 NPC dialogue through the server-side intent boundary. -- [ ] Add BodyTime meter MVP with one earn source and one spend sink. -- [ ] Add reincarnation placeholder flow: death -> SECOND token check -> +- [x] Add BodyTime meter MVP with one earn source and one spend sink. +- [x] Add reincarnation placeholder flow: death -> SECOND token check -> respawn with current-body reset. +- [ ] Surface BodyTime, lifecycle, SECOND balance, reincarnation count, and + debug reincarnation controls in the Unity prototype. +- [ ] Design server-authoritative PvP or contested-zone loot rules where + BodyTime and SECOND can be taken from other users after validated combat or + zone events. Clients and LLMs must never self-report this loot. - [ ] Add one dungeon instance with one boss and grounded dialogue. - [ ] Add one Hunter NFT skin equip placeholder with DOS Chain escrow design still server-authoritative. diff --git a/Unity/Assets/_SecondSpawn/Scenes/ZoneTest_Hub.unity b/Unity/Assets/_SecondSpawn/Scenes/ZoneTest_Hub.unity index d080741..36bcd05 100644 --- a/Unity/Assets/_SecondSpawn/Scenes/ZoneTest_Hub.unity +++ b/Unity/Assets/_SecondSpawn/Scenes/ZoneTest_Hub.unity @@ -131,6 +131,7 @@ GameObject: - component: {fileID: 34500817} - component: {fileID: 34500816} - component: {fileID: 34500815} + - component: {fileID: 34500819} m_Layer: 0 m_Name: _AgentGateway m_TagString: Untagged @@ -167,7 +168,10 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: SecondSpawn.AI::SecondSpawn.AI.CharacterMemorySync _syncOnStart: 1 + _preferNakama: 1 _seedPrototypeMemory: 1 + _applyProfileStatsToLocalPlayer: 1 + _applyProfileEquipmentToLocalPlayer: 1 _prototypeMemory: JOY wants overnight prototype progress without client-side LLM secrets. --- !u!114 &34500817 @@ -184,6 +188,14 @@ MonoBehaviour: m_EditorClassIdentifier: SecondSpawn.AI::SecondSpawn.AI.SecondSpawnGatewayClient _gatewayBaseUrl: https://second-spawn-gateway-535583621422.asia-southeast1.run.app _playerId: dev-player + _authenticateOnStart: 1 + _supabaseUrl: + _supabaseAnonKey: + _nakamaBaseUrl: http://127.0.0.1:7350 + _nakamaServerKey: defaultkey + _allowNakamaDeviceFallback: 1 + _bootstrapProfileAfterAuth: 1 + _requestTimeoutSeconds: 10 --- !u!4 &34500818 Transform: m_ObjectHideFlags: 0 @@ -199,6 +211,24 @@ Transform: m_Children: [] m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &34500819 +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: 47e3e1f6f17c4f5a87dbd4b0c70b321d, type: 3} + m_Name: + m_EditorClassIdentifier: SecondSpawn.AI::SecondSpawn.AI.PrototypeBodyLifecycleDebugPanel + _showPanel: 1 + _panelPosition: {x: 16, y: 212} + _panelSize: {x: 320, y: 196} + _earnSeconds: 300 + _spendSeconds: 600 + _dangerDrainSeconds: 300 --- !u!1 &47583031 GameObject: m_ObjectHideFlags: 0 @@ -552,6 +582,7 @@ GameObject: m_Component: - component: {fileID: 529322320} - component: {fileID: 529322319} + - component: {fileID: 529322321} m_Layer: 0 m_Name: _AgentNPC_Prototype m_TagString: Untagged @@ -582,6 +613,8 @@ MonoBehaviour: _talkIntervalSeconds: 7.5 _seedSoulOnStart: 1 _alignFeetToGround: 1 + _logPhaseTransitions: 1 + _gatewayFailureErrorThreshold: 3 --- !u!4 &529322320 Transform: m_ObjectHideFlags: 0 @@ -597,6 +630,30 @@ Transform: m_Children: [] m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &529322321 +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: 2bb44b8edfbf4b41a928f752d071c948, type: 3} + m_Name: + m_EditorClassIdentifier: SecondSpawn.AI::SecondSpawn.AI.ActorProfileBinder + _loadOnStart: 1 + _actorId: prototype-npc-guide + _actorType: npc + _displayName: Prototype Guide + _archetypeId: prototype-npc + _visualPrefabKey: prototype-npc + _applyDisplayNameToGameObject: 1 + _seedMemoryOnStart: 1 + _seedMemoryKind: system + _seedMemorySummary: Prototype Guide is a hub NPC body with its own actor profile, + memory, stats, traits, soul, and policy. + _seedMemoryImportance: 6 --- !u!1 &584568219 GameObject: m_ObjectHideFlags: 0 diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs index d67c827..64e690c 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs @@ -74,6 +74,53 @@ public IEnumerator Refresh() yield return ApplyProfileToLocalPlayerWhenAvailable(); } + public IEnumerator ApplyBodyTimeEvent(BodyTimeEventRequestDto request) + { + if (!_preferNakama || !_gateway.HasNakamaSession) + { + Debug.LogWarning("[CharacterMemorySync] BodyTime events require an authenticated Nakama session."); + yield break; + } + + AgentContextDto context = null; + string error = null; + yield return _gateway.ApplyNakamaBodyTimeEvent(request, value => context = value, value => error = value); + if (context == null) + { + Debug.LogWarning($"[CharacterMemorySync] BodyTime event failed: {error}"); + yield break; + } + + _context = context; + yield return ApplyProfileToLocalPlayerWhenAvailable(); + } + + public IEnumerator ReincarnateCurrentBody(string reason) + { + if (!_preferNakama || !_gateway.HasNakamaSession) + { + Debug.LogWarning("[CharacterMemorySync] Reincarnation requires an authenticated Nakama session."); + yield break; + } + + AgentContextDto context = null; + string error = null; + yield return _gateway.ReincarnateNakamaBody(new ReincarnationRequestDto + { + id = BuildClientEventId("reincarnation"), + reason = string.IsNullOrWhiteSpace(reason) ? "Unity prototype debug reincarnation." : reason.Trim() + }, value => context = value, value => error = value); + + if (context == null) + { + Debug.LogWarning($"[CharacterMemorySync] Reincarnation failed: {error}"); + yield break; + } + + _context = context; + yield return ApplyProfileToLocalPlayerWhenAvailable(); + } + private IEnumerator WaitForAuthAttempt() { const float maxWaitSeconds = 10f; @@ -153,10 +200,11 @@ private bool TryApplyProfileBody(BodyProfileDto body) return false; } - private static void ApplyStats(NetworkPlayer player, BodyProfileDto body) + private void ApplyStats(NetworkPlayer player, BodyProfileDto body) { var stats = body.stats ?? new CharacterStatsDto(); var time = body.time ?? new BodyTimeDto(); + var account = _context?.player ?? new PlayerProfileDto(); player.ApplyProfileStats( stats.level, stats.vitality, @@ -170,7 +218,15 @@ private static void ApplyStats(NetworkPlayer player, BodyProfileDto body) stats.defense_power, ToNetworkSeconds(time.remaining_seconds), ToNetworkSeconds(time.max_seconds), - ToNetworkSeconds(time.danger_drain_rate)); + ToNetworkSeconds(time.danger_drain_rate), + body.lifecycle, + ToNetworkSeconds(account.second_balance_seconds), + ToNetworkSeconds(account.reincarnation_count)); + } + + public static string BuildClientEventId(string prefix) + { + return $"{prefix}-{System.Guid.NewGuid():N}"; } private static void ApplyEquipment(NetworkPlayer player, BodyProfileDto body) diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeBodyLifecycleDebugPanel.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeBodyLifecycleDebugPanel.cs new file mode 100644 index 0000000..9eb5ced --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeBodyLifecycleDebugPanel.cs @@ -0,0 +1,174 @@ +using System.Collections; +using UnityEngine; + +namespace SecondSpawn.AI +{ + /// + /// Prototype-only IMGUI controls for exercising the BodyTime death and + /// reincarnation loop against Nakama during Play Mode. + /// + [DisallowMultipleComponent] + [RequireComponent(typeof(CharacterMemorySync))] + public sealed class PrototypeBodyLifecycleDebugPanel : MonoBehaviour + { + private const string EarnSource = "prototype_safe_farming"; + private const string SpendSource = "prototype_service"; + private const string DrainSource = "danger_zone_tick"; + private const string DebugFatalDrainSource = "prototype_reincarnation_debug"; + + [SerializeField] private bool _showPanel = true; + [SerializeField] private Vector2 _panelPosition = new Vector2(16f, 212f); + [SerializeField] private Vector2 _panelSize = new Vector2(320f, 196f); + [SerializeField] private long _earnSeconds = 300; + [SerializeField] private long _spendSeconds = 600; + [SerializeField] private long _dangerDrainSeconds = 300; + + private CharacterMemorySync _memorySync; + private GUIStyle _labelStyle; + private bool _busy; + private string _status = "Ready"; + + private void Awake() + { + _memorySync = GetComponent(); + } + + private void OnGUI() + { + if (!_showPanel) + { + return; + } + + _labelStyle ??= new GUIStyle(GUI.skin.label) + { + fontSize = 13, + normal = { textColor = Color.white } + }; + + var context = _memorySync != null ? _memorySync.Context : null; + var rect = new Rect(_panelPosition.x, _panelPosition.y, _panelSize.x, _panelSize.y); + GUI.Box(rect, "Body Lifecycle Debug"); + GUILayout.BeginArea(new Rect(rect.x + 12f, rect.y + 24f, rect.width - 24f, rect.height - 32f)); + GUILayout.Label(BuildContextLine(context), _labelStyle); + GUILayout.Label(_status, _labelStyle); + + GUI.enabled = !_busy; + if (GUILayout.Button("Refresh Profile")) + { + StartOperation(_memorySync.Refresh(), "Refresh"); + } + + GUILayout.BeginHorizontal(); + if (GUILayout.Button($"+{FormatSeconds(_earnSeconds)}")) + { + StartBodyTimeEvent("earn", EarnSource, _earnSeconds, "Prototype safe farming reward."); + } + + if (GUILayout.Button($"-{FormatSeconds(_spendSeconds)}")) + { + StartBodyTimeEvent("spend", SpendSource, _spendSeconds, "Prototype service spend."); + } + + if (GUILayout.Button($"Drain {FormatSeconds(_dangerDrainSeconds)}")) + { + StartBodyTimeEvent("drain", DrainSource, _dangerDrainSeconds, "Prototype danger tick."); + } + GUILayout.EndHorizontal(); + + GUILayout.BeginHorizontal(); + if (GUILayout.Button("Force Zero Time")) + { + var remaining = context?.body?.time?.remaining_seconds ?? 1; + StartBodyTimeEvent("drain", DebugFatalDrainSource, Mathf.Max(1, ToIntSeconds(remaining)), "Debug fatal drain for reincarnation smoke."); + } + + if (GUILayout.Button("Reincarnate")) + { + StartOperation(_memorySync.ReincarnateCurrentBody("Unity prototype debug reincarnation."), "Reincarnate"); + } + GUILayout.EndHorizontal(); + GUI.enabled = true; + + GUILayout.Label("Force Zero Time requires SECOND_SPAWN_ENABLE_DEBUG_BODYTIME=true in Nakama.", _labelStyle); + GUILayout.EndArea(); + } + + private void StartBodyTimeEvent(string kind, string source, long seconds, string note) + { + StartOperation(_memorySync.ApplyBodyTimeEvent(new BodyTimeEventRequestDto + { + id = CharacterMemorySync.BuildClientEventId($"bodytime-{kind}"), + kind = kind, + source = source, + amount_seconds = seconds, + note = note + }), $"BodyTime {kind}"); + } + + private void StartOperation(IEnumerator operation, string label) + { + if (_busy || operation == null) + { + return; + } + + StartCoroutine(RunOperation(operation, label)); + } + + private IEnumerator RunOperation(IEnumerator operation, string label) + { + _busy = true; + _status = $"{label} running..."; + yield return operation; + _status = $"{label} complete"; + _busy = false; + } + + private static string BuildContextLine(AgentContextDto context) + { + if (context == null) + { + return "No profile loaded yet."; + } + + var body = context.body; + var player = context.player; + var time = body?.time; + return $"{body?.lifecycle ?? "unknown"} | BodyTime {FormatSeconds(time?.remaining_seconds ?? 0)} | SECOND {FormatSeconds(player?.second_balance_seconds ?? 0)} | R{player?.reincarnation_count ?? 0}"; + } + + private static string FormatSeconds(long seconds) + { + if (seconds <= 0) + { + return "0s"; + } + + var days = seconds / 86400; + var hours = seconds % 86400 / 3600; + var minutes = seconds % 3600 / 60; + if (days > 0) + { + return $"{days}d {hours}h"; + } + + if (hours == 0 && minutes == 0) + { + return $"{seconds}s"; + } + + return hours > 0 ? $"{hours}h {minutes}m" : $"{minutes}m"; + } + + private static int ToIntSeconds(long seconds) + { + if (seconds <= 0) + { + return 0; + } + + return seconds >= int.MaxValue ? int.MaxValue : (int)seconds; + } + } +} diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeBodyLifecycleDebugPanel.cs.meta b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeBodyLifecycleDebugPanel.cs.meta new file mode 100644 index 0000000..5f60924 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeBodyLifecycleDebugPanel.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 47e3e1f6f17c4f5a87dbd4b0c70b321d diff --git a/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkPlayer.cs b/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkPlayer.cs index f130d0f..15c8fcb 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkPlayer.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkPlayer.cs @@ -36,6 +36,9 @@ public sealed class NetworkPlayer : NetworkBehaviour [Networked] public int BodyTimeRemainingSeconds { get; set; } [Networked] public int BodyTimeMaxSeconds { get; set; } [Networked] public int BodyTimeDangerDrainRate { get; set; } + [Networked] public NetworkBool IsBodyDead { get; set; } + [Networked] public int SecondBalanceSeconds { get; set; } + [Networked] public int ReincarnationCount { get; set; } [Networked] public int VisualVariant { get; set; } [Networked] public int EquipmentVisualId { get; set; } @@ -177,7 +180,10 @@ public void ApplyProfileStats( int defensePower, int bodyTimeRemainingSeconds, int bodyTimeMaxSeconds, - int bodyTimeDangerDrainRate) + int bodyTimeDangerDrainRate, + string bodyLifecycle, + int secondBalanceSeconds, + int reincarnationCount) { if (!HasStateAuthority) { @@ -187,7 +193,8 @@ public void ApplyProfileStats( var previousMaxHealth = MaxHealth; var previousMaxEnergy = MaxEnergy; - var wasDead = Hp <= 0f; + var profileSaysDead = IsDeadLifecycle(bodyLifecycle) || bodyTimeRemainingSeconds <= 0; + var wasDead = Hp <= 0f || IsBodyDead; var healthRatio = previousMaxHealth > 0 ? Hp / previousMaxHealth : 1f; var energyRatio = previousMaxEnergy > 0 ? Stamina / previousMaxEnergy : 1f; @@ -204,13 +211,20 @@ public void ApplyProfileStats( BodyTimeRemainingSeconds = Mathf.Max(0, bodyTimeRemainingSeconds); BodyTimeMaxSeconds = Mathf.Max(0, bodyTimeMaxSeconds); BodyTimeDangerDrainRate = Mathf.Max(0, bodyTimeDangerDrainRate); + IsBodyDead = profileSaysDead; + SecondBalanceSeconds = Mathf.Max(0, secondBalanceSeconds); + ReincarnationCount = Mathf.Max(0, reincarnationCount); - Hp = wasDead + Hp = profileSaysDead ? 0f + : wasDead + ? MaxHealth : previousMaxHealth > 0 ? Mathf.Clamp(Mathf.Round(MaxHealth * healthRatio), 1f, MaxHealth) : MaxHealth; - Stamina = previousMaxEnergy > 0 + Stamina = profileSaysDead + ? 0f + : previousMaxEnergy > 0 ? Mathf.Clamp(Mathf.Round(MaxEnergy * energyRatio), 0f, MaxEnergy) : MaxEnergy; } @@ -230,10 +244,20 @@ private void ApplyDefaultStats() BodyTimeRemainingSeconds = 24 * 60 * 60; BodyTimeMaxSeconds = 24 * 60 * 60; BodyTimeDangerDrainRate = 1; + VisualVariant = 12; + IsBodyDead = false; + SecondBalanceSeconds = 7 * 24 * 60 * 60; + ReincarnationCount = 0; Hp = MaxHealth; Stamina = MaxEnergy; } + private static bool IsDeadLifecycle(string lifecycle) + { + return !string.IsNullOrWhiteSpace(lifecycle) && + lifecycle.Trim().Equals("dead", System.StringComparison.OrdinalIgnoreCase); + } + private float GetRunSpeed() { return _moveSpeed * GetAgilitySpeedMultiplier(); diff --git a/Unity/Assets/_SecondSpawn/Scripts/UI/HUDController.cs b/Unity/Assets/_SecondSpawn/Scripts/UI/HUDController.cs index 4655851..c654cba 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/UI/HUDController.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/UI/HUDController.cs @@ -19,7 +19,7 @@ public sealed class HUDController : MonoBehaviour { [SerializeField] private bool _showPrototypeStats = true; [SerializeField] private Vector2 _panelPosition = new Vector2(16f, 16f); - [SerializeField] private Vector2 _panelSize = new Vector2(280f, 132f); + [SerializeField] private Vector2 _panelSize = new Vector2(320f, 184f); private NetworkPlayer _cachedPlayer; private GUIStyle _labelStyle; @@ -51,6 +51,8 @@ private void OnGUI() GUILayout.Label($"HP {player.Hp:0}/{player.MaxHealth} | Energy {player.Stamina:0}/{player.MaxEnergy}", _labelStyle); GUILayout.Label($"ATK {player.AttackPower} | DEF {player.DefensePower} | AGI {player.Agility}", _labelStyle); GUILayout.Label($"BodyTime {FormatSeconds(player.BodyTimeRemainingSeconds)} / {FormatSeconds(player.BodyTimeMaxSeconds)}", _labelStyle); + GUILayout.Label($"Lifecycle {(player.IsBodyDead ? "dead" : "alive")} | Drain {player.BodyTimeDangerDrainRate}s/tick", _labelStyle); + GUILayout.Label($"SECOND {FormatSeconds(player.SecondBalanceSeconds)} | Reincarnations {player.ReincarnationCount}", _labelStyle); GUILayout.EndArea(); } @@ -67,7 +69,7 @@ private NetworkPlayer ResolvePlayer() } _nextPlayerRefreshAt = Time.unscaledTime + 0.5f; - var players = FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None); + var players = FindObjectsByType(FindObjectsInactive.Exclude); foreach (var player in players) { if (player.HasInputAuthority) diff --git a/backend/nakama/README.md b/backend/nakama/README.md index 0854363..7abaf94 100644 --- a/backend/nakama/README.md +++ b/backend/nakama/README.md @@ -31,6 +31,17 @@ SUPABASE_URL=https://your-project.supabase.co SUPABASE_PUBLISHABLE_KEY=sb_publishable_... ``` +Optional local Play Mode debug env: + +```text +SECOND_SPAWN_ENABLE_DEBUG_BODYTIME=true +``` + +This enables the prototype fatal BodyTime drain source used by Unity smoke +tools to force the death -> reincarnation loop. Leave it disabled outside +local development. Real PvP, contested-zone, or player-loot time transfers must +come from server-validated combat or zone events, not client self-reporting. + 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: @@ -40,6 +51,7 @@ runtime: env: - "SUPABASE_URL=https://your-project.supabase.co" - "SUPABASE_PUBLISHABLE_KEY=sb_publishable_..." + - "SECOND_SPAWN_ENABLE_DEBUG_BODYTIME=false" ``` If Supabase anonymous auth is not configured yet, the Unity prototype can fall diff --git a/backend/nakama/local.example.yml b/backend/nakama/local.example.yml index 0343b14..a92b208 100644 --- a/backend/nakama/local.example.yml +++ b/backend/nakama/local.example.yml @@ -9,6 +9,7 @@ runtime: env: - "SUPABASE_URL=https://YOUR_PROJECT.supabase.co" - "SUPABASE_PUBLISHABLE_KEY=sb_publishable_YOUR_PUBLIC_KEY" + - "SECOND_SPAWN_ENABLE_DEBUG_BODYTIME=false" socket: server_key: defaultkey diff --git a/backend/nakama/modules/index.ts b/backend/nakama/modules/index.ts index cab12c9..d233e6c 100644 --- a/backend/nakama/modules/index.ts +++ b/backend/nakama/modules/index.ts @@ -26,6 +26,7 @@ var bodyTimeEarnCapSeconds = 3600; var bodyTimeSpendCapSeconds = 600; var bodyTimeDrainCapSeconds = 300; var bodyTimeEarnCooldownSeconds = 60; +var bodyTimeDebugFatalDrainSource = "prototype_reincarnation_debug"; var secondPrototypeMaxBalanceSeconds = 86400 * 365; var secondPrototypeStartingBalanceSeconds = 86400 * 7; var secondPrototypeReincarnationCostSeconds = 86400 * 5; @@ -246,7 +247,10 @@ function rpcBodyTimeEvent( ): string { var state = getOrCreateAgentContextState(ctx, nk); var context = state.context; - var event = normalizeBodyTimeEvent(parseJson(payload || "{}", "body time payload")); + var event = normalizeBodyTimeEvent( + parseJson(payload || "{}", "body time payload"), + debugBodyTimeEnabled(ctx) + ); ensureBodyTime(context); if (event.id && hasAgentActivityId(context.body.agent_activity || [], event.id)) { @@ -804,10 +808,10 @@ function ensureBodyTime(context: any): void { } } -function normalizeBodyTimeEvent(request: any): any { +function normalizeBodyTimeEvent(request: any, allowDebugFatalDrain: boolean): any { var kind = normalizeBodyTimeEventKind(request.kind); - var source = normalizeBodyTimeEventSource(kind, request.source); - var amount = normalizeBodyTimeAmount(kind, firstDefined(request.amount_seconds, request.seconds)); + var source = normalizeBodyTimeEventSource(kind, request.source, allowDebugFatalDrain); + var amount = normalizeBodyTimeAmount(kind, firstDefined(request.amount_seconds, request.seconds), source); return { id: trimString(request.id), kind: kind, @@ -825,7 +829,7 @@ function normalizeBodyTimeEventKind(kind: any): string { throw new Error("body time event kind must be earn, spend, or drain"); } -function normalizeBodyTimeEventSource(kind: string, source: any): string { +function normalizeBodyTimeEventSource(kind: string, source: any, allowDebugFatalDrain: boolean): string { var value = trimString(source); if (kind === "earn" && value === "prototype_safe_farming") { return value; @@ -836,10 +840,13 @@ function normalizeBodyTimeEventSource(kind: string, source: any): string { if (kind === "drain" && value === "danger_zone_tick") { return value; } + if (kind === "drain" && allowDebugFatalDrain && value === bodyTimeDebugFatalDrainSource) { + return value; + } throw new Error("body time source is not allowed for " + kind); } -function normalizeBodyTimeAmount(kind: string, amount: any): number { +function normalizeBodyTimeAmount(kind: string, amount: any, source: string): number { var numberValue = Number(amount); if (isNaN(numberValue) || !isFinite(numberValue) || numberValue <= 0) { throw new Error("body time amount_seconds must be a positive finite number"); @@ -850,11 +857,17 @@ function normalizeBodyTimeAmount(kind: string, amount: any): number { maxAmount = bodyTimeEarnCapSeconds; } else if (kind === "spend") { maxAmount = bodyTimeSpendCapSeconds; + } else if (source === bodyTimeDebugFatalDrainSource) { + maxAmount = bodyTimeMaxSeconds; } return clampNumber(Math.floor(numberValue), 1, maxAmount); } +function debugBodyTimeEnabled(ctx: nkruntime.Context): boolean { + return lowercase(ctx.env["SECOND_SPAWN_ENABLE_DEBUG_BODYTIME"] || "") === "true"; +} + function applyBodyTimeEvent(context: any, event: any, nk: nkruntime.Nakama): void { ensureBodyTime(context); if (context.body.lifecycle === "dead") { diff --git a/backend/nakama/tests/supabase_custom_auth.test.mjs b/backend/nakama/tests/supabase_custom_auth.test.mjs index e6acf49..6f76492 100644 --- a/backend/nakama/tests/supabase_custom_auth.test.mjs +++ b/backend/nakama/tests/supabase_custom_auth.test.mjs @@ -593,6 +593,46 @@ assert.throws( /source is not allowed/ ); +const debugDrainHarness = createRuntimeHarness(module); +debugDrainHarness.registeredRpcs.get("secondspawn_profile_get")( + { userId: "debug-drain-user", env: {} }, + debugDrainHarness.logger, + debugDrainHarness.nk, + "" +); +assert.throws( + () => debugDrainHarness.registeredRpcs.get("secondspawn_bodytime_event")( + { userId: "debug-drain-user", env: {} }, + debugDrainHarness.logger, + debugDrainHarness.nk, + JSON.stringify({ + id: "debug-drain-rejected", + kind: "drain", + source: "prototype_reincarnation_debug", + amount_seconds: 86400 + }) + ), + /source is not allowed/ +); +const debugDrainedBody = JSON.parse(debugDrainHarness.registeredRpcs.get("secondspawn_bodytime_event")( + { + userId: "debug-drain-user", + env: { SECOND_SPAWN_ENABLE_DEBUG_BODYTIME: "true" } + }, + debugDrainHarness.logger, + debugDrainHarness.nk, + JSON.stringify({ + id: "debug-drain-accepted", + kind: "drain", + source: "prototype_reincarnation_debug", + amount_seconds: 86400, + note: "Debug fatal drain." + }) +)); +assert.equal(debugDrainedBody.body.time.remaining_seconds, 0); +assert.equal(debugDrainedBody.body.lifecycle, "dead"); +assert.equal(debugDrainedBody.body.agent_activity[0].body_time_source, "prototype_reincarnation_debug"); + const reincarnationHarness = createRuntimeHarness(module); reincarnationHarness.registeredRpcs.get("secondspawn_profile_get")( { userId: "reincarnation-user", env: {} }, diff --git a/docs/design/12-game-design-document.md b/docs/design/12-game-design-document.md index 95cb1e1..437dd23 100644 --- a/docs/design/12-game-design-document.md +++ b/docs/design/12-game-design-document.md @@ -57,6 +57,9 @@ The player should feel: - Death has weight because the body is gone, not because the account is erased. - Level and stats give readable ARPG growth while deeper body progression is redesigned later. - Time is not just a timer. It is life, pressure, and a resource. +- BodyTime and SECOND can become contested resources. Future combat or zone + rules may let players loot time from other users, but only through validated + server-authoritative outcomes. - NPCs and agents are world citizens, not detached chatbots. - The world is dangerous because all gameplay state is server-authoritative and consequences persist. @@ -124,6 +127,10 @@ Key lore anchors: - Consciousness transfer: The sci-fi basis of reincarnation. - Hunters: Player-controlled or agent-controlled characters who fight and survive. - SECOND token: Account-level time reserve denominated in seconds. The token is used for reincarnation costs and must stay distinct from current-body `BodyTime` unless a future ADR explicitly merges them. +- Time loot: A future PvP or contested-zone rule can allow BodyTime or SECOND + to be taken from another user after server-validated combat, escrow, or zone + events. Clients, LLMs, and connected agents must never self-report or grant + this loot. ---