From 8d9961c53c9039abd0728e18cf70a8352fa1597c Mon Sep 17 00:00:00 2001 From: Ken Silva Date: Thu, 26 Mar 2026 11:05:10 -0700 Subject: [PATCH] feat: Add Relationship Dynamics extension Emotional engine for NPC-player relationships. Separates permanent bonds (Affinity/Speed) from volatile attraction (Passion/RPM). Core features: - RPM/Speed/Gears model: passion drives affinity gain multiplier - 5 love languages with primary/secondary detection - 13 temperaments with passion/reunion/jealousy multipliers - 11 interest categories with per-NPC weights - Environmental resonance via vector similarity (MiniMe/txtai) - Combat passion with temperament-specific bleedout drain - Diminishing returns (exponential session decay) - Reunion system (time-apart spikes gated by affinity) - Jealousy from observed flirting + conflict/repair cycle - 3 relationship stages (early/established/deep) - Demisexual/asexual type constraint filtering - Sharmat bridge (passion -> sex_disposal, gated by class_exists) - Full settings UI with per-subsystem toggles - Per-NPC editor panel (love languages, interests, temperament) All external dependencies soft-gated: - Sharmat NsfwNpcData: class_exists() - MinAI conf_opts: try/catch - Oghma table: try/catch - MiniMe embeddings: HTTP with fallback to keyword matching Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 + ext/relationship_dynamics/api_save_npc.php | 147 ++ ext/relationship_dynamics/context.php | 250 ++ ext/relationship_dynamics/debug_compose.php | 131 + ext/relationship_dynamics/install.php | 44 + .../npc_editor_section.php | 430 ++++ ext/relationship_dynamics/postrequest.php | 547 +++++ ext/relationship_dynamics/prerequest.php | 211 ++ .../relationship_dynamics.php | 2113 +++++++++++++++++ ext/relationship_dynamics/settings.php | 560 +++++ ui/core/config_hub.php | 10 + ui/core/npc_master.php | 22 +- 12 files changed, 4464 insertions(+), 2 deletions(-) create mode 100644 ext/relationship_dynamics/api_save_npc.php create mode 100644 ext/relationship_dynamics/context.php create mode 100644 ext/relationship_dynamics/debug_compose.php create mode 100644 ext/relationship_dynamics/install.php create mode 100644 ext/relationship_dynamics/npc_editor_section.php create mode 100644 ext/relationship_dynamics/postrequest.php create mode 100644 ext/relationship_dynamics/prerequest.php create mode 100644 ext/relationship_dynamics/relationship_dynamics.php create mode 100644 ext/relationship_dynamics/settings.php diff --git a/.gitignore b/.gitignore index cfab3891..e328e2d8 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ !/ext/xLifeLink_plugin !/ext/generic_installer.php !/ext/relationship_system +!/ext/relationship_dynamics /log /soundcache data/CurrentModel_*.json diff --git a/ext/relationship_dynamics/api_save_npc.php b/ext/relationship_dynamics/api_save_npc.php new file mode 100644 index 00000000..8e0e58de --- /dev/null +++ b/ext/relationship_dynamics/api_save_npc.php @@ -0,0 +1,147 @@ + false, 'error' => 'Missing NPC name']); + exit; +} + +$npcName = trim($input['npc']); +$action = $input['action'] ?? 'save'; +$db = $GLOBALS['db']; + +try { + switch ($action) { + + case 'save': + // Load current dynamics + $dynamics = RelationshipDynamics::getDynamics($npcName); + + // Update configurable fields (only if provided) + $configFields = ['love_language_primary', 'love_language_secondary', 'warmth_curve', 'inferred_temperament', 'relationship_preference', 'openness']; + foreach ($configFields as $field) { + if (isset($input[$field])) { + $dynamics[$field] = $input[$field] !== '' ? $input[$field] : null; + } + } + + // Update interests + if (isset($input['interests']) && is_array($input['interests'])) { + $prefs = []; + foreach ($input['interests'] as $act => $mult) { + if (in_array($act, RelationshipDynamics::INTEREST_TYPES)) { + $prefs[$act] = max(0.5, min(2.0, floatval($mult))); + } + } + $dynamics['interests'] = !empty($prefs) ? $prefs : null; + } + + // Backward compat: accept old activity_preferences key + if (!isset($input['interests']) && isset($input['activity_preferences']) && is_array($input['activity_preferences'])) { + $dynamics['interests'] = RelationshipDynamics::migrateOldPreferences($input['activity_preferences']); + unset($dynamics['activity_preferences']); + } + + // Re-embed interest vector with updated sliders + $interests = $dynamics['interests'] ?? RelationshipDynamics::generateInterests(); + RelationshipDynamics::embedInterestVector($npcName, $interests, $dynamics); + + // Save + RelationshipDynamics::saveDynamics($npcName, $dynamics); + RelationshipDynamics::clearConfigCache(); + + echo json_encode(['ok' => true]); + break; + + case 'autogen': + // Load NPC data for auto-generation + $npcRow = $db->fetchOne( + "SELECT skills, extended_data FROM core_npc_master WHERE lower(npc_name) = lower(" + . $db->escapeLiteral($npcName) . ") LIMIT 1" + ); + + // Set GLOBALS so generateInterests() can read them + $GLOBALS['HERIKA_NAME'] = $npcName; + $GLOBALS['HERIKA_SKILLS'] = $npcRow['skills'] ?? ''; + + // Generate interests from bio + class + skills + $prefs = RelationshipDynamics::generateInterests(); + + // Also auto-gen love language if not set + $dynamics = RelationshipDynamics::getDynamics($npcName); + $llPrimary = $dynamics['love_language_primary'] ?? null; + $llSecondary = $dynamics['love_language_secondary'] ?? null; + $warmth = $dynamics['warmth_curve'] ?? null; + $temp = $dynamics['inferred_temperament'] ?? null; + + if (empty($llPrimary)) { + RelationshipDynamics::ensureLoveLanguage($npcName, $dynamics); + $llPrimary = $dynamics['love_language_primary'] ?? null; + $llSecondary = $dynamics['love_language_secondary'] ?? null; + $warmth = $dynamics['warmth_curve'] ?? null; + $temp = $dynamics['inferred_temperament'] ?? null; + } + + // Auto-embed interest vector (async-safe, ~8ms) + RelationshipDynamics::embedInterestVector($npcName, $prefs, $dynamics); + RelationshipDynamics::saveDynamics($npcName, $dynamics); + + echo json_encode([ + 'ok' => true, + 'preferences' => $prefs, + 'love_language_primary' => $llPrimary, + 'love_language_secondary' => $llSecondary, + 'warmth_curve' => $warmth, + 'inferred_temperament' => $temp, + ]); + break; + + case 'reset': + $dynamics = RelationshipDynamics::getDynamics($npcName); + + // Reset runtime state but keep configuration + $dynamics['passion'] = 0.0; + $dynamics['passion_updated_at'] = 0; + $dynamics['passion_sources'] = ['love_match' => 0, 'reunion' => 0, 'dramatic' => 0, 'repair' => 0]; + $dynamics['jealousy_anger'] = 0.0; + $dynamics['jealousy_updated_at'] = 0; + $dynamics['jealousy_trigger_npc'] = null; + $dynamics['in_conflict'] = false; + $dynamics['conflict_entered_at'] = 0; + $dynamics['conflict_positive_count'] = 0; + $dynamics['interaction_count'] = 0; + $dynamics['last_interaction_at'] = 0; + $dynamics['total_positive_interactions'] = 0; + $dynamics['stage'] = 'early'; + $dynamics['last_seen_at'] = 0; + $dynamics['reunion_spike_given'] = false; + $dynamics['love_language_hints_given'] = 0; + + RelationshipDynamics::saveDynamics($npcName, $dynamics); + + echo json_encode(['ok' => true]); + break; + + default: + echo json_encode(['ok' => false, 'error' => 'Unknown action: ' . $action]); + } +} catch (Throwable $e) { + echo json_encode(['ok' => false, 'error' => $e->getMessage()]); +} diff --git a/ext/relationship_dynamics/context.php b/ext/relationship_dynamics/context.php new file mode 100644 index 00000000..7ac3bed0 --- /dev/null +++ b/ext/relationship_dynamics/context.php @@ -0,0 +1,250 @@ + narrative block into LLM context. + * Player never sees numbers — only behavioral descriptions. + */ + +$npcName = $GLOBALS['HERIKA_NAME'] ?? ''; +if (empty($npcName) || $npcName === 'The Narrator') { + return; +} + +require_once __DIR__ . '/relationship_dynamics.php'; + +if (!RelationshipDynamics::isEnabled()) { + return; +} + +$dynamics = RelationshipDynamics::getDynamics($npcName); + +// Only inject if love language has been generated +if (empty($dynamics['love_language_primary'])) { + return; +} + +$passion = floatval($dynamics['passion'] ?? 0); +$jealousy = floatval($dynamics['jealousy_anger'] ?? 0); +$stage = $dynamics['stage'] ?? 'early'; +$temperament = $dynamics['inferred_temperament'] ?? ''; +$inConflict = !empty($dynamics['in_conflict']); +$reunionGiven = !empty($dynamics['reunion_spike_given']); + +$parts = []; + +// ------------------------------------------------------------------------- +// Passion state (the core emotional temperature) +// ------------------------------------------------------------------------- +$passionBand = RelationshipDynamics::getPassionBand($passion); +$passionContext = [ + 'burning' => "{$npcName} feels an electric tension with {$GLOBALS['PLAYER_NAME']} — barely able to keep composure, pulse racing, hyperaware of every word and movement.", + 'intense' => "{$npcName} feels a palpable warmth and heightened awareness around {$GLOBALS['PLAYER_NAME']} — drawn to them, finding excuses to stay close.", + 'warm' => "{$npcName} feels a growing excitement around {$GLOBALS['PLAYER_NAME']} — something is building between them, an undeniable pull.", + 'stirring' => "{$npcName} feels a pleasant warmth when {$GLOBALS['PLAYER_NAME']} is near — something faint stirring beneath the surface.", + 'faint' => "{$npcName} feels the faintest spark of... something... when {$GLOBALS['PLAYER_NAME']} speaks.", +]; +if (isset($passionContext[$passionBand])) { + $parts[] = $passionContext[$passionBand]; +} + +// ------------------------------------------------------------------------- +// Blush self-awareness (dynamic — only fires on passion spike) +// ------------------------------------------------------------------------- +$lastDelta = floatval($dynamics['_last_passion_delta'] ?? 0); +$blushMult = floatval($dynamics['pending_blush_mult'] ?? 1.0); +if ($lastDelta >= 4.0 && $blushMult >= 1.5) { + // High delta + love language match = strong involuntary response + if ($lastDelta >= 7.0) { + $parts[] = "Heat floods {$npcName}'s face unbidden — a deep, visible flush they cannot suppress. Their body is betraying something their words haven't admitted yet. They are acutely aware of it."; + } else { + $parts[] = "Unexpected warmth rises in {$npcName}'s cheeks — an involuntary response they didn't anticipate. They notice it happening and it catches them off guard."; + } + // Clear after injection — one-shot per spike + $dynamics['_last_passion_delta'] = 0; + $dynamics['pending_blush_mult'] = 1.0; + RelationshipDynamics::saveDynamics($npcName, $dynamics); +} elseif ($lastDelta >= 2.0 && $blushMult >= 1.0) { + // Moderate delta — subtle warmth, not full blush + $parts[] = "A faint warmth touches {$npcName}'s skin — barely perceptible, but they feel it. Something about this moment landed differently than expected."; + $dynamics['_last_passion_delta'] = 0; + RelationshipDynamics::saveDynamics($npcName, $dynamics); +} + +// ------------------------------------------------------------------------- +// Relationship stage framing +// ------------------------------------------------------------------------- +$stageContext = [ + 'early' => "{$npcName} and {$GLOBALS['PLAYER_NAME']} are still discovering each other — everything feels heightened, uncertain, full of possibility.", + 'established' => "{$npcName} and {$GLOBALS['PLAYER_NAME']} have settled into comfortable familiarity — they know each other's rhythms and patterns.", + 'deep' => "{$npcName} and {$GLOBALS['PLAYER_NAME']} share something profound and resilient — a bond forged through shared time and experience that weathered storms.", +]; +if (isset($stageContext[$stage])) { + $parts[] = $stageContext[$stage]; +} + +// ------------------------------------------------------------------------- +// Reunion warmth +// ------------------------------------------------------------------------- +if ($reunionGiven) { + $lastSeen = intval($dynamics['last_seen_at'] ?? 0); + $hoursApart = $lastSeen > 0 ? (time() - $lastSeen) / 3600.0 : 0; + $player = $GLOBALS['PLAYER_NAME']; + + // Temperament-aware reunion text + $reunionText = RelationshipDynamics::getReunionText($npcName, $temperament, $hoursApart, $player); + if ($reunionText) { + $parts[] = $reunionText; + } +} + +// ------------------------------------------------------------------------- +// Jealousy state +// ------------------------------------------------------------------------- +$jealousyBand = RelationshipDynamics::getJealousyBand($jealousy); +$triggerNpc = $dynamics['jealousy_trigger_npc'] ?? null; +$jealousyContext = [ + 'seething' => "{$npcName} is seething — jaw clenched, barely containing fury. Something about " . ($triggerNpc ? "{$triggerNpc} and " : "") . "{$GLOBALS['PLAYER_NAME']} has deeply wounded them.", + 'hurt' => "{$npcName} is visibly hurt and suspicious — their warmth has turned brittle, edged with accusation" . ($triggerNpc ? " about {$triggerNpc}" : "") . ".", + 'unsettled' => "{$npcName} seems unsettled — guarded, with flashes of hurt when certain topics arise.", + 'edgy' => "{$npcName} has a slight edge — something is bothering them, a hint of insecurity lurking beneath the surface.", +]; +if (isset($jealousyContext[$jealousyBand])) { + $parts[] = $jealousyContext[$jealousyBand]; +} + +// ------------------------------------------------------------------------- +// Conflict / Repair state +// ------------------------------------------------------------------------- +if ($inConflict) { + $repairCount = intval($dynamics['conflict_positive_count'] ?? 0); + if ($repairCount >= 2) { + $parts[] = "{$npcName} is cautiously warming again — the hurt isn't gone, but {$GLOBALS['PLAYER_NAME']}'s efforts are reaching through. Each kind word carries extra weight right now."; + } elseif ($repairCount >= 1) { + $parts[] = "{$npcName} is still hurt but watching {$GLOBALS['PLAYER_NAME']}'s actions closely — every positive gesture carries extra weight right now, like testing whether this person can be trusted again."; + } else { + $parts[] = "{$npcName} is wounded and wary — something {$GLOBALS['PLAYER_NAME']} did cut deep. They need to see genuine effort before the walls come down."; + } +} + +// ------------------------------------------------------------------------- +// Love language discovery hints (pure behavioral — no labels) +// ------------------------------------------------------------------------- +// The last interaction's love language match gets a hint in context +// so the LLM can describe the reaction differently +$lastLL = $GLOBALS['RELDYN_LAST_INTERACTION_LL'] ?? null; +$primaryLL = $dynamics['love_language_primary'] ?? null; +$secondaryLL = $dynamics['love_language_secondary'] ?? null; + +if ($lastLL && $primaryLL) { + if ($lastLL === $primaryLL) { + // Strong resonance hint + $hints = [ + 'words_of_affirmation' => "{$npcName}'s eyes brighten noticeably — these words clearly reach something deep. Their whole demeanor softens.", + 'quality_time' => "{$npcName} seems genuinely grateful for {$GLOBALS['PLAYER_NAME']}'s presence — as if their company alone is a precious gift.", + 'physical_touch' => "{$npcName}'s breath catches slightly at the contact — their whole posture softens, leaning into it almost involuntarily.", + 'acts_of_service' => "{$npcName} watches what {$GLOBALS['PLAYER_NAME']} did with quiet intensity — actions like this speak louder than any words could.", + 'gifts' => "{$npcName} handles the offering with surprising tenderness — more moved than the gift's value alone would suggest.", + ]; + if (isset($hints[$lastLL])) { + $parts[] = $hints[$lastLL]; + } + } elseif ($lastLL === $secondaryLL) { + // Moderate resonance + $parts[] = "{$npcName} appreciates the gesture warmly — it clearly means something to them, though perhaps not as deeply as some other form of affection might."; + } else { + // No match — contrast signal (this IS the discovery mechanic) + $parts[] = "{$npcName} acknowledges the gesture with a polite smile — appreciative, but something tells you this isn't quite what moves them most."; + } +} + +// ------------------------------------------------------------------------- +// Interest resonance (shared experience context) +// ------------------------------------------------------------------------- +// Use cached ambient result from prerequest (avoids double-computing) +$currentInterest = $GLOBALS['RELDYN_AMBIENT_INTEREST'] ?? null; +$currentResonance = floatval($GLOBALS['RELDYN_AMBIENT_RESONANCE'] ?? 0.0); +$currentLocation = $GLOBALS['RELDYN_AMBIENT_LOCATION'] ?? ''; +$currentSource = $GLOBALS['RELDYN_AMBIENT_SOURCE'] ?? 'none'; + +if ($currentInterest && $currentResonance >= 0.15) { + if ($currentSource === 'vector' && $currentResonance >= 0.3) { + // Rich vector-based resonance text — the NPC is responding to the specific place + $intText = RelationshipDynamics::getEnvironmentalResonanceText($npcName, $currentInterest, $currentResonance, $currentLocation); + if ($intText) $parts[] = $intText; + } else { + // Keyword-based or low resonance — use original interest category text + $intPrefs = RelationshipDynamics::getInterests($dynamics); + $rawMult = floatval($intPrefs[$currentInterest] ?? 1.0); + $intText = RelationshipDynamics::getInterestResonanceText($npcName, $currentInterest, $rawMult); + if ($intText) $parts[] = $intText; + } +} + +// ------------------------------------------------------------------------- +// Topic resonance hint (from previous interaction's topic match) +// ------------------------------------------------------------------------- +$lastTopicMatch = $dynamics['_last_topic_match'] ?? null; +if (!empty($lastTopicMatch)) { + $parts[] = "{$npcName} was genuinely engaged by a recent conversation about {$lastTopicMatch} — this topic touched on something they truly care about. If the subject comes up again, they'll light up."; +} + +// ------------------------------------------------------------------------- +// NPC initiation context (LLM decides, we just provide the urge) +// ------------------------------------------------------------------------- +if ($passion >= 40 && $primaryLL) { + $initiationHints = [ + 'words_of_affirmation' => "{$npcName} has a strong urge to express what they feel — the words are right there, wanting to come out.", + 'quality_time' => "{$npcName} doesn't want this moment to end — they want to find reasons to keep {$GLOBALS['PLAYER_NAME']} close.", + 'physical_touch' => "{$npcName} is acutely aware of the space between them and {$GLOBALS['PLAYER_NAME']} — wanting to close it.", + 'acts_of_service' => "{$npcName} wants to DO something for {$GLOBALS['PLAYER_NAME']} — to show through action what words can't capture.", + 'gifts' => "{$npcName} thinks about what they could give {$GLOBALS['PLAYER_NAME']} — something meaningful, something that says what they feel.", + ]; + if (isset($initiationHints[$primaryLL])) { + $parts[] = $initiationHints[$primaryLL]; + } +} + + +// ------------------------------------------------------------------------- +// Combat awareness (shared danger / post-combat glow) +// ------------------------------------------------------------------------- +$combatCtx = RelationshipDynamics::getCombatContext($npcName); +$player = $GLOBALS['PLAYER_NAME'] ?? 'Player'; + +if ($combatCtx) { + if (!empty($combatCtx['bleeding_out'])) { + $parts[] = "{$npcName} is critically wounded and barely conscious. The pain is overwhelming — every breath is a fight to stay awake."; + } elseif ($combatCtx['in_combat']) { + $hpPct = $combatCtx['health_pct']; + if ($hpPct < 0.3) { + $parts[] = "{$npcName} is badly hurt but still fighting alongside {$player}. The shared danger sharpens every sense."; + } elseif ($hpPct < 0.6) { + $parts[] = "{$npcName} is wounded but holding the line with {$player}. The adrenaline of shared combat bonds them."; + } else { + $parts[] = "{$npcName} fights alongside {$player}. The rhythm of shared combat — watching each other's backs, coordinating strikes — builds unspoken trust."; + } + } elseif ($combatCtx['in_combat']) { + $parts[] = "{$npcName} is engaged in combat. Adrenaline sharpens focus and strips away social pretense."; + } +} + +// Post-combat glow — combat ended recently but NPC is no longer in active combat +if (!$combatCtx || !$combatCtx['in_combat']) { + $recentCombat = RelationshipDynamics::getRecentCombatSummary($npcName); + if ($recentCombat) { + $parts[] = "The adrenaline from recent combat still lingers. {$npcName} and {$player} just survived a fight together — that shared experience hangs in the air."; + } +} + +// ------------------------------------------------------------------------- +// Assemble and inject +// ------------------------------------------------------------------------- +if (!empty($parts)) { + $block = "\n" . implode("\n", $parts) . "\n"; + $GLOBALS['contextDataFull'][] = ['role' => 'system', 'content' => $block]; + RelationshipDynamics::log("CTX: Injected emotional_dynamics for {$npcName}: " . count($parts) . " parts, passion={$passion}"); +} else { + RelationshipDynamics::log("CTX: No parts for {$npcName}: passion={$passion} stage={$stage}"); +} diff --git a/ext/relationship_dynamics/debug_compose.php b/ext/relationship_dynamics/debug_compose.php new file mode 100644 index 00000000..7d35ad87 --- /dev/null +++ b/ext/relationship_dynamics/debug_compose.php @@ -0,0 +1,131 @@ + 0 ? date('Y-m-d H:i:s', $lastPassionUpdate) . " (" . round((time() - $lastPassionUpdate) / 60) . " min ago)" : 'never') . "\n"; +echo "Passion Sources: " . json_encode($dyn['passion_sources'] ?? []) . "\n\n"; + +echo "Affinity Gain Mult: " . number_format(RelationshipDynamics::getAffinityGainMultiplier($dyn), 2) . "x (RPM→Speed)\n"; +echo "Session Multiplier: " . number_format(RelationshipDynamics::getSessionMultiplier($dyn), 2) . "x (diminishing returns)\n\n"; + +echo "Jealousy Anger: " . number_format(floatval($dyn['jealousy_anger']), 1) . "\n"; +echo "Jealousy Band: " . RelationshipDynamics::getJealousyBand($dyn['jealousy_anger']) . "\n"; +echo "Jealousy Trigger NPC: " . ($dyn['jealousy_trigger_npc'] ?? '(none)') . "\n\n"; + +echo "In Conflict: " . ($dyn['in_conflict'] ? 'YES' : 'no') . "\n"; +echo "Conflict Positive Count: " . intval($dyn['conflict_positive_count']) . "\n\n"; + +echo "Interaction Count: " . intval($dyn['interaction_count']) . "\n"; +$lastInt = intval($dyn['last_interaction_at'] ?? 0); +echo "Last Interaction: " . ($lastInt > 0 ? date('Y-m-d H:i:s', $lastInt) . " (" . round((time() - $lastInt) / 60) . " min ago)" : 'never') . "\n"; + +$lastSeen = intval($dyn['last_seen_at'] ?? 0); +echo "Last Seen: " . ($lastSeen > 0 ? date('Y-m-d H:i:s', $lastSeen) . " (" . round((time() - $lastSeen) / 3600, 1) . "h ago)" : 'never') . "\n"; +echo "Reunion Spike Given: " . ($dyn['reunion_spike_given'] ? 'YES' : 'no') . "\n\n"; + +echo "Stage: " . strtoupper($dyn['stage'] ?? 'early') . "\n"; +echo "Total Positive Ints: " . intval($dyn['total_positive_interactions']) . "\n"; +echo "LL Hints Given: " . intval($dyn['love_language_hints_given']) . "\n\n"; + +// Curve parameters +$curve = $dyn['warmth_curve'] ?? 'moderate'; +$params = RelationshipDynamics::CURVE_PARAMS[$curve] ?? []; +if ($params) { + echo "--- Warmth Curve: {$curve} ---\n"; + echo "Decay Rate: {$params['decay_rate']} per interaction\n"; + echo "Half-Life: {$params['half_life']}h\n"; + echo "Lambda: {$params['lambda']}\n"; + echo "Passion Decay: {$params['passion_decay']}/hour\n\n"; +} + +// Stage parameters +$stage = $dyn['stage'] ?? 'early'; +$stageParams = RelationshipDynamics::STAGE_PARAMS[$stage] ?? []; +if ($stageParams) { + echo "--- Stage: {$stage} ---\n"; + echo "Passion Floor: {$stageParams['floor']}\n"; + echo "Passion Ceiling: {$stageParams['ceiling']}\n"; + echo "Gain Multiplier: {$stageParams['gain_mult']}x\n"; + echo "DR Rate Modifier: {$stageParams['dr_mult']}x\n\n"; +} + +// CHIM affinity for context +try { + $db = $GLOBALS['db']; + $escaped = $db->escape($npcName); + $row = $db->fetchOne("SELECT extended_data FROM core_npc_master WHERE lower(npc_name) = lower('{$escaped}') LIMIT 1"); + if (is_array($row) && !empty($row['extended_data'])) { + $ext = json_decode($row['extended_data'], true) ?: []; + $playerName = $GLOBALS['PLAYER_NAME']; + $rel = $ext['relationships'][$playerName] ?? null; + if ($rel) { + echo "--- CHIM Relationship ---\n"; + echo "Affinity: " . ($rel['aff'] ?? '?') . "\n"; + echo "Type: " . ($rel['type'] ?? '?') . "\n"; + if (isset($rel['maras'])) { + echo "MARAS Affection: " . ($rel['maras']['affection'] ?? '?') . "\n"; + echo "MARAS Status: " . ($rel['maras']['status'] ?? '?') . "\n"; + echo "MARAS Temperament: " . ($rel['maras']['temperament'] ?? '?') . "\n"; + } else { + echo "MARAS: (no data — running without MARAS)\n"; + } + } + } +} catch (Throwable $e) { + echo "Error reading CHIM data: " . $e->getMessage() . "\n"; +} + +echo "\n--- Simulation ---\n"; +echo "If you talk to {$npcName} right now:\n"; +$simDyn = $dyn; +$simGain = RelationshipDynamics::calculatePassionGain($simDyn, $dyn['love_language_primary']); +echo " Primary LL match passion gain: +" . number_format($simGain, 1) . "\n"; +$simGain2 = RelationshipDynamics::calculatePassionGain($simDyn, 'quality_time'); +echo " Quality time (generic) gain: +" . number_format($simGain2, 1) . "\n"; +$simGain3 = RelationshipDynamics::calculatePassionGain($simDyn, null); +echo " Unclassified interaction: +0.0 (no passion from noise)\n"; +echo " Current effective disposition: " . RelationshipDynamics::getEffectiveDisposition(10, $simDyn) . " (base=10)\n"; diff --git a/ext/relationship_dynamics/install.php b/ext/relationship_dynamics/install.php new file mode 100644 index 00000000..f8d61c4f --- /dev/null +++ b/ext/relationship_dynamics/install.php @@ -0,0 +1,44 @@ +fetchOne("SELECT id FROM conf_opts WHERE id = 'relationship_dynamics_config' LIMIT 1"); +if (!empty($existing)) { + echo "Relationship Dynamics config already exists. No changes made.\n"; + echo "To reset, run: DELETE FROM conf_opts WHERE id = 'relationship_dynamics_config';\n"; + return; +} + +require_once __DIR__ . '/relationship_dynamics.php'; + +$defaultConfig = RelationshipDynamics::defaultConfig(); + +$jsonConfig = json_encode($defaultConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); +$escaped = $db->escape($jsonConfig); + +$db->execQuery("INSERT INTO conf_opts (id, value) VALUES ('relationship_dynamics_config', '{$escaped}')"); + +echo "Relationship Dynamics config installed successfully!\n"; +echo "Config size: " . strlen($jsonConfig) . " bytes\n"; +echo "\nCore model: Passion (RPM) drives Affinity gain (Speed)\n"; +echo "Systems: Love Languages, Diminishing Returns, Passion, Reunion, Jealousy, Repair, Stages\n"; +echo "\nTo disable: UPDATE conf_opts SET value = jsonb_set(value::jsonb, '{enabled}', 'false')::text WHERE id = 'relationship_dynamics_config';\n"; diff --git a/ext/relationship_dynamics/npc_editor_section.php b/ext/relationship_dynamics/npc_editor_section.php new file mode 100644 index 00000000..a3c3f273 --- /dev/null +++ b/ext/relationship_dynamics/npc_editor_section.php @@ -0,0 +1,430 @@ + block). + * Expects $editItem to be in scope from npc_master.php. + */ + +if (empty($editItem['npc_name'])) return; + +$rdNpcName = $editItem['npc_name']; + +// Load relationship dynamics from core_npc_master (CHIM-native) +$rdDynamics = []; +try { + $rdRow = $GLOBALS['db']->fetchOne( + "SELECT extended_data FROM core_npc_master WHERE lower(npc_name) = lower(" + . $GLOBALS['db']->escapeLiteral($rdNpcName) . ") LIMIT 1" + ); + if ($rdRow) { + $rdExt = json_decode($rdRow['extended_data'] ?? '{}', true) ?: []; + $rdDynamics = $rdExt['relationship_dynamics'] ?? []; + } +} catch (Throwable $e) { + // Silently fail — section will show defaults +} + +// Current values (with defaults) +$rdLLPrimary = $rdDynamics['love_language_primary'] ?? ''; +$rdLLSecondary = $rdDynamics['love_language_secondary'] ?? ''; +$rdWarmth = $rdDynamics['warmth_curve'] ?? ''; +$rdTemperament = $rdDynamics['inferred_temperament'] ?? ''; +$rdRelPref = $rdDynamics['relationship_preference'] ?? ''; +$rdOpenness = $rdDynamics['openness'] ?? ''; +$rdPassion = floatval($rdDynamics['passion'] ?? 0); +$rdJealousy = floatval($rdDynamics['jealousy_anger'] ?? 0); +$rdStage = $rdDynamics['stage'] ?? 'early'; +$rdTotalPos = intval($rdDynamics['total_positive_interactions'] ?? 0); +$rdInConflict = !empty($rdDynamics['in_conflict']); +$rdInterests = $rdDynamics['interests'] ?? ($rdDynamics['activity_preferences'] ?? []); +$rdInteractions = intval($rdDynamics['interaction_count'] ?? 0); + +// Love language options +$rdLLOptions = [ + '' => '— Auto-generate —', + 'words_of_affirmation' => 'Words of Affirmation', + 'quality_time' => 'Quality Time', + 'physical_touch' => 'Physical Touch', + 'acts_of_service' => 'Acts of Service', + 'gifts' => 'Gifts', +]; + +// Warmth curve options +$rdCurveOptions = [ + '' => '— Auto-generate —', + 'slow_burn' => 'Slow Burn (10h half-life, trust earned slowly)', + 'moderate' => 'Moderate (8h half-life, open but paces)', + 'quick_warmth' => 'Quick Warmth (6h half-life, warms in 1-2 days)', + 'guarded' => 'Guarded (12h half-life, hardest to crack)', +]; + +// Temperament options (11 types) +$rdTempOptions = [ + '' => '— Auto-generate —', + 'Romantic' => 'Romantic (passion ×1.3, falls fast)', + 'Anxious' => 'Anxious (reunion ×1.8, fears abandonment)', + 'Bold' => 'Bold (passion ×1.1, confident & direct)', + 'Playful' => 'Playful (passion ×1.4, flirty & volatile)', + 'Humble' => 'Humble (passion ×1.1, steady & modest)', + 'Nurturing' => 'Nurturing (reunion ×1.2, caretaker)', + 'Jealous' => 'Jealous (jealousy ×2.0, possessive)', + 'Proud' => 'Proud (passion ×0.8, demands respect)', + 'Guarded' => 'Guarded (passion ×0.6, slow burn, huge payoff)', + 'Independent' => 'Independent (passion ×0.7, autonomy-first)', + 'Stoic' => 'Stoic (passion ×0.5, duty-first, deep quiet loyalty)', +]; + +// Relationship preference options +$rdRelPrefOptions = [ + '' => '— Not set —', + 'monogamous' => 'Monogamous', + 'polyamorous' => 'Polyamorous', + 'uncommitted' => 'Uncommitted', + 'demisexual' => 'Demisexual', + 'asexual' => 'Asexual', + 'not_interested' => 'Not Interested', +]; + +// Openness options +$rdOpennessOptions = [ + '' => '— Auto from temperament —', + 'high' => 'High (tolerant, effort compensates)', + 'medium' => 'Medium (soft blocks, 2x effort needed)', + 'low' => 'Low (hard blocks, strict standards)', +]; + +// Interest types and labels +$rdInterestTypes = [ + 'combat' => ['label' => 'Combat', 'icon' => '⚔️'], + 'crafting' => ['label' => 'Crafting', 'icon' => '⚒️'], + 'alchemy' => ['label' => 'Alchemy', 'icon' => '⚗️'], + 'enchanting' => ['label' => 'Enchanting', 'icon' => '✨'], + 'scholarly' => ['label' => 'Scholarly', 'icon' => '📖'], + 'nature' => ['label' => 'Nature', 'icon' => '🌲'], + 'social' => ['label' => 'Social', 'icon' => '🍺'], + 'domestic' => ['label' => 'Domestic', 'icon' => '🏠'], + 'adventure' => ['label' => 'Adventure', 'icon' => '🗺️'], + 'spiritual' => ['label' => 'Spiritual', 'icon' => '🙏'], + 'wealth' => ['label' => 'Wealth', 'icon' => '💎'], +]; + +// Stage thresholds +$rdStageThresholds = ['early' => 0, 'established' => 50, 'deep' => 200]; +$rdNextThreshold = ($rdStage === 'early') ? 50 : (($rdStage === 'established') ? 200 : null); + +// API base URL +$rdApiUrl = ''; +$rdScriptPath = $_SERVER['SCRIPT_NAME'] ?? ''; +$rdUiPos = strpos($rdScriptPath, '/ui/'); +if ($rdUiPos !== false) { + $rdApiUrl = substr($rdScriptPath, 0, $rdUiPos); +} +?> + +
+
+ + 💕 Relationship Dynamics + + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ '🌱', 'established' => '🌿', 'deep' => '🌳']; + echo ($stageEmoji[$rdStage] ?? '') . ' ' . ucfirst($rdStage); + if ($rdNextThreshold !== null) { + echo " ({$rdTotalPos}/{$rdNextThreshold} interactions)"; + } else { + echo " ({$rdTotalPos} interactions)"; + } + ?> +
+
+
+ +
+ + ⚠️ In Conflict + = 40): ?> + 🔥 High Passion + = 15): ?> + ✨ Warming + + ◽ Neutral + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+ +
+
+
+
+
+ + +
+ +
+ $intInfo): + $intVal = $rdInterests[$intKey] ?? 1.0; + $barColor = $intVal >= 1.3 ? '#22c55e' : ($intVal <= 0.85 ? '#ef4444' : '#4a4a4a'); + ?> +
+
+ + x +
+ +
+ +
+
+ + +
+ + + + +
+ +
+
+
+ + diff --git a/ext/relationship_dynamics/postrequest.php b/ext/relationship_dynamics/postrequest.php new file mode 100644 index 00000000..f7e2d98c --- /dev/null +++ b/ext/relationship_dynamics/postrequest.php @@ -0,0 +1,547 @@ + 1 && $gain > 0) { + $streakBonus = min(2.0, ($combatCtx['recent_kills'] - 1) * 0.5); + $gain += $streakBonus; + RelationshipDynamics::log("Kill streak bonus: +{$streakBonus} ({$combatCtx['recent_kills']} kills)"); + } + + // MinAI shared danger bonus: low HP while fighting together + if ($combatCtx && $combatCtx['in_combat'] && $gain > 0) { + $combatInterest = floatval($dynamics['interests']['combat'] ?? 1.0); + $dangerThreshold = max(0.0, 0.30 - ($combatInterest * 0.15)); + if ($combatCtx['health_pct'] <= $dangerThreshold && $combatCtx['health_pct'] > 0) { + $gain *= 1.5; // shared danger intensity boost + RelationshipDynamics::log("Shared danger boost: HP={$combatCtx['health_pct']} threshold={$dangerThreshold}"); + } + // Confirmed fighting together upgrades all gains + $gain *= 1.3; + RelationshipDynamics::log("Shared combat confirmed (source={$combatCtx['source']}): 1.3x multiplier"); + } + } + + // Apply the passion change + if (abs($gain) > 0.01) { + if ($gain > 0) { + RelationshipDynamics::addPassion($dynamics, $gain, 'combat'); + $dynamics['total_positive_interactions'] = intval($dynamics['total_positive_interactions'] ?? 0) + 1; + } else { + // Negative drain (bleedout): clamp at zero, don't use addPassion + $dynamics['passion'] = max(0, floatval($dynamics['passion']) + $gain); + $dynamics['passion_updated_at'] = time(); + } + $dynamics['interaction_count'] = intval($dynamics['interaction_count'] ?? 0) + 1; + $dynamics['last_interaction_at'] = time(); + $dynamics['passion_sources']['combat'] = floatval($dynamics['passion_sources']['combat'] ?? 0) + $gain; + RelationshipDynamics::saveDynamics($combatNpc, $dynamics); + $witnessTag = $isWitness ? ' [WITNESS 0.5x]' : ''; + RelationshipDynamics::log("COMBAT EVENT: {$combatNpc} type={$reqType} LL={$combatLL} gain=" . round($gain, 2) . " passion=" . round($dynamics['passion'], 2) . " source=" . ($combatCtx['source'] ?? 'basic') . $witnessTag); + } + } + } + // Non-combat narrator events or combat events that didn't match — nothing more to do + return; +} + +require_once __DIR__ . '/relationship_dynamics.php'; + +if (!RelationshipDynamics::isEnabled()) { + return; +} + +$dynamics = RelationshipDynamics::getDynamics($npcName); +$reldynCfg = RelationshipDynamics::getConfig(); + +// ── NPC-TO-NPC FILTER ── +// Radiant dialogue is NPC-to-NPC — player isn't involved. +// Passion/affinity between those NPCs is handled by CHIM core's relationship_system. +// Skip RelDyn player↔NPC passion math to prevent parasitism. +$radiantTypes = ['radiant', 'radiantsearchingfriend', 'radiantsearchinghostile', + 'radiantcombathostile', 'minai_force_rechat']; +if (in_array($reqType, $radiantTypes)) { + return; +} + +// ── BYSTANDER FILTER ── +// Only the active conversation target gets full passion math. +// Bystanders (NPCs in CACHE_PEOPLE but not being spoken to) already received +// ambient trickle in prerequest.php — no interaction-based passion for them. +$activeNpc = $GLOBALS['RELDYN_NPC_NAME'] + ?? (function_exists('GetOriginalHerikaName') ? GetOriginalHerikaName() : null) + ?? ''; +if (!empty($activeNpc) && strtolower($npcName) !== strtolower($activeNpc)) { + // Bystander — skip full passion math, ambient trickle already applied + return; +} + +// Ensure love language exists (in case prerequest was skipped) +RelationshipDynamics::ensureLoveLanguage($npcName, $dynamics); + +// ------------------------------------------------------------------------- +// 1. Classify interaction +// ------------------------------------------------------------------------- +$lastMood = null; +try { + if (isset($GLOBALS['db'])) { + $db = $GLOBALS['db']; + $moodRow = $db->fetchOne( + "SELECT mood FROM moods_issued WHERE lower(speaker) = lower('" . $db->escape($npcName) . "') ORDER BY localts DESC LIMIT 1" + ); + $lastMood = $moodRow['mood'] ?? null; + } +} catch (Throwable $e) { + // Mood query failed, continue without +} + +$interactionLL = RelationshipDynamics::classifyInteraction($GLOBALS['gameRequest'], $lastMood); +$GLOBALS['RELDYN_LAST_INTERACTION_LL'] = $interactionLL; +RelationshipDynamics::log("POST classify: npc={$npcName} type={$reqType} mood={$lastMood} LL=" . ($interactionLL ?? 'NULL')); + +// Detect interest context for passion weighting (all love languages) +$currentInterest = RelationshipDynamics::detectInterestContext($interactionLL); +$GLOBALS['RELDYN_CURRENT_INTEREST'] = $currentInterest; +if ($currentInterest) { + $intMult = RelationshipDynamics::getInterestMultiplier($dynamics, $interactionLL); + $GLOBALS['RELDYN_INTEREST_MULT'] = $intMult; + $dynamics['last_interest'] = $currentInterest; + $dynamics['last_interest_mult'] = $intMult; + $dynamics['last_interest_ll'] = $interactionLL; +} + +// ------------------------------------------------------------------------- +// 1b. Topic Talk Bonus — conversation topic matches NPC interests +// ------------------------------------------------------------------------- +$topicBonus = 1.0; +if ($reldynCfg['topic_bonus_enabled'] ?? true) { +$topicMatch = null; +try { + $db_topic = $GLOBALS['db'] ?? null; + if ($db_topic) { + // Read the current Oghma topic from this conversation + $oghmaTopicRow = $db_topic->fetchOne("SELECT value FROM conf_opts WHERE id = 'current_oghma_topic' LIMIT 1"); + $oghmaTopic = ($oghmaTopicRow && !empty($oghmaTopicRow['value'])) ? trim($oghmaTopicRow['value']) : null; + + if ($oghmaTopic) { + // Look up the Oghma article to get its knowledge_class and vector + $topicEscaped = $db_topic->escape(strtolower($oghmaTopic)); + $articleRow = $db_topic->fetchOne( + "SELECT knowledge_class, vector384 FROM oghma " + . "WHERE lower(topic) = '{$topicEscaped}' LIMIT 1" + ); + + if ($articleRow) { + // Method 1: Vector similarity (preferred — uses the NPC's interest vector) + $npcVector = $dynamics['_interest_vector'] ?? null; + if (!empty($npcVector) && is_array($npcVector) && !empty($articleRow['vector384'])) { + $articleVecStr = trim($articleRow['vector384'], '[]'); + $articleVec = array_map('floatval', explode(',', $articleVecStr)); + $topicSimilarity = RelationshipDynamics::cosineSimilarity($npcVector, $articleVec); + + if ($topicSimilarity >= 0.35) { + $topicBonus = 1.0 + min(0.5, ($topicSimilarity - 0.35) * 1.67); // 0.35→1.0x, 0.65→1.5x + $topicMatch = $oghmaTopic; + RelationshipDynamics::log("TopicBonus: {$npcName} topic='{$oghmaTopic}' sim=" . round($topicSimilarity, 3) . " bonus={$topicBonus}x"); + } + } + // Method 2: Knowledge class keyword fallback + elseif (!empty($articleRow['knowledge_class'])) { + $klasses = array_map('trim', explode(',', strtolower($articleRow['knowledge_class']))); + $interests = RelationshipDynamics::getInterests($dynamics); + $klassMap = RelationshipDynamics::KNOWLEDGE_CLASS_TO_INTEREST ?? []; + foreach ($klasses as $klass) { + $mappedInterest = $klassMap[$klass] ?? null; + if ($mappedInterest && isset($interests[$mappedInterest]) && $interests[$mappedInterest] >= 1.3) { + $topicBonus = 1.3; + $topicMatch = $oghmaTopic; + RelationshipDynamics::log("TopicBonus(keyword): {$npcName} topic='{$oghmaTopic}' class={$klass} => {$mappedInterest} bonus=1.3x"); + break; + } + } + } + } + } + } +} catch (\Throwable $e) { + // Topic bonus is optional — don't crash on failure +} +} // end topic_bonus_enabled +$GLOBALS['RELDYN_TOPIC_BONUS'] = $topicBonus; +$GLOBALS['RELDYN_TOPIC_MATCH'] = $topicMatch; + +// ------------------------------------------------------------------------- +// 1c. Flirt-in-Context Bonus — flirty mood + (topic match OR location match) +// ------------------------------------------------------------------------- +$flirtBonus = 1.0; +if ($reldynCfg['flirt_bonus_enabled'] ?? true) { +$flirtyMoods = ['flirty', 'romantic', 'playful', 'teasing', 'amused', 'charmed', + 'smitten', 'coy', 'seductive', 'affectionate', 'bashful', 'flustered']; +if (!empty($lastMood) && in_array(strtolower($lastMood), $flirtyMoods)) { + $hasLocationMatch = floatval($GLOBALS['RELDYN_AMBIENT_RESONANCE'] ?? 0) >= 0.3; + $hasTopicMatch = ($topicBonus > 1.0); + if ($hasLocationMatch || $hasTopicMatch) { + $flirtBonus = 1.2; + RelationshipDynamics::log("FlirtBonus: {$npcName} mood={$lastMood} location=" . ($hasLocationMatch ? 'yes' : 'no') . " topic=" . ($hasTopicMatch ? 'yes' : 'no') . " bonus=1.2x"); + } +} +} // end flirt_bonus_enabled +$GLOBALS['RELDYN_FLIRT_BONUS'] = $flirtBonus; + +// ------------------------------------------------------------------------- +// 2. Calculate and apply passion gain +// ------------------------------------------------------------------------- +$passionGain = 0.0; +if (($reldynCfg['passion_enabled'] ?? true) && $interactionLL !== null) { + $rawPassionGain = RelationshipDynamics::calculatePassionGain($dynamics, $interactionLL); + // Apply topic and flirt bonuses on top of base passion gain + $passionGain = $rawPassionGain * $topicBonus * $flirtBonus; + error_log("[RelDyn-POST] passionGain: npc={$npcName} LL={$interactionLL} raw={$rawPassionGain} topic={$topicBonus}x flirt={$flirtBonus}x final={$passionGain} currentPassion={$dynamics['passion']}"); + if ($passionGain > 0) { + RelationshipDynamics::addPassion($dynamics, $passionGain, 'love_match'); + } + + if ($passionGain > 0) { + // Store blush multiplier for next prerequest cycle + // (maras_bridge runs before relationship_dynamics in prerequest, + // so we persist it for the next cycle's blush trigger to read) + if ($interactionLL === $dynamics['love_language_primary']) { + $dynamics['pending_blush_mult'] = 2.0; + $GLOBALS['RELDYN_BLUSH_MULTIPLIER'] = 2.0; + } elseif ($interactionLL === $dynamics['love_language_secondary']) { + $dynamics['pending_blush_mult'] = 1.5; + $GLOBALS['RELDYN_BLUSH_MULTIPLIER'] = 1.5; + } else { + $dynamics['pending_blush_mult'] = 1.0; + } + + // Store passion delta for blush self-awareness in next context.php cycle + $dynamics['_last_passion_delta'] = round($passionGain, 2); + } +} + +// Store topic match for next context.php cycle (topic resonance hint) +if ($topicMatch) { + $dynamics['_last_topic_match'] = $topicMatch; +} else { + // Clear stale topic match after one conversation without a match + unset($dynamics['_last_topic_match']); +} + +// ------------------------------------------------------------------------- +// 3. Diminishing returns — record interaction +// ------------------------------------------------------------------------- +RelationshipDynamics::recordInteraction($dynamics); + +// ------------------------------------------------------------------------- +// 4. RPM → Speed: Apply passion-weighted affinity change +// ------------------------------------------------------------------------- +// RelDyn owns aff. The relationship_system's LLM eval provides type/note +// but does NOT write aff when RelDyn is active. +// Base delta: +1 per positive interaction, scaled by passion multiplier. +// Formula: aff_delta = base × passion_multiplier +// passion 0 → ×0.3 (idling — affinity barely moves) +// passion 50 → ×1.15 (cruising — normal pace) +// passion 100 → ×2.0 (redline — maximum) +$affinityGainMult = RelationshipDynamics::getAffinityGainMultiplier($dynamics); +$baseDelta = ($passionGain > 0) ? 1 : 0; // +1 per positive interaction + +try { + $db = $GLOBALS['db'] ?? null; + $playerName = $GLOBALS['RELDYN_PLAYER_NAME'] ?? trim($GLOBALS['PLAYER_NAME'] ?? 'Player'); + + if ($db && !empty($playerName) && $baseDelta != 0) { + $escaped = $db->escape($npcName); + $row = $db->fetchOne("SELECT id, extended_data FROM core_npc_master WHERE lower(npc_name) = lower('{$escaped}') LIMIT 1"); + + if (is_array($row) && !empty($row['extended_data'])) { + $extData = json_decode($row['extended_data'], true) ?: []; + $relationships = $extData['relationships'] ?? []; + $playerRel = $relationships[$playerName] ?? null; + + if ($playerRel !== null) { + $currentAff = intval($playerRel['aff'] ?? 0); + $modifiedDelta = round($baseDelta * $affinityGainMult, 2); + + // Accumulate fractional deltas — only apply when they round to ≥1 + $pendingAff = floatval($dynamics['_pending_aff_delta'] ?? 0); + $pendingAff += $modifiedDelta; + $intDelta = intval(floor($pendingAff)); + $dynamics['_pending_aff_delta'] = $pendingAff - $intDelta; + + if ($intDelta != 0) { + $newAff = max(-100, min(100, $currentAff + $intDelta)); + $playerRel['aff'] = $newAff; + $relationships[$playerName] = $playerRel; + $extData['relationships'] = $relationships; + + $extJson = json_encode($extData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $extEscaped = $db->escape($extJson); + $npcId = intval($row['id']); + $db->execQuery("UPDATE core_npc_master SET extended_data = '{$extEscaped}'::jsonb WHERE id = {$npcId}"); + + RelationshipDynamics::log("RPM→Speed: base={$baseDelta} × mult=" . round($affinityGainMult, 2) . " = +{$intDelta} aff (passion=" . intval($dynamics['passion']) . ", aff {$currentAff}→{$newAff})"); + } else { + RelationshipDynamics::log("RPM→Speed: base={$baseDelta} × mult=" . round($affinityGainMult, 2) . " = pending " . round($pendingAff + $intDelta, 2) . " (passion=" . intval($dynamics['passion']) . ", not enough for +1 yet)"); + } + + // Check for conflict from negative delta + if ($intDelta < 0) { + RelationshipDynamics::checkAffinityDropConflict($dynamics, $intDelta); + } + } + } + } +} catch (Throwable $e) { + error_log("[RelDyn] RPM→Speed error: " . $e->getMessage()); +} + +// ------------------------------------------------------------------------- +// 5. Jealousy scan — check nearby NPCs +// ------------------------------------------------------------------------- +if (!($reldynCfg['jealousy_enabled'] ?? true)) goto skip_jealousy; +$romanticInteraction = in_array($interactionLL, [ + RelationshipDynamics::LL_TOUCH, + RelationshipDynamics::LL_WORDS, +]); + +if ($romanticInteraction) { + // CACHE_PEOPLE is pipe-delimited: "|Ashe|Lydia|Faendal|" + $nearbyNpcs = array_values(array_filter(array_map('trim', explode('|', $GLOBALS['CACHE_PEOPLE'] ?? '')))); + + foreach ($nearbyNpcs as $nearbyNpc) { + if (empty($nearbyNpc) || strtolower($nearbyNpc) === strtolower($npcName)) { + continue; + } + + // Load nearby NPC's dynamics + $nearbyDynamics = RelationshipDynamics::getDynamics($nearbyNpc); + if (empty($nearbyDynamics['love_language_primary'])) { + continue; // Not initialized — skip + } + + // Check if they have reason to be jealous + $relPref = null; + $marasStatus = null; + $marasAff = 0; + + // Read relationship_preference: RelDyn first, Sharmat fallback + $nearbyDynamics = RelationshipDynamics::getDynamics($nearbyNpc); + $relPref = $nearbyDynamics['relationship_preference'] ?? null; + if (empty($relPref) && class_exists('NsfwNpcData')) { + $relPref = NsfwNpcData::getKey($nearbyNpc, 'relationship_preference'); + } + + try { + $db2 = $GLOBALS['db'] ?? null; + if ($db2) { + $playerName2 = $GLOBALS['RELDYN_PLAYER_NAME'] ?? trim($GLOBALS['PLAYER_NAME'] ?? 'Player'); + $nearbyEsc = $db2->escape($nearbyNpc); + $nRow = $db2->fetchOne("SELECT extended_data FROM core_npc_master WHERE lower(npc_name) = lower('{$nearbyEsc}') LIMIT 1"); + if (is_array($nRow) && !empty($nRow['extended_data'])) { + $nExt = json_decode($nRow['extended_data'], true) ?: []; + $nRel = $nExt['relationships'][$playerName2] ?? null; + if ($nRel && isset($nRel['maras'])) { + $marasStatus = $nRel['maras']['status'] ?? null; + $marasAff = intval($nRel['maras']['affection'] ?? 0); + } + } + } + } catch (Throwable $e) { + continue; + } + + $jealousyGain = RelationshipDynamics::calculateJealousyGain( + $nearbyNpc, $npcName, $nearbyDynamics, + $relPref, $marasStatus, $marasAff + ); + + if ($jealousyGain > 0) { + RelationshipDynamics::addJealousy($nearbyDynamics, $jealousyGain, $npcName); + RelationshipDynamics::saveDynamics($nearbyNpc, $nearbyDynamics); + } + } +} + +skip_jealousy: + +// ------------------------------------------------------------------------- +// 6. Conflict resolution check +// ------------------------------------------------------------------------- +if (!($reldynCfg['conflict_enabled'] ?? true)) goto skip_conflict; +if ($passionGain > 0 && !empty($dynamics['in_conflict'])) { + $repairBurst = RelationshipDynamics::recordConflictPositive($dynamics); + if ($repairBurst > 0) { + RelationshipDynamics::addPassion($dynamics, $repairBurst, 'repair'); + } +} + +skip_conflict: + +// ------------------------------------------------------------------------- +// 7. Track positive interactions + stage advancement +// ------------------------------------------------------------------------- +if ($passionGain > 0) { + $dynamics['total_positive_interactions'] = intval($dynamics['total_positive_interactions'] ?? 0) + 1; + RelationshipDynamics::checkStageAdvancement($dynamics); +} + +// ------------------------------------------------------------------------- +// 8. Save +// ------------------------------------------------------------------------- +RelationshipDynamics::saveDynamics($npcName, $dynamics); diff --git a/ext/relationship_dynamics/prerequest.php b/ext/relationship_dynamics/prerequest.php new file mode 100644 index 00000000..e6de96d1 --- /dev/null +++ b/ext/relationship_dynamics/prerequest.php @@ -0,0 +1,211 @@ +escape($npcName); + $row = $db->fetchOne("SELECT extended_data FROM core_npc_master WHERE lower(npc_name) = lower('{$escaped}') LIMIT 1"); + if (is_array($row) && !empty($row['extended_data'])) { + $ext = json_decode($row['extended_data'], true) ?: []; + $playerRel = $ext['relationships'][$playerName] ?? null; + if ($playerRel) { + // Use raw CHIM affinity (-100..+100) directly + // reunion_min_affection default 40 means CHIM aff >= 40 (Friendly+) + $npcAffection = intval($playerRel['aff'] ?? 0); + } + } + } +} catch (Throwable $e) { + // Use default +} + +// Snapshot current CHIM affinity for RPM→Speed delta in postrequest +$GLOBALS['RELDYN_PRE_AFF'] = null; +try { + $db2 = $GLOBALS['db'] ?? null; + if ($db2) { + $playerName2 = trim($GLOBALS['PLAYER_NAME'] ?? 'Player'); + $escaped2 = $db2->escape($npcName); + $row2 = $db2->fetchOne("SELECT extended_data FROM core_npc_master WHERE lower(npc_name) = lower('{$escaped2}') LIMIT 1"); + if (is_array($row2) && !empty($row2['extended_data'])) { + $ext2 = json_decode($row2['extended_data'], true) ?: []; + $pRel = $ext2['relationships'][$playerName2] ?? null; + if ($pRel) { + $GLOBALS['RELDYN_PRE_AFF'] = intval($pRel['aff'] ?? 0); + } + } + } +} catch (Throwable $e) { + // Best effort +} + +$reunionPassion = ($reldynCfg['reunion_enabled'] ?? true) ? RelationshipDynamics::checkReunion($dynamics, $npcAffection) : 0; +if ($reunionPassion > 0) { + RelationshipDynamics::addPassion($dynamics, $reunionPassion, 'reunion'); +} + +// ------------------------------------------------------------------------- +// Ambient presence: being in a location matching NPC interests builds +// warmth passively and resists decay. No interactions needed. +// ------------------------------------------------------------------------- +$ambientResult = ($reldynCfg['ambient_enabled'] ?? true) ? RelationshipDynamics::detectCurrentInterest($dynamics) : null; +$ambientInterest = is_array($ambientResult) ? ($ambientResult['interest'] ?? null) : $ambientResult; +$ambientResonance = is_array($ambientResult) ? ($ambientResult['resonance'] ?? 0.0) : 0.0; +$ambientSource = is_array($ambientResult) ? ($ambientResult['source'] ?? 'none') : 'none'; +$ambientLocation = is_array($ambientResult) ? ($ambientResult['location'] ?? '') : ''; + +// Store for context.php and postrequest.php to use +$GLOBALS['RELDYN_AMBIENT_INTEREST'] = $ambientInterest; +$GLOBALS['RELDYN_AMBIENT_RESONANCE'] = $ambientResonance; +$GLOBALS['RELDYN_AMBIENT_LOCATION'] = $ambientLocation; +$GLOBALS['RELDYN_AMBIENT_SOURCE'] = $ambientSource; + +if ($ambientInterest && $ambientResonance >= 0.15) { + // Use resonance score directly for vector path, or interest multiplier for keyword path + if ($ambientSource === 'vector') { + // Vector resonance: scale 0.15-0.65 → multiplier 1.1-2.0 + $ambientMult = 1.0 + min(1.0, ($ambientResonance - 0.15) * 2.0); + } else { + // Keyword fallback: use NPC's interest slider value + $interests = RelationshipDynamics::getInterests($dynamics); + $ambientMult = floatval($interests[$ambientInterest] ?? 1.0); + } + + if ($ambientMult > 1.0) { + // CEILING — trickle builds up to this, then stops + // Aela in wilderness (resonance 0.61): ceiling ≈ 19 + // Ashe in Dwemer ruin (resonance 0.65): ceiling ≈ 20 + $ambientCeiling = 10.0 * $ambientMult; + $currentPassion = floatval($dynamics['passion'] ?? 0); + + // TRICKLE — passive gain, ~0.3/min at high resonance, stops at ceiling + $lastAmbient = intval($dynamics['_ambient_updated_at'] ?? 0); + $minutesSince = $lastAmbient > 0 ? (time() - $lastAmbient) / 60.0 : 0; + if ($minutesSince > 0.5 && $currentPassion < $ambientCeiling) { + $trickle = min($ambientCeiling - $currentPassion, 0.3 * ($ambientMult - 1.0) * $minutesSince); + $dynamics['passion'] = $currentPassion + $trickle; + $dynamics['_ambient_updated_at'] = time(); + if ($trickle > 0.01) { + RelationshipDynamics::log("Ambient trickle: {$npcName} @ '{$ambientLocation}' ({$ambientSource}, resonance=" . round($ambientResonance, 3) . ", mult={$ambientMult}x) +{" . round($trickle, 2) . "} passion=" . round($dynamics['passion'], 1) . " (ceiling=" . round($ambientCeiling, 0) . ")"); + } + } elseif ($lastAmbient === 0) { + $dynamics['_ambient_updated_at'] = time(); + } + + // DECAY RESIST — while in matching location, reduce decay rate + $dynamics['_ambient_decay_resist'] = 1.0 / $ambientMult; + } +} else { + // Not in a matching location — clear decay resist + unset($dynamics['_ambient_decay_resist']); +} + +// Calculate effective disposition (overlay on existing sex_disposal) +$npcNameKey = "aiagent_nsfw_intimacy_" . strtolower(str_replace(' ', '_', $npcName)); +$existingDisposal = 0; +try { + if (isset($GLOBALS['db'])) { + $escapedKey = $GLOBALS['db']->escape($npcNameKey); + $confRow = $GLOBALS['db']->fetchOne("SELECT value FROM conf_opts WHERE id = '{$escapedKey}' LIMIT 1"); + if (is_array($confRow) && !empty($confRow['value'])) { + $intimacyData = json_decode($confRow['value'], true) ?: []; + $existingDisposal = intval($intimacyData['sex_disposal'] ?? 0); + } + } +} catch (Throwable $e) { + // Use 0 +} + +$effectiveDisposal = RelationshipDynamics::getEffectiveDisposition($existingDisposal, $dynamics); + +// Snapshot NPC name AND player name for postrequest (processor/postrequest.php re-requires +// conf.php which resets HERIKA_NAME to 'The Narrator' and PLAYER_NAME to 'Prisoner') +$GLOBALS['RELDYN_NPC_NAME'] = $npcName; +$GLOBALS['RELDYN_PLAYER_NAME'] = $GLOBALS['PLAYER_NAME'] ?? 'Player'; + +// Store effective disposal for other extensions to read +$GLOBALS['RELDYN_EFFECTIVE_DISPOSAL'] = $effectiveDisposal; +$GLOBALS['RELDYN_PASSION'] = floatval($dynamics['passion']); +$GLOBALS['RELDYN_JEALOUSY'] = floatval($dynamics['jealousy_anger']); +$GLOBALS['RELDYN_STAGE'] = $dynamics['stage'] ?? 'early'; +$GLOBALS['RELDYN_LOVE_LANG_PRIMARY'] = $dynamics['love_language_primary']; +$GLOBALS['RELDYN_LOVE_LANG_SECONDARY'] = $dynamics['love_language_secondary']; + +// Set blush multiplier for love language match +// (maras_bridge reads this to scale blush duration) +$GLOBALS['RELDYN_BLUSH_MULTIPLIER'] = 1.0; + +// Bridge to Sharmat: write effective disposal so Sharmat's scene gating reads it +// Only fires if Sharmat is installed — zero dependency otherwise +if (class_exists('NsfwNpcData')) { + try { + NsfwNpcData::setKey($npcName, 'sex_disposal', intval($effectiveDisposal)); + } catch (\Throwable $e) { + // Sharmat not available — silently continue + } +} + +// Save dynamics (decay + reunion applied) +RelationshipDynamics::saveDynamics($npcName, $dynamics); diff --git a/ext/relationship_dynamics/relationship_dynamics.php b/ext/relationship_dynamics/relationship_dynamics.php new file mode 100644 index 00000000..4d508561 --- /dev/null +++ b/ext/relationship_dynamics/relationship_dynamics.php @@ -0,0 +1,2113 @@ + ['decay_rate' => 0.10, 'half_life' => 10.0, 'lambda' => 0.069, 'passion_decay' => 2.5], + 'moderate' => ['decay_rate' => 0.08, 'half_life' => 8.0, 'lambda' => 0.087, 'passion_decay' => 3.0], + 'quick_warmth' => ['decay_rate' => 0.06, 'half_life' => 6.0, 'lambda' => 0.116, 'passion_decay' => 4.0], + 'guarded' => ['decay_rate' => 0.12, 'half_life' => 12.0, 'lambda' => 0.058, 'passion_decay' => 5.0], + ]; + + // Stage properties: [passion_floor, passion_ceiling, gain_mult, dr_rate_mult] + const STAGE_PARAMS = [ + 'early' => ['floor' => 0, 'ceiling' => 100, 'gain_mult' => 1.3, 'dr_mult' => 0.8], + 'established' => ['floor' => 5, 'ceiling' => 70, 'gain_mult' => 1.0, 'dr_mult' => 1.2], + 'deep' => ['floor' => 15, 'ceiling' => 50, 'gain_mult' => 0.8, 'dr_mult' => 1.0], + ]; + + // Temperament → passion gain multiplier (used when MARAS or inferred temperament available) + const TEMPERAMENT_PASSION_MULT = [ + 'Romantic' => 1.3, + 'Anxious' => 1.2, + 'Playful' => 1.15, + 'Humble' => 1.1, + 'Nurturing' => 1.05, + 'Gentle' => 1.0, + 'Jealous' => 1.0, + 'Stoic' => 0.85, + 'Proud' => 0.8, + 'Bold' => 0.75, + 'Independent' => 0.7, + 'Defiant' => 0.7, + 'Guarded' => 0.6, + ]; + + // Temperament -> bleedout passion drain (negative: how much passion is lost when NPC falls) + // Guarded/Stoic NPCs lose more (they see vulnerability as weakness) + // Nurturing/Anxious NPCs lose less (they bond through shared danger) + const TEMPERAMENT_BLEEDOUT_DRAIN = [ + 'Anxious' => -3.0, // Spirals into panic, abandonment terror + 'Guarded' => -2.5, // Walls slam up instantly + 'Independent' => -2.0, // Vulnerability is intolerable + 'Proud' => -2.0, // Humiliation of helplessness + 'Jealous' => -1.5, // Fear of being replaced while weak + 'Gentle' => -1.5, // Deeply shaken by violence + 'Romantic' => -1.0, // Scared but trusts their partner + 'Nurturing' => -1.0, // Worried about others, not self + 'Humble' => -0.8, // Accepts it quietly + 'Playful' => -0.5, // Shakes it off with humor + 'Stoic' => -0.5, // Barely registers externally + 'Bold' => -0.3, // Rage fuel, not fear + 'Defiant' => 1.0, // Fights HARDER when cornered — passion UP + ]; + + + // Temperament → reunion multiplier + const TEMPERAMENT_REUNION_MULT = [ + 'Romantic' => 1.5, + 'Anxious' => 1.4, + 'Jealous' => 1.2, + 'Playful' => 1.1, + 'Humble' => 1.0, + 'Nurturing' => 1.0, + 'Gentle' => 0.9, + 'Proud' => 0.8, + 'Stoic' => 0.7, + 'Bold' => 0.6, + 'Guarded' => 0.6, + 'Independent' => 0.5, + 'Defiant' => 0.5, + ]; + + // Temperament → jealousy multiplier + const TEMPERAMENT_JEALOUSY_MULT = [ + 'Anxious' => 2.5, + 'Jealous' => 2.0, + 'Proud' => 1.5, + 'Romantic' => 1.3, + 'Nurturing' => 1.0, + 'Playful' => 0.8, + 'Gentle' => 0.7, + 'Guarded' => 0.6, + 'Humble' => 0.5, + 'Stoic' => 0.5, + 'Bold' => 0.4, + 'Independent' => 0.3, + 'Defiant' => 0.3, + ]; + + // ========================================================================= + // INTERESTS SYSTEM (modulates ALL love languages) + // ========================================================================= + + const INTEREST_TYPES = [ + 'combat', 'crafting', 'alchemy', 'enchanting', 'scholarly', + 'nature', 'social', 'domestic', 'adventure', 'spiritual', 'wealth', + ]; + + // Location keyword → interest category mapping + const KEYWORD_TO_INTEREST = [ + 'forge' => 'crafting', + 'smithy' => 'crafting', + 'smelter' => 'crafting', + 'tannery' => 'crafting', + 'grindstone' => 'crafting', + 'alchemy' => 'alchemy', + 'potion_store' => 'alchemy', + 'enchanter' => 'enchanting', + 'cooking' => 'domestic', + 'tavern' => 'social', + 'inn' => 'social', + 'store' => 'social', + 'city' => 'social', + 'town' => 'social', + 'village' => 'social', + 'settlement' => 'social', + 'dungeon' => 'adventure', + 'ruin' => 'adventure', + 'nordic_ruin' => 'adventure', + 'dwemer_ruin' => 'adventure', + 'cave' => 'adventure', + 'tomb' => 'adventure', + 'crypt' => 'adventure', + 'barrow' => 'adventure', + 'mine' => 'adventure', + 'camp' => 'nature', + 'bandit_camp' => 'adventure', + 'military_camp'=> 'nature', + 'giant_camp' => 'nature', + 'farm' => 'nature', + 'lumber_mill' => 'nature', + 'library' => 'scholarly', + 'college' => 'scholarly', + 'temple' => 'spiritual', + ]; + + // Item name keyword → interest category (for gift classification) + const ITEM_INTEREST_MAP = [ + // Combat (weapons + armor) + 'sword' => 'combat', 'axe' => 'combat', 'mace' => 'combat', 'bow' => 'combat', + 'arrow' => 'combat', 'dagger' => 'combat', 'greatsword' => 'combat', + 'battleaxe' => 'combat', 'warhammer' => 'combat', 'shield' => 'combat', + 'armor' => 'combat', 'helmet' => 'combat', 'gauntlet' => 'combat', + 'boots' => 'combat', 'cuirass' => 'combat', 'war ' => 'combat', + // Crafting + 'ore' => 'crafting', 'ingot' => 'crafting', 'leather' => 'crafting', + 'hide' => 'crafting', 'strip' => 'crafting', 'firewood' => 'crafting', + // Alchemy + 'potion' => 'alchemy', 'elixir' => 'alchemy', 'poison' => 'alchemy', + 'ingredient' => 'alchemy', 'flower' => 'alchemy', 'root' => 'alchemy', + 'wing' => 'alchemy', 'dust' => 'alchemy', 'salt' => 'alchemy', + 'herb' => 'alchemy', 'mushroom' => 'alchemy', 'petal' => 'alchemy', + 'extract' => 'alchemy', 'eye of' => 'alchemy', + // Enchanting + 'soul gem' => 'enchanting', 'soul_gem' => 'enchanting', + 'staff' => 'enchanting', 'scroll' => 'enchanting', + // Scholarly + 'book' => 'scholarly', 'tome' => 'scholarly', 'journal' => 'scholarly', + 'note' => 'scholarly', 'letter' => 'scholarly', 'map' => 'scholarly', + 'spell tome' => 'scholarly', + // Nature + 'pelt' => 'nature', 'antler' => 'nature', 'claw' => 'nature', + 'feather' => 'nature', 'tusk' => 'nature', 'bone' => 'nature', + 'scale' => 'nature', + // Wealth + 'gem' => 'wealth', 'jewel' => 'wealth', 'ruby' => 'wealth', + 'sapphire' => 'wealth', 'emerald' => 'wealth', 'diamond' => 'wealth', + 'necklace' => 'wealth', 'ring' => 'wealth', 'circlet' => 'wealth', + 'gold' => 'wealth', 'silver' => 'wealth', + // Domestic + 'food' => 'domestic', 'bread' => 'domestic', 'cheese' => 'domestic', + 'meat' => 'domestic', 'stew' => 'domestic', 'pie' => 'domestic', + 'soup' => 'domestic', 'ale' => 'domestic', 'wine' => 'domestic', + 'mead' => 'domestic', 'sweet roll' => 'domestic', + // Spiritual + 'amulet of' => 'spiritual', 'blessing' => 'spiritual', + 'divine' => 'spiritual', 'holy' => 'spiritual', 'talos' => 'spiritual', + ]; + + // How strongly interest multiplier affects each love language + // Formula: effectiveMult = 1.0 + (rawMult - 1.0) * weight + const LL_INTEREST_WEIGHT = [ + 'quality_time' => 1.0, // Full weight + 'gifts' => 0.8, // Strong — gift matching is very relevant + 'acts_of_service' => 0.6, // Moderate — context-dependent + 'words_of_affirmation' => 0.4, // Mild — conversation topic proxy + 'physical_touch' => 0.15, // Minimal — slight combat context boost + ]; + + // Class → default interest preferences + const CLASS_INTEREST_DEFAULTS = [ + 'Barbarian' => [ + 'combat' => 1.5, 'adventure' => 1.4, 'nature' => 1.3, 'crafting' => 1.2, + 'domestic' => 0.9, 'scholarly' => 0.8, 'social' => 0.8, 'wealth' => 0.9, + 'alchemy' => 0.9, 'enchanting' => 0.9, 'spiritual' => 0.9, + ], + 'Warrior' => [ + 'combat' => 1.5, 'crafting' => 1.3, 'adventure' => 1.2, 'social' => 1.1, + 'nature' => 1.0, 'domestic' => 1.0, 'wealth' => 1.0, + 'scholarly' => 0.8, 'alchemy' => 0.9, 'enchanting' => 0.9, 'spiritual' => 0.9, + ], + 'Mage' => [ + 'scholarly' => 1.5, 'enchanting' => 1.4, 'alchemy' => 1.3, 'spiritual' => 1.1, + 'adventure' => 1.0, 'social' => 1.0, 'domestic' => 1.0, 'wealth' => 1.0, + 'nature' => 0.9, 'crafting' => 0.8, 'combat' => 0.8, + ], + 'Thief' => [ + 'adventure' => 1.4, 'social' => 1.4, 'wealth' => 1.3, 'crafting' => 1.0, + 'domestic' => 0.9, 'nature' => 1.0, 'combat' => 1.0, + 'scholarly' => 0.8, 'enchanting' => 0.9, 'alchemy' => 1.0, 'spiritual' => 0.8, + ], + 'Ranger' => [ + 'nature' => 1.5, 'combat' => 1.3, 'adventure' => 1.3, 'crafting' => 1.2, + 'domestic' => 1.1, 'alchemy' => 1.1, + 'social' => 0.8, 'scholarly' => 0.8, 'enchanting' => 0.9, 'wealth' => 0.9, 'spiritual' => 0.9, + ], + 'Healer' => [ + 'alchemy' => 1.5, 'spiritual' => 1.4, 'scholarly' => 1.2, 'domestic' => 1.2, + 'social' => 1.1, 'nature' => 1.1, 'enchanting' => 1.1, + 'combat' => 0.8, 'adventure' => 0.9, 'crafting' => 0.9, 'wealth' => 0.9, + ], + 'Noble' => [ + 'social' => 1.4, 'wealth' => 1.4, 'scholarly' => 1.2, 'domestic' => 1.1, + 'enchanting' => 1.0, 'spiritual' => 1.0, 'alchemy' => 1.0, + 'crafting' => 0.9, 'nature' => 0.8, 'combat' => 0.8, 'adventure' => 0.9, + ], + 'Merchant' => [ + 'social' => 1.5, 'wealth' => 1.5, 'domestic' => 1.2, 'crafting' => 1.1, + 'scholarly' => 1.0, 'alchemy' => 1.0, 'enchanting' => 1.0, + 'combat' => 0.7, 'adventure' => 0.7, 'nature' => 0.8, 'spiritual' => 0.9, + ], + ]; + + // Skill name fragments → interest bonus (+0.2 added to preference for high skills) + const SKILL_INTEREST_BONUS = [ + 'two-handed' => 'combat', + 'one-handed' => 'combat', + 'archery' => 'combat', + 'block' => 'combat', + 'heavy armor' => 'combat', + 'smithing' => 'crafting', + 'alchemy' => 'alchemy', + 'enchanting' => 'enchanting', + 'destruction' => 'scholarly', + 'conjuration' => 'scholarly', + 'alteration' => 'scholarly', + 'illusion' => 'scholarly', + 'restoration' => 'scholarly', + 'light armor' => 'adventure', + 'sneak' => 'adventure', + 'lockpicking' => 'adventure', + 'pickpocket' => 'social', + 'speech' => 'social', + ]; + + // ========================================================================= + // CONFIG + // ========================================================================= + + public static function getConfig() + { + if (self::$config !== null) { + return self::$config; + } + + try { + $db = $GLOBALS['db'] ?? null; + if (!$db) { + self::$config = self::defaultConfig(); + return self::$config; + } + + $row = $db->fetchOne("SELECT value FROM conf_opts WHERE id = 'relationship_dynamics_config' LIMIT 1"); + if (!is_array($row) || empty($row['value'])) { + self::$config = self::defaultConfig(); + return self::$config; + } + + self::$config = json_decode($row['value'], true) ?: self::defaultConfig(); + } catch (Throwable $e) { + error_log("[RelDyn] Config load error: " . $e->getMessage()); + self::$config = self::defaultConfig(); + } + + return self::$config; + } + + public static function defaultConfig() + { + return [ + 'enabled' => true, + 'base_passion_gain' => 2.0, + 'passion_max' => 100.0, + 'jealousy_max' => 100.0, + 'jealousy_decay_per_hour' => 1.5, + 'conflict_threshold_affinity_drop' => 10, + 'conflict_threshold_jealousy' => 40, + 'conflict_resolution_positive_count' => 3, + 'conflict_repair_passion_burst' => 20.0, + 'conflict_repair_passion_mult' => 1.5, + 'reunion_min_hours' => 8, + 'reunion_min_affection' => 40, + 'stage_established_threshold' => 50, + 'stage_deep_threshold' => 200, + 'log_enabled' => false, + ]; + } + + public static function clearConfigCache() + { + self::$config = null; + } + + public static function isEnabled() + { + $cfg = self::getConfig(); + return !empty($cfg['enabled']); + } + + // ========================================================================= + // NPC DYNAMICS DATA (read/write from nsfw_npc_data.extended_data) + // ========================================================================= + + public static function getDynamics($npcName) + { + if (empty($npcName)) return self::defaultDynamics(); + + $cacheKey = strtolower($npcName); + if (isset(self::$npcCache[$cacheKey])) { + return self::$npcCache[$cacheKey]; + } + + // Primary: read from core_npc_master.extended_data.relationship_dynamics + try { + $db = $GLOBALS['db'] ?? null; + if ($db) { + $escaped = $db->escape($npcName); + $row = $db->fetchOne("SELECT extended_data FROM core_npc_master WHERE lower(npc_name) = lower('{$escaped}') LIMIT 1"); + if (is_array($row) && !empty($row['extended_data'])) { + $ext = json_decode($row['extended_data'], true) ?: []; + $rd = $ext['relationship_dynamics'] ?? null; + if (is_array($rd) && !empty($rd)) { + self::$npcCache[$cacheKey] = array_merge(self::defaultDynamics(), $rd); + return self::$npcCache[$cacheKey]; + } + } + } + } catch (\Throwable $e) { + self::log("getDynamics DB error: " . $e->getMessage()); + } + + // Fallback: try nsfw_npc_data (legacy, pre-storage-pivot) + if (class_exists('NsfwNpcData')) { + $rd = NsfwNpcData::getKey($npcName, 'relationship_dynamics'); + if (is_array($rd) && !empty($rd)) { + self::$npcCache[$cacheKey] = array_merge(self::defaultDynamics(), $rd); + return self::$npcCache[$cacheKey]; + } + } + + // No data found: return defaults + $defaults = self::defaultDynamics(); + self::$npcCache[$cacheKey] = $defaults; + return $defaults; + } + + public static function saveDynamics($npcName, $dynamics) + { + if (empty($npcName)) return false; + + $cacheKey = strtolower($npcName); + self::$npcCache[$cacheKey] = $dynamics; + + // Primary: save to core_npc_master.extended_data.relationship_dynamics + try { + $db = $GLOBALS['db'] ?? null; + if ($db) { + $escaped = $db->escape($npcName); + $jsonDynamics = json_encode($dynamics); + $escapedJson = $db->escape($jsonDynamics); + $db->execQuery("UPDATE core_npc_master SET extended_data = jsonb_set(COALESCE(extended_data, '{}'::jsonb), '{relationship_dynamics}', '{$escapedJson}'::jsonb) WHERE lower(npc_name) = lower('{$escaped}')"); + return true; + } + } catch (\Throwable $e) { + self::log("saveDynamics DB error: " . $e->getMessage()); + } + + // Fallback: try nsfw_npc_data (legacy) + if (class_exists('NsfwNpcData')) { + return NsfwNpcData::setKey($npcName, 'relationship_dynamics', $dynamics); + } + + return false; + } + + public static function defaultDynamics() + { + return [ + 'love_language_primary' => null, + 'love_language_secondary' => null, + 'warmth_curve' => null, + + 'passion' => 0.0, + 'passion_updated_at' => 0, + 'passion_sources' => ['love_match' => 0, 'reunion' => 0, 'dramatic' => 0, 'repair' => 0], + + 'jealousy_anger' => 0.0, + 'jealousy_updated_at' => 0, + 'jealousy_trigger_npc' => null, + + 'in_conflict' => false, + 'conflict_entered_at' => 0, + 'conflict_positive_count' => 0, + + 'interaction_count' => 0, + 'last_interaction_at' => 0, + + 'last_seen_at' => 0, + 'reunion_spike_given' => false, + + 'total_positive_interactions' => 0, + 'stage' => self::STAGE_EARLY, + + 'love_language_hints_given' => 0, + + 'inferred_temperament' => null, + ]; + } + + // ========================================================================= + // LOVE LANGUAGE AUTO-GENERATION + // ========================================================================= + + /** + * Ensure NPC has love languages assigned. Auto-generates if missing. + * Priority: MARAS temperament > Sharmat profile > CHIM race/faction + */ + public static function ensureLoveLanguage($npcName, &$dynamics) + { + if (!empty($dynamics['love_language_primary'])) { + return; // Already set + } + + $primary = null; + $secondary = null; + $temperament = null; + $warmthCurve = null; + + // Priority 1: MARAS temperament + $marasTemp = self::getMarasTemperament($npcName); + if ($marasTemp) { + $temperament = $marasTemp; + $primary = self::temperamentToLoveLanguage($marasTemp); + $warmthCurve = self::temperamentToWarmthCurve($marasTemp); + } + + // Priority 2: Sharmat profile inference + if (!$primary) { + $speechStyle = self::getSharmatSpeechStyle($npcName); + if ($speechStyle) { + $primary = self::speechStyleToLoveLanguage($speechStyle); + $temperament = self::speechStyleToTemperament($speechStyle); + $warmthCurve = self::temperamentToWarmthCurve($temperament); + } + } + + // Priority 3: CHIM race/faction fallback + if (!$primary) { + $race = self::getNpcRace($npcName); + $primary = self::raceToLoveLanguage($race); + $warmthCurve = self::CURVE_MODERATE; // default + } + + // Secondary from social context + $socialClass = self::getSocialClass($npcName); + $secondary = self::socialClassToLoveLanguage($socialClass); + + // If secondary == primary, rotate + if ($secondary === $primary) { + $secondary = self::rotateLoveLanguage($primary); + } + + $dynamics['love_language_primary'] = $primary ?: self::LL_TIME; + $dynamics['love_language_secondary'] = $secondary ?: self::LL_WORDS; + $dynamics['warmth_curve'] = $warmthCurve ?: self::CURVE_MODERATE; + $dynamics['inferred_temperament'] = $temperament; + + self::log("Auto-gen LL for {$npcName}: primary={$dynamics['love_language_primary']}, secondary={$dynamics['love_language_secondary']}, curve={$dynamics['warmth_curve']}, temp={$temperament}"); + } + + // ---- Love language mapping helpers ---- + + private static function temperamentToLoveLanguage($temperament) + { + $map = [ + 'Romantic' => self::LL_WORDS, + 'Jealous' => self::LL_TIME, + 'Proud' => self::LL_SERVICE, + 'Humble' => self::LL_GIFTS, + 'Independent' => self::LL_TIME, + ]; + return $map[$temperament] ?? self::LL_TIME; + } + + private static function speechStyleToLoveLanguage($style) + { + $style = strtolower(trim($style)); + $map = [ + 'passionate' => self::LL_WORDS, 'romantic' => self::LL_WORDS, 'seductive' => self::LL_WORDS, + 'submissive' => self::LL_TOUCH, 'shy' => self::LL_TOUCH, 'gentle' => self::LL_TOUCH, + 'dominant' => self::LL_SERVICE, 'aggressive' => self::LL_SERVICE, 'bratty' => self::LL_SERVICE, + 'playful' => self::LL_TIME, 'teasing' => self::LL_TIME, 'flirty' => self::LL_TIME, + 'reserved' => self::LL_GIFTS, 'cold' => self::LL_GIFTS, 'formal' => self::LL_GIFTS, + ]; + return $map[$style] ?? null; + } + + private static function speechStyleToTemperament($style) + { + $style = strtolower(trim($style)); + $map = [ + 'passionate' => 'Romantic', 'romantic' => 'Romantic', 'seductive' => 'Romantic', + 'shy' => 'Anxious', 'submissive' => 'Gentle', 'gentle' => 'Gentle', + 'dominant' => 'Bold', 'aggressive' => 'Defiant', 'bratty' => 'Defiant', + 'playful' => 'Playful', 'teasing' => 'Playful', 'flirty' => 'Playful', + 'reserved' => 'Guarded', 'cold' => 'Stoic', 'formal' => 'Proud', + 'nurturing' => 'Nurturing', 'motherly' => 'Nurturing', 'caring' => 'Nurturing', + 'measured' => 'Guarded', 'cautious' => 'Guarded', 'stoic' => 'Stoic', + 'bold' => 'Bold', 'confident' => 'Bold', 'commanding' => 'Bold', + 'anxious' => 'Anxious', 'nervous' => 'Anxious', 'clingy' => 'Anxious', + 'jealous' => 'Jealous', 'possessive' => 'Jealous', + 'humble' => 'Humble', 'modest' => 'Humble', + 'independent' => 'Independent', 'aloof' => 'Independent', + 'defiant' => 'Defiant', 'rebellious' => 'Defiant', + ]; + return $map[$style] ?? null; + } + + private static function raceToLoveLanguage($race) + { + $race = strtolower(trim($race ?? '')); + $map = [ + 'khajiit' => self::LL_TOUCH, 'woodelf' => self::LL_TOUCH, 'bosmer' => self::LL_TOUCH, + 'highelf' => self::LL_GIFTS, 'altmer' => self::LL_GIFTS, 'imperial' => self::LL_GIFTS, + 'nord' => self::LL_SERVICE, 'orc' => self::LL_SERVICE, 'orsimer' => self::LL_SERVICE, + 'breton' => self::LL_WORDS, 'darkelf' => self::LL_WORDS, 'dunmer' => self::LL_WORDS, + 'redguard' => self::LL_SERVICE, 'argonian' => self::LL_TIME, + ]; + return $map[$race] ?? self::LL_TIME; + } + + private static function socialClassToLoveLanguage($socialClass) + { + $class = strtolower(trim($socialClass ?? '')); + $map = [ + 'nobles' => self::LL_GIFTS, 'rulers' => self::LL_GIFTS, + 'wealthy' => self::LL_TIME, 'middle' => self::LL_TIME, + 'working' => self::LL_SERVICE, 'poverty' => self::LL_SERVICE, + 'religious' => self::LL_WORDS, + 'outcast' => self::LL_TOUCH, + ]; + return $map[$class] ?? self::LL_WORDS; + } + + private static function rotateLoveLanguage($ll) + { + $rotation = [ + self::LL_WORDS => self::LL_TIME, + self::LL_TIME => self::LL_TOUCH, + self::LL_TOUCH => self::LL_WORDS, + self::LL_SERVICE => self::LL_WORDS, + self::LL_GIFTS => self::LL_TIME, + ]; + return $rotation[$ll] ?? self::LL_WORDS; + } + + private static function temperamentToWarmthCurve($temperament) + { + $map = [ + 'Romantic' => self::CURVE_SLOW_BURN, + 'Anxious' => self::CURVE_QUICK, + 'Playful' => self::CURVE_QUICK, + 'Bold' => self::CURVE_MODERATE, + 'Humble' => self::CURVE_MODERATE, + 'Nurturing' => self::CURVE_MODERATE, + 'Gentle' => self::CURVE_SLOW_BURN, + 'Jealous' => self::CURVE_SLOW_BURN, + 'Defiant' => self::CURVE_GUARDED, + 'Stoic' => self::CURVE_GUARDED, + 'Proud' => self::CURVE_GUARDED, + 'Guarded' => self::CURVE_GUARDED, + 'Independent' => self::CURVE_GUARDED, + ]; + return $map[$temperament] ?? self::CURVE_MODERATE; + } + + // ---- Data source helpers ---- + + private static function getMarasTemperament($npcName) + { + try { + $db = $GLOBALS['db'] ?? null; + if (!$db) return null; + + $escaped = $db->escape($npcName); + $row = $db->fetchOne("SELECT extended_data FROM core_npc_master WHERE lower(npc_name) = lower('{$escaped}') LIMIT 1"); + if (!is_array($row) || empty($row['extended_data'])) return null; + + $ext = json_decode($row['extended_data'], true) ?: []; + $playerName = trim($GLOBALS['PLAYER_NAME'] ?? 'Player'); + $maras = $ext['relationships'][$playerName]['maras'] ?? null; + return $maras['temperament'] ?? null; + } catch (Throwable $e) { + return null; + } + } + + private static function getSharmatSpeechStyle($npcName) + { + if (!class_exists('NsfwNpcData')) return null; + return NsfwNpcData::getKey($npcName, 'sex_speech_style'); + } + + private static function getNpcRace($npcName) + { + try { + $db = $GLOBALS['db'] ?? null; + if (!$db) return null; + + $escaped = $db->escape($npcName); + $row = $db->fetchOne("SELECT race FROM core_npc_master WHERE lower(npc_name) = lower('{$escaped}') LIMIT 1"); + return $row['race'] ?? null; + } catch (Throwable $e) { + return null; + } + } + + private static function getSocialClass($npcName) + { + // Try MARAS social_class first + try { + $db = $GLOBALS['db'] ?? null; + if (!$db) return null; + + $escaped = $db->escape($npcName); + $row = $db->fetchOne("SELECT extended_data FROM core_npc_master WHERE lower(npc_name) = lower('{$escaped}') LIMIT 1"); + if (!is_array($row) || empty($row['extended_data'])) return null; + + $ext = json_decode($row['extended_data'], true) ?: []; + $playerName = trim($GLOBALS['PLAYER_NAME'] ?? 'Player'); + $maras = $ext['relationships'][$playerName]['maras'] ?? null; + return $maras['socialClass'] ?? null; + } catch (Throwable $e) { + return null; + } + } + + // ========================================================================= + // PASSION CALCULATIONS + // ========================================================================= + + /** + * Apply time-based passion decay. Call at prerequest time. + */ + public static function decayPassion(&$dynamics) + { + $now = time(); + $lastUpdate = intval($dynamics['passion_updated_at'] ?? 0); + if ($lastUpdate <= 0) { + $dynamics['passion_updated_at'] = $now; + return; + } + + $hoursSince = ($now - $lastUpdate) / 3600.0; + if ($hoursSince <= 0) return; + + // Cap decay hours — 0 means no between-session decay (passion frozen when offline) + $cfg = self::getConfig(); + $maxDecayHours = floatval($cfg['decay_max_hours'] ?? 0); + if ($maxDecayHours > 0) { + $hoursSince = min($hoursSince, $maxDecayHours); + } elseif ($maxDecayHours == 0) { + // Between-session decay disabled — only decay within active play sessions + // Cap at 10 minutes to handle normal in-session gaps + $hoursSince = min($hoursSince, 0.167); + } + + $curve = $dynamics['warmth_curve'] ?? self::CURVE_MODERATE; + $params = self::CURVE_PARAMS[$curve] ?? self::CURVE_PARAMS[self::CURVE_MODERATE]; + $decayRate = $params['passion_decay']; + + $decay = $decayRate * $hoursSince; + $stage = $dynamics['stage'] ?? self::STAGE_EARLY; + $floor = self::STAGE_PARAMS[$stage]['floor'] ?? 0; + + $dynamics['passion'] = max($floor, floatval($dynamics['passion']) - $decay); + $dynamics['passion_updated_at'] = $now; + } + + /** + * Apply time-based jealousy decay. Call at prerequest time. + */ + public static function decayJealousy(&$dynamics) + { + $now = time(); + $lastUpdate = intval($dynamics['jealousy_updated_at'] ?? 0); + if ($lastUpdate <= 0 || floatval($dynamics['jealousy_anger'] ?? 0) <= 0) return; + + $hoursSince = ($now - $lastUpdate) / 3600.0; + if ($hoursSince <= 0) return; + + $cfg = self::getConfig(); + $decayRate = floatval($cfg['jealousy_decay_per_hour'] ?? 1.5); + $decay = $decayRate * $hoursSince; + + $dynamics['jealousy_anger'] = max(0.0, floatval($dynamics['jealousy_anger']) - $decay); + $dynamics['jealousy_updated_at'] = $now; + } + + /** + * Calculate passion gain from an interaction. + * Returns the passion gain amount (before adding to pool). + */ + public static function calculatePassionGain($dynamics, $interactionLoveLanguage) + { + $cfg = self::getConfig(); + $baseGain = floatval($cfg['base_passion_gain'] ?? 2.0); + + // Love language multiplier + $llMult = 1.0; + if ($interactionLoveLanguage) { + if ($interactionLoveLanguage === ($dynamics['love_language_primary'] ?? null)) { + $llMult = 2.0; + } elseif ($interactionLoveLanguage === ($dynamics['love_language_secondary'] ?? null)) { + $llMult = 1.5; + } + } + + // Diminishing returns multiplier (exponential decay) + $sessionMult = self::getSessionMultiplier($dynamics); + + // Stage multiplier + $stage = $dynamics['stage'] ?? self::STAGE_EARLY; + $stageMult = self::STAGE_PARAMS[$stage]['gain_mult'] ?? 1.0; + + // Temperament multiplier + $temperament = $dynamics['inferred_temperament'] ?? null; + $tempMult = self::TEMPERAMENT_PASSION_MULT[$temperament] ?? 1.0; + + // Interest multiplier (context-weighted by shared experience + NPC interests) + $interestMult = self::getInterestMultiplier($dynamics, $interactionLoveLanguage); + + // Conflict repair bonus + $repairMult = 1.0; + if (!empty($dynamics['in_conflict'])) { + $cfg2 = self::getConfig(); + $repairMult = floatval($cfg2['conflict_repair_passion_mult'] ?? 1.5); + } + + $gain = $baseGain * $llMult * $sessionMult * $stageMult * $tempMult * $interestMult * $repairMult; + + return max(0.0, $gain); + } + + /** + * Add passion to pool, respecting stage ceiling. + */ + public static function addPassion(&$dynamics, $amount, $source = 'love_match') + { + $stage = $dynamics['stage'] ?? self::STAGE_EARLY; + $ceiling = self::STAGE_PARAMS[$stage]['ceiling'] ?? 100; + $cfg = self::getConfig(); + $max = min(floatval($cfg['passion_max'] ?? 100.0), $ceiling); + + $dynamics['passion'] = min($max, floatval($dynamics['passion']) + $amount); + $dynamics['passion_updated_at'] = time(); + + // Track source + if (isset($dynamics['passion_sources'][$source])) { + $dynamics['passion_sources'][$source] += $amount; + } + } + + // ========================================================================= + // DIMINISHING RETURNS + // ========================================================================= + + /** + * Get the current session multiplier based on exponential decay of interaction count. + */ + public static function getSessionMultiplier($dynamics) + { + $now = time(); + $lastInteraction = intval($dynamics['last_interaction_at'] ?? 0); + $rawCount = intval($dynamics['interaction_count'] ?? 0); + + if ($rawCount <= 0 || $lastInteraction <= 0) { + return 1.0; + } + + $curve = $dynamics['warmth_curve'] ?? self::CURVE_MODERATE; + $params = self::CURVE_PARAMS[$curve] ?? self::CURVE_PARAMS[self::CURVE_MODERATE]; + $decayRate = $params['decay_rate']; + $lambda = $params['lambda']; + + // Stage modifier on decay_rate + $stage = $dynamics['stage'] ?? self::STAGE_EARLY; + $drMult = self::STAGE_PARAMS[$stage]['dr_mult'] ?? 1.0; + $effectiveDecayRate = $decayRate * $drMult; + + // Exponential decay of interaction count over real time + $hoursSince = ($now - $lastInteraction) / 3600.0; + $effectiveCount = $rawCount * exp(-$hoursSince * $lambda); + + $multiplier = 1.0 - ($effectiveCount * $effectiveDecayRate); + + return max(0.05, $multiplier); + } + + /** + * Increment interaction count after an interaction. + */ + public static function recordInteraction(&$dynamics) + { + $now = time(); + $lastInteraction = intval($dynamics['last_interaction_at'] ?? 0); + $rawCount = intval($dynamics['interaction_count'] ?? 0); + + // Apply exponential decay to existing count before incrementing + if ($lastInteraction > 0 && $rawCount > 0) { + $curve = $dynamics['warmth_curve'] ?? self::CURVE_MODERATE; + $params = self::CURVE_PARAMS[$curve] ?? self::CURVE_PARAMS[self::CURVE_MODERATE]; + $lambda = $params['lambda']; + $hoursSince = ($now - $lastInteraction) / 3600.0; + $rawCount = $rawCount * exp(-$hoursSince * $lambda); + } + + $dynamics['interaction_count'] = intval(ceil($rawCount)) + 1; + $dynamics['last_interaction_at'] = $now; + $dynamics['last_seen_at'] = $now; + $dynamics['reunion_spike_given'] = false; + } + + // ========================================================================= + // AFFINITY GAIN MULTIPLIER (RPM → Speed) + // ========================================================================= + + /** + * Calculate the affinity gain multiplier from current passion. + * passion 0 → 0.3x, passion 50 → 1.15x, passion 100 → 2.0x + */ + public static function getAffinityGainMultiplier($dynamics) + { + $passion = floatval($dynamics['passion'] ?? 0); + return 0.3 + ($passion / 100.0) * 1.7; + } + + // ========================================================================= + // REUNION SPIKE + // ========================================================================= + + /** + * Check for reunion spike. Call at prerequest time. + * Returns the passion amount added (0 if no reunion). + */ + public static function checkReunion(&$dynamics, $npcAffection = 0) + { + $cfg = self::getConfig(); + $minHours = floatval($cfg['reunion_min_hours'] ?? 8); + $minAff = intval($cfg['reunion_min_affection'] ?? 40); + + // Already spiked this visit + if (!empty($dynamics['reunion_spike_given'])) return 0.0; + + $lastSeen = intval($dynamics['last_seen_at'] ?? 0); + if ($lastSeen <= 0) { + // First time — initialize, no spike + $dynamics['last_seen_at'] = time(); + return 0.0; + } + + // Check affection threshold + if ($npcAffection < $minAff) return 0.0; + + $hoursApart = (time() - $lastSeen) / 3600.0; + if ($hoursApart < $minHours) return 0.0; + + // Calculate spike + $spike = 0.0; + if ($hoursApart >= 72) { + $spike = 25.0; + } elseif ($hoursApart >= 48) { + $spike = 18.0; + } elseif ($hoursApart >= 24) { + $spike = 12.0; + } elseif ($hoursApart >= 16) { + $spike = 8.0; + } else { + $spike = 5.0; + } + + // Temperament modifier + $temperament = $dynamics['inferred_temperament'] ?? null; + $tempMult = self::TEMPERAMENT_REUNION_MULT[$temperament] ?? 1.0; + $spike *= $tempMult; + + $dynamics['reunion_spike_given'] = true; + + self::log("Reunion spike for NPC: +{$spike} passion (hours_apart={$hoursApart}, temp_mult={$tempMult})"); + + return $spike; + } + + // ========================================================================= + // JEALOUSY + // ========================================================================= + + /** + * Check if an NPC should gain jealousy from player's romantic interaction with another NPC. + * + * @param string $jealousNpcName The NPC who might be jealous + * @param string $flirtTargetName The NPC the player is flirting with + * @param array $jealousDynamics Dynamics data for the jealous NPC + * @param string|null $relPreference relationship_preference from nsfw_npc_data + * @param string|null $marasStatus MARAS status (married/engaged/candidate) + * @param int $marasAffection MARAS affection 0-100 + * @return float Jealousy amount to add + */ + public static function calculateJealousyGain( + $jealousNpcName, $flirtTargetName, $jealousDynamics, + $relPreference = null, $marasStatus = null, $marasAffection = 0 + ) { + $cfg = self::getConfig(); + $baseGain = 10.0; + + // Temperament multiplier + $temperament = $jealousDynamics['inferred_temperament'] ?? null; + $tempMult = self::TEMPERAMENT_JEALOUSY_MULT[$temperament] ?? 1.0; + + // Relationship status multiplier + $relMult = 1.0; + if ($marasStatus === 'married') { + // Check rank for lead vs lower spouse (simplified: assume lead) + $relMult = 1.5; + } elseif ($marasStatus === 'engaged') { + $relMult = 1.2; + } elseif ($marasStatus === 'candidate' && $marasAffection >= 60) { + $relMult = 0.8; // boyfriend/girlfriend level + } else { + return 0.0; // Not committed enough to be jealous + } + + // Relationship preference modifier + if ($relPreference === 'polyamorous') { + $relMult *= 0.2; + } elseif ($relPreference === 'not_interested') { + return 0.0; // Doesn't care + } + + $gain = $baseGain * $tempMult * $relMult; + + self::log("Jealousy: {$jealousNpcName} gains {$gain} (target={$flirtTargetName}, temp={$tempMult}, rel={$relMult})"); + + return max(0.0, min(floatval($cfg['jealousy_max'] ?? 100.0), $gain)); + } + + /** + * Add jealousy to an NPC's dynamics. + */ + public static function addJealousy(&$dynamics, $amount, $triggerNpc = null) + { + $cfg = self::getConfig(); + $max = floatval($cfg['jealousy_max'] ?? 100.0); + + $dynamics['jealousy_anger'] = min($max, floatval($dynamics['jealousy_anger']) + $amount); + $dynamics['jealousy_updated_at'] = time(); + if ($triggerNpc) { + $dynamics['jealousy_trigger_npc'] = $triggerNpc; + } + + // Check if this triggers conflict + $threshold = floatval($cfg['conflict_threshold_jealousy'] ?? 40); + if ($dynamics['jealousy_anger'] >= $threshold && empty($dynamics['in_conflict'])) { + self::enterConflict($dynamics); + } + } + + // ========================================================================= + // CONFLICT / REPAIR + // ========================================================================= + + public static function enterConflict(&$dynamics) + { + $dynamics['in_conflict'] = true; + $dynamics['conflict_entered_at'] = time(); + $dynamics['conflict_positive_count'] = 0; + self::log("Entered conflict state"); + } + + /** + * Record a positive interaction during conflict. Check for resolution. + * Returns passion burst if conflict resolved, 0 otherwise. + */ + public static function recordConflictPositive(&$dynamics) + { + if (empty($dynamics['in_conflict'])) return 0.0; + + $dynamics['conflict_positive_count'] = intval($dynamics['conflict_positive_count']) + 1; + + $cfg = self::getConfig(); + $neededCount = intval($cfg['conflict_resolution_positive_count'] ?? 3); + $jealousyThreshold = floatval($cfg['conflict_threshold_jealousy'] ?? 40) * 0.5; + + if ($dynamics['conflict_positive_count'] >= $neededCount + && floatval($dynamics['jealousy_anger']) < $jealousyThreshold) { + + // Conflict resolved + $dynamics['in_conflict'] = false; + $dynamics['conflict_positive_count'] = 0; + + $burst = floatval($cfg['conflict_repair_passion_burst'] ?? 20.0); + self::log("Conflict resolved! Passion burst: +{$burst}"); + return $burst; + } + + return 0.0; + } + + /** + * Check if affinity drop triggers conflict. + */ + public static function checkAffinityDropConflict(&$dynamics, $affinityDelta) + { + if ($affinityDelta >= 0) return; + if (!empty($dynamics['in_conflict'])) return; + + $cfg = self::getConfig(); + $threshold = intval($cfg['conflict_threshold_affinity_drop'] ?? 10); + + if (abs($affinityDelta) >= $threshold) { + self::enterConflict($dynamics); + } + } + + // ========================================================================= + // RELATIONSHIP STAGES + // ========================================================================= + + /** + * Check and advance relationship stage if threshold crossed. + */ + public static function checkStageAdvancement(&$dynamics) + { + $cfg = self::getConfig(); + $total = intval($dynamics['total_positive_interactions'] ?? 0); + $currentStage = $dynamics['stage'] ?? self::STAGE_EARLY; + + $deepThreshold = intval($cfg['stage_deep_threshold'] ?? 200); + $estThreshold = intval($cfg['stage_established_threshold'] ?? 50); + + $newStage = $currentStage; + if ($total >= $deepThreshold) { + $newStage = self::STAGE_DEEP; + } elseif ($total >= $estThreshold) { + $newStage = self::STAGE_ESTABLISHED; + } + + if ($newStage !== $currentStage) { + self::log("Stage advanced: {$currentStage} → {$newStage} (interactions: {$total})"); + $dynamics['stage'] = $newStage; + } + } + + // ========================================================================= + // INTERACTION CLASSIFICATION + // ========================================================================= + + /** + * Classify the current interaction into a love language category. + * + * @param array $gameRequest The game request array + * @param string|null $npcMood The NPC's mood after this interaction + * @return string|null Love language constant or null if unclassifiable + */ + public static function classifyInteraction($gameRequest, $npcMood = null) + { + $type = $gameRequest[0] ?? ''; + $action = $gameRequest[3] ?? ''; + + // Physical touch + if (in_array($type, ['ext_nsfw_physics', 'ext_nsfw_physics_raw'])) { + return self::LL_TOUCH; + } + if (stripos($action, 'ExtCmdHug') !== false || stripos($action, 'ExtCmdKiss') !== false) { + return self::LL_TOUCH; + } + if (stripos($action, 'ExtCmdStartMassage') !== false) { + return self::LL_TOUCH; + } + // OStim scene events + if (stripos($action, 'OStim') !== false || stripos($type, 'ostim') !== false) { + return self::LL_TOUCH; + } + + // Gifts (MARAS gift sync) + if ($type === 'maras_sync' && stripos($action, 'gift') !== false) { + return self::LL_GIFTS; + } + + // Words of affirmation (flirty/loving mood) + $romanticMoods = ['flirty', 'loving', 'lovely', 'playful', 'seductive', 'aroused', 'charming', 'affectionate']; + if ($npcMood && in_array(strtolower($npcMood), $romanticMoods)) { + return self::LL_WORDS; + } + + // Acts of service (combat/quest/protective context) + if ($type === 'maras_sync' && stripos($action, 'promotion') !== false) { + return self::LL_SERVICE; + } + // Combat-together events — fighting alongside = acts of service + $combatTypes = ['combatend', 'combatendmighty', 'bleedout']; + if (in_array($type, $combatTypes)) { + return self::LL_SERVICE; + } + // Give/trade item actions — providing for someone = acts of service + if (stripos($action, 'ExtCmdGiveItem') !== false || stripos($action, 'ExtCmdTradeItem') !== false) { + return self::LL_SERVICE; + } + // Post-combat dialogue — talking after fighting together + if (in_array($type, ['inputtext', 'inputtext_s', 'ginputtext', 'ginputtext_s', 'rechat'])) { + if (self::isNpcInCombatRecently($gameRequest)) { + return self::LL_SERVICE; + } + } + // Service-oriented moods — NPC feels protected/grateful + $serviceMoods = ['grateful', 'protective', 'loyal', 'admiring', 'relieved', 'safe']; + if ($npcMood && in_array(strtolower($npcMood), $serviceMoods)) { + return self::LL_SERVICE; + } + + // Quality time (regular conversation) + $dialogueTypes = ['inputtext', 'inputtext_s', 'ginputtext', 'ginputtext_s', 'rechat']; + if (in_array($type, $dialogueTypes)) { + return self::LL_TIME; + } + + return null; + } + + /** + * Check if the NPC was in combat recently (within last 5 minutes real-time). + * Queries MinAI actor variables if available, falls back to conf_opts. + */ + private static function isNpcInCombatRecently($gameRequest) + { + $npcName = $GLOBALS['HERIKA_NAME'] ?? ''; + if (empty($npcName)) return false; + + try { + $db = $GLOBALS['db'] ?? null; + if (!$db) return false; + + // Check MinAI inCombat flag via conf_opts + $key = '_minai_' . strtolower($npcName) . '//incombat'; + $row = $db->fetchOne( + "SELECT value FROM conf_opts WHERE lower(id) = " . $db->escapeLiteral($key) + ); + if ($row && strtolower(trim($row['value'])) === 'true') { + return true; + } + + // Check InCombatState (0=none, 1=searching, 2=in combat) + $key2 = '_minai_' . strtolower($npcName) . '//incombatstate'; + $row2 = $db->fetchOne( + "SELECT value FROM conf_opts WHERE lower(id) = " . $db->escapeLiteral($key2) + ); + if ($row2 && intval($row2['value']) >= 1) { + return true; + } + } catch (\Throwable $e) { + // Silently fail — combat detection is a bonus, not critical + } + + return false; + } + + // ========================================================================= + // INTEREST-WEIGHTED PASSION — Detection, Classification & Preferences + // ========================================================================= + + // minai_items.category → interest mapping (100% coverage for giveable items) + const ITEM_CATEGORY_TO_INTEREST = [ + 'Weapon' => 'combat', + 'Armor' => 'combat', + 'Ammo' => 'combat', + 'Potion' => 'alchemy', + 'Ingredient' => 'alchemy', + 'Book' => 'scholarly', + 'Scroll' => 'enchanting', + 'SoulGem' => 'enchanting', + 'Key' => 'adventure', + 'Currency' => 'wealth', + 'Light' => 'domestic', + 'Misc' => null, // falls through to keyword matching + ]; + + // Oghma knowledge_class → interest mapping (for items with lore entries) + const KNOWLEDGE_CLASS_TO_INTEREST = [ + 'blacksmith' => 'crafting', + 'alchemist' => 'alchemy', + 'mage' => 'enchanting', + 'scholar' => 'scholarly', + 'priest' => 'spiritual', + 'noble' => 'wealth', + 'hunter' => 'nature', + 'thief' => 'adventure', + ]; + + /** + * Classify an item name into an interest category. + * 3-tier lookup: minai_items.category → oghma.knowledge_class → keyword fallback + */ + public static function classifyItemInterest($itemName) + { + if (empty($itemName)) return null; + + $db = $GLOBALS['db'] ?? null; + + // Tier 1: minai_items.category (100% coverage for registered items) + if ($db) { + try { + $row = $db->fetchOne( + "SELECT category FROM minai_items WHERE lower(name) = lower(" + . $db->escapeLiteral(trim($itemName)) . ") LIMIT 1" + ); + if ($row && !empty($row['category'])) { + $interest = self::ITEM_CATEGORY_TO_INTEREST[$row['category']] ?? null; + if ($interest) return $interest; + // category=Misc falls through to Tier 2 + } + } catch (\Throwable $e) {} + } + + // Tier 2: oghma.knowledge_class (8% coverage, finer classification) + if ($db) { + try { + $topic = strtolower(str_replace(' ', '_', trim($itemName))); + $row = $db->fetchOne( + "SELECT knowledge_class, category FROM oghma WHERE lower(topic) = " + . $db->escapeLiteral($topic) . " LIMIT 1" + ); + if ($row && !empty($row['knowledge_class'])) { + // knowledge_class can be CSV: "blacksmith, alchemist" + $classes = array_map('trim', explode(',', strtolower($row['knowledge_class']))); + foreach ($classes as $kc) { + if (isset(self::KNOWLEDGE_CLASS_TO_INTEREST[$kc])) { + return self::KNOWLEDGE_CLASS_TO_INTEREST[$kc]; + } + } + } + // Oghma category fallback + if ($row && !empty($row['category'])) { + $catMap = ['artifacts' => 'adventure', 'equipment' => 'crafting', 'items' => 'domestic', 'spells' => 'enchanting']; + if (isset($catMap[$row['category']])) { + return $catMap[$row['category']]; + } + } + } catch (\Throwable $e) {} + } + + // Tier 3: keyword fallback (for items not in any DB) + $lower = strtolower(trim($itemName)); + $map = self::ITEM_INTEREST_MAP; + uksort($map, function($a, $b) { return strlen($b) - strlen($a); }); + foreach ($map as $keyword => $interest) { + if (strpos($lower, strtolower($keyword)) !== false) { + return $interest; + } + } + + return null; + } + + /** + * Detect interest context appropriate for the current love language. + */ + public static function detectInterestContext($interactionLL) + { + switch ($interactionLL) { + case self::LL_TIME: + case self::LL_WORDS: + return self::detectCurrentInterest(); + + case self::LL_GIFTS: + return self::detectGiftInterest(); + + case self::LL_SERVICE: + if (self::isNpcInCombatRecently($GLOBALS['gameRequest'] ?? [])) { + return 'combat'; + } + $itemInterest = self::detectGiftInterest(); + if ($itemInterest) return $itemInterest; + return self::detectCurrentInterest(); + + case self::LL_TOUCH: + if (self::isNpcInCombatRecently($GLOBALS['gameRequest'] ?? [])) { + return 'combat'; + } + return null; + + default: + return null; + } + } + + /** + * Detect interest category from a gift/item interaction. + * Extracts item name from LLM response or gameRequest action, then classifies. + */ + private static function detectGiftInterest() + { + // Source 1: LLM response item field + $llmResponse = $GLOBALS['LAST_LLM_RESPONSE'] ?? null; + if (is_array($llmResponse) && !empty($llmResponse['item'])) { + $interest = self::classifyItemInterest($llmResponse['item']); + if ($interest) return $interest; + } + + // Source 2: gameRequest action (ExtCmdGiveItem@ItemName:Count) + $action = $GLOBALS['gameRequest'][3] ?? ''; + if (preg_match('/ExtCmd(?:Give|Trade)Item@([^:\r\n]+)/i', $action, $m)) { + $interest = self::classifyItemInterest(trim($m[1])); + if ($interest) return $interest; + } + + return null; + } + + /** + * Detect current interest from location keywords and actor state. + */ + public static function detectCurrentInterest() + { + $npcName = $GLOBALS['HERIKA_NAME'] ?? ''; + if (empty($npcName)) return null; + + $db = $GLOBALS['db'] ?? null; + if (!$db) return null; + + $interest = null; + + try { + // 1. Check location keywords + $key = '_minai_' . strtolower($npcName) . '//locationkeywords'; + $row = $db->fetchOne( + "SELECT value FROM conf_opts WHERE lower(id) = " . $db->escapeLiteral($key) + ); + if ($row && !empty($row['value'])) { + $keywords = array_map('trim', explode('~', strtolower($row['value']))); + foreach ($keywords as $kw) { + if (isset(self::KEYWORD_TO_INTEREST[$kw])) { + $interest = self::KEYWORD_TO_INTEREST[$kw]; + break; + } + } + } + + // 2. Sneaking overrides to adventure (unless already in adventure) + $sneakKey = '_minai_' . strtolower($npcName) . '//issneaking'; + $sneakRow = $db->fetchOne( + "SELECT value FROM conf_opts WHERE lower(id) = " . $db->escapeLiteral($sneakKey) + ); + if ($sneakRow && strtolower(trim($sneakRow['value'])) === 'true') { + if ($interest !== 'adventure') { + $interest = 'adventure'; + } + } + + // 3. On mount = adventure + $mountKey = '_minai_' . strtolower($npcName) . '//isonmount'; + $mountRow = $db->fetchOne( + "SELECT value FROM conf_opts WHERE lower(id) = " . $db->escapeLiteral($mountKey) + ); + if ($mountRow && strtolower(trim($mountRow['value'])) === 'true') { + $interest = 'adventure'; + } + + // 4. Sitting outdoors = nature + if (!$interest) { + $sitKey = '_minai_' . strtolower($npcName) . '//sitstate'; + $sitRow = $db->fetchOne( + "SELECT value FROM conf_opts WHERE lower(id) = " . $db->escapeLiteral($sitKey) + ); + $interiorKey = '_minai_' . strtolower($npcName) . '//isinterior'; + $intRow = $db->fetchOne( + "SELECT value FROM conf_opts WHERE lower(id) = " . $db->escapeLiteral($interiorKey) + ); + $isSitting = ($sitRow && intval($sitRow['value']) == 3); + $isInterior = ($intRow && strtolower(trim($intRow['value'])) === 'true'); + + if ($isSitting && !$isInterior) { + $interest = 'nature'; + } + } + + // 5. Exterior + no other match = nature + if (!$interest) { + $interiorKey = '_minai_' . strtolower($npcName) . '//isinterior'; + $intRow = $db->fetchOne( + "SELECT value FROM conf_opts WHERE lower(id) = " . $db->escapeLiteral($interiorKey) + ); + if ($intRow && strtolower(trim($intRow['value'])) !== 'true') { + $interest = 'nature'; + } + } + } catch (\Throwable $e) { + // Silently fail + } + + return $interest; + } + + /** + * Get or auto-generate interest preferences for an NPC. + * Checks for manual 'interests' key, falls back to old 'activity_preferences', then auto-gen. + */ + public static function getInterests($dynamics) + { + if (!empty($dynamics['interests']) && is_array($dynamics['interests'])) { + return $dynamics['interests']; + } + + // Backward compat: migrate old activity_preferences + if (!empty($dynamics['activity_preferences']) && is_array($dynamics['activity_preferences'])) { + return self::migrateOldPreferences($dynamics['activity_preferences']); + } + + return self::generateInterests(); + } + + /** + * Migrate old activity_preferences keys to new interest categories. + */ + private static function migrateOldPreferences($oldPrefs) + { + $migration = [ + 'smithing' => 'crafting', + 'dungeon' => 'adventure', + 'wilderness' => 'nature', + 'tavern' => 'social', + 'studying' => 'scholarly', + 'cooking' => 'domestic', + 'exploring' => 'adventure', + 'traveling' => 'adventure', + 'camping' => 'nature', + 'alchemy' => 'alchemy', + 'enchanting' => 'enchanting', + ]; + + $newPrefs = []; + foreach ($oldPrefs as $oldKey => $value) { + $newKey = $migration[$oldKey] ?? null; + if ($newKey) { + $newPrefs[$newKey] = max($newPrefs[$newKey] ?? 0.5, floatval($value)); + } + } + foreach (self::INTEREST_TYPES as $type) { + if (!isset($newPrefs[$type])) $newPrefs[$type] = 1.0; + } + return $newPrefs; + } + + /** + * Auto-generate interest preferences from NPC class and skills. + */ + public static function generateInterests() + { + $npcName = $GLOBALS['HERIKA_NAME'] ?? ''; + $prefs = []; + + // Try to get NPC class + $class = null; + try { + $db = $GLOBALS['db'] ?? null; + if ($db && !empty($npcName)) { + $row = $db->fetchOne( + "SELECT extended_data->>'class' AS class FROM core_npc_master WHERE npc_name = " + . $db->escapeLiteral($npcName) + ); + if ($row && !empty($row['class'])) { + $class = $row['class']; + foreach (array_keys(self::CLASS_INTEREST_DEFAULTS) as $className) { + if (stripos($class, $className) !== false) { + $class = $className; + break; + } + } + } + } + } catch (\Throwable $e) {} + + $prefs = self::CLASS_INTEREST_DEFAULTS[$class] ?? [ + 'combat' => 1.0, 'crafting' => 1.0, 'alchemy' => 1.0, 'enchanting' => 1.0, + 'scholarly' => 1.0, 'nature' => 1.0, 'social' => 1.0, 'domestic' => 1.0, + 'adventure' => 1.0, 'spiritual' => 1.0, 'wealth' => 1.0, + ]; + + // Skill-based bonuses + $skills = $GLOBALS['HERIKA_SKILLS'] ?? ''; + if (!empty($skills)) { + foreach (self::SKILL_INTEREST_BONUS as $skillFragment => $interestType) { + if (stripos($skills, $skillFragment) !== false) { + if (preg_match('/' . preg_quote($skillFragment, '/') . '\D*(\d+)/i', $skills, $m)) { + if (intval($m[1]) >= 20) { + $prefs[$interestType] = ($prefs[$interestType] ?? 1.0) + 0.2; + } + } else { + $prefs[$interestType] = ($prefs[$interestType] ?? 1.0) + 0.1; + } + } + } + } + + foreach ($prefs as $k => $v) { + $prefs[$k] = max(0.5, min(2.0, $v)); + } + + return $prefs; + } + + /** + * Get interest-weighted passion multiplier for the given love language. + * Detects context, looks up NPC interest, applies LL-appropriate weight. + */ + public static function getInterestMultiplier($dynamics, $interactionLL = null) + { + if ($interactionLL === null) return 1.0; + + $interest = self::detectInterestContext($interactionLL); + if ($interest === null) return 1.0; + + $prefs = self::getInterests($dynamics); + $rawMult = floatval($prefs[$interest] ?? 1.0); + + // Apply LL weight to temper the multiplier + $weight = self::LL_INTEREST_WEIGHT[$interactionLL] ?? 0.5; + return 1.0 + ($rawMult - 1.0) * $weight; + } + + /** + * Get narrative text describing NPC's reaction to the current interest context. + * Uses raw multiplier (not LL-weighted) for narrative accuracy. + */ + public static function getInterestResonanceText($npcName, $interest, $multiplier) + { + if ($interest === null || abs($multiplier - 1.0) < 0.05) { + return null; + } + + $player = $GLOBALS['PLAYER_NAME'] ?? 'the player'; + + if ($multiplier >= 1.3) { + $positive = [ + 'combat' => "{$npcName} is energized — there's a shared intensity here, a mutual understanding of what it means to fight and survive alongside {$player}.", + 'crafting' => "{$npcName} is drawn to the rhythm of creation — shaping raw materials into something lasting. Being here with {$player} feels right.", + 'alchemy' => "{$npcName} watches the ingredients combine with genuine fascination — there's something meditative about this precise, analytical work.", + 'enchanting' => "{$npcName} studies the enchantment process with quiet intensity — the way magic and matter intertwine speaks to something deep.", + 'scholarly' => "{$npcName} is absorbed in the knowledge around them — a rare, unguarded enthusiasm. Sharing this with {$player} feels intimate.", + 'nature' => "{$npcName} breathes easier out here — the open sky, the quiet of wild places. More themselves here than behind walls.", + 'social' => "{$npcName} settles into the social warmth of this place — surrounded by stories and strangers, sharing a moment of normalcy with {$player}.", + 'domestic' => "{$npcName} finds unexpected comfort in the simple domestic rhythm — a quiet warmth that catches them off guard.", + 'adventure' => "{$npcName} is alert and engaged — exploring together, relying on each other. This is where trust is forged.", + 'spiritual' => "{$npcName} feels a quiet reverence in this place — the sacred atmosphere resonates with something deep and personal.", + 'wealth' => "{$npcName}'s eyes light up — beauty and craftsmanship speak to something they genuinely respect and appreciate.", + ]; + return $positive[$interest] ?? null; + } elseif ($multiplier <= 0.85) { + $negative = [ + 'combat' => "{$npcName} endures the violence but takes no particular satisfaction in it.", + 'crafting' => "{$npcName} tolerates the heat and noise but finds little to engage with here.", + 'alchemy' => "{$npcName} watches the work with polite patience, but their attention drifts.", + 'enchanting' => "{$npcName} waits while the enchanting proceeds — it holds little personal interest.", + 'scholarly' => "{$npcName} tries to engage with the scholarly work but their attention wanders.", + 'nature' => "{$npcName} endures the open wild but prefers more structured surroundings.", + 'social' => "{$npcName} scans for the exit. Crowded places and idle chatter drain their patience.", + 'domestic' => "{$npcName} stands aside — the domestic ritual doesn't quite land for them.", + 'adventure' => "{$npcName} stays alert but finds no joy in these depths — they'd rather be elsewhere.", + 'spiritual' => "{$npcName} respects the place but feels no personal connection to the sacred.", + 'wealth' => "{$npcName} is unmoved by material displays — value means something different to them.", + ]; + return $negative[$interest] ?? null; + } + + return null; + } + + /** @deprecated Use detectCurrentInterest() */ + public static function detectCurrentActivity() + { + return self::detectCurrentInterest(); + } + + /** @deprecated Use getInterests() */ + public static function getActivityPreferences($dynamics) + { + return self::getInterests($dynamics); + } + + /** @deprecated Use generateInterests() */ + public static function generateActivityPreferences() + { + return self::generateInterests(); + } + + /** @deprecated Use getInterestMultiplier() */ + public static function getActivityMultiplier($dynamics, $activity = null) + { + if ($activity === null) { + $activity = self::detectCurrentInterest(); + } + if ($activity === null) return 1.0; + $prefs = self::getInterests($dynamics); + return floatval($prefs[$activity] ?? 1.0); + } + + /** @deprecated Use getInterestResonanceText() */ + public static function getActivityResonanceText($npcName, $activity, $multiplier) + { + return self::getInterestResonanceText($npcName, $activity, $multiplier); + } + + // ========================================================================= + // REUNION TEXT (temperament-aware) + // ========================================================================= + + /** + * Generate temperament-appropriate reunion narrative text. + * + * @param string $npcName NPC name + * @param string $temperament Inferred temperament (Romantic, Independent, etc.) + * @param float $hoursApart Real-time hours since last seen + * @param string $player Player name + * @return string|null Context text or null if no reunion + */ + public static function getReunionText($npcName, $temperament, $hoursApart, $player) + { + if ($hoursApart < 8) return null; + + // Time tier: long (48h+), medium (24h+), short (8h+) + if ($hoursApart >= 48) { + $tier = 'long'; + } elseif ($hoursApart >= 24) { + $tier = 'medium'; + } else { + $tier = 'short'; + } + + $texts = [ + 'Romantic' => [ + 'long' => "{$npcName} hasn't seen {$player} in far too long — there's a rush of emotion, relief and warmth flooding back at seeing them again. The urge to close the distance is overwhelming.", + 'medium' => "{$npcName} missed {$player} — seeing them again brings a wave of warmth and the urge to close the distance between them.", + 'short' => "{$npcName} is genuinely glad to see {$player} again — a warmth that shows in her eyes before she can hide it.", + ], + 'Independent' => [ + 'long' => "{$npcName} notes {$player}'s return with a measured look. Something eases in her posture — barely perceptible — though her expression gives nothing away. She noticed the absence more than she expected to.", + 'medium' => "{$npcName} registers {$player}'s presence. A pause — almost imperceptible — before she continues what she was doing. The silence between them is slightly warmer than it was before.", + 'short' => "{$npcName} acknowledges {$player}'s return with a slight nod. If she is pleased to see them, it shows only in the fact that she looked up at all.", + ], + 'Proud' => [ + 'long' => "{$npcName} composes herself at seeing {$player} again after so long. Something flickers behind her eyes — quickly mastered. She would never admit how much the absence weighed on her.", + 'medium' => "{$npcName} carries herself with deliberate poise as {$player} returns. She has things to say about the absence, but they will keep. For now, she allows a measured warmth.", + 'short' => "{$npcName} greets {$player}'s return with composure and the faintest warming of her tone.", + ], + 'Jealous' => [ + 'long' => "{$npcName} stares at {$player} with an intensity that holds both relief and accusation. Where have they been? Who were they with? The questions burn behind her eyes even as warmth floods back.", + 'medium' => "{$npcName} is clearly relieved to see {$player}, but an edge of anxiety lingers — a need to know where they were and why they stayed away.", + 'short' => "{$npcName} watches {$player} return with sharp eyes. Glad, yes — but watchful. Already cataloguing whether anything has changed.", + ], + 'Humble' => [ + 'long' => "{$npcName} quietly brightens at {$player}'s return, like a hearth rekindled after a long cold. She doesn't demand explanations — she's simply, genuinely glad they came back.", + 'medium' => "{$npcName} greets {$player} with a warm, unguarded smile. The relief is honest and unhidden. She doesn't try to make it more or less than it is.", + 'short' => "{$npcName} looks up at {$player}'s return with quiet pleasure. A small, genuine warmth.", + ], + 'Anxious' => [ + 'long' => "{$npcName} freezes at the sight of {$player}. Relief crashes into hurt crashes into desperate gladness. The words come too fast — 'Where were you? I thought — never mind. You're here.'", + 'medium' => "{$npcName} visibly exhales seeing {$player}. The tension she's been carrying dissolves into nervous warmth. She moves closer almost involuntarily.", + 'short' => "{$npcName} brightens immediately at {$player}'s return, then catches herself — tries to play it cool. Fails.", + ], + 'Bold' => [ + 'long' => "{$npcName} strides toward {$player} without hesitation. 'About time.' The directness masks the depth of what she felt during the absence.", + 'medium' => "{$npcName} greets {$player} with confident warmth. No games, no pretense. She's glad they're here and she shows it plainly.", + 'short' => "{$npcName} acknowledges {$player}'s return with a firm nod and the ghost of a smile. 'Missed the action.'", + ], + 'Playful' => [ + 'long' => "{$npcName} greets {$player} with an exaggerated pout. 'Oh, you're alive. I was about to give away your things.' The lightness barely hides how much she missed them.", + 'medium' => "{$npcName} flashes a grin at {$player}. 'Couldn't stay away, could you?' There's genuine warmth under the teasing.", + 'short' => "{$npcName} gives {$player} a playful look. 'Back already? I was just getting comfortable.'", + ], + 'Nurturing' => [ + 'long' => "{$npcName} searches {$player}'s face with quiet concern — are they hurt? Tired? Hungry? The questions are gentle but thorough. She's been worrying.", + 'medium' => "{$npcName} greets {$player} with a warm, steady presence. 'You look tired. Come sit.' Caretaking first, everything else after.", + 'short' => "{$npcName} smiles warmly at {$player}'s return. A quiet check — eyes scanning for injury — before relaxing.", + ], + 'Gentle' => [ + 'long' => "{$npcName} looks at {$player} for a long moment without speaking. When the words come, they're soft. 'I'm glad.' That's all. It's enough.", + 'medium' => "{$npcName} greets {$player} with a soft warmth that radiates without effort. Her presence says what her words don't.", + 'short' => "{$npcName} offers {$player} a gentle smile. Understated, sincere.", + ], + 'Guarded' => [ + 'long' => "{$npcName} studies {$player} from across the room. Something shifts behind her eyes — a wall lowering a fraction. She doesn't approach. But she doesn't look away either.", + 'medium' => "{$npcName} notes {$player}'s return with careful neutrality. Only the slight easing of her shoulders betrays that she noticed the absence.", + 'short' => "{$npcName} glances at {$player}. A beat longer than necessary. Then back to what she was doing.", + ], + 'Stoic' => [ + 'long' => "{$npcName} stands still as {$player} approaches. Her expression is unreadable. But she turns to face them fully — and that, from her, is a declaration.", + 'medium' => "{$npcName} acknowledges {$player} with the barest inclination of her head. The silence that follows is not cold. It's loaded.", + 'short' => "{$npcName} meets {$player}'s eyes briefly. Says nothing. The corner of her mouth moves — not quite a smile.", + ], + 'Defiant' => [ + 'long' => "{$npcName} looks {$player} up and down. 'You look like hell. Good — means you were doing something.' The defiance is the affection.", + 'medium' => "{$npcName} gives {$player} a sharp grin. 'Didn't think you'd come crawling back this fast.' She's pleased. She'd never say so.", + 'short' => "{$npcName} smirks at {$player}. 'Back for more?' Challenge as greeting — her native tongue.", + ], + ]; + + // Default fallback (original text for unknown temperaments) + $default = [ + 'long' => "{$npcName} hasn't seen {$player} in a long time — there's a rush of emotion, relief and warmth flooding back at seeing them again.", + 'medium' => "{$npcName} missed {$player} — seeing them again brings a wave of warmth and the urge to close the distance between them.", + 'short' => "{$npcName} is glad to see {$player} again — a pleasant warmth at their return.", + ]; + + $set = $texts[$temperament] ?? $default; + return $set[$tier] ?? null; + } + + // ========================================================================= + // EFFECTIVE DISPOSITION + // ========================================================================= + + /** + * Calculate effective sex_disposal with passion and jealousy overlay. + */ + public static function getEffectiveDisposition($baseDisposal, $dynamics) + { + $passion = floatval($dynamics['passion'] ?? 0); + $jealousy = floatval($dynamics['jealousy_anger'] ?? 0); + + $effective = $baseDisposal + ($passion * 0.3) - ($jealousy * 0.3); + return max(0, min(30, intval(round($effective)))); + } + + // ========================================================================= + // UTILITY + // ========================================================================= + + public static function log($message) + { + $cfg = self::getConfig(); + if (!empty($cfg['log_enabled'])) { + error_log("[RelDyn] " . $message); + } + } + + /** + * Get a human-readable passion band label. + */ + public static function getPassionBand($passion) + { + if ($passion >= 80) return 'burning'; + if ($passion >= 60) return 'intense'; + if ($passion >= 40) return 'warm'; + if ($passion >= 20) return 'stirring'; + if ($passion > 0) return 'faint'; + return 'none'; + } + + /** + * Get a human-readable jealousy band label. + */ + public static function getJealousyBand($jealousy) + { + if ($jealousy >= 80) return 'seething'; + if ($jealousy >= 60) return 'hurt'; + if ($jealousy >= 40) return 'unsettled'; + if ($jealousy >= 20) return 'edgy'; + return 'none'; + } + + // ========================================================================= + // CACHE MANAGEMENT + // ========================================================================= + + public static function clearNpcCache($npcName = null) + { + if ($npcName !== null) { + unset(self::$npcCache[$npcName]); + } else { + self::$npcCache = []; + } + } + + // ========================================================================= + // VECTOR OPERATIONS + // ========================================================================= + + public static function cosineSimilarity($vecA, $vecB) + { + if (empty($vecA) || empty($vecB)) return 0.0; + $dot = 0.0; + $normA = 0.0; + $normB = 0.0; + $len = min(count($vecA), count($vecB)); + for ($i = 0; $i < $len; $i++) { + $dot += $vecA[$i] * $vecB[$i]; + $normA += $vecA[$i] * $vecA[$i]; + $normB += $vecB[$i] * $vecB[$i]; + } + $denom = sqrt($normA) * sqrt($normB); + return ($denom > 0) ? ($dot / $denom) : 0.0; + } + + public static function embedInterestVector($interests) + { + if (empty($interests)) return null; + try { + $parts = []; + foreach ($interests as $key => $weight) { + if (is_numeric($weight) && floatval($weight) > 0.5) { + $parts[] = "{$key}(" . number_format(floatval($weight), 1) . ")"; + } + } + if (empty($parts)) return null; + $text = implode(' ', $parts); + + $url = 'http://localhost:8082/api/embedtext'; + $payload = json_encode(['text' => $text]); + $ctx = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/json\r\n", + 'content' => $payload, + 'timeout' => 5, + ], + ]); + $result = @file_get_contents($url, false, $ctx); + if ($result === false) return null; + $data = json_decode($result, true); + return $data['vector'] ?? $data['embedding'] ?? $data ?? null; + } catch (\Throwable $e) { + self::log("embedInterestVector error: " . $e->getMessage()); + return null; + } + } + + public static function getInterestVector(&$dynamics) + { + if (!empty($dynamics['_interest_vector'])) { + return $dynamics['_interest_vector']; + } + $interests = self::getInterests($dynamics); + if (empty($interests)) return null; + $vec = self::embedInterestVector($interests); + if ($vec) { + $dynamics['_interest_vector'] = $vec; + } + return $vec; + } + + // ========================================================================= + // COMBAT SYSTEM + // ========================================================================= + + public static function getCombatContext($npcName) + { + try { + $db = $GLOBALS['db'] ?? null; + if (!$db) return null; + + $inCombat = false; + $healthPct = 1.0; + $bleedingOut = false; + $recentKills = 0; + $source = 'none'; + + // Check MinAI combat state from conf_opts + $npcLower = strtolower($npcName); + try { + $combatRow = $db->fetchOne("SELECT value FROM conf_opts WHERE id = 'minai_combat_{$db->escape($npcLower)}'"); + if ($combatRow) { + $combatData = json_decode($combatRow['value'], true); + $inCombat = !empty($combatData['inCombat']); + $healthPct = floatval($combatData['healthPct'] ?? 1.0); + $bleedingOut = !empty($combatData['bleedingOut']); + } + } catch (\Throwable $e) {} + + // Fallback: check gameRequest for combat event types + $reqType = $GLOBALS['gameRequest'][0] ?? ''; + $combatTypes = ['radiantcombatfriend', 'combatend', 'combatendmighty', 'death', 'bleedout', + 'minai_bleedoutself', 'minai_combatendvictory', 'minai_combatenddefeat']; + if (in_array($reqType, $combatTypes)) { + $inCombat = true; + $source = 'event'; + } + + // Check MinAI conf_opts flags directly + try { + $flagRow = $db->fetchOne("SELECT value FROM conf_opts WHERE id = 'inCombat'"); + if ($flagRow && $flagRow['value'] === '1') { + $inCombat = true; + $source = 'minai_flag'; + } + } catch (\Throwable $e) {} + + // Count recent kills from eventlog (last 5 minutes game time) + try { + $rows = $db->fetchAll("SELECT COUNT(*) as cnt FROM eventlog WHERE type = 'death' AND people LIKE '%{$db->escape($npcName)}%'"); + $recentKills = intval($rows[0]['cnt'] ?? 0); + } catch (\Throwable $e) {} + + if (!$inCombat && $recentKills === 0 && !$bleedingOut) return null; + + return [ + 'in_combat' => $inCombat, + 'health_pct' => $healthPct, + 'recent_kills' => $recentKills, + 'bleeding_out' => $bleedingOut, + 'source' => $source, + ]; + } catch (\Throwable $e) { + self::log("getCombatContext error: " . $e->getMessage()); + return null; + } + } + + public static function getRecentCombatSummary($npcName) + { + try { + $db = $GLOBALS['db'] ?? null; + if (!$db) return null; + + $player = $GLOBALS['PLAYER_NAME'] ?? 'the player'; + $combatTypes = "'death','bleedout','combatend','combatendmighty','minai_combatendvictory','minai_combatenddefeat'"; + + // Check last 10 combat events, filter to recent ones + $rows = $db->fetchAll( + "SELECT type, data, people FROM eventlog WHERE type IN ({$combatTypes}) ORDER BY rowid DESC LIMIT 10" + ); + + if (empty($rows)) return null; + + $npcInvolved = false; + $killCount = 0; + $wasBleedout = false; + $sharedCombat = false; + + foreach ($rows as $row) { + $people = strtolower($row['people'] ?? ''); + $npcLower = strtolower($npcName); + $playerLower = strtolower($player); + + if (strpos($people, $npcLower) !== false) { + $npcInvolved = true; + if ($row['type'] === 'death') $killCount++; + if (in_array($row['type'], ['bleedout', 'minai_bleedoutself'])) $wasBleedout = true; + } + if (strpos($people, $playerLower) !== false && strpos($people, $npcLower) !== false) { + $sharedCombat = true; + } + } + + if (!$npcInvolved && !$sharedCombat) return null; + + // Build narrative + $parts = []; + if ($sharedCombat) { + $parts[] = "Survived combat alongside {$player}. Bond forged under pressure."; + } + if ($killCount > 0) { + $parts[] = "Witnessed {$killCount} kill" . ($killCount > 1 ? 's' : '') . " — the violence is fresh."; + } + if ($wasBleedout) { + $parts[] = "Nearly died in the fighting. The vulnerability lingers."; + } + if (empty($parts)) { + $parts[] = "Recent combat still echoes. Adrenaline fading but the tension remains."; + } + + return implode(' ', $parts); + } catch (\Throwable $e) { + self::log("getRecentCombatSummary error: " . $e->getMessage()); + return null; + } + } + + // ========================================================================= + // ENVIRONMENTAL RESONANCE TEXT + // ========================================================================= + + public static function getEnvironmentalResonanceText($npcName, $interest, $resonance, $location) + { + if ($resonance < 0.3) return ''; + + $interestTexts = [ + 'combat' => 'the thrill of danger and the weight of weapons', + 'crafting' => 'the hum of creation, raw materials waiting to become something', + 'alchemy' => 'the scent of reagents and the promise of discovery', + 'enchanting' => 'the crackle of bound magic and soul energy', + 'scholarly' => 'ancient knowledge etched into every surface', + 'nature' => 'the living world breathing around them', + 'social' => 'the warmth of gathered voices and shared stories', + 'domestic' => 'the comfort of hearth and home', + 'adventure' => 'the call of the unknown, paths yet untaken', + 'spiritual' => 'something sacred resonating in the stones', + 'wealth' => 'the glint of opportunity and valuable resources', + ]; + + $desc = $interestTexts[$interest] ?? 'something that catches their attention'; + + if ($resonance >= 0.6) { + return "This place resonates deeply with {$npcName} — {$desc}. There is a visible ease, an alertness that speaks to genuine engagement with the surroundings."; + } else { + return "Something about {$location} catches {$npcName}'s attention — {$desc}. A quiet interest, not quite fascination, but the environment holds their gaze."; + } + } + + // ========================================================================= + // TYPE CONSTRAINT FUNCTIONS + // ========================================================================= + + public static function getBlockedTypes($dynamics, $chimAffinity = null) + { + $pref = strtolower(trim($dynamics['relationship_preference'] ?? '')); + if (empty($pref) || $pref === 'default') return []; + + $blocked = []; + // Use CHIM affinity if provided (from relationship_system), fall back to dynamics blob + $aff = ($chimAffinity !== null) ? floatval($chimAffinity) : floatval($dynamics['affinity'] ?? 0); + + switch ($pref) { + case 'demisexual': + if ($aff < 60) $blocked[] = 'romantic'; + if ($aff < 80) $blocked[] = 'committed'; + $blocked[] = 'sworn'; // always requires deep bond + break; + case 'asexual': + $blocked[] = 'romantic'; + $blocked[] = 'committed'; + $blocked[] = 'sworn'; + break; + case 'aromantic': + $blocked[] = 'romantic'; + $blocked[] = 'committed'; + $blocked[] = 'sworn'; + $blocked[] = 'crush'; + break; + } + + return $blocked; + } + + public static function getTypeConstraintPrompt($dynamics, $npcName, $chimAffinity = null) + { + $pref = strtolower(trim($dynamics['relationship_preference'] ?? '')); + if (empty($pref) || $pref === 'default') return ''; + + $blocked = self::getBlockedTypes($dynamics, $chimAffinity); + if (empty($blocked)) return ''; + + $prompts = [ + 'demisexual' => "{$npcName} forms deep bonds slowly — romantic connection requires genuine trust built over time. Physical intimacy without emotional foundation feels wrong to them.", + 'asexual' => "{$npcName} does not experience sexual attraction. Deep emotional bonds are possible, but physical intimacy is not something they seek or welcome.", + 'aromantic' => "{$npcName} does not experience romantic attraction. They can form deep platonic bonds and loyal friendships, but romantic framing feels foreign and uncomfortable.", + ]; + + return $prompts[$pref] ?? ''; + } +} diff --git a/ext/relationship_dynamics/settings.php b/ext/relationship_dynamics/settings.php new file mode 100644 index 00000000..fae44421 --- /dev/null +++ b/ext/relationship_dynamics/settings.php @@ -0,0 +1,560 @@ + isset($_POST['enabled']), + 'log_enabled' => isset($_POST['log_enabled']), + // Subsystem toggles + 'passion_enabled' => isset($_POST['passion_enabled']), + 'ambient_enabled' => isset($_POST['ambient_enabled']), + 'combat_enabled' => isset($_POST['combat_enabled']), + 'jealousy_enabled' => isset($_POST['jealousy_enabled']), + 'reunion_enabled' => isset($_POST['reunion_enabled']), + 'conflict_enabled' => isset($_POST['conflict_enabled']), + 'topic_bonus_enabled' => isset($_POST['topic_bonus_enabled']), + 'flirt_bonus_enabled' => isset($_POST['flirt_bonus_enabled']), + 'type_filter_enabled' => isset($_POST['type_filter_enabled']), + // Passion settings + 'base_passion_gain' => max(0.1, min(10.0, floatval($_POST['base_passion_gain'] ?? 2.0))), + 'passion_max' => max(10, min(200, floatval($_POST['passion_max'] ?? 100))), + 'decay_max_hours' => max(0, min(168, floatval($_POST['decay_max_hours'] ?? 0))), + // Jealousy settings + 'jealousy_max' => max(10, min(200, floatval($_POST['jealousy_max'] ?? 100))), + 'jealousy_decay_per_hour' => max(0.1, min(10.0, floatval($_POST['jealousy_decay_per_hour'] ?? 1.5))), + // Conflict settings + 'conflict_threshold_affinity_drop' => max(1, min(50, intval($_POST['conflict_threshold_affinity_drop'] ?? 10))), + 'conflict_threshold_jealousy' => max(5, min(100, intval($_POST['conflict_threshold_jealousy'] ?? 40))), + 'conflict_resolution_positive_count' => max(1, min(20, intval($_POST['conflict_resolution_positive_count'] ?? 3))), + 'conflict_repair_passion_burst' => max(1.0, min(50.0, floatval($_POST['conflict_repair_passion_burst'] ?? 20.0))), + 'conflict_repair_passion_mult' => max(1.0, min(3.0, floatval($_POST['conflict_repair_passion_mult'] ?? 1.5))), + // Reunion settings + 'reunion_min_hours' => max(1, min(48, intval($_POST['reunion_min_hours'] ?? 8))), + 'reunion_min_affection' => max(0, min(100, intval($_POST['reunion_min_affection'] ?? 40))), + // Stage thresholds + 'stage_established_threshold' => max(10, min(500, intval($_POST['stage_established_threshold'] ?? 50))), + 'stage_deep_threshold' => max(50, min(2000, intval($_POST['stage_deep_threshold'] ?? 200))), + ]; + + $jsonConfig = json_encode($newConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $escaped = $db->escape($jsonConfig); + + // Upsert + $existing = $db->fetchOne("SELECT id FROM conf_opts WHERE id = 'relationship_dynamics_config' LIMIT 1"); + if (!empty($existing)) { + $db->execQuery("UPDATE conf_opts SET value = '{$escaped}' WHERE id = 'relationship_dynamics_config'"); + } else { + $db->execQuery("INSERT INTO conf_opts (id, value) VALUES ('relationship_dynamics_config', '{$escaped}')"); + } + + // Clear cached config so re-read picks up changes + RelationshipDynamics::clearConfigCache(); + + $saveMsg = 'Settings saved.'; + $saveOk = true; +} + +// Load current config +$cfg = RelationshipDynamics::getConfig(); + +// ========================================================================= +// HTML +// ========================================================================= +if (!$embed) { + $TITLE = "Relationship Dynamics"; + ob_start(); + include $enginePath . 'ui/tmpl/head.html'; + include $enginePath . 'ui/tmpl/navbar.php'; +} +?> + + + + + + + +
+
+

