"""Azure AI powered NPC chat service for realistic LOTR-style companions.""" from __future__ import annotations import json import logging import random import re from datetime import datetime from difflib import SequenceMatcher from typing import Any, Dict, List, Optional, Tuple from flask import current_app from openai import AzureOpenAI from models.location import Location from models.quest import Quest from services.shop_service import ShopService from services.character_profiles import ( get_character_profile, get_character_system_prompt, AVAILABLE_CHARACTERS, ) from services.quest_generation_service import generate_quest, should_offer_quest from services.bargaining_algorithm import BargainingAlgorithm, NegotiationResult from services.bargaining_config import BargainingConfig from services.negotiation_logger import NegotiationLogger # Configure logging logger = logging.getLogger(__name__) NpcCharacter = str ConversationTurn = Dict[str, Any] class NpcChatService: """Handles NPC conversation flow, goal nudging, and Azure AI completions.""" _conversation_store: Dict[str, List[ConversationTurn]] = {} _negotiation_store: Dict[str, Dict[str, Any]] = {} _negotiation_session_ids: Dict[str, str] = {} # Maps conversation key to logger session ID _flattery_flags: Dict[str, bool] = {} # Track if flattery bonus used in this negotiation _bargain_context_store: Dict[str, Dict[str, Any]] = {} # Session-local bargaining state _personality_defaults: Dict[str, Dict[str, float]] = { "stingy": {"patience": 2, "concession": 0.05, "boredom": 0.20, "accept_ratio": 1.0}, "bargainer": {"patience": 4, "concession": 0.10, "boredom": 0.10, "accept_ratio": 0.95}, "generous": {"patience": 5, "concession": 0.15, "boredom": 0.05, "accept_ratio": 0.90}, "sentimental": {"patience": 6, "concession": 0.20, "boredom": 0.05, "accept_ratio": 0.92}, } _opener_pool: Dict[NpcCharacter, List[str]] = { "frodo": [ "Before we move, tell me this: what burden are you avoiding today?", "I have a feeling the smallest task might matter most today. Which one is it?", "If we could finish one thing before dusk, what should it be?", ], "sam": [ "Right then, what can we get done first so the road gets easier?", "You look ready. Which quest should we push over the line now?", "If we tidy one trouble before second breakfast, which one would you pick?", ], "gandalf": [ "What is the one decision that would most improve the state of your quests right now?", "Name the most urgent unfinished matter, and we shall act on it.", "Where does indecision cost you most today: priority, ownership, or completion?", ], } _fallback_replies: Dict[NpcCharacter, List[str]] = { "frodo": [ "I hear you. Let us take one step that lightens the load now.", "Even a small act done now can spare us greater trouble later.", ], "sam": [ "Aye, that makes sense. Let us pick one task and finish it proper.", "Good thinking. Start small, finish strong, then we move to the next.", ], "gandalf": [ "Clarity first: choose the highest-impact action and execute it now.", "Do not wait for perfect conditions. Act on the essential next step.", ], } # Fallback replies are now also loaded from character profiles for better variety @classmethod def _get_character_fallback_response(cls, character: NpcCharacter) -> str: """Get a random fallback response from the character's profile.""" profile = get_character_profile(character) responses = profile.get("fallback_responses", []) if responses: return random.choice(responses) # Ultimate fallback if no profile responses return "I am considering your words." _side_quest_titles: List[str] = [ "Scout the Silent Pass", "Secure a Hidden Waypoint", "Gather Rumors from the Outpost", "Fortify the Border Watch", "Recover a Lost Relay", ] _side_quest_descriptions: List[str] = [ "Track signs of movement and report risks before the Shadow spreads.", "Survey this path and establish a safer route for the Fellowship.", "Collect local intelligence and map any unstable zones.", "Prepare supplies and secure position lines for future quests.", ] @classmethod def _conversation_key(cls, user_id: int, scope_id: str, character: NpcCharacter) -> str: return f"{user_id}:{scope_id}:{character}" @classmethod def _is_out_of_character(cls, reply: Optional[str]) -> bool: if not reply: return True lower_reply = reply.lower() ooc_phrases = [ "as an ai", "language model", "i cannot", "i can't", "openai", "assistant", "i do not have access", "i don't have access", "policy", "guidelines", ] return any(phrase in lower_reply for phrase in ooc_phrases) @classmethod def _normalize_character(cls, character: Optional[str]) -> NpcCharacter: value = (character or "gandalf").strip().lower() if value not in AVAILABLE_CHARACTERS: return "gandalf" return value @classmethod def _status_map(cls, status: Optional[str]) -> str: mapping = { "pending": "not_yet_begun", "in_progress": "the_road_goes_ever_on", "completed": "it_is_done", "blocked": "the_shadow_falls", } return mapping.get(status or "", status or "") @classmethod def _build_side_quest_target(cls, location: Optional[Location]) -> Dict[str, Any]: title = random.choice(cls._side_quest_titles) description = random.choice(cls._side_quest_descriptions) quest_type = random.choice(["The Journey", "The Fellowship", "The Battle"]) priority = random.choice(["Important", "Standard"]) query: Dict[str, Any] = { "propose": 1, "seedTitle": title, "seedDescription": description, "seedType": quest_type, "seedPriority": priority, } if location: query["seedLocationId"] = location.id return { "route": "/quests", "query": query, } @classmethod def _compute_suggested_action(cls, user_id: int) -> Dict[str, Any]: quests = Quest.query.all() dark_magic = [ quest for quest in quests if quest.is_dark_magic and cls._status_map(quest.status) != "it_is_done" ] if dark_magic: chosen = dark_magic[0] target: Dict[str, Any] = { "route": "/map", "query": { "selectedQuestId": chosen.id, }, } if chosen.location_id: target["query"]["zoomToLocation"] = chosen.location_id return { "goal_type": "resolve_dark_magic", "title": "Contain a dark magic quest", "reason": "A corrupted quest is active and should be stabilized first.", "target": target, } in_progress = [ quest for quest in quests if cls._status_map(quest.status) == "the_road_goes_ever_on" ] critical_in_progress = [quest for quest in in_progress if (quest.priority or "") == "Critical"] if critical_in_progress: chosen = critical_in_progress[0] return { "goal_type": "finish_critical_in_progress", "title": "Finish a critical in-progress quest", "reason": "You already started a critical objective; finishing it unlocks momentum.", "target": { "quest_id": chosen.id, "route": "/quests", "query": { "status": "the_road_goes_ever_on", "focusQuestId": chosen.id, }, }, } unassigned_critical = [ quest for quest in quests if (quest.priority or "") == "Critical" and not quest.assigned_to ] if unassigned_critical: chosen = unassigned_critical[0] return { "goal_type": "assign_critical", "title": "Assign an unowned critical quest", "reason": "Critical objectives without an owner tend to stall quickly.", "target": { "quest_id": chosen.id, "route": "/quests", "query": { "focusQuestId": chosen.id, }, }, } not_started_with_location = [ quest for quest in quests if cls._status_map(quest.status) == "not_yet_begun" and quest.location_id ] if not_started_with_location: chosen = not_started_with_location[0] return { "goal_type": "scout_map_hotspot", "title": "Scout a location with pending objectives", "reason": "Exploring the map hotspot first makes it easier to choose a smart next move.", "target": { "quest_id": chosen.id, "route": "/map", "query": { "selectedQuestId": chosen.id, "zoomToLocation": chosen.location_id, }, }, } available = [quest for quest in quests if cls._status_map(quest.status) != "it_is_done"] if available: chosen = available[0] return { "goal_type": "advance_next_quest", "title": "Advance the next unfinished quest", "reason": "Progress compounds when one unfinished objective moves forward.", "target": { "quest_id": chosen.id, "route": "/quests", "query": { "focusQuestId": chosen.id, }, }, } location = Location.query.first() return { "goal_type": "propose_side_quest", "title": "Propose a new side quest", "reason": "All tracked quests are complete; create a fresh objective to keep momentum alive.", "target": cls._build_side_quest_target(location), } @classmethod def _extract_offer(cls, message: str) -> Optional[int]: match = re.search(r"(\d{1,6})", message) if not match: return None try: return int(match.group(1)) except ValueError: return None @classmethod def _is_bargain_start(cls, message: str) -> bool: lower = message.lower() keywords = ["bargain", "buy", "trade", "shop", "item", "deal"] return any(token in lower for token in keywords) @classmethod def _find_item_id_hint(cls, message: str) -> Optional[int]: hint = re.search(r"#(\d+)", message) if not hint: return None try: return int(hint.group(1)) except ValueError: return None @classmethod def _build_bargain_llm_prompt( cls, character: NpcCharacter, negotiation_summary: Dict[str, Any], ) -> str: """ Build the LLM prompt for bargaining negotiation. The LLM receives the algorithm's result and should: 1. Rephrase and justify the result naturally 2. Stay in character 3. Acknowledge flattery if applicable 4. Reference item qualities and in-world context 5. Try to persuade user to accept the offer """ char_profile = get_character_profile(character) personality_traits = ", ".join(char_profile.get("personality", [])) result = negotiation_summary.get("negotiation_result", "") prompt = ( f"You are {char_profile.get('full_name', character)}, a character in Middle-earth. " f"Your personality traits: {personality_traits}. " f"\n\nYou are negotiating over item: {negotiation_summary.get('item_name', 'an item')}. " ) if negotiation_summary.get("is_flattered"): prompt += "\nThe user just flattered you—acknowledge this naturally and favorably. " if result == "counter-offer": counter = negotiation_summary.get("counter_offer") prompt += ( f"\nYou are making a counter-offer of {counter} gold. " f"The user offered {negotiation_summary.get('user_offer')} gold (you originally asked {negotiation_summary.get('current_ask')}). " f"Justify this counter-offer, reference the item's importance to you, " f"and subtly persuade the user to accept. Stay brief (1-2 sentences). " f"Stay in character. Do NOT mention the negotiation mechanics or rounds." ) elif result == "offer-accepted": prompt += ( f"\nThe user's offer of {negotiation_summary.get('user_offer')} gold is acceptable! " f"Express satisfaction, perhaps acknowledge their negotiation skill, " f"and finalize the deal in character. Stay brief (1 sentence)." ) elif result == "offer-rejected": prompt += ( f"\nThe user's offer of {negotiation_summary.get('user_offer')} gold is too low. " f"You originally asked {negotiation_summary.get('current_ask')} gold. " f"Express disappointment or frustration (in character) and encourage them to do better. " f"Stay brief (1-2 sentences)." ) elif result == "stop-bargain": stop_reason = negotiation_summary.get("stop_reason", "") if stop_reason == "boredom_threshold": prompt += ( f"\nYou are done haggling. You are bored and offended by this negotiation. " f"Exit the negotiation angrily but in character. Stay brief (1 sentence). " f"Do NOT offer further negotiation." ) elif stop_reason == "max_rounds_exceeded": prompt += ( f"\nYou've spent enough time on this negotiation. " f"Tell the user you're done discussing price and walk away in character. " f"Stay brief (1 sentence)." ) return prompt @classmethod def _complete_bargaining_with_llm( cls, character: NpcCharacter, negotiation_summary: Dict[str, Any], ) -> Optional[str]: """ Generate a natural language response for bargaining using LLM. Takes the algorithm's structured result and asks LLM to generate an in-character response that justifies and rephrases it. """ deployment = current_app.config.get("AZURE_OPENAI_DEPLOYMENT", "") if not deployment: return None client = cls._new_client() if client is None: return None prompt = cls._build_bargain_llm_prompt(character, negotiation_summary) messages: List[Dict[str, str]] = [ { "role": "system", "content": ( f"You are {negotiation_summary.get('character')}, a LOTR character. " "Negotiate over items in-character, naturally and briefly. " "Never break character or mention system details." ) }, { "role": "user", "content": prompt } ] try: max_tokens = current_app.config.get("AZURE_OPENAI_MAX_TOKENS", 150) temperature = current_app.config.get("AZURE_OPENAI_TEMPERATURE", 0.85) completion = client.chat.completions.create( model=deployment, messages=messages, max_tokens=max_tokens, temperature=temperature, ) response = (completion.choices[0].message.content or "").strip() if response: logger.info(f"✓ Bargaining LLM generated response for {character}") # Log the LLM interaction session_id = cls._negotiation_session_ids.get( f"{negotiation_summary.get('item_id')}:{character}" ) if session_id: NegotiationLogger.log_llm_interaction( session_id=session_id, llm_input_summary=negotiation_summary, llm_output=response ) return response or None except Exception as e: logger.error(f"✗ Bargaining LLM failed for {character}: {type(e).__name__}: {str(e)}") return None @classmethod def _build_negotiation_state(cls, selected_character: NpcCharacter, item: Dict[str, Any]) -> Dict[str, Any]: profile_name = item.get("personality_profile", "bargainer") personality = cls._personality_defaults.get(profile_name, cls._personality_defaults["bargainer"]) char_config = BargainingConfig.get_character_config(selected_character) max_rounds = int(char_config.get("max_rounds", int(personality["patience"]))) return { "item_id": item["id"], "item_name": item["name"], "owner_character": item["owner_character"], "personality_profile": profile_name, "current_ask": int(item["asking_price"]), "round": 0, "patience": int(personality["patience"]), "max_rounds": max_rounds, "concession": float(personality["concession"]), "boredom": float(personality["boredom"]), "accept_ratio": float(personality["accept_ratio"]), "status": "active", "character": selected_character, } @classmethod def _build_chat_message( cls, role: str, content: str, message_type: Optional[str] = None, fmt: str = "markdown", metadata: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: payload: Dict[str, Any] = { "role": role, "content": content, "type": message_type or role, "format": fmt, } if metadata: payload["metadata"] = metadata return payload @classmethod def _build_item_catalog_markdown( cls, items: List[Dict[str, Any]], heading: str = "Available wares to bargain for:", ) -> str: if not items: return "No wares remain for this trader. Try another character marker on the map." lines = [heading] for item in items: lines.append(f"- **{item['name']}** — ask **{int(item['asking_price'])} Gold** (id: #{item['id']})") return "\n".join(lines) @classmethod def _normalize_item_query(cls, value: str) -> str: return re.sub(r"[^a-z0-9]+", " ", (value or "").lower()).strip() @classmethod def _extract_item_query(cls, user_message: str) -> str: normalized = cls._normalize_item_query(user_message) cleaned = re.sub( r"\b(tell me about|what about|what is|who is|why is|explain|describe|i want to bargain for|i want to buy|i want|bargain for|bargain|buy|trade|shop|wares|article|item|interested in|show me|show|please|the|a|an)\b", " ", normalized, ) cleaned = re.sub(r"\s+", " ", cleaned).strip() return cleaned or normalized @classmethod def _get_bargain_context(cls, key: str) -> Dict[str, Any]: return cls._bargain_context_store.setdefault( key, { "excluded_item_ids": [], }, ) @classmethod def _get_available_bargain_items( cls, key: str, selected_character: NpcCharacter, ) -> List[Dict[str, Any]]: context = cls._get_bargain_context(key) excluded_ids = set(context.get("excluded_item_ids", [])) items = ShopService.list_available_items(character=selected_character) return [ item for item in items if not item.get("is_sold", False) and item.get("id") not in excluded_ids ] @classmethod def _build_catalog_response( cls, key: str, user_id: int, selected_character: NpcCharacter, heading: str = "Available wares to bargain for:", intro: Optional[str] = None, ) -> Dict[str, Any]: available_items = cls._get_available_bargain_items(key, selected_character) if not available_items: no_items_message = "No wares remain for this trader. Try another character marker on the map." return { "message": no_items_message, "message_payload": cls._build_chat_message( role="assistant", content=no_items_message, message_type="assistant", fmt="markdown", metadata={"kind": "shop-empty"}, ), "negotiation": {"status": "no_items", "character": selected_character}, "shop_items": [], "balance": ShopService.get_balance(user_id), } catalog_text = cls._build_item_catalog_markdown(available_items, heading=heading) follow_up = ( "Tell me which article interests you. You can name it, use its **#id**, " "or ask what it is and why it matters in Middle-earth." ) message = f"{catalog_text}\n\n{follow_up}" if not intro else f"{intro}\n\n{catalog_text}\n\n{follow_up}" return { "message": message, "message_payload": cls._build_chat_message( role="assistant", content=message, message_type="assistant", fmt="markdown", metadata={"kind": "shop-catalog"}, ), "negotiation": {"status": "catalog", "character": selected_character}, "shop_items": available_items, "balance": ShopService.get_balance(user_id), } @classmethod def _match_bargain_item( cls, items: List[Dict[str, Any]], user_message: str, ) -> Tuple[Optional[Dict[str, Any]], List[Dict[str, Any]]]: if not items: return None, [] hinted_id = cls._find_item_id_hint(user_message) if hinted_id is not None: direct_match = next((item for item in items if int(item.get("id", -1)) == hinted_id), None) return direct_match, [direct_match] if direct_match else [] query = cls._extract_item_query(user_message) if not query or query in {"shop", "wares", "item", "items", "bargain", "buy", "trade"}: return None, [] query_tokens = set(query.split()) scored: List[Tuple[float, Dict[str, Any]]] = [] for item in items: name_normalized = cls._normalize_item_query(item.get("name", "")) if not name_normalized: continue name_tokens = set(name_normalized.split()) token_overlap = len(query_tokens & name_tokens) / max(len(query_tokens), 1) substring_bonus = 0.35 if query and query in name_normalized else 0.0 prefix_bonus = 0.15 if query and name_normalized.startswith(query) else 0.0 ratio = SequenceMatcher(None, query, name_normalized).ratio() score = max(ratio, min(1.0, token_overlap + substring_bonus + prefix_bonus)) if score >= 0.4: scored.append((score, item)) if not scored: return None, [] scored.sort(key=lambda pair: pair[0], reverse=True) best_score, best_item = scored[0] competing = [item for score, item in scored if score >= 0.6 and abs(best_score - score) < 0.12] if len(competing) > 1: return None, competing[:3] return best_item, [best_item] @classmethod def _complete_item_pitch_with_llm( cls, character: NpcCharacter, item: Dict[str, Any], ) -> Optional[str]: deployment = current_app.config.get("AZURE_OPENAI_DEPLOYMENT", "") if not deployment: return None client = cls._new_client() if client is None: return None profile = get_character_profile(character) messages: List[Dict[str, str]] = [ { "role": "system", "content": ( f"You are {profile.get('full_name', character)}, a trader in Middle-earth. " "Answer in character. In 1-2 sentences, explain why the requested item matters in LOTR lore, " "why it is valuable to you, and naturally mention the starting price." ), }, { "role": "user", "content": ( f"The user asked about '{item.get('name')}'. " f"Description: {item.get('description') or 'No description given.'} " f"Starting ask: {int(item.get('asking_price', 0))} Gold." ), }, ] try: completion = client.chat.completions.create( model=deployment, messages=messages, max_tokens=current_app.config.get("AZURE_OPENAI_MAX_TOKENS", 180), temperature=current_app.config.get("AZURE_OPENAI_TEMPERATURE", 0.85), ) content = (completion.choices[0].message.content or "").strip() return content or None except Exception as error: logger.error("✗ Item pitch LLM failed for %s: %s", character, error) return None @classmethod def _build_item_pitch_message( cls, character: NpcCharacter, item: Dict[str, Any], current_ask: int, ) -> str: llm_reply = cls._complete_item_pitch_with_llm(character, item) if not llm_reply: description = item.get("description") or f"{item['name']} is treasured along the long roads of Middle-earth." llm_reply = ( f"**{item['name']}** matters in Middle-earth because {description.rstrip('.')}" f". I would start at **{current_ask} Gold**." ) if f"**{current_ask} Gold**" not in llm_reply: llm_reply = f"{llm_reply.rstrip()}\n\nStarting ask: **{current_ask} Gold**." return ( f"{llm_reply.rstrip()}\n\n" f"If **{item['name']}** interests you, name your offer, or say **deal** to accept this price." ) @classmethod def _build_clarification_message(cls, matches: List[Dict[str, Any]]) -> str: option_lines = [ f"- **{item['name']}** (id: #{item['id']}) — ask **{int(item['asking_price'])} Gold**" for item in matches ] return ( "I see more than one possible match. Which of these wares did you mean?\n\n" + "\n".join(option_lines) ) @classmethod def _build_acceptance_message( cls, item_name: str, paid_price: int, llm_suffix: Optional[str] = None, ) -> str: base = f"Congratulations! **{item_name}** is yours for **{paid_price} Gold**." if llm_suffix: return f"{base}\n\n{llm_suffix.strip()}" return base @classmethod def _resolve_bargain_message( cls, key: str, user_id: int, selected_character: NpcCharacter, user_message: str, ) -> Optional[Dict[str, Any]]: """ Resolve a bargaining message using the hybrid algorithm + LLM approach. 1. Algorithm evaluates the offer 2. LLM generates natural language response 3. Logs the negotiation """ negotiation = cls._negotiation_store.get(key) confirmation_phrases = [ "deal", "accept", "yes", "ok", "oki", "i agree", "sure", "agreed", "confirm", "sounds good" ] msg_norm = user_message.strip().lower() has_bargain_context = key in cls._bargain_context_store direct_item_inquiry = ( cls._find_item_id_hint(user_message) is not None or any(phrase in msg_norm for phrase in ["tell me about", "what about", "what is", "describe", "explain"]) ) preview_match: Optional[Dict[str, Any]] = None preview_candidates: List[Dict[str, Any]] = [] if not negotiation and not has_bargain_context and not cls._is_bargain_start(user_message): preview_items = ShopService.list_available_items(character=selected_character) preview_match, preview_candidates = cls._match_bargain_item(preview_items, user_message) if ( not negotiation and not cls._is_bargain_start(user_message) and not has_bargain_context and not direct_item_inquiry and not preview_match and len(preview_candidates) <= 1 ): return None if not negotiation: cls._get_bargain_context(key) available_items = cls._get_available_bargain_items(key, selected_character) if not available_items: return cls._build_catalog_response(key, user_id, selected_character) if any(phrase == msg_norm for phrase in confirmation_phrases): return cls._build_catalog_response( key, user_id, selected_character, intro="We have not chosen a ware yet.", ) selected_item, candidate_items = cls._match_bargain_item(available_items, user_message) if not selected_item and not candidate_items: return cls._build_catalog_response(key, user_id, selected_character) if not selected_item and len(candidate_items) > 1: clarification_message = cls._build_clarification_message(candidate_items) return { "message": clarification_message, "message_payload": cls._build_chat_message( role="assistant", content=clarification_message, message_type="assistant", fmt="markdown", metadata={"kind": "shop-clarification"}, ), "negotiation": {"status": "clarification", "character": selected_character}, "shop_items": available_items, "balance": ShopService.get_balance(user_id), } chosen_item = selected_item negotiation = cls._build_negotiation_state(selected_character, chosen_item) negotiation["original_price"] = int(chosen_item["asking_price"]) cls._negotiation_store[key] = negotiation session_id = NegotiationLogger.log_negotiation_start( character=selected_character, item_id=chosen_item["id"], item_name=chosen_item["name"], original_price=int(chosen_item["asking_price"]) ) cls._negotiation_session_ids[key] = session_id cls._flattery_flags[key] = False opening_message = cls._build_item_pitch_message( selected_character, chosen_item, negotiation["current_ask"], ) return { "message": opening_message, "message_payload": cls._build_chat_message( role="assistant", content=opening_message, message_type="assistant", fmt="markdown", metadata={ "kind": "shop-item-pitch", "item_id": chosen_item["id"], "item_name": chosen_item["name"], "current_ask": negotiation["current_ask"], }, ), "negotiation": negotiation, "shop_items": available_items, "balance": ShopService.get_balance(user_id), } # Extract offer from message offer = cls._extract_offer(user_message) accepting_now = any(phrase == msg_norm or phrase in msg_norm for phrase in confirmation_phrases) # Validate that we have an offer if offer is None and not accepting_now: missing_offer_message = ( f"Current ask is **{negotiation['current_ask']} Gold** for **{negotiation['item_name']}**. " "Reply with a numeric offer or say **deal**." ) return { "message": missing_offer_message, "message_payload": cls._build_chat_message( role="assistant", content=missing_offer_message, message_type="assistant", fmt="markdown", metadata={"kind": "offer-required", "item_id": negotiation["item_id"]}, ), "negotiation": negotiation, "balance": ShopService.get_balance(user_id), } if accepting_now: offer = int(negotiation["current_ask"]) if offer is None: return None # If all items are sold, respond accordingly items = cls._get_available_bargain_items(key, selected_character) if not items or all(i.get("is_sold", False) for i in items): return { "message": "No wares remain. All items are sold out. Farewell, friend!", "message_payload": cls._build_chat_message( role="assistant", content="No wares remain. All items are sold out. Farewell, friend!", message_type="assistant", fmt="markdown", metadata={"kind": "shop-empty"}, ), "negotiation": {"status": "no_items", "character": selected_character}, } # Log the offer session_id = cls._negotiation_session_ids.get(key) if session_id: NegotiationLogger.log_offer_made( session_id=session_id, round_num=negotiation["round"], user_offer=offer, current_ask=negotiation["current_ask"], is_flattered=cls._flattery_flags.get(key, False) ) # Detect flattery is_flattered = ( BargainingAlgorithm.detect_flattery(user_message) and not cls._flattery_flags.get(key, False) ) if is_flattered: cls._flattery_flags[key] = True # Mark flattery as used if session_id: NegotiationLogger.log_behavior_detected(session_id, "flattery") # Calculate mood modifiers based on user behavior previous_offer = negotiation.get("previous_offer") mood_modifiers = BargainingAlgorithm.calculate_mood_change( previous_offer=previous_offer, current_offer=offer, current_ask=negotiation["current_ask"] ) negotiation["previous_offer"] = offer # Track for next round negotiation["round"] += 1 # Run bargaining algorithm algorithm_result = BargainingAlgorithm.evaluate_offer( user_offer=offer, current_ask=negotiation["current_ask"], character=selected_character, round_num=negotiation["round"], is_flattered=is_flattered, mood_modifiers=mood_modifiers if mood_modifiers else None ) # Log algorithm result if session_id: NegotiationLogger.log_algorithm_result( session_id=session_id, result_type=algorithm_result["result"].value, context=algorithm_result["context"] ) result_type = algorithm_result["result"] # Handle OFFER_ACCEPTED if result_type == NegotiationResult.OFFER_ACCEPTED: try: purchase = ShopService.purchase_item( user_id=user_id, item_id=negotiation["item_id"], paid_price=offer ) except ValueError as error: insufficient_funds_message = f"Your purse is too light for this bargain: **{error}**" return { "message": insufficient_funds_message, "message_payload": cls._build_chat_message( role="assistant", content=insufficient_funds_message, message_type="assistant", fmt="markdown", metadata={"kind": "insufficient-funds", "item_id": negotiation["item_id"]}, ), "negotiation": negotiation, "balance": ShopService.get_balance(user_id), } negotiation["status"] = "accepted" cls._negotiation_store.pop(key, None) cls._flattery_flags.pop(key, None) # Log the successful negotiation if session_id: NegotiationLogger.log_negotiation_end( session_id=session_id, final_status="accepted", final_price=offer, rounds_taken=negotiation["round"] ) # Generate LLM response summary = BargainingAlgorithm.get_summary_for_llm( negotiation_state={ "character": selected_character, "item_name": negotiation["item_name"], "item_id": negotiation["item_id"], "original_price": negotiation.get("original_price", negotiation["current_ask"]), "current_ask": negotiation["current_ask"], "round": negotiation["round"], }, algorithm_result=algorithm_result, user_offer=offer, character_personality=negotiation.get("personality_profile", "bargainer"), is_flattered=is_flattered, mood_modifiers=mood_modifiers or {} ) npc_reply = cls._complete_bargaining_with_llm(selected_character, summary) if not npc_reply: npc_reply = ( f"Agreed at **{offer} Gold**. The true price was **{purchase['purchase']['base_price_revealed']} Gold**. " f"Deal score: **{purchase['purchase']['savings_percent']:+.2f}%**" ) acceptance_message = cls._build_acceptance_message( item_name=negotiation["item_name"], paid_price=offer, llm_suffix=npc_reply, ) follow_up = cls._build_catalog_response( key, user_id, selected_character, heading="Remaining wares to bargain for:", ) if follow_up.get("negotiation", {}).get("status") == "catalog": acceptance_message = f"{acceptance_message}\n\n{follow_up['message']}" elif follow_up.get("negotiation", {}).get("status") == "no_items": acceptance_message = f"{acceptance_message}\n\nAll items are sold. Farewell, friend!" return { "message": acceptance_message, "message_payload": cls._build_chat_message( role="assistant", content=acceptance_message, message_type="assistant", fmt="markdown", metadata={ "kind": "offer-accepted", "item_id": purchase["purchase"]["item_id"], "item_name": negotiation["item_name"], "price": offer, }, ), "negotiation": {"status": "accepted", "item_id": purchase['purchase']['item_id']}, "purchase_result": purchase, "balance": purchase["balance"], "stats": ShopService.get_user_stats(user_id), "shop_items": follow_up.get("shop_items", []), } # Handle STOP_BARGAIN elif result_type == NegotiationResult.STOP_BARGAIN: negotiation["status"] = "stop-bargain" cls._negotiation_store.pop(key, None) cls._flattery_flags.pop(key, None) context = cls._get_bargain_context(key) excluded_item_ids = set(context.get("excluded_item_ids", [])) excluded_item_ids.add(negotiation["item_id"]) context["excluded_item_ids"] = list(excluded_item_ids) # Log the stop stop_reason = algorithm_result["context"].get("reason", "unknown") if session_id: NegotiationLogger.log_negotiation_end( session_id=session_id, final_status="stopped", final_price=None, rounds_taken=negotiation["round"] ) # Generate LLM response for stopping summary = BargainingAlgorithm.get_summary_for_llm( negotiation_state={ "character": selected_character, "item_name": negotiation["item_name"], "item_id": negotiation["item_id"], "original_price": negotiation.get("original_price", negotiation["current_ask"]), "current_ask": negotiation["current_ask"], "round": negotiation["round"], }, algorithm_result=algorithm_result, user_offer=offer, character_personality=negotiation.get("personality_profile", "bargainer"), is_flattered=is_flattered, mood_modifiers=mood_modifiers or {} ) npc_reply = cls._complete_bargaining_with_llm(selected_character, summary) if not npc_reply: if stop_reason == "boredom_threshold": npc_reply = "I am bored of haggling. No sale this time." else: npc_reply = "We are finished haggling." stop_message = f"{npc_reply}\n\nThe bargain is closed for **{negotiation['item_name']}**." follow_up = cls._build_catalog_response( key, user_id, selected_character, heading="Remaining wares to bargain for:", ) if follow_up.get("negotiation", {}).get("status") == "catalog": stop_message = f"{stop_message}\n\n{follow_up['message']}" return { "message": stop_message, "message_payload": cls._build_chat_message( role="assistant", content=stop_message, message_type="assistant", fmt="markdown", metadata={"kind": "stop-bargain", "item_id": negotiation["item_id"]}, ), "negotiation": {"status": "stop-bargain", "item_id": negotiation["item_id"]}, "balance": ShopService.get_balance(user_id), "shop_items": follow_up.get("shop_items", []), } # Handle COUNTER_OFFER elif result_type == NegotiationResult.COUNTER_OFFER: new_ask = algorithm_result["counter_offer"] negotiation["current_ask"] = new_ask # Generate LLM response for counter-offer summary = BargainingAlgorithm.get_summary_for_llm( negotiation_state={ "character": selected_character, "item_name": negotiation["item_name"], "item_id": negotiation["item_id"], "original_price": negotiation.get("original_price", new_ask), "current_ask": new_ask, "round": negotiation["round"], }, algorithm_result=algorithm_result, user_offer=offer, character_personality=negotiation.get("personality_profile", "bargainer"), is_flattered=is_flattered, mood_modifiers=mood_modifiers or {} ) npc_reply = cls._complete_bargaining_with_llm(selected_character, summary) if not npc_reply: context = algorithm_result.get("context", {}) if context.get("reason") == "lucky_drop": npc_reply = f"You wore me down. Rare mercy: **{new_ask} Gold** and not a coin less." else: npc_reply = ( f"Too low. I can move to **{new_ask} Gold** for **{negotiation['item_name']}**." ) if "**" not in npc_reply: npc_reply = f"{npc_reply}\n\nCurrent ask: **{new_ask} Gold**." return { "message": npc_reply, "message_payload": cls._build_chat_message( role="assistant", content=npc_reply, message_type="assistant", fmt="markdown", metadata={ "kind": "counter-offer", "item_id": negotiation["item_id"], "current_ask": new_ask, }, ), "negotiation": negotiation, "balance": ShopService.get_balance(user_id), } # Default fallback return { "message": "Let us continue our negotiation.", "message_payload": cls._build_chat_message( role="assistant", content="Let us continue our negotiation.", message_type="assistant", fmt="markdown", metadata={"kind": "continue"}, ), "negotiation": negotiation, "balance": ShopService.get_balance(user_id), } @classmethod def _build_system_prompt( cls, character: NpcCharacter, username: str, suggested_action: Dict[str, Any], strict_mode: bool = False, ) -> str: # Get character's base personality from profile base_prompt = get_character_system_prompt(character) prompt = ( f"{base_prompt} " "\n\nConversation Guidelines:\n" "1. Respond in 1-3 paragraphs naturally—adapt tone to what the user shares.\n" "2. Reference specific things the user mentioned to show you're truly listening.\n" "3. Ask thoughtful follow-up questions that deepen understanding, not generic prompts.\n" "4. Subtly hint at quest opportunities when the user mentions challenges or goals.\n" "5. Never use movie quotes directly; instead, speak authentically in character.\n" "6. Avoid breaking character or mentioning system/AI aspects.\n" f"\nContext: Conversing with {username}.\n" f"Current suggested direction: {suggested_action.get('title', 'Unclear')}.\n" f"Reason: {suggested_action.get('reason', 'No guidance yet')}." ) if strict_mode: prompt += ( "\n\nSTRICT MODE: Respond ONLY as the character. No meta-commentary, " "no breaking character, no AI references. Be concise and action-focused. " "If the user seems stuck or overwhelmed, gently suggest a quest that fits their situation." ) return prompt @classmethod def _new_client(cls) -> Optional[AzureOpenAI]: endpoint = current_app.config.get("AZURE_OPENAI_ENDPOINT", "") api_key = current_app.config.get("AZURE_OPENAI_API_KEY", "") api_version = current_app.config.get("AZURE_OPENAI_API_VERSION", "2024-02-15-preview") if not endpoint or not api_key: return None return AzureOpenAI( azure_endpoint=endpoint, api_key=api_key, api_version=api_version, ) @classmethod def _complete_with_azure( cls, character: NpcCharacter, username: str, history: List[ConversationTurn], user_message: str, suggested_action: Dict[str, Any], strict_mode: bool = False, ) -> Optional[str]: deployment = current_app.config.get("AZURE_OPENAI_DEPLOYMENT", "") max_tokens = current_app.config.get("AZURE_OPENAI_MAX_TOKENS", 220) temperature = current_app.config.get("AZURE_OPENAI_TEMPERATURE", 0.85) if not deployment: return None client = cls._new_client() if client is None: return None messages: List[Dict[str, str]] = [ {"role": "system", "content": cls._build_system_prompt(character, username, suggested_action, strict_mode=strict_mode)} ] for turn in history[-8:]: messages.append({"role": turn["role"], "content": turn["content"]}) messages.append({"role": "user", "content": user_message}) try: completion = client.chat.completions.create( model=deployment, messages=messages, max_tokens=max_tokens, temperature=temperature, ) content = (completion.choices[0].message.content or "").strip() if content: logger.info(f"✓ Azure OpenAI generated response for {character}") return content or None except Exception as e: logger.error(f"✗ Azure OpenAI failed for {character}: {type(e).__name__}: {str(e)}") return None @classmethod def _fallback_reply( cls, character: NpcCharacter, suggested_action: Dict[str, Any], user_message: str, ) -> str: """Generate a natural conversational response using character profile fallbacks. This method returns authentic character responses that vary and feel natural, rather than always appending action suggestions. The suggested_action is displayed separately in the UI. """ return cls._get_character_fallback_response(character) @classmethod def start_conversation( cls, user_id: int, username: str, character: Optional[str], scope_id: str = "", ) -> Dict[str, Any]: selected_character = cls._normalize_character(character) key = cls._conversation_key(user_id, scope_id, selected_character) # Clear bargain context so excluded_item_ids don't carry over from previous sessions cls._bargain_context_store.pop(key, None) suggested_action = cls._compute_suggested_action(user_id) # Check if this character has items available for bargaining. # If yes, open the conversation with the wares catalog instead of a generic opener # so the first message the buyer sees is the tradeable items list. available_items = ShopService.list_available_items(character=selected_character) if available_items: catalog_response = cls._build_catalog_response(key, user_id, selected_character) catalog_message = catalog_response.get("message_payload") or cls._build_chat_message( role="assistant", content=catalog_response.get("message", ""), message_type="assistant", fmt="markdown", metadata={"kind": "shop-catalog"}, ) cls._conversation_store[key] = [catalog_message] return { "conversation_id": key, "character": selected_character, "opener": catalog_response.get("message", ""), "suggested_action": suggested_action, "messages": cls._conversation_store[key], "timestamp": datetime.utcnow().isoformat(), } # Fallback to regular opener when character has no items opener = random.choice(cls._opener_pool[selected_character]) cls._conversation_store[key] = [ cls._build_chat_message( role="assistant", content=opener, message_type="assistant", fmt="markdown", metadata={"kind": "opener"}, ) ] return { "conversation_id": key, "character": selected_character, "opener": opener, "suggested_action": suggested_action, "messages": cls._conversation_store[key], "timestamp": datetime.utcnow().isoformat(), } @classmethod def send_message( cls, user_id: int, username: str, character: Optional[str], user_message: str, scope_id: str = "", ) -> Dict[str, Any]: selected_character = cls._normalize_character(character) key = cls._conversation_key(user_id, scope_id, selected_character) if key not in cls._conversation_store: if cls._is_bargain_start(user_message): cls._conversation_store[key] = [] else: cls.start_conversation(user_id, username, selected_character, scope_id=scope_id) bargain_result = cls._resolve_bargain_message( key=key, user_id=user_id, selected_character=selected_character, user_message=user_message, ) if bargain_result: npc_reply = bargain_result.get("message", "Let us continue.") history = cls._conversation_store.get(key, []) assistant_payload = bargain_result.get("message_payload") or cls._build_chat_message( role="assistant", content=npc_reply, message_type="assistant", fmt="markdown", ) updated = history + [ cls._build_chat_message( role="user", content=user_message.strip(), message_type="user", fmt="markdown", ), assistant_payload, ] cls._conversation_store[key] = updated[-20:] result: Dict[str, Any] = { "conversation_id": key, "character": selected_character, "message": npc_reply, "suggested_action": cls._compute_suggested_action(user_id), "messages": cls._conversation_store[key], "timestamp": datetime.utcnow().isoformat(), } if "negotiation" in bargain_result: result["negotiation"] = bargain_result["negotiation"] if "shop_items" in bargain_result: result["shop_items"] = bargain_result["shop_items"] if "balance" in bargain_result: result["balance"] = bargain_result["balance"] if "purchase_result" in bargain_result: result["purchase_result"] = bargain_result["purchase_result"] if "stats" in bargain_result: result["stats"] = bargain_result["stats"] return result suggested_action = cls._compute_suggested_action(user_id) history = cls._conversation_store.get(key, []) npc_reply = cls._complete_with_azure( character=selected_character, username=username, history=history, user_message=user_message, suggested_action=suggested_action, ) if npc_reply and cls._is_out_of_character(npc_reply): npc_reply = cls._complete_with_azure( character=selected_character, username=username, history=history, user_message=user_message, suggested_action=suggested_action, strict_mode=True, ) if not npc_reply or cls._is_out_of_character(npc_reply): npc_reply = cls._fallback_reply(selected_character, suggested_action, user_message) updated = history + [ cls._build_chat_message( role="user", content=user_message.strip(), message_type="user", fmt="markdown", ), cls._build_chat_message( role="assistant", content=npc_reply, message_type="assistant", fmt="markdown", ), ] cls._conversation_store[key] = updated[-20:] # Determine if a quest should be offered suggested_quest = None turn_count = len(updated) // 2 # Approximate conversation turn count if should_offer_quest(user_message, turn_count): generated_quest = generate_quest( character=selected_character, user_message=user_message, conversation_history=updated[-6:], ) if generated_quest: suggested_quest = generated_quest result = { "conversation_id": key, "character": selected_character, "message": npc_reply, "suggested_action": suggested_action, "messages": cls._conversation_store[key], "timestamp": datetime.utcnow().isoformat(), } # Add suggested quest if one was generated if suggested_quest: result["suggested_quest"] = suggested_quest return result @classmethod def get_session( cls, user_id: int, character: Optional[str], scope_id: str = "", ) -> Dict[str, Any]: selected_character = cls._normalize_character(character) key = cls._conversation_key(user_id, scope_id, selected_character) return { "conversation_id": key, "character": selected_character, "messages": cls._conversation_store.get(key, []), "suggested_action": cls._compute_suggested_action(user_id), "negotiation": cls._negotiation_store.get(key), "balance": ShopService.get_balance(user_id), } @classmethod def reset_session(cls, user_id: int, character: Optional[str], scope_id: str = "") -> Dict[str, Any]: selected_character = cls._normalize_character(character) key = cls._conversation_key(user_id, scope_id, selected_character) cls._conversation_store.pop(key, None) cls._negotiation_store.pop(key, None) cls._negotiation_session_ids.pop(key, None) cls._flattery_flags.pop(key, None) cls._bargain_context_store.pop(key, None) return { "conversation_id": key, "character": selected_character, "messages": [], "reset": True, }