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'
Failed to talk to NPC: {e}
', 500 +# ===== Quest Accept/Decline Routes ===== + +@game_bp.route('/session//quest/accept', methods=['POST']) +@require_auth +def accept_quest(session_id: str): + """ + Accept a quest offer from NPC chat. + + Called when player clicks 'Accept Quest' button on inline quest offer card. + Returns updated card with confirmation message and triggers toast notification. + """ + client = get_api_client() + quest_id = request.form.get('quest_id') + npc_id = request.form.get('npc_id') + npc_name = request.form.get('npc_name', 'NPC') + + if not quest_id: + return render_template( + 'game/partials/quest_action_response.html', + action='error', + error_message='No quest specified', + session_id=session_id + ) + + try: + # Get character_id from session + session_response = client.get(f'/api/v1/sessions/{session_id}') + session_data = session_response.get('result', {}) + character_id = session_data.get('character_id') + + if not character_id: + return render_template( + 'game/partials/quest_action_response.html', + action='error', + error_message='Session error - no character found', + session_id=session_id + ) + + # Call API to accept quest + response = client.post('/api/v1/quests/accept', { + 'character_id': character_id, + 'quest_id': quest_id, + 'npc_id': npc_id + }) + + result = response.get('result', {}) + quest_name = result.get('quest_name', 'Quest') + + logger.info( + "quest_accepted", + quest_id=quest_id, + quest_name=quest_name, + character_id=character_id, + session_id=session_id + ) + + return render_template( + 'game/partials/quest_action_response.html', + action='accept', + quest_name=quest_name, + npc_name=npc_name, + session_id=session_id + ) + + except APIError as e: + logger.error( + "failed_to_accept_quest", + quest_id=quest_id, + session_id=session_id, + error=str(e) + ) + return render_template( + 'game/partials/quest_action_response.html', + action='error', + error_message=str(e), + session_id=session_id + ) + + +@game_bp.route('/session//quest/decline', methods=['POST']) +@require_auth +def decline_quest(session_id: str): + """ + Decline a quest offer from NPC chat. + + Called when player clicks 'Decline' button on inline quest offer card. + Returns updated card with decline confirmation. + """ + client = get_api_client() + quest_id = request.form.get('quest_id') + npc_id = request.form.get('npc_id') + npc_name = request.form.get('npc_name', 'NPC') + + if not quest_id: + return render_template( + 'game/partials/quest_action_response.html', + action='error', + error_message='No quest specified', + session_id=session_id + ) + + try: + # Get character_id from session + session_response = client.get(f'/api/v1/sessions/{session_id}') + session_data = session_response.get('result', {}) + character_id = session_data.get('character_id') + + if not character_id: + return render_template( + 'game/partials/quest_action_response.html', + action='error', + error_message='Session error - no character found', + session_id=session_id + ) + + # Call API to decline quest + client.post('/api/v1/quests/decline', { + 'character_id': character_id, + 'quest_id': quest_id, + 'npc_id': npc_id + }) + + logger.info( + "quest_declined", + quest_id=quest_id, + character_id=character_id, + session_id=session_id + ) + + return render_template( + 'game/partials/quest_action_response.html', + action='decline', + quest_name='', # Not needed for decline message + npc_name=npc_name, + session_id=session_id + ) + + except APIError as e: + logger.error( + "failed_to_decline_quest", + quest_id=quest_id, + session_id=session_id, + error=str(e) + ) + return render_template( + 'game/partials/quest_action_response.html', + action='error', + error_message=str(e), + session_id=session_id + ) + + # ===== Shop Routes ===== @game_bp.route('/session//shop-modal') diff --git a/public_web/static/css/play.css b/public_web/static/css/play.css index d890c1f..5b78971 100644 --- a/public_web/static/css/play.css +++ b/public_web/static/css/play.css @@ -2662,3 +2662,273 @@ background: #ef4444; color: white; } + +/* ===== QUEST OFFER CARD ===== */ +/* Inline card that appears in NPC chat when quest is offered */ + +.quest-offer-card { + background: linear-gradient(135deg, rgba(139, 92, 246, 0.15), rgba(59, 130, 246, 0.1)); + border: 2px solid var(--action-premium); + border-radius: 8px; + margin-top: 1rem; + padding: 1rem; + animation: questCardAppear 0.4s ease; +} + +@keyframes questCardAppear { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.quest-offer-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.75rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid rgba(139, 92, 246, 0.3); +} + +.quest-offer-icon { + font-size: 1.25rem; +} + +.quest-offer-label { + font-size: var(--text-xs); + text-transform: uppercase; + letter-spacing: 1px; + color: var(--action-premium); + font-weight: 600; +} + +.quest-offer-content { + margin-bottom: 1rem; +} + +.quest-offer-title { + font-family: var(--font-heading); + font-size: var(--text-lg); + color: var(--accent-gold); + margin: 0 0 0.5rem 0; +} + +.quest-offer-description { + font-size: var(--text-sm); + color: var(--text-secondary); + margin: 0 0 0.75rem 0; + line-height: 1.5; +} + +.quest-offer-rewards { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} + +.rewards-label { + font-size: var(--text-xs); + color: var(--text-muted); + text-transform: uppercase; +} + +.reward-item { + font-size: var(--text-sm); + padding: 0.25rem 0.5rem; + border-radius: 4px; + background: rgba(0, 0, 0, 0.3); +} + +.reward-gold { + color: var(--accent-gold); +} + +.reward-xp { + color: #10b981; +} + +.quest-offer-actions { + display: flex; + gap: 0.75rem; +} + +.quest-btn { + flex: 1; + padding: 0.625rem 1rem; + font-size: var(--text-sm); + font-weight: 600; + border: none; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; +} + +.quest-btn--accept { + background: #10b981; + color: white; +} + +.quest-btn--accept:hover { + background: #059669; + transform: translateY(-1px); +} + +.quest-btn--decline { + background: rgba(255, 255, 255, 0.1); + color: var(--text-secondary); + border: 1px solid var(--play-border); +} + +.quest-btn--decline:hover { + background: rgba(255, 255, 255, 0.15); + color: var(--text-primary); +} + +/* Quest action result (replaces card after action) */ +.quest-action-result { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + border-radius: 6px; + margin-top: 1rem; + animation: questCardAppear 0.3s ease; +} + +.quest-action-result--accept { + background: rgba(16, 185, 129, 0.15); + border: 1px solid #10b981; +} + +.quest-action-result--decline { + background: rgba(107, 114, 128, 0.15); + border: 1px solid var(--play-border); +} + +.quest-action-result--error { + background: rgba(239, 68, 68, 0.15); + border: 1px solid #ef4444; +} + +.quest-action-icon { + font-size: 1.5rem; +} + +.quest-action-message { + flex: 1; +} + +.quest-action-message strong { + display: block; + color: var(--text-primary); + margin-bottom: 0.25rem; +} + +.quest-action-message p { + margin: 0; + font-size: var(--text-sm); + color: var(--text-secondary); +} + + +/* ===== TOAST NOTIFICATIONS ===== */ +/* Fixed position notifications for quick feedback */ + +.toast-container { + position: fixed; + top: 1rem; + right: 1rem; + z-index: 9999; + display: flex; + flex-direction: column; + gap: 0.5rem; + max-width: 350px; + pointer-events: none; +} + +.toast { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + border-radius: 6px; + font-size: var(--text-sm); + box-shadow: var(--shadow-lg); + opacity: 0; + transform: translateX(100%); + transition: all 0.3s ease; + pointer-events: auto; +} + +.toast--visible { + opacity: 1; + transform: translateX(0); +} + +.toast--dismissing { + opacity: 0; + transform: translateX(100%); +} + +.toast--success { + background: rgba(16, 185, 129, 0.95); + color: white; +} + +.toast--error { + background: rgba(239, 68, 68, 0.95); + color: white; +} + +.toast--info { + background: rgba(59, 130, 246, 0.95); + color: white; +} + +.toast-message { + flex: 1; + margin-right: 0.5rem; +} + +.toast-close { + background: none; + border: none; + color: rgba(255, 255, 255, 0.8); + font-size: 1.25rem; + cursor: pointer; + padding: 0; + line-height: 1; + transition: color 0.2s ease; +} + +.toast-close:hover { + color: white; +} + +/* Mobile responsiveness for quest card */ +@media (max-width: 768px) { + .quest-offer-card { + margin-top: 0.75rem; + padding: 0.75rem; + } + + .quest-offer-actions { + flex-direction: column; + } + + .quest-btn { + width: 100%; + } + + .toast-container { + left: 1rem; + right: 1rem; + max-width: none; + } +} diff --git a/public_web/static/js/toast.js b/public_web/static/js/toast.js new file mode 100644 index 0000000..e7195ed --- /dev/null +++ b/public_web/static/js/toast.js @@ -0,0 +1,82 @@ +/** + * Toast Notification System + * Provides temporary notifications for game events (quest acceptance, errors, etc.) + * + * Usage: + * showToast('Quest accepted!', 'success'); + * showToast('Failed to save', 'error'); + * showToast('Item added to inventory', 'info'); + */ + +/** + * Show a toast notification + * @param {string} message - The message to display + * @param {string} type - Type of toast: 'success', 'error', or 'info' + * @param {number} duration - Duration in ms before auto-dismiss (default: 4000) + */ +function showToast(message, type = 'info', duration = 4000) { + // Create toast container if it doesn't exist + let container = document.getElementById('toast-container'); + if (!container) { + container = document.createElement('div'); + container.id = 'toast-container'; + container.className = 'toast-container'; + document.body.appendChild(container); + } + + // Create toast element + const toast = document.createElement('div'); + toast.className = `toast toast--${type}`; + toast.innerHTML = ` + ${escapeHtml(message)} + + `; + + // Add to container + container.appendChild(toast); + + // Trigger animation (small delay for DOM to register element) + requestAnimationFrame(() => { + toast.classList.add('toast--visible'); + }); + + // Auto-remove after duration + setTimeout(() => { + dismissToast(toast); + }, duration); +} + +/** + * Dismiss a toast notification with animation + * @param {HTMLElement} toast - The toast element to dismiss + */ +function dismissToast(toast) { + if (!toast || toast.classList.contains('toast--dismissing')) { + return; + } + + toast.classList.add('toast--dismissing'); + toast.classList.remove('toast--visible'); + + // Remove from DOM after animation completes + setTimeout(() => { + if (toast.parentElement) { + toast.remove(); + } + }, 300); +} + +/** + * Escape HTML to prevent XSS + * @param {string} text - Text to escape + * @returns {string} Escaped text + */ +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// Make functions globally available +window.showToast = showToast; +window.dismissToast = dismissToast; diff --git a/public_web/templates/game/partials/npc_dialogue_response.html b/public_web/templates/game/partials/npc_dialogue_response.html index 9863a5d..10e8c03 100644 --- a/public_web/templates/game/partials/npc_dialogue_response.html +++ b/public_web/templates/game/partials/npc_dialogue_response.html @@ -9,6 +9,8 @@ Expected context: - player_line: What the player just said - dialogue: NPC's current response - session_id: For any follow-up actions +- quest_offer: Optional quest offer data {quest_id, quest_name, quest_description, rewards, npc_id} +- npc_id: NPC identifier for quest accept/decline #} {# Only show CURRENT exchange (removed conversation_history loop) #} @@ -23,6 +25,50 @@ Expected context: +{# Quest Offer Card - appears inline after NPC dialogue when quest is offered #} +{% if quest_offer %} +
+
+ 📜 + Quest Offered +
+ +
+

