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.
---