338 lines
12 KiB
Python
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
|