"""Relationship affinity / stage / memory (R6).""" from __future__ import annotations from typing import Dict, List, Optional STAGE_ORDER = ["STRANGER", "ACQUAINTANCE", "FAMILIAR", "TRUSTED", "BOND"] class RelationshipStore: def __init__(self, data: Optional[Dict[str, dict]] = None, rules: Optional[dict] = None): self.data: Dict[str, dict] = {} self.rules = rules or {} for npc_id, raw in (data or {}).items(): self.data[npc_id] = { "affinity": int(raw.get("affinity", 0)), "visit_count": int(raw.get("visit_count", 0)), "stage": raw.get("stage", "STRANGER"), "memories": list(raw.get("memories") or []), "deep_chats_done": list(raw.get("deep_chats_done") or []), } self.recompute_stage(npc_id, day_index=0) @classmethod def from_state(cls, state: dict, rules: Optional[dict] = None) -> "RelationshipStore": return cls(state.get("relationships") or {}, rules=rules) def ensure(self, npc_id: str) -> dict: if npc_id not in self.data: self.data[npc_id] = { "affinity": 0, "visit_count": 0, "stage": "STRANGER", "memories": [], "deep_chats_done": [], } return self.data[npc_id] def apply_visit(self, npc_id: str, day_index: int) -> None: r = self.ensure(npc_id) # +1 once per day tracked via visit_count increment on enter r["visit_count"] += 1 r["affinity"] = min(100, r["affinity"] + int(self._delta("visit", 1))) self.recompute_stage(npc_id, day_index) def apply_purchase(self, npc_id: str, item_match_score: float, day_index: int) -> None: r = self.ensure(npc_id) if item_match_score >= 0.5: d = int(self._delta("buy_good", 3)) else: d = int(self._delta("buy_weak", 1)) r["affinity"] = min(100, r["affinity"] + d) self.recompute_stage(npc_id, day_index) def can_deep_chat(self, npc_id: str) -> bool: """Deep chat unlocks at FAMILIAR (affinity ≥ 35) or higher.""" r = self.ensure(npc_id) stage = r.get("stage") or "STRANGER" return STAGE_ORDER.index(stage) >= STAGE_ORDER.index("FAMILIAR") def apply_deep_chat(self, npc_id: str, chat_id: str, kind: bool, day_index: int) -> bool: r = self.ensure(npc_id) if not self.can_deep_chat(npc_id): return False if chat_id in r["deep_chats_done"]: return False r["deep_chats_done"].append(chat_id) d = int(self._delta("deep_chat_kind" if kind else "deep_chat_neutral", 5 if kind else 1)) r["affinity"] = min(100, r["affinity"] + d) self.recompute_stage(npc_id, day_index) return True def add_memory(self, npc_id: str, memory_id: str, text: str) -> None: r = self.ensure(npc_id) if any(m.get("id") == memory_id for m in r["memories"]): return r["memories"].append({"id": memory_id, "text": text}) def _delta(self, key: str, default: int) -> int: return int((self.rules.get("affinity_delta") or {}).get(key, default)) def recompute_stage(self, npc_id: str, day_index: int = 0) -> str: r = self.ensure(npc_id) aff = r["affinity"] visits = r["visit_count"] chats = len(r["deep_chats_done"]) has_arc = any(m.get("id", "").startswith("arc_") for m in r["memories"]) best = "STRANGER" # sticky upgrade only candidates = [ ("STRANGER", aff >= 0, True), ("ACQUAINTANCE", aff >= 15, visits >= 2), ("FAMILIAR", aff >= 35, True), ("TRUSTED", aff >= 60, chats >= 1), ("BOND", aff >= 85, day_index >= 5 and has_arc), ] for name, aff_ok, hard in candidates: if aff_ok and hard: best = name # never downgrade if STAGE_ORDER.index(best) < STAGE_ORDER.index(r.get("stage", "STRANGER")): best = r["stage"] r["stage"] = best return best def sync_to_state(self, state: dict) -> None: state["relationships"] = {k: dict(v, memories=list(v["memories"]), deep_chats_done=list(v["deep_chats_done"])) for k, v in self.data.items()}