diff --git a/build.gradle.kts b/build.gradle.kts index 4ffb10d..113ac38 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -357,7 +357,7 @@ tasks.register("deployToSSH") { remotePath ?: throw GradleException("REMOTE_PATH not set. Set it as environment variable or in local.properties") - commandLine("scp", localFile, "$user@$host:$path") + commandLine("scp", "-v", localFile, "$user@$host:$path") doLast { println("✅ Deployed plugin JAR to $user@$host:$path") diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..05fbe59 --- /dev/null +++ b/plan.md @@ -0,0 +1,69 @@ +# Conversation Theme System - Implementation Plan + +## Architecture + +### Core abstractions (new package: `conversation/theme/`) + +1. **`ConversationTheme`** - Abstract base class that defines a theme + - `name: String` - unique identifier (e.g., "chat", "violence") + - `displayName: String` - human-readable name + - `compatibleWith: Set` - theme names this can stack with + - `onActivate(conversation: Conversation)` - callback when theme becomes active + - `onDeactivate(conversation: Conversation)` - callback when theme is removed + - `onMessage(conversation: Conversation, message: ConversationMessage)` - called on each message + +2. **`ConversationThemeFactory`** - Interface for creating theme instances + - `fun create(): ConversationTheme` + +3. **`ConversationThemeRegistry`** - Singleton registry + - `register(name: String, factory: ConversationThemeFactory)` - register a theme type + - `unregister(name: String)` - remove a theme type + - `create(name: String): ConversationTheme` - instantiate from factory + - `getRegisteredThemes(): Set` - list available themes + +4. **`ConversationThemeManager`** - Manages active themes per conversation (separate manager, observes conversations) + - `activateTheme(conversation: Conversation, themeName: String): Boolean` - activate a theme (checks compatibility with existing active themes) + - `deactivateTheme(conversation: Conversation, themeName: String): Boolean` + - `getActiveThemes(conversation: Conversation): List` + - `hasTheme(conversation: Conversation, themeName: String): Boolean` + - `onConversationEnd(conversation: Conversation)` - cleanup + - `onMessage(conversation: Conversation, message: ConversationMessage)` - propagate to active themes + +5. **`ConversationThemeData`** - Data class held within `Conversation` + - `activeThemeNames: List` (read-only view of active theme names) + - Kept minimal - just state, no logic + +### Example Themes + +6. **`ChatTheme`** - Default theme, compatible with everything +7. **`ViolenceTheme`** - Activates on combat events, not compatible with "trade" + +### Integration Points + +- Add `themeData: ConversationThemeData` field to `Conversation` +- `ConversationThemeManager` initialized in `Story.onEnable()` and stored on plugin +- Theme manager listens to `ConversationStartEvent` to apply default "chat" theme +- Theme manager listens to conversation messages via `ConversationManager` + +### File list +``` +src/main/kotlin/com/canefe/story/conversation/theme/ +├── ConversationTheme.kt # Abstract base +├── ConversationThemeFactory.kt # Factory interface +├── ConversationThemeRegistry.kt # Singleton registry +├── ConversationThemeManager.kt # Per-conversation manager +├── ConversationThemeData.kt # Data class for Conversation +├── ChatTheme.kt # Example: default chat +└── ViolenceTheme.kt # Example: violence/combat + +Modified files: +- Conversation.kt # Add themeData field +- Story.kt # Initialize theme manager + register defaults +``` + +### Tests +``` +src/test/kotlin/com/canefe/story/conversation/theme/ +├── ConversationThemeRegistryTest.kt +├── ConversationThemeManagerTest.kt +``` diff --git a/src/main/kotlin/com/canefe/story/Story.kt b/src/main/kotlin/com/canefe/story/Story.kt index c577358..7feb568 100644 --- a/src/main/kotlin/com/canefe/story/Story.kt +++ b/src/main/kotlin/com/canefe/story/Story.kt @@ -12,6 +12,7 @@ import com.canefe.story.context.ContextExtractor import com.canefe.story.conversation.ConversationManager import com.canefe.story.conversation.ConversationMessage import com.canefe.story.conversation.radiant.RadiantConversationService +import com.canefe.story.conversation.theme.* import com.canefe.story.event.EventManager import com.canefe.story.information.WorldInformationManager import com.canefe.story.location.LocationManager @@ -143,6 +144,14 @@ open class Story : lateinit var voiceManager: VoiceManager + // Theme system + lateinit var themeRegistry: ConversationThemeRegistry + private set + lateinit var themeManager: ConversationThemeManager + private set + lateinit var themeAgent: com.canefe.story.conversation.theme.ConversationThemeAgent + private set + // NPC Name Aliasing System lateinit var npcNameManager: com.canefe.story.npc.name.NPCNameManager private set @@ -289,6 +298,15 @@ open class Story : npcNameManager = NPCNameManager(this) npcNameResolver = NPCNameResolver(this) + // Initialize theme system + themeRegistry = ConversationThemeRegistry() + themeRegistry.register(ChatTheme.NAME) { ChatTheme() } + themeRegistry.register(ViolenceTheme.NAME) { ViolenceTheme() } + themeManager = ConversationThemeManager(themeRegistry) + themeAgent = + com.canefe.story.conversation.theme + .ConversationThemeAgent(this, themeManager, themeRegistry) + eventManager = EventManager(this) eventManager.registerEvents() diff --git a/src/main/kotlin/com/canefe/story/command/conversation/ConvListCommand.kt b/src/main/kotlin/com/canefe/story/command/conversation/ConvListCommand.kt index 8f04872..66715f7 100644 --- a/src/main/kotlin/com/canefe/story/command/conversation/ConvListCommand.kt +++ b/src/main/kotlin/com/canefe/story/command/conversation/ConvListCommand.kt @@ -77,10 +77,19 @@ class ConvListCommand( npcNames: List, playerNames: List, ): List { - // Build the prefix with conversation ID + // Build the prefix with conversation ID and active themes val miniMessage = commandUtils.mm val componentList = mutableListOf() - val prefix = miniMessage.deserialize("=====[$id]=====") + + val conversation = commandUtils.conversationManager.getConversationById(id) + val activeThemes = conversation?.themeData?.activeThemeNames ?: emptyList() + val themeSuffix = + if (activeThemes.isNotEmpty()) { + " [${activeThemes.joinToString(", ")}]" + } else { + "" + } + val prefix = miniMessage.deserialize("=====[$id]=====$themeSuffix") // Append clickable NPC names val clickableNpcNames = createClickableNpcNames(id, npcNames) diff --git a/src/main/kotlin/com/canefe/story/config/ConfigService.kt b/src/main/kotlin/com/canefe/story/config/ConfigService.kt index a5cf6a2..ade6644 100644 --- a/src/main/kotlin/com/canefe/story/config/ConfigService.kt +++ b/src/main/kotlin/com/canefe/story/config/ConfigService.kt @@ -51,6 +51,8 @@ class ConfigService( false // Whether to enable dialogue path selection for DMs var delayedPlayerMessageProcessing: Boolean = false // Whether to delay player message processing like /g command + var summarizationThreshold: Int = + 5 // Summarize conversation history every N messages /* NPC Behavior settings @@ -196,6 +198,11 @@ class ConfigService( "conversation.delayedPlayerMessageProcessing", false, ) // Whether to delay player message processing like /g command + summarizationThreshold = + config.getInt( + "conversation.summarizationThreshold", + 5, + ) // Summarize conversation history every N messages // NPC Behavior Settings headRotationDelay = config.getInt("npc.headRotationDelay", 2) @@ -290,6 +297,7 @@ class ConfigService( config.set("conversation.behavioralDirectivesEnabled", behavioralDirectivesEnabled) config.set("conversation.dialoguePathSelectionEnabled", dialoguePathSelectionEnabled) config.set("conversation.delayedPlayerMessageProcessing", delayedPlayerMessageProcessing) + config.set("conversation.summarizationThreshold", summarizationThreshold) // NPC Behavior settings config.set("npc.headRotationDelay", headRotationDelay) diff --git a/src/main/kotlin/com/canefe/story/config/PromptService.kt b/src/main/kotlin/com/canefe/story/config/PromptService.kt index b05b174..721922b 100644 --- a/src/main/kotlin/com/canefe/story/config/PromptService.kt +++ b/src/main/kotlin/com/canefe/story/config/PromptService.kt @@ -43,6 +43,19 @@ class PromptService( return prompt } + /** Gets the theme analysis prompt */ + fun getThemeAnalysisPrompt( + availableThemes: String, + activeThemes: String, + ): String { + val variables = + mapOf( + "available_themes" to availableThemes, + "active_themes" to activeThemes, + ) + return getPrompt("theme_analysis", variables) + } + /** Gets the behavioral directive prompt with context */ fun getBehavioralDirectivePrompt( recentMessages: String, @@ -248,6 +261,9 @@ class PromptService( return getPrompt("location_context_generation", variables) } + /** Gets the message history summary prompt for mid-conversation summarization */ + fun getMessageHistorySummaryPrompt(): String = getPrompt("message_history_summary") + /** Gets the session history summary prompt */ fun getSessionHistorySummaryPrompt(): String = getPrompt("session_history_summary") diff --git a/src/main/kotlin/com/canefe/story/conversation/Conversation.kt b/src/main/kotlin/com/canefe/story/conversation/Conversation.kt index 5a599b6..df24296 100644 --- a/src/main/kotlin/com/canefe/story/conversation/Conversation.kt +++ b/src/main/kotlin/com/canefe/story/conversation/Conversation.kt @@ -1,5 +1,6 @@ package com.canefe.story.conversation +import com.canefe.story.conversation.theme.ConversationThemeData import com.canefe.story.util.EssentialsUtils import net.citizensnpcs.api.npc.NPC import org.bukkit.entity.Player @@ -14,6 +15,12 @@ class Conversation( private val _npcs: MutableSet = HashSet(initialNPCs) private val _history: MutableList = ArrayList() + // Track the number of non-system messages added since the last history summarization + var messagesSinceLastSummary: Int = 0 + + // Theme data for this conversation + val themeData: ConversationThemeData = ConversationThemeData() + // Public properties var active: Boolean = true var chatEnabled: Boolean = true @@ -157,6 +164,9 @@ class Conversation( message, ) _history.add(userMessage) + if (message != "...") { + messagesSinceLastSummary++ + } } private fun addAssistantMessage(message: String) { @@ -166,6 +176,30 @@ class Conversation( message, ) _history.add(assistantMessage) + messagesSinceLastSummary++ + } + + fun replaceHistoryWithSummary( + summary: String, + summarizedMessagesCount: Int, + countedMessages: Int = summarizedMessagesCount, + ) { + if (summarizedMessagesCount <= 0 || _history.size < summarizedMessagesCount) { + return + } + // Remove only the messages that were actually summarized + _history.subList(0, summarizedMessagesCount).clear() + // Prepend the new summary + _history.add(0, ConversationMessage("system", "Summary of conversation so far: $summary")) + + // Decrement only by the number of messages that were actually counted + // toward the summarization threshold (excludes system messages and "..." + // placeholders). Messages added during the async window will have + // incremented the counter and must be preserved. + messagesSinceLastSummary -= countedMessages + if (messagesSinceLastSummary < 0) { + messagesSinceLastSummary = 0 + } } fun clearHistory() { diff --git a/src/main/kotlin/com/canefe/story/conversation/ConversationManager.kt b/src/main/kotlin/com/canefe/story/conversation/ConversationManager.kt index 45ed7cf..c931816 100644 --- a/src/main/kotlin/com/canefe/story/conversation/ConversationManager.kt +++ b/src/main/kotlin/com/canefe/story/conversation/ConversationManager.kt @@ -35,6 +35,7 @@ class ConversationManager private constructor( private val responseTimers = mutableMapOf() private val endingConversations = Collections.synchronizedSet(mutableSetOf()) + private val summarizingConversations = Collections.synchronizedSet(mutableSetOf()) // Getter for scheduled tasks fun getScheduledTasks(): MutableMap = scheduledTasks @@ -337,6 +338,9 @@ class ConversationManager private constructor( cleanupHolograms(conversation) + // Cleanup theme state to prevent memory leak + plugin.themeManager.onConversationEnd(conversation) + // Remove from repository repository.removeConversation(conversation) } @@ -649,6 +653,12 @@ class ConversationManager private constructor( conversation.addPlayerMessage(player, message) handleHolograms(conversation, player.name) + // Analyze and update conversation themes + plugin.themeAgent.analyzeAndUpdateThemes(conversation) + + // Check if message history needs summarization + checkAndSummarizeHistory(conversation) + // Skip response generation if chat is disabled if (!conversation.chatEnabled) { return @@ -841,6 +851,123 @@ class ConversationManager private constructor( return npc.entity } + /** + * Checks if the conversation history needs summarization and triggers it if so. + * Summarization occurs every 5 conversational messages to keep the context window manageable. + */ + private fun checkAndSummarizeHistory(conversation: Conversation) { + val summarizationThreshold = plugin.config.summarizationThreshold + val recentMessagesToKeep = RECENT_MESSAGES_TO_KEEP + + if (conversation.messagesSinceLastSummary < summarizationThreshold) { + return + } + + // Prevent concurrent summarization requests for the same conversation + if (!summarizingConversations.add(conversation.id)) { + return + } + + val history = conversation.history + val nonSystemMessages = history.filter { it.role != "system" } + + // Need enough messages to make summarization worthwhile + if (nonSystemMessages.size <= recentMessagesToKeep) { + summarizingConversations.remove(conversation.id) + return + } + + // Guard against negative splitIndex when history has many system messages + if (history.size <= recentMessagesToKeep) { + summarizingConversations.remove(conversation.id) + return + } + + if (plugin.config.debugMessages) { + plugin.logger.info( + "Summarizing message history for conversation ${conversation.id} " + + "(${history.size} messages, ${conversation.messagesSinceLastSummary} since last summary)", + ) + } + + // Split history: messages to summarize vs recent messages to keep + val splitIndex = history.size - recentMessagesToKeep + val messagesToSummarize = history.subList(0, splitIndex) + + // Count only non-system, non-placeholder messages that were actually + // counted toward the summarization threshold, so replaceHistoryWithSummary + // decrements the counter accurately. + val countedMessages = + messagesToSummarize.count { + it.role != "system" && it.content != "..." + } + + // Build the summarization prompt — character names are already in the + // message content (e.g. "DrJonas: hello"), so we only need the text. + // Newlines within messages are flattened to prevent injection. + val summaryPrompt = plugin.promptService.getMessageHistorySummaryPrompt() + + // Extract existing summary (if any) and new messages separately + val existingSummary = + messagesToSummarize + .firstOrNull { it.role == "system" && it.content.startsWith("Summary of conversation") } + ?.content + + val newMessages = + messagesToSummarize + .filter { it.content != "..." } + .filter { it.role != "system" } + .joinToString("\n") { it.content.replace("\n", " ") } + + val userMessage = + if (existingSummary != null) { + "Here is the existing summary of earlier events:\n---\n$existingSummary\n---\n\n" + + "Here are the new messages that happened after that summary:\n---\n$newMessages\n---\n\n" + + "Write a single combined summary that incorporates ALL details from the existing summary " + + "and the new messages. Do not drop any information from the existing summary." + } else { + "Summarize the following conversation transcript:\n---\n$newMessages\n---" + } + + val prompts = + listOf( + ConversationMessage("system", summaryPrompt), + ConversationMessage("user", userMessage), + ) + + // Run summarization asynchronously to avoid blocking the main thread + val messagesToSummarizeCount = messagesToSummarize.size + plugin + .getAIResponse(prompts, lowCost = false) + .thenAccept { summary -> + if (!summary.isNullOrBlank()) { + // Safely modify conversation state on the main server thread + Bukkit.getScheduler().runTask( + plugin, + Runnable { + conversation.replaceHistoryWithSummary(summary, messagesToSummarizeCount, countedMessages) + + if (plugin.config.debugMessages) { + plugin.logger.info( + "Message history summarized for conversation ${conversation.id}. " + + "New history size: ${conversation.history.size}", + ) + } + summarizingConversations.remove(conversation.id) + }, + ) + } else { + summarizingConversations.remove(conversation.id) + } + }.exceptionally { e -> + plugin.logger.warning( + "Failed to summarize message history for conversation ${conversation.id}: ${e.message}", + ) + summarizingConversations.remove(conversation.id) + null + } + } + // NPC conversation coordination fun generateResponses( conversation: Conversation, @@ -966,6 +1093,9 @@ class ConversationManager private constructor( // Add the NPC's response to the conversation history conversation.addNPCMessage(npcEntity, npcResponse) + // Check if message history needs summarization + checkAndSummarizeHistory(conversation) + // Hologram cleanup cleanupHolograms(conversation) @@ -1058,6 +1188,7 @@ class ConversationManager private constructor( .thenAccept { npcResponse -> // Rest of processing code conversation.addNPCMessage(npcEntity, npcResponse) + checkAndSummarizeHistory(conversation) cleanupHolograms(conversation) // Process action intents... result.complete(Unit) @@ -1234,6 +1365,7 @@ class ConversationManager private constructor( } companion object { + private const val RECENT_MESSAGES_TO_KEEP = 4 private var instance: ConversationManager? = null @JvmStatic diff --git a/src/main/kotlin/com/canefe/story/conversation/theme/ChatTheme.kt b/src/main/kotlin/com/canefe/story/conversation/theme/ChatTheme.kt new file mode 100644 index 0000000..1482725 --- /dev/null +++ b/src/main/kotlin/com/canefe/story/conversation/theme/ChatTheme.kt @@ -0,0 +1,12 @@ +package com.canefe.story.conversation.theme + +class ChatTheme : ConversationTheme() { + override val name: String = NAME + override val displayName: String = "Chat" + override val description: String = "Casual, friendly conversation. The default theme for normal dialogue." + override val compatibleWith: Set = emptySet() // compatible with everything + + companion object { + const val NAME = "chat" + } +} diff --git a/src/main/kotlin/com/canefe/story/conversation/theme/ConversationTheme.kt b/src/main/kotlin/com/canefe/story/conversation/theme/ConversationTheme.kt new file mode 100644 index 0000000..1bfe04d --- /dev/null +++ b/src/main/kotlin/com/canefe/story/conversation/theme/ConversationTheme.kt @@ -0,0 +1,20 @@ +package com.canefe.story.conversation.theme + +import com.canefe.story.conversation.Conversation +import com.canefe.story.conversation.ConversationMessage + +abstract class ConversationTheme { + abstract val name: String + abstract val displayName: String + abstract val description: String + abstract val compatibleWith: Set + + open fun onActivate(conversation: Conversation) {} + + open fun onDeactivate(conversation: Conversation) {} + + open fun onMessage( + conversation: Conversation, + message: ConversationMessage, + ) {} +} diff --git a/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeAgent.kt b/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeAgent.kt new file mode 100644 index 0000000..7fa948b --- /dev/null +++ b/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeAgent.kt @@ -0,0 +1,168 @@ +package com.canefe.story.conversation.theme + +import com.canefe.story.Story +import com.canefe.story.conversation.Conversation +import com.canefe.story.conversation.ConversationMessage +import java.util.Collections + +class ConversationThemeAgent( + private val plugin: Story, + private val themeManager: ConversationThemeManager, + private val registry: ConversationThemeRegistry, +) { + private val analyzingConversations = Collections.synchronizedSet(mutableSetOf()) + + fun analyzeAndUpdateThemes(conversation: Conversation) { + val debug = plugin.config.debugMessages + + // Skip if chat/AI is disabled + if (!conversation.chatEnabled) { + if (debug) plugin.logger.info("[ThemeAgent] Skipping conversation ${conversation.id}: chat disabled") + return + } + + // Prevent concurrent analysis for the same conversation + if (!analyzingConversations.add(conversation.id)) { + if (debug) { + plugin.logger.info( + "[ThemeAgent] Skipping conversation ${conversation.id}: analysis already in progress", + ) + } + return + } + + val recentMessages = + conversation.history + .filter { it.role != "system" && it.content != "..." } + .takeLast(RECENT_MESSAGES_TO_ANALYZE) + + if (recentMessages.isEmpty()) { + if (debug) { + plugin.logger.info( + "[ThemeAgent] Skipping conversation ${conversation.id}: no messages to analyze", + ) + } + analyzingConversations.remove(conversation.id) + return + } + + val availableThemes = buildThemeDescriptions() + val activeThemeNames = themeManager.getActiveThemes(conversation).map { it.name } + + if (debug) { + plugin.logger.info( + "[ThemeAgent] Analyzing conversation ${conversation.id} (${recentMessages.size} messages, active themes: ${activeThemeNames.ifEmpty { + listOf( + "none", + ) + }})", + ) + } + + val prompt = + plugin.promptService.getThemeAnalysisPrompt( + availableThemes = availableThemes, + activeThemes = activeThemeNames.joinToString(", ").ifEmpty { "none" }, + ) + + val transcript = + recentMessages.joinToString("\n") { + it.content.replace("\n", " ") + } + + val prompts = + listOf( + ConversationMessage("system", prompt), + ConversationMessage("user", transcript), + ) + + try { + plugin + .getAIResponse(prompts, lowCost = false) + .thenAccept { response -> + if (debug) { + plugin.logger.info( + "[ThemeAgent] AI response for conversation ${conversation.id}: '$response'", + ) + } + if (!response.isNullOrBlank() && conversation.active) { + applyThemeChanges(conversation, response.trim(), activeThemeNames) + } else if (debug) { + plugin.logger.info( + "[ThemeAgent] No action for conversation ${conversation.id}: response blank=${response.isNullOrBlank()}, active=${conversation.active}", + ) + } + analyzingConversations.remove(conversation.id) + }.exceptionally { ex -> + plugin.logger.warning( + "[ThemeAgent] Analysis failed for conversation ${conversation.id}: ${ex.message}", + ) + analyzingConversations.remove(conversation.id) + null + } + } catch (e: java.util.concurrent.RejectedExecutionException) { + if (debug) plugin.logger.info("[ThemeAgent] Executor rejected for conversation ${conversation.id}") + analyzingConversations.remove(conversation.id) + } + } + + private fun applyThemeChanges( + conversation: Conversation, + response: String, + previousThemes: List, + ) { + val debug = plugin.config.debugMessages + val desiredThemes = + response + .split(",") + .map { it.trim().lowercase() } + .filter { it.isNotEmpty() && it != "none" && registry.isRegistered(it) } + .toSet() + + if (debug) { + plugin.logger.info( + "[ThemeAgent] Desired themes for conversation ${conversation.id}: $desiredThemes (previous: $previousThemes)", + ) + } + + // Deactivate themes that are no longer needed + for (themeName in previousThemes) { + if (themeName !in desiredThemes) { + themeManager.deactivateTheme(conversation, themeName) + if (debug) { + plugin.logger.info( + "[ThemeAgent] Deactivated '$themeName' for conversation ${conversation.id}", + ) + } + } + } + + // Activate new themes + for (themeName in desiredThemes) { + if (themeName !in previousThemes) { + val activated = themeManager.activateTheme(conversation, themeName) + if (debug) { + if (activated) { + plugin.logger.info("[ThemeAgent] Activated '$themeName' for conversation ${conversation.id}") + } else { + plugin.logger.info( + "[ThemeAgent] Failed to activate '$themeName' for conversation ${conversation.id} (incompatible?)", + ) + } + } + } + } + } + + private fun buildThemeDescriptions(): String { + val themes = registry.getRegisteredThemes() + return themes.joinToString("\n") { name -> + val theme = registry.create(name) + "- ${theme.name}: ${theme.description}" + } + } + + companion object { + private const val RECENT_MESSAGES_TO_ANALYZE = 6 + } +} diff --git a/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeData.kt b/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeData.kt new file mode 100644 index 0000000..265dfc4 --- /dev/null +++ b/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeData.kt @@ -0,0 +1,19 @@ +package com.canefe.story.conversation.theme + +data class ConversationThemeData( + private val _activeThemeNames: MutableList = mutableListOf(), +) { + val activeThemeNames: List get() = _activeThemeNames.toList() + + fun addThemeName(name: String) { + if (!_activeThemeNames.contains(name)) { + _activeThemeNames.add(name) + } + } + + fun removeThemeName(name: String): Boolean = _activeThemeNames.remove(name) + + fun clearThemeNames() { + _activeThemeNames.clear() + } +} diff --git a/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeFactory.kt b/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeFactory.kt new file mode 100644 index 0000000..2badea6 --- /dev/null +++ b/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeFactory.kt @@ -0,0 +1,5 @@ +package com.canefe.story.conversation.theme + +fun interface ConversationThemeFactory { + fun create(): ConversationTheme +} diff --git a/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeManager.kt b/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeManager.kt new file mode 100644 index 0000000..6e45f3b --- /dev/null +++ b/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeManager.kt @@ -0,0 +1,81 @@ +package com.canefe.story.conversation.theme + +import com.canefe.story.conversation.Conversation +import com.canefe.story.conversation.ConversationMessage +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList + +class ConversationThemeManager( + private val registry: ConversationThemeRegistry, +) { + private val activeThemes = ConcurrentHashMap>() + + fun activateTheme( + conversation: Conversation, + themeName: String, + ): Boolean { + if (!registry.isRegistered(themeName)) return false + + val themes = activeThemes.getOrPut(conversation.id) { CopyOnWriteArrayList() } + + // Don't activate if already active + if (themes.any { it.name == themeName }) return false + + val newTheme = registry.create(themeName) + + // Check compatibility with all currently active themes + for (existing in themes) { + val newCompatible = newTheme.compatibleWith.isEmpty() || newTheme.compatibleWith.contains(existing.name) + val existingCompatible = existing.compatibleWith.isEmpty() || existing.compatibleWith.contains(themeName) + if (!newCompatible || !existingCompatible) return false + } + + themes.add(newTheme) + conversation.themeData.addThemeName(themeName) + newTheme.onActivate(conversation) + return true + } + + fun deactivateTheme( + conversation: Conversation, + themeName: String, + ): Boolean { + val themes = activeThemes[conversation.id] ?: return false + val theme = themes.find { it.name == themeName } ?: return false + + theme.onDeactivate(conversation) + themes.remove(theme) + conversation.themeData.removeThemeName(themeName) + + if (themes.isEmpty()) { + activeThemes.remove(conversation.id) + } + return true + } + + fun getActiveThemes(conversation: Conversation): List = + activeThemes[conversation.id]?.toList() ?: emptyList() + + fun hasTheme( + conversation: Conversation, + themeName: String, + ): Boolean = activeThemes[conversation.id]?.any { it.name == themeName } ?: false + + fun onConversationEnd(conversation: Conversation) { + val themes = activeThemes.remove(conversation.id) ?: return + for (theme in themes) { + theme.onDeactivate(conversation) + } + conversation.themeData.clearThemeNames() + } + + fun onMessage( + conversation: Conversation, + message: ConversationMessage, + ) { + val themes = activeThemes[conversation.id] ?: return + for (theme in themes) { + theme.onMessage(conversation, message) + } + } +} diff --git a/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeRegistry.kt b/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeRegistry.kt new file mode 100644 index 0000000..3210de8 --- /dev/null +++ b/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeRegistry.kt @@ -0,0 +1,24 @@ +package com.canefe.story.conversation.theme + +class ConversationThemeRegistry { + private val factories = mutableMapOf() + + fun register( + name: String, + factory: ConversationThemeFactory, + ) { + factories[name] = factory + } + + fun unregister(name: String) { + factories.remove(name) + } + + fun create(name: String): ConversationTheme = + factories[name]?.create() + ?: throw IllegalArgumentException("No theme registered with name: $name") + + fun getRegisteredThemes(): Set = factories.keys.toSet() + + fun isRegistered(name: String): Boolean = factories.containsKey(name) +} diff --git a/src/main/kotlin/com/canefe/story/conversation/theme/ViolenceTheme.kt b/src/main/kotlin/com/canefe/story/conversation/theme/ViolenceTheme.kt new file mode 100644 index 0000000..c3049f9 --- /dev/null +++ b/src/main/kotlin/com/canefe/story/conversation/theme/ViolenceTheme.kt @@ -0,0 +1,76 @@ +package com.canefe.story.conversation.theme + +import com.canefe.story.Story +import com.canefe.story.conversation.Conversation +import org.bukkit.Bukkit +import org.mcmonkey.sentinel.SentinelTrait + +class ViolenceTheme : ConversationTheme() { + override val name: String = NAME + override val displayName: String = "Violence" + override val description: String = "Combat, fighting, threats, or physical aggression. Activate when violence occurs or is imminent." + override val compatibleWith: Set = setOf(ChatTheme.NAME) + + private val sentinelAvailable: Boolean by lazy { + try { + Class.forName("org.mcmonkey.sentinel.SentinelTrait") + true + } catch (_: ClassNotFoundException) { + false + } + } + + override fun onActivate(conversation: Conversation) { + if (!sentinelAvailable) return + + val plugin = Story.instance + val npcs = conversation.npcs + val playerUUIDs = conversation.players + + if (npcs.isEmpty() || playerUUIDs.isEmpty()) return + + // Make each NPC attack each player in the conversation + for (npc in npcs) { + for (playerUUID in playerUUIDs) { + val player = Bukkit.getPlayer(playerUUID) ?: continue + plugin.askForPermission( + "Violence theme activated — make ${npc.name} attack ${player.name}?", + onAccept = { + val sentinel = npc.getOrAddTrait(SentinelTrait::class.java) + sentinel.addTarget("player:${player.name}") + if (plugin.config.debugMessages) { + plugin.logger.info("[ViolenceTheme] ${npc.name} is now attacking ${player.name}") + } + }, + onRefuse = {}, + ) + } + } + } + + override fun onDeactivate(conversation: Conversation) { + if (!sentinelAvailable) return + + val plugin = Story.instance + val npcs = conversation.npcs + val playerUUIDs = conversation.players + + // Stop NPCs from attacking players when violence theme ends + for (npc in npcs) { + if (!npc.hasTrait(SentinelTrait::class.java)) continue + val sentinel = npc.getOrAddTrait(SentinelTrait::class.java) + for (playerUUID in playerUUIDs) { + val player = Bukkit.getPlayer(playerUUID) ?: continue + sentinel.removeTarget("player:${player.name}") + } + sentinel.tryUpdateChaseTarget(null) + if (plugin.config.debugMessages) { + plugin.logger.info("[ViolenceTheme] ${npc.name} stopped attacking") + } + } + } + + companion object { + const val NAME = "violence" + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 599a4db..8587a0f 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -41,6 +41,7 @@ conversation: behavioralDirectivesEnabled: false dialoguePathSelectionEnabled: false delayedPlayerMessageProcessing: false + summarizationThreshold: 5 # Summarize conversation history every N messages # NPC Behavior npc: headRotationDelay: 2 diff --git a/src/main/resources/prompts.yml b/src/main/resources/prompts.yml index eeb991c..b2ffdae 100644 --- a/src/main/resources/prompts.yml +++ b/src/main/resources/prompts.yml @@ -1,6 +1,21 @@ # AI Prompts Configuration # This file contains all AI prompts used throughout the Story plugin +theme_analysis: + system_prompt: | + You are a conversation theme classifier. Analyze the conversation transcript and determine which themes should be active. + + Available themes: + {available_themes} + + Currently active themes: {active_themes} + + RULES: + - Respond ONLY with a comma-separated list of theme names that should be active (e.g. "chat, violence"). + - Respond with "none" if no themes apply. + - Only change themes when the conversation tone has clearly shifted. Do not flip themes on a single ambiguous message. + - Consider the overall direction of the conversation, not just the last message. + behavioral_directive: system_prompt: | You are a behavioral analysis agent responsible for generating actionable roleplay directives. @@ -380,6 +395,20 @@ recent_events_generation: Respond with a JSON object containing only a 'recent_events' field. +message_history_summary: + system_prompt: | + IMPORTANT: You are NOT roleplaying. You are a narrative summarizer for an ongoing roleplay conversation. + Do NOT continue the conversation. Do NOT write new dialogue or actions. + Do NOT use asterisks, emojis, bold text, markdown, or any formatting. + Do NOT include meta-commentary about "the user" or "the assistant" — use character names only. + + Write a concise narrative summary of everything that happened in the conversation below. + Use third person, past tense. Narrate it like a story recap — describe what each character + said, did, and felt. Do not omit any significant events, actions, threats, or revelations. + + Keep the summary under 300 words but include all important details. + Output ONLY the narrative summary — no prefixes, no labels, no section headers. + session_history_summary: system_prompt: | You are a concise summarizer for a medieval fantasy role-playing session. diff --git a/src/test/kotlin/com/canefe/story/MessageHistorySummarizationTest.kt b/src/test/kotlin/com/canefe/story/MessageHistorySummarizationTest.kt new file mode 100644 index 0000000..43db316 --- /dev/null +++ b/src/test/kotlin/com/canefe/story/MessageHistorySummarizationTest.kt @@ -0,0 +1,456 @@ +package com.canefe.story + +import com.canefe.story.command.base.CommandManager +import com.canefe.story.conversation.Conversation +import com.canefe.story.conversation.ConversationManager +import com.canefe.story.testutils.makeNpc +import dev.jorel.commandapi.CommandAPI +import io.mockk.* +import net.citizensnpcs.api.CitizensAPI +import net.citizensnpcs.api.npc.NPCRegistry +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.mockbukkit.mockbukkit.MockBukkit +import org.mockbukkit.mockbukkit.ServerMock +import java.util.concurrent.CompletableFuture + +class MessageHistorySummarizationTest { + private lateinit var server: ServerMock + private lateinit var plugin: Story + + @BeforeEach + fun setUp() { + System.setProperty("mockbukkit", "true") + server = MockBukkit.mock() + + mockkStatic(CommandAPI::class) + every { CommandAPI.onLoad(any()) } just Runs + every { CommandAPI.onEnable() } just Runs + every { CommandAPI.onDisable() } just Runs + + mockkConstructor(CommandManager::class) + every { anyConstructed().registerCommands() } just Runs + + plugin = MockBukkit.load(Story::class.java) + + val mockRegistry = mockk() + every { mockRegistry.isNPC(any()) } returns false + CitizensAPI.setNPCRegistry(mockRegistry) + + plugin.commandManager = mockk(relaxed = true) + ConversationManager.reset() + plugin.npcResponseService = mockk(relaxed = true) + plugin.worldInformationManager = mockk(relaxed = true) + plugin.npcContextGenerator = mockk(relaxed = true) + plugin.sessionManager = mockk(relaxed = true) + plugin.aiResponseService = mockk(relaxed = true) + + plugin.conversationManager = + ConversationManager.getInstance( + plugin, + plugin.npcContextGenerator, + plugin.npcResponseService, + plugin.worldInformationManager, + ) + } + + @AfterEach + fun teardown() { + MockBukkit.unmock() + } + + @Nested + inner class ConversationMessageCounterTests { + @Test + fun `messagesSinceLastSummary increments on player messages`() { + val player = server.addPlayer("Alice") + val npc = makeNpc("Guard") + val conversation = + Conversation( + _players = mutableListOf(player.uniqueId), + initialNPCs = listOf(npc), + ) + + assertEquals(0, conversation.messagesSinceLastSummary) + + conversation.addPlayerMessage(player, "hello") + assertEquals(1, conversation.messagesSinceLastSummary) + + conversation.addPlayerMessage(player, "how are you") + assertEquals(2, conversation.messagesSinceLastSummary) + } + + @Test + fun `messagesSinceLastSummary increments on NPC messages`() { + val player = server.addPlayer("Alice") + val npc = makeNpc("Guard") + val conversation = + Conversation( + _players = mutableListOf(player.uniqueId), + initialNPCs = listOf(npc), + ) + + assertEquals(0, conversation.messagesSinceLastSummary) + + // addNPCMessage adds an assistant message (+1) and a "..." user message (not counted) + conversation.addNPCMessage(npc, "Greetings traveler") + assertEquals(1, conversation.messagesSinceLastSummary) + } + + @Test + fun `messagesSinceLastSummary does not increment on system messages`() { + val player = server.addPlayer("Alice") + val npc = makeNpc("Guard") + val conversation = + Conversation( + _players = mutableListOf(player.uniqueId), + initialNPCs = listOf(npc), + ) + + conversation.addSystemMessage("A new player has joined") + assertEquals(0, conversation.messagesSinceLastSummary) + } + + @Test + fun `messagesSinceLastSummary does not count ellipsis user messages`() { + val player = server.addPlayer("Alice") + val npc = makeNpc("Guard") + val conversation = + Conversation( + _players = mutableListOf(player.uniqueId), + initialNPCs = listOf(npc), + ) + + // Simulate the pattern from addNPCMessage: assistant + "..." + conversation.addNPCMessage(npc, "Hello") + // assistant message counts as 1, "..." does not count + assertEquals(1, conversation.messagesSinceLastSummary) + + // A real player message should count + conversation.addPlayerMessage(player, "Hi back") + assertEquals(2, conversation.messagesSinceLastSummary) + } + + @Test + fun `replaceHistoryWithSummary decrements counter and removes summarized messages`() { + val player = server.addPlayer("Alice") + val npc = makeNpc("Guard") + val conversation = + Conversation( + _players = mutableListOf(player.uniqueId), + initialNPCs = listOf(npc), + ) + + // Add several messages + conversation.addPlayerMessage(player, "msg1") // user (+1) + conversation.addPlayerMessage(player, "msg2") // user (+1) + conversation.addNPCMessage(npc, "response1") // assistant (+1) + "..." + conversation.addPlayerMessage(player, "msg3") // user (+1) + conversation.addNPCMessage(npc, "response2") // assistant (+1) + "..." + + assertEquals(5, conversation.messagesSinceLastSummary) + + val totalMessages = conversation.history.size + // Summarize all but the last 3 messages + val messagesToSummarizeCount = totalMessages - 3 + + conversation.replaceHistoryWithSummary( + "Alice and Guard discussed things.", + messagesToSummarizeCount, + ) + + // Counter should be decremented by the number of summarized messages + assertEquals(5 - messagesToSummarizeCount, conversation.messagesSinceLastSummary) + + // History should contain 1 summary + 3 remaining recent messages + assertEquals(4, conversation.history.size) + assertEquals("system", conversation.history[0].role) + assertTrue(conversation.history[0].content.contains("Summary of conversation")) + } + + @Test + fun `replaceHistoryWithSummary preserves messages added during async summarization`() { + val player = server.addPlayer("Alice") + val npc = makeNpc("Guard") + val conversation = + Conversation( + _players = mutableListOf(player.uniqueId), + initialNPCs = listOf(npc), + ) + + // Add initial messages + conversation.addPlayerMessage(player, "msg1") // index 0 (+1) + conversation.addPlayerMessage(player, "msg2") // index 1 (+1) + conversation.addPlayerMessage(player, "msg3") // index 2 (+1) + + // Snapshot: summarize first 2 messages + val messagesToSummarizeCount = 2 + + // Simulate new messages arriving during async summarization + conversation.addPlayerMessage(player, "msg4") // index 3 (+1) + conversation.addPlayerMessage(player, "msg5") // index 4 (+1) + + // Counter is 5 (all 5 player messages counted) + assertEquals(5, conversation.messagesSinceLastSummary) + + // Now apply the summary (as if the async call completed) + conversation.replaceHistoryWithSummary( + "Summary: discussed msg1 and msg2.", + messagesToSummarizeCount, + ) + + // Should have: 1 summary + msg3 + msg4 + msg5 = 4 messages + assertEquals(4, conversation.history.size) + assertEquals("system", conversation.history[0].role) + assertTrue(conversation.history[0].content.contains("Summary")) + // msg4 and msg5 (added during async) should be preserved + assertTrue(conversation.history.any { it.content.contains("msg4") }) + assertTrue(conversation.history.any { it.content.contains("msg5") }) + // Counter should reflect only unsummarized messages (5 - 2 = 3) + assertEquals(3, conversation.messagesSinceLastSummary) + } + + @Test + fun `replaceHistoryWithSummary is a no-op when count exceeds history size`() { + val player = server.addPlayer("Alice") + val npc = makeNpc("Guard") + val conversation = + Conversation( + _players = mutableListOf(player.uniqueId), + initialNPCs = listOf(npc), + ) + + conversation.addPlayerMessage(player, "msg1") + val originalSize = conversation.history.size + + // Attempt to summarize more messages than exist + conversation.replaceHistoryWithSummary("Bad summary", originalSize + 5) + + // History should be unchanged + assertEquals(originalSize, conversation.history.size) + assertEquals(1, conversation.messagesSinceLastSummary) + } + } + + @Nested + inner class SummarizationTriggerTests { + @Test + fun `summarization is not triggered below threshold`() { + val player = server.addPlayer("Alice") + val npc = makeNpc("Guard") + + // Set threshold to 5 (default) + plugin.configService.summarizationThreshold = 5 + + val conversation = plugin.conversationManager.startConversation(player, listOf(npc)) + // Disable chat to prevent response generation + conversation.chatEnabled = false + + // Add 4 messages (below threshold of 5) + plugin.conversationManager.addPlayerMessage(player, conversation, "msg1") + plugin.conversationManager.addPlayerMessage(player, conversation, "msg2") + plugin.conversationManager.addPlayerMessage(player, conversation, "msg3") + plugin.conversationManager.addPlayerMessage(player, conversation, "msg4") + + // getAIResponse should NOT have been called for summarization + verify(exactly = 0) { plugin.aiResponseService.getAIResponseAsync(any(), any()) } + } + + @Test + fun `summarization is triggered at threshold`() { + val player = server.addPlayer("Alice") + val npc = makeNpc("Guard") + + plugin.configService.summarizationThreshold = 5 + + // Mock the AI response for summarization + every { + plugin.aiResponseService.getAIResponseAsync(any(), any()) + } returns CompletableFuture.completedFuture("Summary of conversation so far: things happened") + + val conversation = plugin.conversationManager.startConversation(player, listOf(npc)) + conversation.chatEnabled = false + + // Add 5 messages to hit the threshold + plugin.conversationManager.addPlayerMessage(player, conversation, "msg1") + plugin.conversationManager.addPlayerMessage(player, conversation, "msg2") + plugin.conversationManager.addPlayerMessage(player, conversation, "msg3") + plugin.conversationManager.addPlayerMessage(player, conversation, "msg4") + plugin.conversationManager.addPlayerMessage(player, conversation, "msg5") + + // getAIResponseAsync should have been called for summarization + verify(atLeast = 1) { plugin.aiResponseService.getAIResponseAsync(any(), any()) } + } + + @Test + fun `summarization replaces old messages and keeps recent ones`() { + val player = server.addPlayer("Alice") + val npc = makeNpc("Guard") + + plugin.configService.summarizationThreshold = 5 + + val summaryText = "Alice talked to Guard about the kingdom." + every { + plugin.aiResponseService.getAIResponseAsync(any(), any()) + } returns CompletableFuture.completedFuture(summaryText) + + val conversation = plugin.conversationManager.startConversation(player, listOf(npc)) + conversation.chatEnabled = false + + // Add 6 messages to trigger summarization with enough to split + plugin.conversationManager.addPlayerMessage(player, conversation, "msg1") + plugin.conversationManager.addPlayerMessage(player, conversation, "msg2") + plugin.conversationManager.addPlayerMessage(player, conversation, "msg3") + plugin.conversationManager.addPlayerMessage(player, conversation, "msg4") + plugin.conversationManager.addPlayerMessage(player, conversation, "msg5") + plugin.conversationManager.addPlayerMessage(player, conversation, "msg6") + + // Tick the scheduler so the runTask callback from thenAccept executes + server.scheduler.performOneTick() + + // After summarization completes, history should be condensed + // 1 summary system message + recent messages kept + assertTrue(conversation.history.size <= 6) + // Counter is decremented by summarized count, not reset to 0 + assertTrue(conversation.messagesSinceLastSummary < 6) + + // First message should be the summary + val firstMessage = conversation.history[0] + assertEquals("system", firstMessage.role) + assertEquals("Summary of conversation so far: $summaryText", firstMessage.content) + } + + @Test + fun `configurable threshold is respected`() { + val player = server.addPlayer("Alice") + val npc = makeNpc("Guard") + + // Set a higher threshold + plugin.configService.summarizationThreshold = 10 + + val conversation = plugin.conversationManager.startConversation(player, listOf(npc)) + conversation.chatEnabled = false + + // Add 5 messages (below threshold of 10) + repeat(5) { i -> + plugin.conversationManager.addPlayerMessage(player, conversation, "msg${i + 1}") + } + + // Should NOT trigger summarization at 5 with threshold of 10 + verify(exactly = 0) { plugin.aiResponseService.getAIResponseAsync(any(), any()) } + + // Now mock AI response and add more to reach 10 + every { + plugin.aiResponseService.getAIResponseAsync(any(), any()) + } returns CompletableFuture.completedFuture("Summary text") + + repeat(5) { i -> + plugin.conversationManager.addPlayerMessage(player, conversation, "msg${i + 6}") + } + + // Should now trigger summarization + verify(atLeast = 1) { plugin.aiResponseService.getAIResponseAsync(any(), any()) } + } + + @Test + fun `summarization handles null AI response gracefully`() { + val player = server.addPlayer("Alice") + val npc = makeNpc("Guard") + + plugin.configService.summarizationThreshold = 5 + + // Return null response + every { + plugin.aiResponseService.getAIResponseAsync(any(), any()) + } returns CompletableFuture.completedFuture(null) + + val conversation = plugin.conversationManager.startConversation(player, listOf(npc)) + conversation.chatEnabled = false + + // Add enough messages to trigger summarization + repeat(6) { i -> + plugin.conversationManager.addPlayerMessage(player, conversation, "msg${i + 1}") + } + + // History should remain unchanged since AI returned null + assertTrue(conversation.history.size >= 6) + // Counter should still be at the accumulated value since no summary was applied + assertTrue(conversation.messagesSinceLastSummary >= 5) + } + + @Test + fun `summarization handles AI exception gracefully`() { + val player = server.addPlayer("Alice") + val npc = makeNpc("Guard") + + plugin.configService.summarizationThreshold = 5 + + // Return a failed future + val failedFuture = CompletableFuture() + failedFuture.completeExceptionally(RuntimeException("API error")) + every { + plugin.aiResponseService.getAIResponseAsync(any(), any()) + } returns failedFuture + + val conversation = plugin.conversationManager.startConversation(player, listOf(npc)) + conversation.chatEnabled = false + + val historyBefore = conversation.history.size + + // Add enough messages to trigger summarization - should not throw + repeat(6) { i -> + plugin.conversationManager.addPlayerMessage(player, conversation, "msg${i + 1}") + } + + // Conversation should still be functional + assertTrue(conversation.history.isNotEmpty()) + assertTrue(conversation.active) + } + + @Test + fun `counter decrements after successful summarization allowing re-trigger`() { + val player = server.addPlayer("Alice") + val npc = makeNpc("Guard") + + plugin.configService.summarizationThreshold = 5 + + every { + plugin.aiResponseService.getAIResponseAsync(any(), any()) + } returns CompletableFuture.completedFuture("Summary of conversation so far: round 1") + + val conversation = plugin.conversationManager.startConversation(player, listOf(npc)) + conversation.chatEnabled = false + + // First round: trigger summarization + repeat(6) { i -> + plugin.conversationManager.addPlayerMessage(player, conversation, "round1_msg${i + 1}") + } + + // Tick the scheduler so the runTask callback from thenAccept executes + server.scheduler.performOneTick() + + // Counter should be decremented (not necessarily 0) after summarization + assertTrue(conversation.messagesSinceLastSummary < 6) + + // Update mock for second round + every { + plugin.aiResponseService.getAIResponseAsync(any(), any()) + } returns CompletableFuture.completedFuture("Summary of conversation so far: round 2") + + // Second round: add more messages to trigger again + repeat(6) { i -> + plugin.conversationManager.addPlayerMessage(player, conversation, "round2_msg${i + 1}") + } + + // Tick the scheduler so the second round's runTask executes + server.scheduler.performOneTick() + + // Should have been called at least twice total (once per round) + verify(atLeast = 2) { plugin.aiResponseService.getAIResponseAsync(any(), any()) } + } + } +} diff --git a/src/test/kotlin/com/canefe/story/PromptIntegrationTest.kt b/src/test/kotlin/com/canefe/story/PromptIntegrationTest.kt index 2b26022..c752b21 100644 --- a/src/test/kotlin/com/canefe/story/PromptIntegrationTest.kt +++ b/src/test/kotlin/com/canefe/story/PromptIntegrationTest.kt @@ -40,7 +40,7 @@ class PromptIntegrationTest { val envKey = System.getenv("OPENROUTER_API_KEY") ?: System.getenv("OPENAI_API_KEY") ?: "" if (envKey.isNotBlank()) { - plugin.config.openAIKey = envKey + // plugin.config.openAIKey = envKey } } diff --git a/src/test/kotlin/com/canefe/story/conversation/theme/ConversationThemeManagerTest.kt b/src/test/kotlin/com/canefe/story/conversation/theme/ConversationThemeManagerTest.kt new file mode 100644 index 0000000..00577f8 --- /dev/null +++ b/src/test/kotlin/com/canefe/story/conversation/theme/ConversationThemeManagerTest.kt @@ -0,0 +1,129 @@ +package com.canefe.story.conversation.theme + +import com.canefe.story.conversation.Conversation +import com.canefe.story.conversation.ConversationMessage +import com.canefe.story.testutils.makeNpc +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class ConversationThemeManagerTest { + private lateinit var registry: ConversationThemeRegistry + private lateinit var manager: ConversationThemeManager + private lateinit var conversation: Conversation + + @BeforeEach + fun setUp() { + registry = ConversationThemeRegistry() + registry.register(ChatTheme.NAME) { ChatTheme() } + registry.register(ViolenceTheme.NAME) { ViolenceTheme() } + manager = ConversationThemeManager(registry) + + val npc = makeNpc("Guard") + conversation = + Conversation( + id = 1, + _players = mutableListOf(), + initialNPCs = listOf(npc), + ) + } + + @Test + fun `activate theme adds it to conversation`() { + assertTrue(manager.activateTheme(conversation, ChatTheme.NAME)) + assertTrue(manager.hasTheme(conversation, ChatTheme.NAME)) + assertEquals(listOf(ChatTheme.NAME), conversation.themeData.activeThemeNames) + } + + @Test + fun `activate same theme twice returns false`() { + assertTrue(manager.activateTheme(conversation, ChatTheme.NAME)) + assertFalse(manager.activateTheme(conversation, ChatTheme.NAME)) + } + + @Test + fun `activate unregistered theme returns false`() { + assertFalse(manager.activateTheme(conversation, "nonexistent")) + } + + @Test + fun `compatible themes can stack`() { + // ChatTheme has empty compatibleWith (compatible with everything) + // ViolenceTheme is compatible with ChatTheme + assertTrue(manager.activateTheme(conversation, ChatTheme.NAME)) + assertTrue(manager.activateTheme(conversation, ViolenceTheme.NAME)) + + assertEquals(2, manager.getActiveThemes(conversation).size) + } + + @Test + fun `incompatible themes cannot stack`() { + // Register a "trade" theme that is incompatible with violence + registry.register("trade") { + object : ConversationTheme() { + override val name = "trade" + override val displayName = "Trade" + override val description = "Trading and bartering" + override val compatibleWith = setOf(ChatTheme.NAME) + } + } + + assertTrue(manager.activateTheme(conversation, ViolenceTheme.NAME)) + // trade is compatible with chat only, not violence; violence is compatible with chat only, not trade + assertFalse(manager.activateTheme(conversation, "trade")) + } + + @Test + fun `deactivate theme removes it`() { + manager.activateTheme(conversation, ChatTheme.NAME) + assertTrue(manager.deactivateTheme(conversation, ChatTheme.NAME)) + assertFalse(manager.hasTheme(conversation, ChatTheme.NAME)) + assertTrue(conversation.themeData.activeThemeNames.isEmpty()) + } + + @Test + fun `deactivate nonexistent theme returns false`() { + assertFalse(manager.deactivateTheme(conversation, ChatTheme.NAME)) + } + + @Test + fun `onConversationEnd cleans up all themes`() { + manager.activateTheme(conversation, ChatTheme.NAME) + manager.activateTheme(conversation, ViolenceTheme.NAME) + + manager.onConversationEnd(conversation) + + assertTrue(manager.getActiveThemes(conversation).isEmpty()) + assertTrue(conversation.themeData.activeThemeNames.isEmpty()) + } + + @Test + fun `onMessage propagates to active themes`() { + var messageReceived = false + registry.register("spy") { + object : ConversationTheme() { + override val name = "spy" + override val displayName = "Spy" + override val description = "Espionage and stealth" + override val compatibleWith = emptySet() + + override fun onMessage( + conversation: Conversation, + message: ConversationMessage, + ) { + messageReceived = true + } + } + } + + manager.activateTheme(conversation, "spy") + manager.onMessage(conversation, ConversationMessage("user", "hello")) + + assertTrue(messageReceived) + } + + @Test + fun `getActiveThemes returns empty list for conversation with no themes`() { + assertTrue(manager.getActiveThemes(conversation).isEmpty()) + } +} diff --git a/src/test/kotlin/com/canefe/story/conversation/theme/ConversationThemeRegistryTest.kt b/src/test/kotlin/com/canefe/story/conversation/theme/ConversationThemeRegistryTest.kt new file mode 100644 index 0000000..ccbf9bb --- /dev/null +++ b/src/test/kotlin/com/canefe/story/conversation/theme/ConversationThemeRegistryTest.kt @@ -0,0 +1,53 @@ +package com.canefe.story.conversation.theme + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class ConversationThemeRegistryTest { + private lateinit var registry: ConversationThemeRegistry + + @BeforeEach + fun setUp() { + registry = ConversationThemeRegistry() + } + + @Test + fun `register and create theme`() { + registry.register(ChatTheme.NAME) { ChatTheme() } + + val theme = registry.create(ChatTheme.NAME) + assertEquals(ChatTheme.NAME, theme.name) + assertEquals("Chat", theme.displayName) + } + + @Test + fun `create unregistered theme throws`() { + assertThrows(IllegalArgumentException::class.java) { + registry.create("nonexistent") + } + } + + @Test + fun `getRegisteredThemes returns all registered names`() { + registry.register(ChatTheme.NAME) { ChatTheme() } + registry.register(ViolenceTheme.NAME) { ViolenceTheme() } + + val themes = registry.getRegisteredThemes() + assertEquals(setOf(ChatTheme.NAME, ViolenceTheme.NAME), themes) + } + + @Test + fun `unregister removes theme`() { + registry.register(ChatTheme.NAME) { ChatTheme() } + assertTrue(registry.isRegistered(ChatTheme.NAME)) + + registry.unregister(ChatTheme.NAME) + assertFalse(registry.isRegistered(ChatTheme.NAME)) + } + + @Test + fun `isRegistered returns false for unknown theme`() { + assertFalse(registry.isRegistered("unknown")) + } +}