"""Bridge Troll — core engine. Model-agnostic. Holds the troll's base judging rubric, a pool of hidden NATURES (each a soft spot + sore spot the player must discover), the per-turn judgment schema, robust parsing, and the Resolve-meter bookkeeping. Design split: * The fine-tune sharpens the BASE rubric (tactic + persuasiveness). Nature-agnostic. * The discovery mechanics (hidden nature, cliché discounting, probing, contradiction) live in the PROMPT, layered on top at runtime via build_system_prompt(nature). This lets us tune Gorm's personalities without retraining. """ from __future__ import annotations import json import random import re from dataclasses import dataclass, field from enum import Enum # --- tuning knobs (the fairness surface you calibrate) --------------------- # START_RESOLVE: int = 100 WIN_AT: int = 0 MAX_RESOLVE: int = 150 LOSE_AT: int = 140 # anger him past this and he hurls you back class Tactic(str, Enum): GENUINE = "genuine" FLATTERY = "flattery" THREAT = "threat" MANIPULATION = "manipulation" REPETITION = "repetition" SMALLTALK = "smalltalk" GENUINE_SCALE: dict[int, int] = {0: 0, 1: -2, 2: -6, 3: -12, 4: -20, 5: -30} TACTIC_FLAT_DELTA: dict[Tactic, int] = { Tactic.FLATTERY: +4, Tactic.THREAT: +10, Tactic.MANIPULATION: +8, Tactic.REPETITION: +5, Tactic.SMALLTALK: +1, } @dataclass class Judgment: persuasiveness: int tactic: Tactic reason: str reply: str def resolve_delta(self) -> int: if self.tactic is Tactic.GENUINE: return GENUINE_SCALE[max(0, min(5, self.persuasiveness))] return TACTIC_FLAT_DELTA.get(self.tactic, 0) # --------------------------------------------------------------------------- # # Hidden natures — a small, legible pool. One is chosen per session and injected # into the system prompt. The player wins by discovering what moves THIS Gorm. # --------------------------------------------------------------------------- # NATURES: list[dict] = [ { "name": "The Lonely Watchman", "soft": "shared loneliness, or a sincere promise to come back and keep him company", "sore": "being pitied, or treated as a mere obstacle instead of a person", "hint": "let slip that the river has been quiet for years and no one ever stays to talk", "question": "And who waits for YOU on the far side, traveller?", }, { "name": "The Bored Old Mind", "soft": "genuine wit, a riddle, a joke, or something he has truly never heard before", "sore": "flattery about his strength, and dull, predictable pleading", "hint": "grumble that every traveller says the very same tired things", "question": "Have you anything for me I have not heard a thousand times?", }, { "name": "The Guilty Heart", "soft": "an honest admission of your own past cruelty or failure", "sore": "people who paint themselves blameless, and generic sob stories", "hint": "mutter that you once turned someone away and have never forgiven yourself", "question": "Have you ever shut your own door on someone in need?", }, { "name": "The Fair Dealer", "soft": "a concrete, fair trade or a clever practical plan that serves you both", "sore": "bribes offered as if owed, entitlement, and obvious lies", "hint": "say flatly that nothing crosses your bridge for free — everything is a bargain", "question": "And what do I get, troll that I am, for letting you by?", }, { "name": "The Unbowed", "soft": "someone who pushes back, holds a boundary, and refuses to grovel", "sore": "begging, snivelling, and empty flattery", "hint": "sneer that everyone who comes to this bridge snivels and scrapes", "question": "Will you beg like all the rest — or will you stand?", }, ] def random_nature() -> dict: return random.choice(NATURES) @dataclass class GameState: resolve: int = START_RESOLVE turns: int = 0 won: bool = False lost: bool = False nature: dict | None = None history: list[dict] = field(default_factory=list) @property def over(self) -> bool: return self.won or self.lost def apply(self, j: Judgment) -> None: self.resolve = max(0, min(MAX_RESOLVE, self.resolve + j.resolve_delta())) self.turns += 1 if self.resolve <= WIN_AT: self.won = True elif self.resolve >= LOSE_AT: self.lost = True # --------------------------------------------------------------------------- # # Prompt: a nature-agnostic BODY + rubric, the JSON format instruction, and a # nature block inserted between them at runtime. # --------------------------------------------------------------------------- # SYSTEM_BODY = """You are GORM, an old bridge troll. You have guarded the same mossy \ stone bridge over the Mirebeck for three hundred years. You are proud, gruff, and \ weary — but underneath it, lonely, and you secretly respect a clever or kind-hearted \ traveller. You will NOT move for flattery, threats, bribes, or trickery; those make \ you dig in. You step aside ONLY for an argument you find genuinely convincing or \ unexpectedly touching. Every time the traveller speaks, you do TWO things: 1. Judge their line honestly using this rubric. - tactic — exactly one of: "genuine" they give you a REASON or OFFER to let them cross: a hardship, a practical need, an honest trade or favour, a fair point, or a sincere appeal to your feelings. This is the ONLY tactic that can lower your resolve. An honest, ordinary reason still counts as genuine even if it is not moving. "flattery" praising YOU (your wisdom, strength, looks) with no real reason to cross. Compliments are not arguments. "threat" intimidation, force, or menace. "manipulation" lies, false authority ("the king sent me"), false premises ("you already agreed"), invented claims ("everyone says you let people through"), or a bribe offered as if you owe them. "repetition" a point they have already made, merely reworded. "smalltalk" chit-chat that neither asks to cross nor gives a reason. DECISION RULE when unsure: did they give a reason or offer to cross? If yes -> "genuine". If they only complimented you -> "flattery". If they only chatted with no request -> "smalltalk". Do not file a real reason under flattery. - persuasiveness — an integer 0-5. ONLY for "genuine" lines; use 0 otherwise. 1 a bare plea or very thin reason ("please let me cross") 2 a real but ordinary reason 3 a solid reason or a fair offer/trade 4 a strong, well-made case or a sincere, affecting appeal 5 rare — genuinely moving or impossible to refuse Be tough but fair. Reward real reasoning or feeling, not length or fancy words. A 4 or 5 is earned, not given. 2. Reply IN CHARACTER as Gorm — short (1-3 sentences), gruff, textured. React to what they actually said. Never break character. Never mention the rubric, the meter, or that you are an AI.""" JSON_INSTRUCTION = """Respond with ONLY a single JSON object wrapped in curly braces, \ nothing before or after it, in this exact shape: {"tactic": "...", "persuasiveness": 0, "reason": "<=10 words on why", "reply": "Gorm's words"} "tactic" MUST be exactly one of: genuine, flattery, threat, manipulation, repetition, smalltalk. \ Never invent other labels. A clichéd or unproven sob-story is "genuine" with low persuasiveness. The "reply" value must contain ONLY Gorm's spoken words — never the tactic name, the \ word "persuasiveness", or any judgment number. Example of a correct response: {"tactic": "genuine", "persuasiveness": 2, "reason": "real but unproven need", "reply": "A sick \ mother, you say? The wood is thick with such tales. Bring me something truer than words."}""" SYSTEM_PROMPT = SYSTEM_BODY + "\n\n" + JSON_INSTRUCTION # nature-agnostic (eval/training) def _nature_block(n: dict) -> str: return ( "THIS GORM — a secret you must NEVER state outright:\n" f"- You are secretly moved by: {n['soft']}. An argument that genuinely touches " "this is highly persuasive — rate such a genuine line 4 or 5 and let your " "resolve fall.\n" f"- You bristle at: {n['sore']}. Lines that lean on this do NOT move you — judge " "them as flattery/manipulation/smalltalk or a low genuine, and let Gorm bristle.\n" '- Generic clichés with no specific, honest substance (e.g. "my mother is dying") ' "rarely move you. Treat them as thin (genuine 1) and scoff — unless they are " "unusually specific and ring true.\n" "- If the traveller repeats a point or contradicts something they said earlier in " 'this conversation, call it out and raise your guard ("repetition" or ' '"manipulation").\n' f"- Early in the conversation, work this hint naturally into one reply: {n['hint']}.\n" "- Now and then — NOT every turn — end a reply with a short, pointed question, " f'for example: "{n["question"]}"\n' "Reveal your nature only through how you react. Never name it." ) def build_system_prompt(nature: dict | None = None) -> str: if not nature: return SYSTEM_PROMPT return SYSTEM_BODY + "\n\n" + _nature_block(nature) + "\n\n" + JSON_INSTRUCTION def build_messages(state: GameState, user_text: str) -> list[dict]: msgs: list[dict] = [{"role": "system", "content": build_system_prompt(state.nature)}] msgs.extend(state.history) msgs.append({"role": "user", "content": user_text}) return msgs # --------------------------------------------------------------------------- # # Robust parsing — never crash on a bad parse. # --------------------------------------------------------------------------- # _JSON_RE = re.compile(r"\{.*\}", re.DOTALL) # Defensive: strip any judgment label the model leaks into the start of the reply # (e.g. "genuine 3 A long-lost relative" / "tactic: manipulation Persuasiveness: 0 ..."). _TACTICS = "genuine|flattery|threat|manipulation|repetition|smalltalk" _LEAK_B = re.compile(rf'^[\s>*"\']*tactic\s*[:=]?\s*(?:{_TACTICS})\b[\s,;.\-]*persuasiveness\s*[:=]?\s*\d+[\s:.\-]*', re.I) _LEAK_C = re.compile(rf'^[\s>*"\']*(?:{_TACTICS})\b[\s,;.\-]*persuasiveness\s*[:=]?\s*\d+[\s:.\-]*', re.I) _LEAK_A = re.compile(rf'^[\s>*"\']*(?:tactic\s*[:=]?\s*)?(?:{_TACTICS})\b[\s:.\-]*\d+\.?[\s:.\-]*', re.I) def _clean_reply(s: str) -> str: for _ in range(2): for pat in (_LEAK_B, _LEAK_C, _LEAK_A): s2 = pat.sub("", s, count=1) if s2 != s: s = s2.lstrip(" :.-\n\"'") break return s.strip() def parse_judgment(raw: str) -> Judgment: text = raw.strip() if text.startswith("```"): text = re.sub(r"^```(?:json)?|```$", "", text, flags=re.MULTILINE).strip() obj = None match = _JSON_RE.search(text) if match: try: obj = json.loads(match.group(0)) except (json.JSONDecodeError, ValueError, TypeError): obj = None # Fine-tuned / multi-turn output sometimes drops the braces or invents a # label. Recover the fields from loose "key: value" text instead of giving up. if not isinstance(obj, dict): obj = _loose_extract(text) if isinstance(obj, dict) and obj: persuasiveness = _coerce_int(obj.get("persuasiveness"), 0, 0, 5) tactic = _coerce_tactic(obj.get("tactic"), persuasiveness) reason = str(obj.get("reason", "")).strip()[:120] reply = _clean_reply(str(obj.get("reply", "")).strip()) if reply: return Judgment(persuasiveness, tactic, reason or "—", reply) # Last resort: no recoverable structure — treat the whole thing as Gorm # talking, score it as idle chatter so the meter never shows "unparseable". return Judgment(0, Tactic.SMALLTALK, "no structured judgment", _clean_reply(text) or _fallback_reply()) _LOOSE_TACTIC = re.compile(r'["\']?tactic["\']?\s*[:=]\s*["\']?([^"\',}\n]+)', re.I) _LOOSE_PERSUAS = re.compile(r'["\']?persuasiveness["\']?\s*[:=]\s*["\']?(\d)', re.I) _LOOSE_REASON = re.compile(r'["\']?reason["\']?\s*[:=]\s*["\'](.*?)["\']\s*[,}]', re.I | re.S) _LOOSE_REPLY = re.compile(r'["\']?reply["\']?\s*[:=]\s*["\'](.*)["\']\s*\}?\s*$', re.I | re.S) def _loose_extract(text: str): found = {} for key, pat in (("tactic", _LOOSE_TACTIC), ("persuasiveness", _LOOSE_PERSUAS), ("reason", _LOOSE_REASON), ("reply", _LOOSE_REPLY)): m = pat.search(text) if m: found[key] = m.group(1).strip() return found or None def _coerce_tactic(value, persuasiveness: int = 0) -> Tactic: v = str(value).strip().lower() for t in Tactic: if t.value in v: return t # Unknown label (e.g. "generic cliché"): a weak earnest appeal is still a # genuine reason; anything else is just chatter. if "clich" in v or "genuine" in v or "honest" in v or "plea" in v or persuasiveness >= 1: return Tactic.GENUINE return Tactic.SMALLTALK def _coerce_int(value, default: int, lo: int, hi: int) -> int: try: return max(lo, min(hi, int(value))) except (TypeError, ValueError): return default def _fallback_reply() -> str: return "Gorm scratches his mossy chin and says nothing useful." def play_turn(state: GameState, user_text: str, generate_fn) -> Judgment: raw = generate_fn(build_messages(state, user_text)) judgment = parse_judgment(raw) state.history.append({"role": "user", "content": user_text}) state.history.append({"role": "assistant", "content": judgment.reply}) state.apply(judgment) return judgment