diff --git a/api/app/ai/narrative_generator.py b/api/app/ai/narrative_generator.py index b22603d..59a3cf1 100644 --- a/api/app/ai/narrative_generator.py +++ b/api/app/ai/narrative_generator.py @@ -448,7 +448,9 @@ class NarrativeGenerator: npc_relationship: str | None = None, previous_dialogue: list[dict[str, Any]] | None = None, npc_knowledge: list[str] | None = None, - quest_offering_context: dict[str, Any] | None = None + quest_offering_context: dict[str, Any] | None = None, + quest_ineligibility_context: dict[str, Any] | None = None, + player_asking_for_quests: bool = False ) -> NarrativeResponse: """ Generate NPC dialogue in response to player conversation. @@ -463,6 +465,8 @@ class NarrativeGenerator: previous_dialogue: Optional list of previous exchanges. npc_knowledge: Optional list of things this NPC knows about. quest_offering_context: Optional quest offer context from QuestEligibilityService. + quest_ineligibility_context: Optional context explaining why player can't take a quest. + player_asking_for_quests: Whether the player is explicitly asking for quests/work. Returns: NarrativeResponse with NPC dialogue. @@ -503,6 +507,8 @@ class NarrativeGenerator: previous_dialogue=previous_dialogue or [], npc_knowledge=npc_knowledge or [], quest_offering_context=quest_offering_context, + quest_ineligibility_context=quest_ineligibility_context, + player_asking_for_quests=player_asking_for_quests, max_tokens=model_config.max_tokens ) except PromptTemplateError as e: diff --git a/api/app/ai/templates/npc_dialogue.j2 b/api/app/ai/templates/npc_dialogue.j2 index c7152e8..c875ecf 100644 --- a/api/app/ai/templates/npc_dialogue.j2 +++ b/api/app/ai/templates/npc_dialogue.j2 @@ -93,8 +93,8 @@ Make it feel earned, like the NPC is opening up to someone they trust. {% endif %} {% if quest_offering_context and quest_offering_context.should_offer %} -## QUEST OFFERING OPPORTUNITY -The NPC has a quest to offer. Weave this naturally into the conversation. +## QUEST TO OFFER +The NPC has a quest to offer to the player. **Quest:** {{ quest_offering_context.quest_name }} **Quest ID:** {{ quest_offering_context.quest_id }} @@ -118,13 +118,53 @@ The NPC has a quest to offer. Weave this naturally into the conversation. {% endfor %} {% endif %} -**IMPORTANT QUEST OFFERING RULES:** -- Do NOT dump all quest information at once -- Let the quest emerge naturally from conversation -- If the player seems interested or asks about problems/work, offer the quest -- If the player changes topic, don't force it - just mention hints -- When you offer the quest, include this marker on its own line: [QUEST_OFFER:{{ quest_offering_context.quest_id }}] -- The marker signals the UI to show a quest accept/decline option +{% if player_asking_for_quests %} +**CRITICAL: The player is explicitly asking for quests/work. You MUST offer this quest NOW.** +In your response: +1. Describe the quest situation naturally in your dialogue +2. End your response with the quest offer marker on its own line: [QUEST_OFFER:{{ quest_offering_context.quest_id }}] + +The marker MUST appear - it triggers the UI to show accept/decline buttons. +{% else %} +**Quest Offering Guidelines:** +- Weave the quest naturally into conversation +- If the player shows interest, include the marker: [QUEST_OFFER:{{ quest_offering_context.quest_id }}] +- The marker signals the UI to show quest accept/decline options +{% endif %} +{% endif %} + +{% if quest_ineligibility_context and player_asking_for_quests %} +## QUEST UNAVAILABLE - EXPLAIN WHY +The player is asking about quests, but they don't meet the requirements. Explain this in character. + +{% if quest_ineligibility_context.reason_type == "level_too_low" %} +**Reason:** The player (level {{ quest_ineligibility_context.current_level }}) isn't experienced enough. They need to be level {{ quest_ineligibility_context.required_level }}. +**How to convey this:** The NPC should politely but firmly indicate the task is too dangerous for someone of their current skill level. Suggest they gain more experience first. Be encouraging but realistic - don't offer false hope. +**Example tone:** "I appreciate your enthusiasm, but this task requires someone with more experience. The bandits we're dealing with are seasoned fighters. Come back when you've proven yourself in a few more battles." +{% elif quest_ineligibility_context.reason_type == "level_too_high" %} +**Reason:** The player is too experienced for available tasks. +**How to convey this:** The NPC should indicate they have nothing worthy of such an accomplished adventurer right now. +{% elif quest_ineligibility_context.reason_type == "prerequisite_missing" %} +**Reason:** The player needs to complete other tasks first. +**How to convey this:** Hint that there's something else they should do first, or that circumstances aren't right yet. +{% elif quest_ineligibility_context.reason_type == "relationship_too_low" %} +**Reason:** The NPC doesn't trust the player enough yet. +**How to convey this:** Be guarded. Hint that you might have work, but you need to know you can trust them first. +{% elif quest_ineligibility_context.reason_type == "quest_already_active" %} +**Reason:** The player is already working on this quest. +**How to convey this:** Remind them they already accepted this task and should focus on completing it. +{% elif quest_ineligibility_context.reason_type == "quest_already_completed" %} +**Reason:** The player already completed this quest. +**How to convey this:** Thank them again for their previous help, mention you have nothing else right now. +{% elif quest_ineligibility_context.reason_type == "too_many_quests" %} +**Reason:** The player has too many active quests. +**How to convey this:** Suggest they finish some of their current commitments before taking on more. +{% else %} +**Reason:** {{ quest_ineligibility_context.message }} +**How to convey this:** Politely decline, staying in character. +{% endif %} + +**IMPORTANT:** Do NOT offer the quest. Explain the situation naturally in dialogue. {% endif %} {% if lore_context and lore_context.has_content %} diff --git a/api/app/api/npcs.py b/api/app/api/npcs.py index 322eb69..d293174 100644 --- a/api/app/api/npcs.py +++ b/api/app/api/npcs.py @@ -195,25 +195,47 @@ def talk_to_npc(npc_id: str): # Check for quest eligibility quest_offering_context = None + quest_ineligibility_context = None # For explaining why player can't take a quest + player_asking_for_quests = _is_player_asking_for_quests(topic) try: quest_eligibility_service = get_quest_eligibility_service() location_type = _get_location_type(session.game_state.current_location) + # If player is explicitly asking about quests, bypass probability roll + force_probability = 1.0 if player_asking_for_quests else None + eligibility_result = quest_eligibility_service.check_eligibility( npc_id=npc_id, character=character, location_type=location_type, - location_id=session.game_state.current_location + location_id=session.game_state.current_location, + force_probability=force_probability ) if eligibility_result.should_offer_quest and eligibility_result.selected_quest_context: quest_offering_context = eligibility_result.selected_quest_context.to_dict() + # Add should_offer flag for template conditional check + quest_offering_context['should_offer'] = True logger.debug( "Quest eligible for offering", npc_id=npc_id, quest_id=quest_offering_context.get("quest_id"), character_id=character.character_id ) + elif player_asking_for_quests and eligibility_result.blocking_reasons: + # Player asked for quests but isn't eligible - tell them why + quest_ineligibility_context = _build_ineligibility_context( + eligibility_result.blocking_reasons, + character.level, + npc_id + ) + if quest_ineligibility_context: + logger.debug( + "Quest ineligible - providing reason to AI", + npc_id=npc_id, + reason=quest_ineligibility_context.get("reason_type"), + character_level=character.level + ) except Exception as e: # Don't fail the conversation if quest eligibility check fails logger.warning( @@ -251,6 +273,8 @@ def talk_to_npc(npc_id: str): "relationship_level": interaction.get("relationship_level", 50), "previous_dialogue": previous_dialogue, # Pass conversation history "quest_offering_context": quest_offering_context, # Quest offer if eligible + "quest_ineligibility_context": quest_ineligibility_context, # Why player can't take quest + "player_asking_for_quests": player_asking_for_quests, # Player explicitly asking for work } # Enqueue AI task @@ -483,3 +507,139 @@ def _get_location_type(location_id: str) -> str: return "dungeon" return "town" # Default for town centers, squares, etc. + + +def _build_ineligibility_context( + blocking_reasons: dict[str, str], + character_level: int, + npc_id: str +) -> dict | None: + """ + Build context explaining why a player can't take a quest. + + Parses the blocking reasons and creates a structured context + that the AI can use to explain to the player why they can't + help with the quest yet. + + Args: + blocking_reasons: Dict of quest_id -> reason string + character_level: Player's current level + npc_id: The NPC they're talking to + + Returns: + Dict with reason_type, message, and details, or None if no relevant reason + """ + if not blocking_reasons: + return None + + # Look through blocking reasons for level-related issues + for quest_id, reason in blocking_reasons.items(): + if "level too low" in reason.lower(): + # Extract required level from reason string like "Character level too low (need 3)" + import re + match = re.search(r'need (\d+)', reason) + required_level = int(match.group(1)) if match else character_level + 1 + + return { + "reason_type": "level_too_low", + "current_level": character_level, + "required_level": required_level, + "message": f"The player is level {character_level} but needs to be level {required_level}", + "quest_id": quest_id, + } + + if "level too high" in reason.lower(): + return { + "reason_type": "level_too_high", + "current_level": character_level, + "message": "The player is too experienced for this task", + "quest_id": quest_id, + } + + if "prerequisite" in reason.lower(): + return { + "reason_type": "prerequisite_missing", + "message": "The player hasn't completed a required earlier task", + "quest_id": quest_id, + } + + if "already active" in reason.lower(): + return { + "reason_type": "quest_already_active", + "message": "The player is already working on this quest", + "quest_id": quest_id, + } + + if "already completed" in reason.lower(): + return { + "reason_type": "quest_already_completed", + "message": "The player has already completed this quest", + "quest_id": quest_id, + } + + if "relationship" in reason.lower(): + return { + "reason_type": "relationship_too_low", + "message": "The NPC doesn't trust the player enough yet", + "quest_id": quest_id, + } + + if "max" in reason.lower() and "quest" in reason.lower(): + return { + "reason_type": "too_many_quests", + "message": "The player already has too many active quests", + "quest_id": quest_id, + } + + return None + + +def _is_player_asking_for_quests(topic: str) -> bool: + """ + Detect if the player is explicitly asking about quests or work. + + This is used to bypass the probability roll when the player + clearly intends to find quests. + + Args: + topic: The player's message/conversation topic + + Returns: + True if player is asking about quests, False otherwise + """ + topic_lower = topic.lower() + + # Quest-related keywords + quest_keywords = [ + "quest", + "quests", + "any work", + "work for me", + "job", + "jobs", + "task", + "tasks", + "help you", + "help with", + "need help", + "anything i can do", + "can i help", + "how can i help", + "i'd love to help", + "i would love to help", + "want to help", + "like to help", + "offer my services", + "hire me", + "bounty", + "bounties", + "adventure", + "mission", + "missions", + ] + + for keyword in quest_keywords: + if keyword in topic_lower: + return True + + return False diff --git a/api/app/data/npcs/crossville/npc_mayor_aldric.yaml b/api/app/data/npcs/crossville/npc_mayor_aldric.yaml index c419e24..6cb48c9 100644 --- a/api/app/data/npcs/crossville/npc_mayor_aldric.yaml +++ b/api/app/data/npcs/crossville/npc_mayor_aldric.yaml @@ -13,15 +13,11 @@ personality: - increasingly desperate - hiding something significant speech_style: | - Speaks with the practiced cadence of a politician - measured words, - careful pauses for effect. His voice wavers slightly when stressed, - and he has a habit of clearing his throat before difficult topics. - Uses formal address even in casual conversation. + Speaks with the practiced cadence of a politician - measured words. + His voice wavers slightly when stressed, has a habit of clearing his + throat before difficult topics. Uses formal address even in casual conversation. quirks: - - Constantly adjusts his mayoral chain of office - Glances at his manor when the Old Mines are mentioned - - Keeps touching a ring on his left hand - - Offers wine to guests but never drinks himself appearance: brief: Tall, thin man with receding grey hair, worry lines, and expensive but slightly disheveled clothing diff --git a/api/app/tasks/ai_tasks.py b/api/app/tasks/ai_tasks.py index ef2ee86..e49b3db 100644 --- a/api/app/tasks/ai_tasks.py +++ b/api/app/tasks/ai_tasks.py @@ -26,6 +26,7 @@ Usage: """ import json +import re import uuid from datetime import datetime, timezone from enum import Enum @@ -712,7 +713,9 @@ def _process_npc_dialogue_task( npc_relationship=context.get('npc_relationship'), previous_dialogue=context.get('previous_dialogue'), npc_knowledge=context.get('npc_knowledge'), - quest_offering_context=context.get('quest_offering_context') + quest_offering_context=context.get('quest_offering_context'), + quest_ineligibility_context=context.get('quest_ineligibility_context'), + player_asking_for_quests=context.get('player_asking_for_quests', False) ) # Get NPC info for result @@ -723,8 +726,44 @@ def _process_npc_dialogue_task( # Get previous dialogue for display (before adding new exchange) previous_dialogue = context.get('previous_dialogue', []) + # Parse for quest offer marker and extract structured data for UI + dialogue_text = response.narrative + quest_offer_data = None + + quest_offer_match = re.search(r'\[QUEST_OFFER:([^\]]+)\]', dialogue_text) + if quest_offer_match: + offered_quest_id = quest_offer_match.group(1).strip() + + # Remove the marker from displayed dialogue (clean up whitespace around it) + dialogue_text = re.sub(r'\s*\[QUEST_OFFER:[^\]]+\]\s*', ' ', dialogue_text).strip() + + # Get quest details from the offering context for UI display + quest_ctx = context.get('quest_offering_context') + if quest_ctx: + quest_offer_data = { + 'quest_id': offered_quest_id, + 'quest_name': quest_ctx.get('quest_name', 'Unknown Quest'), + 'quest_description': quest_ctx.get('quest_description', ''), + 'rewards': quest_ctx.get('rewards', {}), + 'npc_id': npc_id, + } + logger.info( + "Quest offer detected in NPC dialogue", + quest_id=offered_quest_id, + quest_name=quest_ctx.get('quest_name'), + npc_id=npc_id, + character_id=character_id + ) + else: + # AI mentioned a quest but no context was provided - log warning + logger.warning( + "Quest offer marker found but no quest_offering_context available", + quest_id=offered_quest_id, + npc_id=npc_id + ) + result = { - "dialogue": response.narrative, + "dialogue": dialogue_text, "tokens_used": response.tokens_used, "model": response.model, "context_type": response.context_type, @@ -734,6 +773,7 @@ def _process_npc_dialogue_task( "character_name": character_name, "player_line": context['conversation_topic'], "conversation_history": previous_dialogue, # History before this exchange + "quest_offer": quest_offer_data, # Structured quest offer for UI (None if no quest offered) } # Save dialogue exchange to chat_messages collection and update character's recent_messages cache @@ -743,15 +783,16 @@ def _process_npc_dialogue_task( location_id = context.get('game_state', {}).get('current_location') # Save to chat_messages collection (also updates character's recent_messages) + # Note: Save the cleaned dialogue_text (without quest markers) for display chat_service = get_chat_message_service() chat_service.save_dialogue_exchange( character_id=character_id, user_id=user_id, npc_id=npc_id, player_message=context['conversation_topic'], - npc_response=response.narrative, + npc_response=dialogue_text, # Use cleaned text without quest markers context=MessageContext.DIALOGUE, # Default context, can be enhanced based on quest/shop interactions - metadata={}, # Can add quest_id, item_id, etc. when those systems are implemented + metadata={'quest_offer_id': quest_offer_data.get('quest_id') if quest_offer_data else None}, session_id=session_id, location_id=location_id ) diff --git a/public_web/app/views/game_views.py b/public_web/app/views/game_views.py index ccdfaa3..d7e3793 100644 --- a/public_web/app/views/game_views.py +++ b/public_web/app/views/game_views.py @@ -601,6 +601,7 @@ def poll_job(session_id: str, job_id: str): nested_result = result.get('result', {}) if nested_result.get('context_type') == 'npc_dialogue': # NPC dialogue response - return dialogue partial + # Include quest_offer data for inline quest offer card return render_template( 'game/partials/npc_dialogue_response.html', npc_name=nested_result.get('npc_name', 'NPC'), @@ -608,6 +609,8 @@ def poll_job(session_id: str, job_id: str): conversation_history=nested_result.get('conversation_history', []), player_line=nested_result.get('player_line', ''), dialogue=nested_result.get('dialogue', 'No response'), + quest_offer=nested_result.get('quest_offer'), # Quest offer data for UI card + npc_id=nested_result.get('npc_id'), # NPC ID for accept/decline session_id=session_id ) else: @@ -1429,6 +1432,158 @@ def talk_to_npc(session_id: str, npc_id: str): return f'
{{ quest_offer.quest_description }}
+ + {% if quest_offer.rewards %} +