diff --git a/data/schema.sql b/data/schema.sql index 4c5686c..1f025f6 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -2188,6 +2188,28 @@ Fields currently editable for this NPC: #ALLOWED_FIELDS#$$, $$, $$Prompt template for bored-event generation. Supports #NPC_LIST#, #LOCATION#, #WORLD_EVENTS#. Used in lib/chat_helper_functions.php.$$ ), +( + 'kenshi_mechanics_alignment', + $$ + Apply these grounding rules before generating any response. + #EVENT_TYPE# + + #IN_PLAYER_FACTION# + #NPC_HEALTH# + #NPC_BLOOD# + #NPC_HUNGER# + #PLAYER_CATS# + + + Treat Kenshi constraints as hard reality. Do not imply modern conveniences, safe institutions, or guaranteed mercy. + Ground tone and priorities in survival pressure: injuries, blood loss, hunger, danger, faction tension, and nearby threats. + Do not invent items, money, authority, travel options, or social status not supported by context. + If suggesting actions, keep them physically and socially plausible for the speaker's current condition and affiliations. + Prefer terse, practical speech under stress; only become reflective when context supports safety or downtime. + +$$, + $$Grounding block to align chat/rechat/bored responses with Kenshi mechanics and current character state. Supports #EVENT_TYPE#, #IN_PLAYER_FACTION#, #NPC_HEALTH#, #NPC_BLOOD#, #NPC_HUNGER#, #PLAYER_CATS#. Used in lib/chat_helper_functions.php.$$ +), ( 'random_narration_prompt', $$Describe the current scene visually using only details from context. Focus on characters present, body language, environment, and atmosphere in 1-2 concise sentences. Do not invent events or include action tags.$$, diff --git a/debug/db_updates.php b/debug/db_updates.php index 7e10b1f..ede7f9a 100644 --- a/debug/db_updates.php +++ b/debug/db_updates.php @@ -1610,6 +1610,36 @@ function stobeRunDatabaseUpdates(): void [$prompt, $description] ); }); + $applyPatch('prompts', 202605070101, static function () use ($db): void { + $prompt = "\n" + . " Apply these grounding rules before generating any response.\n" + . " #EVENT_TYPE#\n" + . " \n" + . " #IN_PLAYER_FACTION#\n" + . " #NPC_HEALTH#\n" + . " #NPC_BLOOD#\n" + . " #NPC_HUNGER#\n" + . " #PLAYER_CATS#\n" + . " \n" + . " \n" + . " Treat Kenshi constraints as hard reality. Do not imply modern conveniences, safe institutions, or guaranteed mercy.\n" + . " Ground tone and priorities in survival pressure: injuries, blood loss, hunger, danger, faction tension, and nearby threats.\n" + . " Do not invent items, money, authority, travel options, or social status not supported by context.\n" + . " If suggesting actions, keep them physically and socially plausible for the speaker's current condition and affiliations.\n" + . " Prefer terse, practical speech under stress; only become reflective when context supports safety or downtime.\n" + . " \n" + . ""; + $description = 'Grounding block to align chat/rechat/bored responses with Kenshi mechanics and current character state. Supports #EVENT_TYPE#, #IN_PLAYER_FACTION#, #NPC_HEALTH#, #NPC_BLOOD#, #NPC_HUNGER#, #PLAYER_CATS#. Used in lib/chat_helper_functions.php.'; + $db->exec( + "INSERT INTO prompts (prompt_key, default_prompt, description) + VALUES ('kenshi_mechanics_alignment', $1, $2) + ON CONFLICT (prompt_key) DO UPDATE + SET default_prompt = EXCLUDED.default_prompt, + description = EXCLUDED.description, + updated_at = NOW()", + [$prompt, $description] + ); + }); $applyPatch('core_profiles', 202604110102, static function () use ($db): void { $db->exec( "UPDATE core_profiles diff --git a/lib/chat_helper_functions.php b/lib/chat_helper_functions.php index 139384d..d989364 100644 --- a/lib/chat_helper_functions.php +++ b/lib/chat_helper_functions.php @@ -1859,7 +1859,17 @@ function stobeWorldKnowledgeResolveForcedRaceHints( $seenTopics = []; foreach ($raceSignals as $raceSignal) { - $row = stobeWorldKnowledgeLookupTopicRowByLabel(strval($raceSignal)); + $row = null; + $lookupCandidates = stobeWorldKnowledgeBuildLookupCandidates(strval($raceSignal), $npcData); + if (count($lookupCandidates) === 0) { + $lookupCandidates = [strval($raceSignal)]; + } + foreach ($lookupCandidates as $lookupLabel) { + $row = stobeWorldKnowledgeLookupTopicRowByLabel(strval($lookupLabel)); + if (is_array($row) && count($row) > 0) { + break; + } + } if (!is_array($row) || count($row) === 0) { continue; } @@ -2348,7 +2358,591 @@ function stobeWorldKnowledgeExtractTopics(string $message, string $contextKeywor if (count($topics) > 0) { return $topics; } - return stobeWorldKnowledgeExtractTopicsHeuristic($message, $contextKeywords, $maxTopics); + return stobeWorldKnowledgeExtractTopicsHeuristic($message, $contextKeywords, $maxTopics); +} + +function stobeWorldKnowledgeSignalTranslationEnabled(): bool +{ + return getSettingBool('WORLD_KNOWLEDGE_SIGNAL_TRANSLATION_ENABLED', false); +} + +function stobeWorldKnowledgeSignalTranslationAllowAllSignals(): bool +{ + return getSettingBool('WORLD_KNOWLEDGE_SIGNAL_TRANSLATION_ALL_SIGNALS', false); +} + +function stobeWorldKnowledgeSignalGlossaryMap(): array +{ + static $cached = null; + if ($cached !== null) { + return $cached; + } + + $defaults = [ + 'nacion santa' => 'Holy Nation', + 'nación santa' => 'Holy Nation', + 'anti esclavistas' => 'Anti-Slavers', + 'antiesclavistas' => 'Anti-Slavers', + 'ciudades unidas' => 'United Cities', + 'reino shek' => 'Shek Kingdom', + 'colmena occidental' => 'Western Hive', + 'colmena del sur' => 'Southern Hive', + ]; + + $map = []; + foreach ($defaults as $source => $target) { + $key = mb_strtolower(trim(strval($source)), 'UTF-8'); + $value = trim(strval($target)); + if ($key !== '' && $value !== '') { + $map[$key] = $value; + } + } + + $raw = trim(strval(getSetting('WORLD_KNOWLEDGE_SIGNAL_GLOSSARY_JSON', ''))); + if ($raw !== '') { + $decoded = json_decode($raw, true); + if (is_array($decoded)) { + foreach ($decoded as $source => $target) { + if (!is_scalar($source) || !is_scalar($target)) { + continue; + } + $key = mb_strtolower(trim(strval($source)), 'UTF-8'); + $value = trim(strval($target)); + if ($key === '' || $value === '') { + continue; + } + $map[$key] = $value; + } + } + } + + $cached = $map; + return $cached; +} + +function stobeWorldKnowledgeApplyGlossaryToSignal(string $text, array $glossary, array &$applied): string +{ + $normalizedText = trim($text); + if ($normalizedText === '' || count($glossary) === 0) { + return $normalizedText; + } + + $terms = array_keys($glossary); + usort( + $terms, + static fn(string $a, string $b): int => strlen($b) <=> strlen($a) + ); + + $output = $normalizedText; + foreach ($terms as $source) { + $target = trim(strval($glossary[$source] ?? '')); + if ($target === '') { + continue; + } + $pattern = '/\b' . preg_quote($source, '/') . '\b/iu'; + $newOutput = preg_replace($pattern, $target, $output) ?? $output; + if ($newOutput !== $output) { + $applied[$source] = $target; + $output = $newOutput; + } + } + + $output = preg_replace('/\s+/u', ' ', $output) ?? $output; + return trim($output); +} + +function stobeWorldKnowledgeReadSignalTranslationCache(string $text): string +{ + $db = $GLOBALS['db'] ?? null; + if (!$db) { + return ''; + } + + $safeText = trim($text); + if ($safeText === '') { + return ''; + } + + $cacheMinutes = max(5, min(10080, getSettingInt('WORLD_KNOWLEDGE_SIGNAL_TRANSLATION_CACHE_MIN', 1440))); + $cacheId = 'wk_signal_translate:' . substr(sha1(mb_strtolower($safeText, 'UTF-8')), 0, 32); + + try { + $row = $db->fetchOne( + "SELECT value + FROM conf_opts + WHERE id = $1 + AND updated_at >= NOW() - (($2::text || ' minutes')::interval) + LIMIT 1", + [$cacheId, strval($cacheMinutes)] + ); + } catch (Throwable $exception) { + return ''; + } + + return trim(strval($row['value'] ?? '')); +} + +function stobeWorldKnowledgeWriteSignalTranslationCache(string $text, string $translated): void +{ + $db = $GLOBALS['db'] ?? null; + if (!$db) { + return; + } + + $safeText = trim($text); + $safeTranslated = trim($translated); + if ($safeText === '' || $safeTranslated === '') { + return; + } + + $cacheId = 'wk_signal_translate:' . substr(sha1(mb_strtolower($safeText, 'UTF-8')), 0, 32); + try { + $db->exec( + "INSERT INTO conf_opts (id, value, updated_at) + VALUES ($1, $2, NOW()) + ON CONFLICT (id) DO UPDATE + SET value = EXCLUDED.value, + updated_at = NOW()", + [$cacheId, truncatePromptValue($safeTranslated, 500)] + ); + } catch (Throwable $exception) { + return; + } +} + +function stobeWorldKnowledgeResolveTranslatorConfig(array|false $npcData): array +{ + $forcedConnectorId = getSettingInt('WORLD_KNOWLEDGE_SIGNAL_TRANSLATOR_CONNECTOR_ID', 0); + if ($forcedConnectorId > 0 && function_exists('getLlmConnectorById')) { + $forced = getLlmConnectorById($forcedConnectorId); + if ($forced && function_exists('stobeBuildLlmConfigFromConnector')) { + return stobeBuildLlmConfigFromConnector($forced); + } + } + + $forcedType = strtolower(trim(strval(getSetting('WORLD_KNOWLEDGE_SIGNAL_TRANSLATOR_DRIVER', 'player2json')))); + if ($forcedType !== '' && function_exists('getAllLlmConnectors') && function_exists('stobeBuildLlmConfigFromConnector')) { + $connectors = getAllLlmConnectors(); + foreach ($connectors as $connector) { + $connectorType = strtolower(trim(strval($connector['connector_type'] ?? ''))); + if ($connectorType !== $forcedType) { + continue; + } + return stobeBuildLlmConfigFromConnector($connector); + } + } + + return getLlmConfigForNpcPurpose($npcData, 'response'); +} + +function stobeWorldKnowledgeNormalizeSpanishText(string $value): string +{ + $normalized = mb_strtolower(trim($value), 'UTF-8'); + if ($normalized === '') { + return ''; + } + $normalized = strtr( + $normalized, + [ + 'á' => 'a', 'à' => 'a', 'ä' => 'a', 'â' => 'a', + 'é' => 'e', 'è' => 'e', 'ë' => 'e', 'ê' => 'e', + 'í' => 'i', 'ì' => 'i', 'ï' => 'i', 'î' => 'i', + 'ó' => 'o', 'ò' => 'o', 'ö' => 'o', 'ô' => 'o', + 'ú' => 'u', 'ù' => 'u', 'ü' => 'u', 'û' => 'u', + 'ñ' => 'n', + ] + ); + $normalized = preg_replace('/\s+/u', ' ', $normalized) ?? $normalized; + return trim($normalized); +} + +function stobeWorldKnowledgeHeuristicCanonicalTargets(string $value): array +{ + $normalized = stobeWorldKnowledgeNormalizeSpanishText($value); + if ($normalized === '') { + return []; + } + + $targets = []; + if (preg_match('/\bnacion\s+(sagrada|santa|santo)\b/u', $normalized) === 1) { + $targets[] = 'Holy Nation'; + } + + return stobeWorldKnowledgeUniqueTerms($targets, 3); +} + +function stobeWorldKnowledgeTranslateSignalWithLlmDetailed(string $text, array $glossary, array|false $npcData): array +{ + $result = [ + 'attempted' => false, + 'accepted' => false, + 'translated' => '', + 'raw' => '', + 'reason' => 'unknown', + ]; + + if (!stobeWorldKnowledgeSignalTranslationEnabled()) { + $result['reason'] = 'translation_disabled'; + return $result; + } + + $safeText = trim($text); + if ($safeText === '') { + $result['reason'] = 'empty_signal'; + return $result; + } + + if (!stobeWorldKnowledgeHasQueryableTerms($safeText)) { + $result['reason'] = 'non_queryable_signal'; + return $result; + } + + $result['attempted'] = true; + $cached = stobeWorldKnowledgeReadSignalTranslationCache($safeText); + if ($cached !== '') { + $result['accepted'] = true; + $result['translated'] = $cached; + $result['reason'] = 'cache_hit'; + return $result; + } + + $enginePath = dirname(__DIR__) . DIRECTORY_SEPARATOR; + $dispatcher = $enginePath . 'connector' . DIRECTORY_SEPARATOR . 'llm_dispatcher.php'; + if (is_file($dispatcher)) { + require_once($dispatcher); + } + if (!function_exists('stobeCallLLM')) { + $result['reason'] = 'dispatcher_unavailable'; + return $result; + } + + $llmConfig = stobeWorldKnowledgeResolveTranslatorConfig($npcData); + $glossaryLines = []; + foreach ($glossary as $source => $target) { + $sourceSafe = trim(strval($source)); + $targetSafe = trim(strval($target)); + if ($sourceSafe === '' || $targetSafe === '') { + continue; + } + $glossaryLines[] = '- ' . $sourceSafe . ' => ' . $targetSafe; + if (count($glossaryLines) >= 80) { + break; + } + } + + $systemPrompt = "You normalize retrieval signals for Kenshi lore lookup. " + . "This is not dialogue translation. " + . "Your job is to produce a short English search phrase that matches canonical Kenshi lore terms used by the English knowledge base. " + . "Respect glossary replacements exactly when provided. " + . "If a Spanish proper noun has a known canonical English lore name, use that canonical lore name instead of a literal translation. " + . "Examples: Nación Santa => Holy Nation, antiesclavistas => Anti-Slavers, Ciudades Unidas => United Cities. " + . "Preserve only retrieval-relevant entities, factions, locations, races and core intent terms. " + . "Do not add explanations, do not roleplay, do not expand with extra lore. " + . "If unsure about a token, keep the original token rather than inventing a new lore term. " + . "Return strict JSON only with shape: {\"en\":\"...\"}."; + $userPrompt = "source_text:\n" . $safeText; + if (count($glossaryLines) > 0) { + $userPrompt .= "\n\nglossary:\n" . implode("\n", $glossaryLines); + } + + $raw = stobeCallLLM( + [ + ['role' => 'system', 'content' => $systemPrompt], + ['role' => 'user', 'content' => $userPrompt], + ], + $llmConfig, + [ + 'event_type' => 'world_knowledge_signal_translation', + 'response_format' => ['type' => 'json_object'], + ] + ); + + if ($raw === false) { + $result['reason'] = 'llm_call_failed'; + return $result; + } + + $result['raw'] = trim(strval($raw)); + $decoded = json_decode($result['raw'], true); + if (!is_array($decoded)) { + $result['reason'] = 'invalid_json'; + return $result; + } + + $translated = trim(strval($decoded['en'] ?? '')); + $result['translated'] = $translated; + if ($translated === '') { + $result['reason'] = 'empty_en'; + return $result; + } + if (!stobeWorldKnowledgeHasQueryableTerms($translated)) { + $result['reason'] = 'non_queryable_en'; + return $result; + } + + stobeWorldKnowledgeWriteSignalTranslationCache($safeText, $translated); + $result['accepted'] = true; + $result['reason'] = 'accepted'; + return $result; +} + +function stobeWorldKnowledgeTranslateSignalWithLlm(string $text, array $glossary, array|false $npcData): string +{ + $detail = stobeWorldKnowledgeTranslateSignalWithLlmDetailed($text, $glossary, $npcData); + if (!boolval($detail['accepted'] ?? false)) { + return ''; + } + return trim(strval($detail['translated'] ?? '')); +} + +function stobeWorldKnowledgeBuildSignalVariantPack( + string $signal, + array|false $npcData = false, + bool $allowLlm = false +): array { + $base = trim($signal); + if ($base === '') { + return [ + 'entries' => [], + 'llm' => [ + 'attempted' => false, + 'accepted' => false, + 'translated' => '', + 'raw' => '', + 'reason' => 'empty_signal', + ], + ]; + } + + $rawEntries = [ + ['text' => $base, 'source' => 'original'], + ]; + + $glossary = stobeWorldKnowledgeSignalGlossaryMap(); + $applied = []; + $glossaryVariant = stobeWorldKnowledgeApplyGlossaryToSignal($base, $glossary, $applied); + if ($glossaryVariant !== '' && strcasecmp($glossaryVariant, $base) !== 0) { + $rawEntries[] = ['text' => $glossaryVariant, 'source' => 'glossary']; + } + if (count($applied) > 0) { + foreach ($applied as $canonicalTarget) { + $target = trim(strval($canonicalTarget)); + if ($target === '' || !stobeWorldKnowledgeHasQueryableTerms($target)) { + continue; + } + $rawEntries[] = ['text' => $target, 'source' => 'glossary']; + } + } + + $heuristicTargets = stobeWorldKnowledgeHeuristicCanonicalTargets($base); + foreach ($heuristicTargets as $heuristicTarget) { + $target = trim(strval($heuristicTarget)); + if ($target === '' || !stobeWorldKnowledgeHasQueryableTerms($target)) { + continue; + } + $rawEntries[] = ['text' => $target, 'source' => 'heuristic']; + } + + $llmDetail = [ + 'attempted' => false, + 'accepted' => false, + 'translated' => '', + 'raw' => '', + 'reason' => $allowLlm ? 'translation_disabled' : 'llm_not_requested', + ]; + if ($allowLlm && stobeWorldKnowledgeSignalTranslationEnabled()) { + $llmDetail = stobeWorldKnowledgeTranslateSignalWithLlmDetailed( + $glossaryVariant !== '' ? $glossaryVariant : $base, + $glossary, + $npcData + ); + if (boolval($llmDetail['accepted'] ?? false)) { + $llmVariant = trim(strval($llmDetail['translated'] ?? '')); + if ($llmVariant !== '') { + $rawEntries[] = ['text' => $llmVariant, 'source' => 'llm']; + } + } + } + + $merged = []; + foreach ($rawEntries as $rawEntry) { + $text = trim(strval($rawEntry['text'] ?? '')); + if ($text === '') { + continue; + } + $source = trim(strval($rawEntry['source'] ?? '')); + if ($source === '') { + $source = 'original'; + } + + $key = mb_strtolower($text, 'UTF-8'); + if (!isset($merged[$key])) { + $merged[$key] = [ + 'text' => $text, + 'sources' => [$source], + ]; + if (count($merged) >= 4) { + break; + } + continue; + } + + if (!in_array($source, $merged[$key]['sources'], true)) { + $merged[$key]['sources'][] = $source; + } + } + + $entries = []; + foreach ($merged as $entry) { + $entries[] = $entry; + } + + return [ + 'entries' => $entries, + 'llm' => $llmDetail, + ]; +} + +function stobeWorldKnowledgeBuildSignalVariants( + string $signal, + array|false $npcData = false, + bool $allowLlm = false +): array { + $pack = stobeWorldKnowledgeBuildSignalVariantPack($signal, $npcData, $allowLlm); + $entries = is_array($pack['entries'] ?? null) ? $pack['entries'] : []; + $variants = []; + foreach ($entries as $entry) { + $text = trim(strval($entry['text'] ?? '')); + if ($text === '') { + continue; + } + $variants[] = $text; + } + return stobeWorldKnowledgeUniqueTerms($variants, 4); +} + +function stobeWorldKnowledgeBuildSignalVariantEntries( + string $signal, + array|false $npcData = false, + bool $allowLlm = false +): array { + $pack = stobeWorldKnowledgeBuildSignalVariantPack($signal, $npcData, $allowLlm); + return is_array($pack['entries'] ?? null) ? $pack['entries'] : []; +} + +function stobeWorldKnowledgeFormatLlmAttemptTrace(string $label, array $llmDetail): string +{ + $cleanLabel = trim($label); + if ($cleanLabel === '') { + return ''; + } + $attempted = boolval($llmDetail['attempted'] ?? false); + if (!$attempted) { + return ''; + } + + $accepted = boolval($llmDetail['accepted'] ?? false); + $reason = trim(strval($llmDetail['reason'] ?? '')); + $translated = truncatePromptValue(trim(strval($llmDetail['translated'] ?? '')), 180); + $raw = truncatePromptValue(trim(strval($llmDetail['raw'] ?? '')), 180); + + $parts = []; + $parts[] = 'status=' . ($accepted ? 'accepted' : 'rejected'); + if ($reason !== '') { + $parts[] = 'reason=' . $reason; + } + if ($translated !== '') { + $parts[] = 'en=' . $translated; + } + if ($raw !== '') { + $parts[] = 'raw=' . $raw; + } + + return 'llm_' . $cleanLabel . '={' . implode(', ', $parts) . '}'; +} + +function stobeWorldKnowledgeFormatVariantEntryTrace(string $label, array $entries): string +{ + $cleanLabel = trim($label); + if ($cleanLabel === '' || count($entries) === 0) { + return ''; + } + + $parts = []; + foreach ($entries as $entry) { + $text = trim(strval($entry['text'] ?? '')); + if ($text === '') { + continue; + } + $sources = $entry['sources'] ?? []; + if (!is_array($sources)) { + $sources = []; + } + $sourceParts = []; + foreach ($sources as $source) { + $token = trim(strval($source)); + if ($token === '') { + continue; + } + $sourceParts[] = $token; + } + if (count($sourceParts) === 0) { + $parts[] = $text; + } else { + $parts[] = $text . '{' . implode('+', $sourceParts) . '}'; + } + if (count($parts) >= 4) { + break; + } + } + + if (count($parts) === 0) { + return ''; + } + + return $cleanLabel . '=[' . implode(' => ', $parts) . ']'; +} + +function stobeWorldKnowledgeBuildLookupCandidates(string $label, array|false $npcData = false): array +{ + $candidates = []; + $variants = stobeWorldKnowledgeBuildSignalVariants($label, $npcData, true); + if (count($variants) === 0) { + $variants = [$label]; + } + foreach ($variants as $variant) { + $normalized = stobeWorldKnowledgeNormalizeLookupLabel($variant); + if ($normalized === '') { + continue; + } + $candidates[] = $normalized; + } + return stobeWorldKnowledgeUniqueTerms($candidates, 6); +} + +function stobeWorldKnowledgeFormatVariantTrace(string $label, array $variants): string +{ + $cleanLabel = trim($label); + if ($cleanLabel === '' || count($variants) === 0) { + return ''; + } + + $parts = []; + foreach ($variants as $variant) { + $value = trim(strval($variant)); + if ($value === '') { + continue; + } + $parts[] = $value; + if (count($parts) >= 4) { + break; + } + } + if (count($parts) === 0) { + return ''; + } + + return $cleanLabel . '=[' . implode(' => ', $parts) . ']'; } function stobeWorldKnowledgeWriteAuditRow(array $payload): void { @@ -2361,11 +2955,14 @@ function stobeWorldKnowledgeWriteAuditRow(array $payload): void { $selectedTopic = trim(strval($payload['selected_topic'] ?? '')); $selectedMode = trim(strval($payload['selected_mode'] ?? '')); $selectedEntryId = intval($payload['selected_entry_id'] ?? 0); + $npcName = truncatePromptValue(strval($payload['npc_name'] ?? ''), 120); + $eventType = truncatePromptValue(strval($payload['event_type'] ?? ''), 60); $locationContext = truncatePromptValue(strval($payload['location_context'] ?? ''), 400); $contextKeywords = truncatePromptValue(strval($payload['context_keywords'] ?? ''), 400); $currentTopicBefore = truncatePromptValue(strval($payload['current_topic_before'] ?? ''), 180); $currentTopicAfter = truncatePromptValue(strval($payload['current_topic_after'] ?? ''), 180); $knowledgeTags = truncatePromptValue(strval($payload['knowledge_tags'] ?? ''), 240); + $signalDebug = truncatePromptValue(strval($payload['signal_debug'] ?? ''), 2000); $notes = truncatePromptValue(strval($payload['notes'] ?? ''), 800); $rank = floatval($payload['selected_rank'] ?? 0.0); @@ -2393,9 +2990,18 @@ function stobeWorldKnowledgeWriteAuditRow(array $payload): void { if ($topicText !== '') { $keywordParts[] = 'topics=' . $topicText; } + if ($npcName !== '') { + $keywordParts[] = 'npc=' . $npcName; + } + if ($eventType !== '') { + $keywordParts[] = 'event=' . $eventType; + } if ($notes !== '') { $keywordParts[] = 'notes=' . $notes; } + if ($signalDebug !== '') { + $keywordParts[] = 'signals=' . $signalDebug; + } $keywords = trim(implode(' | ', $keywordParts)); if ($keywords === '') { $keywords = 'world_knowledge'; @@ -2411,6 +3017,12 @@ function stobeWorldKnowledgeWriteAuditRow(array $payload): void { if ($selectedEntryId > 0) { $memoryParts[] = 'entry_id=' . strval($selectedEntryId); } + if ($npcName !== '') { + $memoryParts[] = 'npc=' . $npcName; + } + if ($eventType !== '') { + $memoryParts[] = 'event=' . $eventType; + } if ($locationContext !== '') { $memoryParts[] = 'location=' . $locationContext; } @@ -2426,6 +3038,9 @@ function stobeWorldKnowledgeWriteAuditRow(array $payload): void { if ($knowledgeTags !== '') { $memoryParts[] = 'tags=' . $knowledgeTags; } + if ($signalDebug !== '') { + $memoryParts[] = 'signals=' . $signalDebug; + } $memory = truncatePromptValue(implode(' / ', $memoryParts), 8000); $elapsedSeconds = floatval($payload['elapsed_seconds'] ?? 0.0); @@ -3363,6 +3978,113 @@ function stobeNormalizeContextHistoryDataLine(string $historyData): string { return stobeSanitizePromptContextLine($singleLine); } +function stobeExtractRoleplayActionContextLine(string $historyData): string { + $raw = trim($historyData); + if ($raw === '') { + return ''; + } + + if (preg_match('/ROLEPLAY_ACTION\s*@\s*(.+)$/iu', $raw, $matches) !== 1) { + return ''; + } + + $notice = stobeSanitizePromptContextLine(trim(strval($matches[1] ?? ''))); + if ($notice === '') { + return ''; + } + + $parsed = parseDialogueEventData($raw); + $speaker = normalizeParticipantNameToken(strval($parsed['speaker'] ?? '')); + $target = normalizeParticipantNameToken(strval($parsed['target'] ?? '')); + + if ($speaker !== '') { + $line = $speaker . ': roleplay context - ' . $notice; + if ($target !== '' && strcasecmp($target, $speaker) !== 0) { + $line .= ' (talking to: ' . $target . ')'; + } + return stobeSanitizePromptContextLine($line); + } + + return stobeSanitizePromptContextLine('Roleplay context: ' . $notice); +} + +function stobeGetRecentRoleplayActionContextForSpeaker( + string $speaker, + string $listener = '', + int $maxAgeSeconds = 900, + int $minLocalTsHint = 0, + bool $allowFallbackToOlder = true, + int $waitForFreshMs = 0 +): string { + $db = $GLOBALS['db'] ?? null; + if (!$db || !method_exists($db, 'fetchAll')) { + return ''; + } + + $safeSpeaker = normalizeParticipantNameToken($speaker); + if ($safeSpeaker === '') { + return ''; + } + $safeListener = normalizeParticipantNameToken($listener); + $minLocalTs = time() - max(30, intval($maxAgeSeconds)); + + $fetchRecent = static function (int $sinceTs) use ($db, $safeSpeaker, $safeListener): string { + $rows = $db->fetchAll( + "SELECT rowid, data, people, localts + FROM eventlog + WHERE type = 'infoaction' + AND data ILIKE $1 + AND COALESCE(localts, 0) >= $2 + ORDER BY rowid DESC + LIMIT 40", + [$safeSpeaker . ': action command received:%ROLEPLAY_ACTION@%', $sinceTs] + ); + + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + if ($safeListener !== '') { + $people = strtolower(trim(strval($row['people'] ?? ''))); + if ($people !== '' && strpos($people, strtolower($safeListener)) === false) { + continue; + } + } + + $line = stobeExtractRoleplayActionContextLine(strval($row['data'] ?? '')); + if ($line !== '') { + return $line; + } + } + + return ''; + }; + + $freshSince = max($minLocalTs, max(0, intval($minLocalTsHint))); + $line = $fetchRecent($freshSince); + if ($line !== '') { + return $line; + } + + $waitMs = max(0, intval($waitForFreshMs)); + if ($waitMs > 0) { + $deadline = microtime(true) + ($waitMs / 1000.0); + while (microtime(true) < $deadline) { + usleep(150000); + $line = $fetchRecent($freshSince); + if ($line !== '') { + return $line; + } + } + } + + if (!$allowFallbackToOlder) { + return ''; + } + + return $fetchRecent($minLocalTs); +} + function stobeIsMergeableRecentContextType(string $historyType): bool { return in_array($historyType, ['infonpc', 'infonpc_close', 'infoloc', 'location', 'infoitems'], true); } @@ -3635,13 +4357,16 @@ function stobeBuildRecentContextMessages(array $eventHistory, int $currentGamets } $historyType = strtolower(trim(strval($row['type'] ?? 'event'))); - if ($historyType === 'infoaction') { - continue; - } if ($historyType === 'inputtext' || $historyType === 'inputtext_s') { continue; } $historyData = stobeNormalizeContextHistoryDataLine(strval($row['data'] ?? '')); + if ($historyType === 'infoaction') { + $historyData = stobeExtractRoleplayActionContextLine($historyData); + if ($historyData !== '') { + $historyType = 'roleplay_action'; + } + } if ($historyData === '') { continue; } @@ -3650,6 +4375,7 @@ function stobeBuildRecentContextMessages(array $eventHistory, int $currentGamets 'inputtext', 'inputtext_s', 'chat', 'rechat', 'bored', 'action', 'death', 'limb_loss', 'knockout', 'enslaved', 'freed_slave', 'item_pickup', 'carry', 'predation', 'trade', + 'roleplay_action', ]; if (!in_array($historyType, $inlineTypes, true)) { $historyData = '[' . $historyType . '] ' . $historyData; @@ -3751,7 +4477,11 @@ function stobeBuildRecentContextMessagesFromText(string $historyText, int $maxMe continue; } if (preg_match('/^\[\s*infoaction\b/i', $clean) === 1) { - continue; + $roleplayLine = stobeExtractRoleplayActionContextLine($clean); + if ($roleplayLine === '') { + continue; + } + $clean = $roleplayLine; } if (preg_match('/^\[\s*inputtext(?:_s)?\b/i', $clean) === 1) { continue; @@ -4720,7 +5450,7 @@ function queryWorldKnowledgeForNpc( $db = $GLOBALS["db"]; $vectorExpr = "COALESCE(native_vector, to_tsvector('english', COALESCE(topic, '') || ' ' || COALESCE(topic_desc, '') || ' ' || COALESCE(topic_desc_basic, '')))"; - $aliasExpr = "to_tsvector('simple', regexp_replace(replace(lower(COALESCE(topic, '')), '_', ' '), ',', ' ', 'g'))"; + $aliasExpr = "to_tsvector('simple', regexp_replace(replace(lower(COALESCE(topic, '') || ' ' || COALESCE(aliases, '')), '_', ' '), ',', ' ', 'g'))"; $scoreParts = ['0']; $whereParts = []; $params = []; @@ -4743,18 +5473,94 @@ function queryWorldKnowledgeForNpc( $notes[] = $label; }; - $addSignal($primaryTopic, 10.0, 'topic'); - $addSignal($currentTopicBefore, 5.0, 'continuity'); - $addSignal($locationContext, 2.0, 'location'); - $addSignal($contextKeywords, 1.0, 'context'); - $addSignal($message, 1.0, 'message'); + $addSignalVariants = static function (array $variants, float $baseWeight, string $label) use (&$addSignal): void { + $i = 0; + foreach ($variants as $variant) { + $weight = ($i === 0) ? $baseWeight : max(0.5, $baseWeight * 0.7); + $suffix = ($i === 0) ? '' : '_x' . strval($i); + $addSignal(strval($variant), $weight, $label . $suffix); + $i++; + } + }; + + $translateAllSignals = stobeWorldKnowledgeSignalTranslationAllowAllSignals(); + $topicVariantPack = stobeWorldKnowledgeBuildSignalVariantPack($primaryTopic, $npcData, true); + $continuityVariantPack = stobeWorldKnowledgeBuildSignalVariantPack($currentTopicBefore, $npcData, $translateAllSignals); + $locationVariantPack = stobeWorldKnowledgeBuildSignalVariantPack($locationContext, $npcData, $translateAllSignals); + $contextVariantPack = stobeWorldKnowledgeBuildSignalVariantPack($contextKeywords, $npcData, $translateAllSignals); + $messageVariantPack = stobeWorldKnowledgeBuildSignalVariantPack($message, $npcData, $translateAllSignals); + + $topicVariantEntries = is_array($topicVariantPack['entries'] ?? null) ? $topicVariantPack['entries'] : []; + $continuityVariantEntries = is_array($continuityVariantPack['entries'] ?? null) ? $continuityVariantPack['entries'] : []; + $locationVariantEntries = is_array($locationVariantPack['entries'] ?? null) ? $locationVariantPack['entries'] : []; + $contextVariantEntries = is_array($contextVariantPack['entries'] ?? null) ? $contextVariantPack['entries'] : []; + $messageVariantEntries = is_array($messageVariantPack['entries'] ?? null) ? $messageVariantPack['entries'] : []; + + $extractTexts = static function (array $entries): array { + $variants = []; + foreach ($entries as $entry) { + $text = trim(strval($entry['text'] ?? '')); + if ($text === '') { + continue; + } + $variants[] = $text; + } + return stobeWorldKnowledgeUniqueTerms($variants, 4); + }; - if (stobeWorldKnowledgeHasQueryableTerms($primaryTopic)) { - $params[] = $primaryTopic; + $topicVariants = $extractTexts($topicVariantEntries); + $continuityVariants = $extractTexts($continuityVariantEntries); + $locationVariants = $extractTexts($locationVariantEntries); + $contextVariants = $extractTexts($contextVariantEntries); + $messageVariants = $extractTexts($messageVariantEntries); + $signalDebugParts = []; + foreach ([ + 'topic' => $topicVariantEntries, + 'continuity' => $continuityVariantEntries, + 'location' => $locationVariantEntries, + 'context' => $contextVariantEntries, + 'message' => $messageVariantEntries, + ] as $signalLabel => $signalEntries) { + $trace = stobeWorldKnowledgeFormatVariantEntryTrace($signalLabel, $signalEntries); + if ($trace !== '') { + $signalDebugParts[] = $trace; + } + } + foreach ([ + 'topic' => ($topicVariantPack['llm'] ?? []), + 'continuity' => ($continuityVariantPack['llm'] ?? []), + 'location' => ($locationVariantPack['llm'] ?? []), + 'context' => ($contextVariantPack['llm'] ?? []), + 'message' => ($messageVariantPack['llm'] ?? []), + ] as $signalLabel => $llmDetail) { + $llmTrace = stobeWorldKnowledgeFormatLlmAttemptTrace($signalLabel, is_array($llmDetail) ? $llmDetail : []); + if ($llmTrace !== '') { + $signalDebugParts[] = $llmTrace; + } + } + + $addSignalVariants($topicVariants, 10.0, 'topic'); + $addSignalVariants($continuityVariants, 5.0, 'continuity'); + $addSignalVariants($locationVariants, 2.0, 'location'); + $addSignalVariants($contextVariants, 1.0, 'context'); + $addSignalVariants($messageVariants, 1.0, 'message'); + + $aliasCandidates = count($topicVariants) > 0 ? $topicVariants : [$primaryTopic]; + $aliasTrace = stobeWorldKnowledgeFormatVariantEntryTrace('alias', $topicVariantEntries); + if ($aliasTrace !== '') { + $signalDebugParts[] = $aliasTrace; + } + $signalDebug = implode(' | ', $signalDebugParts); + foreach ($aliasCandidates as $index => $aliasCandidate) { + if (!stobeWorldKnowledgeHasQueryableTerms(strval($aliasCandidate))) { + continue; + } + $params[] = strval($aliasCandidate); $aliasIdx = count($params); - $scoreParts[] = "(CASE WHEN {$aliasExpr} @@ websearch_to_tsquery('simple', $" . $aliasIdx . ") THEN 20 ELSE 0 END)"; + $bonus = ($index === 0) ? 20.0 : 12.0; + $scoreParts[] = "(CASE WHEN {$aliasExpr} @@ websearch_to_tsquery('simple', $" . $aliasIdx . ") THEN " . strval($bonus) . " ELSE 0 END)"; $whereParts[] = "{$aliasExpr} @@ websearch_to_tsquery('simple', $" . $aliasIdx . ")"; - $notes[] = 'alias'; + $notes[] = ($index === 0) ? 'alias' : ('alias_x' . strval($index)); } if (count($whereParts) === 0) { @@ -4773,6 +5579,7 @@ function queryWorldKnowledgeForNpc( 'current_topic_before' => $currentTopicBefore, 'current_topic_after' => $currentTopicBefore, 'knowledge_tags' => $knowledgeSummary, + 'signal_debug' => $signalDebug, 'notes' => 'No queryable world_knowledge signals', 'elapsed_seconds' => max(0.0, microtime(true) - $queryStartedAt), ]); @@ -4866,6 +5673,7 @@ function queryWorldKnowledgeForNpc( 'current_topic_before' => $currentTopicBefore, 'current_topic_after' => $currentTopicAfter, 'knowledge_tags' => $knowledgeSummary, + 'signal_debug' => $signalDebug, 'notes' => implode(',', $notes), 'elapsed_seconds' => max(0.0, microtime(true) - $queryStartedAt), ]); @@ -7947,6 +8755,672 @@ function stobeBuildGeneralInstructionsText(array|false $npcData = false): string return implode("\n", $defaults); } +function stobeBuildKenshiMechanicsAlignmentPromptBlock( + array $npcData, + array $metadata, + string $eventType, + string $playerCats, + bool $inPlayerFaction +): string { + $event = strtolower(trim($eventType)); + if ($event === '') { + $event = 'chat'; + } + + $health = trim(strval($npcData['health'] ?? ($metadata['health'] ?? ''))); + if ($health === '') { + $health = 'Unknown'; + } + $blood = stobeDescribeBloodStatus($npcData['blood'] ?? ''); + if ($blood === '') { + $blood = 'Unknown'; + } + $hunger = stobeDescribeHungerStatus($npcData['hunger'] ?? ''); + if ($hunger === '') { + $hunger = 'Unknown'; + } + + $defaultTemplate = "\n" + . " Apply these grounding rules before generating any response.\n" + . " #EVENT_TYPE#\n" + . " \n" + . " #IN_PLAYER_FACTION#\n" + . " #NPC_HEALTH#\n" + . " #NPC_BLOOD#\n" + . " #NPC_HUNGER#\n" + . " #PLAYER_CATS#\n" + . " \n" + . " \n" + . " Treat Kenshi constraints as hard reality. Do not imply modern conveniences, safe institutions, or guaranteed mercy.\n" + . " Ground tone and priorities in survival pressure: injuries, blood loss, hunger, danger, faction tension, and nearby threats.\n" + . " Do not invent items, money, authority, travel options, or social status not supported by context.\n" + . " If suggesting actions, keep them physically and socially plausible for the speaker's current condition and affiliations.\n" + . " Prefer terse, practical speech under stress; only become reflective when context supports safety or downtime.\n" + . " \n" + . ""; + + $template = $defaultTemplate; + if (function_exists('stobeGetPromptTemplateValue')) { + $template = stobeGetPromptTemplateValue('kenshi_mechanics_alignment', $defaultTemplate); + } + + $replacements = [ + '#EVENT_TYPE#' => stobePromptXmlEscape($event), + '#IN_PLAYER_FACTION#' => $inPlayerFaction ? 'true' : 'false', + '#NPC_HEALTH#' => stobePromptXmlEscape($health), + '#NPC_BLOOD#' => stobePromptXmlEscape($blood), + '#NPC_HUNGER#' => stobePromptXmlEscape($hunger), + '#PLAYER_CATS#' => stobePromptXmlEscape($playerCats), + ]; + + return str_replace(array_keys($replacements), array_values($replacements), $template); +} + +/** + * PRMK v1 — Política Realismo Mecánicas Kenshi + * + * Builds a dynamic, state-aware XML block injected into every system prompt. + * Detects real NPC signals (hunger, wounds, slave status, current action) and + * generates hard constraints for the LLM: what must be considered, what to avoid, + * what actions are allowed, and what claims are forbidden in Kenshi's world. + * + * @param array $npcData NPC profile data array (hunger, blood, is_slave, etc.) + * @param array $metadata Additional metadata from extended_data or request context + * @param string $eventType 'chat' | 'rechat' | 'bored' + * @param string $playerCats Raw cats string (e.g. "320") for economic context + * @return string XML block to append to system prompt, or '' if no signals detected + */ +function stobeBuildPrmkContextBlock( + array $npcData, + array $metadata, + string $eventType, + string $playerCats = '' +): string { + $event = strtolower(trim($eventType)); + if ($event === '') { + $event = 'chat'; + } + + // --- Detect signals --- + + // Hunger + $hungerRaw = $npcData['hunger'] ?? ($metadata['hunger'] ?? ''); + $hungerRatio = stobeParseCurrentMaxRatio($hungerRaw); + $hungerPct = $hungerRatio['valid'] ? floatval($hungerRatio['pct']) : -1.0; + $hungerActive = $hungerPct >= 0.0 && $hungerPct < 45.0; + $hungerCritical = $hungerPct >= 0.0 && $hungerPct < 10.0; + + // Blood / wounds + $bloodRaw = $npcData['blood'] ?? ($metadata['blood'] ?? ''); + $bloodRatio = stobeParseCurrentMaxRatio($bloodRaw); + $bloodPct = $bloodRatio['valid'] ? floatval($bloodRatio['pct']) : -1.0; + $woundedActive = $bloodPct >= 0.0 && $bloodPct < 75.0; + $woundedCritical = $bloodPct >= 0.0 && $bloodPct < 25.0; + + // Slave status + $isSlave = boolval($npcData['is_slave'] ?? ($metadata['is_slave'] ?? false)); + + // Character state (combat, fleeing, mining, idle...) + $charState = strtolower(trim(strval($npcData['character_state'] ?? ($metadata['character_state'] ?? '')))); + if ($charState === '') { + // Try extended_data + $extended = is_array($npcData['extended_data'] ?? null) + ? $npcData['extended_data'] + : (is_string($npcData['extended_data'] ?? null) ? json_decode(strval($npcData['extended_data']), true) : []); + $charState = strtolower(trim(strval(($extended['character_state'] ?? '')))); + } + $inCombat = in_array($charState, ['combat', 'fleeing', 'attacking', 'flee'], true); + $isMining = $charState === 'mining'; + $isWorking = in_array($charState, ['mining', 'farming', 'building', 'crafting', 'working'], true); + + // Economic signal — low cats + $catsInt = intval(preg_replace('/[^0-9]/', '', $playerCats)); + $lowCats = $playerCats !== '' && $catsInt < 500; + + // If no signals detected, return empty (no block injected) + $hasAnySignal = $hungerActive || $woundedActive || $isSlave || $inCombat || $isWorking || $lowCats; + if (!$hasAnySignal) { + return ''; + } + + // --- Build signal list --- + $signalLines = []; + if ($hungerCritical) { + $signalLines[] = ' '; + } elseif ($hungerActive) { + $pctLabel = $hungerPct < 25.0 ? 'very_hungry' : 'hungry'; + $urg = $hungerPct < 25.0 ? '75' : '55'; + $signalLines[] = ' '; + } + if ($woundedCritical) { + $signalLines[] = ' '; + } elseif ($woundedActive) { + $pctLabel = $bloodPct < 50.0 ? 'badly_wounded' : 'lightly_wounded'; + $urg = $bloodPct < 50.0 ? '65' : '40'; + $signalLines[] = ' '; + } + if ($isSlave) { + $signalLines[] = ' '; + } + if ($inCombat) { + $signalLines[] = ' '; + } elseif ($isWorking) { + $signalLines[] = ' '; + } + if ($lowCats) { + $signalLines[] = ' '; + } + + // --- Build must_consider list --- + $mustLines = []; + if ($hungerCritical) { + $mustLines[] = ' This NPC is near starvation. Obtaining food is the immediate survival priority. Every statement and suggestion must reflect this urgency.'; + } elseif ($hungerActive) { + $mustLines[] = ' This NPC is hungry. Food access and obtaining it should dominate their concerns and color their speech.'; + } + if ($woundedCritical) { + $mustLines[] = ' This NPC is critically wounded. Survival — finding rest, basic wound care (rags, bandages), or retreating — is their top concern.'; + } elseif ($woundedActive) { + $mustLines[] = ' This NPC has significant wounds. Pain, fatigue, and limited capacity are real. Do not ignore this in their speech or behavior.'; + } + if ($isSlave) { + $mustLines[] = ' This NPC is a slave or has slave status. Captivity, risk of recapture, and social vulnerability are constant realities. Discretion, obedience under threat, or quiet resistance are natural.'; + } + if ($inCombat) { + $mustLines[] = ' This NPC is in combat or fleeing. Their speech must reflect immediate danger, adrenaline, and survival focus. No casual conversation is appropriate.'; + } + if ($isWorking && !$inCombat) { + $mustLines[] = ' This NPC is currently ' . stobePromptXmlEscape($charState) . '. Dialogue should acknowledge this activity (e.g., "while I work on this..." or interrupted focus).'; + } + if ($lowCats) { + $mustLines[] = ' Resources are scarce (low cats). Practical economic options — trading, taking a job, looting, earning bounties — are priorities over luxury thinking.'; + } + + // --- Build avoid list --- + $avoidLines = []; + if ($hungerActive) { + $avoidLines[] = ' Treating hunger as a minor inconvenience or changing subjects casually while the NPC has not addressed the need for food.'; + } + if ($woundedActive) { + $avoidLines[] = ' Ignoring wounds or speaking as if at full capacity. Do not suggest strenuous activity without acknowledging the cost.'; + } + if ($inCombat) { + $avoidLines[] = ' Initiating or continuing casual, reflective, or social conversation during combat or flight.'; + } + $avoidLines[] = ' Suggesting resources, authorities, or solutions that do not exist in Kenshi (hospitals, police, weather shelter, modern tools, guaranteed safety).'; + + // --- Forbidden claims (always present, Kenshi world-grounding) --- + $forbiddenLines = [ + ' There are hospitals, medical clinics, or organized healing institutions', + ' Cold or heat weather affects survival (Kenshi has no temperature mechanics)', + ' Police, guards, or legal authority will provide protection or justice', + ' Automated machines or conveniences exist to ease labor', + ' The world is safe or neutral by default for someone without power or faction protection', + ]; + + // --- Allowed actions (Kenshi-valid verbs) --- + $allowedActions = 'kill, attack, steal, trade, loot, hunt, work, flee, hide, craft, mine, buy, sell, recruit, bribe, intimidate, explore, heal_with_bandages, rest, scavenge, escort, betray'; + + // --- Compute overall urgency --- + $urgency = 0; + if ($hungerCritical) { + $urgency = max($urgency, 95); + } elseif ($hungerActive) { + $urgency = max($urgency, $hungerPct < 25.0 ? 75 : 55); + } + if ($woundedCritical) { + $urgency = max($urgency, 85); + } elseif ($woundedActive) { + $urgency = max($urgency, $bloodPct < 50.0 ? 65 : 40); + } + if ($isSlave) { + $urgency = max($urgency, 70); + } + if ($inCombat) { + $urgency = max($urgency, 95); + } + if ($lowCats) { + $urgency = max($urgency, 40); + } + + // --- Assemble block --- + $block = "\n"; + $block .= " Apply these grounding constraints before generating any response. They reflect the NPC's real current state.\n"; + $block .= " " . stobePromptXmlEscape($event) . "\n"; + $block .= " \n" . implode("\n", $signalLines) . "\n \n"; + $block .= " \n"; + foreach ($mustLines as $line) { + $block .= $line . "\n"; + } + foreach ($avoidLines as $line) { + $block .= $line . "\n"; + } + $block .= " \n" . implode("\n", $forbiddenLines) . "\n \n"; + $block .= " " . $allowedActions . "\n"; + $block .= " \n"; + $block .= " " . $urgency . "\n"; + $block .= ""; + + return $block; +} + +/** + * MIC v1 — Motor Intención Conversacional + * + * Server-side decision engine for bored and rechat events. + * Given a speaker NPC and a list of potential listener candidates, decides: + * - should_speak: whether the NPC should initiate/continue dialogue + * - target_candidates: ranked list with scores and reasons + * - intent_topic: the dominant topic the NPC has reason to raise + * - urgency: 0-100 scale of how urgent this communication is + * - delivery_mode: talk | whisper | shout + * - reason_trace: human-readable justification string + * + * @param array $speakerData Full NPC data for the potential speaker + * @param array $listenerPool Array of candidate listener arrays, each with at least ['name' => ...] + * May include player entry (player data may be minimal) + * @param string $playerName Normalized player name (for scoring player target priority) + * @param string $eventType 'bored' | 'rechat' + * @return array MIC decision struct + */ +function stobeBuildMicDecision( + array $speakerData, + array $listenerPool, + string $playerName = '', + string $eventType = 'bored' +): array { + $result = [ + 'should_speak' => true, // default: allow (conservative; gates below can suppress) + 'target_candidates' => [], + 'intent_topic' => 'general', + 'urgency' => 20, + 'delivery_mode' => 'talk', + 'reason_trace' => '', + ]; + + // --- Extract speaker signals --- + $speakerName = normalizeParticipantNameToken(strval($speakerData['name'] ?? '')); + + $hungerRaw = $speakerData['hunger'] ?? ($speakerData['metadata']['hunger'] ?? ''); + $hungerRatio = stobeParseCurrentMaxRatio($hungerRaw); + $hungerPct = $hungerRatio['valid'] ? floatval($hungerRatio['pct']) : 100.0; + $hungerActive = $hungerPct < 45.0; + $hungerCritical = $hungerPct < 10.0; + + $bloodRaw = $speakerData['blood'] ?? ($speakerData['metadata']['blood'] ?? ''); + $bloodRatio = stobeParseCurrentMaxRatio($bloodRaw); + $bloodPct = $bloodRatio['valid'] ? floatval($bloodRatio['pct']) : 100.0; + $woundedActive = $bloodPct < 75.0; + $woundedCritical = $bloodPct < 25.0; + + $isSlave = boolval($speakerData['is_slave'] ?? false); + + $charState = strtolower(trim(strval($speakerData['character_state'] ?? ''))); + if ($charState === '') { + $extended = is_array($speakerData['extended_data'] ?? null) + ? $speakerData['extended_data'] + : (is_string($speakerData['extended_data'] ?? null) ? json_decode(strval($speakerData['extended_data']), true) : []); + $charState = strtolower(trim(strval(($extended['character_state'] ?? '')))); + } + $inCombat = in_array($charState, ['combat', 'fleeing', 'attacking', 'flee'], true); + + $speakerFactionId = strtolower(trim(strval($speakerData['faction'] ?? ''))); + $speakerInPlayerFaction = npcIsInPlayerFaction($speakerData); + + // --- Determine intent topic and urgency --- + $urgency = 20; + $topic = 'general'; + $reasons = []; + $deliveryMode = 'talk'; + + if ($inCombat) { + $topic = 'combat'; + $urgency = 95; + $deliveryMode = 'shout'; + $reasons[] = 'speaker_in_combat'; + } elseif ($hungerCritical) { + $topic = 'food_critical'; + $urgency = 90; + $reasons[] = 'hunger_critical'; + } elseif ($hungerActive) { + $topic = 'food_needed'; + $urgency = max($urgency, $hungerPct < 25.0 ? 70 : 50); + $reasons[] = 'hunger_active'; + } + + if ($woundedCritical) { + if ($urgency < 80) { + $topic = 'injury'; + $urgency = 80; + } + $reasons[] = 'critically_wounded'; + if ($deliveryMode !== 'shout') { + $deliveryMode = 'talk'; // injured but not shouting unless combat + } + } elseif ($woundedActive) { + if ($urgency < 40) { + $topic = 'injury'; + $urgency = 40; + } + $reasons[] = 'wounded'; + } + + if ($isSlave) { + if ($urgency < 65) { + $topic = 'safety'; + $urgency = 65; + } + $deliveryMode = 'whisper'; // slaves speak quietly about sensitive matters + $reasons[] = 'slave_status'; + } + + // Default urgency when no signals: low base + if (empty($reasons)) { + $reasons[] = 'idle_social'; + $urgency = 20; + $topic = 'general'; + } + + $result['intent_topic'] = $topic; + $result['urgency'] = $urgency; + $result['delivery_mode'] = $deliveryMode; + + // --- Score listener candidates --- + $scored = []; + foreach ($listenerPool as $candidate) { + $candName = normalizeParticipantNameToken(strval($candidate['name'] ?? '')); + if ($candName === '' || strcasecmp($candName, $speakerName) === 0) { + continue; + } + + $score = 0.5; // base + $candReasons = []; + + $isPlayer = ($playerName !== '' && strcasecmp($candName, $playerName) === 0); + + if ($isPlayer) { + // Player target: neutral base, boosted by urgency + $candReasons[] = 'player'; + if ($urgency >= 80) { + $score += 0.25; // high urgency → player is a valid target + $candReasons[] = 'high_urgency_override'; + } elseif ($urgency >= 50) { + $score += 0.10; + } + if ($speakerInPlayerFaction) { + $score += 0.10; + $candReasons[] = 'same_faction_as_player'; + } + } else { + // NPC candidate + $candData = $candidate; // listener pool entries may be full npcData arrays + $candFactionId = strtolower(trim(strval($candData['faction'] ?? ''))); + $candInPlayerFaction = npcIsInPlayerFaction($candData); + + if ($speakerFactionId !== '' && $candFactionId !== '' && $speakerFactionId === $candFactionId) { + $score += 0.25; + $candReasons[] = 'same_faction'; + } + if ($candInPlayerFaction && $speakerInPlayerFaction) { + $score += 0.10; + $candReasons[] = 'both_in_player_faction'; + } + + // If topic is food: prefer candidate with trader role or likely has food + if (in_array($topic, ['food_needed', 'food_critical'], true)) { + $candOccupation = strtolower(strval($candData['occupation'] ?? '')); + if (strpos($candOccupation, 'trader') !== false || strpos($candOccupation, 'cook') !== false) { + $score += 0.20; + $candReasons[] = 'likely_has_food'; + } + } + + // Slave topic: prefer faction allies strongly + if ($topic === 'safety' && $isSlave) { + if ($speakerFactionId !== '' && $candFactionId === $speakerFactionId) { + $score += 0.20; + $candReasons[] = 'faction_ally_safety'; + } + } + } + + $scored[] = [ + 'name' => $candName, + 'score' => round(min(1.0, $score), 3), + 'reasons' => $candReasons, + 'is_player' => $isPlayer, + ]; + } + + // Sort descending by score + usort($scored, static function (array $a, array $b): int { + return $b['score'] <=> $a['score']; + }); + + $result['target_candidates'] = $scored; + $result['reason_trace'] = implode(', ', $reasons); + + return $result; +} + +/** + * Build an XML prompt block from a MIC decision result. + * Injected into the system prompt to inform the LLM of the NPC's communicative intent. + * + * @param array $mic Result from stobeBuildMicDecision() + * @return string XML block, or '' if should_speak is false + */ +function stobeMicBuildPromptBlock(array $mic): string { + if (!boolval($mic['should_speak'] ?? true)) { + return ''; + } + + $topic = stobePromptXmlEscape(strval($mic['intent_topic'] ?? 'general')); + $urgency = intval($mic['urgency'] ?? 20); + $delivery = stobePromptXmlEscape(strval($mic['delivery_mode'] ?? 'talk')); + $trace = stobePromptXmlEscape(strval($mic['reason_trace'] ?? '')); + + $topTarget = ''; + $candidates = $mic['target_candidates'] ?? []; + if (count($candidates) > 0) { + $topTarget = stobePromptXmlEscape(strval($candidates[0]['name'] ?? '')); + } + + $block = "\n"; + $block .= " " . $topic . "\n"; + $block .= " " . $urgency . "\n"; + $block .= " " . $delivery . "\n"; + if ($topTarget !== '') { + $block .= " " . $topTarget . "\n"; + } + if ($trace !== '') { + $block .= " " . $trace . "\n"; + } + $block .= " Let this intention shape what the NPC says and how — but do not reveal this metadata in dialogue.\n"; + $block .= ""; + + return $block; +} + +// --------------------------------------------------------------------------- +// Conversation Floor System +// +// Implements urgency-based serialization of spontaneous NPC speech (bored/rechat). +// Only one NPC can "hold the floor" per scene at a time. When multiple NPCs +// want to speak simultaneously, the one with higher MIC urgency wins. +// NPCs with lower urgency that arrive while the floor is occupied are silenced +// for that cycle (the plugin will retry on the next timer tick naturally). +// +// The floor also functions as a context-freshness gate: a minimum hold time +// (FLOOR_MIN_HOLD_SECONDS) ensures the previous speaker's response has been +// persisted to the event log before the next NPC builds their context. +// --------------------------------------------------------------------------- + +/** + * Compute a deterministic scene key from a list of participant names. + * The key identifies the "conversation group" regardless of order. + * Names are normalized, sorted, then SHA1-hashed. + * + * @param array $participantNames Raw participant name strings + * @return string 40-char hex SHA1 scene key, or 'global' if list is empty + */ +function stobeComputeSceneKey(array $participantNames): string { + $normalized = []; + foreach ($participantNames as $name) { + $n = strtolower(trim(strval($name))); + if ($n !== '') { + $normalized[] = $n; + } + } + if (count($normalized) === 0) { + return 'global'; + } + sort($normalized); + return sha1(implode('|', $normalized)); +} + +/** + * Read the current floor state for a scene. + * Returns the DB row as an array, or false if no floor is active (or expired). + * + * @param string $sceneKey Result of stobeComputeSceneKey() + * @return array|false Floor row array with keys: speaker, urgency, topic, acquired_epoch, expires_epoch + */ +function stobeGetConversationFloor(string $sceneKey): array|false { + $db = $GLOBALS['db'] ?? null; + if (!$db || $sceneKey === '') { + return false; + } + $now = time(); + $row = $db->fetchOne( + "SELECT speaker, urgency, topic, acquired_epoch, expires_epoch + FROM conversation_floor + WHERE scene_key = $1 + AND expires_epoch > $2", + [$sceneKey, $now] + ); + return is_array($row) && count($row) > 0 ? $row : false; +} + +/** + * Try to acquire the conversation floor for a speaker. + * + * Acquisition rules: + * - If no floor is active (or floor has expired) → always granted. + * - If a floor is active AND the incoming urgency is strictly greater than the + * current holder's urgency → granted (higher urgency preempts). + * - Otherwise → denied (current holder keeps the floor). + * + * Floor TTL (time-to-live) is: + * max(FLOOR_MIN_HOLD_SECONDS, FLOOR_BASE_TTL_SECONDS) + * where FLOOR_MIN_HOLD_SECONDS (default 8) ensures context freshness before + * the next NPC can speak, and FLOOR_BASE_TTL_SECONDS (default 30) is a safety + * expiry in case the floor is never released. + * + * @param string $sceneKey Scene identifier + * @param string $speakerName NPC acquiring the floor + * @param int $urgency MIC urgency (0-100); higher urgency can preempt + * @param string $topic MIC intent_topic for logging + * @return bool True if floor was granted, false if denied + */ +function stobeAcquireConversationFloor( + string $sceneKey, + string $speakerName, + int $urgency, + string $topic = 'general' +): bool { + $db = $GLOBALS['db'] ?? null; + if (!$db || $sceneKey === '' || $speakerName === '') { + return true; // graceful degradation: no DB → allow speech + } + + // Configurable via general_settings. + // FLOOR_MIN_HOLD_SECONDS: context-freshness window between speeches. + // FLOOR_BASE_TTL_SECONDS: safety expiry if floor is never released. + $minHoldSeconds = max(0, min(120, getSettingInt('FLOOR_MIN_HOLD_SECONDS', 8))); + $baseTtlSeconds = max(1, min(300, getSettingInt('FLOOR_BASE_TTL_SECONDS', 30))); + + $now = time(); + $ttl = max($minHoldSeconds, $baseTtlSeconds); + $expires = $now + $ttl; + + $current = stobeGetConversationFloor($sceneKey); + + if ($current === false) { + // Floor is free — acquire it + $db->exec( + "INSERT INTO conversation_floor + (scene_key, speaker, urgency, topic, acquired_epoch, expires_epoch) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (scene_key) DO UPDATE + SET speaker = EXCLUDED.speaker, + urgency = EXCLUDED.urgency, + topic = EXCLUDED.topic, + acquired_epoch = EXCLUDED.acquired_epoch, + expires_epoch = EXCLUDED.expires_epoch", + [$sceneKey, $speakerName, $urgency, $topic, $now, $expires] + ); + return true; + } + + $currentUrgency = intval($current['urgency'] ?? 0); + + // Preemption: strictly greater urgency wins + if ($urgency > $currentUrgency) { + $db->exec( + "UPDATE conversation_floor + SET speaker = $1, urgency = $2, topic = $3, + acquired_epoch = $4, expires_epoch = $5 + WHERE scene_key = $6", + [$speakerName, $urgency, $topic, $now, $expires, $sceneKey] + ); + return true; + } + + // Denied: current holder has equal or higher urgency and floor is still valid + return false; +} + +/** + * Release the conversation floor after the speaker has finished. + * Should be called after the LLM response is generated and stored. + * Leaves the row in DB with expires_epoch in the past so the freshness + * window (FLOOR_MIN_HOLD_SECONDS) still blocks immediate re-acquisition. + * + * @param string $sceneKey Scene identifier + * @param string $speakerName Name of the NPC that should currently hold the floor + * (safety check: only release if we own it) + * @param int $minHoldSeconds How long to keep the floor "cooling down" after release + */ +function stobeReleaseConversationFloor( + string $sceneKey, + string $speakerName, + int $minHoldSeconds = 8 +): void { + $db = $GLOBALS['db'] ?? null; + if (!$db || $sceneKey === '') { + return; + } + + if ($minHoldSeconds <= 0) { + $minHoldSeconds = max(0, min(120, getSettingInt('FLOOR_MIN_HOLD_SECONDS', 8))); + } + + // Set expires_epoch to now + minHold so context-freshness window is preserved + // but the floor can be re-acquired after the hold period. + // urgency is reset to 0 so any NPC can re-acquire after the hold. + $cooldownExpiry = time() + $minHoldSeconds; + $db->exec( + "UPDATE conversation_floor + SET urgency = 0, + topic = 'released', + expires_epoch = $1 + WHERE scene_key = $2 + AND LOWER(speaker) = LOWER($3)", + [$cooldownExpiry, $sceneKey, $speakerName] + ); +} + function stobeResolveNpcPromptOverrides(array $npcData, array $metadata = []): array { $profileId = intval($npcData['profile_id'] ?? 0); if ($profileId <= 0 && function_exists('getDefaultNpcProfileId')) { @@ -9983,6 +11457,17 @@ function buildSystemPrompt( $prompt .= "\n"; } + $mechanicsAlignmentBlock = stobeBuildKenshiMechanicsAlignmentPromptBlock( + $npcData, + $metadata, + $eventType, + $playerCats, + $inPlayerFaction + ); + if (trim($mechanicsAlignmentBlock) !== '') { + $prompt .= "\n\n" . trim($mechanicsAlignmentBlock); + } + if ($includeActionGuidance) { $prompt = appendActionGuidanceToPrompt($prompt, $eventType, $npcData); } diff --git a/processor/bored.php b/processor/bored.php index 1f28626..ebe205b 100644 --- a/processor/bored.php +++ b/processor/bored.php @@ -12,6 +12,10 @@ $playerName = normalizeParticipantNameToken(getSetting('PLAYER_NAME', 'Drifter')); $incomingProfile = normalizeParticipantNameToken(trim(strval($_GET['profile'] ?? ''))); $peopleRaw = strval($GLOBALS["CACHE_PEOPLE"] ?? ($_GET['people'] ?? '')); +$participantIdentities = extractParticipantIdentities([ + 'people' => $peopleRaw, + 'profile' => $incomingProfile, +]); $participants = extractParticipantNames([ 'people' => $peopleRaw, @@ -20,14 +24,11 @@ $candidateNames = []; $seen = []; -$pushCandidate = static function (string $candidate) use (&$candidateNames, &$seen, $playerName): void { +$pushCandidate = static function (string $candidate) use (&$candidateNames, &$seen): void { $name = normalizeParticipantNameToken($candidate); if ($name === '') { return; } - if ($playerName !== '' && strcasecmp($name, $playerName) === 0) { - return; - } $key = strtolower($name); if (isset($seen[$key])) { return; @@ -59,6 +60,10 @@ $speakerNpc = ''; $speakerData = false; foreach ($candidateNames as $candidateName) { + if ($playerName !== '' && strcasecmp($candidateName, $playerName) === 0) { + // Player may be a valid listener target, but never auto-bootstrap as NPC speaker. + continue; + } $candidateData = getNpcData($candidateName); if (!$candidateData) { storeNpcProfile($candidateName, []); @@ -83,7 +88,7 @@ return; } -$boredChance = getNpcProfileIntegerSetting( +$baseBoredChance = getNpcProfileIntegerSetting( is_array($speakerData) ? $speakerData : [], ['BORED_EVENT_CHANCE', 'BORED_EVENT'], 'BORED_EVENT_CHANCE', @@ -91,45 +96,120 @@ 0, 100 ); -$roll = mt_rand(0, 99); -if (!$forceDirectorMode && $roll >= $boredChance) { - stobeLogInfo('Bored event skipped: chance gate', [ - 'speaker' => $speakerNpc, - 'roll' => $roll, - 'chance' => $boredChance, - ]); - echo "ok"; - return; -} +$roll = 0; +$effectiveBoredChance = $baseBoredChance; $listener = ''; $dialogueData = parseDialogueEventData($eventData); $suggestedTarget = normalizeParticipantNameToken(strval($dialogueData['target'] ?? '')); if ($suggestedTarget !== '' && - strcasecmp($suggestedTarget, $speakerNpc) !== 0 && - ($forceDirectorMode || $playerName === '' || strcasecmp($suggestedTarget, $playerName) !== 0)) { + strcasecmp($suggestedTarget, $speakerNpc) !== 0) { $listener = $suggestedTarget; } +$micDecision = null; if ($listener === '') { - $listeners = []; + // Build listener pool with NPC data for MIC scoring + $listenerPool = []; foreach ($candidateNames as $candidateName) { if (strcasecmp($candidateName, $speakerNpc) === 0) { continue; } - $listeners[] = $candidateName; + $candData = getNpcData($candidateName); + if ($candData) { + $listenerPool[] = $candData; + } else { + $listenerPool[] = ['name' => $candidateName]; + } } - if (count($listeners) > 0) { - $listener = $listeners[array_rand($listeners)]; + if (count($listenerPool) > 0) { + $micDecision = stobeBuildMicDecision( + is_array($speakerData) ? $speakerData : [], + $listenerPool, + $playerName, + 'bored' + ); + // Use top MIC candidate if scoring is available, otherwise random fallback + if (!empty($micDecision['target_candidates'])) { + $listener = strval($micDecision['target_candidates'][0]['name'] ?? ''); + } + if ($listener === '') { + $allNames = array_column($listenerPool, 'name'); + $listener = $allNames[array_rand($allNames)] ?? ''; + } } } if ($listener === '') { - stobeLogInfo('Bored event skipped: no eligible NPC listener', [ + stobeLogInfo('Bored event skipped: no eligible listener', [ 'speaker' => $speakerNpc, 'candidate_count' => count($candidateNames), ]); echo "ok"; return; } +$listenerStorageId = ''; +foreach ($participantIdentities as $identity) { + $identityName = normalizeParticipantNameToken(strval($identity['name'] ?? '')); + if ($identityName === '' || strcasecmp($identityName, $listener) !== 0) { + continue; + } + $listenerStorageId = normalizeStorageIdToken(strval($identity['storage_id'] ?? '')); + if ($listenerStorageId !== '') { + break; + } +} + +// --- Configurable Bored Frequency Gate --- +// Frequency is now tunable via general_settings: +// - BORED_DECISION_FREQUENCY_MULTIPLIER (float, default 1.5) +// - BORED_DECISION_URGENCY_BONUS_MAX (int 0-100, default 25) +// Effective chance = base profile chance * multiplier + urgency bonus. +$micUrgency = intval($micDecision['urgency'] ?? 20); +$freqMultiplierRaw = getSettingFloat('BORED_DECISION_FREQUENCY_MULTIPLIER', 1.5); +$freqMultiplier = max(0.1, min(5.0, $freqMultiplierRaw)); +$urgencyBonusMaxRaw = getSettingInt('BORED_DECISION_URGENCY_BONUS_MAX', 25); +$urgencyBonusMax = max(0, min(100, $urgencyBonusMaxRaw)); +$urgencyBonus = intval(round(($micUrgency / 100.0) * $urgencyBonusMax)); +$effectiveBoredChance = intval(round(($baseBoredChance * $freqMultiplier) + $urgencyBonus)); +$effectiveBoredChance = max(0, min(100, $effectiveBoredChance)); + +$roll = mt_rand(0, 99); +if (!$forceDirectorMode && $roll >= $effectiveBoredChance) { + stobeLogInfo('Bored event skipped: chance gate', [ + 'speaker' => $speakerNpc, + 'roll' => $roll, + 'base_chance' => $baseBoredChance, + 'effective_chance' => $effectiveBoredChance, + 'frequency_multiplier' => $freqMultiplier, + 'mic_urgency' => $micUrgency, + 'urgency_bonus' => $urgencyBonus, + ]); + echo "ok"; + return; +} + +// --- Conversation Floor Gate --- +// Prevents multiple NPCs from speaking simultaneously in the same scene. +// Urgency (from MIC) determines who wins when the floor is occupied. +// A minimum hold time after each speech also ensures the previous speaker's +// response is written to the event log before the next NPC builds context. +$sceneKey = stobeComputeSceneKey($candidateNames); +$micTopic = strval($micDecision['intent_topic'] ?? 'general'); +$floorGranted = $forceDirectorMode + ? true // director mode bypasses floor entirely + : stobeAcquireConversationFloor($sceneKey, $speakerNpc, $micUrgency, $micTopic); +if (!$floorGranted) { + $currentFloor = stobeGetConversationFloor($sceneKey); + stobeLogInfo('Bored event skipped: floor occupied by higher/equal urgency speaker', [ + 'speaker' => $speakerNpc, + 'speaker_urgency' => $micUrgency, + 'floor_holder' => strval($currentFloor['speaker'] ?? '?'), + 'floor_urgency' => intval($currentFloor['urgency'] ?? 0), + 'floor_topic' => strval($currentFloor['topic'] ?? '?'), + 'scene_key' => $sceneKey, + ]); + echo "ok"; + return; +} $cuePool = [ 'comment on the current location', @@ -151,6 +231,9 @@ 10, 120 ); +// Context freshness: re-fetch event history right before building the prompt. +// This ensures that if another NPC just spoke (and released the floor), their +// line is visible in the context before this NPC generates their own speech. $eventHistory = DataEventLog($contextHistory, $speakerNpc, $campaign); $eventHistory = stobeFilterNarratorRowsForContext($eventHistory, $speakerNpc, 'bored'); $historyLines = []; @@ -180,6 +263,21 @@ if ($nearbyPartyPrompt !== '') { $systemPrompt .= "\n\n" . $nearbyPartyPrompt; } +$prmkBlock = stobeBuildPrmkContextBlock( + is_array($speakerData) ? $speakerData : [], + is_array(($speakerData['metadata'] ?? null)) ? $speakerData['metadata'] : [], + 'bored', + strval(max(0, intval(floatval(trim(getConfOpt('PLAYER_CATS', '0')))))) +); +if ($prmkBlock !== '') { + $systemPrompt .= "\n\n" . $prmkBlock; +} +if ($micDecision !== null) { + $micBlock = stobeMicBuildPromptBlock($micDecision); + if ($micBlock !== '') { + $systemPrompt .= "\n\n" . $micBlock; + } +} $messages = [ [ 'role' => 'system', @@ -250,6 +348,9 @@ $actionsStreamedInLlm = boolval($streamResult['actions_streamed'] ?? false); } else { stobeLogWarn('Bored event LLM stream failed', ['speaker' => $speakerNpc]); + if (!$forceDirectorMode) { + stobeReleaseConversationFloor($sceneKey, $speakerNpc); + } echo "ok"; return; } @@ -267,6 +368,9 @@ ); if ($responseText === '' && count($responseActions) === 0) { + if (!$forceDirectorMode) { + stobeReleaseConversationFloor($sceneKey, $speakerNpc); + } echo "ok"; return; } @@ -280,12 +384,33 @@ storeActionEvents($speakerNpc, $responseActions, $gamets, $listener, 'bored'); storeEvent('chat', time(), $gamets, $chatEventData); +$listenerMetaPayload = $speakerNpc . '|ListenerMeta|' . $listener; +if ($listenerStorageId !== '') { + $listenerMetaPayload .= '|sid=' . $listenerStorageId; +} +$listenerMetaPayload .= "\r\n"; +echo $listenerMetaPayload; +stobeLogOutputToPlugin($speakerNpc, 'ListenerMeta', $listener, trim($listenerMetaPayload)); +if (ob_get_length()) { + ob_flush(); +} +flush(); + +// Release the conversation floor now that the response has been stored. +// The floor will remain in a cooldown state for FLOOR_MIN_HOLD_SECONDS (8 s) +// so the next NPC to attempt speech will see a fresh event log. +if (!$forceDirectorMode) { + stobeReleaseConversationFloor($sceneKey, $speakerNpc); +} + stobeLogInfo('Bored event response generated', [ 'speaker' => $speakerNpc, 'listener' => $listener, 'force_director_mode' => $forceDirectorMode, 'roll' => $roll, - 'chance' => $boredChance, + 'base_chance' => $baseBoredChance, + 'effective_chance' => $effectiveBoredChance, + 'mic_urgency' => $micUrgency, 'response_length' => strlen($responseText), 'structured_json' => $structuredJson, 'actions_count' => count($responseActions), diff --git a/ui/controlpanel_hub.php b/ui/controlpanel_hub.php index 870bc57..485f99b 100644 --- a/ui/controlpanel_hub.php +++ b/ui/controlpanel_hub.php @@ -51,6 +51,7 @@ function buildTabTargetSrc(array $tab, string $webRoot): string ['id' => 'server_logs', 'label' => 'Server Logs', 'page' => '', 'url' => $distroDebuggerStobeEmbedUrl, 'status' => 'wired', 'embed' => false], ['id' => 'audio_image_cache', 'label' => 'Audio & Image Cache', 'page' => '', 'url' => ($webRoot !== '' ? $webRoot : '') . '/soundcache/', 'status' => 'wired', 'embed' => false], ['id' => 'request_logs', 'label' => 'Request Logs', 'page' => 'request_logs.php', 'status' => 'wired', 'embed' => true], + ['id' => 'world_knowledge_audit', 'label' => 'World Knowledge Audit', 'page' => 'world_knowledge_audit.php', 'status' => 'wired', 'embed' => true], ['id' => 'cost_breakdown', 'label' => 'Cost Breakdown', 'page' => 'audit.php', 'status' => 'wired', 'embed' => true], ['id' => 'response_queue', 'label' => 'Response Queue', 'page' => 'response_queue.php', 'status' => 'wired', 'embed' => true], ['id' => 'relationship_logs', 'label' => 'Relationship Logs', 'page' => 'relationship_logs.php', 'status' => 'wired', 'embed' => true], diff --git a/ui/settings.php b/ui/settings.php index 3ca92e0..b9ea977 100644 --- a/ui/settings.php +++ b/ui/settings.php @@ -37,6 +37,36 @@ 'value' => 'true', 'description' => 'When true, always inject world knowledge entries for detected speaker and nearby NPC races when matching topics exist.', ], + [ + 'id' => 'WORLD_KNOWLEDGE_SIGNAL_TRANSLATION_ENABLED', + 'value' => 'false', + 'description' => 'When true, normalize retrieval signals for World Knowledge using glossary rules and optional LLM translation before ranking.', + ], + [ + 'id' => 'WORLD_KNOWLEDGE_SIGNAL_TRANSLATION_ALL_SIGNALS', + 'value' => 'false', + 'description' => 'When true, also normalize context, location, continuity and full-message signals. When false, only the primary topic signal is translated.', + ], + [ + 'id' => 'WORLD_KNOWLEDGE_SIGNAL_TRANSLATOR_DRIVER', + 'value' => 'player2json', + 'description' => 'Preferred LLM connector type for signal normalization. Default is player2json.', + ], + [ + 'id' => 'WORLD_KNOWLEDGE_SIGNAL_TRANSLATOR_CONNECTOR_ID', + 'value' => '0', + 'description' => 'Optional exact core_llm_connector ID to force for signal normalization. Use 0 to auto-resolve by driver.', + ], + [ + 'id' => 'WORLD_KNOWLEDGE_SIGNAL_TRANSLATION_CACHE_MIN', + 'value' => '1440', + 'description' => 'How long, in minutes, translated retrieval signals stay cached in conf_opts.', + ], + [ + 'id' => 'WORLD_KNOWLEDGE_SIGNAL_GLOSSARY_JSON', + 'value' => '', + 'description' => 'JSON object mapping Spanish lore terms to canonical English Kenshi terms for retrieval. Example: {"Nación Santa":"Holy Nation"}', + ], ]; foreach ($requiredSettings as $requiredSetting) { @@ -146,7 +176,7 @@ function stobeSettingType(string $id, string $value): string if (strpos($idUpper, 'API_KEY') !== false || strpos($idUpper, 'SECRET') !== false || strpos($idUpper, 'TOKEN') !== false) { return 'password'; } - if (in_array($idUpper, ['PROMPT_HEAD', 'EMOTEMOODS', 'ROLEPLAY_INSTRUCTIONS', 'GENERAL_INSTRUCTIONS', 'ACTIONS_ALLOWLIST', 'PLAYER_FACTION_PROMPT'], true)) { + if (in_array($idUpper, ['PROMPT_HEAD', 'EMOTEMOODS', 'ROLEPLAY_INSTRUCTIONS', 'GENERAL_INSTRUCTIONS', 'ACTIONS_ALLOWLIST', 'PLAYER_FACTION_PROMPT', 'WORLD_KNOWLEDGE_SIGNAL_GLOSSARY_JSON'], true)) { return 'textarea'; } if (strlen($value) > 120 || strpos($value, "\n") !== false) { @@ -230,6 +260,18 @@ function stobeSettingWarningMessage(string $id): string return ''; } +function stobeTranslatorDriverOptions(): array +{ + return [ + 'player2json' => 'Player2 JSON', + 'koboldcppjson' => 'KoboldCpp JSON', + 'openaijson' => 'OpenAI JSON', + 'openrouterjson' => 'OpenRouter JSON', + 'google_openaijson' => 'Google OpenAI JSON', + 'groqjson' => 'Groq JSON', + ]; +} + $isEmbed = (isset($_GET['embed']) && strval($_GET['embed']) === '1'); $webRoot = stobeSettingsWebRoot(); $selfPage = $webRoot . '/ui/settings.php' . ($isEmbed ? '?embed=1' : ''); @@ -299,6 +341,12 @@ function stobeSettingWarningMessage(string $id): string 'MEMORY_ENABLED' => 0, 'WORLD_KNOWLEDGE_ENABLED' => 0, 'ALWAYS_INSERT_RACE' => 1, + 'WORLD_KNOWLEDGE_SIGNAL_TRANSLATION_ENABLED' => 90, + 'WORLD_KNOWLEDGE_SIGNAL_TRANSLATION_ALL_SIGNALS' => 91, + 'WORLD_KNOWLEDGE_SIGNAL_TRANSLATOR_DRIVER' => 92, + 'WORLD_KNOWLEDGE_SIGNAL_TRANSLATOR_CONNECTOR_ID' => 93, + 'WORLD_KNOWLEDGE_SIGNAL_TRANSLATION_CACHE_MIN' => 94, + 'WORLD_KNOWLEDGE_SIGNAL_GLOSSARY_JSON' => 95, 'PLAYTHROUGH_AUTOLOAD_ENABLED' => 0, 'PLAYTHROUGH_PRUNE_ON_ROLLBACK_ENABLED' => 1, ]; @@ -539,7 +587,14 @@ class="setting-row"
- + + + +