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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ versioned release tag yet, so entries are organized as pre-alpha snapshots.
with a clean / controlled PBR material pipeline, including reference
examples, negative examples, asset search language, selection guardrails, and
PC/Web/mobile scalability notes.
- Auth state HUD polish: Play Mode now shows a dedicated Supabase-backed versus
local-fallback auth block, a retry button, and more actionable Supabase or
Nakama custom-auth failure messages.
- Prototype combat training target lifecycle hardening: the target registry now
prunes destroyed references, and the scene helper respawns a missing training
target without duplicating live or rebuilding targets.
Expand Down
3 changes: 3 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ Recommended views:
- [x] Unity Play Mode Supabase anonymous signup uses the DOS Supabase
publishable key and passes the resulting access token through Nakama custom
auth, with local device auth kept as an explicit fallback only.
- [x] Unity Play Mode auth now has a dedicated HUD block that distinguishes
Supabase-backed auth, local device fallback, missing Nakama session, fallback
lock state, and retryable Supabase or Nakama bridge errors.
- [x] Nakama runtime scaffold with health, character context, memory, soul
update, NPC chat, BodyTime, legacy reincarnation/reinhabitation, OpenClaw,
and agent decision RPCs.
Expand Down
58 changes: 56 additions & 2 deletions Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ public IEnumerator Authenticate(Action onSuccess = null, Action<string> onError
if (supabaseSession == null || string.IsNullOrWhiteSpace(supabaseSession.access_token))
{
yield return TryDeviceFallbackOrFail(
"Supabase anonymous auth failed: " + (string.IsNullOrWhiteSpace(supabaseError) ? "missing access token." : supabaseError),
"Supabase anonymous auth failed: " + HumanizeSupabaseAuthError(supabaseError),
onSuccess,
onError);
yield break;
Expand All @@ -175,7 +175,7 @@ public IEnumerator Authenticate(Action onSuccess = null, Action<string> onError
if (nakamaSession == null || string.IsNullOrWhiteSpace(nakamaSession.token))
{
yield return TryDeviceFallbackOrFail(
"Nakama custom auth failed: " + (string.IsNullOrWhiteSpace(nakamaError) ? "missing session token." : nakamaError),
"Nakama custom auth failed: " + HumanizeNakamaCustomAuthError(nakamaError),
onSuccess,
onError);
yield break;
Expand Down Expand Up @@ -914,6 +914,60 @@ private static bool IsNakamaAuthInvalid(string error)
error.Contains("Auth token invalid", StringComparison.OrdinalIgnoreCase);
}

private static string HumanizeSupabaseAuthError(string error)
{
if (string.IsNullOrWhiteSpace(error))
{
return "missing access token. Check the Supabase URL, publishable key, and anonymous sign-in setting.";
}

var normalized = error.Trim();
if (normalized.IndexOf("anonymous_provider_disabled", StringComparison.OrdinalIgnoreCase) >= 0 ||
normalized.IndexOf("anonymous sign-ins are disabled", StringComparison.OrdinalIgnoreCase) >= 0)
{
return "anonymous sign-in is disabled in Supabase Auth. Enable anonymous sign-ins for local Play Mode.";
}

if (normalized.StartsWith("401:", StringComparison.OrdinalIgnoreCase) ||
normalized.StartsWith("403:", StringComparison.OrdinalIgnoreCase) ||
normalized.IndexOf("invalid api key", StringComparison.OrdinalIgnoreCase) >= 0 ||
normalized.IndexOf("jwt", StringComparison.OrdinalIgnoreCase) >= 0)
{
return "Supabase rejected the publishable key or token. Check SECOND_SPAWN_SUPABASE_URL and SECOND_SPAWN_SUPABASE_PUBLISHABLE_KEY.";
}

if (normalized.StartsWith("404:", StringComparison.OrdinalIgnoreCase))
{
return "Supabase auth endpoint was not found. Check the configured Supabase project URL.";
}

return normalized;
}

private static string HumanizeNakamaCustomAuthError(string error)
{
if (string.IsNullOrWhiteSpace(error))
{
return "missing session token. Check Nakama SUPABASE_URL and SUPABASE_PUBLISHABLE_KEY runtime env.";
}

var normalized = error.Trim();
if (normalized.StartsWith("401:", StringComparison.OrdinalIgnoreCase) ||
normalized.IndexOf("invalid", StringComparison.OrdinalIgnoreCase) >= 0 ||
normalized.IndexOf("unauthorized", StringComparison.OrdinalIgnoreCase) >= 0)
{
return "Nakama rejected the Supabase token. Check token freshness and the Nakama Supabase runtime env.";
}

if (normalized.StartsWith("500:", StringComparison.OrdinalIgnoreCase) ||
normalized.IndexOf("missing SUPABASE_URL", StringComparison.OrdinalIgnoreCase) >= 0)
{
return "Nakama custom auth bridge is misconfigured. Set SUPABASE_URL and SUPABASE_PUBLISHABLE_KEY in Nakama runtime env.";
}

return normalized;
}

private static string ExtractJwtStringClaim(string jwt, string claimName)
{
if (string.IsNullOrWhiteSpace(jwt))
Expand Down
112 changes: 99 additions & 13 deletions Unity/Assets/_SecondSpawn/Scripts/UI/HUDController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ public sealed class HUDController : MonoBehaviour
private bool _gameplayLedgerRefreshInFlight;
private string _gameplayLedgerSummary = "No gameplay ledger yet.";
private Text _hudTitleText;
private Text _hudAuthText;
private RectTransform _authActionRow;
private Button _authRetryButton;
private Text _hudBodyText;
private Text _hudFrameText;
private Text _hudAgentText;
Expand Down Expand Up @@ -212,7 +215,7 @@ private void BuildRuntimeHud()
panel.anchorMax = new Vector2(0f, 1f);
panel.pivot = new Vector2(0f, 1f);
panel.anchoredPosition = new Vector2(_panelPosition.x, -_panelPosition.y);
panel.sizeDelta = new Vector2(360f, 560f);
panel.sizeDelta = new Vector2(380f, 620f);

var image = panel.gameObject.AddComponent<Image>();
image.color = new Color(0.05f, 0.07f, 0.08f, 0.86f);
Expand All @@ -230,17 +233,34 @@ private void BuildRuntimeHud()
_hudTitleText.text = "SECOND SPAWN";
SetPreferredHeight(_hudTitleText.rectTransform, 20f);

_hudAuthText = CreateText("AuthState", panel, 13, FontStyle.Bold, new Color(0.72f, 0.9f, 1f));
SetPreferredHeight(_hudAuthText.rectTransform, 58f);

_authActionRow = CreateRect("AuthActions", panel);
SetPreferredHeight(_authActionRow, 28f);
var authActionLayout = _authActionRow.gameObject.AddComponent<HorizontalLayoutGroup>();
authActionLayout.spacing = 8f;
authActionLayout.childAlignment = TextAnchor.MiddleLeft;
authActionLayout.childControlHeight = true;
authActionLayout.childControlWidth = true;
authActionLayout.childForceExpandHeight = true;
authActionLayout.childForceExpandWidth = false;

_authRetryButton = CreateButton("RetryAuthButton", _authActionRow, "Retry Auth");
SetPreferredWidth(_authRetryButton.transform as RectTransform, 112f);
_authRetryButton.onClick.AddListener(RetryAuthentication);

_hudBodyText = CreateText("Body", panel, 15, FontStyle.Normal, Color.white);
SetPreferredHeight(_hudBodyText.rectTransform, 116f);
SetPreferredHeight(_hudBodyText.rectTransform, 104f);

_hudFrameText = CreateText("BodyIdentity", panel, 14, FontStyle.Normal, Color.white);
SetPreferredHeight(_hudFrameText.rectTransform, 160f);
SetPreferredHeight(_hudFrameText.rectTransform, 132f);

_hudAgentText = CreateText("AgentRuntime", panel, 14, FontStyle.Normal, Color.white);
SetPreferredHeight(_hudAgentText.rectTransform, 106f);
SetPreferredHeight(_hudAgentText.rectTransform, 90f);

_hudTraceText = CreateText("PromptTrace", panel, 13, FontStyle.Normal, new Color(0.78f, 0.84f, 0.88f));
SetPreferredHeight(_hudTraceText.rectTransform, 132f);
SetPreferredHeight(_hudTraceText.rectTransform, 118f);

_questTrackerPanel = CreateRect("QuestTracker", transform);
_questTrackerPanel.anchorMin = new Vector2(1f, 1f);
Expand Down Expand Up @@ -432,6 +452,9 @@ private void BuildRuntimeHud()
private bool HasCompleteRuntimeHud()
{
return _hudBodyText != null &&
_hudAuthText != null &&
_authActionRow != null &&
_authRetryButton != null &&
_hudFrameText != null &&
_hudAgentText != null &&
_hudTraceText != null &&
Expand Down Expand Up @@ -471,6 +494,9 @@ private bool HasCompleteRuntimeHud()
private void ResetRuntimeHudRefs()
{
_hudTitleText = null;
_hudAuthText = null;
_authActionRow = null;
_authRetryButton = null;
_hudBodyText = null;
_hudFrameText = null;
_hudAgentText = null;
Expand Down Expand Up @@ -556,6 +582,7 @@ private void RenderRuntimeHud()
}

_hudBodyText.text = _showPrototypeStats ? BuildStatsText(player) : "";
RenderAuthState(gateway);
_hudFrameText.text = _showFrameIdentity ? BuildFrameIdentityText(context) : "";
_hudAgentText.text = _showAgentActivity ? BuildAgentActivityText(context) : "";
_hudTraceText.text = BuildRuntimeDebugText(gateway);
Expand Down Expand Up @@ -1457,15 +1484,43 @@ private static string FormatAiReason(string reason)
};
}

private void RenderAuthState(SecondSpawnGatewayClient gateway)
{
if (_hudAuthText != null)
{
_hudAuthText.text = BuildAuthDebugText(gateway);
_hudAuthText.color = ResolveAuthColor(gateway);
}

if (_authActionRow != null)
{
_authActionRow.gameObject.SetActive(gateway != null);
}

if (_authRetryButton != null)
{
var canRetry = gateway != null && !gateway.IsAuthInProgress;
_authRetryButton.interactable = canRetry;
SetButtonLabel(_authRetryButton, gateway != null && gateway.HasNakamaSession ? "Refresh Auth" : "Retry Auth");
}
}

private void RetryAuthentication()
{
var gateway = ResolveGateway();
if (gateway == null || gateway.IsAuthInProgress)
{
return;
}

StartCoroutine(gateway.Authenticate());
}

private string BuildRuntimeDebugText(SecondSpawnGatewayClient gateway)
{
var builder = new StringBuilder();
builder.AppendLine("Auth");
builder.AppendLine(BuildAuthDebugText(gateway));

if (_showPromptTrace)
{
builder.AppendLine();
builder.AppendLine("Prompt Trace");
builder.AppendLine(_promptTraceSummary);
}
Expand All @@ -1491,7 +1546,32 @@ private static string BuildAuthDebugState(SecondSpawnGatewayClient gateway)
return "auth:no-gateway";
}

return $"{gateway.HasNakamaSession}|{gateway.IsAuthInProgress}|{gateway.AuthSource}|{gateway.NakamaUserId}|{gateway.SupabaseUserId}|{gateway.LastAuthError}|{gateway.AuthStatus}";
return $"{gateway.HasNakamaSession}|{gateway.IsAuthInProgress}|{gateway.AuthSource}|" +
$"{gateway.NakamaUserId}|{gateway.SupabaseUserId}|{gateway.LastAuthError}|" +
$"{gateway.AuthStatus}|{gateway.IsDeviceFallbackAllowedForDebug}|" +
$"{gateway.IsLocalNakamaEndpointForDebug}";
}

private static Color ResolveAuthColor(SecondSpawnGatewayClient gateway)
{
if (gateway == null)
{
return new Color(1f, 0.42f, 0.36f);
}

if (gateway.IsAuthInProgress)
{
return new Color(0.94f, 0.86f, 0.48f);
}

if (!gateway.HasNakamaSession)
{
return new Color(1f, 0.42f, 0.36f);
}

return string.Equals(gateway.AuthSource, "device_auth", System.StringComparison.OrdinalIgnoreCase)
? new Color(1f, 0.68f, 0.28f)
: new Color(0.58f, 1f, 0.74f);
}

private static string BuildAuthDebugText(SecondSpawnGatewayClient gateway)
Expand All @@ -1506,14 +1586,20 @@ private static string BuildAuthDebugText(SecondSpawnGatewayClient gateway)
: gateway.AuthSource;
var fallback = gateway.IsDeviceFallbackAllowedForDebug ? "LOCAL DEVICE FALLBACK ENABLED" : "device fallback locked";
var local = gateway.IsLocalNakamaEndpointForDebug ? "local" : "remote";
var mode = gateway.HasNakamaSession
? (string.Equals(gateway.AuthSource, "device_auth", System.StringComparison.OrdinalIgnoreCase)
? "LOCAL FALLBACK"
: "SUPABASE BACKED")
: "NO SESSION";
var status = TrimForHud(gateway.AuthStatus, 72);
var error = string.IsNullOrWhiteSpace(gateway.LastAuthError)
? ""
: "\nLast auth issue: " + TrimForHud(gateway.LastAuthError, 72);
: "\nIssue: " + TrimForHud(gateway.LastAuthError, 88);

return $"{source} | {local} Nakama | {fallback}\n" +
return $"{mode} | {source} | {local} Nakama\n" +
$"{fallback}\n" +
$"Nakama {Fallback(gateway.NakamaUserId, "no session")} | Supabase {Fallback(gateway.SupabaseUserId, "none")}\n" +
$"{TrimForHud(gateway.ResolvedNakamaBaseUrl, 56)} | {status}{error}";
$"{status}{error}";
}

private void DrawGameplayLedger()
Expand Down
21 changes: 21 additions & 0 deletions backend/nakama/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,27 @@ 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.

### Unity Auth HUD Troubleshooting

The Play Mode HUD has a dedicated auth block:

- `SUPABASE BACKED` means Unity received a Supabase access token and Nakama
accepted it through custom auth.
- `LOCAL FALLBACK` means Unity used Nakama device auth. This requires the
explicit debug flag or command-line opt-in and should not be treated as real
account binding.
- `NO SESSION` means Unity has no usable Nakama session. The retry button calls
the full Supabase -> Nakama custom-auth flow again.

Common failure messages:

| HUD Issue | Fix |
| ---- | ---- |
| Anonymous sign-in is disabled | Enable anonymous sign-ins in Supabase Auth for local Play Mode. |
| Supabase rejected the publishable key or token | Check `SECOND_SPAWN_SUPABASE_URL` and `SECOND_SPAWN_SUPABASE_PUBLISHABLE_KEY`. |
| Nakama custom auth bridge is misconfigured | Set `SUPABASE_URL` and `SUPABASE_PUBLISHABLE_KEY` in Nakama runtime env. |
| Device fallback is disabled | Use this as the default for Supabase-auth testing, or explicitly opt into local fallback only for local iteration. |

No game auth secret belongs in `api.dos.ai`. The model service receives only
bounded game context after Nakama has authenticated the player and shaped the
request.
Expand Down
36 changes: 36 additions & 0 deletions backend/nakama/tests/supabase_custom_auth.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3463,4 +3463,40 @@ const invalidSupabasePayloadRejected = harness.registeredHooks[0](
);
assert.equal(invalidSupabasePayloadRejected, null);

const supabaseServerErrorRejected = harness.registeredHooks[0](
{
env: {
SUPABASE_URL: "https://project.supabase.co",
SUPABASE_PUBLISHABLE_KEY: "sb_publishable_test",
},
},
{ error: () => {}, info: () => {}, debug: () => {} },
{
httpRequest: () => ({
code: 500,
body: "temporary Supabase outage",
}),
},
{ account: { id: "supabase-access-token" } }
);
assert.equal(supabaseServerErrorRejected, null);

const invalidSupabaseJsonRejected = harness.registeredHooks[0](
{
env: {
SUPABASE_URL: "https://project.supabase.co",
SUPABASE_PUBLISHABLE_KEY: "sb_publishable_test",
},
},
{ error: () => {}, info: () => {}, debug: () => {} },
{
httpRequest: () => ({
code: 200,
body: "{not valid json",
}),
},
{ account: { id: "supabase-access-token" } }
);
assert.equal(invalidSupabaseJsonRejected, null);

console.log("supabase_custom_auth tests passed");
5 changes: 5 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,11 @@ quest state directly.
- Can be used where it clearly reduces integration work
- Not the primary game backend baseline after ADR 0010
- Never used for combat / movement sync
- For the current Play Mode identity bridge, Unity may use the public Supabase
publishable key to obtain an anonymous access token, but Nakama must verify
that token through Supabase Auth before issuing a Nakama session.
- Local Nakama device auth is an explicit debug fallback only. The HUD must make
fallback mode visible and must not hide Supabase or Nakama rejection reasons.

### `api.dos.ai` Model Service

Expand Down
1 change: 1 addition & 0 deletions docs/setup/play-mode-smoke-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ too early can mix stale cleanup asserts into the next run.
| Fusion CodeGen | No invalid `AssetDatabase` path errors from Fusion diagnostics. |
| Nakama | HTTP endpoint returns 200 locally. |
| Auth HUD | Shows `supabase_custom_auth`, Nakama user id, Supabase user id, and `device fallback locked` for normal Play Mode. |
| Auth retry | When auth fails, the HUD shows an actionable Supabase or Nakama bridge issue and the retry button starts the full auth flow again. |
| Local fallback test | When launched with `-secondspawn-no-supabase -secondspawn-allow-device-auth`, HUD shows `LOCAL DEVICE FALLBACK ENABLED`. |

## NPC Dialogue
Expand Down
Loading