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"
= h($warning) ?>
-
+
+
+
+