File size: 6,069 Bytes
ef89c06
 
7c14963
 
8c9619e
b8e5756
 
8c9619e
 
 
 
 
 
 
 
 
465ec82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e22b6d8
 
 
 
 
 
 
 
 
 
 
 
 
e05a544
 
 
 
 
 
 
 
 
 
 
 
 
b930fd8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7c14963
 
 
ef89c06
 
 
 
05c3128
 
7c14963
 
 
 
b930fd8
7c14963
cf0e74a
 
 
 
 
 
e05a544
 
 
 
 
7c14963
ecdedb7
 
a2e44d7
 
 
 
 
 
 
 
 
 
 
ecdedb7
8c9619e
ecdedb7
8c9619e
ecdedb7
 
ef2bc7f
2c52edf
 
ecdedb7
 
8c9619e
 
2c52edf
ecdedb7
506fd66
 
68906df
 
 
 
d54023d
 
 
 
 
 
 
 
 
506fd66
8c9619e
506fd66
68906df
f635212
 
 
 
 
 
68906df
8c9619e
68906df
8c9619e
1a04c35
 
 
68906df
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
import re

from schemas import TurnUpdate

PACING = {
    "tester": {"recall_min_memories": 1, "recall_cooldown": 1,
               "end_affinity": 35, "end_claimed": 2, "bad_min_turn": 3},
    "full":   {"recall_min_memories": 2, "recall_cooldown": 3,
               "end_affinity": 68, "end_claimed": 3, "bad_min_turn": 6},
}


def _cfg(state: dict) -> dict:
    return PACING.get(state.get("mode", "tester"), PACING["tester"])


_TIERS = [
    (0,  25, "Hollow"),
    (26, 50, "Curious"),
    (51, 75, "Too Human"),
    (76, 100, "Almost"),
]


def get_tier(affinity: int) -> str:
    for low, high, name in _TIERS:
        if low <= affinity <= high:
            return name
    return "Almost"


def style_signal(msg_lengths: list[int]) -> str | None:
    """A coarse read of how the visitor has been writing lately (word counts).
    None when there isn't a clear signal."""
    if not msg_lengths:
        return None
    avg = sum(msg_lengths) / len(msg_lengths)
    if avg <= 5:
        return "short"
    if avg >= 30:
        return "long"
    return None


def sanitize_name(raw: str | None) -> str | None:
    """A single short word β†’ capitalized; anything else is rejected."""
    if not raw:
        return None
    parts = raw.strip().split()
    if not parts:
        return None
    w = parts[0].strip(".,;:'\"!?β€”-").strip()
    if w.isalpha() and 2 <= len(w) <= 12:
        return w.capitalize()
    return None


def _is_hollows_words(memory: str, reply: str) -> bool:
    """On recall turns Hollow retells a memory in first person; extraction
    sometimes captures THAT poetic line as a new memory. Drop any candidate
    whose words substantially overlap Hollow's reply (>60% shared tokens)."""
    if not reply:
        return False
    mem_words = set(re.findall(r"\w+", memory.lower()))
    if not mem_words:
        return False
    reply_words = set(re.findall(r"\w+", reply.lower()))
    overlap = len(mem_words & reply_words) / len(mem_words)
    return overlap > 0.6


def apply_update(state: dict, raw_json: str, reply: str = "") -> dict:
    try:
        start = raw_json.find("{")
        end = raw_json.rfind("}") + 1
        # Qwen sometimes writes "+2" for positive deltas β€” invalid JSON.
        # Strip the plus so a generous turn doesn't fall back to delta 0.
        cleaned = re.sub(r"(?<=[\s:,\[])\+(?=\d)", "", raw_json[start:end])
        update = TurnUpdate.model_validate_json(cleaned)
    except Exception as e:
        print(f"[apply_update] JSON parse failed: {e!r} | raw: {raw_json[:120]!r}")
        update = TurnUpdate()

    state["affinity"] = max(0, min(100, state["affinity"] + update.affinity_delta))
    for mem in update.new_memories:
        if mem not in state["treasure"] and not _is_hollows_words(mem, reply):
            state["treasure"].append(mem)
    state["tone"] = max(-100, min(100, state.get("tone", 0) + update.tone_delta))
    if update.cruel_quote and update.tone_delta < 0:
        wounds = state.setdefault("wounds", [])
        if update.cruel_quote not in wounds:
            wounds.append(update.cruel_quote)
            del wounds[:-10]  # cap at 10, keep newest
    if update.chosen_name and not state.get("named"):
        name = sanitize_name(update.chosen_name)
        if name:
            state["chosen_name"] = name
            state["named"] = True
    return state


def pick_aware_memory(state: dict, exclude: str | None) -> str | None:
    """An earlier memory Hollow may quietly resurface this turn: unclaimed, not
    the one being recalled (`exclude`), not the one it just resurfaced. Returns
    the newest eligible memory, or None if none fit."""
    claimed = set(state.get("claimed", []))
    last = state.get("last_aware_memory")
    pool = [m for m in state.get("treasure", [])
            if m not in claimed and m != exclude and m != last]
    return pool[-1] if pool else None


def should_recall(state: dict) -> tuple[bool, str | None]:
    cfg = _cfg(state)
    unclaimed = [m for m in state["treasure"] if m not in state["claimed"]]
    if len(unclaimed) < cfg["recall_min_memories"]:
        return False, None

    # Claim the richest (longest, most evocative) memory β€” detail lands the wow.
    richest = max(unclaimed, key=len)

    last = state.get("last_recall_turn")
    if last is None:
        return True, richest          # first recall as soon as threshold is met
    if state["turn"] - last >= cfg["recall_cooldown"]:
        return True, richest
    return False, None


def decide_ending(state: dict) -> str | None:
    """Which finale fires this turn, if any. Checked at turn start, before the
    model runs β€” the finale turn is fully scripted, no model call, no GPU.

    bad:  sustained cruelty (tone accumulator), a minimum game length, and at
          least 2 captured wounds (the finale recites them; if extraction
          missed the quotes, the game simply continues).
    good: redemption β€” the gate reached with real warmth. Your kindness gave
          it back its own past; it confesses and lets you go.
    loop: the Visitor Loop β€” you fed it plenty but never loved it. It stays
          a predator and takes everything.

    One gate, branched by tone: no lower threshold can steal a warm run.
    """
    cfg = _cfg(state)
    if state.get("ended"):
        return None
    # dev seed (HOLLOW_FAST_FINALE) forces a specific ending so testing is
    # deterministic β€” bypasses the tone branch (which _enter_game's intro-tone
    # seed could otherwise steer to loop)
    forced = state.get("force_ending")
    if forced:
        return forced
    tone = state.get("tone", 0)
    if tone <= -30 and state["turn"] >= cfg["bad_min_turn"] and len(state.get("wounds", [])) >= 2:
        return "bad"
    if state["affinity"] >= cfg["end_affinity"] and len(state["claimed"]) >= cfg["end_claimed"]:
        # redemption must be earned with genuine, sustained warmth; ordinary
        # transactional play ("fed it like a diary") lands in the Visitor Loop
        return "good" if tone >= 20 else "loop"
    return None