diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 7dc1bdc..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,11 +67,14 @@ International-friendly framing. Explained via science (Nibirium, biotech, consci ### Backend -- **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) +- **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. +- **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 @@ -82,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 @@ -96,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) @@ -115,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. @@ -140,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) @@ -178,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 @@ -222,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 @@ -230,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. @@ -285,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: @@ -301,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 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.** 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. @@ -316,5 +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 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/.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 ./... diff --git a/.gitignore b/.gitignore index 54c89ff..3d5e87e 100644 --- a/.gitignore +++ b/.gitignore @@ -91,6 +91,16 @@ 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 + +# 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/ @@ -99,3 +109,12 @@ PhotonServerSettings.asset.local # 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 4b8288b..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,11 +67,14 @@ International-friendly framing. Explained via science (Nibirium, biotech, consci ### Backend -- **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) +- **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. +- **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 @@ -82,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 @@ -96,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) @@ -115,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. @@ -140,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) @@ -178,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 @@ -222,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 @@ -230,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. @@ -285,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: @@ -301,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 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.** 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. @@ -316,5 +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 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/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/Art.meta b/Unity/Assets/_SecondSpawn/Art.meta new file mode 100644 index 0000000..ff4f723 --- /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..ccdfb11 --- /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..207720e --- /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..ca63956 --- /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..b58534c --- /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..77d360c --- /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..7ba68ab --- /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..23015d5 --- /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..9853e8b --- /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..0f6ce3f --- /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 2cfbe51..e0d6828 100644 --- a/Unity/Assets/_SecondSpawn/Prefabs/Player_NetworkCube.prefab +++ b/Unity/Assets/_SecondSpawn/Prefabs/Player_NetworkCube.prefab @@ -10,10 +10,15 @@ 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} + - component: {fileID: 6129384756102938475} + - component: {fileID: 3400762132235786765} + - component: {fileID: 1778901195370216267} + - component: {fileID: 586566440311235492} m_Layer: 0 m_Name: Player_NetworkCube m_TagString: Untagged @@ -44,27 +49,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 +98,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,14 +135,16 @@ 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} + - {fileID: 6129384756102938475} ForceRemoteRenderTimeframe: 0 --- !u!114 &8359936860818564255 MonoBehaviour: @@ -143,13 +156,127 @@ 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 + _VisualVariant: 0 _IsAgentControlled: RawValue: 0 _moveSpeed: 5 + _walkSpeed: 2.2 + _jumpImpulse: 7.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 +--- !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..3d03685 --- /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); + 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..f755b49 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeLLMAgentDriver.cs @@ -0,0 +1,207 @@ +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 (!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."); + 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 bool CanDrivePrototypeAgent() + { + return _networkPlayer != null && _networkPlayer.HasStateAuthority; + } + + 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..032c81a --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeSpeechBubble.cs @@ -0,0 +1,88 @@ +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..d47cd07 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeSpeechBubble.cs.meta @@ -0,0 +1,11 @@ +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..feb9465 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeVoiceCue.cs @@ -0,0 +1,58 @@ +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..a529272 --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeVoiceCue.cs.meta @@ -0,0 +1,11 @@ +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..64f668b --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs @@ -0,0 +1,509 @@ +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 claims = JsonUtility.FromJson(json); + return claimName switch + { + "uid" => claims?.uid ?? "", + _ => "" + }; + } + 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; + } + + [Serializable] + private sealed class NakamaJwtClaimsDto + { + public string uid; + } + } +} 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/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/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..77593ce --- /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..ae2adda --- /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..f34011c --- /dev/null +++ b/Unity/Assets/_SecondSpawn/Scripts/Networking/NetworkAnimatorBridge.cs @@ -0,0 +1,434 @@ +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 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(); + } + + public override void FixedUpdateNetwork() + { + if (!HasStateAuthority) + { + return; + } + + _kcc ??= GetComponent(); + if (_kcc == null) + { + return; + } + + var worldVelocity = _kcc.RealVelocity; + worldVelocity.y = 0f; + + 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..16ade14 --- /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 1063cc4..e612e64 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,51 +18,122 @@ 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; } + [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. Will be replaced by Opsive UCC stats in slice phase 2.")] + [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() + { + _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; + if (EquipmentVisualId == EquipmentVisualCatalog.None) + { + EquipmentVisualId = EquipmentVisualCatalog.GetDefaultForVisualVariant(VisualVariant); + } + IsAgentControlled = false; } } 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 (GetInput(out NetworkInputData input)) + if (_kcc == null) + { + return; + } + + 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 (TryGetAuthoritativeInput(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) + { + _kcc.SetLookRotation(Quaternion.LookRotation(move), preservePitch: false, preserveYaw: false); + var speed = input.Run ? _moveSpeed : _walkSpeed; + moveVelocity = move * speed; + } + + if (input.Jump) { - NetworkedPosition += move.normalized * _moveSpeed * Runner.DeltaTime; + jumpImpulse = _jumpImpulse; } } - // Apply networked transform to GameObject so all clients see the - // server-authoritative position. - transform.position = NetworkedPosition; - transform.rotation = NetworkedRotation; + _kcc.Move(moveVelocity, jumpImpulse); + } + + public void SetPrototypeAgentInput(NetworkInputData input) + { + if (!HasStateAuthority) + { + 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() + { + if (!HasStateAuthority) + { + return; + } + + _prototypeAgentInput = default; + _hasPrototypeAgentInput = false; + 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..d636c29 --- /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/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/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..714b16b --- /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..515c865 --- /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/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": { 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..ab94461 --- /dev/null +++ b/backend/gateway/deploy/cloudrun.env.yaml @@ -0,0 +1,3 @@ +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..9f78025 --- /dev/null +++ b/backend/nakama/modules/index.ts @@ -0,0 +1,516 @@ +// 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 = newMemoryId(context); + } + + 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 = 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; + } + + 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 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) { + 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/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..cc1976f 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -16,6 +16,9 @@ - [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) +- [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 @@ -28,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 e4b63cf..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 @@ -29,16 +30,17 @@ 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 | | 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,14 +92,14 @@ 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) -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 @@ -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 | --- @@ -166,11 +170,11 @@ 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 | -| 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 | 4 (cultivation, overview design, player controller prototype, time-as-currency) | +| 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 1d52b5b..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` --- @@ -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. --- @@ -86,7 +87,7 @@ The first prototype should create a small, project-owned movement contract. Opsi | 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. | @@ -100,7 +101,7 @@ The first prototype should create a small, project-owned movement contract. Opsi - 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 @@ -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 | Nakama 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..0e7596e 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-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` @@ -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. --- @@ -96,16 +97,41 @@ 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 | | ---- | ---- | ---- | | `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 | +| `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 | -| `Player_NetworkCube.prefab` | Placeholder networked player prefab. | Existing Phase B, may evolve | +| `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 | --- @@ -147,11 +173,20 @@ 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 + +- [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. +- [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. -- [ ] Import Opsive UCC in a separate branch/commit only after baseline passes. +### 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 +221,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 +232,9 @@ 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 | +| 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/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/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 df1e2dc..ec24121 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: @@ -54,20 +65,20 @@ 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 - **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/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 new file mode 100644 index 0000000..fcef925 --- /dev/null +++ b/docs/setup/paid-assets.md @@ -0,0 +1,78 @@ +# 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 | +| Warrior Pack Bundle 1-3 FREE | Unity Package Manager > My Assets | `Unity/Assets/ExplosiveLLC/` | Ignored, do not commit raw files | Prototype random visual variants | + +--- + +## 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. +- 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`.