Spaces:
Sleeping
Sleeping
| 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 | |