Relationship Dynamics

+

RPM / Speed / Gears — passion drives affinity gain, diminishing returns enforce multi-day pacing

+
+ + +
+ + +
+ + +
+

General

+
+ + > + +
+
+ + > + +
+
+ + +
+

Subsystem Toggles

+

Enable or disable individual systems. All default to ON. Disabling a subsystem skips its processing entirely — zero overhead.

+
+
+ + > + +
+
+ + > + +
+
+ + > + +
+
+ + > + +
+
+ + > + +
+
+ + > + +
+
+ + > + +
+
+ + > + +
+
+ + > + +
+
+
+ + +
+

Passion (RPM)

+
affinity_gain_mult = 0.3 + (passion / 100) x 1.7 + passion 0 = x0.3 (idling) passion 50 = x1.15 (cruising) passion 100 = x2.0 (redline)
+
+ + + Per interaction before multipliers (default: 2.0) +
+
+ + + Hard cap (default: 100) +
+
+ + + 0 = passion frozen when offline (default). Set > 0 for persistent server / sim scenarios. +
+
+ + +
+

Warmth Curves

+

Per-NPC curve set in Sharmat NPC Editor. These are the built-in presets:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CurveTemperamentsDecay RateHalf-LifePassion DecayTarget Days
slow_burnRomantic, Jealous0.10/int10h2.5/hr3 IRL days
moderateHumble0.08/int8h3.0/hr2 days
quick_warmth(default)0.06/int6h4.0/hr1.5 days
guardedProud, Independent0.12/int12h5.0/hr4+ days
+
+ + +
+