{{ quest_offer.quest_name }}

+

{{ quest_offer.quest_description }}

+ + {% if quest_offer.rewards %} +
+ Rewards: + {% if quest_offer.rewards.gold %} + {{ quest_offer.rewards.gold }} gold + {% endif %} + {% if quest_offer.rewards.experience %} + {{ quest_offer.rewards.experience }} XP + {% endif %} +
+ {% endif %} +
+ +
+ + +
+
+{% endif %} + {# Trigger history refresh after new message #}
+
+
+ Error +

{{ error_message | default('Something went wrong') }}

+
+
+ +{% elif action == 'accept' %} +{# Quest accepted - show success message #} +
+
+
+ Quest Accepted! +

{{ npc_name }} nods approvingly. Check your quest log for details.

+
+
+ +{# Trigger toast notification #} + + +{# Refresh quests accordion to show newly accepted quest #} +
+ +{% elif action == 'decline' %} +{# Quest declined - show confirmation message #} +
+
+
+ Quest Declined +

{{ npc_name }} understands. Perhaps another time.

+
+
+ +{# Trigger toast notification #} + + +{% endif %} diff --git a/public_web/templates/game/play.html b/public_web/templates/game/play.html index 756fd14..3a9e420 100644 --- a/public_web/templates/game/play.html +++ b/public_web/templates/game/play.html @@ -154,4 +154,7 @@ document.addEventListener('keydown', function(e) { + + + {% endblock %}