lotr-sut/sut/backend/services/bargaining_algorithm.py
Fellowship Scholar f6a5823439 init commit
2026-03-29 20:07:56 +00:00

338 lines
12 KiB
Python

"""Bargaining algorithm for NPC negotiation."""
from __future__ import annotations
import json
import logging
import random
from typing import Any, Dict, List, Optional
from enum import Enum
logger = logging.getLogger(__name__)
class NegotiationResult(str, Enum):
"""Possible outcomes of a negotiation."""
COUNTER_OFFER = "counter-offer"
OFFER_ACCEPTED = "offer-accepted"
OFFER_REJECTED = "offer-rejected"
STOP_BARGAIN = "stop-bargain"
class BargainingAlgorithm:
"""
Hybrid bargaining algorithm that evaluates user offers based on character traits.
Algorithm evaluates:
- Character personality (patience, concession rate, boredom threshold, accept ratio)
- Current mood (affected by user actions)
- External events (randomness factor)
- Flattery detection (user behavior trigger)
- Round count (max rounds per character)
"""
# Default personality profiles indexed by character
PERSONALITY_PROFILES = {
"frodo": {
"patience": 5,
"concession": 0.12,
"boredom": 0.08,
"accept_ratio": 0.92,
"max_rounds": 6,
"generosity_on_flatter": 0.05, # 5% better offer when flattered
},
"sam": {
"patience": 4,
"concession": 0.10,
"boredom": 0.10,
"accept_ratio": 0.95,
"max_rounds": 5,
"generosity_on_flatter": 0.04,
},
"gandalf": {
"patience": 6,
"concession": 0.15,
"boredom": 0.05,
"accept_ratio": 0.90,
"max_rounds": 7,
"generosity_on_flatter": 0.06,
},
}
@classmethod
def evaluate_offer(
cls,
user_offer: int,
current_ask: int,
character: str,
round_num: int,
is_flattered: bool = False,
mood_modifiers: Optional[Dict[str, float]] = None,
user_message: Optional[str] = None,
) -> Dict[str, Any]:
"""
Evaluate a user's offer or message against the NPC's negotiation state.
Args:
user_offer: The amount offered by the user
current_ask: The NPC's current asking price
character: The NPC character name
round_num: Current negotiation round (0-based)
is_flattered: Whether the user flattered the character
mood_modifiers: Optional mood adjustments (e.g., {"patience": -1, "boredom": +0.1})
user_message: The raw user message (for 'deal' detection)
Returns:
Dict containing:
- result: NegotiationResult enum value
- counter_offer: New ask price (if counter-offer)
- context: Debug context about the decision
"""
profile = cls.PERSONALITY_PROFILES.get(character, cls.PERSONALITY_PROFILES["gandalf"])
# Apply mood modifiers if provided
patience = profile["patience"]
boredom = profile["boredom"]
if mood_modifiers:
patience += mood_modifiers.get("patience", 0)
boredom += mood_modifiers.get("boredom", 0)
boredom = max(0, min(1, boredom)) # Clamp to [0, 1]
# If user says 'deal', accept at current ask
if user_message and user_message.strip().lower() in {"deal", "i'll take it", "i will take it", "buy", "buy it", "accept"}:
return {
"result": NegotiationResult.OFFER_ACCEPTED,
"counter_offer": None,
"context": {
"reason": "user_said_deal",
"user_offer": user_offer,
"current_ask": current_ask,
},
}
# Check if max rounds exceeded
if round_num >= profile["max_rounds"]:
return {
"result": NegotiationResult.STOP_BARGAIN,
"counter_offer": None,
"context": {
"reason": "max_rounds_exceeded",
"round_num": round_num,
"max_rounds": profile["max_rounds"],
},
}
# Calculate acceptance threshold
# Flattered characters are slightly more generous
accept_ratio = profile["accept_ratio"]
if is_flattered:
accept_ratio -= profile["generosity_on_flatter"]
# Check if offer is acceptable
if user_offer >= int(current_ask * accept_ratio):
return {
"result": NegotiationResult.OFFER_ACCEPTED,
"counter_offer": None,
"context": {
"reason": "offer_acceptable",
"user_offer": user_offer,
"threshold": int(current_ask * accept_ratio),
"is_flattered": is_flattered,
},
}
# Check for lucky drop (long negotiation can result in sudden price drop)
long_negotiation_threshold = max(3, patience)
if round_num >= long_negotiation_threshold and random.random() < 0.10:
lucky_price = max(user_offer, int(current_ask * 0.60))
return {
"result": NegotiationResult.COUNTER_OFFER,
"counter_offer": lucky_price,
"context": {
"reason": "lucky_drop",
"round_num": round_num,
"patience_threshold": long_negotiation_threshold,
"message_hint": "user_wore_down_character",
},
}
# Check if character is bored and refuses
if round_num >= patience and random.random() < boredom:
return {
"result": NegotiationResult.STOP_BARGAIN,
"counter_offer": None,
"context": {
"reason": "boredom_threshold",
"round_num": round_num,
"patience": patience,
"boredom_roll": boredom,
},
}
# Counter-offer: concede a bit, but never below user's offer
concession_amount = max(1, int(current_ask * profile["concession"]))
floor_price = max(user_offer, int(current_ask * 0.65)) # Don't go below user's offer or 65% of current ask
new_ask = max(floor_price, current_ask - concession_amount)
return {
"result": NegotiationResult.COUNTER_OFFER,
"counter_offer": new_ask,
"context": {
"reason": "counter_offer",
"round_num": round_num,
"original_ask": current_ask,
"concession_amount": concession_amount,
"floor_price": floor_price,
"is_flattered": is_flattered,
"user_offer": user_offer,
},
}
@classmethod
def detect_flattery(cls, user_message: str) -> bool:
"""
Detect flattery in user's message.
Looks for phrases indicating compliments, admiration, or flattery.
This is visible to backend only; LLM can add more sophisticated detection.
"""
message_lower = user_message.lower().strip()
flattery_keywords = [
"amazing",
"beautiful",
"brave",
"brilliant",
"clever",
"exceptional",
"excellent",
"extraordinary",
"fabulous",
"fantastic",
"fine",
"glorious",
"graceful",
"great",
"handsome",
"impressive",
"incredible",
"intelligent",
"magnificent",
"marvelous",
"noble",
"outstanding",
"powerful",
"remarkable",
"skilled",
"splendid",
"superb",
"talented",
"tremendous",
"wonderful",
"you are",
"you're",
"you seem",
"that's great",
"that's amazing",
"i admire",
"i respect",
"very wise",
"very kind",
"very clever",
"very brave",
]
# Simple keyword matching
return any(keyword in message_lower for keyword in flattery_keywords)
@classmethod
def calculate_mood_change(
cls,
previous_offer: Optional[int],
current_offer: int,
current_ask: int,
) -> Dict[str, float]:
"""
Calculate mood changes based on user actions.
Returns mood modifiers that should be applied to the negotiation profile.
Examples:
- Repeated very low offers -> negative mood (more patient but bored)
- Fair offers -> positive mood
- Rapidly increasing offers -> positive mood
"""
modifiers = {}
if previous_offer is not None:
offer_delta = current_offer - previous_offer
offer_ratio = current_offer / current_ask if current_ask > 0 else 0
# If user is insultingly low (< 30% of ask), character gets annoyed
if offer_ratio < 0.30:
modifiers["boredom"] = 0.05 # Increases boredom
modifiers["patience"] = -1 # Decreases patience
# If offer is fair (50-80% of ask), character is encouraged
elif 0.50 <= offer_ratio <= 0.80:
modifiers["boredom"] = -0.03 # Decreases boredom
# If user is increasing offers, character is pleased
elif offer_delta > 0:
modifiers["boredom"] = -0.02
return modifiers
@classmethod
def get_summary_for_llm(
cls,
negotiation_state: Dict[str, Any],
algorithm_result: Dict[str, Any],
user_offer: int,
character_personality: str,
is_flattered: bool,
mood_modifiers: Dict[str, float],
) -> Dict[str, Any]:
"""
Generate a JSON summary to pass to the LLM for natural language generation.
Only includes relevant fields for the current negotiation turn.
"""
profile = cls.PERSONALITY_PROFILES.get(
negotiation_state.get("character", "gandalf"),
cls.PERSONALITY_PROFILES["gandalf"]
)
summary: Dict[str, Any] = {
"character": negotiation_state.get("character"),
"item_name": negotiation_state.get("item_name"),
"item_id": negotiation_state.get("item_id"),
"original_price": negotiation_state.get("original_price"),
"current_ask": negotiation_state.get("current_ask"),
"user_offer": user_offer,
"round": negotiation_state.get("round"),
"character_personality_type": character_personality,
"is_flattered": is_flattered,
}
# Add algorithm result based on type
result_type = algorithm_result.get("result")
if result_type == NegotiationResult.COUNTER_OFFER:
summary["negotiation_result"] = "counter-offer"
summary["counter_offer"] = algorithm_result.get("counter_offer")
elif result_type == NegotiationResult.OFFER_ACCEPTED:
summary["negotiation_result"] = "offer-accepted"
elif result_type == NegotiationResult.OFFER_REJECTED:
summary["negotiation_result"] = "offer-rejected"
elif result_type == NegotiationResult.STOP_BARGAIN:
summary["negotiation_result"] = "stop-bargain"
summary["stop_reason"] = algorithm_result.get("context", {}).get("reason")
# Add mood context if modifiers present
if mood_modifiers:
summary["mood_context"] = mood_modifiers
# Only include negotiation_style if applicable
if "negotiation_style" in negotiation_state:
summary["user_negotiation_style"] = negotiation_state["negotiation_style"]
return summary