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);
+}
+?>
+
+
+
+
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';
+}
+?>
+
+
+
+
+
+
+
+
+
+)(.*?)(<\/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 @@
💬Prompts Manager
📢 TTS Studio
🔌Server Plugins
+
+ 💕Relationship Dynamics
+
@@ -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");
} ?>
+
+
Occupation
@@ -2258,7 +2276,7 @@ function updateInheritedSettings(profileId) {
Override global and profile settings for this specific NPC. Changes here take precedence over all other configurations.