File size: 44,424 Bytes
bc8b36a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9a41b58
bc8b36a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9a41b58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bc8b36a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9a41b58
 
bc8b36a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9a41b58
 
bc8b36a
 
9a41b58
 
bc8b36a
 
 
9a41b58
 
bc8b36a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9a41b58
 
bc8b36a
 
 
 
 
 
 
 
 
 
 
 
 
 
9a41b58
 
bc8b36a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9a41b58
 
bc8b36a
 
 
 
 
 
 
 
 
 
 
9a41b58
 
 
 
 
bc8b36a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9a41b58
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
"""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)


@dataclass
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


@dataclass
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)