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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down
57 changes: 57 additions & 0 deletions Unity/Assets/_SecondSpawn/Scenes/ZoneTest_Hub.unity
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -582,6 +613,8 @@ MonoBehaviour:
_talkIntervalSeconds: 7.5
_seedSoulOnStart: 1
_alignFeetToGround: 1
_logPhaseTransitions: 1
_gatewayFailureErrorThreshold: 3
--- !u!4 &529322320
Transform:
m_ObjectHideFlags: 0
Expand All @@ -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
Expand Down
60 changes: 58 additions & 2 deletions Unity/Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
using System.Collections;
using UnityEngine;

namespace SecondSpawn.AI
{
/// <summary>
/// Prototype-only IMGUI controls for exercising the BodyTime death and
/// reincarnation loop against Nakama during Play Mode.
/// </summary>
[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<CharacterMemorySync>();
}

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;
}
}
}
Loading
Loading