| """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 |
|
|
| |
| START_RESOLVE: int = 100 |
| WIN_AT: int = 0 |
| MAX_RESOLVE: int = 150 |
| LOSE_AT: int = 140 |
|
|
|
|
| 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) |
|
|
|
|
| |
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
| |
|
|
| 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 |
|
|
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| _JSON_RE = re.compile(r"\{.*\}", re.DOTALL) |
|
|
| |
| |
| _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 |
|
|
| |
| |
| 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) |
|
|
| |
| |
| 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 |
| |
| |
| 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 |
|
|