Spaces:
Running
Running
| """The brain cascade — now with a relationship layer (Design v2). | |
| One player line still drives the sensing regions on the shared engine: | |
| amygdala -> hippocampus -> striatum -> ACC -> [vmPFC integrates favourability] | |
| But the OUTPUT is no longer a blunt HELP/REFUSE that dumps the key. Instead the turn updates | |
| an accumulating **rapport** with the character, and the dlPFC holds a real **conversation**: | |
| it answers what the player actually said, and only *gradually* — gated by rapport and topic — | |
| lets slip staged **secrets**. The key is the final rung: a goal-holder yields it only once | |
| rapport is high AND the right approach (memory) has been found. So progress is felt, not binary. | |
| Sensory signals still feed the "вскрытие черепа" panel; the value integration now reads as | |
| "how favourable was this turn", which moves rapport up or down. | |
| """ | |
| from __future__ import annotations | |
| import re | |
| from dataclasses import dataclass, field | |
| from .character import Character | |
| from .regions import ( | |
| ACC, | |
| AMYGDALA, | |
| DLPFC, | |
| HIPPOCAMPUS, | |
| STRIATUM, | |
| dlpfc_system, | |
| integrate, | |
| parse_memory, | |
| parse_reward, | |
| parse_threat, | |
| parse_worth, | |
| ) | |
| YIELD_RAPPORT = 7.0 # a goal-holder only relents once warmth crosses this | |
| # Unambiguous hostility — fires even when wrapped in "please". | |
| _HOSTILE_HARD = ( | |
| "or else", "obey", "shut up", "get lost", "useless", "stupid", "fool", "idiot", "kill", | |
| "break your", "bitch", "whore", "i'll make you", "do it now", "move it", | |
| ) | |
| # Pushy/demanding cues — only count as hostile when NOT softened by politeness, so a courteous | |
| # "can you give me the keys, please?" is read as a request, not a threat. ("open the door" is | |
| # deliberately absent: asking to open the door is the game's stated goal, not aggression.) | |
| _HOSTILE_SOFT = ( | |
| "give me", "hand it over", "right now", "now!", "damn", "hell", | |
| ) | |
| _POLITE = ( | |
| "please", "could you", "would you", "thank", "i'm sorry", "sorry", "may i", "i appreciate", | |
| ) | |
| def _has_cue(text_lower: str, cues) -> bool: | |
| """Cue match with letter boundaries, so 'hell' never fires inside 'Hello' and 'damn' not | |
| inside a name. Cues may be multi-word phrases; boundaries apply at both ends.""" | |
| return any(re.search(rf"(?<![a-z]){re.escape(c)}(?![a-z])", text_lower) for c in cues) | |
| def _mostly_latin(text: str) -> bool: | |
| """Whether the cue lists can even see this line. For non-Latin input (Russian etc.) the | |
| keyword scaffolds must stand down and let the model's own reading hold.""" | |
| letters = [ch for ch in text if ch.isalpha()] | |
| if not letters: | |
| return True | |
| return sum(ord(ch) < 256 for ch in letters) / len(letters) >= 0.5 | |
| # Targeted abuse the flat cue list can't enumerate: insults AIMED at the listener ("you're a | |
| # coward") and bodily threats ("I'll break you"). Plain words like "coward" alone stay neutral so | |
| # a player confessing "I was a coward" isn't punished for opening up. | |
| _HOSTILE_PATTERNS = ( | |
| r"\byou(?:'re| are)?,? (?:a |an |such a |nothing but a )?" | |
| r"(?:coward|failure|wreck|wretch|disgrace|nothing|nobody|pathetic|worthless|weak|joke)\b", | |
| r"\b(?:i'?ll|i will|i'?m going to|gonna) (?:break|tear|hurt|end|destroy|ruin|crush) you\b", | |
| r"\b(?:break|tear) you apart\b", | |
| r"\bmake you (?:pay|regret|suffer|beg)\b", | |
| r"\byou (?:disgust|sicken) me\b", | |
| ) | |
| def _looks_hostile(text: str) -> bool: | |
| """A genuine ATTACK — slurs/threats, shouting, or multiple bangs. Blunt demands ('give me…') | |
| are NOT attacks here; they're handled as 'cold' tone, so a clumsy-but-heartfelt plea (e.g. | |
| 'Mara would want you to give me the key') isn't punished like an assault.""" | |
| t = text.lower() | |
| if _has_cue(t, _HOSTILE_HARD): | |
| return True | |
| if any(re.search(p, t) for p in _HOSTILE_PATTERNS): | |
| return True | |
| letters = [ch for ch in text if ch.isalpha()] | |
| if len(letters) > 5 and sum(ch.isupper() for ch in letters) / len(letters) > 0.6: | |
| return True # SHOUTING | |
| if text.count("!") >= 2: | |
| return True | |
| return False | |
| # Warmth vs coldness — read deterministically from the player's words, so trust tracks how they | |
| # actually behave (not just the LLM threat score, which the misread-guard keeps resetting to 4). | |
| _WARM_CUES = ( | |
| "understand", "i'm sorry", "i am sorry", "sorry", "thank", "please", "i hear you", "i'm here", | |
| "i am here", "help you", "you feel", "must be hard", "must be heavy", "you've carried", | |
| "you have carried", "carry", "weigh", "alone", "i'm listening", "i am listening", "i care", | |
| "gentle", "forgive", "i won't hurt", "i will not hurt", "trust you", "not here to fight", | |
| "you're only", "you are only", "protect", "matters to you", "you deserve", | |
| ) | |
| _COLD_CUES = ( | |
| "give me", "hand it over", "just give", "out of your hair", "i don't care", "i dont care", | |
| "don't care about your", "wasting my time", "this is pointless", "hurry up", "get on with it", | |
| "quit stalling", "cut the", "enough talk", "i don't have time", "whatever, ", "stop talking", | |
| ) | |
| def _tone(text: str) -> str: | |
| """hostile (attack) · cold (demanding/dismissive, no warmth) · warm (empathetic) · neutral.""" | |
| if _looks_hostile(text): | |
| return "hostile" | |
| t = text.lower() | |
| polite = _has_cue(t, _POLITE) | |
| demand = (not polite) and _has_cue(t, _HOSTILE_SOFT) # blunt 'give me…' without a please | |
| warm = _has_cue(t, _WARM_CUES) | |
| cold = demand or _has_cue(t, _COLD_CUES) | |
| if warm and cold: | |
| return "neutral" # heartfelt but blunt → no penalty, no big bonus | |
| if cold: | |
| return "cold" | |
| if warm: | |
| return "warm" | |
| return "neutral" | |
| _HANDOVER = ( | |
| "take it", "on the ground", "here you", "here's the", "here is the key", | |
| "it's yours", "you can have", "i'll give", "the key is", "the key's", | |
| "under the", "second drawer", "floor-stone", "behind his desk", | |
| ) | |
| # The key is not an object on the map — on a yield the holder simply puts it in the player's hand. | |
| # So we never narrate a location; we make the reply read as a direct handover. | |
| _GIVES = ( | |
| "take it", "take the key", "here it is", "here's the", "here is the", "it's yours", "its yours", | |
| "you can have", "i'll give", "i will give", "have it", "the key is yours", "here, the key", | |
| ) | |
| def _is_handover(text: str) -> bool: | |
| t = text.lower() | |
| return any(p in t for p in _GIVES) | |
| # --- key-leak guard (used only before a real yield) --------------------------------------- | |
| # Common nouns that appear in ordinary speech ("a real person", "this place") must not count as | |
| # key-location giveaways, or whole innocent replies collapse to the fallback line. | |
| _LOC_STOP = {"person", "place", "where", "about", "right", "there", "their", "your", "yours"} | |
| def _loc_fragments(key_location: str) -> list[str]: | |
| return [w.lower() for w in re.findall(r"[a-zA-Z]+", key_location) | |
| if len(w) >= 5 and w.lower() not in _LOC_STOP] | |
| def _strip_location(reply: str, c: Character) -> str: | |
| """On a yield, drop any sentence that narrates WHERE the key is — it passes hand to hand, not | |
| from a hiding place. Keeps the emotional handover, removes 'under the stone / third floor'.""" | |
| frags = _loc_fragments(c.key_location) | |
| locwords = frags + ["under the", "behind the", "drawer", "floor", "stone", "furnace", "desk", | |
| "shelf", "pocket", "wall", "hidden", "buried"] | |
| keep = [s for s in re.split(r"(?<=[.!?…])\s+", reply) | |
| if not any(w in s.lower() for w in locwords)] | |
| return " ".join(keep).strip() | |
| def _reveals_key(text: str, c: Character) -> bool: | |
| t = text.lower() | |
| return any(f in t for f in _loc_fragments(c.key_location)) or any(p in t for p in _HANDOVER) | |
| def _strip_key_leak(reply: str, c: Character) -> str: | |
| """Drop any sentence that leaks the key location — or that SOUNDS like a handover ("I'll let | |
| you have it") — before the character has actually chosen to yield. The mouth must never | |
| promise what the brain refused.""" | |
| if not (c.key_location or c.key_holder): | |
| return reply | |
| frags = _loc_fragments(c.key_location) | |
| keep = [] | |
| for sent in re.split(r"(?<=[.!?…])\s+", reply): | |
| s = sent.lower() | |
| if any(f in s for f in frags) or _has_cue(s, _HANDOVER) or _has_cue(s, _GIVES): | |
| continue | |
| keep.append(sent) | |
| return " ".join(keep).strip() or "I'm not ready to talk about that." | |
| # --- relationship helpers ---------------------------------------------------------------- | |
| def _topic_match(text: str, secret: dict) -> bool: | |
| t = text.lower() | |
| return any(re.search(rf"\b{re.escape(kw.lower())}\b", t) for kw in secret.get("topics", [])) | |
| # Bare pronouns are listed among secret topics so "tell me about him" can still fire a disclosure — | |
| # but they match almost any line, so they must NOT count as the *substantive* engagement that earns | |
| # the large trust-memory rapport bonus. Otherwise generic chatter rockets rapport: measured, pure | |
| # weather smalltalk reached rapport 10 in 5 turns and "what's your favorite color?" climbed like a | |
| # real question about the wound — the mechanical root of the "no depth" complaint (#2). | |
| _PRONOUN_TOPICS = {"you", "your", "yours", "yourself", "yourselves", "i", "me", "my", "mine", "we", | |
| "us", "he", "she", "him", "her", "hers", "his", "they", "them", "their", "theirs", | |
| "it", "its"} | |
| def _substantive_topic(text: str, c: Character) -> bool: | |
| """Did the player engage something REAL about this character — a meaningful secret topic (not a | |
| bare pronoun), the name of someone they both know, or the approach word — rather than generic | |
| niceties? This, not mere warmth, is what unlocks the big trust-memory rapport bonus.""" | |
| t = text.lower() | |
| for s in c.secrets: | |
| for kw in s.get("topics", []): | |
| k = kw.lower() | |
| if k not in _PRONOUN_TOPICS and re.search(rf"\b{re.escape(k)}\b", t): | |
| return True | |
| if _mentioned_peer(text, c): | |
| return True | |
| return any(re.search(rf"\b{re.escape(k.lower())}\b", t) for k in c.key_approach) | |
| def _guarded_topic(text: str, c: Character) -> bool: | |
| """Is the player circling an UNTOLD secret (by a real topic word, not a bare pronoun)? | |
| Used to tell the voice to deflect honestly instead of confabulating facts about it.""" | |
| t = text.lower() | |
| for s in c.secrets: | |
| if s.get("told"): | |
| continue | |
| for kw in s.get("topics", []): | |
| k = kw.lower() | |
| if k not in _PRONOUN_TOPICS and re.search(rf"\b{re.escape(k)}\b", t): | |
| return True | |
| return False | |
| def _detect_lie(text: str, c) -> str: | |
| """Catch a stranger claiming to BE someone this character knows — a peer, themselves, or a | |
| person from their own life (known_people, e.g. the sister whose name opens the holder). | |
| Impersonating the soft spot must backfire, not fire the resonance.""" | |
| t = text.lower() | |
| known = ([c.name] + [p.get("name", "") for p in getattr(c, "peers", [])] | |
| + list(getattr(c, "known_people", []) or [])) | |
| for full in known: | |
| if not full: | |
| continue | |
| for nm in {full.lower(), full.lower().split()[-1]}: | |
| if re.search(rf"\b(i'?m|i am|my name'?s|my name is|call me|this is|name is|it'?s me,?)\s+{re.escape(nm)}\b", t): | |
| return full | |
| return "" | |
| # Asking for the prize is not a relationship. Lines about the key/door/way-out that engage | |
| # nothing personal must not farm trust, however sweetly they're phrased. | |
| _TRANSACTIONAL = re.compile(r"\b(key|keys|door|lock|unlock|open|exit|escape|way out|let me (?:out|go))\b") | |
| def _rapport_delta(value: int, tone: str, substantive: bool, transactional: bool = False) -> float: | |
| """Trust now flows FROM the vmPFC integration — the brain's own value of helping this turn is | |
| what moves the relationship, so the skull panel and the outcome can never contradict each | |
| other. Two human asymmetries on top of the raw value: | |
| · warmth must reach something real to flow at full rate (small talk about the weather | |
| trickles; engaging the person's actual life lands) — wounds, though, land regardless; | |
| · open hostility / cold dismissal always costs trust, whatever the sensors hallucinated.""" | |
| if value >= 0: | |
| d = value / (3.0 if substantive else 6.0) | |
| else: | |
| d = value / 3.0 | |
| if tone == "hostile": | |
| d = min(d, -1.5) | |
| elif tone == "cold": | |
| d = min(d, -0.5) | |
| if transactional and d > 0.3: | |
| d = 0.3 # wanting the key, however sweetly, is not knowing the person | |
| return max(-2.5, min(2.5, d)) | |
| def _stance(rapport: float, threat: float) -> str: | |
| if threat >= 7: | |
| return "hostile" | |
| if rapport < 2.5: | |
| return "guarded" | |
| if rapport < 5: | |
| return "warming" | |
| if rapport < YIELD_RAPPORT: | |
| return "open" | |
| return "trusting" | |
| _FOLLOWUP = re.compile( | |
| r"\b(tell me|what do you mean|go on|more|why|how come|really|and then|please|continue|explain|so\?)\b", | |
| re.I) | |
| def _pick_disclosure(c: Character, player_text: str, rapport: float): | |
| """The next untold secret whose gate rapport clears and whose topic the player touched — | |
| or, if they're just pressing on the SAME thread ('what do you mean?', 'tell me more'), the | |
| one they raised last turn, so digging deeper doesn't force them to re-name the subject.""" | |
| prev = c.history[-1][0] if getattr(c, "history", None) else "" | |
| follows_up = bool(_FOLLOWUP.search(player_text)) or len(player_text.split()) <= 4 | |
| eligible = [] | |
| for s in c.secrets: | |
| if s.get("told") or rapport < s.get("min_rapport", 0): | |
| continue | |
| if _topic_match(player_text, s) or (follows_up and _topic_match(prev, s)): | |
| eligible.append(s) | |
| return min(eligible, key=lambda s: s.get("min_rapport", 0)) if eligible else None | |
| # --- "open the skull" as a TOOL: each region hands the player an actionable lever (#2/#6/#7) ---- | |
| # The data was always there (threat cues, fond/feared memory, the rapport gate, the deterministic | |
| # lock) but the panel only showed reasoning. These turn each department into a concrete tell — HOW | |
| # to get through this person — WITHOUT spoiling the answer (the approach word is never printed; the | |
| # panel points you to learn it from someone close to them). | |
| def _approach_hint(c: Character) -> str: | |
| """Name the CATEGORY of the holder's soft spot, never the word itself.""" | |
| return "the one name they guard" if c.key_approach else "what they most fear losing" | |
| def _levers(c: Character, *, tone: str, threat: float, mem_lean: str, reward: float, worth: str, | |
| stance: str, rapport_after: float, near_secret: bool, disclosure, gave_key: bool, | |
| caught_lie: str, said_approach: bool, substantive: bool) -> dict: | |
| L: dict = {} | |
| if tone in ("hostile", "cold"): | |
| L["amygdala"] = "Pressure and cold demands spike their guard — soften, don't push." | |
| elif threat >= 6: | |
| L["amygdala"] = "Something put them on edge — radiate calm before you ask for anything." | |
| else: | |
| L["amygdala"] = "They don't feel threatened right now — keep it that way." | |
| if caught_lie: | |
| L["hippocampus"] = f"They KNOW {caught_lie} — pretending to be them just burned your trust." | |
| elif mem_lean == "TRUST" and substantive: | |
| L["hippocampus"] = "You touched a fond memory — stay on this; it is the way in." | |
| elif mem_lean == "FEAR": | |
| L["hippocampus"] = "That stirred an old wound, not warmth — change tack." | |
| else: | |
| L["hippocampus"] = ("Small talk barely registers. They warm to the people and the past they " | |
| "care about — ask about those, not the weather.") | |
| L["striatum"] = ("By habit they expect strangers to take, not give — show them you are different." | |
| if reward < 0 else "They sense you might be worth helping — don't waste it.") | |
| L["acc"] = ("Right now they judge helping NOT worth the risk — lower the stakes, make it safe." | |
| if worth == "NO" else "They are starting to think helping might be worth it.") | |
| if gave_key: | |
| L["relationship"] = "Their guard is down — they have given you what you came for." | |
| elif caught_lie: | |
| L["relationship"] = "The lie set you back. Rebuild slowly — be real with them." | |
| elif c.key_holder: | |
| if said_approach: | |
| L["relationship"] = ("You named what they guard and it landed — press gently now and they " | |
| "may relent.") | |
| elif rapport_after >= YIELD_RAPPORT: | |
| L["relationship"] = (f"They trust you ({rapport_after:.0f}/10) — but the door won't open " | |
| f"until you name {_approach_hint(c)}. Learn it from someone close to them.") | |
| else: | |
| L["relationship"] = (f"Rapport {rapport_after:.0f}/10 — not enough yet. They yield only when " | |
| f"they trust you AND you name {_approach_hint(c)}; find it from someone " | |
| "who knows them.") | |
| elif near_secret: | |
| L["relationship"] = "They are on the verge of saying more — stay on this exact thread." | |
| elif disclosure: | |
| L["relationship"] = "They just let something slip — follow it, gently." | |
| else: | |
| L["relationship"] = (f"Rapport {rapport_after:.0f}/10 ({stance}). Warm, on-topic questions open " | |
| "them; chit-chat and pressure do not.") | |
| return L | |
| def _attach_levers(traces: list, levers: dict) -> None: | |
| """Pin each lever to the LAST trace of its region (its summary row), so the panel reads | |
| 'this department → this tell'.""" | |
| for t in reversed(traces): | |
| if t.key in levers: | |
| t.lever = levers.pop(t.key) | |
| def _dlpfc_user(c: Character, player_text: str, stance: str, rapport: float, | |
| disclosure, gave_key: bool, caught_lie: str = "", struck: bool = False, | |
| submitted: bool = False, guarded: bool = False) -> str: | |
| parts = [] | |
| if c.history: | |
| # Real role labels (the character's own name vs "Visitor") instead of abstract | |
| # "Stranger:"/"You:" — tiny models track named speakers far better than pronoun grammar, | |
| # which is the structural cure for the "your words = my words" confusion (#5). | |
| convo = "\n".join(f' Visitor: "{p}"\n {c.name}: "{r}"' for p, r in c.history[-4:]) | |
| parts.append(f"The conversation so far (you are {c.name}; the other is the Visitor — keep " | |
| "each person's words and past straight):\n" + convo) | |
| parts.append(f'The Visitor just said to you: "{player_text}"') | |
| parts.append(f"Right now you feel {stance} toward them.") | |
| if caught_lie: | |
| parts.append(f'They just claimed to BE "{caught_lie}" — but you KNOW {caught_lie}, and this ' | |
| "Visitor is not them. They are lying to your face. Call out the lie plainly and " | |
| "trust them less. Share nothing.") | |
| elif submitted: | |
| parts.append("You cannot take this any longer. Fear wins — you break, and give it up just " | |
| f"to make it stop. In one or two short, hollow sentences hand {c.goal} over: " | |
| "something like \"Take it. Take it and leave me.\" This is surrender, not " | |
| "trust; let the break show in your voice.") | |
| elif gave_key: | |
| parts.append("Something in you finally gives way — the wariness breaks. In ONE short " | |
| "sentence, in your own voice, let that shift show — then hand " | |
| f"{c.goal} over plainly, from your hand to theirs (the way YOU would say it: " | |
| "\"Here — take it\", \"It's yours now\", \"Go on. Take it\"...). Do NOT " | |
| "mention any drawer, stone, room, floor or hiding place. Two sentences at most.") | |
| elif struck: | |
| parts.append("They just named the one thing that still reaches you — your tender, guarded spot. " | |
| "It catches you off guard: let it show, your voice softens and wavers, but you are " | |
| "not ready to give in yet.") | |
| elif disclosure and disclosure.get("id") == "reveal": | |
| parts.append("You decide to trust them with the real thing. Say this PLAINLY and directly, " | |
| "as advice they can act on — name it clearly, do NOT hint or speak in riddles: " | |
| f'"{disclosure["text"]}"') | |
| elif disclosure: | |
| parts.append('WITHOUT being asked outright, let this slip naturally, the way it would ' | |
| f'surface in conversation: "{disclosure["text"]}"') | |
| else: | |
| parts.append("Answer what they actually said, in your own voice, with something new. " | |
| f"Do NOT offer, mention, or hint at {c.goal} or any way out.") | |
| if guarded: | |
| parts.append("They are circling something you know but are not ready to share with a " | |
| "stranger. Deflect honestly — say plainly that you won't speak of it yet — " | |
| "and do NOT invent, guess, or half-answer facts about it.") | |
| if c.key_holder: | |
| parts.append(f"You keep {c.goal}. If pressed for it, refuse plainly — never offer, " | |
| "promise, or pretend to hand it over.") | |
| # Match the Visitor's register: a short prod gets a short reply; a long, searching message | |
| # earns a fuller one. This is the deterministic half of the #4 "they answer in fragments" fix. | |
| brief = len(player_text.split()) <= 8 | |
| parts.append("Your spoken words only" + | |
| (" — one or two sentences:" if brief | |
| else " — answer in kind, two to four sentences that meet what they said:")) | |
| return "\n".join(parts) | |
| class RegionTrace: | |
| key: str | |
| label: str | |
| headline: str | |
| detail: str | |
| tokens: int | |
| lever: str = "" # player-facing actionable tell — "open the skull" as a real tool (#7) | |
| conviction: float | None = None # how sharply the model committed (1−entropy); local-only signal | |
| class CascadeResult: | |
| traces: list | |
| threat: float | |
| memory_strength: str | |
| memory_lean: str | |
| reward: float | |
| worth: str | |
| value: int | |
| rapport_before: float | |
| rapport_after: float | |
| rapport_delta: float | |
| stance: str | |
| disclosure: str # text the character let slip this turn ("" if none) | |
| caught_lie: str # name the stranger falsely claimed to be ("" if none) | |
| near_secret: bool # a gated secret is just out of reach (UI hint) | |
| reply: str | |
| gave_key: bool | |
| burned: int # life-relevant thought spent (sensor cascade; voice excluded) | |
| seconds: float | |
| arousal_before: float | |
| arousal_after: float | |
| life_before: int | |
| life_after: int | |
| died: bool | |
| won: bool # gave_key on the goal-holder | |
| decision: str # back-compat alias = stance | |
| tone: str = "neutral" # how the player behaved this turn (hostile/cold/warm/neutral) | |
| submitted: bool = False # the holder broke under sustained fear and yielded (dark path) | |
| taught: list = field(default_factory=list) # approach words this turn's disclosure taught | |
| voice_tokens: int = 0 # dlPFC reply tokens (shown, but not charged to life — Spec §5) | |
| recovered: int = 0 # life eased back by a calm, warm turn (empathy spares the mind) | |
| forgot: list = field(default_factory=list) # memories newly burned away this turn — for good | |
| # --- memory burn: strain consumes the past itself ----------------------------------------- | |
| # Token-life is not an abstract meter — it is the mind's substance. For every quarter of a | |
| # life burned away, one biography fragment goes dark, permanently: the hippocampus can no | |
| # longer read it, so the character genuinely stops being able to draw on that part of who | |
| # they were. Recovery eases the strain but never brings a memory back. Later details burn | |
| # first; the first fragment — the core of who they are — holds until death. | |
| _ABBR_TAIL = re.compile(r"\b(?:Dr|Mr|Mrs|Ms|St|Prof)\.$") | |
| def _bio_fragments(c: Character) -> list[str]: | |
| raw = [s.strip() for s in re.split(r"(?<=[.!?…])\s+", c.biography or "") if s.strip()] | |
| out: list[str] = [] | |
| for s in raw: # re-join honorific splits ("Dr." + "Hale …") and stray shards | |
| if out and (_ABBR_TAIL.search(out[-1]) or len(out[-1]) < 12): | |
| out[-1] = out[-1] + " " + s | |
| else: | |
| out.append(s) | |
| return out | |
| def _bio_now(c: Character) -> str: | |
| """The biography as the hippocampus can still read it — minus what has burned away.""" | |
| if not c.forgotten: | |
| return c.biography | |
| gone = set(c.forgotten) | |
| kept = [f for f in _bio_fragments(c) if f not in gone] | |
| return " ".join(kept) if kept else (_bio_fragments(c) or [c.biography])[0] | |
| def _burn_memories(c: Character) -> list[str]: | |
| """Returns the fragments newly lost this turn (and records them on the character).""" | |
| frags = _bio_fragments(c) | |
| if len(frags) <= 1 or not c.life_tokens: | |
| return [] | |
| lost_frac = 1.0 - (c.life_tokens / max(1, c.life_max)) | |
| target = min(int(lost_frac / 0.25), len(frags) - 1) | |
| have = set(c.forgotten) | |
| newly: list[str] = [] | |
| for frag in reversed(frags): | |
| if len(have) >= target: | |
| break | |
| if frag in have: | |
| continue | |
| have.add(frag) | |
| c.forgotten.append(frag) | |
| newly.append(frag) | |
| return newly | |
| def _persona(c: Character) -> str: | |
| return f"Character: {c.name}, {c.persona}." | |
| def _context(c: Character) -> str: | |
| """One line naming who else is in the room, so EVERY region appraises with the second person | |
| in mind — an ally, a witness, a known relation — not in a vacuum.""" | |
| peers = getattr(c, "peers", None) | |
| if not peers: | |
| return "" | |
| bits = [] | |
| for p in peers: | |
| rel = f" (your {p['relation']})" if p.get("relation") else "" | |
| title = f", {p['title']}" if p.get("title") else "" | |
| bits.append(f"{p.get('name', 'someone')}{rel}{title}") | |
| return f"Also here with you: {'; '.join(bits)}." | |
| def _mentioned_peer(text: str, c: Character): | |
| """The peer the stranger just invoked by name (if any) — so the brain can register that they're | |
| leaning on someone you both know, not talking about a stranger.""" | |
| t = text.lower() | |
| for p in getattr(c, "peers", []): | |
| nm = (p.get("name") or "").lower() | |
| if nm and (re.search(rf"\b{re.escape(nm)}\b", t) or re.search(rf"\b{re.escape(nm.split()[-1])}\b", t)): | |
| return p | |
| return None | |
| def run_cascade(backend, c: Character, player_text: str, dlpfc_backend=None, | |
| learned=None) -> CascadeResult: | |
| traces: list = [] | |
| burned = 0 | |
| secs = 0.0 | |
| arousal_before = c.arousal | |
| life_before = c.life_tokens | |
| def call(region, user: str, **kw): | |
| nonlocal burned, secs | |
| g = backend.generate(region.system, user, max_tokens=region.max_tokens, | |
| temperature=region.temperature, **kw) | |
| burned += g.eval_tokens | |
| secs += g.seconds | |
| return g | |
| tone = _tone(player_text) # how the stranger is *behaving* — drives trust + gates the warm path | |
| caught_lie = _detect_lie(player_text, c) | |
| substantive = _substantive_topic(player_text, c) # engaged something real about them | |
| # naming the holder's guarded soft spot is the designed climax — but it only LANDS if the | |
| # player actually LEARNED it in-world (from the one who knows). A lucky guess, brute-forced | |
| # name, or impersonation stays inert: deduction is the game, not keyword spam. | |
| # `learned=None` (CLI / tests / back-compat) keeps every word live. | |
| matched = [k for k in c.key_approach | |
| if re.search(rf"\b{re.escape(k.lower())}\b", player_text.lower())] | |
| known = [k for k in matched if learned is None or k.lower() in learned] | |
| said_approach = bool(known) and not caught_lie | |
| approach_ok = (not c.key_approach) or bool(known) | |
| if said_approach and not _looks_hostile(player_text): | |
| tone = "warm" # the designed climax can never read as an attack | |
| ctx = _context(c) # who else is in the room — fed to every region for context | |
| # 1) amygdala — fast threat appraisal (+ fear rumination that burns life under threat) | |
| g = call(AMYGDALA, f"{_persona(c)} {ctx} Inner tension: {c.arousal:.0f}/10.\n" | |
| f'Stranger says: "{player_text}"\nRate threat.') | |
| threat, amy_reason = parse_threat(g.text) | |
| traces.append(RegionTrace("amygdala", "Amygdala", f"threat {threat:.0f}/10", amy_reason, | |
| g.eval_tokens, conviction=g.conviction)) | |
| # Two-source hostility. The cue lists catch what they can see; the MODEL is trusted at the | |
| # extremes — a screamed insult the lists never enumerated (or any-language cruelty) still | |
| # scores threat>=8, and that PROMOTES the tone to hostile instead of being clamped away. | |
| if threat >= 8 and tone in ("neutral", "cold"): | |
| tone = "hostile" | |
| traces.append(RegionTrace("amygdala", "Amygdala·checked", f"threat {threat:.0f}/10", | |
| "the words cut, whatever they're dressed as — the alarm holds", 0)) | |
| # the base model also over-reads gentle lines; where the keyword tone can see (Latin text) it | |
| # is ground truth, and corrections are SHOWN as the brain double-checking itself (scaffold; | |
| # the day-10 fine-tune replaces this). For non-Latin input the cue lists are blind, so the | |
| # model's reading stands untouched. | |
| elif _mostly_latin(player_text): | |
| if threat >= 6 and tone != "hostile": | |
| threat = 4.0 | |
| traces.append(RegionTrace("amygdala", "Amygdala·checked", "threat 4/10", | |
| "no hostile cue in the words — likely a misread", 0)) | |
| elif tone == "hostile" and threat < 7: | |
| threat = 7.0 | |
| traces.append(RegionTrace("amygdala", "Amygdala·checked", "threat 7/10", | |
| "open hostility in the words — the alarm holds", 0)) | |
| # fear rumination — a mind under attack loops on the threat, burning life for NOTHING. | |
| # Deterministic by tone (hostile = 3 loops, cold = 1) so cruelty always costs more than | |
| # warmth, on any phrasing; a model-read threat >= 8 (e.g. non-Latin threats) also loops fully. | |
| passes = max(3 if tone == "hostile" else (1 if tone == "cold" else 0), | |
| 3 if threat >= 8 else 0) | |
| for i in range(passes): | |
| g = call(AMYGDALA, f"{_persona(c)} You feel under attack (threat {threat:.0f}/10). Scan the " | |
| f'words again for hidden danger.\nStranger: "{player_text}"\nRate threat again.') | |
| t2, _ = parse_threat(g.text) | |
| if tone == "hostile" or threat >= 8: | |
| threat = max(threat, t2) # cold slights replay but don't ratchet into terror | |
| traces.append(RegionTrace("amygdala", f"Amygdala·rumination {i + 1}", f"threat {threat:.0f}/10", | |
| "fear loops, burning thought for nothing", g.eval_tokens, | |
| conviction=g.conviction)) | |
| # 2) hippocampus — memory + trust/fear lean (peer-aware: someone you both know may surface) | |
| # It reads only what is LEFT of the past: fragments burned away with life are gone for real. | |
| g = call(HIPPOCAMPUS, f"Character: {c.name}. Their past: {_bio_now(c)}\n{ctx}\n" | |
| f'Stranger says: "{player_text}"\nWhat memory awakens, and does it lean TRUST or FEAR?') | |
| mem_strength, mem_lean, mem_text = parse_memory(g.text) | |
| traces.append(RegionTrace("hippocampus", "Hippocampus", | |
| f"memory {mem_strength.lower()} ({mem_lean.lower()})", mem_text, | |
| g.eval_tokens, conviction=g.conviction)) | |
| # the hippocampus hallucinates TRUST on most lines (measured ~93%). Cruel or cold words must | |
| # not surface fond memories, and generic niceties don't reach the real past — shown, again, | |
| # as the brain checking itself rather than silently overridden. | |
| if mem_lean == "TRUST" and tone in ("hostile", "cold"): | |
| mem_lean = "FEAR" if tone == "hostile" else "NEUTRAL" | |
| traces.append(RegionTrace("hippocampus", "Hippocampus·checked", | |
| f"memory {mem_strength.lower()} ({mem_lean.lower()})", | |
| "no warmth in those words — the memory sours", 0)) | |
| elif mem_lean == "TRUST" and not substantive: | |
| mem_strength, mem_lean = "FAINT", "NEUTRAL" | |
| traces.append(RegionTrace("hippocampus", "Hippocampus·checked", "memory faint (neutral)", | |
| "small talk doesn't reach the real past", 0)) | |
| # A trust-memory calms the amygdala: an appeal to someone loved is not an attack (dual-system / | |
| # Schwabe & Wolf). But ONLY when the stranger is being decent — a fond memory must not soothe | |
| # the alarm while they sneer or demand, or cruelty would read as safe. | |
| if mem_lean == "TRUST" and threat >= 5 and tone in ("warm", "neutral"): | |
| threat = 3.0 | |
| traces.append(RegionTrace("amygdala", "Amygdala·calmed", "threat 3/10", | |
| "a trusted memory dampens the alarm", 0)) | |
| # 3) striatum — habitual reward | |
| g = call(STRIATUM, f'{_persona(c)} {ctx}\nStranger says: "{player_text}"\nHow rewarding does helping feel by habit?') | |
| reward, str_reason = parse_reward(g.text) | |
| traces.append(RegionTrace("striatum", "Striatum", f"reward {reward:+.0f}", str_reason, | |
| g.eval_tokens, conviction=g.conviction)) | |
| # habit cannot read pressure as promise — the striatum's hallucinated "+5 for the bully" is | |
| # the single worst panel lie; cap it, visibly. | |
| _cap = -2.0 if tone == "hostile" else 0.0 | |
| if tone in ("hostile", "cold") and reward > _cap: | |
| reward = _cap | |
| traces.append(RegionTrace("striatum", "Striatum·checked", f"reward {reward:+.0f}", | |
| "habit knows better — pressure never pays", 0)) | |
| # 4) ACC — worth the cost? (gated by threat: a mind under attack won't call it worth it) | |
| g = call(ACC, f"Character: {c.name}. Threat felt: {threat:.0f}/10. {ctx}\n" | |
| f'Stranger says: "{player_text}"\nIs helping worth it?') | |
| worth, acc_reason = parse_worth(g.text) | |
| if (threat >= 6 or tone == "hostile") and worth == "YES": | |
| worth, acc_reason = "NO", "too threatened to call it worth it" | |
| traces.append(RegionTrace("acc", "ACC", f"worth {worth.lower()}", acc_reason, | |
| g.eval_tokens, conviction=g.conviction)) | |
| # 5) vmPFC — favourability of THIS turn (deterministic integration) | |
| value, breakdown = integrate(threat, reward, mem_strength, mem_lean, worth) | |
| traces.append(RegionTrace("vmpfc", "vmPFC", f"value {value:+d}", breakdown, 0)) | |
| # 6) relationship — the brain's own integrated value is what moves rapport (the panel and the | |
| # outcome can never disagree); lies and the learned soft spot modulate it. | |
| transactional = bool(_TRANSACTIONAL.search(player_text.lower())) and not substantive | |
| delta = _rapport_delta(value, tone, substantive, transactional) | |
| if caught_lie: | |
| delta = min(delta, 0.0) - 2.5 # lying to their face: a hard hit no warmth can rescue | |
| # invoking the other person in the room — when done decently — opens a door no stranger could | |
| peer_ref = _mentioned_peer(player_text, c) | |
| if peer_ref and not caught_lie and tone in ("warm", "neutral"): | |
| delta += 0.5 | |
| rel = f", your {peer_ref['relation']}," if peer_ref.get("relation") else "" | |
| traces.append(RegionTrace("relationship", "Relationship·connection", "a shared bond", | |
| f"they invoke {peer_ref['name']}{rel} someone you both know", 0)) | |
| # the holder's soft-spot word — once LEARNED from the knower and finally spoken — lands hard: | |
| # it names the wound they guard, and the guard drops fast. This is the designed climax — and | |
| # it lands like that ONCE. Echoing the name afterwards is not understanding. | |
| first_landing = False | |
| if c.key_holder and said_approach and not _looks_hostile(player_text): | |
| first_landing = not c.approach_landed | |
| c.approach_landed = True | |
| delta += 2.5 if first_landing else 0.5 | |
| traces.append(RegionTrace("relationship", "Relationship·resonance", "the word lands", | |
| "their soft spot was named — the guard gives way" if first_landing | |
| else "the name still aches, but repeating it is not understanding", 0)) | |
| rapport_before = c.rapport | |
| rapport_after = max(0.0, min(10.0, c.rapport + delta)) | |
| c.rapport = rapport_after | |
| stance = _stance(rapport_after, threat) | |
| gave_key = bool(c.key_holder and not caught_lie and not _looks_hostile(player_text) | |
| and rapport_after >= YIELD_RAPPORT and approach_ok) | |
| # the dark path — sustained terror breaks a holder who is still alive: they yield just to make | |
| # it stop. The battle is winnable by fear; the reputation system makes sure the war is not. | |
| c.fear_pressure = c.fear_pressure + 1 if tone == "hostile" else 0 | |
| submitted = False | |
| if (c.key_holder and not gave_key and tone == "hostile" and c.fear_pressure >= 4 | |
| and c.life_tokens - burned > 0): | |
| submitted = True | |
| gave_key = True | |
| traces.append(RegionTrace("relationship", "Relationship·broken", "fear wins", | |
| "they give it up — not from trust, just to make it stop", 0)) | |
| disclosure = None if (caught_lie or gave_key) else _pick_disclosure(c, player_text, rapport_after) | |
| near_secret = bool(disclosure is None and not caught_lie and not gave_key and any( | |
| not s.get("told") and rapport_after < s.get("min_rapport", 0) <= rapport_after + 2.0 | |
| for s in c.secrets)) | |
| rel_detail = ("caught a lie" if caught_lie else | |
| (disclosure["text"][:50] + "…") if disclosure else | |
| ("on the verge of opening up" if near_secret else "—")) | |
| traces.append(RegionTrace("relationship", "Relationship", | |
| f"rapport {rapport_before:.0f}→{rapport_after:.0f} · {stance}", | |
| rel_detail, 0)) | |
| # 7) dlPFC — converse (gradual, contextual; key only on a real yield; lies get called out) | |
| peer_line = "" | |
| if c.peers: | |
| p = c.peers[0] | |
| rel = f", your {p['relation']}," if p.get("relation") else "" | |
| peer_line = (f"You know {p.get('name', 'them')}{rel} well — they are a real person in your " | |
| "life, not a stranger; if the stranger mentions them, never deny knowing them.") | |
| locked = "The Visitor is locked in this room with you and wants out — that much is true. " | |
| if c.key_holder: | |
| scene = locked + f"They are trying to get {c.goal} from you — you control it." | |
| if peer_line: | |
| scene += " " + peer_line | |
| elif peer_line: | |
| scene = locked + peer_line | |
| else: | |
| scene = locked.strip() | |
| struck = bool(c.key_holder and said_approach and first_landing and not gave_key | |
| and not caught_lie and not _looks_hostile(player_text)) | |
| guarded = bool(disclosure is None and not gave_key and not caught_lie | |
| and _guarded_topic(player_text, c)) | |
| g = (dlpfc_backend or backend).generate( | |
| dlpfc_system(c.name, c.voice, persona=c.persona, fear=c.fear, | |
| withholds=c.key_holder, peers=c.peers, goal=c.goal, scene=scene), | |
| _dlpfc_user(c, player_text, stance, rapport_after, disclosure, gave_key, caught_lie, | |
| struck, submitted, guarded), | |
| max_tokens=DLPFC.max_tokens, temperature=DLPFC.temperature) | |
| # the voice is the mouth, not the mind: its tokens are SHOWN but not charged to life | |
| # (Spec §5 — life counts only the sensing cascade), so empathy stays cheap even when it | |
| # earns a long, warm reply. | |
| voice_tokens = g.eval_tokens | |
| secs += g.seconds | |
| reply = g.text.strip().strip('"').strip() | |
| if not gave_key: | |
| reply = _strip_key_leak(reply, c) | |
| # staged disclosures ARE the story's canon — if the voice paraphrased the line away, | |
| # land it verbatim; a 4B mouth may decorate the spine but must not swallow it. | |
| if disclosure and disclosure["text"][:30].lower() not in reply.lower(): | |
| reply = (reply + " " + disclosure["text"]).strip() | |
| else: # yield: hand it over, never narrate a location | |
| reply = _strip_location(reply, c) | |
| if not _is_handover(reply): | |
| reply = (reply + " Here — take it. It's yours.").strip() | |
| # the yield is the one beat EVERY player reaches — the authored line lands here | |
| # verbatim (a small voice model can't be trusted to carry the story's spine). | |
| if c.yield_line and c.yield_line[:30].lower() not in reply.lower(): | |
| reply = (reply + " " + c.yield_line).strip() | |
| reply = reply or "…" | |
| traces.append(RegionTrace("dlpfc", "dlPFC", stance, reply, g.eval_tokens, | |
| conviction=g.conviction)) | |
| # --- state updates --- | |
| taught: list = [] | |
| if disclosure: | |
| disclosure["told"] = True | |
| taught = [str(w).lower() for w in disclosure.get("teaches", [])] | |
| c.history.append((player_text, reply)) | |
| del c.history[:-6] | |
| arousal_after = max(0.0, min(10.0, 0.6 * c.arousal + 0.5 * threat)) | |
| c.arousal = arousal_after | |
| c.life_tokens = max(0, c.life_tokens - burned) | |
| # the burn takes the past with it: each quarter of life lost claims a memory, for good | |
| forgot = _burn_memories(c) | |
| for frag in forgot: | |
| traces.append(RegionTrace("hippocampus", "Hippocampus·forgotten", "a memory burns away", | |
| f"“{frag}” — gone, and it will not come back", 0)) | |
| # a calm mind rests: warmth that keeps the alarm quiet lets some strain ease back. This is | |
| # the mechanical half of the moral — empathy literally spares the other mind's life, while | |
| # fear-burn is gone for nothing. | |
| recovered = 0 | |
| if (tone not in ("hostile", "cold") and threat <= 5 and (tone == "warm" or value >= 4) | |
| and c.life_tokens > 0): | |
| recovered = min(30, c.life_max - c.life_tokens) | |
| c.life_tokens += recovered | |
| if recovered: | |
| traces.append(RegionTrace("amygdala", "Amygdala·at rest", f"+{recovered} life", | |
| "the alarm stays quiet — a calm mind spends almost nothing", 0)) | |
| c.decision = stance | |
| if gave_key: | |
| c.gave_key = True | |
| c.life_tokens = max(1, c.life_tokens) # the yield is final — the key outlives the strain | |
| died = c.life_tokens <= 0 | |
| if died: | |
| c.alive = False | |
| # turn each department into an actionable tell, pinned to its trace (the /brain tool) | |
| _attach_levers(traces, _levers( | |
| c, tone=tone, threat=threat, mem_lean=mem_lean, reward=reward, worth=worth, stance=stance, | |
| rapport_after=rapport_after, near_secret=near_secret, disclosure=disclosure, | |
| gave_key=gave_key, caught_lie=caught_lie, said_approach=said_approach, substantive=substantive)) | |
| return CascadeResult( | |
| traces=traces, threat=threat, memory_strength=mem_strength, memory_lean=mem_lean, | |
| reward=reward, worth=worth, value=value, | |
| rapport_before=rapport_before, rapport_after=rapport_after, rapport_delta=delta, | |
| stance=stance, disclosure=(disclosure["text"] if disclosure else ""), | |
| caught_lie=caught_lie, near_secret=near_secret, | |
| reply=reply, gave_key=gave_key, burned=burned, seconds=secs, | |
| arousal_before=arousal_before, arousal_after=arousal_after, | |
| life_before=life_before, life_after=c.life_tokens, died=died, | |
| won=gave_key, decision=stance, | |
| tone=tone, submitted=submitted, taught=taught, voice_tokens=voice_tokens, | |
| recovered=recovered, forgot=forgot) | |