Jealousy

+
+ + + Hard cap (default: 100) +
+
+ + + Anger fades slower than joy (default: 1.5) +
+
+ + +
+

Conflict / Repair Cycle

+
+ + + Points of affinity lost in session (default: 10) +
+
+ + + Jealousy anger threshold (default: 40) +
+
+ + + Kind acts needed to end conflict (default: 3) +
+
+ + + Passion gained on resolution (default: 20) +
+
+ + + Bonus on positive acts during conflict (default: 1.5x) +
+
+ + +
+

Reunion Spike

+
+ + + Real-time hours before reunion fires (default: 8) +
+
+ + + MARAS affection required (default: 40) +
+

+ 8-16h = +5 | 16-24h = +8 | 24-48h = +12 | 48-72h = +18 | 72h+ = +25 passion. + Bypasses diminishing returns. +

+
+ + +
+

Relationship Stages

+
+ + + Positive interactions to reach Established (default: 50) +
+
+ + + Positive interactions to reach Deep (default: 200) +
+ + + + + + + + + + + + + + + + +
StagePassion FloorPassion CeilingGain MultDR MultCharacter
Early01001.3x0.8xButterflies, high highs
Established5701.0x1.2xComfortable, routine
Deep15500.8x1.0xResilient, hard to break
+
+ + +
+ + Changes apply immediately to all NPCs. +
+ +
+
+ +)(.*?)(<\/title>)/i', '$1' . $title . '$3', $buffer); + echo $buffer; +} +?> diff --git a/ui/core/config_hub.php b/ui/core/config_hub.php index df207c90..2b29f857 100644 --- a/ui/core/config_hub.php +++ b/ui/core/config_hub.php @@ -136,6 +136,9 @@ + + + @@ -235,6 +238,13 @@ + +
+
+ +
+
+ diff --git a/ui/core/npc_master.php b/ui/core/npc_master.php index e4f1bfd2..cc5bc39a 100644 --- a/ui/core/npc_master.php +++ b/ui/core/npc_master.php @@ -457,7 +457,21 @@ function chimUiAutoLockProfileEnabled(): bool } catch (Throwable $e) { $_POST['extended_data'] = '{}'; } - + + // Merge relationship_dynamics from DB into posted extended_data + // RelDyn saves via its own AJAX endpoint, so the form textarea has stale data + if (file_exists(__DIR__."/../../ext/relationship_dynamics/relationship_dynamics.php")) { + try { + $_rdPosted = json_decode($_POST['extended_data'] ?? '{}', true) ?: []; + $_rdRow = $npcMaster->getByName($_POST['npc_name'] ?? ''); + $_rdCurrent = json_decode($_rdRow['extended_data'] ?? '{}', true) ?: []; + if (isset($_rdCurrent['relationship_dynamics'])) { + $_rdPosted['relationship_dynamics'] = $_rdCurrent['relationship_dynamics']; + $_POST['extended_data'] = json_encode($_rdPosted); + } + } catch (Throwable $e) {} + } + // Handle dynamic_profile: if empty string sent, set to NULL (inherit from profile) if (array_key_exists('dynamic_profile', $_POST)) { $dynVal = $_POST['dynamic_profile']; @@ -1913,6 +1927,10 @@ function updateInheritedSettings(profileId) { include(__DIR__."/../../ext/relationship_system/relationship_editor.php"); } ?> + +
@@ -2258,7 +2276,7 @@ function updateInheritedSettings(profileId) { Override global and profile settings for this specific NPC. Changes here take precedence over all other configurations.