From a1e877054965340c366ec5a53bbb5e72b1355112 Mon Sep 17 00:00:00 2001 From: JOY Date: Tue, 19 May 2026 00:04:59 +0700 Subject: [PATCH 1/2] docs: add character stat system gdd Docs-only merge after local code-review fallback and green markdown lint. --- CHANGELOG.md | 3 + ROADMAP.md | 3 + docs/design/03-systems-index.md | 4 +- .../10-character-profile-agent-memory.md | 13 +- docs/design/12-game-design-document.md | 4 +- ...-character-stat-and-relationship-system.md | 433 ++++++++++++++++++ 6 files changed, 453 insertions(+), 7 deletions(-) create mode 100644 docs/design/14-character-stat-and-relationship-system.md diff --git a/CHANGELOG.md b/CHANGELOG.md index bc2188e..625c65c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ versioned release tag yet, so entries are organized as pre-alpha snapshots. - Character-model taxonomy covering core stats, derived stats, social attributes, body presentation, identity fields, and multi-axis relationships for NPC-like Frames and player-inhabitable bodies. +- Character stat and relationship system GDD covering the six-stat MVP backend + contract, deferred stat candidates, secondary stat direction, presentation + attributes, relationship axes, and reincarnation carryover boundaries. - Human-believable NPC agent design doc covering trait axes, relationship ledger, memory tiers, needs, mood, stress, proactive communication, and research anchors for LLM-driven NPC behavior. diff --git a/ROADMAP.md b/ROADMAP.md index 20085fa..cfa444e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -76,6 +76,9 @@ Recommended views: AI offline control, agent workflow, and backend boundaries. - [x] Character-model taxonomy documented for core stats, secondary stats, social attributes, body presentation, identity, and multi-axis relationships. +- [x] Character stat and relationship system GDD added for the six-stat MVP + backend contract, secondary stat direction, presentation attributes, and + reincarnation carryover boundaries. - [x] Backend tests for Nakama runtime behavior and model-backed fallback. - [x] Unity project baseline upgraded to Unity `6000.5.0b8`. - [x] Local Nakama runtime smoke-tested with the current TypeScript module. diff --git a/docs/design/03-systems-index.md b/docs/design/03-systems-index.md index f5846e4..2ca6bd8 100644 --- a/docs/design/03-systems-index.md +++ b/docs/design/03-systems-index.md @@ -42,7 +42,7 @@ This index enumerates every system the game needs, categorizes by Core/Gameplay/ | 10 | Boss LLM dialogue (Convai grounded) | Gameplay | VS | Not started | (TDD pending) | NPC dialogue | | 11 | AI agent for offline players (server-side) | Gameplay | VS | Prototype | [10-character-profile-agent-memory.md](10-character-profile-agent-memory.md) | NetworkRunner, api.dos.ai model service, 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 model service, NPC dialogue, LLM safety | -| 12 | Level/stat progression | Progression | MVP | Prototype | (covered by profile/runtime contracts) | Persistence | +| 12 | Level/stat progression | Progression | MVP | Prototype | [14-character-stat-and-relationship-system.md](14-character-stat-and-relationship-system.md) | Persistence | | 13 | Reincarnation flow (death -> SECOND -> new Frame) | Progression | VS | Prototype | [12-game-design-document.md](12-game-design-document.md) | Level/stats, NFT escrow, Persistence | | 14 | SECOND economy | Economy | VS | Not designed | (GDD pending - JOY input) | DOS Chain integration | | 36 | TIME / SECOND economy | Economy | VS | Prototype | [08-time-as-currency.md](08-time-as-currency.md) | Reincarnation, Combat, Persistence | @@ -52,7 +52,7 @@ This index enumerates every system the game needs, categorizes by Core/Gameplay/ | 18 | Profile persistence (Nakama OSS + Postgres) | Persistence | MVP | Prototype | [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 | Level/stat persistence | Persistence | MVP | Prototype | (covered by profile/runtime contracts) | Profile | +| 21 | Level/stat persistence | Persistence | MVP | Prototype | [14-character-stat-and-relationship-system.md](14-character-stat-and-relationship-system.md) | Profile | | 22 | Auth (Nakama + DOS Chain wallet, Supabase sidecar if useful) | Persistence | MVP | Prototype | (TDD pending - reuse DOS.Me pattern as identity bridge reference) | Nakama, thirdweb | | 23 | HUD (combat, level/stats, TIME) | UI | VS | Prototype | (deferred template `_deferred/hud-design.md`) | Combat, Profile | | 24 | Inventory UI | UI | VS | Not started | (deferred template `_deferred/ux-spec.md`) | Inventory persistence | diff --git a/docs/design/10-character-profile-agent-memory.md b/docs/design/10-character-profile-agent-memory.md index 734a75b..cc33cf2 100644 --- a/docs/design/10-character-profile-agent-memory.md +++ b/docs/design/10-character-profile-agent-memory.md @@ -255,6 +255,10 @@ These keys are implemented today by the gateway, Nakama runtime, and Unity prototype HUD. The six core stats are the canonical backend contract. The older serialized keys remain in runtime payloads as aliases until the Unity networked prototype stats are renamed in a coordinated compatibility pass. +See +[14-character-stat-and-relationship-system.md](14-character-stat-and-relationship-system.md) +for the system-level stat, secondary stat, presentation, and relationship +baseline. MVP core stat taxonomy: @@ -331,10 +335,11 @@ Design notes: mechanics. - `focus` must never be connected to prompt-injection defense. Security and moderation are harness constants, not stats. -- `intelligence` must never make the model smarter or grant new authority. It - can only affect server-approved technical actions and rolls inside policy. -- `luck` must never mint loot, TIME, or SECOND directly. It can only bias - backend-approved rolls inside strict caps. +- Deferred candidate stats such as `intelligence`, `charisma`, `luck`, and + `dexterity` are not part of the MVP backend contract. If added later, they + must remain server-owned and must never grant new authority. +- `luck`, if revived, must never mint loot, TIME, or SECOND directly. It can + only bias backend-approved rolls inside strict caps. --- diff --git a/docs/design/12-game-design-document.md b/docs/design/12-game-design-document.md index 65e665e..76194f5 100644 --- a/docs/design/12-game-design-document.md +++ b/docs/design/12-game-design-document.md @@ -414,7 +414,9 @@ Target character-model taxonomy: The MVP backend uses six canonical body-bound core stats: `strength`, `agility`, `endurance`, `perception`, `focus`, and `presence`. The older serialized keys `vitality`, `force`, and `resilience` remain compatibility aliases until the -Unity networked prototype stats are renamed safely. +Unity networked prototype stats are renamed safely. See +[14-character-stat-and-relationship-system.md](14-character-stat-and-relationship-system.md) +for the detailed stat, secondary stat, presentation, and relationship baseline. - Core stats: body-bound gameplay numbers such as strength, agility, endurance, perception, focus, and presence. Do not add wisdom as a core stat and do not diff --git a/docs/design/14-character-stat-and-relationship-system.md b/docs/design/14-character-stat-and-relationship-system.md new file mode 100644 index 0000000..d2c572e --- /dev/null +++ b/docs/design/14-character-stat-and-relationship-system.md @@ -0,0 +1,433 @@ +# Character Stat and Relationship System + +*Status: Design baseline* +*Created: 2026-05-18* +*Last updated: 2026-05-18* + +--- + +## Purpose + +This document defines the baseline character stat, presentation, identity, and +relationship model for SECOND SPAWN. + +The goal is to keep body gameplay readable, backend-owned, and useful to both +ARPG combat and LLM-driven NPC behavior without letting the LLM bypass game +authority. It is a design contract, not a final combat spreadsheet. + +--- + +## Design Goals + +- Keep the MVP stat model compact enough for Nakama, Unity, and future Fusion + server validation. +- Separate body-bound combat stats from soul identity, AI personality, + relationship state, memory, and policy. +- Support Diablo IV and Path of Exile 2 style ARPG readability without copying + their full UX complexity. +- Make defensive scaling clear: show ratings and effective percentages where + useful, but keep formula constants tunable by content level. +- Keep LLM-facing stats descriptive and bounded. Stats can shape prompts, + context, and validated rolls, but never grant direct authority. +- Avoid Nibirium, cultivation XP, Lucky Hit, and wisdom-like overlap in the + vertical slice. + +--- + +## Non-Goals + +- No cultivation tier, Nibirium XP, or hidden body-absorption progression. +- No client-authoritative stats, rewards, currency, TIME, or relationship + mutation. +- No stat can expand `AgentPolicy`, moderation boundaries, tool access, or + server authority. +- No final full combat formula in this document. +- No final reincarnation carryover economy for relationships or memory. + +--- + +## Current Source of Truth + +The MVP backend uses six canonical body-bound core stats: + +- `strength` +- `agility` +- `endurance` +- `perception` +- `focus` +- `presence` + +Older serialized prototype fields remain compatibility aliases until Unity +networked stats are renamed safely: + +- `force` +- `vitality` +- `resilience` + +The broader brainstorm candidates `intelligence`, `charisma`, `luck`, and +`dexterity` are not current MVP backend contract fields. They are documented +below as deferred candidates so the design discussion is not lost. + +--- + +## Layer Taxonomy + +| Layer | Examples | Authority | +| ---- | ---- | ---- | +| Core Stats | `strength`, `agility`, `endurance`, `perception`, `focus`, `presence` | Game backend, then Fusion server for live combat | +| Secondary Stats | HP, energy, attack power, armor, elemental resistance, dodge, crit | Derived or cached by backend and server simulation | +| Social Attributes | Appeal band, reputation, faction standing | Backend-owned profile and presentation data | +| Body Presentation | Visual tags, intimidation tags, style, voice profile | Backend-owned, Unity-rendered | +| Identity | Name, callsign, profession, gender identity, pronouns, age fields | Backend-owned identity layer | +| Relationship Ledger | Trust, affection, hostility, fear, respect, debt, familiarity | Backend-owned per-target records | +| Character Traits | Curiosity, courage, discipline, aggression, sociability | Backend-owned agent context | +| Memory Records | Bounded event summaries and evidence links | Backend-owned, LLM-readable | +| Agent Policy | Allowed action and risk surface | Backend-owned hard limit | + +--- + +## Runtime Contract + +The current prototype runtime should keep these fields stable: + +| Field | Type | Notes | +| ---- | ---- | ---- | +| `level` | integer | Current body level, not durable soul level | +| `strength` | integer | Canonical core stat | +| `agility` | integer | Canonical core stat | +| `endurance` | integer | Canonical core stat | +| `perception` | integer | Canonical core stat | +| `focus` | integer | Canonical core stat | +| `presence` | integer | Canonical core stat | +| `force` | integer | Legacy alias for prototype compatibility | +| `vitality` | integer | Legacy alias for prototype compatibility | +| `resilience` | integer | Legacy alias for prototype compatibility | +| `max_health` | integer | Derived or cached | +| `max_energy` | integer | Derived or cached | +| `attack_power` | integer | Derived or cached | +| `defense_power` | integer | Derived or cached | + +New gameplay systems should read the canonical six. Legacy aliases should only +exist at compatibility boundaries. + +--- + +## Core Stats + +| Stat | Meaning | Main Use | +| ---- | ---- | ---- | +| `strength` | Physical output, heavy weapon force, carry capacity, brute impact | Melee damage, stagger pressure, heavy tool use | +| `agility` | Movement quality, handling, reaction, attack cadence, dodge scaling | Move speed, dodge rating, attack speed hooks | +| `endurance` | Body durability, recovery, energy reserve, survival tolerance | HP, energy, recovery, BodyTime efficiency hooks | +| `perception` | Sensor quality and awareness input, not LLM intelligence | Detection, weak-point reads, social cue input, threat awareness | +| `focus` | Concentration, panic resistance, instruction stability, pressure tolerance | Channeling, interruption resistance, agent consistency under stress | +| `presence` | Active social force and command weight | Persuasion, negotiation, leadership, intimidation, crowd effect hooks | + +### Why Not Wisdom + +Do not add `wisdom` as a core stat for MVP. Wisdom-like behavior is distributed +across `perception`, `focus`, `SoulProfile`, `CharacterTraits`, `FrameMemory`, +and `RelationshipLedger`. + +### Why Not Accuracy + +Do not expose `accuracy` as a player-facing secondary stat for MVP. If future +combat needs hit checks, use a backend-only `hit_reliability` value and keep the +player-facing model centered on direct-hit avoidance through dodge. + +--- + +## Deferred Candidate Stats + +These are useful ideas, but they should not be added to the current backend +contract without a migration and balance pass. + +| Candidate | Current MVP Placement | Revisit When | +| ---- | ---- | ---- | +| `intelligence` | Represented through profession, skill, memory, and agent context, not body core stats | Technical builds need a readable stat for hacking, crafting, analysis, or device use | +| `charisma` | Folded into `presence` | Social builds need a clear split between influence, command, intimidation, and charm | +| `luck` | Deferred | Loot, crit variance, rare events, and TIME rewards need a server-owned variance stat with strict caps | +| `dexterity` | Folded into `agility` | Precision weapons, finesse tools, or handling builds need separation from raw movement | + +If `luck` is added later, it must never mint loot, TIME, or SECOND directly. It +can only bias backend-approved rolls inside explicit caps. + +--- + +## Secondary Stats + +Secondary stats are derived, cached, or server-computed values. They can appear +in UI once combat and tuning are ready. + +### Offensive + +| Stat | Notes | +| ---- | ---- | +| `attack_power` | Baseline direct physical or weapon output | +| `skill_power` | Ability scaling budget | +| `attack_speed` | Animation and attack cadence budget | +| `crit_chance` | Chance for direct hits to crit | +| `crit_damage` | Bonus multiplier for crits | +| `cooldown_reduction` | Ability cadence modifier | +| `resource_cost_reduction` | Energy or ability cost modifier | + +### Defensive + +| Stat | Notes | +| ---- | ---- | +| `max_health` | Current body HP ceiling | +| `max_energy` | Current body action resource ceiling | +| `armor_rating` | Physical mitigation rating | +| `metal_resistance` | Kim elemental resistance rating | +| `wood_resistance` | Moc elemental resistance rating | +| `water_resistance` | Thuy elemental resistance rating | +| `fire_resistance` | Hoa elemental resistance rating | +| `earth_resistance` | Tho elemental resistance rating | +| `dodge_rating` | Rating converted to capped dodge chance | +| `dodge_chance` | Effective chance after conversion | + +### Body, TIME, and Agent Support + +| Stat | Notes | +| ---- | ---- | +| `body_time_drain_rate` | How quickly this body burns TIME in relevant states | +| `body_time_efficiency` | Modifier for survival cost and drain hooks | +| `body_stability` | Injury, mutation, overload, or degradation budget | +| `recovery_rate` | Health or energy recovery hook | +| `sensor_range` | How far the actor can sense relevant entities | +| `threat_detection` | Backend and prompt context for danger awareness | +| `stealth_detection` | Backend and prompt context for hidden targets | +| `social_read` | How much social cue context the agent receives | +| `instruction_stability` | How well the agent keeps owner policy under pressure | +| `stress_resistance` | How hard pressure must push before behavior changes | + +--- + +## Defense Scaling + +SECOND SPAWN should use rating-based defense with diminishing conversion. This +keeps high values useful without letting defense become a permanent full block. + +Example direction: + +```text +armor_dr = armor_rating / (armor_rating + armor_scale) +element_dr = resistance_rating / (resistance_rating + resistance_scale) +final_damage = raw_damage * (1 - armor_dr) * (1 - element_dr) * active_modifiers +``` + +`armor_scale` and `resistance_scale` should be content-level constants or tier +constants. The player-facing UI can show: + +- rating value +- effective reduction against same-level content +- warnings when higher-tier content reduces effective mitigation + +This borrows the useful idea from modern ARPGs without copying a complicated +UX stack. + +--- + +## Dodge Rules + +Dodge should exist without a player-facing accuracy stat. + +Example direction: + +```text +dodge_chance = max_dodge * dodge_rating / (dodge_rating + dodge_scale) +``` + +Rules: + +- Dodge applies to direct hit attempts only. +- Dodge does not fully avoid damage over time. +- Dodge does not fully avoid ground hazards. +- Dodge does not fully avoid aura damage. +- Dodge does not bypass guaranteed boss mechanics. +- Dodge should have an effective cap. +- Future backend-only hit reliability may exist, but should not become a + player-facing accuracy stat in MVP. + +--- + +## Appeal and Body Presentation + +`Appeal` is not a core stat and not a beauty score. It is a body presentation +attribute used for first impression, social framing, and LLM context. + +Recommended fields: + +| Field | Notes | +| ---- | ---- | +| `appeal_band` | Broad range such as `plain`, `striking`, `uncanny`, `iconic`, `intimidating`, or `engineered` | +| `appeal_tags` | Short tags such as `scarred`, `polished`, `warm`, `cold`, `predatory`, `official`, or `streetwise` | +| `visual_tags` | Body silhouette, faction styling, cosmetic cues | +| `intimidation_tags` | Threat presentation, armor style, weapon impression | +| `presentation_style` | How the character tends to carry themselves | +| `voice_profile` | Optional voice or speech surface hint | + +Presence and Appeal should stay separate: + +- `presence` is active social pressure: command, persuade, threaten, lead. +- `appeal` is passive first impression: how the body reads before action. + +Neither can bypass consent, moderation, faction rules, or server validation. + +--- + +## Identity Fields + +Identity should not be compressed into stats. + +Recommended identity fields: + +| Field | Notes | +| ---- | ---- | +| `display_name` | Current public name | +| `callsign` | Short battlefield or street identifier | +| `profession` | Role such as courier, medic, scavenger, sentinel, broker | +| `faction_title` | Public role inside a faction | +| `reputation_summary` | Short public reputation hint | +| `gender_identity` | Identity value when relevant | +| `pronouns` | Display and dialogue support | +| `identity_age` | Age of the identity or persona | +| `soul_continuity_age` | Time since this consciousness became continuous | +| `memory_span` | How far reliable memory extends | + +Recommended body profile fields: + +| Field | Notes | +| ---- | ---- | +| `apparent_age` | How old the body appears | +| `chronological_body_age` | How long this body has existed | +| `body_sex_marker` | Biological marker or synthetic equivalent where relevant | +| `synthetic_marker` | Whether the body is synthetic, cloned, rebuilt, or unknown | + +--- + +## Relationship Ledger + +Relationships are part of the character model, not dialogue flavor. They should +be per-target, multi-axis records. + +Recommended fields: + +| Field | Range | Meaning | +| ---- | ---- | ---- | +| `affinity` | -100 to 100 | Overall pull or warmth toward the target | +| `hostility` | 0 to 100 | Desire to oppose, harm, sabotage, or reject | +| `trust` | 0 to 100 | Belief that the target keeps promises and shares truth | +| `fear` | 0 to 100 | Perceived danger from the target | +| `respect` | 0 to 100 | Recognition of strength, competence, or status | +| `debt` | -100 to 100 | Obligation owed or owed by the target | +| `familiarity` | 0 to 100 | How known and predictable the target feels | +| `affection` | 0 to 100 | Personal warmth, care, or attachment | +| `attachment` | 0 to 100 | Dependency, loyalty, or bond strength | +| `rivalry` | 0 to 100 | Competitive tension that can coexist with respect | +| `last_tone` | enum | Last interaction tone such as `friendly`, `tense`, `hostile`, `intimate`, `transactional`, or `protective` | +| `tags` | list | Facts such as `mentor`, `rival`, `saved-by-target`, `debtor`, `suspect`, or `old-crew` | +| `memory_refs` | list | Memory record IDs that justify current relationship values | + +Do not collapse relationships into one `like_score`. A character can respect +someone they hate, fear someone they trust, love someone who betrayed them, or +owe a debt to a rival. + +Relationship updates must come from server-validated events. The LLM can +propose a relationship or memory update, but Nakama owns whether it is accepted +and persisted. + +--- + +## Reincarnation and Carryover + +Reincarnation replaces or retires the current body. It should not copy every +body-bound value forward. + +Default direction: + +| Layer | Carryover | +| ---- | ---- | +| Core Stats | No, unless a future explicit rule says otherwise | +| Secondary Stats | No | +| Body Presentation | No, follows the new body | +| Identity | Mostly yes, if it belongs to the durable soul or player identity | +| SoulProfile | Yes, with future tuning | +| Relationship Ledger | Only by approved carryover rule, likely with decay | +| Memory Records | Selected records only, with decay and curation | +| Agent Policy | Yes, when owner-bound | +| Agent Runtime | Partial, mostly counters and audit state | + +World NPCs may remember the same soul in a new body, but that should be a +designed rule, not an automatic profile copy. + +--- + +## LLM and Backend Safety + +Stats can shape the context an LLM receives, but they do not grant authority. + +Rules: + +- The client sends intent, never authoritative mutation. +- Nakama and Fusion server compute or validate stat effects. +- `focus` must never weaken prompt-injection defense. +- `presence` and `appeal` must never bypass consent, moderation, or faction + rules. +- `perception` controls what context is available, not how intelligent the + model is. +- A stronger LLM provider must not turn a low-perception Frame into an + omniscient character. +- Tool access lives in `AgentPolicy` and server validation, not stats. +- Memory and relationship writes are accepted only through backend rules. + +--- + +## MVP Cut Line + +For the vertical slice: + +- Ship the canonical six core stats. +- Keep legacy aliases only at compatibility boundaries. +- Use existing derived fields: `max_health`, `max_energy`, `attack_power`, and + `defense_power`. +- Document defense, dodge, and resistance formula direction, but do not treat + this document as final balance. +- Use RelationshipLedger MVP fields: `affinity`, `hostility`, `trust`, `fear`, + `respect`, `debt`, and `familiarity`. +- Add `appeal_band` and simple presentation tags when the body profile schema is + migrated. +- Do not add `luck`, `charisma`, `intelligence`, or `dexterity` as MVP backend + contract fields. + +--- + +## Migration Plan + +1. Document the stat and relationship baseline. +2. Add schema fields in backward-compatible form. +3. Dual-read canonical fields and aliases until Unity prototype state is + renamed safely. +4. Update Unity HUD and debug panels to show canonical names. +5. Add relationship and presentation fields to Nakama storage with optimistic + concurrency. +6. Move combat formulas into a dedicated combat TDD before real damage goes + live. +7. Remove legacy aliases only after all Unity and backend call sites stop + depending on them. + +--- + +## Open Questions + +- Should `presence` remain the single social core stat, or should a future + system split it into `presence` and `charisma`? +- Should `luck` exist at all, given economy, loot, TIME, and SECOND risk? +- Which relationship axes survive reincarnation, and how much decay should + apply? +- Should Appeal be visible in player-facing UI, debug-only, or only used for + agent context? +- What are the initial armor, resistance, and dodge conversion constants for + same-level content? +- How should profession and future skill systems scale without becoming another + hidden XP layer? From 04c84c3e213a4a3eead621ddabef5c4657cb25b0 Mon Sep 17 00:00:00 2001 From: JOY Date: Tue, 19 May 2026 00:24:50 +0700 Subject: [PATCH 2/2] fix: stabilize NPC model decision fallback --- .../Scripts/AI/SecondSpawnGatewayClient.cs | 22 ++- backend/nakama/modules/index.ts | 115 +++++++++++++-- .../tests/supabase_custom_auth.test.mjs | 131 ++++++++++++++---- backend/nakama/types/nakama-runtime.d.ts | 4 +- 4 files changed, 230 insertions(+), 42 deletions(-) diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs index dcced10..baabfa3 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs @@ -37,6 +37,9 @@ public sealed class SecondSpawnGatewayClient : MonoBehaviour [SerializeField, Min(1), Tooltip("Seconds before Nakama or Supabase HTTP requests fail fast in Play Mode.")] private int _requestTimeoutSeconds = 10; + [SerializeField, Min(1), Tooltip("Seconds before the agent decision RPC fails. DOS.AI model decisions can take longer than normal Nakama calls.")] + private int _agentDecisionRequestTimeoutSeconds = 135; + private bool _authAttempted; private bool _authInProgress; private string _supabaseAccessToken; @@ -240,7 +243,7 @@ public IEnumerator SubmitPermanentNpcIntent(NpcIntentSubmitRequestDto request, A public IEnumerator Decide(AgentDecisionRequestDto request, Action onSuccess, Action onError = null) { - yield return SendNakamaRpc("secondspawn_agent_decide", request, onSuccess, onError); + yield return SendNakamaRpc("secondspawn_agent_decide", request, onSuccess, onError, _agentDecisionRequestTimeoutSeconds); } public IEnumerator Chat(NpcChatRequestDto request, Action onSuccess, Action onError = null) @@ -291,7 +294,12 @@ public IEnumerator GetVoiceSession(Action onSuccess, Action(string rpcId, object payload, Action onSuccess, Action onError) + private IEnumerator SendNakamaRpc( + string rpcId, + object payload, + Action onSuccess, + Action onError, + int timeoutSecondsOverride = 0) { if (!HasNakamaSession) { @@ -314,7 +322,7 @@ private IEnumerator SendNakamaRpc(string rpcId, object payload, Actio } onError?.Invoke(error); - }); + }, timeoutSecondsOverride); } private UnityWebRequest BuildNakamaRpcRequest(string rpcId, string json) @@ -429,9 +437,13 @@ private IEnumerator AuthenticateNakamaDevice(string deviceId, string username, A yield return Send(request, onSuccess, onError); } - private IEnumerator Send(UnityWebRequest request, Action onSuccess, Action onError) + private IEnumerator Send( + UnityWebRequest request, + Action onSuccess, + Action onError, + int timeoutSecondsOverride = 0) { - request.timeout = Mathf.Max(1, _requestTimeoutSeconds); + request.timeout = Mathf.Max(1, timeoutSecondsOverride > 0 ? timeoutSecondsOverride : _requestTimeoutSeconds); yield return request.SendWebRequest(); if (request.result != UnityWebRequest.Result.Success) diff --git a/backend/nakama/modules/index.ts b/backend/nakama/modules/index.ts index ec38690..7de0459 100644 --- a/backend/nakama/modules/index.ts +++ b/backend/nakama/modules/index.ts @@ -50,9 +50,10 @@ var bodyTimeDebugFatalDrainSource = "prototype_reincarnation_debug"; var secondPrototypeMaxBalanceSeconds = 86400 * 365; var secondPrototypeStartingBalanceSeconds = 86400 * 7; var secondPrototypeReincarnationCostSeconds = 86400 * 5; -var dosAiDecisionBackoffSeconds = 30; +var dosAiDecisionBackoffSeconds = 180; var dosAiDecisionMaxTokens = 96; var dosAiDecisionMemoryCap = 3; +var dosAiDecisionDefaultTimeoutMs = 120000; var prototypeVisualVariantMax = 17; var bodyArchetypePool = [ { @@ -463,10 +464,17 @@ function rpcAgentDecide( var userId = requireUserId(ctx); var request = parseJson(payload || "{}", "agent decision payload"); var state: any = null; + var statelessActorState: any = null; var context: any; var shouldPersistDecision = true; if (isStatelessAgentDecisionRequest(userId, request)) { - context = normalizeStatelessAgentDecisionContext(request.context, userId); + var statelessActorId = statelessAgentDecisionActorId(request); + if (findPermanentNpcFrame(statelessActorId)) { + statelessActorState = getOrCreateWorldNpcProfileState(nk, userId, statelessActorId); + context = agentDecisionContextFromActorProfile(statelessActorState.profile); + } else { + context = normalizeStatelessAgentDecisionContext(request.context, userId); + } shouldPersistDecision = false; } else { state = getOrCreateAgentContextState(ctx, nk); @@ -550,6 +558,8 @@ function rpcAgentDecide( if (shouldPersistDecision) { recordAndWriteAgentDecisionWithRetry(nk, userId, context, state.version, decision); + } else if (statelessActorState) { + recordAndWriteWorldActorDecisionWithRetry(nk, userId, statelessActorState, context, decision); } return JSON.stringify(decision); } @@ -595,15 +605,38 @@ function isStatelessAgentDecisionRequest(userId: string, request: any): boolean return !!requestedPlayerId && requestedPlayerId !== userId; } +function statelessAgentDecisionActorId(request: any): string { + return normalizeActorId(request && request.context && request.context.player && request.context.player.player_id); +} + function normalizeStatelessAgentDecisionContext(context: any, fallbackUserId: string): any { var cloned = cloneJson(context || {}); var requestedPlayerId = trimString(cloned && cloned.player && cloned.player.player_id) || fallbackUserId; return ensureAgentContext(cloned, requestedPlayerId); } +function agentDecisionContextFromActorProfile(profile: any): any { + var clonedBody = cloneJson(profile && profile.body ? profile.body : {}); + clonedBody.memory = cloneJson(profile && profile.memory ? profile.memory : []); + clonedBody.relationships = cloneJson(profile && profile.relationships ? profile.relationships : []); + clonedBody.agent_runtime = cloneJson(profile && profile.agent_runtime ? profile.agent_runtime : defaultAgentRuntime(new Date().toISOString())); + clonedBody.agent_activity = cloneJson(profile && profile.agent_activity ? profile.agent_activity : []); + return ensureAgentContext({ + player: { + player_id: profile.actor_id, + display_name: profile.display_name, + second_balance_seconds: 0, + reincarnation_count: 0 + }, + body: clonedBody + }, profile.actor_id); +} + function shouldBackoffModelDecision(reason: string): boolean { return reason === "dos_ai_timeout" || reason === "dos_ai_exception" || + reason === "dos_ai_empty_content" || + reason === "dos_ai_validate_error" || reason === "dos_ai_http_429" || reason === "dos_ai_http_500" || reason === "dos_ai_http_502" || @@ -874,7 +907,17 @@ function rpcNpcIntentSubmit( var targetState = intent.target_actor_id ? getOrCreateWorldNpcProfileState(nk, userId, intent.target_actor_id) : null; - validateNpcIntentRules(state.profile, targetState ? targetState.profile : null, request); + var validationError = validateNpcIntentRules(state.profile, targetState ? targetState.profile : null, request); + if (validationError) { + logger.info("NPC intent rejected: " + validationError); + return JSON.stringify({ + accepted: false, + status: validationError, + intent: intent, + actor: state.profile, + target_actor: targetState ? targetState.profile : null + }); + } var timestamp = intent.requested_at; var targetName = targetState ? targetState.profile.display_name : "the hub"; addActorActivity(state.profile, { @@ -1961,25 +2004,27 @@ function npcInteractionRules(): any { }; } -function validateNpcIntentRules(actor: any, target: any, request: any): void { +function validateNpcIntentRules(actor: any, target: any, request: any): string { var distanceMeters = finiteNumberOrDefault(firstDefined(request.distance_meters, request.distance), 0); if (distanceMeters > npcInteractionMaxDistanceMeters) { - throw new Error("NPC target is too far away for interaction"); + return "npc_target_too_far"; } if (!target) { - return; + return ""; } var relationship = findRelationshipRecord(actor.relationships || [], target.actor_id); if (relationship.hostility >= npcHostilityBlockThreshold) { - throw new Error("NPC relationship hostility blocks voluntary interaction"); + return "npc_relationship_hostility_block"; } if ( relationship.familiarity_count >= npcFrequentInteractionCount && relationship.affinity < npcRelationshipMinAffinityForFrequent ) { - throw new Error("NPC relationship affinity is too low for frequent interaction"); + return "npc_relationship_affinity_too_low"; } + + return ""; } function normalizeNpcInteractionTopic(value: any): string { @@ -2968,6 +3013,50 @@ function recordAndWriteAgentDecisionWithRetry( throw lastError || new Error("agent decision write conflict"); } +function recordAndWriteWorldActorDecisionWithRetry( + nk: nkruntime.Nakama, + ownerId: string, + actorState: any, + context: any, + decision: any +): void { + var profile = actorState.profile; + var version = actorState.version; + var lastError: any = null; + + for (var attempt = 0; attempt < 4; attempt += 1) { + var writableContext = cloneJson(context); + recordAgentDecision(writableContext, decision, nk); + applyDecisionContextRuntimeToActorProfile(profile, writableContext); + try { + writeWorldActorProfile(nk, ownerId, profile, version); + return; + } catch (err) { + if (!isStorageVersionConflict(err)) { + throw err; + } + + lastError = err; + var latest = readWorldActorProfile(nk, ownerId, profile.actor_id); + if (!latest) { + throw err; + } + + profile = ensureActorProfile(latest.value || {}, ownerId, profile.actor_id); + version = latest.version; + context = agentDecisionContextFromActorProfile(profile); + } + } + + throw lastError || new Error("world actor decision write conflict"); +} + +function applyDecisionContextRuntimeToActorProfile(profile: any, context: any): void { + profile.agent_runtime = cloneJson(context.body.agent_runtime || defaultAgentRuntime(new Date().toISOString())); + profile.agent_activity = cloneJson(context.body.agent_activity || []); + profile.updated_at = new Date().toISOString(); +} + function shouldRecordDecisionActivity(context: any, summary: string): boolean { var activities = context.body.agent_activity || []; if (activities.length === 0) { @@ -3016,6 +3105,7 @@ function tryDosAiAgentDecision( } var model = trimString(ctx.env["AGENT_DECISION_MODEL"] || "dos-ai") || "dos-ai"; + var timeoutMs = dosAiDecisionTimeoutMs(ctx); var body = { model: model, messages: [ @@ -3023,6 +3113,8 @@ function tryDosAiAgentDecision( { role: "user", content: dosAiAgentDecisionUserPrompt(context, request, world, allowed) } ], max_tokens: dosAiDecisionMaxTokens, + max_completion_tokens: dosAiDecisionMaxTokens, + temperature: 0, stream: false }; @@ -3032,7 +3124,7 @@ function tryDosAiAgentDecision( "content-type": "application/json", "accept": "application/json", "authorization": "Bearer " + apiKey - }, JSON.stringify(body)); + }, JSON.stringify(body), timeoutMs); } catch (err) { logger.info("DOS.AI decision request threw: " + err); return { decision: null, reason: isTimeoutLikeError(err) ? "dos_ai_timeout" : "dos_ai_exception" }; @@ -3069,6 +3161,11 @@ function tryDosAiAgentDecision( } } +function dosAiDecisionTimeoutMs(ctx: nkruntime.Context): number { + var configured = finiteNumberOrDefault(ctx.env["DOS_AI_DECISION_TIMEOUT_MS"], dosAiDecisionDefaultTimeoutMs); + return Math.floor(clampNumber(configured, 1000, 120000)); +} + function isTimeoutLikeError(err: any): boolean { var message = lowercase(String(err || "")); return message.indexOf("timeout") >= 0 || diff --git a/backend/nakama/tests/supabase_custom_auth.test.mjs b/backend/nakama/tests/supabase_custom_auth.test.mjs index 30236f4..ae474c8 100644 --- a/backend/nakama/tests/supabase_custom_auth.test.mjs +++ b/backend/nakama/tests/supabase_custom_auth.test.mjs @@ -283,21 +283,20 @@ assert.equal(npcIntent.actor.relationships[0].affinity, 4); assert.equal(npcIntent.actor.relationships[0].familiarity_count, 1); assert.ok(npcIntent.actor.memory.some((memory) => /Route check complete/.test(memory.summary))); assert.ok(npcIntent.target_actor.memory.some((memory) => /Route check complete/.test(memory.summary))); -assert.throws( - () => harness.registeredRpcs.get("secondspawn_npc_intent_submit")( - { userId: "user-1", env: {} }, - harness.logger, - harness.nk, - JSON.stringify({ - actor_id: "npc-synthetic-sentinel-0101", - target_actor_id: "npc-wasteland-courier-0244", - intent: "say", - text: "Too far.", - distance_meters: 13 - }) - ), - /too far away/ -); +const tooFarNpcIntent = JSON.parse(harness.registeredRpcs.get("secondspawn_npc_intent_submit")( + { userId: "user-1", env: {} }, + harness.logger, + harness.nk, + JSON.stringify({ + actor_id: "npc-synthetic-sentinel-0101", + target_actor_id: "npc-wasteland-courier-0244", + intent: "say", + text: "Too far.", + distance_meters: 13 + }) +)); +assert.equal(tooFarNpcIntent.accepted, false); +assert.equal(tooFarNpcIntent.status, "npc_target_too_far"); assert.throws( () => harness.registeredRpcs.get("secondspawn_npc_intent_submit")( { userId: "user-1", env: {} }, @@ -759,8 +758,8 @@ assert.equal(profileAfterDecisionConflict.body.agent_activity[0].kind, "agent_de const modelHarness = createRuntimeHarness(module); const modelCalls = []; -modelHarness.nk.httpRequest = (url, method, headers, body) => { - modelCalls.push({ url, method, headers, body: JSON.parse(body) }); +modelHarness.nk.httpRequest = (url, method, headers, body, timeout) => { + modelCalls.push({ url, method, headers, body: JSON.parse(body), timeout }); return { code: 200, body: JSON.stringify({ @@ -808,11 +807,14 @@ assert.equal(modelCalls[0].url, "https://api.dos.ai/v1/chat/completions"); assert.equal(modelCalls[0].method, "post"); assert.equal(modelCalls[0].headers.authorization, "Bearer dos-ai-test-key"); assert.equal(modelCalls[0].body.model, "dos-ai"); +assert.equal(modelCalls[0].body.temperature, 0); +assert.equal(modelCalls[0].body.max_completion_tokens, 96); +assert.equal(modelCalls[0].timeout, 120000); const statelessNpcHarness = createRuntimeHarness(module); const statelessNpcCalls = []; -statelessNpcHarness.nk.httpRequest = (url, method, headers, body) => { - statelessNpcCalls.push({ url, method, headers, body: JSON.parse(body) }); +statelessNpcHarness.nk.httpRequest = (url, method, headers, body, timeout) => { + statelessNpcCalls.push({ url, method, headers, body: JSON.parse(body), timeout }); return { code: 200, body: JSON.stringify({ @@ -856,19 +858,24 @@ const statelessNpcDecision = JSON.parse(statelessNpcHarness.registeredRpcs.get(" )); assert.equal(statelessNpcDecision.source, "model"); assert.equal(statelessNpcCalls.length, 1); +assert.equal(statelessNpcCalls[0].timeout, 120000); assert.match(statelessNpcCalls[0].body.messages[1].content, /npc-synthetic-wasteland-courier-0244/); assert.doesNotMatch(statelessNpcCalls[0].body.messages[1].content, /agent_activity/); assert.equal(statelessNpcHarness.storage.has(storageKey("local-playtest-user", "secondspawn_agent", "context")), false); const invalidModelHarness = createRuntimeHarness(module); -invalidModelHarness.nk.httpRequest = () => ({ - code: 200, - body: JSON.stringify({ - choices: [{ - message: { content: JSON.stringify({ action: "attack", target_id: "not-nearby", reason: "bad", confidence: 0.9 }) } - }] - }) -}); +let invalidModelCalls = 0; +invalidModelHarness.nk.httpRequest = () => { + invalidModelCalls += 1; + return { + code: 200, + body: JSON.stringify({ + choices: [{ + message: { content: JSON.stringify({ action: "attack", target_id: "not-nearby", reason: "bad", confidence: 0.9 }) } + }] + }) + }; +}; const invalidModelDecision = JSON.parse(invalidModelHarness.registeredRpcs.get("secondspawn_agent_decide")( { userId: "invalid-model-user", @@ -887,6 +894,24 @@ const invalidModelDecision = JSON.parse(invalidModelHarness.registeredRpcs.get(" )); assert.equal(invalidModelDecision.source, "fallback"); assert.equal(invalidModelDecision.source_reason, "dos_ai_validate_error"); +const invalidModelCircuitDecision = JSON.parse(invalidModelHarness.registeredRpcs.get("secondspawn_agent_decide")( + { + userId: "invalid-model-user", + env: { + DOS_AI_API_KEY: "dos-ai-test-key", + DOS_AI_BASE_URL: "https://api.dos.ai/v1", + AGENT_DECISION_MODEL: "dos-ai" + } + }, + invalidModelHarness.logger, + invalidModelHarness.nk, + JSON.stringify({ + world_snapshot: { position: { x: 2, z: 3 }, body_time_seconds: 3600 }, + allowed: ["say", "stop"] + }) +)); +assert.equal(invalidModelCircuitDecision.source_reason, "dos_ai_circuit_open"); +assert.equal(invalidModelCalls, 1); const modelFallbackCases = [ { @@ -976,6 +1001,58 @@ const circuitOpenDecision = JSON.parse(timeoutCircuitHarness.registeredRpcs.get( assert.equal(circuitOpenDecision.source_reason, "dos_ai_circuit_open"); assert.equal(timeoutCircuitCalls, 1); +const statelessNpcTimeoutHarness = createRuntimeHarness(module); +let statelessNpcTimeoutCalls = 0; +statelessNpcTimeoutHarness.nk.httpRequest = () => { + statelessNpcTimeoutCalls += 1; + throw new Error("context deadline exceeded"); +}; +const statelessNpcTimeoutPayload = { + context: { + player: { + player_id: "npc-wasteland-courier-0244", + display_name: "Route Courier 0244" + }, + body: { + body_id: "body-npc-0244", + archetype_id: "courier", + time: { remaining_seconds: 3600 }, + agent_policy: { stop_when_body_time_below: 900 } + } + }, + world_snapshot: { position: { x: 2, z: 3 }, body_time_seconds: 3600 }, + allowed: ["move", "stop"] +}; +const statelessNpcTimeoutDecision = JSON.parse(statelessNpcTimeoutHarness.registeredRpcs.get("secondspawn_agent_decide")( + { + userId: "local-playtest-user", + env: { + DOS_AI_API_KEY: "dos-ai-test-key", + DOS_AI_BASE_URL: "https://api.dos.ai/v1", + AGENT_DECISION_MODEL: "dos-ai" + } + }, + statelessNpcTimeoutHarness.logger, + statelessNpcTimeoutHarness.nk, + JSON.stringify(statelessNpcTimeoutPayload) +)); +assert.equal(statelessNpcTimeoutDecision.source_reason, "dos_ai_timeout"); +const statelessNpcCircuitDecision = JSON.parse(statelessNpcTimeoutHarness.registeredRpcs.get("secondspawn_agent_decide")( + { + userId: "local-playtest-user", + env: { + DOS_AI_API_KEY: "dos-ai-test-key", + DOS_AI_BASE_URL: "https://api.dos.ai/v1", + AGENT_DECISION_MODEL: "dos-ai" + } + }, + statelessNpcTimeoutHarness.logger, + statelessNpcTimeoutHarness.nk, + JSON.stringify(statelessNpcTimeoutPayload) +)); +assert.equal(statelessNpcCircuitDecision.source_reason, "dos_ai_circuit_open"); +assert.equal(statelessNpcTimeoutCalls, 1); + const lowTimeDecision = JSON.parse(harness.registeredRpcs.get("secondspawn_agent_decide")( { userId: "user-1", env: {} }, harness.logger, diff --git a/backend/nakama/types/nakama-runtime.d.ts b/backend/nakama/types/nakama-runtime.d.ts index 18de5b6..12c8d2e 100644 --- a/backend/nakama/types/nakama-runtime.d.ts +++ b/backend/nakama/types/nakama-runtime.d.ts @@ -15,7 +15,9 @@ declare namespace nkruntime { url: string, method: string, headers: { [key: string]: string }, - body?: string + body?: string, + timeout?: number, + insecure?: boolean ): HttpResponse; storageRead(requests: StorageReadRequest[]): StorageObject[]; storageWrite(requests: StorageWriteRequest[]): void;